netsleuth/ARCHITECTURE.ru.md
Andrew Pimenov 84c3dffbce detect captive portals up front, flag off-country speedtest servers
New Phase 0 in diagnose(): three generate_204-style probes run before every
other module, using detect-and-tag rather than short-circuit — a portal is
rarely total (DNS/ICMP often pass while only HTTP is intercepted), and an
aborted run would look identical to a dead link. On detection every later
phase still runs, tagged with a crit finding and a banner at the top of the
report.

Caught live before it shipped: detectportal.firefox.com/success.txt (one of
the original three default check URLs) returns 200 "success" on a genuinely
clean connection, which the generate_204-shaped classifier misread as a
portal redirect. Swapped for a second real generate_204 endpoint instead of
teaching the classifier a second success shape.

Also redesigned the speedtest server geo-sanity check after checking the
bundled Ookla CLI's actual JSON output: its server object never carries
lat/lon (documented schema, not a version gap), so speed.server_other_country
compares server_country against the client's own country instead of a
haversine distance that no tier can actually supply.

Part of plans/zesty-zooming-wilkinson.md (gitignored).
2026-08-12 21:36:54 +03:00

162 lines
42 KiB
Markdown
Raw 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.

# Архитектура
[English](ARCHITECTURE.md)
netsleuth собирает сетевые факты из множества независимых, в основном безключевых источников, сводит их в типизированные структуры, интерпретирует чистыми функциями и на каждый прогон отдаёт два артефакта. Сбор, интерпретация и рендер никогда не смешиваются: модуль либо собирает данные, либо рассуждает об уже собранных — но не одновременно.
## Структура каталогов
```
src/netsleuth/
cli.py Typer-приложение: флаги, порядок фаз, прогресс Rich. Без бизнес-логики.
config.py Загрузчик pydantic-settings. Приоритет: env > .env > config.yaml > default.
models.py Все общие датаклассы плюс to_jsonable() для сериализации.
orchestration.py run_module(): тайминг, таймаут, классификация исключений в ProbeError.
netinfo.py Определение возможностей и локальные факты: интерфейс, шлюз, MTU, резолверы.
iface.py Резолв --interface: имя/IP адаптера -> BindTarget, чистый код, покрыт тестами.
ip_geo.py Цепочка из шести провайдеров, нормализаторы, пополевое слияние.
bgp.py RIPEstat, CAIDA ASRank, Team Cymru через DNS, PeeringDB (кэш на диске).
reputation.py Кэш netset'ов FireHOL и локальный поиск, Shodan InternetDB, декодер DNSBL.
stats.py Чистая статистика RTT: потери, min/avg/max, mdev, джиттер.
traceparse.py Чистые парсеры текста в TraceHop для Windows, Linux и BSD/macOS.
interpret.py Чистый движок вердиктов: пороги -> Finding[], скоринг VPN, оценка bufferbloat.
speed.py Каскадный замер скорости, математика пропускной способности, разбор cfL4, bufferbloat.
exporter.py Рендер JSON и Markdown, атомарная запись в ./logs/. Какие
артефакты писать, решает вызывающий код (cli.py), не этот модуль.
compare.py --compare: сравнение двух сохранённых JSON-отчётов.
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
probes/
latency.py Веер пингов и статистика джиттера/потерь/mdev/p95/p99.
traceroute.py Каскад: mtr --json -> icmp_win -> icmplib -> системный бинарник, плюс опциональный уровень tcp_trace.
icmp_win.py Движок ctypes IcmpSendEcho2 / Icmp6SendEcho2 (только Windows).
dns_leak.py Перечисление резолверов по адаптерам, echo-пробы, детект ECS.
tls_rtt.py --tls: TCP RTT + TLS 1.3-рукопожатие + TTFB через два последовательных соединения.
dns_advanced.py --dns-advanced: системный DNS vs DoH, проверка прозрачного прокси на bogon-IP резолвера.
bgp_path.py --path-diversity: поиск colo по CF-RAY, детект международной петли маршрута.
prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS.
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT.
hop_asn.py Обогащение хопов ASN/именем AS/страной/PTR через DNS Team Cymru, дедуп в рамках прогона.
captive_portal.py Фаза 0: три generate_204-пробы на признаки логин-стены публичного wifi.
```
**Исключения по размеру файла.** Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно:
- `exporter.py` (~910 строк) — JSON и оба языка Markdown — рендеры одной и той же формы отчёта; то, что они вместе, гарантирует, что английский и русский отчёты никогда не разойдутся в том, какие разделы или строки существуют. `render_markdown(report, lang="en"|"ru")` и точечные вызовы `_t(lang, en, ru)` в местах со строками рендерят оба языка из одного пути кода — намеренно, вместо отдельного файла-рендера, который мог бы незаметно разойтись. Файл вырос на пять разделов (TLS, DNS advanced, path diversity, prefix bench, DPI check), не меняя этот принцип.
- `cli.py` (~805 строк) — приложение Typer, все флаги и весь конвейер фаз `diagnose()`; разделение разбора флагов и того, что они переключают, добавило бы только лишний импорт.
- `models.py` (~490 строк) — все общие датаклассы плюс `to_jsonable()`; это намеренно единственное место, определяющее словарь отчёта.
- `interpret.py` (~600 строк) — пороговые полосы, генераторы находок по задержке/маршруту/скорости/TLS/DNS/DPI/anycast/префиксам, скоринг VPN и оценка bufferbloat — всё рассуждает над одной формой `Finding[]`.
- `bgp.py` (~305 строк) — четыре независимых источника (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) плюс общий для них дисковый кэш.
- `ip_geo.py` (~280 строк) — шесть независимых нормализаторов провайдеров плюс пополевое слияние, которое их примиряет.
- `netinfo.py`, `speed.py` (~260 строк каждый) — определение возможностей по ОС и каскад замера скорости из четырёх уровней оба веерятся по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.
- `reputation.py` (~250 строк) — netset'ы FireHOL, Shodan InternetDB и декодирование DNSBL — три несвязанных источника репутации с одной общей формой результата.
- `probes/traceroute.py` (~225 строк) — каскад из четырёх уровней плюс опциональный уровень `tcp_trace`.
## Поток данных
```
+-- netinfo (локальные факты, возможности)
|
cli.py -------+-- цепочка ip_geo ---> внешний IP + ASN [блокирующая фаза: всё дальше зависит от ASN]
|
+-- bgp || reputation || dns_leak || dns_advanced || path_diversity (параллельно)
|
+-- latency || traceroute || tls (параллельно, ограничено семафором)
|
+-- prefix_benchmark (опционально, последовательно — изолирован от веера latency выше)
|
+-- dpi_check (опционально, последовательно — один хост, с ограничением скорости)
|
+-- speed (эксклюзивная фаза)
|
+-- interpret ---> Finding[] + общий вердикт
|
+-- exporter ---> logs/report_<ASN>_<ts>.{md,ru.md,json}
```
Три жёстких ограничения по конкурентности:
1. **Изоляция измерений.** Замер скорости никогда не пересекается с трассировкой, пингами и запросами к API — он держит эксклюзивную блокировку фазы. Единственное намеренное исключение — проба bufferbloat, которая по замыслу работает во время насыщения канала, внутри `speed.py`.
2. **Ограниченный веер трассировок.** Параллельные трассировки к разным целям делят первые хопы и завышают задержки друг друга, поэтому число одновременных трассировок ограничено семафором (`probing.trace_concurrency`, по умолчанию 2).
3. **Бенчмарк префиксов AS никогда не делит фазу с веером latency по эталонным хостам.** Пинг сразу `prefix_bench.max_prefixes` (ограничено 256, по умолчанию 32) хостов исказил бы и собственные результаты, и цифры latency по фиксированным эталонным хостам в то же окно, поэтому это отдельный последовательный опциональный шаг (`--prefix-bench`) после фазы 3, никогда не входящий в `--full`.
## Ключевые решения
**Отказ — это значение, а не поток управления.** Каждый вызов модуля проходит через `run_module()`: он замеряет время, применяет таймаут на модуль, перехватывает всё кроме `CancelledError` и возвращает `ModuleResult` со статусом `ok | partial | failed | skipped`. `asyncio.gather(..., return_exceptions=True)` — вторая страховка на уровне оркестрации. Ни один сбой провайдера не может прервать прогон, и оба формата вывода показывают каждый сбой явно: отчёт без раздела скорости не должен выглядеть так же, как отчёт, где со скоростью всё было хорошо.
**Никогда не требовать повышения прав.** Определение возможностей построено на попытках, а не на предположениях об ОС: сначала непривилегированный datagram ICMP, затем raw ICMP, затем (на Windows) API `IcmpSendEcho2`, затем откат на тайминг TCP-connect и заголовок Cloudflare `Server-Timing: cfL4`. В худшем случае инструмент всё равно выдаёт полный отчёт и один раз, простыми словами, сообщает о деградации и способе её устранить.
**Трассировка на Windows использует Win32 ICMP API, а не текст `tracert.exe`.** `IcmpSendEcho2` из `Iphlpapi.dll` — тот же непривилегированный API, который `tracert.exe` использует внутри. Через `ctypes` он даёт RTT по хопам, определение истёкшего TTL и корректные таймауты без администратора, Npcap и сторонних зависимостей — строго лучше, чем разбор локализованной таблицы из трёх проб.
**Разбор текста — крайняя мера, и он опирается на форму строки.** Когда системный бинарник — единственный вариант, `traceparse.py` смотрит на структуру (ведущее целое хопа, токены RTT, регулярка IP) и никогда на слова в заголовке или подвале: вывод `tracert` зависит от кодовой страницы — cp866 на русской Windows, а не UTF-8. BSD/macOS вдобавок выдаёт несколько IP на хоп и аннотации `!H`/`!N`/`!X`, которых нет в Linux, поэтому три парсера действительно разные.
**Замер задержек не разбирает текст `ping`.** Это означало бы ещё три locale-зависимых парсера без всякой пользы. Каждый `PingResult` помечен использованным методом, потому что «потери» TCP (доля неудачных подключений) и потери ICMP — разные метрики, и молча смешивать их в отчёте нельзя.
**Репутация по умолчанию опирается на источники, которые не узнают, что вы проверяете.** Netset'ы FireHOL скачиваются один раз, кэшируются и сопоставляются локально — ни запроса наружу, ни лимитов, ни раскрытия. Shodan InternetDB для домашнего пользователя гораздо чаще говорит что-то полезное (открытый роутер, забытый проброс порта), чем почтовый блоклист. Классические DNSBL включаются явно флагом `--dnsbl`.
**Ответы DNSBL в диапазоне `127.255.255.0/24` — это ошибки, а не листинг.** Spamhaus возвращает `127.255.255.254` на «вы спросили через публичный резолвер» — а это все, кто сидит на 1.1.1.1 или 8.8.8.8 — и `127.255.255.255` на «превышен лимит». Наивная реализация «любой `127.x.x.x` значит в списке» помечает красным большинство пользователей. Оба случая подаются как *результат недоступен*, но никогда как находка.
**`--interface` привязывается там, где это структурно возможно, отбрасывается там, где невозможно, и никогда не работает молча "без привязки".** Любой httpx-клиент-провайдер умеет привязаться к точному локальному адресу (`local_address=` на транспорте) — дёшево и надёжно. Уровень бинарника Ookla прокидывает `--interface`/`--ip` в сам процесс. `icmp_win` использует `IcmpSendEcho2Ex` (добавляет `SourceAddress`) вместо `IcmpSendEcho2` только когда привязка запрошена, поэтому непривязанный путь не меняется ни на байт. Единственный структурный тупик — Windows `tracert.exe`: у него есть переопределение адреса-источника только для IPv6, никогда для IPv4 (`-S` документирован как IPv6-only в его же справке) — `filter_trace_tiers()` отбрасывает этот уровень, а не даёт ему молча замерить путь по умолчанию ОС под отчётом, заявляющим о принудительном адаптере. Собственное наблюдение `netinfo.collect_local_net()` за реальным маршрутом ОС по умолчанию намеренно не трогается флагом: отчёт должен показывать *и* принудительный адаптер, *и* то, что ОС выбрала бы сама — перезапись наблюдения превратила бы сигналы шлюза/туннельного интерфейса для VPN в тавтологию (форсируешь VPN-адаптер — бесплатно получаешь "VPN подтверждён").
**Интерфейс без маршрута в интернет — диагностический ответ, а не ошибка.** Резолв неизвестного/выключенного/безадресного адаптера падает сразу (`typer.BadParameter`, со списком всех адаптеров) — это плохой *ввод*. А адаптер, который резолвится нормально, но не несёт трафика — ровно то, для чего существует эта утилита, поэтому прогон продолжается, каждая проба падает в обычную классификацию `unavailable`/`timeout`, и отчёт прямо это показывает, а не притворяется обычным прогоном.
**Массовые эндпоинты RIPEstat всегда ограничены.** `routing-history` и подобные на крупных провайдерах возвращают 10 МБ+, поэтому каждый такой вызов несёт `max_rows` и временное окно из `config.yaml`.
**PeeringDB кэшируется на диск.** Анонимные лимиты — 20 запросов в минуту, а крупные ответы душатся до одного в час, поэтому ответ не запрашивается повторно внутри прогона и сохраняется в `.cache/` между прогонами на `providers.peeringdb_cache_hours`.
**В JSON-дампе есть раздел `raw`.** Каждый ответ провайдера сохраняется дословно под ключом источника. Типизированный слой над ним неизбежно теряет поля; именно `raw` действительно выполняет требование «100% собранных данных без усечения».
**Почему проверка DPI — не портсканер.** `probes/dpi_check.py` механически представляет собой код, открывающий TCP-соединения к IP на нескольких портах — тот же примитив, что использует портсканер. Разница между самодиагностикой и сканером — в ограничениях вокруг этого примитива, и они несущие, а не декоративные:
1. **Цель — всегда только строка, которую пользователь ввёл после `--my-server`.** `check_dpi(target, resolved_ip, ports, ...)` принимает оба параметра как обязательные, без значения по умолчанию — нет пути кода, который подставил бы их из `IpGeo.ip`, `CfTrace.ip`, `--target` или `BgpIntel.announced_prefixes`. `tests/test_dpi_check.py` фиксирует это через `inspect.signature`.
2. **Список портов фиксирован и короткий.** `dpi_check.ports` в `config.yaml` по умолчанию — шесть портов и ограничен 16 записями pydantic-валидатором в `config.py` — нет флага `--ports`/диапазона, поднять потолок можно только правкой и передеплоем конфига, а не аргументом CLI.
3. **Один хост за запуск.** `--my-server` принимает одно значение; ввод CIDR отклоняется сразу в `cli.py`, ещё до создания `Options` (`"this is a single-host self-diagnostic, not a network scanner"`).
4. **Ограничение скорости встроено в конструкцию.** `asyncio.Semaphore(dpi_check.concurrency)` (потолок 4) плюс `asyncio.sleep(delay_between_ports_seconds)` между стартами проб; одна попытка на порт, без повторов.
5. **Согласие печатается, а не только документируется.** Перед пробой `cli.py` печатает, какой хост и сколько портов сейчас будут затронуты, и что это должен быть хост, которым пользователь владеет или на проверку которого уполномочен — тот же паттерн, что уже существующее предупреждение `--tcp-trace`, а не файл-маркер согласия как у `--ndt7`, потому что здесь нет раскрытия данных третьей стороне, только вопрос авторизации.
6. **Цель аудируется.** `meta["dpi_target"]` в каждом JSON-отчёте фиксирует, что именно проверялось.
Бенчмарк префиксов AS (`probes/prefix_benchmark.py`) получил такое же обращение по той же причине в другом масштабе: пинг первого хоста *каждого* префикса, анонсируемого крупным провайдером, — это массовый прозвон сети, которой пользователь не обязательно владеет. `prefix_bench.max_prefixes` ограничен 256 в валидаторе (по умолчанию 32), проба только ICMP (никогда не TCP-connect), и она никогда не включается `--full` — только явным `--prefix-bench`.
**Замер TLS-рукопожатия использует два последовательных соединения, а не `ssl.SSLObject.start_tls()` на одном.** `probes/tls_rtt.py` сначала открывает обычное TCP-соединение (`tcp_connect_rtt()`, уже используется в `latency.py`) для базовой линии TCP, затем второе `asyncio.open_connection(..., ssl=...)` к тому же хосту и вычитает базовую линию из полного времени второго соединения, получая `tls_handshake_ms`. Это немного завышает истинное время рукопожатия (второе TCP-рукопожатие тоже происходит), но избегает сложности апгрейда живого соединения на лету, а отчёт прямо говорит в сноске под таблицей, что число получено вычитанием — не выдаётся за лабораторно точный замер.
**`dns_advanced.py` и `dns_leak.py` отвечают на разные вопросы и не должны сливаться.** `dns_leak.py` спрашивает «откуда мои DNS-запросы физически выходят в интернет» (echo-пробы, сверка ASN резолвера с ASN исходящего адреса) и питает скоринг уверенности VPN. `dns_advanced.py` спрашивает «насколько быстро и честно отвечают» (сравнение времени и содержания system vs DoH) и питает собственные находки. У них нет ничего общего, кроме `dnspython`; общий хелпер (в духе `resolver_for`) намеренно не выделялся, потому что единственное место пересечения (создание резолвера) — по три строки с каждой стороны, и не стоит связности.
**То, что резолвер отвечает иначе, чем DoH, само по себе ничего не доказывает.** Любой CDN за geo-DNS или ECS законно отдаёт разным резолверам разные IP для одного имени. `dns_advanced.compare_answers()` просто фиксирует расхождение текстом; `detect_poisoning()` эскалирует до находки только когда ответ *системного* резолвера сам по себе некорректен (`0.0.0.0`, loopback, приватный адрес или RFC 5737), а DoH при этом вернул нормальный публичный адрес — именно эта асимметрия, а не само расхождение, похожа на подменённый/перехваченный ответ.
**Таблица colo→город/страна Cloudflare — курируемый словарь на ~30 записей, а не полная база IATA.** Тянуть полный пакет кодов аэропортов ради одного этого поиска — новая зависимость ради функции, которой достаточно ответить «в другой ли стране edge PoP, чем клиент» для скромного набора colo, смещённого в сторону СНГ/Европы. Нераспознанный код рендерится как «неизвестно», а не угадывается.
**Детектор международной петли маршрута сравнивает собственную геолокацию клиента со страной edge PoP — и ничего больше.** Более ранняя версия `detect_international_loop()` также требовала совпадения геолокации по резолвнутому IP *цели* со страной клиента, из соображения, что это отсекает лишний конфаундер. На практике это поле никогда и ничем не заполнялось, поэтому детектор не мог сработать вообще ни разу — это обнаружилось при ручном сквозном запуске `--path-diversity` и разборе JSON, а не юнит-тестом: тесты дёргали функцию напрямую и сами подставляли значение, которое рабочий код так и не научился передавать. Вывод общий: два входных параметра лучше трёх, если третий нечем надёжно заполнить, а смоук-прогон новых опциональных флагов должен идти в том же проходе, что юнит-тесты, а не после них.
**Детект CGNAT использует три источника доказательств, а не один.** Пользователь за домашним роутером видит локально приватный `192.168.x.x`, пока carrier-grade NAT (RFC 6598, `100.64.0.0/10`) сидит на хоп выше — проверка только `LocalNet.local_ipv4` пропустила бы самый частый случай. `cgnat_findings()` проверяет локальный адрес, затем шлюз по умолчанию, затем каждый хоп трассировки, в этом порядке, и записывает, какой именно сработал, в строку доказательства находки.
**NAT64/464XLAT определяется по RFC 7050, а не проверкой собственного адреса клиента.** Известный префикс синтеза `64:ff9b::/96` — это куда NAT64-резолвер *встраивает* IPv4-адрес назначения, а не то, во что попадает собственный исходящий адрес клиента — более ранний дизайн, проверявший egress IPv6 на вхождение в этот префикс, никогда бы не сработал. Правильная проба — один AAAA-запрос к `ipv4only.arpa`: его A-записи — два фиксированных адреса (`192.0.0.170`/`.171`), и DNS64-резолвер синтезирует AAAA-ответ, встраивая один из них в реальный (часто специфичный для провайдера) `/96`-префикс сети; настоящая dual-stack сеть на этот запрос вообще не вернёт AAAA.
**Первый хоп трассировки диагностируется отдельно от остального маршрута.** Потери или задержка на хопе 1 означают проблему в собственном роутере или Wi-Fi пользователя, а не у провайдера или где-то дальше по маршруту — `path_findings()` принимает опциональный `LocalNet` и, если он передан, сравнивает IP первого хопа с шлюзом по умолчанию, который видит ОС, перед тем как запускать существующую логику потерь в середине маршрута — так два вопроса («в порядке ли моя локальная сеть» и «где деградирует остальной путь») остаются структурно разделены, а не тонут один в другом.
**Обогащение хопов по ASN — отдельная фаза после завершения трассировки, а не часть самого traceroute.** Поля `TraceHop.asn`/`as_name` существовали в модели с самого начала, но ни один писатель их не заполнял — `probes/hop_asn.py::enrich_hops()` заполняет их (плюс новое поле `country` и PTR-фоллбэк для бэкендов, которые сами не резолвят имена), собирая уникальный набор IP хопов по всем трассировкам и делая по одной паре DNS-запросов Team Cymru на IP, с дедупом в рамках прогона. Это отдельный шаг `run_module("path_enrich", ...)` в `cli.py` после веера latency/path/TLS и перед speed, мутирующий уже собранные объекты `TraceHop` на месте; ошибка поиска попадает только в предупреждения этого шага и никогда не понижает `ModuleResult.status` самой трассировки. У DNS-сервиса Team Cymru нет лимита запросов (в отличие от обёрнутых в `cached_json()` вызовов RIPEstat/CAIDA), поэтому дисковый кэш не нужен — только дедуп повторяющихся IP хопов в рамках прогона.
**Детект captive portal размечает, но никогда не обрывает прогон.** `ModuleResult.status` — информационное поле везде в конвейере, и ничто в `diagnose()` не ветвится по нему — введение первого статус-управляемого control flow ради одной фичи противоречило бы принципу «отказ — это значение, а не поток управления» выше. К тому же captive portal редко тотален: DNS и ICMP часто проходят, пока перехватывается только HTTP, и оборванный прогон дал бы отчёт, неотличимый от мёртвой ссылки. Поэтому `probes/captive_portal.py` работает как Фаза 0, до всего остального, и при обнаружении добавляет находку `crit` плюс баннер в начале отчёта — все остальные фазы всё равно выполняются и честно помечены как измеренные из-за портала, а это больше информации, чем отказ измерять вовсе.
**Три URL captive-portal-проверки по умолчанию обязаны иметь одну и ту же форму успеха, и подобрать их — не взаимозаменяемая мелочь.** `check_captive_portal()` ожидает, что каждый URL ведёт себя как `generate_204`-эндпоинт: HTTP 204 с пустым телом при успехе. `detectportal.firefox.com/success.txt` Mozilla пробовался как третий дефолт и сразу дал ложное срабатывание на реальной сети — его *корректный*, не-портальный ответ — HTTP 200 с телом `success`, что неотличимо от сигнала «200 с телом» в `classify_portal_response()`, означающего портал. Поймано той же дисциплиной живого смоук-теста, что и баг с международной петлёй выше, а не юнит-тестом (юнит-тесты подавали функции буквальные формы ответа и никогда не моделировали реальное поведение конкретного эндпоинта). Исправлено заменой на второй настоящий `generate_204`-эндпоинт (`clients3.google.com`) вместо обучения классификатора второй форме успеха — единый контракт на все URL проще, чем таблица ожидаемых тел по каждому URL ради трёх записей.
**Geo-sanity сервера speedtest сравнивает страны, а не координаты.** Изначальный дизайн предполагал расстояние по haversine в км между клиентом и сервером speedtest, для чего нужны lat/lon обеих сторон. Живая проверка собственного JSON-вывода бандл-бинарника Ookla CLI (`speedtest.exe --format=json`) показала, что его объект `server` несёт `location` (название города) и `country`, но никогда `lat`/`lon` — это документированная схема, а не особенность версии. `speed_findings()` вместо этого сравнивает `SpeedResult.server_country` с собственной геолоцированной страной клиента: грубее расстояния, но реально, всегда доступно, когда уровень каскада сообщает страну, и всё равно ловит именно тот случай, что важен — тест попал на сервер совсем в другой стране.
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
## Хранение
- `./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `<ASN>` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Компактный ISO-таймстамп, потому что Windows запрещает `:` в именах файлов. При полном провале определения ASN — `report_unknown_…`. Запись через временный файл и `os.replace()`, всегда атомарно. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера.
- `./logs/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик.
- `./.cache/firehol/*.netset` — скачанные блоклисты, обновляются по `providers.firehol_refresh_hours`.
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — ответы PeeringDB, действительны `providers.peeringdb_cache_hours`.
- `./config.yaml` — все несекретные настройки. `./.env` — три опциональных API-ключа и ничего больше.
## Конфигурация
`config.py` собирает объект `Settings` на pydantic-settings, источники которого упорядочены **init > переменные окружения > `.env` > `config.yaml` > значение по умолчанию**. Три API-ключа объявлены как `SecretStr | None = None` на верхнем уровне: диагностический инструмент обязан работать с нулевой настройкой, поэтому отсутствие ключа переводит зависящее от него обогащение в `skipped` с предупреждением и никогда не роняет прогон.
Пять секций обслуживают L4-L7 пробы, все опциональны на уровне CLI и все с потолком через валидатор там, где функция иначе могла бы стать массовым прозвоном: `tls`, `dns_advanced`, `path_diversity`, `prefix_bench` (`max_prefixes` ограничен 256), `dpi_check` (`ports` ограничен 16 записями, `concurrency` — 4).
## Тесты
`uv run pytest -q`. Около 758 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.