openvpn autoconnect
This commit is contained in:
commit
6c8d5982d0
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
*.ovpn
|
||||||
|
*.log
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.zip
|
||||||
|
*.7z
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
0. запустить `bash show_tuns.sh` и обновить имя адаптера (выводится до GUID) в конфиге vpn в строке после `dev tun`
|
||||||
|
1. расшифровать QR, вынуть secret
|
||||||
|
2. запустить `bash init.sh <secret> <static_pass>`
|
||||||
|
3. поменять юзернейм в `launch_vpn_gui.py`
|
||||||
|
4. запуск `python launch_vpn_gui.py`
|
||||||
3
init.sh
Normal file
3
init.sh
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
pip install pyotp keyring pystray
|
||||||
|
OVPN_TOTP_SECRET=$1 OVPN_STATIC_PASS=$2 python init_vpn.py
|
||||||
24
init_vpn.py
Normal file
24
init_vpn.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""setup_keyring.py — одноразово кладёт secret и static в keyring из env."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import keyring
|
||||||
|
|
||||||
|
SERVICE = "openvpn-totp"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
secret = os.environ.get("OVPN_TOTP_SECRET")
|
||||||
|
static = os.environ.get("OVPN_STATIC_PASS")
|
||||||
|
|
||||||
|
missing = [name for name, val in
|
||||||
|
(("OVPN_TOTP_SECRET", secret), ("OVPN_STATIC_PASS", static))
|
||||||
|
if not val]
|
||||||
|
if missing:
|
||||||
|
sys.exit(f"Не заданы переменные окружения: {', '.join(missing)}")
|
||||||
|
|
||||||
|
keyring.set_password(SERVICE, "secret", secret)
|
||||||
|
keyring.set_password(SERVICE, "static", static)
|
||||||
|
print("Сохранено в keyring.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
launch_vpn.py
Normal file
23
launch_vpn.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""launch_vpn.py — auth = static+TOTP, конкатенация без разделителя."""
|
||||||
|
import subprocess, pyotp, keyring, tempfile, os
|
||||||
|
|
||||||
|
USERNAME = "boyko_vn"
|
||||||
|
OVPN_CONFIG = r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn"
|
||||||
|
OPENVPN_BIN = r"C:\Program Files\OpenVPN\bin\openvpn.exe"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
secret = keyring.get_password("openvpn-totp", "secret")
|
||||||
|
static = keyring.get_password("openvpn-totp", "static")
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".txt")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", newline="\n") as f:
|
||||||
|
f.write(f"{USERNAME}\n{static}{code}\n")
|
||||||
|
subprocess.run([OPENVPN_BIN, "--config", OVPN_CONFIG,
|
||||||
|
"--auth-user-pass", path])
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
191
launch_vpn_gui.py
Normal file
191
launch_vpn_gui.py
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
"""launch_vpn.py — VPN с TOTP-аутентификацией и индикатором в трее.
|
||||||
|
auth = static+TOTP, конкатенация без разделителя."""
|
||||||
|
import sys, ctypes
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import pyotp
|
||||||
|
import keyring
|
||||||
|
import pystray
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
USERNAME = "boyko_vn"
|
||||||
|
OVPN_CONFIG = r"C:\Users\User\app\vpn\ovpn-config-ano.ovpn"
|
||||||
|
OPENVPN_BIN = r"C:\Program Files\OpenVPN\bin\openvpn.exe"
|
||||||
|
DEV_NODE = "OpenVPN Data Channel Offload"
|
||||||
|
SERVICE = "openvpn-totp"
|
||||||
|
LOG_PATH = r"ovpn.log"
|
||||||
|
|
||||||
|
proc = None # текущий процесс openvpn
|
||||||
|
proc_lock = threading.Lock()
|
||||||
|
icon = None
|
||||||
|
|
||||||
|
|
||||||
|
def make_icon(connected: bool) -> Image.Image:
|
||||||
|
"""Кружок: зелёный = подключено, серый = отключено."""
|
||||||
|
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
color = (40, 200, 80, 255) if connected else (130, 130, 130, 255)
|
||||||
|
d.ellipse((12, 12, 52, 52), fill=color)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def update_icon():
|
||||||
|
if icon is None:
|
||||||
|
return
|
||||||
|
connected = is_running()
|
||||||
|
icon.icon = make_icon(connected)
|
||||||
|
icon.title = "VPN: подключено" if connected else "VPN: отключено"
|
||||||
|
icon.update_menu() # перерисовать тексты/доступность пунктов
|
||||||
|
|
||||||
|
|
||||||
|
def is_running() -> bool:
|
||||||
|
with proc_lock:
|
||||||
|
return proc is not None and proc.poll() is None
|
||||||
|
|
||||||
|
|
||||||
|
def build_auth_file() -> str:
|
||||||
|
secret = keyring.get_password(SERVICE, "secret")
|
||||||
|
static = keyring.get_password(SERVICE, "static")
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".txt")
|
||||||
|
with os.fdopen(fd, "w", newline="\n") as f:
|
||||||
|
f.write(f"{USERNAME}\n{static}{code}\n")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def run_vpn():
|
||||||
|
"""Запуск openvpn в отдельном потоке; чистит auth-файл и обновляет иконку."""
|
||||||
|
global proc
|
||||||
|
auth_path = build_auth_file()
|
||||||
|
try:
|
||||||
|
logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8")
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[OPENVPN_BIN, "--config", OVPN_CONFIG,
|
||||||
|
"--auth-user-pass", auth_path,
|
||||||
|
"--dev-node", DEV_NODE,
|
||||||
|
"--log", LOG_PATH,
|
||||||
|
"--verb", "4"],
|
||||||
|
stdout=logf, stderr=logf,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||||
|
)
|
||||||
|
with proc_lock:
|
||||||
|
proc = p
|
||||||
|
update_icon()
|
||||||
|
p.wait() # блокируется до завершения процесса
|
||||||
|
finally:
|
||||||
|
# auth-файл больше не нужен сразу после старта; openvpn уже прочитал его,
|
||||||
|
# но удаляем после завершения на всякий случай
|
||||||
|
try:
|
||||||
|
os.remove(auth_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
with proc_lock:
|
||||||
|
proc = None
|
||||||
|
update_icon()
|
||||||
|
|
||||||
|
|
||||||
|
def on_connect(_icon, _item):
|
||||||
|
if is_running():
|
||||||
|
return
|
||||||
|
threading.Thread(target=run_vpn, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def on_disconnect(_icon, _item):
|
||||||
|
with proc_lock:
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
def on_open_log(_icon, _item):
|
||||||
|
"""Открыть файл лога в редакторе по умолчанию; иначе — в блокноте."""
|
||||||
|
log_abs = os.path.abspath(LOG_PATH)
|
||||||
|
# создаём пустой файл, если лога ещё нет, чтобы было что открывать
|
||||||
|
if not os.path.exists(log_abs):
|
||||||
|
try:
|
||||||
|
open(log_abs, "a", encoding="utf-8").close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
# глагол "edit" открывает в редакторе по умолчанию для типа файла
|
||||||
|
os.startfile(log_abs, "edit")
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
# запасной вариант — обычное открытие ассоциированной программой
|
||||||
|
os.startfile(log_abs)
|
||||||
|
except OSError:
|
||||||
|
# последний резерв — блокнот Windows
|
||||||
|
subprocess.Popen(["notepad.exe", log_abs])
|
||||||
|
|
||||||
|
|
||||||
|
def on_exit(_icon, _item):
|
||||||
|
on_disconnect(_icon, _item)
|
||||||
|
_icon.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def menu():
|
||||||
|
return pystray.Menu(
|
||||||
|
pystray.MenuItem(
|
||||||
|
lambda item: "Отключено" if not is_running() else "Подключено",
|
||||||
|
None, enabled=False),
|
||||||
|
pystray.MenuItem("Открыть лог", on_open_log),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem("Подключить", on_connect,
|
||||||
|
enabled=lambda item: not is_running()),
|
||||||
|
pystray.MenuItem("Отключить", on_disconnect,
|
||||||
|
enabled=lambda item: is_running()),
|
||||||
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem("Выход", on_exit),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def watcher():
|
||||||
|
last = None
|
||||||
|
while True:
|
||||||
|
cur = is_running()
|
||||||
|
if cur != last:
|
||||||
|
update_icon()
|
||||||
|
last = cur
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_admin_old():
|
||||||
|
try:
|
||||||
|
if ctypes.windll.shell32.IsUserAnAdmin():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
params = " ".join(f'"{a}"' for a in sys.argv)
|
||||||
|
ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, params, None, 1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_admin():
|
||||||
|
try:
|
||||||
|
if ctypes.windll.shell32.IsUserAnAdmin():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# запускать elevated-копию через pythonw.exe (без консоли)
|
||||||
|
exe = sys.executable
|
||||||
|
pyw = exe.replace("python.exe", "pythonw.exe")
|
||||||
|
if not os.path.exists(pyw):
|
||||||
|
pyw = exe # fallback, если pythonw не нашёлся
|
||||||
|
params = " ".join(f'"{a}"' for a in sys.argv)
|
||||||
|
ctypes.windll.shell32.ShellExecuteW(None, "runas", pyw, params, None, 1)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ensure_admin()
|
||||||
|
global icon
|
||||||
|
icon = pystray.Icon("vpn-totp", make_icon(False), "VPN: отключено", menu())
|
||||||
|
threading.Thread(target=watcher, daemon=True).start()
|
||||||
|
icon.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
show_tuns.sh
Normal file
1
show_tuns.sh
Normal file
@ -0,0 +1 @@
|
|||||||
|
"/c/Program Files/OpenVPN/bin/openvpn.exe" --show-adapters | grep "ovpn-dco" | grep -v "Connect"
|
||||||
Loading…
x
Reference in New Issue
Block a user