zapret-kvn/xray_fluent/network/probe_gate.py
loop-uh 54ac4b0251
Some checks failed
Windows project source guards / test (push) Has been cancelled
Шлюз проверок: по одному серверу с паузой, кандидат замены в бюджете
Требование незаметности: пачка соединений к разным серверам или ровный
ритм проверок выдаёт провайдеру (ТСПУ) весь список подписки.

- network/probe_gate.py — одно место с правилами: пауза прохода «все»
  10–20 с со случайным разбросом, не больше 3 автоматических проверок
  других серверов в скользящий час, журнал всех проверок для тестов.
- «Пинг всех» — одно соединение за раз (было до 8) и пауза шлюза между
  серверами; отмена будит паузу, одиночный пинг после прохода не ждёт.
- «Скорость всех» — пауза 10–20 с между серверами вместо 0,4–1,6 с.
- Умная замена мерит одного кандидата (было до 3 прямых рукопожатий с
  другими серверами каждые ~5 мин) и только в бюджете шлюза.
- Тест-список сетевых вызовов: create_connection/getaddrinfo/urlopen и т. п.
  в новом модуле роняют тест, пока не решено, по какому правилу шлюза
  идёт проверка.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:06:17 +03:00

90 lines
4.1 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.

"""Шлюз проверок: когда и куда приложению можно стучаться самому.
Требование незаметности: проверки не должны выдавать VPN-серверы оборудованию
провайдера (ТСПУ). Обычный пользователь работает с одним сервером; пачка
одновременных рукопожатий к разным адресам или ровный ритм фоновых запросов
выдают весь список подписки. Правила здесь, а не в каждом месте, которое
что-то проверяет:
* текущий сервер — только по сигналу беды;
* другой сервер — только во время замены (кандидат) и в пределах бюджета;
* по кнопке — по одному серверу; между серверами одного прохода «все» —
пауза со случайным разбросом, проход можно отменить;
* каждая проверка записывается в журнал — по нему тесты проверяют, что без
сигнала к нетекущим серверам не ушло ни одного соединения.
"""
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
import random
import threading
import time
from typing import Callable
# Пауза между серверами прохода «Пинг всех» / «Скорость всех»: на проводе это
# отдельные подключения, а не веер.
SWEEP_PAUSE_SEC: tuple[float, float] = (10.0, 20.0)
# Автоматических проверок других серверов (умная замена) — не больше стольких
# за скользящий час, сверх кулдаунов самой замены.
CANDIDATE_PROBES_PER_HOUR = 3
_HOUR_SEC = 3600.0
_JOURNAL_LIMIT = 512
# Виды проверок.
SWEEP = "sweep" # по кнопке, проход по списку
USER = "user" # по кнопке, один сервер
CURRENT = "current" # текущий сервер по сигналу
CANDIDATE = "candidate" # другой сервер во время замены
def sweep_pause(rng: random.Random | None = None) -> float:
low, high = SWEEP_PAUSE_SEC
return (rng or random).uniform(low, high)
@dataclass(frozen=True)
class ProbeRecord:
kind: str
target: str
at: float
class ProbeGate:
"""Бюджет автоматических проверок и журнал всех проверок; потокобезопасен."""
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
self._clock = clock
self._lock = threading.Lock()
self._journal: deque[ProbeRecord] = deque(maxlen=_JOURNAL_LIMIT)
self._candidates: deque[float] = deque()
def record(self, kind: str, target: str) -> None:
with self._lock:
self._journal.append(ProbeRecord(kind, str(target or ""), self._clock()))
def try_candidate(self, target: str) -> bool:
"""Можно ли сейчас автоматически проверить другой сервер; да — записано."""
with self._lock:
now = self._clock()
while self._candidates and now - self._candidates[0] >= _HOUR_SEC:
self._candidates.popleft()
if len(self._candidates) >= CANDIDATE_PROBES_PER_HOUR:
return False
self._candidates.append(now)
self._journal.append(ProbeRecord(CANDIDATE, str(target or ""), now))
return True
def journal(self) -> tuple[ProbeRecord, ...]:
with self._lock:
return tuple(self._journal)
def reset(self) -> None:
with self._lock:
self._journal.clear()
self._candidates.clear()
GATE = ProbeGate()