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",
|
"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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user