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:
parent
70004e0645
commit
bb2813f0ed
@ -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,16 +118,28 @@ 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)
|
||||
config.clear()
|
||||
config.update(merged)
|
||||
@ -181,18 +216,39 @@ 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:
|
||||
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", config["dev_node"],
|
||||
"--dev-node", dev_node,
|
||||
"--log", config["log_path"],
|
||||
"--verb", "4"],
|
||||
"--verb", "4"] + extra_args,
|
||||
stdout=logf, stderr=logf,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
@ -201,24 +257,34 @@ def run_vpn():
|
||||
update_icon()
|
||||
p.wait() # блокируется до завершения процесса
|
||||
finally:
|
||||
# auth-файл больше не нужен сразу после старта; openvpn уже прочитал его,
|
||||
# но удаляем после завершения на всякий случай
|
||||
# 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:
|
||||
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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user