zapret-kvn/xray_fluent/platform/windows/proxy_manager.py
loop-uh 18096c1319 Переходы подключения целиком шагами через координатор, без зависаний GUI
connect/disconnect/reconnect/смена прокси/полная замена runtime стали
генераторами (connect_selected_steps, disconnect_current_steps,
reconnect_steps, _replace_runtime_steps, _start_singbox_runtime_plan_steps,
_apply_proxy_runtime_change_steps) и выполняются TransitionRunner'ом.
Устаревание поколения генераторы проверяют сами после каждого шага и сами
прибирают недостартовавшее; раннер их не закрывает.

Координатор:
- клик во время перехода только фиксирует желаемое состояние и поколение;
  подготовка выбранного сервера (DNS/Zapret) повторяется после завершения
  идущего перехода (_kick_transition), клики сливаются в последнее состояние;
- синхронные disconnect_current() из обработчиков убраны: остановка перед
  DNS/Zapret — шаг координатора (_run_prestop), отказ профиля Zapret и потеря
  winws2 — тихий disconnect через очередь (_request_stop), lock() — через
  очередь;
- устаревший переход не блокирует сигнатуру и не сбрасывает desired;
  процессы прерванного перехода (включая запущенные, но не готовые —
  process_alive) гасит отдельный шаг cleanup;
- горячий свитч во время полного перехода уходит в очередь.

Системный прокси: все записи WinAPI — в отдельном FIFO-потоке
(PROXY_EXECUTOR): шагами в переходах, без ожидания из обработчиков сигналов,
с дренажом очереди перед финальным восстановлением при выходе.

Аварийное отключение из обработчиков (unexpected disconnect, отказ Hysteria)
закрывает допуск kill'ом без ожидания; внутри перехода — шагами.

Повторный PUT выбора сервера после connect удалён: тот же тег
(plan.selected_outbound_tag == selector_tags[node.id]) уже закреплён при
старте runtime; инварианты пула/тегов не затронуты.

Сигнатуры переходов больше не строят полный план 7–10 раз за клик: поля
плана (теги селектора, провайдер, used_selected_node) кэшируются по
документу, режиму, ноде и составу пула; сброс по nodes_changed.

Приёмка: tests/test_app_transition_gui_stalls.py — настоящий контроллер и
QProcess с заглушкой ядра, блокирующие примитивы с реалистичными задержками;
connect, горячий и полный свитч, смена прокси, TUN туда-обратно и disconnect
дают max_stall < 50 мс, ноль processEvents, клик во время перехода не
вкладывается.
2026-09-26 13:27:04 +03:00

381 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import ctypes
import json
import logging
import sys
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
try:
import winreg
except ImportError: # pragma: no cover - non-Windows dev runs
winreg = None # type: ignore[assignment]
from ...constants import PROXY_HOST, RUNTIME_DIR
logger = logging.getLogger(__name__)
#: Записи системного прокси (реестр, RasEnumEntriesW, InternetSetOptionW,
#: широковещательные SETTINGS_CHANGED/REFRESH) блокируют на сотни мс, поэтому
#: из GUI-потока не выполняются. Один поток даёт строгий FIFO: enable/disable
#: применяются ровно в порядке запросов, резервная копия не гоняется сама с
#: собой. Перед финальным восстановлением при выходе очередь дренируется.
PROXY_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="xray_fluent_proxy")
INTERNET_OPTION_REFRESH = 37
INTERNET_OPTION_SETTINGS_CHANGED = 39
INTERNET_OPTION_PER_CONNECTION_OPTION = 75
INTERNET_PER_CONN_FLAGS = 1
INTERNET_PER_CONN_PROXY_SERVER = 2
INTERNET_PER_CONN_PROXY_BYPASS = 3
PROXY_TYPE_DIRECT = 0x1
PROXY_TYPE_PROXY = 0x2
RAS_MAX_ENTRY_NAME = 256
RAS_MAX_PATH = 260
INTERNET_SETTINGS_KEY = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
INTERNET_POLICY_KEY = r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"
class _PerConnValue(ctypes.Union):
_fields_ = [
("dwValue", ctypes.c_uint32),
("pszValue", ctypes.c_wchar_p),
("ftValue", ctypes.c_uint64),
]
class INTERNET_PER_CONN_OPTIONW(ctypes.Structure):
_fields_ = [
("dwOption", ctypes.c_uint32),
("Value", _PerConnValue),
]
class INTERNET_PER_CONN_OPTION_LISTW(ctypes.Structure):
_fields_ = [
("dwSize", ctypes.c_uint32),
("pszConnection", ctypes.c_wchar_p),
("dwOptionCount", ctypes.c_uint32),
("dwOptionError", ctypes.c_uint32),
("pOptions", ctypes.POINTER(INTERNET_PER_CONN_OPTIONW)),
]
class RASENTRYNAMEW(ctypes.Structure):
_fields_ = [
("dwSize", ctypes.c_uint32),
("szEntryName", ctypes.c_wchar * (RAS_MAX_ENTRY_NAME + 1)),
("dwFlags", ctypes.c_uint32),
("szPhonebookPath", ctypes.c_wchar * (RAS_MAX_PATH + 1)),
]
@dataclass(frozen=True)
class SystemProxyState:
"""Реальное (эффективное) состояние системного прокси Windows.
``source`` — откуда читались значения: ``"hkcu"`` (обычный per-user режим)
или ``"hklm-policy"`` (политика ``ProxySettingsPerUser=0`` перенаправляет
настройки на машинный улей).
"""
supported: bool = False
enabled: bool = False
server: str = ""
override: str = ""
autoconfig_url: str = ""
source: str = "hkcu"
is_ours: bool = False
class ProxyManager:
def __init__(self) -> None:
self._backup: dict[str, str | int] | None = None
self._backup_file = RUNTIME_DIR / "system_proxy_backup.json"
@property
def is_supported(self) -> bool:
return winreg is not None
@staticmethod
def is_our_proxy_server(proxy_server: str) -> bool:
"""Проверяет, что строка ProxyServer указывает на наш локальный прокси.
Единственный источник истины для «наш ли прокси»: записи
http=/https=/socks= должны указывать на PROXY_HOST (порт любой) —
именно такую строку пишет :meth:`enable`.
"""
value = str(proxy_server or "").lower().replace(" ", "")
if not value:
return False
host = PROXY_HOST.lower()
return (
f"http={host}:" in value
and f"https={host}:" in value
and f"socks={host}:" in value
)
def _read_settings(self) -> dict[str, str | int]:
if not self.is_supported:
return {}
values: dict[str, str | int] = {}
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, INTERNET_SETTINGS_KEY, 0, winreg.KEY_READ) as key:
for name, default in (("ProxyEnable", 0), ("ProxyServer", ""), ("ProxyOverride", "")):
try:
values[name], _ = winreg.QueryValueEx(key, name)
except FileNotFoundError:
values[name] = default
return values
def _write_settings(self, values: dict[str, str | int]) -> None:
if not self.is_supported:
return
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, INTERNET_SETTINGS_KEY, 0, winreg.KEY_SET_VALUE) as key:
if "ProxyEnable" in values:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, int(values["ProxyEnable"]))
if "ProxyServer" in values:
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, str(values["ProxyServer"]))
if "ProxyOverride" in values:
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, str(values["ProxyOverride"]))
def query_state(self) -> SystemProxyState:
"""Полный снимок эффективного состояния прокси (только чтение реестра).
Учитывает политику ``ProxySettingsPerUser=0`` (машинные настройки в
HKLM вместо HKCU) и PAC (``AutoConfigURL``). Без вызовов WinINet/RAS —
безопасно вызывать с UI-потока.
"""
if not self.is_supported:
return SystemProxyState(supported=False)
hive = winreg.HKEY_CURRENT_USER
source = "hkcu"
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, INTERNET_POLICY_KEY, 0, winreg.KEY_READ) as key:
per_user, _ = winreg.QueryValueEx(key, "ProxySettingsPerUser")
if int(per_user) == 0:
hive = winreg.HKEY_LOCAL_MACHINE
source = "hklm-policy"
except (FileNotFoundError, OSError, ValueError, TypeError):
pass
enabled = False
server = ""
override = ""
autoconfig_url = ""
try:
with winreg.OpenKey(hive, INTERNET_SETTINGS_KEY, 0, winreg.KEY_READ) as key:
for name, default in (
("ProxyEnable", 0),
("ProxyServer", ""),
("ProxyOverride", ""),
("AutoConfigURL", ""),
):
try:
value, _ = winreg.QueryValueEx(key, name)
except FileNotFoundError:
value = default
if name == "ProxyEnable":
try:
enabled = int(value or 0) == 1
except (ValueError, TypeError):
enabled = False
elif name == "ProxyServer":
server = str(value or "")
elif name == "ProxyOverride":
override = str(value or "")
else:
autoconfig_url = str(value or "")
except OSError:
logger.debug("Failed to read effective proxy state", exc_info=True)
return SystemProxyState(
supported=True,
enabled=enabled,
server=server,
override=override,
autoconfig_url=autoconfig_url,
source=source,
is_ours=enabled and self.is_our_proxy_server(server),
)
def has_backup(self) -> bool:
return self._backup is not None or self._load_persisted_backup() is not None
def should_auto_disable(self) -> bool:
"""Можно ли автоматически выключать системный прокси.
True, только когда активный прокси установлен нами (``is_ours``) либо
существует резервная копия для восстановления. Чужой/корпоративный
прокси без нашей резервной копии никогда не трогаем.
"""
if not self.is_supported:
return False
state = self.query_state()
if state.enabled and state.is_ours:
return True
return self.has_backup()
def release_if_owned(self, restore_previous: bool = True) -> bool:
"""Выключает системный прокси только если он наш или есть бэкап."""
if not self.should_auto_disable():
return False
self.disable(restore_previous=restore_previous)
return True
def _load_persisted_backup(self) -> dict[str, str | int] | None:
if not self._backup_file.exists():
return None
try:
payload = json.loads(self._backup_file.read_text(encoding="utf-8"))
except Exception:
return None
if not isinstance(payload, dict):
return None
result: dict[str, str | int] = {}
for key in ("ProxyEnable", "ProxyServer", "ProxyOverride"):
if key in payload:
result[key] = payload[key]
return result or None
def _persist_backup(self, values: dict[str, str | int] | None) -> None:
try:
if values:
self._backup_file.parent.mkdir(parents=True, exist_ok=True)
self._backup_file.write_text(json.dumps(values, ensure_ascii=True, indent=2), encoding="utf-8")
elif self._backup_file.exists():
self._backup_file.unlink()
except Exception:
pass
def _ras_entry_names(self) -> list[str]:
"""Имена RAS/VPN-подключений (RasEnumEntriesW); отсутствие — норма."""
if sys.platform != "win32":
return []
try:
rasapi = ctypes.windll.rasapi32
entries = (RASENTRYNAMEW * 64)()
entries[0].dwSize = ctypes.sizeof(RASENTRYNAMEW)
cb = ctypes.c_uint32(ctypes.sizeof(entries))
count = ctypes.c_uint32(0)
ret = rasapi.RasEnumEntriesW(None, None, entries, ctypes.byref(cb), ctypes.byref(count))
if ret != 0:
return []
return [entries[i].szEntryName for i in range(count.value) if entries[i].szEntryName]
except Exception:
logger.debug("RAS entry enumeration unavailable", exc_info=True)
return []
def _apply_per_connection(self, proxy_server: str, override: str, enable: bool) -> None:
"""Применяет настройки через WinINet для DefaultConnectionSettings.
Пишет per-connection параметры (option 75) для подключения по
умолчанию (NULL) и всех RAS-подключений, чтобы страница «Прокси» в
Параметрах Windows видела актуальное состояние. Любой сбой деградирует
до текущего поведения (только реестр) без исключений.
"""
if sys.platform != "win32":
return
try:
wininet = ctypes.windll.Wininet
except Exception:
logger.debug("WinINet unavailable for per-connection apply", exc_info=True)
return
connections: list[str | None] = [None]
connections.extend(self._ras_entry_names())
for connection in connections:
try:
options = (INTERNET_PER_CONN_OPTIONW * 3)()
options[0].dwOption = INTERNET_PER_CONN_FLAGS
options[0].Value.dwValue = (
PROXY_TYPE_DIRECT | PROXY_TYPE_PROXY if enable else PROXY_TYPE_DIRECT
)
options[1].dwOption = INTERNET_PER_CONN_PROXY_SERVER
options[1].Value.pszValue = str(proxy_server or "")
options[2].dwOption = INTERNET_PER_CONN_PROXY_BYPASS
options[2].Value.pszValue = str(override or "")
option_list = INTERNET_PER_CONN_OPTION_LISTW()
option_list.dwSize = ctypes.sizeof(INTERNET_PER_CONN_OPTION_LISTW)
option_list.pszConnection = connection
option_list.dwOptionCount = len(options)
option_list.dwOptionError = 0
option_list.pOptions = options
wininet.InternetSetOptionW(
0,
INTERNET_OPTION_PER_CONNECTION_OPTION,
ctypes.byref(option_list),
option_list.dwSize,
)
except Exception:
logger.debug(
"Per-connection proxy apply failed for %r", connection, exc_info=True
)
def _refresh_system_proxy(self) -> None:
if sys.platform != "win32":
return
try:
wininet = ctypes.windll.Wininet
wininet.InternetSetOptionW(0, INTERNET_OPTION_SETTINGS_CHANGED, 0, 0)
wininet.InternetSetOptionW(0, INTERNET_OPTION_REFRESH, 0, 0)
except Exception:
logger.debug("WinINet refresh failed", exc_info=True)
def enable(self, http_port: int, socks_port: int, bypass_lan: bool = True) -> None:
if not self.is_supported:
return
if self._backup is None:
self._backup = self._read_settings()
self._persist_backup(self._backup)
proxy_server = (
f"http={PROXY_HOST}:{http_port};"
f"https={PROXY_HOST}:{http_port};"
f"socks={PROXY_HOST}:{socks_port}"
)
override = "<local>;localhost;127.*"
if bypass_lan:
override = (
"<local>;localhost;127.*;10.*;172.*;192.168.*;"
"*.local;::1"
)
self._write_settings(
{
"ProxyEnable": 1,
"ProxyServer": proxy_server,
"ProxyOverride": override,
}
)
self._apply_per_connection(proxy_server, override, enable=True)
self._refresh_system_proxy()
def disable(self, restore_previous: bool = True) -> None:
if not self.is_supported:
return
backup = self._backup or self._load_persisted_backup()
if restore_previous and backup:
self._write_settings(dict(backup))
try:
restored_enabled = int(backup.get("ProxyEnable", 0) or 0) == 1
except (ValueError, TypeError):
restored_enabled = False
self._apply_per_connection(
str(backup.get("ProxyServer", "") or ""),
str(backup.get("ProxyOverride", "") or ""),
enable=restored_enabled,
)
else:
self._write_settings({"ProxyEnable": 0, "ProxyServer": ""})
self._apply_per_connection("", "", enable=False)
self._backup = None
self._persist_backup(None)
self._refresh_system_proxy()
def is_enabled(self) -> bool:
return self.query_state().enabled