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",
# имя 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()