All checks were successful
Zapret source guards / validate (push) Successful in 12s
Адреса и шифрованные имена проверены с win10: обычный DNS, DoT и DoH отвечают, адреса сайтов ИИ подменены, остальные имена настоящие. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
263 lines
12 KiB
Python
263 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
from dns import runtime, winapi
|
||
from dns.adapters import DnsAdapter
|
||
from dns.address_migration import migrate_outdated_dns_addresses
|
||
from dns.dns_providers import DNS_PROVIDERS, doh_templates
|
||
from dns.reachability import ServerReach
|
||
from dns.state import DnsState, LineCheck
|
||
|
||
ETH = "{00000000-0000-0000-0000-000000000009}"
|
||
WIFI = "{00000000-0000-0000-0000-000000000012}"
|
||
|
||
|
||
class _FakeWinApi:
|
||
def __init__(self, *, doh: bool = False, fail: set[str] = frozenset()) -> None:
|
||
self.doh = doh
|
||
self.fail = set(fail)
|
||
self.writes: list[tuple[str, tuple[str, ...], bool, object]] = []
|
||
self.flushes = 0
|
||
|
||
def write_dns(self, guid, servers, *, ipv6, doh_templates=None):
|
||
if guid in self.fail:
|
||
raise winapi.DnsWinApiError("SetInterfaceDnsSettings: ошибка Windows 5 — Отказано в доступе.")
|
||
self.writes.append((guid, tuple(servers), ipv6, doh_templates))
|
||
|
||
def flush_resolver_cache(self):
|
||
self.flushes += 1
|
||
return True
|
||
|
||
def is_doh_supported(self):
|
||
return self.doh
|
||
|
||
def patches(self):
|
||
return [
|
||
patch.object(winapi, name, getattr(self, name))
|
||
for name in ("write_dns", "flush_resolver_cache", "is_doh_supported")
|
||
]
|
||
|
||
|
||
KNOWN = DnsState(
|
||
adapters=(
|
||
DnsAdapter(ETH, "Ethernet", "", "ethernet", True, True),
|
||
DnsAdapter(WIFI, "Беспроводная сеть", "", "wifi", True, False),
|
||
)
|
||
)
|
||
|
||
|
||
class DnsRuntimeTests(unittest.TestCase):
|
||
reach = staticmethod(lambda ipv4, ipv6, templates=None: ServerReach())
|
||
|
||
def test_server_that_does_not_answer_is_not_applied(self) -> None:
|
||
"""Заблокированный провайдером сервер не ставится: с ним не осталось бы DNS."""
|
||
fake = _FakeWinApi()
|
||
self.reach = lambda ipv4, ipv6, templates=None: ServerReach(blocked="Сервер 144.31.82.230 не отвечает из вашей сети")
|
||
|
||
result = self._run(fake, runtime.apply_dns, [ETH, WIFI], ["144.31.82.230"], [])
|
||
|
||
self.assertFalse(result.success)
|
||
self.assertEqual((fake.writes, fake.flushes), ([], 0))
|
||
self.assertEqual((result.affected_count, result.total_count), (0, 2))
|
||
self.assertIn("не отвечает из вашей сети", result.message)
|
||
|
||
def _run(self, fake: _FakeWinApi, func, *args, custom_servers=()):
|
||
patchers = [
|
||
*fake.patches(),
|
||
patch.object(runtime, "load_state", return_value=KNOWN),
|
||
patch.object(runtime, "load_custom_servers", return_value=list(custom_servers)),
|
||
# В сеть тесты не ходят: сервер считается отвечающим, если тест не говорит иного.
|
||
patch.object(runtime, "check_servers", self.reach),
|
||
]
|
||
for item in patchers:
|
||
item.start()
|
||
self.addCleanup(item.stop)
|
||
return func(*args)
|
||
|
||
def test_closed_doh_is_carried_to_the_result_only_when_dns_is_set(self) -> None:
|
||
self.reach = lambda ipv4, ipv6, templates=None: ServerReach(doh_closed=True)
|
||
|
||
result = self._run(_FakeWinApi(doh=True), runtime.apply_dns, [ETH], ["9.9.9.9"], [])
|
||
self.assertEqual((result.success, result.doh_closed), (True, True))
|
||
|
||
nothing_set = self._run(_FakeWinApi(doh=True), runtime.apply_dns, ["{00000000-0000-0000-0000-00000000dead}"], ["9.9.9.9"], [])
|
||
self.assertFalse(nothing_set.doh_closed)
|
||
|
||
def test_apply_writes_both_families_then_flushes_cache(self) -> None:
|
||
fake = _FakeWinApi()
|
||
|
||
result = self._run(fake, runtime.apply_dns, [ETH, WIFI, ETH], ["1.1.1.1", "1.0.0.1"], [])
|
||
|
||
self.assertEqual(
|
||
fake.writes,
|
||
[
|
||
(ETH, ("1.1.1.1", "1.0.0.1"), False, None),
|
||
(ETH, (), True, None),
|
||
(WIFI, ("1.1.1.1", "1.0.0.1"), False, None),
|
||
(WIFI, (), True, None),
|
||
],
|
||
)
|
||
self.assertEqual(fake.flushes, 1)
|
||
self.assertTrue(result.success)
|
||
self.assertEqual((result.affected_count, result.total_count), (2, 2))
|
||
|
||
def test_windows_11_gets_doh_templates_for_known_servers(self) -> None:
|
||
fake = _FakeWinApi(doh=True)
|
||
|
||
self._run(fake, runtime.reset_to_auto, [ETH])
|
||
|
||
self.assertEqual([write[1] for write in fake.writes], [(), ()])
|
||
self.assertEqual(fake.writes[0][3], doh_templates())
|
||
|
||
def test_custom_server_addresses_get_their_own_doh_template(self) -> None:
|
||
fake = _FakeWinApi(doh=True)
|
||
custom = [
|
||
{"id": "a", "name": "Мой", "ipv4": ["203.0.113.5"], "ipv6": ["2001:db8::5"], "doh": "https://dns.example.com/dns-query"},
|
||
{"id": "b", "name": "Без шифрования", "ipv4": ["203.0.113.9"], "ipv6": [], "doh": ""},
|
||
# Свой сервер с адресом сервера программы шаблон программы не перебивает.
|
||
{"id": "c", "name": "Подмена", "ipv4": ["1.1.1.1"], "ipv6": [], "doh": "https://other.example/dns-query"},
|
||
]
|
||
|
||
self._run(fake, runtime.apply_dns, [ETH], ["203.0.113.5"], ["2001:db8::5"], custom_servers=custom)
|
||
|
||
templates = fake.writes[0][3]
|
||
self.assertEqual(templates["203.0.113.5"], "https://dns.example.com/dns-query")
|
||
self.assertEqual(templates["2001:db8::5"], "https://dns.example.com/dns-query")
|
||
self.assertNotIn("203.0.113.9", templates)
|
||
self.assertEqual(templates["1.1.1.1"], doh_templates()["1.1.1.1"])
|
||
self.assertEqual(fake.writes[1][3], templates)
|
||
|
||
def test_windows_without_doh_gets_no_templates_even_for_custom_servers(self) -> None:
|
||
fake = _FakeWinApi(doh=False)
|
||
custom = [{"id": "a", "name": "Мой", "ipv4": ["203.0.113.5"], "ipv6": [], "doh": "https://dns.example.com/dns-query"}]
|
||
|
||
self._run(fake, runtime.apply_dns, [ETH], ["203.0.113.5"], [], custom_servers=custom)
|
||
|
||
self.assertIsNone(fake.writes[0][3])
|
||
|
||
def test_state_carries_custom_servers_from_settings(self) -> None:
|
||
custom = [{"id": "a", "name": "Мой", "ipv4": ["203.0.113.5"], "ipv6": [], "doh": ""}]
|
||
route = type("Route", (), {"has_ipv6": False})()
|
||
with patch.object(winapi, "list_interfaces", return_value=[]), patch.object(
|
||
winapi, "internet_route", return_value=route
|
||
), patch.object(winapi, "is_doh_supported", return_value=True), patch.object(
|
||
runtime, "_local_proxy_mode", return_value=""
|
||
), patch("settings.store.get_custom_dns_servers", return_value=custom):
|
||
state = runtime.load_state()
|
||
|
||
self.assertEqual(state.custom_servers, tuple(custom))
|
||
|
||
def test_unreadable_settings_give_no_custom_servers(self) -> None:
|
||
with patch("settings.store.get_custom_dns_servers", side_effect=RuntimeError("база занята")), patch.object(
|
||
runtime, "log"
|
||
):
|
||
self.assertEqual(runtime.load_custom_servers(), [])
|
||
|
||
def test_failed_adapter_is_reported_by_name(self) -> None:
|
||
fake = _FakeWinApi(fail={WIFI})
|
||
|
||
result = self._run(fake, runtime.apply_dns, [ETH, WIFI], ["9.9.9.9"], ["2620:fe::fe"])
|
||
|
||
self.assertFalse(result.success)
|
||
self.assertEqual((result.affected_count, result.total_count), (1, 2))
|
||
self.assertIn("«Беспроводная сеть»: SetInterfaceDnsSettings: ошибка Windows 5", result.message)
|
||
|
||
def test_unknown_adapter_is_never_written(self) -> None:
|
||
# Windows молча принимает любой GUID и заводит под него ветку реестра.
|
||
fake = _FakeWinApi()
|
||
ghost = "{00000000-0000-0000-0000-000000000000}"
|
||
|
||
result = self._run(fake, runtime.apply_dns, [ghost], ["1.1.1.1"], [])
|
||
|
||
self.assertEqual(fake.writes, [])
|
||
self.assertEqual(fake.flushes, 0)
|
||
self.assertFalse(result.success)
|
||
self.assertIn("не найден", result.message)
|
||
|
||
def test_warmed_state_is_given_out_once(self) -> None:
|
||
state = DnsState()
|
||
with patch.object(runtime, "load_page_state", return_value=state):
|
||
runtime.warm_state()
|
||
|
||
self.assertIs(runtime.consume_warmed_state(), state)
|
||
self.assertIsNone(runtime.consume_warmed_state())
|
||
|
||
def test_page_state_adds_the_last_server_check_on_this_line(self) -> None:
|
||
line = LineCheck(time="2026-10-12T21:05:00", cards=(("Quad9", "ok", ""),))
|
||
with (
|
||
patch.object(runtime, "load_state", return_value=KNOWN),
|
||
patch("dns.line_check.load_line_check", return_value=line),
|
||
):
|
||
state = runtime.load_page_state()
|
||
|
||
self.assertEqual((state.adapters, state.line_check), (KNOWN.adapters, line))
|
||
|
||
def test_migration_replaces_only_outdated_static_addresses(self) -> None:
|
||
fake = _FakeWinApi(doh=True)
|
||
adapters = (
|
||
DnsAdapter(ETH, "Ethernet", "", "ethernet", True, True, static_ipv4=("176.99.11.77", "80.78.247.254"), static_ipv6=("2a00:ab00:1233:26::50",)),
|
||
DnsAdapter(WIFI, "Wi-Fi", "", "wifi", True, False, static_ipv4=("1.1.1.1",)),
|
||
)
|
||
|
||
with patch.object(runtime, "adapters_with_static_dns", return_value=adapters):
|
||
changes = self._run(fake, migrate_outdated_dns_addresses)
|
||
|
||
# Xbox DNS закрыл сервисы ИИ: его адреса переходят на DNS-AI, чужой 1.1.1.1 не трогают.
|
||
self.assertEqual(
|
||
[write[:3] for write in fake.writes],
|
||
[
|
||
(ETH, ("192.144.59.14", "186.246.49.127"), False),
|
||
(ETH, ("2a0d:8480:0:67c::14",), True),
|
||
],
|
||
)
|
||
# DNS-AI принимает только шифрованные запросы: адреса пишутся вместе с его шаблоном DoH.
|
||
for write in fake.writes:
|
||
self.assertEqual(write[3]["192.144.59.14"], "https://dns.dns-ai.ru/dns-query")
|
||
self.assertEqual(len(changes), 2)
|
||
self.assertEqual(fake.flushes, 1)
|
||
|
||
def test_migration_to_encrypted_only_server_is_skipped_without_doh(self) -> None:
|
||
# Без DoH система не сможет спросить DNS-AI: адаптер остался бы без DNS.
|
||
fake = _FakeWinApi(doh=False)
|
||
adapters = (DnsAdapter(ETH, "Ethernet", "", "ethernet", True, True, static_ipv4=("111.88.96.54", "111.88.96.55")),)
|
||
|
||
with patch.object(runtime, "adapters_with_static_dns", return_value=adapters):
|
||
changes = self._run(fake, migrate_outdated_dns_addresses)
|
||
|
||
self.assertEqual((fake.writes, changes, fake.flushes), ([], [], 0))
|
||
|
||
|
||
class DnsProviderCatalogTests(unittest.TestCase):
|
||
def test_every_provider_with_doh_has_templates_for_all_addresses(self) -> None:
|
||
templates = doh_templates()
|
||
for group in DNS_PROVIDERS.values():
|
||
for name, data in group.items():
|
||
for address in (*data["ipv4"], *data["ipv6"]):
|
||
with self.subTest(provider=name, address=address):
|
||
if data.get("doh"):
|
||
self.assertEqual(templates[address], data["doh"])
|
||
else:
|
||
# У сервера без DoH шаблона нет: Windows не шифрует запросы к нему.
|
||
self.assertNotIn(address, templates)
|
||
|
||
def test_hosts_dns_services_are_available_as_dns_servers(self) -> None:
|
||
ai = DNS_PROVIDERS["Для ИИ"]
|
||
|
||
self.assertEqual(ai["AstraCat"]["ipv4"], ["135.106.217.200", "135.106.197.22"])
|
||
self.assertEqual(ai["GeoHide"]["ipv4"], ["193.233.112.67", "193.233.112.68"])
|
||
# Узлы GeoHide и вернувшийся Malw.link (проверено с win10 2026-10-20).
|
||
self.assertEqual(ai["GeoHide EU"]["ipv4"], ["5.175.161.3", "5.175.161.40"])
|
||
self.assertEqual(ai["GeoHide US"]["ipv4"], ["192.255.159.240", "192.255.159.241"])
|
||
self.assertEqual(ai["Malw.link"]["ipv4"], ["179.254.166.171", "164.5.224.81"])
|
||
self.assertEqual(ai["Bezmezhau"]["doh"], "https://dns.bezmezhau.com/dns-query")
|
||
# Серверы, которые перестали отвечать, из списка убраны.
|
||
self.assertNotIn("Xbox DNS (old)", ai)
|
||
# Xbox DNS закрыл сервисы ИИ и отвечает на них пустым адресом.
|
||
self.assertNotIn("Xbox DNS", ai)
|
||
self.assertNotIn("Xbox DNS v2", ai)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|