netsleuth/ARCHITECTURE.ru.md
Andrew Pimenov 4ffc39906b add --format/--ru/--json flags to control which report artifacts get written
Every run used to write all three files (en.md, ru.md, json) unconditionally.
Default is now English Markdown only, sourced from output.formats in
config.yaml; --format/--ru/--json pick exactly what a run writes, and any
explicit --format replaces the default rather than adding to it (all/none
supported). write_report() moved from a fixed 3-positional-arg signature to
a {format: text} mapping so writes are conditional instead of always-three.
The en/ru Markdown cross-link header is now only set when both languages are
actually being written.
2026-08-09 13:00:13 +03:00

18 KiB
Raw Blame History

Архитектура

English

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, резолверы.
  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.
    traceroute.py Каскад: mtr --json -> icmp_win -> icmplib -> системный бинарник, плюс опциональный уровень tcp_trace.
    icmp_win.py   Движок ctypes IcmpSendEcho2 / Icmp6SendEcho2 (только Windows).
    dns_leak.py   Перечисление резолверов по адаптерам, echo-пробы, детект ECS.

Исключения по размеру файла. Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно:

  • exporter.py (~680 строк) — JSON и оба языка Markdown — рендеры одной и той же формы отчёта; то, что они вместе, гарантирует, что английский и русский отчёты никогда не разойдутся в том, какие разделы или строки существуют. render_markdown(report, lang="en"|"ru") и точечные вызовы _t(lang, en, ru) в местах со строками рендерят оба языка из одного пути кода — намеренно, вместо отдельного файла-рендера, который мог бы незаметно разойтись.
  • cli.py (~475 строк) — приложение Typer, все флаги и весь конвейер фаз diagnose(); разделение разбора флагов и того, что они переключают, добавило бы только лишний импорт.
  • models.py (~330 строк) — все общие датаклассы плюс to_jsonable(); это намеренно единственное место, определяющее словарь отчёта.
  • interpret.py (~320 строк) — пороговые полосы, генераторы находок по задержке/маршруту/скорости, скоринг 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         (параллельно)
                |
                +-- latency || веер traceroute            (параллельно, ограничено семафором)
                |
                +-- speed                                  (эксклюзивная фаза)
                |
                +-- interpret  ---> Finding[] + общий вердикт
                |
                +-- exporter   ---> logs/report_<ASN>_<ts>.{md,ru.md,json}

Два жёстких ограничения по конкурентности:

  1. Изоляция измерений. Замер скорости никогда не пересекается с трассировкой, пингами и запросами к API — он держит эксклюзивную блокировку фазы. Единственное намеренное исключение — проба bufferbloat, которая по замыслу работает во время насыщения канала, внутри speed.py.
  2. Ограниченный веер трассировок. Параллельные трассировки к разным целям делят первые хопы и завышают задержки друг друга, поэтому число одновременных трассировок ограничено семафором (probing.trace_concurrency, по умолчанию 2).

Ключевые решения

Отказ — это значение, а не поток управления. Каждый вызов модуля проходит через 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 значит в списке» помечает красным большинство пользователей. Оба случая подаются как результат недоступен, но никогда как находка.

Массовые эндпоинты RIPEstat всегда ограничены. routing-history и подобные на крупных провайдерах возвращают 10 МБ+, поэтому каждый такой вызов несёт max_rows и временное окно из config.yaml.

PeeringDB кэшируется на диск. Анонимные лимиты — 20 запросов в минуту, а крупные ответы душатся до одного в час, поэтому ответ не запрашивается повторно внутри прогона и сохраняется в .cache/ между прогонами на providers.peeringdb_cache_hours.

В JSON-дампе есть раздел raw. Каждый ответ провайдера сохраняется дословно под ключом источника. Типизированный слой над ним неизбежно теряет поля; именно raw действительно выполняет требование «100% собранных данных без усечения».

Хранение

  • ./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 с предупреждением и никогда не роняет прогон.

Тесты

uv run pytest -q. Около 385 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы psutil/ctypes и цикл --watch. Фикстуры трассировок лежат в tests/fixtures/traceroute/{windows,linux,darwin}/ и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры.