mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 19:12:03 +03:00
Line-count exceptions had drifted 18-80% behind actual file sizes, storage section undercounted --format all as three formats instead of five, test count was stale at 880 vs actual 930.
208 lines
67 KiB
Markdown
208 lines
67 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` (~1100 строк) — JSON и оба языка Markdown — рендеры одной и той же формы отчёта; то, что они вместе, гарантирует, что английский и русский отчёты никогда не разойдутся в том, какие разделы или строки существуют. `render_markdown(report, lang="en"|"ru")` и точечные вызовы `_t(lang, en, ru)` в местах со строками рендерят оба языка из одного пути кода — намеренно, вместо отдельного файла-рендера, который мог бы незаметно разойтись. Файл вырос на пять разделов (TLS, DNS advanced, path diversity, prefix bench, DPI check), не меняя этот принцип.
|
||
- `cli.py` (~1078 строк) — приложение Typer, все флаги и весь конвейер фаз `diagnose()`; разделение разбора флагов и того, что они переключают, добавило бы только лишний импорт.
|
||
- `models.py` (~576 строк) — все общие датаклассы плюс `to_jsonable()`; это намеренно единственное место, определяющее словарь отчёта.
|
||
- `interpret.py` (~1079 строк) — пороговые полосы, генераторы находок по задержке/маршруту/скорости/TLS/DNS/DPI/anycast/префиксам, скоринг VPN и оценка bufferbloat — всё рассуждает над одной формой `Finding[]`.
|
||
- `bgp.py` (~305 строк) — четыре независимых источника (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) плюс общий для них дисковый кэш.
|
||
- `ip_geo.py` (~280 строк) — шесть независимых нормализаторов провайдеров плюс пополевое слияние, которое их примиряет.
|
||
- `netinfo.py` (~310 строк) — определение возможностей по ОС веерится по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.
|
||
- `speed.py` (~260 строк) — каскад замера скорости из четырёх уровней веерится по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.
|
||
- `reputation.py` (~250 строк) — netset'ы FireHOL, Shodan InternetDB и декодирование DNSBL — три несвязанных источника репутации с одной общей формой результата.
|
||
- `probes/traceroute.py` (~284 строки) — каскад из четырёх уровней плюс опциональный уровень `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-дамп, дамп в формате Prometheus text и CSV-дамп — только английский, `.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`. Около 930 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|