commit 6c8d5982d0f21c9317b28a2b57cced29890a916c Author: boyko_vn Date: Tue Jul 7 23:43:16 2026 +0300 openvpn autoconnect diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92df407 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.ovpn +*.log +*.png +*.jpg +*.jpeg +*.zip +*.7z diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d4ac0d --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +0. запустить `bash show_tuns.sh` и обновить имя адаптера (выводится до GUID) в конфиге vpn в строке после `dev tun` +1. расшифровать QR, вынуть secret +2. запустить `bash init.sh ` +3. поменять юзернейм в `launch_vpn_gui.py` +4. запуск `python launch_vpn_gui.py` diff --git a/init.sh b/init.sh new file mode 100644 index 0000000..d46d409 --- /dev/null +++ b/init.sh @@ -0,0 +1,3 @@ +#!/bin/bash +pip install pyotp keyring pystray +OVPN_TOTP_SECRET=$1 OVPN_STATIC_PASS=$2 python init_vpn.py diff --git a/init_vpn.py b/init_vpn.py new file mode 100644 index 0000000..8e44003 --- /dev/null +++ b/init_vpn.py @@ -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() diff --git a/launch_vpn.py b/launch_vpn.py new file mode 100644 index 0000000..dcd4e6c --- /dev/null +++ b/launch_vpn.py @@ -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() diff --git a/launch_vpn_gui.py b/launch_vpn_gui.py new file mode 100644 index 0000000..6ab9876 --- /dev/null +++ b/launch_vpn_gui.py @@ -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() diff --git a/show_tuns.sh b/show_tuns.sh new file mode 100644 index 0000000..9e7293f --- /dev/null +++ b/show_tuns.sh @@ -0,0 +1 @@ +"/c/Program Files/OpenVPN/bin/openvpn.exe" --show-adapters | grep "ovpn-dco" | grep -v "Connect"