tls_context() now defaults to real certificate verification instead of the
tool's original always-CERT_NONE posture; measure_tls() only falls back to
unverified on ssl.SSLCertVerificationError, retiming the retry since the
failed attempt's duration isn't representative. This is also the only way
to get subject/issuer/notAfter at all - getpeercert() returns {} for a
chain that never validated.
Every TLS result now carries cert_sha256 (SHA-256 of the full DER cert, not
the SPKI - that would need the cryptography package, a dependency this
doesn't need), cert_issuer, cert_days_remaining, and the long-dead
cert_verified field finally gets written. New tls.pinned_fingerprints
config (host -> expected hash, capped and hex-validated) drives three new
findings: a pin mismatch is crit, an unpinned validation failure is warn,
an expiring cert is warn.
Live-tested with --tls against real Cloudflare/Google endpoints - verified
handshake, correct issuer and expiry both times.
Part of plans/zesty-zooming-wilkinson.md (gitignored).
44 KiB
Архитектура
netsleuth собирает сетевые факты из множества независимых, в основном безключевых источников, сводит их в типизированные структуры, интерпретирует чистыми функциями и на каждый прогон отдаёт два артефакта. Сбор, интерпретация и рендер никогда не смешиваются: модуль либо собирает данные, либо рассуждает об уже собранных — но не одновременно.
Структура каталогов
src/netsleuth/
cli.py Typer-приложение: флаги, порядок фаз, прогресс Rich. Без бизнес-логики.
config.py Загрузчик pydantic-settings. Приоритет: env > .env > config.yaml > default.
models.py Все общие датаклассы плюс to_jsonable() для сериализации.
orchestration.py run_module(): тайминг, таймаут, классификация исключений в ProbeError.
netinfo.py Определение возможностей и локальные факты: интерфейс, шлюз, MTU, резолверы.
iface.py Резолв --interface: имя/IP адаптера -> BindTarget, чистый код, покрыт тестами.
ip_geo.py Цепочка из шести провайдеров, нормализаторы, пополевое слияние.
bgp.py RIPEstat, CAIDA ASRank, Team Cymru через DNS, PeeringDB (кэш на диске).
reputation.py Кэш netset'ов FireHOL и локальный поиск, Shodan InternetDB, декодер DNSBL.
stats.py Чистая статистика RTT: потери, min/avg/max, mdev, джиттер.
traceparse.py Чистые парсеры текста в TraceHop для Windows, Linux и BSD/macOS.
interpret.py Чистый движок вердиктов: пороги -> Finding[], скоринг VPN, оценка bufferbloat.
speed.py Каскадный замер скорости, математика пропускной способности, разбор cfL4, bufferbloat.
exporter.py Рендер JSON и Markdown, атомарная запись в ./logs/. Какие
артефакты писать, решает вызывающий код (cli.py), не этот модуль.
compare.py --compare: сравнение двух сохранённых JSON-отчётов.
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
probes/
latency.py Веер пингов и статистика джиттера/потерь/mdev/p95/p99.
traceroute.py Каскад: mtr --json -> icmp_win -> icmplib -> системный бинарник, плюс опциональный уровень tcp_trace.
icmp_win.py Движок ctypes IcmpSendEcho2 / Icmp6SendEcho2 (только Windows).
dns_leak.py Перечисление резолверов по адаптерам, echo-пробы, детект ECS.
tls_rtt.py --tls: TCP RTT + TLS 1.3-рукопожатие + TTFB через два последовательных соединения.
dns_advanced.py --dns-advanced: системный DNS vs DoH, проверка прозрачного прокси на bogon-IP резолвера.
bgp_path.py --path-diversity: поиск colo по CF-RAY, детект международной петли маршрута.
prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS.
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT.
hop_asn.py Обогащение хопов ASN/именем AS/страной/PTR через DNS Team Cymru, дедуп в рамках прогона.
captive_portal.py Фаза 0: три generate_204-пробы на признаки логин-стены публичного wifi.
Исключения по размеру файла. Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно:
exporter.py(~910 строк) — JSON и оба языка Markdown — рендеры одной и той же формы отчёта; то, что они вместе, гарантирует, что английский и русский отчёты никогда не разойдутся в том, какие разделы или строки существуют.render_markdown(report, lang="en"|"ru")и точечные вызовы_t(lang, en, ru)в местах со строками рендерят оба языка из одного пути кода — намеренно, вместо отдельного файла-рендера, который мог бы незаметно разойтись. Файл вырос на пять разделов (TLS, DNS advanced, path diversity, prefix bench, DPI check), не меняя этот принцип.cli.py(~805 строк) — приложение Typer, все флаги и весь конвейер фазdiagnose(); разделение разбора флагов и того, что они переключают, добавило бы только лишний импорт.models.py(~490 строк) — все общие датаклассы плюсto_jsonable(); это намеренно единственное место, определяющее словарь отчёта.interpret.py(~600 строк) — пороговые полосы, генераторы находок по задержке/маршруту/скорости/TLS/DNS/DPI/anycast/префиксам, скоринг VPN и оценка bufferbloat — всё рассуждает над одной формойFinding[].bgp.py(~305 строк) — четыре независимых источника (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) плюс общий для них дисковый кэш.ip_geo.py(~280 строк) — шесть независимых нормализаторов провайдеров плюс пополевое слияние, которое их примиряет.netinfo.py,speed.py(~260 строк каждый) — определение возможностей по ОС и каскад замера скорости из четырёх уровней оба веерятся по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.reputation.py(~250 строк) — netset'ы FireHOL, Shodan InternetDB и декодирование DNSBL — три несвязанных источника репутации с одной общей формой результата.probes/traceroute.py(~225 строк) — каскад из четырёх уровней плюс опциональный уровеньtcp_trace.
Поток данных
+-- netinfo (локальные факты, возможности)
|
cli.py -------+-- цепочка ip_geo ---> внешний IP + ASN [блокирующая фаза: всё дальше зависит от ASN]
|
+-- bgp || reputation || dns_leak || dns_advanced || path_diversity (параллельно)
|
+-- latency || traceroute || tls (параллельно, ограничено семафором)
|
+-- prefix_benchmark (опционально, последовательно — изолирован от веера latency выше)
|
+-- dpi_check (опционально, последовательно — один хост, с ограничением скорости)
|
+-- speed (эксклюзивная фаза)
|
+-- interpret ---> Finding[] + общий вердикт
|
+-- exporter ---> logs/report_<ASN>_<ts>.{md,ru.md,json}
Три жёстких ограничения по конкурентности:
- Изоляция измерений. Замер скорости никогда не пересекается с трассировкой, пингами и запросами к API — он держит эксклюзивную блокировку фазы. Единственное намеренное исключение — проба bufferbloat, которая по замыслу работает во время насыщения канала, внутри
speed.py. - Ограниченный веер трассировок. Параллельные трассировки к разным целям делят первые хопы и завышают задержки друг друга, поэтому число одновременных трассировок ограничено семафором (
probing.trace_concurrency, по умолчанию 2). - Бенчмарк префиксов 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 на нескольких портах — тот же примитив, что использует портсканер. Разница между самодиагностикой и сканером — в ограничениях вокруг этого примитива, и они несущие, а не декоративные:
- Цель — всегда только строка, которую пользователь ввёл после
--my-server.check_dpi(target, resolved_ip, ports, ...)принимает оба параметра как обязательные, без значения по умолчанию — нет пути кода, который подставил бы их изIpGeo.ip,CfTrace.ip,--targetилиBgpIntel.announced_prefixes.tests/test_dpi_check.pyфиксирует это черезinspect.signature. - Список портов фиксирован и короткий.
dpi_check.portsвconfig.yamlпо умолчанию — шесть портов и ограничен 16 записями pydantic-валидатором вconfig.py— нет флага--ports/диапазона, поднять потолок можно только правкой и передеплоем конфига, а не аргументом CLI. - Один хост за запуск.
--my-serverпринимает одно значение; ввод CIDR отклоняется сразу вcli.py, ещё до созданияOptions("this is a single-host self-diagnostic, not a network scanner"). - Ограничение скорости встроено в конструкцию.
asyncio.Semaphore(dpi_check.concurrency)(потолок 4) плюсasyncio.sleep(delay_between_ports_seconds)между стартами проб; одна попытка на порт, без повторов. - Согласие печатается, а не только документируется. Перед пробой
cli.pyпечатает, какой хост и сколько портов сейчас будут затронуты, и что это должен быть хост, которым пользователь владеет или на проверку которого уполномочен — тот же паттерн, что уже существующее предупреждение--tcp-trace, а не файл-маркер согласия как у--ndt7, потому что здесь нет раскрытия данных третьей стороне, только вопрос авторизации. - Цель аудируется.
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 в диагностическом инструменте, который кто угодно может направить куда угодно.
Python 3.14 — минимальная версия, не 3.10. Проект ушёл с >=3.10 одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (ssl.SSLObject.start_tls(), asyncio.timeout(), datetime.UTC) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
Хранение
./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}— артефакты, выбранные--format/output.formats(по умолчанию: только английский Markdown;allпишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский,.ru.jsonнет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же какREADME.md/README.ru.md; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл.<ASN>— это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Компактный ISO-таймстамп, потому что Windows запрещает:в именах файлов. При полном провале определения ASN —report_unknown_…. Запись через временный файл иos.replace(), всегда атомарно. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера../logs/watch_<ASN>_<YYYYMMDDTHHMMSSZ>.json— один тайм-ряд на сессию--watch, а не отдельный отчёт на каждый тик../.cache/firehol/*.netset— скачанные блоклисты, обновляются поproviders.firehol_refresh_hours../.cache/pdb-net-<asn>.json,./.cache/pdb-netixlan-<net_id>.json— ответы PeeringDB, действительныproviders.peeringdb_cache_hours../config.yaml— все несекретные настройки../.env— три опциональных API-ключа и ничего больше.
Конфигурация
config.py собирает объект Settings на pydantic-settings, источники которого упорядочены init > переменные окружения > .env > config.yaml > значение по умолчанию. Три API-ключа объявлены как SecretStr | None = None на верхнем уровне: диагностический инструмент обязан работать с нулевой настройкой, поэтому отсутствие ключа переводит зависящее от него обогащение в skipped с предупреждением и никогда не роняет прогон.
Пять секций обслуживают L4-L7 пробы, все опциональны на уровне CLI и все с потолком через валидатор там, где функция иначе могла бы стать массовым прозвоном: tls, dns_advanced, path_diversity, prefix_bench (max_prefixes ограничен 256), dpi_check (ports ограничен 16 записями, concurrency — 4).
Тесты
uv run pytest -q. Около 777 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы psutil/ctypes и цикл --watch. Фикстуры трассировок лежат в tests/fixtures/traceroute/{windows,linux,darwin}/ и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в tests/fixtures/api/doh_*.json рядом с фикстурами других провайдеров.