zapret-kvn/tests/test_singbox_template_dns_contract.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

165 lines
7.6 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 json
from ipaddress import ip_address
from pathlib import Path
import unittest
from xray_fluent.constants import HYSTERIA_PATH_DEFAULT
from xray_fluent.engines.singbox.runtime_planner import (
parse_singbox_document,
plan_singbox_proxy_runtime,
plan_singbox_runtime,
)
from xray_fluent.importer.link_parser import parse_single
ROOT = Path(__file__).resolve().parents[1]
# Узел перенаправляет любой plaintext DNS туннеля на собственный resolver, кроме
# своих канонических upstream (1.1.1.1/1.0.0.1). Поэтому адрес-указатель обязан
# быть отличным от них.
NODE_DNS_SENTINEL = "8.8.8.8"
def _reachable(servers: dict, tag: str) -> set[str]:
"""Теги-листья, до которых доходит цепочка fallback-резолверов."""
seen: set[str] = set()
pending = [tag]
while pending:
current = pending.pop()
server = servers[current]
children = server.get("servers")
if children:
pending.extend(child for child in children if child not in seen)
continue
seen.add(current)
return seen
class SingboxTemplateDnsContractTests(unittest.TestCase):
def test_all_templates_define_the_runtime_dns_contract(self) -> None:
template_dir = ROOT / "data" / "templates" / "sing-box"
for path in sorted(template_dir.glob("*.json")):
with self.subTest(template=path.name):
payload = json.loads(path.read_text(encoding="utf-8"))
dns = payload["dns"]
servers = {server["tag"]: server for server in dns["servers"]}
self.assertEqual(servers["bootstrap-dns"]["servers"], ["direct-doh", "local-system-dns"])
self.assertEqual(servers["local-system-dns"]["type"], "local")
# Имена внутри туннеля резолвит DNS узла: обычный 53 через
# outbound `proxy`. Узел перехватывает его своим resolver'ом и
# отдаёт управляемые адреса. DoH через туннель этого перехвата
# не видит и возвращает настоящий origin.
self.assertEqual(
servers["proxy-dns"]["servers"],
["vpn-node-dns-udp", "vpn-node-dns-tcp"],
)
self.assertEqual(servers["proxy-dns"]["strategy"], "sequential")
for tag, transport in (
("vpn-node-dns-udp", "udp"),
("vpn-node-dns-tcp", "tcp"),
):
server = servers[tag]
self.assertEqual(server["type"], transport)
self.assertEqual(server["server"], NODE_DNS_SENTINEL)
self.assertEqual(server["server_port"], 53)
self.assertEqual(server["detour"], "proxy")
self.assertNotIn("tls", server)
# Туннельная цепочка не имеет выхода наружу: ни один резолвер,
# достижимый из `proxy-dns`, не ходит мимо outbound `proxy`.
for tag in _reachable(servers, "proxy-dns"):
self.assertEqual(
servers[tag].get("detour"),
"proxy",
msg=f"{tag} резолвит мимо туннеля",
)
# bootstrap остаётся защищённым DoH напрямую: он поднимает
# туннель и обязан пережить подмену DNS провайдером.
group = servers["direct-doh"]
self.assertEqual(group["strategy"], "parallel")
self.assertEqual(len(group["servers"]), 5)
addresses: set[str] = set()
for tag in group["servers"]:
self.assertEqual(servers[tag]["type"], "https")
self.assertIsNone(servers[tag].get("detour"))
self.assertTrue(servers[tag]["tls"]["enabled"])
self.assertNotIn("insecure", servers[tag]["tls"])
# Имя DoH-сервера (dns.google, cloudflare-dns.com, …) ТСПУ
# рвёт по SNI. Адрес — литеральный IP, а имя остаётся только
# для проверки сертификата: в ClientHello его нет.
ip_address(servers[tag]["server"])
self.assertIs(servers[tag]["tls"]["disable_sni"], True)
self.assertTrue(servers[tag]["tls"]["server_name"])
addresses.add(servers[tag]["server"])
# Пять разных адресов: блокировка одного IP (как TCP у 8.8.8.8
# в июле 2026) не оставляет bootstrap без защищённого пути.
self.assertEqual(len(addresses), 5)
self.assertEqual(dns["final"], "proxy-dns")
route = payload["route"]
self.assertEqual(route["default_domain_resolver"], "proxy-dns")
self.assertEqual(route["rules"][0], {"action": "sniff"})
self.assertEqual(
route["rules"][1],
{"protocol": "dns", "action": "hijack-dns"},
)
# DoT приложений обошёл бы и hijack-dns, и resolver узла.
self.assertEqual(
route["rules"][2],
{
"network": "tcp",
"port": 853,
"action": "reject",
"method": "default",
},
)
direct = next(
outbound
for outbound in payload["outbounds"]
if outbound.get("tag") == "direct"
)
self.assertEqual(direct["domain_resolver"], "bootstrap-dns")
def test_discord_template_accepts_a_domain_based_proxy_node(self) -> None:
path = ROOT / "data" / "templates" / "sing-box" / "discord-folder-example.json"
document = parse_singbox_document(path, path.read_text(encoding="utf-8"))
node = parse_single("hy2://secret@example.com:443/?insecure=1&pinSHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
plans = (
plan_singbox_runtime(document, node),
plan_singbox_proxy_runtime(
document,
node,
allowed_proxy_ports={1390, 1391},
),
)
for plan in plans:
with self.subTest(mode="proxy" if plan.socks_port else "tun"):
proxy = next(
outbound
for outbound in plan.singbox_config["outbounds"]
if outbound.get("tag") == "proxy"
)
self.assertEqual(proxy["type"], "socks")
self.assertEqual(proxy["server"], "127.0.0.1")
self.assertNotIn("domain_resolver", proxy)
self.assertEqual(
plan.singbox_config["route"]["rules"][0],
{
"process_path": [str(HYSTERIA_PATH_DEFAULT.resolve())],
"outbound": "direct",
},
)
if __name__ == "__main__":
unittest.main()