# Архитектура [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 по соглашению об именах файлов: 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/report__.{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/` файлы `report_{key}_*.json`, используя то же соглашение `sanitize_name()`/таймстампа, что уже пишет `exporter.report_filename()` — поэтому ключ поиска не может разойтись с тем, что реально на диске. Срабатывает только когда сам этот прогон пишет 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, с меткой `"