fix: устойчивость к качелям DCO/TAP и битому конфигу

- чтение vpn_config.json через utf-8-sig (BOM от PowerShell/блокнота ломал json.load)
- автоперебор адаптеров: быстрый фатал на dev_node -> повтор через dev_node_fallback с --disable-dco
- вместо молчаливого отката на DEFAULT_CONFIG (старый сервер ano) - диалоговое окно с ошибкой

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
boyko_vn 2026-07-23 10:11:05 +03:00
parent 70004e0645
commit bb2813f0ed

View File

@ -31,6 +31,10 @@ DEFAULT_CONFIG = {
"openvpn_bin": r"C:\Program Files\OpenVPN\bin\openvpn.exe", "openvpn_bin": r"C:\Program Files\OpenVPN\bin\openvpn.exe",
# имя dev-node адаптера OpenVPN. # имя dev-node адаптера OpenVPN.
"dev_node": "OpenVPN Data Channel Offload", "dev_node": "OpenVPN Data Channel Offload",
# запасной адаптер: если openvpn мгновенно падает на основном (обычно после
# обновлений ломается DCO-драйвер), повторяем попытку через этот адаптер
# с --disable-dco. Пустая строка отключает автоперебор.
"dev_node_fallback": "OpenVPN TAP-Windows6",
# имя сервиса в keyring, откуда берутся secret (TOTP) и static (пароль). # имя сервиса в keyring, откуда берутся secret (TOTP) и static (пароль).
"keyring_service": "openvpn-totp", "keyring_service": "openvpn-totp",
# формат передачи OTP серверу. # формат передачи OTP серверу.
@ -77,16 +81,35 @@ proc = None # текущий процесс openvpn
proc_lock = threading.Lock() proc_lock = threading.Lock()
icon = None icon = None
ping_alive = False # результат последней проверки пингов 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(): def load_config():
"""Загрузить настройки из JSON, дополнив дефолтами. Молча создаёт файл, """Загрузить настройки из JSON, дополнив дефолтами. Создаёт файл, если его
если его нет, и падает обратно на дефолты при ошибке чтения.""" нет. При ошибке чтения НЕ откатывается молча на дефолты (там прошито старое
подключение) оставляет действующие настройки и показывает диалог."""
global _config_mtime global _config_mtime
try: try:
mtime = os.path.getmtime(CONFIG_PATH) mtime = os.path.getmtime(CONFIG_PATH)
except OSError: except OSError:
# файла нет — создаём с дефолтами # файла нет — создаём с дефолтами и предупреждаем: дефолты указывают
# на старый .ovpn, молча ехать на него нельзя
try: try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2) json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2)
@ -95,16 +118,28 @@ def load_config():
_config_mtime = None _config_mtime = None
config.clear() config.clear()
config.update(DEFAULT_CONFIG) config.update(DEFAULT_CONFIG)
_show_config_error(
"vpn_config.json не найден — создан заново со встроенными "
"настройками по умолчанию (СТАРОЕ подключение ano).\n"
"Проверь ovpn_config/keyring_service перед подключением.")
return return
if mtime == _config_mtime: if mtime == _config_mtime:
return # файл не менялся — ничего не делаем return # файл не менялся — ничего не делаем
try: 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) data = json.load(f)
except (OSError, ValueError): if not isinstance(data, dict):
data = {} 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) merged = dict(DEFAULT_CONFIG)
if isinstance(data, dict):
merged.update(data) merged.update(data)
config.clear() config.clear()
config.update(merged) config.update(merged)
@ -181,18 +216,39 @@ def build_auth_file() -> str:
return path return path
# если openvpn умер быстрее этого порога — считаем, что дело в адаптере/драйвере
# (фатал вида "tap-windows6 driver, ovpn-dco expected" или мёртвый DCO),
# а не в сети, и пробуем запасной адаптер.
FAST_FAIL_SECONDS = 15
def run_vpn(): def run_vpn():
"""Запуск openvpn в отдельном потоке; чистит auth-файл и обновляет иконку.""" """Запуск openvpn в отдельном потоке; чистит auth-файл и обновляет иконку.
При мгновенном падении на основном адаптере повторяет попытку через
dev_node_fallback с --disable-dco (лечит качели DCO <-> TAP после обновлений)."""
global proc 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:
for n, (dev_node, extra_args) in enumerate(attempts):
auth_path = build_auth_file() # свежий TOTP на каждую попытку
try: try:
logf = open(rf"ovpn-wrapper.log", "a", encoding="utf-8") 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( p = subprocess.Popen(
[config["openvpn_bin"], "--config", config["ovpn_config"], [config["openvpn_bin"], "--config", config["ovpn_config"],
"--auth-user-pass", auth_path, "--auth-user-pass", auth_path,
"--dev-node", config["dev_node"], "--dev-node", dev_node,
"--log", config["log_path"], "--log", config["log_path"],
"--verb", "4"], "--verb", "4"] + extra_args,
stdout=logf, stderr=logf, stdout=logf, stderr=logf,
creationflags=subprocess.CREATE_NO_WINDOW, creationflags=subprocess.CREATE_NO_WINDOW,
) )
@ -201,24 +257,34 @@ def run_vpn():
update_icon() update_icon()
p.wait() # блокируется до завершения процесса p.wait() # блокируется до завершения процесса
finally: finally:
# auth-файл больше не нужен сразу после старта; openvpn уже прочитал его, # auth-файл больше не нужен сразу после старта; openvpn уже
# но удаляем после завершения на всякий случай # прочитал его, но удаляем после завершения на всякий случай
try: try:
os.remove(auth_path) os.remove(auth_path)
except OSError: except OSError:
pass pass
# штатное завершение, долгая сессия или ручное отключение —
# запасной адаптер не пробуем
if (p.returncode == 0 or stop_requested
or time.monotonic() - started > FAST_FAIL_SECONDS):
break
finally:
with proc_lock: with proc_lock:
proc = None proc = None
update_icon() update_icon()
def on_connect(_icon, _item): def on_connect(_icon, _item):
global stop_requested
if is_running(): if is_running():
return return
stop_requested = False
threading.Thread(target=run_vpn, daemon=True).start() threading.Thread(target=run_vpn, daemon=True).start()
def on_disconnect(_icon, _item): def on_disconnect(_icon, _item):
global stop_requested
stop_requested = True
with proc_lock: with proc_lock:
if proc is not None and proc.poll() is None: if proc is not None and proc.poll() is None:
proc.terminate() proc.terminate()