All checks were successful
Zapret source guards / validate (push) Successful in 10s
Подбор по сервису из нескольких имён (YouTube: страница, видеосервер, превью) запоминает хосты для проверки, а проверка после запуска Zapret открывает их все через пресет и называет первое закрытое имя. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1205 lines
54 KiB
Python
1205 lines
54 KiB
Python
"""Порядок работы подбора стратегии на подставной сети и подставном winws2.
|
||
|
||
Каждый тест описывает сеть функцией «ответ на проверку при такой-то
|
||
запущенной стратегии» и смотрит, что подбор решил и сообщил.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from dataclasses import dataclass, replace
|
||
|
||
from blockcheck.strategy_search import verdict as rules
|
||
from blockcheck.strategy_search.engine import SearchRequest, StrategySearch
|
||
from blockcheck.strategy_search.history import TargetHistory
|
||
from blockcheck.strategy_search.ordering import Candidate
|
||
from blockcheck.strategy_search.verdict import ProbeOutcome
|
||
from blockcheck.strategy_search.winws_session import SessionStart
|
||
|
||
OK = ProbeOutcome(rules.PROBE_OK, "HTTP 200", 120.0)
|
||
RESET = ProbeOutcome(rules.PROBE_BLOCKED, "соединение сброшено", 30.0, "reset")
|
||
TIMEOUT = ProbeOutcome(rules.PROBE_BLOCKED, "нет шифрования (таймаут)", 4000.0, "timeout")
|
||
NO_CONNECT = ProbeOutcome(rules.PROBE_UNREACHABLE, "сервер не отвечает на подключение", 4000.0)
|
||
|
||
|
||
def cand(strategy_id: str, function: str = "fake") -> Candidate:
|
||
return Candidate(strategy_id, strategy_id, f"--lua-desync={function}:id={strategy_id}")
|
||
|
||
|
||
def args_id(args: str | None) -> str | None:
|
||
"""Кто сейчас запущен: id стратегии, "pass" или None (winws2 не запущен)."""
|
||
if args is None:
|
||
return None
|
||
if args.strip() == "--lua-desync=pass":
|
||
return "pass"
|
||
return args.split("id=", 1)[1].split()[0] if "id=" in args else args
|
||
|
||
|
||
class FakeSession:
|
||
def __init__(self, env: "FakeEnv", config_text: str) -> None:
|
||
self.env = env
|
||
self.config_text = config_text
|
||
self.strategy = next(
|
||
(line.split("id=", 1)[1] for line in config_text.splitlines() if "id=" in line),
|
||
"pass" if "--lua-desync=pass" in config_text else None,
|
||
)
|
||
|
||
def start(self):
|
||
if self.env.start_fails.get(self.strategy, 0) > 0:
|
||
self.env.start_fails[self.strategy] -= 1
|
||
return SessionStart(False, exit_code=1, output_tail="lua error")
|
||
self.env.running = self.strategy
|
||
return SessionStart(True, ready_ms=100.0)
|
||
|
||
def alive(self) -> bool:
|
||
if self.env.die_during_probe.get(self.strategy, 0) > 0:
|
||
self.env.die_during_probe[self.strategy] -= 1
|
||
return False
|
||
return True
|
||
|
||
def stop(self) -> bool:
|
||
self.env.running = None
|
||
self.env.stops += 1
|
||
return True
|
||
|
||
def output_tail(self, lines: int = 6) -> str:
|
||
return "crash"
|
||
|
||
|
||
@dataclass
|
||
class FakeEnv:
|
||
network: object = None # (running_id, call_number) -> ProbeOutcome
|
||
candidates: list | None = None
|
||
addresses: tuple = ("1.1.1.10", "1.1.1.11")
|
||
resolve_error: str = ""
|
||
control: list | None = None # последовательность ответов control_alive
|
||
port80_open: bool = False
|
||
control_default: bool = True
|
||
# Приветствие с именем до запуска winws2: (адрес, имя) -> True / False / None.
|
||
# По умолчанию обычный фильтр: постороннее имя проходит, режут только цель.
|
||
hello: object = None
|
||
# Объём по соединению: (running_id, адрес) -> VOLUME_*; по умолчанию набирается.
|
||
volume: object = None
|
||
# Обычный HTTP на порту 80: (running_id, номер вызова) -> ProbeOutcome.
|
||
http: object = None
|
||
# QUIC: режут ли по имени (до подбора) и ответ на пакет с именем при стратегии.
|
||
quic_cut_by_name: bool = False
|
||
quic: object = None
|
||
|
||
def __post_init__(self):
|
||
self.running = None
|
||
self.sessions: list[FakeSession] = []
|
||
self.calls: dict = {}
|
||
self.start_fails: dict = {}
|
||
self.die_during_probe: dict = {}
|
||
self.stops = 0
|
||
self.pre_cleaned = False
|
||
self.post_cleaned = False
|
||
self.recovered = 0
|
||
self.history_saved = None
|
||
self.slept = 0.0
|
||
self.hellos: list = []
|
||
self.volume_calls: list = []
|
||
self.history_saves: list = []
|
||
self.clock = 0.0
|
||
self.control = list(self.control or [])
|
||
|
||
# каталог и история
|
||
def load_candidates(self, scan_protocol):
|
||
return list(self.candidates or [])
|
||
|
||
def load_history(self, key):
|
||
return TargetHistory()
|
||
|
||
def record_history(self, key, *, confirmed, failed):
|
||
self.history_saved = (key, list(confirmed), list(failed))
|
||
self.history_saves.append(self.history_saved)
|
||
|
||
def blob_lines(self, strategy_args):
|
||
return []
|
||
|
||
def games_ipset_paths(self, scope):
|
||
return ["lists/ipset-roblox.txt"]
|
||
|
||
# процессы
|
||
def pre_cleanup(self):
|
||
self.pre_cleaned = True
|
||
|
||
def post_cleanup(self):
|
||
self.post_cleaned = True
|
||
|
||
def start_session(self, config_text):
|
||
session = FakeSession(self, config_text)
|
||
self.sessions.append(session)
|
||
return session
|
||
|
||
def recover_after_crash(self):
|
||
self.recovered += 1
|
||
|
||
def strategy_pause_seconds(self):
|
||
return 0.0
|
||
|
||
# сеть
|
||
def control_alive(self):
|
||
return self.control.pop(0) if self.control else self.control_default
|
||
|
||
def resolve(self, host, port, *, udp):
|
||
if self.resolve_error:
|
||
return [], self.resolve_error
|
||
return list(self.addresses), ""
|
||
|
||
def _answer(self):
|
||
key = self.running
|
||
number = self.calls.get(key, 0)
|
||
self.calls[key] = number + 1
|
||
return self.network(key, number)
|
||
|
||
def probe_https(self, host, addresses):
|
||
outcome = self._answer()
|
||
return [outcome for _address in addresses]
|
||
|
||
def probe_http(self, host, addresses):
|
||
outcome = (self.http or (lambda running, n: RESET))(self.running, self.calls.setdefault(("http", self.running), 0))
|
||
self.calls[("http", self.running)] += 1
|
||
return [outcome for _address in addresses]
|
||
|
||
def quic_blocked_by_name(self, host, address):
|
||
return self.quic_cut_by_name
|
||
|
||
def probe_quic(self, host, addresses):
|
||
number = self.calls.setdefault(("quic", self.running), 0)
|
||
self.calls[("quic", self.running)] += 1
|
||
outcome = (self.quic or (lambda running, n: TIMEOUT))(self.running, number)
|
||
return [outcome for _address in addresses]
|
||
|
||
def probe_volume(self, host, address, answered):
|
||
self.volume_calls.append((self.running, address))
|
||
code = self.volume(self.running, address) if self.volume is not None else rules.VOLUME_OK
|
||
if code == rules.VOLUME_CUT:
|
||
return ProbeOutcome(
|
||
rules.PROBE_BLOCKED,
|
||
"соединение замирает после ~16 КБ (блокировка по объёму)",
|
||
answered.time_ms,
|
||
"reset",
|
||
answered.body_size,
|
||
rules.VOLUME_CUT,
|
||
)
|
||
return replace(answered, volume=code)
|
||
|
||
def probe_hello(self, address, name):
|
||
self.hellos.append((address, name))
|
||
if self.hello is not None:
|
||
return self.hello(address, name)
|
||
return name in (None, "example.com")
|
||
|
||
def probe_udp(self, specs):
|
||
return [(spec, self.network(self.running, spec)) for spec in specs]
|
||
|
||
def tcp_port_open(self, address, port):
|
||
return self.port80_open
|
||
|
||
def cancel_probes(self):
|
||
pass
|
||
|
||
# время
|
||
def monotonic(self):
|
||
self.clock += 0.01
|
||
return self.clock
|
||
|
||
def wall_time(self):
|
||
return 1_000_000.0
|
||
|
||
def sleep(self, seconds):
|
||
self.slept += seconds
|
||
self.clock += seconds
|
||
|
||
|
||
class FakeEvents:
|
||
def __init__(self, answer: bool = False) -> None:
|
||
self.answer = answer
|
||
self.questions: list[str] = []
|
||
self.results = []
|
||
self.logs: list[str] = []
|
||
self.cancel_after_results: int | None = None
|
||
self.started_args: list[str] = []
|
||
self.stages: list[tuple[str, str]] = []
|
||
self.stage_texts: list[str] = []
|
||
|
||
def log(self, message):
|
||
self.logs.append(message)
|
||
|
||
def phase(self, text):
|
||
pass
|
||
|
||
def strategy_started(self, name, index, total, args=""):
|
||
self.started_args.append(args)
|
||
|
||
def stage(self, step, status, text=""):
|
||
self.stages.append((step, status))
|
||
self.stage_texts.append(text)
|
||
|
||
def strategy_result(self, result):
|
||
self.results.append(result)
|
||
|
||
def ask_continue(self, reason):
|
||
self.questions.append(reason)
|
||
return self.answer
|
||
|
||
def is_cancelled(self):
|
||
return self.cancel_after_results is not None and len(self.results) >= self.cancel_after_results
|
||
|
||
|
||
def run(env: FakeEnv, events: FakeEvents | None = None, **request):
|
||
events = events or FakeEvents()
|
||
params = {"target": "discord.com", "mode": "quick"}
|
||
params.update(request)
|
||
report = StrategySearch(SearchRequest(**params), env=env, events=events).run()
|
||
return report, events
|
||
|
||
|
||
def blocked_unless(*working: str):
|
||
"""Цель закрыта (сброс), пока не запущена одна из рабочих стратегий."""
|
||
|
||
def network(running, _number):
|
||
return OK if running in working else RESET
|
||
|
||
return network
|
||
|
||
|
||
class FilterHabitTests(unittest.TestCase):
|
||
"""Вывод BlockCheck «Как работает фильтр» меняет очерёдность стратегий и назван в журнале."""
|
||
|
||
def _run(self, habit: str, **request):
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand("split", "multisplit"), cand("fake", "fake")])
|
||
env.load_history = lambda key: TargetHistory(habit=habit)
|
||
_report, events = run(env, **request)
|
||
started = [args_id(args) for args in events.started_args if args_id(args) not in (None, "pass")]
|
||
return list(dict.fromkeys(started)), events
|
||
|
||
def test_filter_that_reassembles_everything_tries_fakes_first(self) -> None:
|
||
order, events = self._run("none")
|
||
|
||
self.assertEqual(order, ["fake", "split"])
|
||
self.assertTrue(any("По последней проверке BlockCheck" in line for line in events.logs))
|
||
|
||
def test_without_habit_order_and_log_are_as_before(self) -> None:
|
||
order, events = self._run("")
|
||
|
||
self.assertEqual(order, ["split", "fake"])
|
||
self.assertFalse(any("BlockCheck" in line for line in events.logs))
|
||
|
||
def test_habit_is_about_tls_and_does_not_touch_voice_search(self) -> None:
|
||
_order, events = self._run("none", scan_protocol="stun_voice")
|
||
|
||
self.assertFalse(any("По последней проверке BlockCheck" in line for line in events.logs))
|
||
|
||
|
||
class BaselineTests(unittest.TestCase):
|
||
def test_open_without_bypass_asks_and_stops_when_declined(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a"), cand("b")])
|
||
report, events = run(env, FakeEvents(answer=False))
|
||
|
||
self.assertEqual(len(events.questions), 1)
|
||
self.assertIn("без обхода", events.questions[0])
|
||
self.assertTrue(report.baseline_accessible)
|
||
self.assertEqual(report.total_tested, 0)
|
||
self.assertEqual(env.sessions, [])
|
||
self.assertTrue(env.post_cleaned)
|
||
|
||
def test_open_without_bypass_continue_tests_all_without_controls(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a"), cand("b")])
|
||
report, events = run(env, FakeEvents(answer=True))
|
||
|
||
self.assertTrue(report.baseline_accessible)
|
||
self.assertEqual(report.total_tested, 2)
|
||
# Ни контрольного pass, ни обратной проверки: цель и так открыта.
|
||
self.assertEqual([session.strategy for session in env.sessions], ["a", "b"])
|
||
self.assertTrue(all(result.raw_data.get("forced") for result in events.results))
|
||
|
||
def test_connect_refused_everywhere_is_not_a_dpi_block(self) -> None:
|
||
env = FakeEnv(
|
||
network=lambda running, n: NO_CONNECT, candidates=[cand("a")], port80_open=True, http=lambda r, n: NO_CONNECT
|
||
)
|
||
report, _events = run(env)
|
||
|
||
self.assertIn("порт 80 открыт", report.fatal_error)
|
||
self.assertIn("не снимают", report.fatal_error)
|
||
self.assertEqual(env.sessions, [])
|
||
self.assertTrue(report.cancelled)
|
||
|
||
def test_unresolvable_target_stops(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a")], resolve_error="нет такого имени")
|
||
report, _events = run(env)
|
||
|
||
self.assertIn("нет такого имени", report.fatal_error)
|
||
self.assertEqual(env.sessions, [])
|
||
|
||
def test_dns_stub_address_stops_with_dns_advice(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: RESET, candidates=[cand("a")], addresses=("195.82.146.214",))
|
||
report, _events = run(env)
|
||
|
||
self.assertIn("Настройка DNS", report.fatal_error)
|
||
self.assertEqual(env.sessions, [])
|
||
|
||
def test_no_internet_stops_before_anything(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: RESET, candidates=[cand("a")], control=[False])
|
||
report, _events = run(env)
|
||
|
||
self.assertIn("Нет интернета", report.fatal_error)
|
||
self.assertEqual(env.sessions, [])
|
||
self.assertTrue(env.pre_cleaned)
|
||
self.assertTrue(env.post_cleaned)
|
||
|
||
def test_pass_control_runs_first_and_passing_pass_asks_user(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("pass"), candidates=[cand("a")])
|
||
report, events = run(env, FakeEvents(answer=False))
|
||
|
||
self.assertEqual([session.strategy for session in env.sessions], ["pass"])
|
||
self.assertEqual(len(events.questions), 1)
|
||
self.assertIn("без приёмов обхода", events.questions[0])
|
||
self.assertEqual(report.total_tested, 0)
|
||
|
||
|
||
@dataclass
|
||
class CompanionEnv(FakeEnv):
|
||
"""Сеть сервиса из двух имён: страница отвечает по ``network``, видеосервер — по ``video``."""
|
||
|
||
video: object = None # running_id -> ProbeOutcome
|
||
unresolved: tuple = ()
|
||
|
||
def resolve(self, host, port, *, udp):
|
||
if host in self.unresolved:
|
||
return [], "нет адреса"
|
||
return super().resolve(host, port, udp=udp)
|
||
|
||
def probe_https(self, host, addresses):
|
||
if host == "video.example.com":
|
||
return [self.video(self.running) for _address in addresses]
|
||
return super().probe_https(host, addresses)
|
||
|
||
|
||
class CompanionTests(unittest.TestCase):
|
||
"""Сервис — несколько имён: стратегия, открывшая одну страницу, без видеосервера не рабочая."""
|
||
|
||
REQUEST = {"target": "www.youtube.com", "companions": (("googlevideo.com", "video.example.com"),)}
|
||
|
||
def _env(self, page, video, **extra):
|
||
return CompanionEnv(network=page, video=video, candidates=[cand("page_only"), cand("both", "multisplit")], **extra)
|
||
|
||
def test_strategy_that_opens_only_the_page_is_not_working(self) -> None:
|
||
env = self._env(
|
||
lambda running, _n: OK if running in ("page_only", "both") else RESET,
|
||
lambda running: OK if running == "both" else RESET,
|
||
)
|
||
_report, events = run(env, **self.REQUEST)
|
||
|
||
by_id = {result.strategy_id: result for result in events.results}
|
||
self.assertFalse(by_id["page_only"].success)
|
||
self.assertIn("video.example.com", by_id["page_only"].error)
|
||
self.assertTrue(by_id["both"].success)
|
||
|
||
def test_profile_covers_every_name_of_the_service(self) -> None:
|
||
env = self._env(lambda running, _n: OK if running == "both" else RESET, lambda running: OK if running == "both" else RESET)
|
||
_report, events = run(env, **self.REQUEST)
|
||
|
||
both = next(result for result in events.results if result.strategy_id == "both")
|
||
self.assertIn("--hostlist-domains=youtube.com,googlevideo.com", both.apply_lines)
|
||
# «Применить» проверит после запуска и остальные имена сервиса.
|
||
self.assertEqual(both.raw_data["companions"], ["video.example.com"])
|
||
|
||
def test_open_page_with_a_closed_video_server_is_still_searched(self) -> None:
|
||
env = self._env(lambda running, _n: OK, lambda running: OK if running == "both" else RESET)
|
||
report, events = run(env, FakeEvents(answer=False), **self.REQUEST)
|
||
|
||
# Вопроса «открывается и без обхода, подбирать нечего» нет: видеосервер закрыт.
|
||
self.assertFalse(report.baseline_accessible)
|
||
self.assertTrue(any("без обхода video.example.com" in line for line in events.logs))
|
||
self.assertTrue(any(result.strategy_id == "both" and result.success for result in events.results))
|
||
|
||
def test_everything_open_without_bypass_still_asks(self) -> None:
|
||
env = self._env(lambda running, _n: OK, lambda running: OK)
|
||
report, _events = run(env, FakeEvents(answer=False), **self.REQUEST)
|
||
|
||
self.assertTrue(report.baseline_accessible)
|
||
|
||
def test_name_that_cannot_be_resolved_is_skipped_not_fatal(self) -> None:
|
||
env = self._env(
|
||
lambda running, _n: OK if running == "page_only" else RESET,
|
||
lambda running: RESET,
|
||
unresolved=("video.example.com",),
|
||
)
|
||
_report, events = run(env, **self.REQUEST)
|
||
|
||
self.assertTrue(any("это имя сервиса не проверяется" in line for line in events.logs))
|
||
self.assertTrue(next(result for result in events.results if result.strategy_id == "page_only").success)
|
||
|
||
|
||
class StrategyVerdictTests(unittest.TestCase):
|
||
def test_working_strategy_needs_three_passes_and_carries_apply_lines(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("bad"), cand("good", "multisplit")])
|
||
report, events = run(env)
|
||
|
||
by_id = {result.strategy_id: result for result in events.results}
|
||
good = by_id["good"]
|
||
self.assertTrue(good.success)
|
||
self.assertEqual(good.verdict, rules.VERDICT_WORKING)
|
||
self.assertEqual((good.attempts_ok, good.attempts_total), (3, 3))
|
||
self.assertEqual(env.calls["good"], 3)
|
||
# «Применить» получит ровно проверенный профиль.
|
||
good_config = next(session.config_text for session in env.sessions if session.strategy == "good")
|
||
for line in good.apply_lines:
|
||
self.assertIn(line, good_config)
|
||
self.assertIn("--hostlist-domains=discord.com", good.apply_lines)
|
||
self.assertFalse(by_id["bad"].success)
|
||
self.assertEqual(by_id["bad"].apply_lines, ())
|
||
self.assertEqual([result.strategy_id for result in report.working_strategies], ["good"])
|
||
|
||
def test_strategy_that_fails_on_recheck_is_unstable_not_working(self) -> None:
|
||
def network(running, number):
|
||
if running == "flaky":
|
||
return OK if number == 0 else TIMEOUT
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("flaky")])
|
||
report, events = run(env)
|
||
|
||
result = events.results[0]
|
||
self.assertEqual(result.verdict, rules.VERDICT_UNSTABLE)
|
||
self.assertFalse(result.success)
|
||
self.assertEqual(report.working_strategies, [])
|
||
|
||
def test_success_is_not_counted_when_target_opens_without_bypass_afterwards(self) -> None:
|
||
state = {"flipped": False}
|
||
|
||
def network(running, number):
|
||
if running == "lucky":
|
||
state["flipped"] = True
|
||
return OK
|
||
if running is None and state["flipped"]:
|
||
return OK # сеть «починилась» сама — стратегия тут ни при чём
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("lucky")])
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_NOT_COUNTED)
|
||
self.assertFalse(events.results[0].success)
|
||
|
||
def test_stop_during_last_control_probe_does_not_record_a_working_strategy(self) -> None:
|
||
state = {"flipped": False, "controls": 0}
|
||
events = FakeEvents()
|
||
events.is_cancelled = lambda: state["controls"] >= 2
|
||
|
||
def network(running, _number):
|
||
if running == "good":
|
||
state["flipped"] = True
|
||
return OK
|
||
if running is None and state["flipped"]:
|
||
state["controls"] += 1 # проба «без обхода»; на второй нажали «Стоп», она оборвана
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("good")])
|
||
report, events = run(env, events)
|
||
|
||
self.assertTrue(report.cancelled)
|
||
self.assertEqual(report.working_strategies, [])
|
||
self.assertEqual(events.results, [])
|
||
|
||
def test_winws2_crash_during_probe_is_retried_not_counted_as_failure(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
|
||
env.die_during_probe["good"] = 1
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
|
||
self.assertEqual(env.recovered, 1)
|
||
|
||
def test_strategy_that_never_starts_is_reported_as_crash(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand("broken")])
|
||
env.start_fails["broken"] = 99
|
||
report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_CRASH)
|
||
# Одинаковое падение второй раз подряд — дело в стратегии: третий запуск
|
||
# не нужен, а в следующий раз она пойдёт в конец очереди.
|
||
self.assertEqual(len(env.sessions), 1 + 2) # pass + два запуска «broken»
|
||
self.assertEqual(env.history_saved[2], ["broken"])
|
||
self.assertEqual(report.working_strategies, [])
|
||
|
||
def test_one_time_start_failure_is_retried_and_not_remembered(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand("racy")])
|
||
env.start_fails["racy"] = 1
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_FAILED)
|
||
self.assertEqual(env.recovered, 1)
|
||
|
||
def test_network_drop_rechecks_strategy_instead_of_failing_it(self) -> None:
|
||
state = {"down": True}
|
||
|
||
def network(running, number):
|
||
if running == "good" and not state["down"]:
|
||
return OK
|
||
if running == "good":
|
||
state["down"] = False # первая проверка попала на пропавший интернет
|
||
return TIMEOUT
|
||
return RESET
|
||
|
||
# 1) перед подбором сеть есть; 2) после таймаута — нет; 3) через паузу вернулась.
|
||
env = FakeEnv(network=network, candidates=[cand("good")], control=[True, False, True])
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(len(events.results), 1)
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
|
||
self.assertGreater(env.slept, 0)
|
||
|
||
def test_failure_on_a_network_that_drops_twice_is_not_remembered(self) -> None:
|
||
# Сеть пропала на этой стратегии дважды: провал ничего о ней не говорит,
|
||
# в историю провалов на 14 дней она не идёт.
|
||
env = FakeEnv(
|
||
network=lambda running, n: TIMEOUT,
|
||
candidates=[cand("a")],
|
||
control=[True, False, True, False, True],
|
||
)
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(len(events.results), 1)
|
||
self.assertFalse(events.results[0].success)
|
||
self.assertEqual([save for save in env.history_saves if "a" in save[2]], [])
|
||
|
||
def test_network_that_never_returns_stops_the_scan(self) -> None:
|
||
env = FakeEnv(
|
||
network=lambda running, n: TIMEOUT,
|
||
candidates=[cand("a"), cand("b")],
|
||
control=[True],
|
||
control_default=False,
|
||
)
|
||
report, events = run(env)
|
||
|
||
self.assertIn("пропал интернет", report.fatal_error)
|
||
self.assertEqual(events.results, [])
|
||
|
||
def test_reset_failure_does_not_waste_time_on_network_check(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand("a")], control=[True, False])
|
||
_report, events = run(env)
|
||
|
||
# Сброс — след работы DPI: сеть жива, второй ответ control не спрашивался.
|
||
self.assertEqual(env.control, [False])
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_FAILED)
|
||
|
||
|
||
class AllowedNamesTests(unittest.TestCase):
|
||
"""Белый список имён: подбор находит разрешённое имя и сначала пробует стратегии с ним."""
|
||
|
||
def test_whitelisted_name_goes_into_fake_packet_first(self) -> None:
|
||
plain = cand("a")
|
||
with_tls_mod = Candidate("b", "b", "--lua-desync=fake:id=b:tls_mod=rnd,dupsid")
|
||
env = FakeEnv(
|
||
# Работает только подставной пакет с разрешённым именем.
|
||
network=lambda running, _n: OK if running and "sni=ya.ru" in running else RESET,
|
||
candidates=[plain, with_tls_mod],
|
||
hello=lambda _address, name: name == "ya.ru",
|
||
)
|
||
report, events = run(env)
|
||
|
||
self.assertEqual([item.strategy_id for item in report.working_strategies], ["b@sni=ya.ru"])
|
||
self.assertIn("--lua-desync=fake:id=b:tls_mod=rnd,dupsid,sni=ya.ru", events.started_args[0])
|
||
self.assertTrue(any("только разрешённые имена: ya.ru" in line for line in events.logs))
|
||
self.assertIn("b@sni=ya.ru", env.history_saved[1])
|
||
# До запуска winws2: цель, постороннее имя и «без имени», потом перебор.
|
||
self.assertEqual([name for _address, name in env.hellos[:3]], ["discord.com", "example.com", None])
|
||
|
||
def test_ordinary_filter_needs_no_search(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
|
||
run(env)
|
||
|
||
self.assertEqual([name for _address, name in env.hellos], ["discord.com", "example.com"])
|
||
|
||
def test_remembered_variant_is_found_again_from_history(self) -> None:
|
||
from blockcheck.strategy_search.engine import _remembered_variants
|
||
|
||
base = Candidate("b", "b", "--lua-desync=fake:id=b:tls_mod=rnd,rndsni,dupsid")
|
||
found = _remembered_variants([base], ["b@sni=vk.com", "gone@sni=vk.com", "b"])
|
||
|
||
self.assertEqual([item.strategy_id for item in found], ["b@sni=vk.com"])
|
||
self.assertIn("tls_mod=rnd,sni=vk.com,dupsid", found[0].args)
|
||
|
||
|
||
class StageEventTests(unittest.TestCase):
|
||
def test_successful_scan_walks_all_steps(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
|
||
_report, events = run(env)
|
||
|
||
done = [step for step, status in events.stages if status == "done"]
|
||
self.assertEqual(done, ["network", "baseline", "names", "control", "strategies"])
|
||
self.assertIn("--lua-desync=fake:id=good", events.started_args)
|
||
|
||
def test_stop_marks_running_step_failed_and_reports_kind(self) -> None:
|
||
env = FakeEnv(
|
||
network=lambda running, n: NO_CONNECT, candidates=[cand("a")], port80_open=True, http=lambda r, n: NO_CONNECT
|
||
)
|
||
report, events = run(env)
|
||
|
||
self.assertEqual(report.stop_kind, "address_block")
|
||
self.assertIn(("baseline", "failed"), events.stages)
|
||
|
||
def test_stop_during_baseline_is_a_stop_not_a_diagnosis(self) -> None:
|
||
# «Стоп» оборвал пробы «без обхода»: их «не отвечает» — отмена, а не
|
||
# «провайдер блокирует адрес целиком».
|
||
events = FakeEvents()
|
||
stop = {"now": False}
|
||
events.is_cancelled = lambda: stop["now"]
|
||
|
||
def network(running, _number):
|
||
stop["now"] = True
|
||
return NO_CONNECT
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("a")], port80_open=True, http=lambda r, n: NO_CONNECT)
|
||
report, _events = run(env, events)
|
||
|
||
self.assertTrue(report.cancelled)
|
||
self.assertEqual(report.fatal_error, "")
|
||
self.assertEqual(report.stop_kind, "")
|
||
|
||
def test_no_internet_kind(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: RESET, candidates=[cand("a")], control=[False])
|
||
report, events = run(env)
|
||
|
||
self.assertEqual(report.stop_kind, "no_internet")
|
||
self.assertIn(("network", "failed"), events.stages)
|
||
|
||
|
||
class BatchTests(unittest.TestCase):
|
||
def test_quick_mode_stops_at_the_first_reliable_strategy(self) -> None:
|
||
# Человеку нужна рабочая стратегия, а не все рабочие: «Быстрый» заканчивает на первой надёжной.
|
||
candidates = [cand(f"s{i}", f"f{i % 5}") for i in range(40)]
|
||
env = FakeEnv(network=blocked_unless("s3"), candidates=candidates)
|
||
report, events = run(env)
|
||
|
||
self.assertEqual(report.total_available, 40)
|
||
self.assertEqual([result.strategy_id for result in report.working_strategies], ["s3"])
|
||
self.assertEqual(events.results[-1].strategy_id, "s3")
|
||
self.assertLess(report.total_tested, 30)
|
||
|
||
def test_all_mode_tests_every_strategy(self) -> None:
|
||
candidates = [cand(f"s{i}", f"f{i % 5}") for i in range(40)]
|
||
env = FakeEnv(network=blocked_unless("s0", "s1", "s2", "s3"), candidates=candidates)
|
||
report, _events = run(env, mode="all")
|
||
|
||
self.assertEqual(report.total_tested, 40)
|
||
self.assertEqual(len(report.working_strategies), 4)
|
||
|
||
def test_empty_quick_batch_continues_with_the_next_strategies(self) -> None:
|
||
# В первых 30 ни одна не подошла — подбор сам берёт следующие, не дожидаясь нового запуска.
|
||
candidates = [cand(f"s{i}", f"f{i % 5}") for i in range(40)]
|
||
env = FakeEnv(network=blocked_unless("s37"), candidates=candidates)
|
||
report, events = run(env)
|
||
|
||
self.assertEqual([result.strategy_id for result in report.working_strategies], ["s37"])
|
||
self.assertGreater(report.total_tested, 30)
|
||
self.assertTrue(any("ни одна не подошла" in line for line in events.logs))
|
||
|
||
def test_recommended_and_popular_strategies_go_first(self) -> None:
|
||
from blockcheck.strategy_search.ordering import order_candidates
|
||
|
||
plain = Candidate("plain", "plain", "--lua-desync=fake:id=plain")
|
||
risky = Candidate("risky", "risky", "--lua-desync=fake:id=risky", label="experimental")
|
||
popular = Candidate("popular", "popular", "--lua-desync=fake:id=popular", usage=5)
|
||
trusted = Candidate("trusted", "trusted", "--lua-desync=fake:id=trusted", label="recommended")
|
||
|
||
ordered = [item.strategy_id for item in order_candidates([risky, plain, popular, trusted])]
|
||
self.assertEqual(ordered, ["trusted", "popular", "plain", "risky"])
|
||
|
||
def test_history_records_confirmed_and_failed(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good"), cand("bad", "multisplit")])
|
||
run(env, mode="all")
|
||
|
||
key, confirmed, failed = env.history_saved
|
||
self.assertEqual(key, "tcp_https|discord.com")
|
||
self.assertEqual(confirmed, ["good"])
|
||
self.assertEqual(failed, ["bad"])
|
||
|
||
def test_recent_failures_go_last_unless_started_over(self) -> None:
|
||
candidates = [cand("a", "fake"), cand("b", "multisplit")]
|
||
|
||
class HistoryEnv(FakeEnv):
|
||
def load_history(self, key):
|
||
# «a» не сработала минуту назад — обычный запуск отодвигает её в конец.
|
||
return TargetHistory(failed={"a": 1_000_000.0 - 60})
|
||
|
||
_report, events = run(HistoryEnv(network=blocked_unless(), candidates=candidates))
|
||
self.assertEqual([result.strategy_id for result in events.results], ["b", "a"])
|
||
|
||
started_over = HistoryEnv(network=blocked_unless(), candidates=candidates)
|
||
_report, events = run(started_over, from_start=True)
|
||
self.assertEqual([result.strategy_id for result in events.results], ["a", "b"])
|
||
self.assertTrue(any("заново" in line for line in events.logs))
|
||
# История не стирается: итоги нового прохода дописываются как обычно.
|
||
self.assertEqual(started_over.history_saved[2], ["a", "b"])
|
||
|
||
def test_forced_scan_does_not_pollute_history(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("a")])
|
||
run(env, FakeEvents(answer=True))
|
||
|
||
self.assertIsNone(env.history_saved)
|
||
|
||
def test_pass_strategy_from_catalog_is_never_a_candidate(self) -> None:
|
||
env = FakeEnv(
|
||
network=blocked_unless(),
|
||
candidates=[Candidate("pass", "pass", "--lua-desync=pass"), cand("a")],
|
||
)
|
||
report, events = run(env)
|
||
|
||
self.assertEqual([result.strategy_id for result in events.results], ["a"])
|
||
self.assertEqual(report.total_available, 1)
|
||
|
||
def test_cancel_stops_and_cleans_up(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand(f"s{i}") for i in range(5)])
|
||
events = FakeEvents()
|
||
events.cancel_after_results = 2
|
||
report, events = run(env, events)
|
||
|
||
self.assertTrue(report.cancelled)
|
||
self.assertEqual(len(events.results), 2)
|
||
self.assertTrue(env.post_cleaned)
|
||
self.assertIsNone(env.running)
|
||
|
||
def test_googlevideo_probe_host_differs_from_profile_domain(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
|
||
seen_hosts = []
|
||
original = env.probe_https
|
||
|
||
def probe_https(host, addresses):
|
||
seen_hosts.append(host)
|
||
return original(host, addresses)
|
||
|
||
env.probe_https = probe_https
|
||
_report, events = run(env, target="googlevideo.com", probe_host="rr1---sn-abc.googlevideo.com")
|
||
|
||
self.assertEqual(set(seen_hosts), {"rr1---sn-abc.googlevideo.com"})
|
||
self.assertIn("--hostlist-domains=googlevideo.com", events.results[0].apply_lines)
|
||
|
||
def test_www_target_profile_covers_the_whole_site(self) -> None:
|
||
# Профиль на www.googlevideo.com не ловил ни одного видеосервера rr…googlevideo.com.
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
|
||
_report, events = run(env, target="www.googlevideo.com", probe_host="rr1---sn-abc.googlevideo.com")
|
||
|
||
self.assertIn("--hostlist-domains=googlevideo.com", events.results[0].apply_lines)
|
||
env = FakeEnv(network=blocked_unless("good"), candidates=[cand("good")])
|
||
_report, events = run(env, target="https://www.youtube.com/")
|
||
self.assertIn("--hostlist-domains=youtube.com", events.results[0].apply_lines)
|
||
|
||
|
||
class UdpTests(unittest.TestCase):
|
||
def test_games_success_ignores_probes_open_without_bypass(self) -> None:
|
||
# Основная цель (Google STUN) открыта и без обхода, игровой сервер закрыт.
|
||
def network(running, spec):
|
||
if spec.primary:
|
||
return OK
|
||
if spec.name == "Rust A2S" and running == "good":
|
||
return OK
|
||
return TIMEOUT
|
||
|
||
env = FakeEnv(
|
||
network=network,
|
||
candidates=[cand("useless"), cand("good", "udplen")],
|
||
addresses=("9.9.9.9",),
|
||
)
|
||
report, events = run(env, target="stun.l.google.com:19302", scan_protocol="udp_games")
|
||
|
||
by_id = {result.strategy_id: result for result in events.results}
|
||
self.assertFalse(by_id["useless"].success)
|
||
self.assertTrue(by_id["good"].success)
|
||
profile = by_id["good"].apply_lines
|
||
self.assertIn("--ipset=lists/ipset-roblox.txt", profile)
|
||
self.assertIn("--ipset-ip=9.9.9.9", profile)
|
||
|
||
def test_games_profile_intercepts_every_probe_port(self) -> None:
|
||
env = FakeEnv(network=lambda running, spec: OK if running == "good" else TIMEOUT, candidates=[cand("good")])
|
||
_report, events = run(env, target="stun.cloudflare.com:3478", scan_protocol="udp_games")
|
||
|
||
lines = events.results[0].apply_lines
|
||
wf = next(line for line in lines if line.startswith("--wf-udp-out="))
|
||
flt = next(line for line in lines if line.startswith("--filter-udp="))
|
||
# Иначе winws2 не видит ни одного проверяемого пакета и стратегия ни при чём.
|
||
for port in (3478, 28015, 27015, 19132):
|
||
self.assertIn(str(port), wf.split("=", 1)[1].split(","))
|
||
self.assertIn(str(port), flt.split("=", 1)[1].split(","))
|
||
self.assertEqual(wf.split("=", 1)[1], flt.split("=", 1)[1])
|
||
|
||
def test_voice_requires_the_users_target(self) -> None:
|
||
def network(running, spec):
|
||
return OK if running == "good" else TIMEOUT
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("good"), cand("bad", "udplen")], addresses=("8.8.8.8",))
|
||
_report, events = run(env, target="stun.l.google.com:19302", scan_protocol="stun_voice", mode="all")
|
||
|
||
by_id = {result.strategy_id: result for result in events.results}
|
||
self.assertTrue(by_id["good"].success)
|
||
self.assertFalse(by_id["bad"].success)
|
||
self.assertIn("--filter-l7=stun,discord", by_id["good"].apply_lines)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
|
||
|
||
class HonestCheckTests(unittest.TestCase):
|
||
"""Честная проверка: объём, одна прощённая тишина, контроль двумя пробами."""
|
||
|
||
def test_volume_block_is_found_without_bypass_and_judges_every_strategy(self) -> None:
|
||
# Короткая страница открывается всегда, но без «good» соединение замирает на объёме.
|
||
env = FakeEnv(
|
||
network=lambda running, n: OK,
|
||
candidates=[cand("bad"), cand("good")],
|
||
volume=lambda running, address: rules.VOLUME_OK if running == "good" else rules.VOLUME_CUT,
|
||
)
|
||
report, events = run(env)
|
||
|
||
self.assertFalse(report.baseline_accessible)
|
||
self.assertEqual(events.questions, [])
|
||
by_id = {result.strategy_id: result for result in events.results}
|
||
self.assertEqual(by_id["bad"].verdict, rules.VERDICT_FAILED)
|
||
self.assertIn("объёму", by_id["bad"].error)
|
||
self.assertEqual(by_id["good"].verdict, rules.VERDICT_WORKING)
|
||
# Каждая попытка «good» и проверка без обхода после неё судятся объёмом.
|
||
self.assertGreaterEqual(sum(1 for running, _a in env.volume_calls if running == "good"), 3)
|
||
self.assertIn(None, [running for running, _a in env.volume_calls[-2:]])
|
||
|
||
def test_short_answer_under_strategy_is_topped_up_once(self) -> None:
|
||
env = FakeEnv(
|
||
network=blocked_unless("cut", "good"),
|
||
candidates=[cand("cut"), cand("good")],
|
||
volume=lambda running, address: rules.VOLUME_CUT if running == "cut" else rules.VOLUME_OK,
|
||
)
|
||
_report, events = run(env)
|
||
|
||
by_id = {result.strategy_id: result for result in events.results}
|
||
self.assertEqual(by_id["cut"].verdict, rules.VERDICT_FAILED)
|
||
self.assertEqual(by_id["good"].verdict, rules.VERDICT_WORKING)
|
||
self.assertEqual(by_id["good"].raw_data["volume"], rules.VOLUME_OK)
|
||
# Для обычной блокировки объём добирается один раз, а не в каждой попытке
|
||
# (адреса 1.1.1.10 и 1.1.1.11 — одна подсеть, проверяется один).
|
||
self.assertEqual(sum(1 for running, _a in env.volume_calls if running == "good"), 1)
|
||
|
||
def test_unknown_volume_is_named_in_the_log(self) -> None:
|
||
env = FakeEnv(
|
||
network=blocked_unless("good"),
|
||
candidates=[cand("good")],
|
||
volume=lambda running, address: rules.VOLUME_UNKNOWN,
|
||
)
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
|
||
self.assertTrue(any("объём не проверен" in line for line in events.logs))
|
||
|
||
def test_one_silent_failure_after_success_is_forgiven(self) -> None:
|
||
def network(running, number):
|
||
if running == "good":
|
||
return TIMEOUT if number == 1 else OK
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("good")])
|
||
_report, events = run(env)
|
||
|
||
result = events.results[0]
|
||
self.assertEqual(result.verdict, rules.VERDICT_WORKING)
|
||
self.assertEqual((result.attempts_ok, result.attempts_total), (3, 4))
|
||
|
||
def test_explicit_failure_after_success_is_unstable(self) -> None:
|
||
def network(running, number):
|
||
if running == "flaky":
|
||
return RESET if number == 1 else OK
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("flaky")])
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_UNSTABLE)
|
||
self.assertEqual(env.calls["flaky"], 2)
|
||
|
||
def test_first_silent_failure_is_final_and_cheap(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: TIMEOUT if running == "a" else RESET, candidates=[cand("a")])
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_FAILED)
|
||
self.assertEqual(env.calls["a"], 1)
|
||
|
||
def test_target_open_only_once_is_treated_as_blocked(self) -> None:
|
||
def network(running, number):
|
||
if running is None:
|
||
return OK if number == 0 else RESET
|
||
return OK if running == "good" else RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("good")])
|
||
report, events = run(env)
|
||
|
||
self.assertEqual(events.questions, [])
|
||
self.assertFalse(report.baseline_accessible)
|
||
self.assertTrue(any("через раз" in line for line in events.logs))
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
|
||
|
||
def test_pass_control_tries_twice(self) -> None:
|
||
def network(running, number):
|
||
if running == "pass":
|
||
return RESET if number == 0 else OK
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("a")])
|
||
report, events = run(env, FakeEvents(answer=False))
|
||
|
||
self.assertEqual(env.calls["pass"], 2)
|
||
self.assertEqual(len(events.questions), 1)
|
||
self.assertTrue(report.baseline_accessible)
|
||
|
||
def test_recheck_without_bypass_tries_twice(self) -> None:
|
||
state = {"after": 0}
|
||
|
||
def network(running, number):
|
||
if running == "lucky":
|
||
state["after"] = 0
|
||
return OK
|
||
if running is None and number > 0:
|
||
state["after"] += 1
|
||
return OK if state["after"] == 2 else RESET
|
||
return RESET
|
||
|
||
env = FakeEnv(network=network, candidates=[cand("lucky")])
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_NOT_COUNTED)
|
||
|
||
def test_network_lost_during_confirmation_rechecks_strategy(self) -> None:
|
||
state = {"down": True}
|
||
|
||
def network(running, number):
|
||
if running == "good" and number == 1 and state["down"]:
|
||
state["down"] = False
|
||
return TIMEOUT
|
||
return OK if running == "good" else RESET
|
||
|
||
# 1) перед подбором сеть есть; 2) после тишины на подтверждении — нет; 3) вернулась.
|
||
env = FakeEnv(network=network, candidates=[cand("good")], control=[True, False, True])
|
||
_report, events = run(env)
|
||
|
||
self.assertEqual(len(events.results), 1)
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
|
||
self.assertGreater(env.slept, 0)
|
||
|
||
def test_full_flow_strategy_gets_incoming_packets(self) -> None:
|
||
env = FakeEnv(network=blocked_unless("rec"), candidates=[cand("rec", "tlsrec")])
|
||
_report, events = run(env)
|
||
|
||
result = events.results[0]
|
||
self.assertTrue(result.success)
|
||
self.assertIn("--wf-tcp-in=443", result.apply_lines)
|
||
self.assertIn("--in-range=a", result.apply_lines)
|
||
self.assertNotIn("--out-range=-d8", result.apply_lines)
|
||
|
||
|
||
class FamilyOrderTests(unittest.TestCase):
|
||
"""Семейства приёмов, соседи удачной стратегии, история после смены фильтра."""
|
||
|
||
def test_modifiers_do_not_make_their_own_family(self) -> None:
|
||
from blockcheck.strategy_search.ordering import family_key
|
||
|
||
plain = Candidate("a", "a", "--lua-desync=multidisorder:pos=1")
|
||
boosted = Candidate("b", "b", "--lua-desync=send:repeats=2\n--lua-desync=syndata\n--lua-desync=multidisorder:pos=1")
|
||
md5 = Candidate("c", "c", "--lua-desync=fake:blob=tls1:tcp_md5:tcp_ts=-1000")
|
||
self.assertEqual(family_key(plain), family_key(boosted))
|
||
self.assertEqual(family_key(md5), ("fake", "md5"))
|
||
|
||
def test_big_family_gets_more_early_places_than_a_single(self) -> None:
|
||
from blockcheck.strategy_search.ordering import interleave_by_technique, main_technique
|
||
|
||
fakes = [Candidate(f"f{i}", "f", f"--lua-desync=fake:n={i}") for i in range(16)]
|
||
singles = [Candidate(f"s{i}", "s", f"--lua-desync=custom{i}") for i in range(8)]
|
||
first = interleave_by_technique([*singles, *fakes])[:8]
|
||
self.assertGreaterEqual(sum(1 for item in first if main_technique(item) == "fake"), 2)
|
||
self.assertLess(sum(1 for item in first if main_technique(item) == "fake"), 8)
|
||
|
||
def test_siblings_of_unstable_strategy_are_checked_next(self) -> None:
|
||
def network(running, number):
|
||
if running == "fam1":
|
||
return OK if number == 0 else RESET # открылась один раз — нестабильно
|
||
return OK if running == "fam3" else RESET
|
||
|
||
candidates = [
|
||
cand("fam1", "fake"),
|
||
cand("other1", "multisplit"),
|
||
cand("other2", "hostfakesplit"),
|
||
cand("other3", "multidisorder"),
|
||
cand("fam2", "fake"),
|
||
cand("fam3", "fake"),
|
||
]
|
||
env = FakeEnv(network=network, candidates=candidates)
|
||
_report, events = run(env)
|
||
|
||
order = [args_id(args) for args in events.started_args if args_id(args) not in (None, "pass")]
|
||
self.assertEqual(order[:3], ["fam1", "fam2", "fam3"])
|
||
self.assertEqual(len(order), len(set(order)))
|
||
self.assertTrue(any("соседи" in line for line in events.logs))
|
||
|
||
def test_confirmed_then_failed_strategy_leaves_the_head(self) -> None:
|
||
from blockcheck.strategy_search.ordering import order_candidates
|
||
|
||
items = [cand("a"), cand("b", "multisplit"), cand("c", "hostfakesplit")]
|
||
ordered = order_candidates(items, confirmed_ids=["c", "b"], failed_at={"c": 999_000.0}, now=1_000_000.0)
|
||
self.assertEqual([item.strategy_id for item in ordered][0], "b")
|
||
self.assertEqual([item.strategy_id for item in ordered][-1], "c")
|
||
|
||
def test_record_results_drops_failed_from_confirmed(self) -> None:
|
||
from unittest import mock
|
||
|
||
from blockcheck.strategy_search import history
|
||
|
||
store = {"strategy_history": {"k": {"confirmed": ["a", "b"], "failed": {}}}}
|
||
with mock.patch("settings.store.update_blockcheck_settings", side_effect=lambda mutate: mutate(store)):
|
||
history.record_results("k", confirmed=["c"], failed=["a"], now=5.0)
|
||
entry = store["strategy_history"]["k"]
|
||
self.assertEqual(entry["confirmed"], ["c", "b"])
|
||
self.assertEqual(entry["failed"], {"a": 5.0})
|
||
|
||
def test_continuation_does_not_loop_when_next_batch_is_already_seen(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand(f"x{i}") for i in range(35)])
|
||
report, _events = run(env)
|
||
|
||
self.assertEqual(report.total_tested, 35)
|
||
|
||
|
||
class HistoryProgressTests(unittest.TestCase):
|
||
def test_history_is_saved_along_the_way(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand(f"x{i}") for i in range(25)])
|
||
run(env)
|
||
|
||
self.assertEqual([len(saved[2]) for saved in env.history_saves], [10, 10, 5])
|
||
|
||
|
||
class StopDuringStartTests(unittest.TestCase):
|
||
def test_stop_while_winws2_starts_is_not_a_crash(self) -> None:
|
||
events = FakeEvents()
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand("a"), cand("b")])
|
||
original = env.start_session
|
||
|
||
def start_session(config_text):
|
||
session = original(config_text)
|
||
if session.strategy == "a":
|
||
def start():
|
||
events.cancel_after_results = 0 # «Стоп» нажали, пока winws2 запускался
|
||
return SessionStart(False, output_tail="остановлено")
|
||
|
||
session.start = start
|
||
return session
|
||
|
||
env.start_session = start_session
|
||
report, _events = run(env, events)
|
||
|
||
self.assertTrue(report.cancelled)
|
||
self.assertEqual(events.results, [])
|
||
self.assertEqual(env.recovered, 0)
|
||
self.assertEqual(env.history_saves, [])
|
||
|
||
|
||
class TimeLeftTests(unittest.TestCase):
|
||
def test_progress_names_the_time_left(self) -> None:
|
||
env = FakeEnv(network=blocked_unless(), candidates=[cand(f"x{i}") for i in range(10)])
|
||
original = env.probe_https
|
||
|
||
def slow_probe(host, addresses):
|
||
env.clock += 20.0 # каждая стратегия — около 20 с
|
||
return original(host, addresses)
|
||
|
||
env.probe_https = slow_probe
|
||
_report, events = run(env)
|
||
|
||
texts = [text for text in events.stage_texts if text.startswith("Проверяем")]
|
||
self.assertNotIn("осталось", texts[0])
|
||
self.assertTrue(any("осталось ≈ 2 мин" in text for text in texts), texts)
|
||
|
||
|
||
class HttpOnlySiteTests(unittest.TestCase):
|
||
"""Сайт без HTTPS: порт 443 молчит, обычный HTTP закрыт фильтром — подбор сам идёт по HTTP."""
|
||
|
||
STUB = ProbeOutcome(rules.PROBE_BLOCKED, "заглушка провайдера: код 451", 40.0, "http")
|
||
|
||
def test_http_only_site_is_searched_over_port_80(self) -> None:
|
||
env = FakeEnv(
|
||
network=lambda running, n: NO_CONNECT,
|
||
candidates=[cand("tls")],
|
||
port80_open=True,
|
||
http=lambda running, n: OK if running == "web" else self.STUB,
|
||
)
|
||
env.load_candidates = lambda protocol: [cand("web")] if protocol == "tcp_http" else [cand("tls")]
|
||
report, events = run(env, target="old-site.ru")
|
||
|
||
self.assertEqual(report.fatal_error, "")
|
||
self.assertEqual(report.scan_protocol, "tcp_http")
|
||
result = events.results[0]
|
||
self.assertEqual((result.strategy_id, result.verdict), ("web", rules.VERDICT_WORKING))
|
||
self.assertIn("--filter-tcp=80", result.apply_lines)
|
||
self.assertIn("--wf-tcp-out=80", result.apply_lines)
|
||
self.assertIn("--hostlist-domains=old-site.ru", result.apply_lines)
|
||
self.assertTrue(env.history_saves[-1][0].startswith("tcp_http|"))
|
||
|
||
def test_port_80_answering_honestly_is_not_a_block(self) -> None:
|
||
env = FakeEnv(
|
||
network=lambda running, n: NO_CONNECT,
|
||
candidates=[cand("tls")],
|
||
port80_open=True,
|
||
http=lambda running, n: OK,
|
||
)
|
||
report, events = run(env, FakeEvents(answer=False), target="old-site.ru")
|
||
|
||
self.assertTrue(report.baseline_accessible)
|
||
self.assertEqual(report.fatal_error, "")
|
||
self.assertIn("без HTTPS", events.questions[0])
|
||
|
||
def test_closed_port_80_keeps_the_address_block_explanation(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: NO_CONNECT, candidates=[cand("tls")], port80_open=False)
|
||
report, _events = run(env)
|
||
|
||
self.assertIn("не отвечает", report.fatal_error)
|
||
|
||
|
||
class QuicSearchTests(unittest.TestCase):
|
||
"""Сайт по TCP открыт, а QUIC режут по имени — подбор сам идёт для QUIC."""
|
||
|
||
def test_one_lost_quic_packet_after_success_is_forgiven(self) -> None:
|
||
# У QUIC провал — всегда тишина: одна потеря после первого успеха
|
||
# не делает рабочую стратегию «нестабильной».
|
||
def quic(running, number):
|
||
if running != "qfake":
|
||
return TIMEOUT
|
||
return TIMEOUT if number == 1 else OK
|
||
|
||
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("tls")], quic_cut_by_name=True, quic=quic)
|
||
env.load_candidates = lambda protocol: [cand("qfake")] if protocol == "udp_quic" else [cand("tls")]
|
||
_report, events = run(env, target="www.youtube.com")
|
||
|
||
self.assertEqual(events.results[0].verdict, rules.VERDICT_WORKING)
|
||
|
||
def test_quic_cut_by_name_is_searched_without_a_question(self) -> None:
|
||
env = FakeEnv(
|
||
network=lambda running, n: OK,
|
||
candidates=[cand("tls")],
|
||
quic_cut_by_name=True,
|
||
quic=lambda running, n: OK if running == "qfake" else TIMEOUT,
|
||
)
|
||
env.load_candidates = lambda protocol: [cand("qfake")] if protocol == "udp_quic" else [cand("tls")]
|
||
report, events = run(env, target="www.youtube.com")
|
||
|
||
self.assertEqual(events.questions, [])
|
||
self.assertEqual(report.scan_protocol, "udp_quic")
|
||
self.assertFalse(report.baseline_accessible)
|
||
result = events.results[0]
|
||
self.assertEqual((result.strategy_id, result.verdict), ("qfake", rules.VERDICT_WORKING))
|
||
self.assertIn("--filter-udp=443", result.apply_lines)
|
||
self.assertIn("--payload=quic_initial", result.apply_lines)
|
||
self.assertIn("--hostlist-domains=youtube.com", result.apply_lines)
|
||
|
||
def test_quic_that_works_leaves_the_usual_question(self) -> None:
|
||
env = FakeEnv(network=lambda running, n: OK, candidates=[cand("tls")], quic_cut_by_name=False)
|
||
report, events = run(env, FakeEvents(answer=False))
|
||
|
||
self.assertEqual(len(events.questions), 1)
|
||
self.assertTrue(report.baseline_accessible)
|
||
self.assertEqual(report.scan_protocol, "tcp_https")
|