zapret-kvn/tests/test_zapret_manager.py
loop-uh ddf42e0e67
Some checks failed
Windows project source guards / test (push) Has been cancelled
DNS: bootstrap без SNI и запомненный адрес сервера
Адрес самого VPN-сервера больше не зависит от имён, которые фильтр видит в
TLS, и от честности провайдерского резолвера.

- Шаблоны sing-box: серверы direct-doh идут по литеральному IP с
  tls.disable_sni, имя остаётся только для проверки сертификата. Имена
  dns.google, cloudflare-dns.com и dns.quad9.net ТСПУ обрывает по SNI, из-за
  чего bootstrap скатывался на системный резолвер. Адресов пять вместо трёх
  (добавлены 94.140.14.14 и 8.8.4.4).
- network/bootstrap_dns.py: доверенный резолвер имени сервера — DoH по IP без
  SNI, затем системный резолвер, затем запомненный адрес. Ответ DoH хранится
  до семи суток в data/bootstrap-lkg.json; системный ответ не запоминается.
- Планировщик sing-box: когда запомненный адрес есть, узел разрешается своей
  цепочкой direct-doh -> запомненный адрес. NXDOMAIN системного резолвера в
  штатной цепочке окончателен, и подмена «домена нет» срывала подключение.
  Секции пользователя не меняются, добавляются только app-теги.
- Zapret: правило сервера строится по тому же доверенному пути вместо
  getaddrinfo, отказ провайдера больше не отменяет подключение.

Проверено собранным под Linux закреплённым ядром: с прежним шаблоном имена
DoH-серверов видны в трафике открытым текстом, с новым их нет, имена
разрешаются. Гибридный режим и WireGuard/AmneziaWG запомненный адрес пока не
используют.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:01:26 +03:00

267 lines
12 KiB
Python

"""winws2 command line and the revisioned process manager."""
from __future__ import annotations
import socket
import unittest
from unittest.mock import patch
from PyQt6.QtCore import QCoreApplication
from xray_fluent.engines.zapret.blobs import lua_init_arguments
from xray_fluent.engines.zapret.command import SERVER_PROFILE_NAME, ServerRule, build_arguments
from xray_fluent.network.bootstrap_dns import BootstrapResolution
from xray_fluent.engines.zapret.endpoint import (
ResolvedEndpoint,
ServerEndpoint,
resolve_endpoint,
resolve_host,
)
from xray_fluent.engines.zapret.manager import ZapretManager, ZapretPlan
from xray_fluent.engines.zapret.strategies import load_strategy_catalog, pass_strategy
_APP = QCoreApplication.instance() or QCoreApplication([])
def _rule(kind: str, ports: tuple[str, ...], ips: tuple[str, ...], strategy) -> ServerRule:
return ServerRule(ResolvedEndpoint(ServerEndpoint(kind, ("one.example",), ports), ips), strategy)
class ServerRuleArgumentTests(unittest.TestCase):
def test_no_rule_leaves_the_preset_untouched(self) -> None:
args = ["--wf-tcp-out=443", "--filter-tcp=443", "--lua-desync=pass"]
self.assertEqual(build_arguments(args, None), args)
def test_rule_is_the_first_profile_and_does_not_mutate_the_preset(self) -> None:
original = ["--wf-tcp-out=80", "--filter-udp=443", "--lua-desync=pass"]
rule = _rule(
"tcp", ("443",), ("2001:0db8::7", "203.0.113.7", "203.0.113.7"),
load_strategy_catalog("tcp")["alt9"],
)
updated = build_arguments(original, rule)
self.assertEqual(original, ["--wf-tcp-out=80", "--filter-udp=443", "--lua-desync=pass"])
self.assertIn("--wf-tcp-out=80,443", updated)
name_index = updated.index(SERVER_PROFILE_NAME)
self.assertLess(name_index, updated.index("--filter-udp=443"))
self.assertEqual(updated[name_index + 1], "--filter-tcp=443")
self.assertEqual(updated[name_index + 2], "--ipset-ip=203.0.113.7,2001:db8::7")
self.assertEqual(updated[name_index + 3], rule.strategy.args[0])
self.assertEqual(updated[name_index + 3 + len(rule.strategy.args)], "--new")
def test_pass_rule_is_inserted_before_original_profiles(self) -> None:
args = [
"--lua-init=@lua/zapret-lib.lua",
"--wf-udp-out=443",
"--filter-udp=443",
"--new",
"--filter-tcp=443",
"--new=catch-all",
"--filter-udp=*",
]
rule = _rule("quic", ("8443",), ("203.0.113.7",), pass_strategy("udp"))
self.assertEqual(
build_arguments(args, rule),
[
"--lua-init=@lua/zapret-antidpi.lua",
"--lua-init=@lua/zapret-lib.lua",
"--wf-udp-out=443,8443",
SERVER_PROFILE_NAME,
"--filter-udp=8443",
"--ipset-ip=203.0.113.7",
"--lua-desync=pass",
"--new",
"--filter-udp=443",
"--new",
"--filter-tcp=443",
"--new=catch-all",
"--filter-udp=*",
],
)
def test_tcp_and_udp_capture_filters_are_not_mixed(self) -> None:
args = ["--wf-tcp-out=443", "--filter-tcp=443", "--lua-desync=pass"]
rule = _rule("quic", ("8443",), ("203.0.113.8",), load_strategy_catalog("udp")["general_bf_32"])
updated = build_arguments(args, rule)
self.assertIn("--wf-tcp-out=443", updated)
self.assertIn("--wf-udp-out=8443", updated)
self.assertIn("--filter-udp=8443", updated)
self.assertIn("--blob=quic_google:@bin/quic_initial_www_google_com.bin", updated)
def test_repeated_capture_filters_keep_every_original_port(self) -> None:
args = ["--wf-tcp-out=80", "--wf-tcp-out=443,8080", "--filter-tcp=80", "--lua-desync=pass"]
rule = _rule("tcp", ("8443",), ("203.0.113.8",), load_strategy_catalog("tcp")["alt9"])
captures = [arg for arg in build_arguments(args, rule) if arg.startswith("--wf-tcp-out=")]
self.assertEqual(captures, ["--wf-tcp-out=80,443,8080,8443"])
def test_wildcard_capture_filter_stays_a_wildcard(self) -> None:
args = ["--wf-udp-out=*", "--wf-udp-out=443", "--filter-udp=443", "--lua-desync=pass"]
rule = _rule("wireguard", ("51820",), ("203.0.113.8",), pass_strategy("udp"))
captures = [arg for arg in build_arguments(args, rule) if arg.startswith("--wf-udp-out=")]
self.assertEqual(captures, ["--wf-udp-out=*"])
def test_extension_lua_is_injected_after_the_core_libraries(self) -> None:
rule = _rule("tcp", ("443",), ("203.0.113.8",), load_strategy_catalog("tcp")["fakemultisplit_google_ultra"])
lua = [arg for arg in build_arguments([], rule) if arg.startswith("--lua-init=")]
self.assertEqual(
lua,
[
"--lua-init=@lua/zapret-lib.lua",
"--lua-init=@lua/zapret-antidpi.lua",
"--lua-init=@lua/fakemultisplit.lua",
],
)
def test_core_only_strategy_gets_no_extension_lua(self) -> None:
rule = _rule("tcp", ("443",), ("203.0.113.8",), load_strategy_catalog("tcp")["multisplit_pos1"])
lua = [arg for arg in build_arguments([], rule) if arg.startswith("--lua-init=")]
self.assertEqual(len(lua), 2)
def test_every_catalog_strategy_gets_its_lua_and_blobs(self) -> None:
for transport, kind in (("tcp", "tcp"), ("udp", "quic")):
for entry in load_strategy_catalog(transport).values():
with self.subTest(transport=transport, strategy=entry.strategy_id):
updated = build_arguments([], _rule(kind, ("443",), ("203.0.113.8",), entry))
core = updated.index("--lua-init=@lua/zapret-antidpi.lua")
for extension in lua_init_arguments(entry.args):
self.assertGreater(updated.index(extension), core)
for dependency in entry.blob_dependencies:
self.assertTrue(
any(arg.startswith(f"--blob={dependency}:") for arg in updated), dependency,
)
class ResolutionTests(unittest.TestCase):
def test_host_resolution_uses_the_trusted_bootstrap_path(self) -> None:
answer = BootstrapResolution(frozenset({"203.0.113.7", "2001:db8::7"}), "doh")
with (
patch("xray_fluent.engines.zapret.endpoint.resolve_bootstrap", return_value=answer) as lookup,
patch("xray_fluent.engines.zapret.endpoint.register_server_aliases") as aliases,
):
resolved = resolve_host("proxy.example.com")
lookup.assert_called_once_with("proxy.example.com")
self.assertEqual(resolved, {"203.0.113.7", "2001:db8::7"})
# Log redaction learns the IPs before anything can print them.
aliases.assert_called_once_with("proxy.example.com", resolved)
def test_spoofed_system_answer_does_not_break_the_server_rule(self) -> None:
# Провайдер отвечает «домена нет», защищённый путь недоступен: правило
# сервера строится по запомненному адресу, подключение не отменяется.
answer = BootstrapResolution(frozenset({"203.0.113.7"}), "cache")
with (
patch("xray_fluent.engines.zapret.endpoint.resolve_bootstrap", return_value=answer),
patch("xray_fluent.engines.zapret.endpoint.register_server_aliases"),
):
self.assertEqual(resolve_host("proxy.example.com"), {"203.0.113.7"})
def test_literal_ip_needs_no_dns(self) -> None:
with patch("xray_fluent.engines.zapret.endpoint.resolve_bootstrap") as lookup:
self.assertEqual(resolve_host("[2001:db8::7]"), {"2001:db8::7"})
lookup.assert_not_called()
def test_every_host_is_resolved_afresh(self) -> None:
endpoint = ServerEndpoint("wireguard", ("one.example", "two.example"), ("51820",))
with patch(
"xray_fluent.engines.zapret.endpoint.resolve_host",
side_effect=({"203.0.113.1"}, {"2001:db8::2", "203.0.113.1"}),
) as resolver:
result = resolve_endpoint(endpoint)
self.assertEqual(resolver.call_count, 2)
self.assertEqual(result.ips, ("203.0.113.1", "2001:db8::2"))
def test_no_address_is_an_error(self) -> None:
endpoint = ServerEndpoint("tcp", ("one.example",), ("443",))
with patch("xray_fluent.engines.zapret.endpoint.resolve_host", return_value=set()):
with self.assertRaises(OSError):
resolve_endpoint(endpoint)
class _NoLaunchManager(ZapretManager):
"""Records launches instead of running the step generator."""
def __init__(self) -> None:
super().__init__()
self.launches: list[tuple[ZapretPlan, int]] = []
def _start_runner(self, plan: ZapretPlan, revision: int) -> None:
self.launches.append((plan, revision))
class ManagerRevisionTests(unittest.TestCase):
def setUp(self) -> None:
self.manager = _NoLaunchManager()
self.applied: list[int] = []
self.failed: list[tuple[int, str]] = []
self.manager.plan_applied.connect(self.applied.append)
self.manager.plan_failed.connect(lambda revision, reason: self.failed.append((revision, reason)))
def test_apply_is_pending_until_its_own_process_starts(self) -> None:
revision = self.manager.apply(ZapretPlan("Default"))
self.assertTrue(self.manager.pending)
self.assertTrue(self.manager.active)
self.manager._on_started(revision)
self.assertFalse(self.manager.pending)
self.assertEqual(self.applied, [revision])
def test_late_start_of_an_older_revision_proves_nothing(self) -> None:
first = self.manager.apply(ZapretPlan("Default"))
second = self.manager.apply(ZapretPlan("Default", _rule("tcp", ("443",), ("203.0.113.7",), pass_strategy("tcp"))))
self.assertGreater(second, first)
self.manager._on_started(first)
self.assertEqual(self.applied, [])
self.assertTrue(self.manager.pending)
self.manager._on_started(second)
self.assertEqual(self.applied, [second])
def test_same_plan_again_is_free(self) -> None:
plan = ZapretPlan("Default")
revision = self.manager.apply(plan)
self.assertEqual(self.manager.apply(plan), revision)
self.assertEqual(len(self.manager.launches), 1)
def test_timeout_fails_only_the_pending_revision(self) -> None:
revision = self.manager.apply(ZapretPlan("Default"))
self.manager._on_apply_timeout(revision - 1)
self.assertEqual(self.failed, [])
self.manager._on_apply_timeout(revision)
self.assertEqual(self.failed, [(revision, "timeout")])
self.assertFalse(self.manager.pending)
def test_stop_fails_a_pending_request_instead_of_marking_it_ready(self) -> None:
stopped: list[bool] = []
self.manager.stopped.connect(lambda: stopped.append(True))
revision = self.manager.apply(ZapretPlan("Default"))
self.manager.stop()
self.assertEqual(self.applied, [])
self.assertEqual(self.failed, [(revision, "stopped")])
self.assertIsNone(self.manager.plan)
self.assertFalse(self.manager.active)
self.assertEqual(stopped, [True])
def test_stop_with_nothing_running_is_silent(self) -> None:
stopped: list[bool] = []
self.manager.stopped.connect(lambda: stopped.append(True))
self.manager.stop()
self.assertEqual(stopped, [])
def test_launch_failure_reports_error_and_stop(self) -> None:
errors: list[str] = []
self.manager.error.connect(errors.append)
revision = self.manager.apply(ZapretPlan("Default"))
self.manager._fail("winws2.exe не найден", "missing_executable")
self.assertEqual(errors, ["winws2.exe не найден"])
self.assertEqual(self.failed, [(revision, "missing_executable")])
self.assertIsNone(self.manager.plan)
def test_clean_exit_is_a_stop_not_an_error(self) -> None:
errors: list[str] = []
self.manager.error.connect(errors.append)
self.manager.apply(ZapretPlan("Default"))
self.manager._fail("", "exited")
self.assertEqual(errors, [])
if __name__ == "__main__":
unittest.main()