diff --git a/launch_vpn_gui.py b/launch_vpn_gui.py index 32f2d70..1276be0 100644 --- a/launch_vpn_gui.py +++ b/launch_vpn_gui.py @@ -31,6 +31,10 @@ DEFAULT_CONFIG = { "openvpn_bin": r"C:\Program Files\OpenVPN\bin\openvpn.exe", # имя dev-node адаптера OpenVPN. "dev_node": "OpenVPN Data Channel Offload", + # запасной адаптер: если openvpn мгновенно падает на основном (обычно после + # обновлений ломается DCO-драйвер), повторяем попытку через этот адаптер + # с --disable-dco. Пустая строка отключает автоперебор. + "dev_node_fallback": "OpenVPN TAP-Windows6", # имя сервиса в keyring, откуда берутся secret (TOTP) и static (пароль). "keyring_service": "openvpn-totp", # формат передачи OTP серверу. @@ -77,16 +81,35 @@ proc = None # текущий процесс openvpn proc_lock = threading.Lock() icon = None ping_alive = False # результат последней проверки пингов +stop_requested = False # пользователь нажал "Отключить" — не перебирать адаптеры + + +def _show_config_error(text): + """Диалог об ошибке настроек. В отдельном потоке, чтобы не блокировать + watcher/трей; кнопка "Да" открывает vpn_config.json в редакторе.""" + def _dlg(): + MB_YESNO, MB_ICONERROR, MB_SYSTEMMODAL, IDYES = 0x4, 0x10, 0x1000, 6 + r = ctypes.windll.user32.MessageBoxW( + None, f"{text}\n\nОткрыть vpn_config.json?", + "VPN: ошибка настроек", MB_YESNO | MB_ICONERROR | MB_SYSTEMMODAL) + if r == IDYES: + try: + os.startfile(CONFIG_PATH, "edit") + except OSError: + subprocess.Popen(["notepad.exe", CONFIG_PATH]) + threading.Thread(target=_dlg, daemon=True).start() def load_config(): - """Загрузить настройки из JSON, дополнив дефолтами. Молча создаёт файл, - если его нет, и падает обратно на дефолты при ошибке чтения.""" + """Загрузить настройки из JSON, дополнив дефолтами. Создаёт файл, если его + нет. При ошибке чтения НЕ откатывается молча на дефолты (там прошито старое + подключение) — оставляет действующие настройки и показывает диалог.""" global _config_mtime try: mtime = os.path.getmtime(CONFIG_PATH) except OSError: - # файла нет — создаём с дефолтами + # файла нет — создаём с дефолтами и предупреждаем: дефолты указывают + # на старый .ovpn, молча ехать на него нельзя try: with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2) @@ -95,17 +118,29 @@ def load_config(): _config_mtime = None config.clear() config.update(DEFAULT_CONFIG) + _show_config_error( + "vpn_config.json не найден — создан заново со встроенными " + "настройками по умолчанию (СТАРОЕ подключение ano).\n" + "Проверь ovpn_config/keyring_service перед подключением.") return if mtime == _config_mtime: return # файл не менялся — ничего не делаем try: - with open(CONFIG_PATH, "r", encoding="utf-8") as f: + # utf-8-sig: переживает BOM, который добавляют PowerShell/блокнот + with open(CONFIG_PATH, "r", encoding="utf-8-sig") as f: data = json.load(f) - except (OSError, ValueError): - data = {} + if not isinstance(data, dict): + raise ValueError("ожидался JSON-объект {...}") + except (OSError, ValueError) as e: + # запоминаем mtime, чтобы диалог не выскакивал каждую секунду из + # watcher — только один раз на каждую правку файла + _config_mtime = mtime + _show_config_error( + f"Не удалось прочитать vpn_config.json:\n{e}\n\n" + "Настройки НЕ применены — действуют предыдущие.") + return merged = dict(DEFAULT_CONFIG) - if isinstance(data, dict): - merged.update(data) + merged.update(data) config.clear() config.update(merged) _config_mtime = mtime @@ -181,44 +216,75 @@ def build_auth_file() -> str: return path +# если openvpn умер быстрее этого порога — считаем, что дело в адаптере/драйвере +# (фатал вида "tap-windows6 driver, ovpn-dco expected" или мёртвый DCO), +# а не в сети, и пробуем запасной адаптер. +FAST_FAIL_SECONDS = 15 + + def run_vpn(): - """Запуск openvpn в отдельном потоке; чистит auth-файл и обновляет иконку.""" + """Запуск openvpn в отдельном потоке; чистит auth-файл и обновляет иконку. + При мгновенном падении на основном адаптере повторяет попытку через + dev_node_fallback с --disable-dco (лечит качели DCO <-> TAP после обновлений).""" global proc - auth_path = build_auth_file() + attempts = [(config["dev_node"], [])] + fallback = config.get("dev_node_fallback") + if fallback and fallback != config["dev_node"]: + attempts.append((fallback, ["--disable-dco"])) try: - logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8") - p = subprocess.Popen( - [config["openvpn_bin"], "--config", config["ovpn_config"], - "--auth-user-pass", auth_path, - "--dev-node", config["dev_node"], - "--log", config["log_path"], - "--verb", "4"], - stdout=logf, stderr=logf, - creationflags=subprocess.CREATE_NO_WINDOW, - ) - with proc_lock: - proc = p - update_icon() - p.wait() # блокируется до завершения процесса + for n, (dev_node, extra_args) in enumerate(attempts): + auth_path = build_auth_file() # свежий TOTP на каждую попытку + try: + logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8") + if n: + logf.write( + f"{time.strftime('%Y-%m-%d %H:%M:%S')} " + f"быстрый фатал на '{attempts[0][0]}' — повторяю через " + f"'{dev_node}' с --disable-dco\n") + logf.flush() + started = time.monotonic() + p = subprocess.Popen( + [config["openvpn_bin"], "--config", config["ovpn_config"], + "--auth-user-pass", auth_path, + "--dev-node", dev_node, + "--log", config["log_path"], + "--verb", "4"] + extra_args, + 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 + # штатное завершение, долгая сессия или ручное отключение — + # запасной адаптер не пробуем + if (p.returncode == 0 or stop_requested + or time.monotonic() - started > FAST_FAIL_SECONDS): + break finally: - # auth-файл больше не нужен сразу после старта; openvpn уже прочитал его, - # но удаляем после завершения на всякий случай - try: - os.remove(auth_path) - except OSError: - pass with proc_lock: proc = None update_icon() def on_connect(_icon, _item): + global stop_requested if is_running(): return + stop_requested = False threading.Thread(target=run_vpn, daemon=True).start() def on_disconnect(_icon, _item): + global stop_requested + stop_requested = True with proc_lock: if proc is not None and proc.poll() is None: proc.terminate()