zapret-kvn/xray_fluent/engines/zapret/endpoint.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

185 lines
6.7 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.

"""Where the selected VPN server lives on the wire: kind, transport, hosts, ports.
Zapret applies its per-server rule by exact IP + transport + port, so this
module answers two questions for a node: which of the three server kinds it is
(each has its own strategy) and which addresses the rule must match. Resolving
the hosts is blocking DNS — callers run :func:`resolve_endpoint` in a worker.
"""
from __future__ import annotations
import ipaddress
from dataclasses import dataclass
from typing import Any, Iterable, Literal
from ...diagnostics.runtime_logging import register_server_aliases
from ...network.bootstrap_dns import resolve_bootstrap
ServerKind = Literal["tcp", "quic", "wireguard"]
Transport = Literal["tcp", "udp"]
KIND_TITLES: dict[str, str] = {
"tcp": "TCP-серверы",
"quic": "QUIC-серверы",
"wireguard": "WireGuard",
}
KIND_PROTOCOLS: dict[str, str] = {
"tcp": "VLESS, VMess, Trojan, Shadowsocks, SOCKS, HTTP",
"quic": "Hysteria, Hysteria2, TUIC, VLESS по QUIC/KCP",
"wireguard": "WireGuard и AmneziaWG",
}
KIND_TRANSPORT: dict[str, Transport] = {"tcp": "tcp", "quic": "udp", "wireguard": "udp"}
_TCP_PROTOCOLS = frozenset({
"vless", "vmess", "trojan", "shadowsocks", "ss", "socks", "socks5",
"http", "https",
})
_QUIC_PROTOCOLS = frozenset({"hysteria", "hysteria2", "hy2", "tuic"})
_WIREGUARD_PROTOCOLS = frozenset({"wireguard", "awg"})
_UDP_STREAMS = frozenset({"quic", "kcp", "mkcp"})
@dataclass(frozen=True, slots=True)
class ServerEndpoint:
kind: ServerKind
hosts: tuple[str, ...]
ports: tuple[str, ...]
@property
def transport(self) -> Transport:
return KIND_TRANSPORT[self.kind]
@property
def port_filter(self) -> str:
return ",".join(self.ports)
@dataclass(frozen=True, slots=True)
class ResolvedEndpoint:
endpoint: ServerEndpoint
ips: tuple[str, ...]
def _port_token(value: Any) -> str:
text = str(value or "").strip().replace(":", "-")
if not text:
return ""
start_text, _, end_text = text.partition("-")
try:
start, end = int(start_text), int(end_text or start_text)
except (TypeError, ValueError):
return ""
if not (1 <= start <= end <= 65535):
return ""
return str(start) if start == end else f"{start}-{end}"
def _ports(values: Iterable[Any], fallback: Any = 0) -> tuple[str, ...]:
tokens = {_port_token(value) for value in values}
tokens.discard("")
if not tokens:
token = _port_token(fallback)
if token:
tokens.add(token)
return tuple(sorted(tokens, key=lambda item: int(item.split("-", 1)[0])))
def _host(value: Any) -> str:
return str(value or "").strip().strip("[]").rstrip(".")
def endpoint_for_node(node: object | None) -> ServerEndpoint | None:
"""The remote endpoint a node really connects to, or None if not targetable."""
if node is None:
return None
outbound = getattr(node, "outbound", {})
if not isinstance(outbound, dict):
outbound = {}
scheme = str(getattr(node, "scheme", "") or "").strip().lower()
protocol = str(outbound.get("protocol") or outbound.get("type") or scheme).strip().lower()
node_port = getattr(node, "port", 0) or 0
def is_one_of(protocols: frozenset[str]) -> bool:
return protocol in protocols or scheme in protocols
if is_one_of(_WIREGUARD_PROTOCOLS):
hosts: list[str] = []
port_values: list[Any] = []
for peer in outbound.get("peers") or ():
if isinstance(peer, dict):
if _host(peer.get("address")):
hosts.append(_host(peer.get("address")))
port_values.append(peer.get("port"))
if not hosts and _host(getattr(node, "server", "")):
hosts.append(_host(getattr(node, "server", "")))
ports = _ports(port_values, node_port)
if not hosts or not ports:
return None
return ServerEndpoint("wireguard", tuple(dict.fromkeys(hosts)), ports)
if is_one_of(_QUIC_PROTOCOLS):
host = _host(outbound.get("server") or getattr(node, "server", ""))
hopping = outbound.get("server_ports")
port_values = hopping if isinstance(hopping, list) else [hopping] if hopping else []
ports = _ports([*port_values, outbound.get("server_port")], node_port)
if not host or not ports:
return None
return ServerEndpoint("quic", (host,), ports)
if not is_one_of(_TCP_PROTOCOLS):
return None
stream = outbound.get("streamSettings")
transport = outbound.get("transport")
if isinstance(stream, dict):
network = str(stream.get("network") or "tcp").lower()
elif isinstance(transport, dict):
network = str(transport.get("type") or "tcp").lower()
else:
network = "tcp"
host = _host(getattr(node, "server", ""))
port = node_port
if not host or not port:
# Native sing-box JSON nodes keep the endpoint in the outbound itself.
host = _host(outbound.get("server"))
port = outbound.get("server_port") or 0
ports = _ports([], port)
if not host or not ports:
return None
# VLESS/VMess over QUIC or mKCP travel as UDP — a UDP rule is what matches.
return ServerEndpoint("quic" if network in _UDP_STREAMS else "tcp", (host,), ports)
def _ip_sort_key(value: str) -> tuple[int, str]:
return ipaddress.ip_address(value).version, value
def resolve_host(host: str) -> set[str]:
"""Blocking DNS for one host; a literal IP is returned as is."""
host = _host(host)
if not host:
return set()
try:
return {str(ipaddress.ip_address(host))}
except ValueError:
pass
# Тот же доверенный путь, что и у ядра: DoH по IP без SNI, затем системный
# резолвер, затем запомненный адрес. Одного системного резолвера мало — на
# заблокированное имя он отвечает NXDOMAIN, и правило сервера не строилось.
resolved = set(resolve_bootstrap(host).addresses)
# The server's IPs are masked in logs exactly like its name — register them
# before anything (the rule log line) can print them.
register_server_aliases(host, resolved)
return resolved
def resolve_endpoint(endpoint: ServerEndpoint) -> ResolvedEndpoint:
"""Worker-only: resolve every host of the endpoint afresh."""
ips: set[str] = set()
for host in endpoint.hosts:
ips.update(resolve_host(host))
if not ips:
raise OSError(f"не удалось определить IP: {', '.join(endpoint.hosts)}")
return ResolvedEndpoint(endpoint, tuple(sorted(ips, key=_ip_sort_key)))