mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 18:02:04 +03:00
145 lines
33 KiB
Markdown
145 lines
33 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), не этот модуль.
|
||
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/фильтрации. Не сканер — см. Ключевые решения.
|
||
```
|
||
|
||
**Исключения по размеру файла.** Лимит ~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}
|
||
```
|
||
|
||
Три жёстких ограничения по конкурентности:
|
||
|
||
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, а не юнит-тестом: тесты дёргали функцию напрямую и сами подставляли значение, которое рабочий код так и не научился передавать. Вывод общий: два входных параметра лучше трёх, если третий нечем надёжно заполнить, а смоук-прогон новых опциональных флагов должен идти в том же проходе, что юнит-тесты, а не после них.
|
||
|
||
**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`. Около 705 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|