document L4-L7 diagnostic modules and the python 3.14 bump

This commit is contained in:
Andrew Pimenov 2026-08-10 21:26:46 +03:00
parent a21197f83b
commit 3a0f95c793
4 changed files with 120 additions and 30 deletions

View file

@ -26,18 +26,23 @@ src/netsleuth/
compare.py --compare: diff two saved JSON reports.
watch.py --watch: periodic re-run loop with a live Rich dashboard.
probes/
latency.py Ping fan-out and jitter/loss/mdev statistics.
latency.py Ping fan-out and jitter/loss/mdev/p95/p99 statistics.
traceroute.py Cascade: mtr --json -> icmp_win -> icmplib -> system binary text, plus the opt-in tcp_trace tier.
icmp_win.py ctypes IcmpSendEcho2 / Icmp6SendEcho2 engine (Windows only).
dns_leak.py Per-adapter resolver enumeration, echo probes, ECS detection.
tls_rtt.py --tls: TCP RTT + TLS 1.3 handshake + TTFB via two sequential connections.
dns_advanced.py --dns-advanced: system DNS vs DoH, transparent-proxy probe on a bogon resolver IP.
bgp_path.py --path-diversity: CF-RAY edge colo lookup, international-routing-loop detection.
prefix_benchmark.py --prefix-bench: ping the first host of a capped sample of the AS's announced prefixes.
dpi_check.py --my-server: single-host TCP RST/filtering self-diagnostic. Not a scanner — see Key decisions.
```
**File-size exceptions.** The ~200-line guideline (Global Constraints) held for most modules, but these grew past it during implementation and were kept as single files rather than split, because each one is a single cohesive responsibility that splitting would only scatter across more files with a thinner reason to draw the line anywhere in particular:
- `exporter.py` (~680 lines) — JSON and both Markdown languages are renderers of the same report shape; keeping them together is what guarantees the English and Russian reports can never drift on which sections or rows exist. `render_markdown(report, lang="en"|"ru")` and a handful of inline `_t(lang, en, ru)` calls at each string site render both languages from one code path, deliberately, instead of a second renderer file that could silently fall out of sync.
- `cli.py` (~475 lines) — the Typer app, every flag, and the full `diagnose()` phase pipeline; splitting flag parsing from what the flags toggle would just add an import hop.
- `models.py` (~330 lines) — every shared dataclass plus `to_jsonable()`; this is deliberately the one place that defines the report's vocabulary.
- `interpret.py` (~320 lines) — threshold bands, the latency/path/speed finding generators, VPN scoring and bufferbloat grading all reason over the same `Finding[]` shape.
- `exporter.py` (~910 lines) — JSON and both Markdown languages are renderers of the same report shape; keeping them together is what guarantees the English and Russian reports can never drift on which sections or rows exist. `render_markdown(report, lang="en"|"ru")` and a handful of inline `_t(lang, en, ru)` calls at each string site render both languages from one code path, deliberately, instead of a second renderer file that could silently fall out of sync. This grew by five sections (TLS, DNS advanced, path diversity, prefix bench, DPI check) without changing that argument.
- `cli.py` (~805 lines) — the Typer app, every flag, and the full `diagnose()` phase pipeline; splitting flag parsing from what the flags toggle would just add an import hop.
- `models.py` (~490 lines) — every shared dataclass plus `to_jsonable()`; this is deliberately the one place that defines the report's vocabulary.
- `interpret.py` (~600 lines) — threshold bands, the latency/path/speed/TLS/DNS/DPI/anycast/prefix finding generators, VPN scoring and bufferbloat grading all reason over the same `Finding[]` shape.
- `bgp.py` (~305 lines) — four independent providers (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) plus the disk cache they share.
- `ip_geo.py` (~280 lines) — six independent provider normalizers plus the field-wise merge that reconciles them.
- `netinfo.py`, `speed.py` (~260 lines each) — OS-specific capability probing and the four-tier speedtest cascade both fan out into several backends that don't split cleanly by single responsibility.
@ -51,9 +56,13 @@ src/netsleuth/
|
cli.py -------+-- ip_geo provider chain ---> egress IP + ASN [blocking: everything downstream needs the ASN]
|
+-- bgp || reputation || dns_leak (parallel)
+-- bgp || reputation || dns_leak || dns_advanced || path_diversity (parallel)
|
+-- latency || traceroute fan-out (parallel, semaphore-bounded)
+-- latency || traceroute || tls fan-out (parallel, semaphore-bounded)
|
+-- prefix_benchmark (opt-in, sequential — isolated from the latency fan-out above)
|
+-- dpi_check (opt-in, sequential — single-host, rate-limited)
|
+-- speed (exclusive phase lock)
|
@ -62,10 +71,11 @@ src/netsleuth/
+-- exporter ---> logs/report_<ASN>_<ts>.{md,ru.md,json}
```
Two hard concurrency constraints:
Three hard concurrency constraints:
1. **Measurement isolation.** The speedtest never overlaps traceroute, ping or API calls — it holds an exclusive phase lock. The single intentional exception is the bufferbloat probe, which overlaps saturation by design, inside `speed.py`.
2. **Bounded traceroute fan-out.** Parallel traceroutes to different targets share early hops and inflate each other's latency, so concurrent traces are capped by a semaphore (`probing.trace_concurrency`, default 2).
3. **The AS prefix benchmark never shares a phase with the reference-host latency fan-out.** Pinging up to `prefix_bench.max_prefixes` (capped at 256, default 32) hosts at once would both skew its own results and pollute the latency numbers measured for the fixed reference hosts in the same window, so it runs as its own sequential, opt-in step (`--prefix-bench`) after phase 3 finishes, never inside `--full`.
## Key decisions
@ -93,6 +103,29 @@ Two hard concurrency constraints:
**The JSON dump carries a `raw` section.** Every provider response is stored verbatim under its source key. The typed layer above it necessarily drops fields; `raw` is what actually satisfies "100% of collected data, no truncation".
**Why the DPI check is not a port scanner.** `probes/dpi_check.py` is, mechanically, code that opens TCP connections to an IP on several ports — the same primitive a port scanner uses. The difference between a self-diagnostic and a scanner is the constraints wrapped around that primitive, and they are load-bearing, not cosmetic:
1. **The target is only ever the string the user typed after `--my-server`.** `check_dpi(target, resolved_ip, ports, ...)` takes both as required positional-or-keyword arguments with no default — there is no code path that fills them in from `IpGeo.ip`, `CfTrace.ip`, `--target`, or `BgpIntel.announced_prefixes`. `tests/test_dpi_check.py` pins this via `inspect.signature`.
2. **The port list is fixed and short.** `dpi_check.ports` in `config.yaml` defaults to six ports and is capped at 16 by a pydantic validator in `config.py` — there is no `--ports`/range flag, and raising the cap requires editing and redeploying the config, not passing a CLI argument.
3. **One host per run.** `--my-server` takes a single value; CIDR input is rejected outright in `cli.py` before `Options` is even built (`"this is a single-host self-diagnostic, not a network scanner"`).
4. **Rate-limited by construction.** `asyncio.Semaphore(dpi_check.concurrency)` (capped at 4) plus `asyncio.sleep(delay_between_ports_seconds)` between probe starts; one attempt per port, no retries.
5. **Consent is printed, not just documented.** Before probing, `cli.py` prints which host and how many ports are about to be touched and that this must be a host the user owns or is authorised to test — the same pattern as the existing `--tcp-trace` warning, not the file-marker consent `--ndt7` uses, because there's no third-party data disclosure here, only a question of authorization.
6. **The target is audited.** `meta["dpi_target"]` in every JSON report records exactly what was checked.
The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment for the same reason at a different scale: pinging the first host of *every* prefix a large ISP announces is mass probing of a network the user does not necessarily own. `prefix_bench.max_prefixes` is capped at 256 in a validator (default 32), the probe is ICMP-only (never TCP-connect), and it is never enabled by `--full` — only by explicit `--prefix-bench`.
**TLS handshake timing uses two sequential connections, not `ssl.SSLObject.start_tls()` on one.** `probes/tls_rtt.py` opens a plain TCP connection first (`tcp_connect_rtt()`, already used by `latency.py`) to get a TCP-only baseline, then a second `asyncio.open_connection(..., ssl=...)` to the same host and subtracts the baseline from the second connection's total time to get `tls_handshake_ms`. This slightly overstates the true handshake time (a second TCP handshake happens too) but avoids the complexity of upgrading one live connection mid-flight, and the report says explicitly, in a footnote under the table, that the number is derived by subtraction — not presented as a lab-grade measurement.
**`dns_advanced.py` and `dns_leak.py` answer different questions and must not merge.** `dns_leak.py` asks "where do my DNS queries physically exit to the internet" (echo probes, resolver-ASN-vs-egress-ASN comparison) and feeds the VPN-confidence scoring. `dns_advanced.py` asks "how fast and how honest are the answers" (system vs DoH timing and content) and feeds its own findings. They share nothing but `dnspython`; a shared helper (`resolver_for`-style) was deliberately not extracted, because the one place they'd overlap (resolver construction) is three lines each and not worth a coupling.
**A resolver answering differently from DoH is not, by itself, evidence of anything.** Any CDN behind geo-DNS or ECS legitimately hands different resolvers different IPs for the same name. `dns_advanced.compare_answers()` records the difference as plain text; `detect_poisoning()` only escalates to a finding when the *system* resolver's answer is itself bogus (`0.0.0.0`, loopback, private, or RFC 5737) while DoH's answer is a normal public address — that specific asymmetry, not mere disagreement, is what a poisoned/hijacked response looks like.
**The Cloudflare colo→city/country table is a curated ~30-entry dictionary, not a full IATA database.** Pulling in a full airport-code package for this one lookup would be a new dependency for a feature that only needs to answer "is the edge PoP in a different country than the client" for a modest, CIS/Europe-weighted set of colos. An unrecognized code renders as "unknown" rather than guessing.
**The international-routing-loop check compares the client's own geolocated country against the edge PoP's country — nothing else.** An earlier version of `detect_international_loop()` also required the *target's* resolved-IP geolocation to match the client's country before it would fire, on the theory that this ruled out an unrelated confound. In practice nothing ever populated that field, so the check could never fire at all — caught by manually running `--path-diversity` end-to-end and inspecting the JSON output, not by any unit test, since the unit tests exercised the function directly and simply supplied the value the buggy production code path never provided. The lesson generalized: two inputs beat three when the third can't reliably be filled in, and a smoke run of new opt-in flags belongs in the same pass as the unit tests, not after them.
**Python 3.14 is the floor, not 3.10.** The project moved off `>=3.10` when this batch of probes was added; none of the new code actually depends on a 3.11+-only construct (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — the two-sequential-connection TLS design above was chosen independently of what the interpreter allows. The version floor moved because there was no longer a reason to hold it back, not because a specific new feature required it.
## Storage
- `./logs/report_<ASN>_<YYYYMMDDTHHMMSSZ>.{md,ru.md,json}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all three: English Markdown, a full Russian translation with identical structure, and the JSON dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `<ASN>` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. Compact ISO timestamps because Windows forbids `:` in filenames. Falls back to `report_unknown_…` when the ASN lookup fails entirely. Written temp-file-plus-`os.replace()`, always atomic. Gitignored: reports contain the external IP, city, coordinates and ISP name.
@ -105,6 +138,8 @@ Two hard concurrency constraints:
`config.py` builds a pydantic-settings `Settings` object whose sources are ordered **init > environment > `.env` > `config.yaml` > field default**. The three API keys are `SecretStr | None = None` at the top level: a diagnostics tool must run with zero configuration, so a missing key downgrades the enrichment that needs it to `skipped` with a warning and never fails the run.
Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a validator-enforced ceiling where the feature could otherwise become mass probing: `tls`, `dns_advanced`, `path_diversity`, `prefix_bench` (`max_prefixes` capped at 256), `dpi_check` (`ports` capped at 16 entries, `concurrency` capped at 4).
## Tests
`uv run pytest -q`. Roughly 385 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers.
`uv run pytest -q`. Roughly 705 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.

View file

@ -26,18 +26,23 @@ src/netsleuth/
compare.py --compare: сравнение двух сохранённых JSON-отчётов.
watch.py --watch: цикл периодических прогонов с живой панелью Rich.
probes/
latency.py Веер пингов и статистика джиттера/потерь/mdev.
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` (~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[]`.
- `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 строк каждый) — определение возможностей по ОС и каскад замера скорости из четырёх уровней оба веерятся по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.
@ -51,9 +56,13 @@ src/netsleuth/
|
cli.py -------+-- цепочка ip_geo ---> внешний IP + ASN [блокирующая фаза: всё дальше зависит от ASN]
|
+-- bgp || reputation || dns_leak (параллельно)
+-- bgp || reputation || dns_leak || dns_advanced || path_diversity (параллельно)
|
+-- latency || веер traceroute (параллельно, ограничено семафором)
+-- latency || traceroute || tls (параллельно, ограничено семафором)
|
+-- prefix_benchmark (опционально, последовательно — изолирован от веера latency выше)
|
+-- dpi_check (опционально, последовательно — один хост, с ограничением скорости)
|
+-- speed (эксклюзивная фаза)
|
@ -62,10 +71,11 @@ src/netsleuth/
+-- 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`.
## Ключевые решения
@ -93,6 +103,29 @@ src/netsleuth/
**В 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, город, координаты и название провайдера.
@ -105,6 +138,8 @@ src/netsleuth/
`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`. Около 385 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры.
`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` рядом с фикстурами других провайдеров.

View file

@ -17,7 +17,7 @@ Everything else — egress ASN, VPN verdict, latency, path, speed — lives in t
Requirements:
- Python 3.10 or newer
- Python 3.14 or newer
- Windows, Linux or macOS
- Internet access (netsleuth is a network tool; it degrades gracefully but cannot do much offline)
- Optional: the native Ookla `speedtest` binary on `PATH` for the best bandwidth tier
@ -62,8 +62,13 @@ None of this is required — netsleuth degrades gracefully without any of it —
- **DNS leak test** — enumerates resolvers *per network adapter*, so it catches the classic case where the tunnel adapter is clean but the Wi-Fi adapter still holds the ISP resolver. Also detects EDNS Client Subnet leakage.
- **BGP intelligence** — upstreams, peers, downstreams, announced prefixes, route stability, IXP presence, and CAIDA ASRank customer-cone size.
- **Reputation** — locally cached FireHOL blocklists (no query leaves your machine), Shodan InternetDB exposure, and optional classic DNSBL zones.
- **Latency & path** — per-host ping statistics with real jitter/loss/mdev, and a traceroute that never needs administrator rights.
- **Latency & path** — per-host ping statistics with real jitter/loss/mdev/p95/p99, and a traceroute that never needs administrator rights.
- **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades.
- **TLS handshake RTT** (`--tls`) — TCP RTT, TLS 1.3 handshake time and time-to-first-byte to reference services, flagging servers whose handshake is disproportionately slow relative to the network (CPU-bound TLS termination, not a network problem).
- **DNS: system vs DoH** (`--dns-advanced`) — compares your system resolver's answers and latency against Cloudflare/Google/Quad9 DoH, and probes a bogus resolver IP to detect a transparent DNS proxy intercepting port 53.
- **Path diversity / Anycast** (`--path-diversity`) — reads Cloudflare's `CF-RAY` edge code to find which PoP is actually serving your traffic, and flags an international routing loop when it's in a different country than you are.
- **AS prefix benchmark** (`--prefix-bench`) — pings the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP. Capped at 256 prefixes and off by default — this samples your own network, it does not scan it.
- **DPI / port self-check** (`--my-server <host>`) — a single-host TCP probe across 6 fixed ports on **a server you own**, classifying TCP RST injection vs silent filtering vs a clean response. Not a port scanner: one host per run, a fixed short port list, and it never reads its target from your own egress IP or from BGP data — see [ARCHITECTURE.md](ARCHITECTURE.md) for why.
## Handy things
@ -77,6 +82,11 @@ None of this is required — netsleuth degrades gracefully without any of it —
| `--watch` | Continuous monitoring with a live dashboard — for catching intermittent drops. Writes one `logs/watch_*.json` time series per session, not one report per tick |
| `--compare a.json b.json` | Diff two earlier reports: before/after a VPN switch, before/after an ISP call. Read-only — no probing, no network calls. Reads reports produced with `--format json` |
| `--dnsbl` | Opt in to classic DNSBL reputation zones |
| `--tls` | Measure TCP/TLS handshake RTT and TTFB to reference services. Included in `--full` |
| `--dns-advanced` | Compare system DNS vs DoH and probe for a transparent DNS proxy. Included in `--full` |
| `--path-diversity` | Compare client geo against the Cloudflare edge PoP actually serving traffic. Included in `--full` |
| `--prefix-bench` | Ping the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP. **Not** included in `--full` — opt in explicitly |
| `--my-server <host>` | Check a server **you own** for TCP port blocking / RST injection. Single host only — rejects CIDR input outright. **Not** included in `--full` — opt in explicitly |
| `--ndt7` | Opt in to M-Lab NDT7 (publishes your measurement, including your IP, as CC0 open data). On an interactive terminal it prints the consent notice and asks first; on a non-interactive one it proceeds without asking |
| `--tcp-trace` | Opt in to a scapy TCP-SYN traceroute through ICMP-filtering middleboxes (needs the `tcptrace` extra plus Npcap on Windows or root on Unix). Falls through to the normal cascade when it cannot run |
| `--format md,ru-md,json,all,none` | Choose which report artifacts to write (default: `md`). Repeatable and comma-separated (`--format md,json`); any use replaces the default instead of adding to it. `all` writes all three, `none` writes nothing. Ignored with `--watch`/`--compare` |
@ -90,7 +100,7 @@ netsleuth never requires administrator or root. Where privileges would give bett
## For developers
Stack: Python 3.10+, Typer, Rich, httpx, pydantic-settings, dnspython, psutil, icmplib, ctypes.
Stack: Python 3.14+, Typer, Rich, httpx, pydantic-settings, dnspython, psutil, icmplib, ctypes.
```bash
uv sync --all-extras --group dev

View file

@ -17,7 +17,7 @@ Report written to logs/report_AS64500_20260808T191200Z.md
Требования:
- Python 3.10 или новее
- Python 3.14 или новее
- Windows, Linux или macOS
- Доступ в интернет (netsleuth — сетевой инструмент; без сети он деградирует корректно, но сделает немного)
- Опционально: нативный бинарник Ookla `speedtest` в `PATH` — лучший уровень замера скорости
@ -62,8 +62,13 @@ netsleuth
- **Тест утечки DNS** — перечисляет резолверы *по каждому сетевому адаптеру*, поэтому ловит классический случай, когда туннельный адаптер чистый, а на Wi-Fi-адаптере остался резолвер провайдера. Также определяет утечку EDNS Client Subnet.
- **BGP-разведка** — аплинки, пиры, клиенты, анонсируемые префиксы, стабильность маршрутов, присутствие на IX и размер клиентского конуса по CAIDA ASRank.
- **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL.
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev и трассировка, которой никогда не нужны права администратора.
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора.
- **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу.
- **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть).
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
- **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент.
- **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование.
- **Самопроверка DPI / портов** (`--my-server <host>`) — одиночная TCP-проба по 6 фиксированным портам **сервера, которым вы владеете**, с классификацией инъекции TCP RST, тихой фильтрации или чистого ответа. Это не портсканер: один хост за запуск, фиксированный короткий список портов, и цель никогда не берётся из вашего собственного внешнего IP или из данных BGP — почему именно так, см. [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
## Полезное
@ -77,6 +82,11 @@ netsleuth
| `--watch` | Непрерывный мониторинг с живой панелью — чтобы поймать плавающие обрывы. Пишет один тайм-ряд `logs/watch_*.json` за сессию, а не отдельный отчёт на каждый тик |
| `--compare a.json b.json` | Сравнить два прошлых отчёта: до/после смены VPN, до/после звонка провайдеру. Только чтение — без проб и сетевых запросов. Читает отчёты, полученные с `--format json` |
| `--dnsbl` | Включить проверку по классическим зонам DNSBL |
| `--tls` | Замерить TCP/TLS RTT рукопожатия и TTFB к эталонным сервисам. Входит в `--full` |
| `--dns-advanced` | Сравнить системный DNS с DoH и проверить прозрачный DNS-прокси. Входит в `--full` |
| `--path-diversity` | Сравнить гео клиента с реальной точкой Cloudflare, обслуживающей трафик. Входит в `--full` |
| `--prefix-bench` | Пропинговать первый хост нескольких анонсируемых префиксов своей AS в поисках точки с наименьшей задержкой. **Не** входит в `--full` — включается явно |
| `--my-server <host>` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно |
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |
| `--format md,ru-md,json,all,none` | Выбрать, какие артефакты отчёта писать (по умолчанию: `md`). Повторяемый флаг и через запятую (`--format md,json`); любое использование заменяет дефолт, а не дополняет его. `all` пишет все три, `none` — ничего. Игнорируется с `--watch`/`--compare` |
@ -90,7 +100,7 @@ netsleuth никогда не требует администратора или
## Для разработчиков
Стек: Python 3.10+, Typer, Rich, httpx, pydantic-settings, dnspython, psutil, icmplib, ctypes.
Стек: Python 3.14+, Typer, Rich, httpx, pydantic-settings, dnspython, psutil, icmplib, ctypes.
```bash
uv sync --all-extras --group dev