mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 16:52:03 +03:00
Filenames only carry a time-of-day now (report_<asn>_HH-MM-SSZ) since the date lives in the directory path; history lookups walk the tree with rglob and sort by full path instead of basename, since the basename alone no longer encodes recency across days.
207 lines
66 KiB
Markdown
207 lines
66 KiB
Markdown
# Архитектура
|
||
|
||
[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), не этот модуль.
|
||
alerting.py Чистый контракт перехода вебхука (дебаунс should_fire) плюс обычный httpx POST для алертов --watch.
|
||
compare.py --compare: сравнение двух сохранённых JSON-отчётов; render_diff_brief() питает автоматическую сводку vs-прошлый-прогон.
|
||
history.py Поиск отчётов той же ASN обходом дерева logs/YYYY/MM/DD: report_key(), find_previous(), latest_key().
|
||
trend.py --trend N: агрегирует N сохранённых JSON-отчётов в спарклайн-серии по меткам.
|
||
metrics.py collect_metrics() извлекает плоский список Metric из отчёта; render_prometheus()/render_csv() его сериализуют.
|
||
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/фильтрации. Не сканер — см. Ключевые решения.
|
||
ecmp.py --ecmp: чистый diff N прогонов трассировки для обнаружения балансировки нагрузки по хопам. Не зависит от бэкенда по дизайну.
|
||
pmtud.py --pmtud: бинарный поиск самого большого пакета с DF, доходящего до цели, для обнаружения чёрной дыры PMTUD.
|
||
quic_rtt.py --quic: RTT QUIC/HTTP3-рукопожатия через опциональную зависимость aioquic; детект блокировки UDP/443.
|
||
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/YYYY/MM/DD/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` с собственной геолоцированной страной клиента: грубее расстояния, но реально, всегда доступно, когда уровень каскада сообщает страну, и всё равно ловит именно тот случай, что важен — тест попал на сервер совсем в другой стране.
|
||
|
||
**TLS-рукопожатие сертификата сначала пробует проверенное соединение, деградирует до `CERT_NONE` только при ошибке валидации.** `probes/tls_rtt.py::tls_context(verify: bool)` по умолчанию — настоящий, немодифицированный `create_default_context()`, что меняет изначальную позицию инструмента «всегда `CERT_NONE`». `measure_tls()` сначала пробует проверенное соединение; только при `ssl.SSLCertVerificationError` повторяет без проверки (перезамеряя время повторной попытки, поскольку длительность неудачной попытки не отражает реальное соединение). Это единственный способ вообще получить `subject`/`issuer`/`notAfter`: `getpeercert()` в Python возвращает `{}` для непровалидированной цепочки, заполняя разобранный словарь, только если сертификат действительно прошёл проверку — `getpeercert(binary_form=True)` (сырой DER, хешируемый в `cert_sha256`) доступен в любом случае. Отпечаток — это SHA-256 **полного DER-сертификата, не SPKI** — извлечение только subject-public-key-info требует пакет `cryptography`, новую зависимость, которая этой фиче не нужна; сноска в отчёте говорит пользователю, сравнивающему с внешне закреплённым значением, считать его так же (`openssl x509 -outform DER | sha256sum`).
|
||
|
||
**Пиннинг конфигурируется, а не флагуется.** `tls.pinned_fingerprints` (хост → ожидаемый SHA-256, capped на 32 записи, каждая нормализована и провалидирована как hex pydantic-валидатором) следует той же позиции, что `dpi_check.ports`: поднять список можно только правкой и передеплоем конфига, а не аргументом `--pin` в диагностическом инструменте, который кто угодно может направить куда угодно.
|
||
|
||
**Prometheus и CSV — одна экстракция, две сериализации, а не два рендерера.** `metrics.py::collect_metrics(report) -> list[Metric]` делает построчный особый разбор каждой секции, который плоский обход словаря отчёта не осилит (latency нужна тройка label/host/method, TLS — пара label/host, хопы пути — четвёрка target/ttl/ip/asn), как единая чистая функция `dict -> list[Metric]`; `render_prometheus()` и `render_csv()` — тогда ~15 строк каждая, форматирующие один и тот же список двумя способами. `FORMAT_EXTENSIONS` (`config.py`) остаётся единственным источником истины, от которого зависят и `cli.py::parse_formats()`, и `config.py::Output._normalize_formats()` — добавление `"prom"`/`"csv"` туда было единственной точкой подключения.
|
||
|
||
**Авто-diff по той же ASN работает до `write_report()`, иначе текущий прогон сравнивался бы сам с собой.** `history.find_previous()` обходит `./logs/` (`Path.rglob("report_*.json")`, поскольку отчёты теперь лежат в поддереве `YYYY/MM/DD`) в поисках `report_{key}_*.json`, используя то же соглашение `sanitize_name()`/таймстампа, что уже пишет `exporter.report_filename()` — поэтому ключ поиска не может разойтись с тем, что реально на диске. Поскольку само имя файла теперь несёт только время суток, свежесть сортируется по полному пути (`p.as_posix()`), а не по имени файла — сортировку хронологичной делает именно дополненный нулями префикс `YYYY/MM/DD`. Срабатывает только когда сам этот прогон пишет JSON (иначе не с чем сравнивать в будущем прогоне) и печатает одну строку-заметку, никогда предупреждение, только когда JSON не входит в форматы этого прогона — молчание корректно на всех остальных путях пропуска (самый первый прогон, либо прошлый прогон не писал JSON). `compare.py::render_diff_brief()` существует потому, что полная таблица `render_diff()` слишком длинная, чтобы печатать её незапрошенной после каждого прогона; она показывает только смену ASN/IP, худшую дельту задержки, дельту скачивания и число новых/устранённых находок.
|
||
|
||
**`--trend` не требует живого сетевого запроса, чтобы понять «историю этой сети».** Вместо повторного запуска определения identity только чтобы узнать, чьи отчёты грузить, `history.latest_key()` считывает это обратно из имени самого нового `report_*.json` на диске — то же соглашение об именовании файлов уже это кодирует, а read-only исторический просмотр не имеет причин вообще трогать сеть.
|
||
|
||
**У `--watch` вообще не было вердикта до этого, и дать ему вердикт было первым шагом.** `watch.py::run_watch()` только веером гонял пинги (плюс изредка спидтест) и рендерил сырые числа — `interpret` никогда не импортировался. Подключить алертинг к «переходу вердикта» было невозможно, пока не было вердикта, между которым переходить, так что `summarize_cycle()` теперь переиспользует те же самые чистые функции `latency_findings()`/`speed_findings()`/`overall_verdict()`, что уже использует одноразовый отчёт, получая `status`/`score`/`finding_ids` на каждый тик — реальное улучшение дашборда само по себе, независимо от того, настроен ли когда-либо вебхук.
|
||
|
||
**Весь контракт дебаунса вебхука живёт в одной чистой функции.** `alerting.should_fire(previous, current, last_fired_at, now, min_interval_s, fire_on)` решает всё: срабатывает только на реальном переходе (никогда на первом тике, поскольку не с чем сравнивать), только в состояние, на которое пользователь подписался (`watch.webhook_on`), и только после того как прошло `webhook_min_interval_seconds` (пол в 30 в валидаторе) с последнего срабатывания — флапающий канал не может спамить эндпоинт на каждом тике. Восстановление (`crit`/`warn` → `ok`/`info`) — отдельный опциональный токен (`"recovered"`), не подразумеваемый настройкой `"crit"`, потому что пользователь, который хочет знать об отключениях, не обязательно хочет второй пинг, когда они кончаются.
|
||
|
||
**Полезная нагрузка вебхука — плоское JSON-тело, не заточенное ни под какую конкретную чат-платформу.** `alerting.build_payload()` возвращает `{tool, asn, at, previous, current, score, findings, host}` — форматирование под Slack/Discord — забота того, что принимает POST, а не netsleuth. `post_webhook()` глотает любой отказ `httpx`/`OSError` и возвращает `False`; мёртвый эндпоинт вебхука не должен прерывать цикл мониторинга, за которым он должен следить.
|
||
|
||
**Детект ECMP сравнивает N независимых прогонов трассировки, а не аннотации по хопам одного прогона.** `traceparse.py` для BSD уже фиксирует сигнал «разный роутер на том же TTL» как аннотацию `alt:`, но эта концепция не обобщается: JSON от mtr агрегирует циклы и теряет вариацию next-hop по циклам, `icmp_win`/`icmplib` по конструкции шлют ровно одну пробу на TTL, а Linux-парсер системного traceroute не моделирует много-IP хопы, даже если текст их содержит. Находка, которая срабатывает только на macOS и Linux с системным бинарником, хуже, чем её отсутствие. Вместо этого `probes/ecmp.py::detect_ecmp()` берёт N независимо прогнанных `TraceResult` для одной цели и сравнивает, какой IP появился на каждом TTL по прогонам — работает одинаково независимо от того, какой бэкенд дал каждую трассировку, и сама функция вообще не трогает определение возможностей или выбор бэкенда.
|
||
|
||
**`--ecmp` опционален и ограничен, и никогда не входит в `--full`.** Он умножает трафик трассировки к одной и той же цели на `ecmp.runs` (по умолчанию 3, потолок 5) — по уже установленной логике конкурентности трассировок (параллельные трассировки к *одной* цели искажают задержку друг друга хуже, чем параллельные трассировки к разным целям), это намеренно последовательно по каждой цели и делит один свежий семафор между `ecmp.max_targets` (потолок 3) целями, выполняясь только после того, как основной веер latency/path/TLS уже завершился.
|
||
|
||
**Задержка IPv6 едет на существующем веере пингов с отдельной меткой, а не в параллельной секции `latency_v6`.** `probing.reference_hosts_v6` (литералы IPv6) подаются в тот же `ping_fanout()`, что и IPv4, с меткой `"<label>-v6"`, и результаты добавляются в `modules["latency"].data` после веера Фазы 3 — `exporter._latency()`, `metrics.collect_metrics()` и `compare.py` уже ключуют всё по `PingResult.label`, так что это потребовало нулевых изменений в них. `interpret.dual_family_findings()` сопоставляет строку `"cloudflare-dns-v6"` с её парой `"cloudflare-dns"`, отрезая суффикс, целиком в прикладном коде — сама схема отчёта не знает об IPv6 как об отдельном понятии.
|
||
|
||
**Для самого пути пинга IPv6 не понадобился код определения семейства адреса, потому что существующая цепочка деградации уже справляется корректно.** `ping_samples_win()` в `icmp_win` вызывает `socket.gethostbyname()`, который работает только с IPv4 и падает с `socket.gaierror` на литерале IPv6 — это ловится уже существующим голым `except Exception: backend = "tcp"` в `latency.py::ping_host()`, который затем корректно пингует v6-литерал через `asyncio.open_connection` (не зависящий от семейства). На Linux/macOS `icmplib.async_ping()` нативно определяет семейство адреса по литералу. Проверено вживую с форсированным `--ipv6` на хосте без реального IPv6: каждая v6-строка корректно откатилась на `method="tcp"` и показала 100% потерь, ровно как задумано, без падения.
|
||
|
||
**Трассировка IPv6 и `--path-diversity` намеренно вне рамок этой фазы.** Расширение каскада трассировки и `bgp_path.py` на второе семейство адресов — это реальная дополнительная работа (уровень трассировки `icmp_win` — IPv4-only по той же причине `gethostbyname()` выше, хотя обработка исключений по уровням в `run_cascade()` означает, что в худшем случае это просто откатится на `system_traceroute`, а не испортит отчёт), которая не разделяет трюк «пометить меткой, ничего не трогать ниже по потоку», сработавший для latency — оставлено как задокументированное продолжение, а не раздувание этой фазы под него.
|
||
|
||
**PMTUD остаётся непривилегированным, переиспользуя тот же DF-флаг хук, что уже несёт определение возможностей ICMP, и откатывается на системный бинарник `ping` вместо ручных сырых сокетов на Unix.** На Windows поле `IP_OPTION_INFORMATION.Flags` для `IcmpSendEcho2` уже существовало в `icmp_win.py` (неиспользуемое до этого) — `echo_once()` получил параметр `df: bool`, устанавливающий `IP_FLAG_DF` (0x02), а `classify_status()` получил `IP_PACKET_TOO_BIG` (11009) — статус, который Windows возвращает, когда роутер отвечает «нужна фрагментация». Новых привилегий не требуется: это тот же непривилегированный API `IcmpSendEcho2`, что уже использует остальной уровень ICMP. На Linux/macOS вместо ручного конструирования сырых ICMP echo-запросов (версионно-зависимые причуды checksum/identifier ядра на непривилегированных сокетах `SOCK_DGRAM`/`IPPROTO_ICMP` делают это хрупким способом, который автор проекта не мог проверить без этих платформ под рукой) `probes/pmtud.py` шеллится в системный `ping` с `-M do -s <size>` (Linux) или `-D -s <size>` (macOS/BSD) — тот же паттерн subprocess-и-разбор, что уже используют системные уровни `traceroute.py`, а флаги DF/размера у `ping` — стабильное, документированное поведение ping(8), а не то, что этому проекту нужно реверс-инжинирить.
|
||
|
||
**Локальный отказ «нужна фрагментация» и настоящий ответ ICMP на пути трактуются как один и тот же сигнал «снижен», без различения.** Linux `ping -M do` падает немедленно с локальной ошибкой, если у ядра уже закэширован MTU пути, вообще не отправляя завышенный пакет; когда закэшированного значения нет, настоящий ICMP «нужна фрагментация» от роутера даёт другое сообщение. `_probe_unix()` сопоставляет нестрого (`"frag"` или `"too long"` где угодно в объединённых stdout/stderr, без учёта регистра) именно потому, что точная формулировка отличается между реализациями ping и ситуациями — сопоставление по наличию сигнала, а не по точной формулировке, тот же принцип «разбирать по форме, не по точным словам», что уже установлен `traceparse.py` для вывода трассировки.
|
||
|
||
**Классическое различие чёрной дыры PMTUD — «сказал ли нам хоть кто-то почему».** `classify_pmtu()` называет найденный MTU `"reduced"`, когда сигнал «нужна фрагментация» был замечен в любой момент поиска (PMTUD работает, путь просто уже — нормально за VPN/туннелем), и `"blackhole"` только когда крупные пакеты просто исчезают вообще без ответа ICMP (настоящий симптом неверно настроенного VPN/PPPoE-роутера: «SSH подключается и зависает»). `path.pmtu_below_iface_mtu` — отдельная, всегда проверяемая находка: MTU пути уже, чем MTU самого локального сетевого адаптера — именно это делает такой класс проблем невидимым для любого локально запущенного инструмента.
|
||
|
||
**QUIC/HTTP3 — единственная фича, добавляющая настоящую зависимость, и она остаётся опциональной так же, как уже делают `tcptrace`/`ndt7`.** `aioquic` встаёт как extra в `[project.optional-dependencies]` (`uv sync --extra quic`), никогда не становясь жёсткой зависимостью базовой установки; `probes/quic_rtt.py::measure_quic()` импортирует его лениво внутри функции (тот же паттерн `_tier_tcp_trace`, что `traceroute.py` уже использует для scapy), поэтому `ImportError` — это пропуск по цели, а не падение, а `_quic_section()` сообщает чёткое предупреждение в духе `quic.unavailable`, называющее команду установки, вместо тихого бездействия. Рукопожатие `connect()` самого `aioquic` — проверено против реально установленной версии 1.3.0, а не предположено — завершается до того, как асинхронный контекстный менеджер отдаёт управление, поэтому, в отличие от трюка с вычитанием двух соединений у TLS RTT-пробы, время рукопожатия QUIC измеряется напрямую, и отчёт об этом прямо говорит.
|
||
|
||
**QUIC не поддерживает `--interface`, и отчёт об этом честно говорит, а не тихо замеряет путь ОС по умолчанию.** Сигнатура `aioquic.asyncio.client.connect()` (проверено против 1.3.0) принимает `local_port`, но вообще без параметра `local_host`/адреса-источника — структурно нет способа уважить форсированный адаптер здесь, в отличие от любой другой пробы в этой кодовой базе. `_quic_section()` добавляет соответствующее предупреждение всякий раз, когда задан `options.bind` — та же позиция «никогда не работает непривязанно и молча», что уже устанавливает ключевое решение про `--interface` для Windows `tracert` выше.
|
||
|
||
**Различие «заблокировано» против «недоступно» — реальная польза, а не сырое число рукопожатия.** `quic_verdict(quic_ok, tcp_ok)` — чистый центр фичи: если QUIC не проходит, пока обычное TCP-соединение с тем же хостом на том же порту проходит, значит сеть именно роняет или дросселирует UDP/443 — сеть незаметно ухудшает предпочтение Chrome/YouTube к HTTP/3 так, что никакая TCP-диагностика этого не покажет. Оба отказавших — обычная недоступность, уже покрытая существующими находками TCP/latency, поэтому `quic_findings()` молчит в этом случае, а не дублирует её.
|
||
|
||
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
|
||
|
||
## Хранение
|
||
|
||
- `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `<ASN>` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Дата старта прогона становится директорией (`exporter.report_date_dir()`), так что просмотр `logs/` — это спуск по году, затем месяцу, затем дню; самому имени файла достаточно читаемого времени суток (`exporter.readable_timestamp()`, например `19-12-00Z`), поскольку дата уже есть в пути — без `:`, потому что Windows запрещает его в именах файлов, вместо него дефисы. При непарсящемся `started_at` — плоский `report_unknown_unknown.…` прямо в корне `logs/`. Запись через временный файл и `os.replace()`, всегда атомарно — `atomic_write()` создаёт всю цепочку `YYYY/MM/DD` через `mkdir(parents=True)`. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера.
|
||
- `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик, датирован по `started_at` сессии так же, как и одноразовые отчёты.
|
||
- `./.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; те, чей список целей или диапазон свипа иначе мог бы стать массовым прозвоном, несут потолок через валидатор: `prefix_bench` (`max_prefixes` ограничен 256), `dpi_check` (`ports` ограничен 16 записями, `concurrency` — 4), `captive_portal` (`check_urls` ограничен 4), `tls` (`pinned_fingerprints` ограничен 32 записями), `ecmp` (`runs` ограничен 5, `max_targets` — 3), `pmtud` (`targets` ограничен 2, `max_bytes` — 9000), `quic` (`targets` ограничен 4). `dns_advanced` и `path_diversity` — простые опциональные флаги без диапазона свипа, который стоило бы ограничивать.
|
||
|
||
## Тесты
|
||
|
||
`uv run pytest -q`. Около 880 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|