mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-11 01:02:10 +03:00
measure IPv6 reference-host latency alongside IPv4
Scoped down from the original plan: traceroute and --path-diversity's v6 legs are deferred as a documented follow-up, since extending the trace cascade to a second address family is real additional work that doesn't share the trick that made latency cheap. Latency alone captures the core value (a tunneled or poorly-peered IPv6 path is otherwise invisible to tools that only report whichever stack the OS picked) at a fraction of the risk. Turns out no family-detection code was needed at all. icmp_win's ping_samples_win() calls the IPv4-only socket.gethostbyname(), which raises on a v6 literal - already caught by ping_host()'s existing bare except-fallback to TCP, which handles v6 fine via asyncio.open_connection. icmplib handles v6 natively on Linux/macOS. So v6 hosts ride the existing ping_fanout() completely unmodified, tagged with a "<label>-v6" PingResult label instead of a parallel report section - exporter, metrics.py and compare.py needed zero changes since they already key everything off label. New probing.reference_hosts_v6/probing.ipv6 (auto/on/off) config, --ipv6/--no-ipv6 override, interpret.dual_family_findings() for net.ipv6_unreachable and latency.v6_much_slower. Live-tested with --ipv6 forced on a v6-unreachable host: every row fell back to method=tcp with 100% loss as expected. Caught and fixed a wording bug in the same pass - the unreachable finding said "on this dual-stack connection" unconditionally, which is wrong when --ipv6 forces the check on a host that isn't actually dual-stack. Part of plans/zesty-zooming-wilkinson.md (gitignored).
This commit is contained in:
parent
a64022b12a
commit
10846e3017
10 changed files with 179 additions and 6 deletions
|
|
@ -166,6 +166,12 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
|
||||||
|
|
||||||
**`--ecmp` is opt-in and capped, and never joins `--full`.** It multiplies traceroute traffic against the same target by `ecmp.runs` (default 3, capped at 5) — per the trace-concurrency reasoning already established (parallel traces to the *same* target inflate each other's latency worse than parallel traces to different targets), this is deliberately sequential per target and shares one fresh semaphore across `ecmp.max_targets` (capped at 3) targets, run only after the main latency/path/TLS fan-out has already finished.
|
**`--ecmp` is opt-in and capped, and never joins `--full`.** It multiplies traceroute traffic against the same target by `ecmp.runs` (default 3, capped at 5) — per the trace-concurrency reasoning already established (parallel traces to the *same* target inflate each other's latency worse than parallel traces to different targets), this is deliberately sequential per target and shares one fresh semaphore across `ecmp.max_targets` (capped at 3) targets, run only after the main latency/path/TLS fan-out has already finished.
|
||||||
|
|
||||||
|
**IPv6 latency rides the existing ping fan-out with a distinct label, not a parallel `latency_v6` section.** `probing.reference_hosts_v6` (literal IPv6 addresses) are fed into the same `ping_fanout()` used for IPv4, labeled `"<label>-v6"`, and the results are appended to `modules["latency"].data` after the Phase 3 fan-out — `exporter._latency()`, `metrics.collect_metrics()` and `compare.py` all key everything off `PingResult.label` already, so this needed zero changes to any of them. `interpret.dual_family_findings()` pairs a `"cloudflare-dns-v6"` row back to its `"cloudflare-dns"` twin by stripping the suffix, entirely in application code — the report schema itself doesn't know IPv6 exists as a separate concept.
|
||||||
|
|
||||||
|
**No family-detection code was needed for the IPv6 ping path itself, because the existing fallback chain already handles it correctly.** `icmp_win`'s `ping_samples_win()` calls `socket.gethostbyname()`, which is IPv4-only and raises `socket.gaierror` on an IPv6 literal — caught by `latency.py::ping_host()`'s existing bare `except Exception: backend = "tcp"`, which then correctly pings the v6 literal via `asyncio.open_connection` (family-agnostic). On Linux/macOS, `icmplib.async_ping()` natively detects the address family from the literal. Verified live with `--ipv6` forced on a v6-unreachable host: every v6 row correctly fell back to `method="tcp"` and reported 100% loss, exactly as designed, no crash.
|
||||||
|
|
||||||
|
**IPv6 traceroute and `--path-diversity` are deliberately out of scope for this phase.** Extending the trace cascade and `bgp_path.py` to a second address family is real additional work (`icmp_win`'s trace tier is IPv4-only for the same `gethostbyname()` reason above, though `run_cascade()`'s per-tier exception handling means it would at worst fall through to `system_traceroute` rather than corrupt a report) that doesn't share the "tag with a label, touch nothing downstream" trick latency did — left as a documented follow-up rather than expanding this phase to match.
|
||||||
|
|
||||||
**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.
|
**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
|
## Storage
|
||||||
|
|
@ -184,4 +190,4 @@ Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`uv run pytest -q`. Roughly 837 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.
|
`uv run pytest -q`. Roughly 844 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.
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,12 @@ src/netsleuth/
|
||||||
|
|
||||||
**`--ecmp` опционален и ограничен, и никогда не входит в `--full`.** Он умножает трафик трассировки к одной и той же цели на `ecmp.runs` (по умолчанию 3, потолок 5) — по уже установленной логике конкурентности трассировок (параллельные трассировки к *одной* цели искажают задержку друг друга хуже, чем параллельные трассировки к разным целям), это намеренно последовательно по каждой цели и делит один свежий семафор между `ecmp.max_targets` (потолок 3) целями, выполняясь только после того, как основной веер latency/path/TLS уже завершился.
|
**`--ecmp` опционален и ограничен, и никогда не входит в `--full`.** Он умножает трафик трассировки к одной и той же цели на `ecmp.runs` (по умолчанию 3, потолок 5) — по уже установленной логике конкурентности трассировок (параллельные трассировки к *одной* цели искажают задержку друг друга хуже, чем параллельные трассировки к разным целям), это намеренно последовательно по каждой цели и делит один свежий семафор между `ecmp.max_targets` (потолок 3) целями, выполняясь только после того, как основной веер latency/path/TLS уже завершился.
|
||||||
|
|
||||||
|
**Задержка IPv6 едет на существующем веере пингов с отдельной меткой, а не в параллельной секции `latency_v6`.** `probing.reference_hosts_v6` (литералы IPv6) подаются в тот же `ping_fanout()`, что и IPv4, с меткой `"<label>-v6"`, и результаты добавляются в `modules["latency"].data` после веера Фазы 3 — `exporter._latency()`, `metrics.collect_metrics()` и `compare.py` уже ключуют всё по `PingResult.label`, так что это потребовало нулевых изменений в них. `interpret.dual_family_findings()` сопоставляет строку `"cloudflare-dns-v6"` с её парой `"cloudflare-dns"`, отрезая суффикс, целиком в прикладном коде — сама схема отчёта не знает об IPv6 как об отдельном понятии.
|
||||||
|
|
||||||
|
**Для самого пути пинга IPv6 не понадобился код определения семейства адреса, потому что существующая цепочка деградации уже справляется корректно.** `ping_samples_win()` в `icmp_win` вызывает `socket.gethostbyname()`, который работает только с IPv4 и падает с `socket.gaierror` на литерале IPv6 — это ловится уже существующим голым `except Exception: backend = "tcp"` в `latency.py::ping_host()`, который затем корректно пингует v6-литерал через `asyncio.open_connection` (не зависящий от семейства). На Linux/macOS `icmplib.async_ping()` нативно определяет семейство адреса по литералу. Проверено вживую с форсированным `--ipv6` на хосте без реального IPv6: каждая v6-строка корректно откатилась на `method="tcp"` и показала 100% потерь, ровно как задумано, без падения.
|
||||||
|
|
||||||
|
**Трассировка IPv6 и `--path-diversity` намеренно вне рамок этой фазы.** Расширение каскада трассировки и `bgp_path.py` на второе семейство адресов — это реальная дополнительная работа (уровень трассировки `icmp_win` — IPv4-only по той же причине `gethostbyname()` выше, хотя обработка исключений по уровням в `run_cascade()` означает, что в худшем случае это просто откатится на `system_traceroute`, а не испортит отчёт), которая не разделяет трюк «пометить меткой, ничего не трогать ниже по потоку», сработавший для latency — оставлено как задокументированное продолжение, а не раздувание этой фазы под него.
|
||||||
|
|
||||||
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
|
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
|
||||||
|
|
||||||
## Хранение
|
## Хранение
|
||||||
|
|
@ -184,4 +190,4 @@ src/netsleuth/
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
`uv run pytest -q`. Около 837 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|
`uv run pytest -q`. Около 844 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ 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.
|
- **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.
|
- **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.
|
- **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/p95/p99, and a traceroute that never needs administrator rights. The first hop is diagnosed separately from the rest of the route, so loss or slowness there is correctly attributed to your own router/Wi-Fi rather than the ISP. Every hop is annotated with its ASN, AS name and country, and flags a route that detours through a third country on the way to the destination.
|
- **Latency & path** — per-host ping statistics with real jitter/loss/mdev/p95/p99, and a traceroute that never needs administrator rights. The first hop is diagnosed separately from the rest of the route, so loss or slowness there is correctly attributed to your own router/Wi-Fi rather than the ISP. Every hop is annotated with its ASN, AS name and country, and flags a route that detours through a third country on the way to the destination. On a dual-stack connection, latency to IPv6 reference hosts is measured alongside IPv4 and compared side by side — catching a tunneled or poorly-peered IPv6 path that's invisible to tools that only report whichever stack the OS happened to pick.
|
||||||
- **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades, and a flag when the test server landed in a different country than you.
|
- **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades, and a flag when the test server landed in a different country than you.
|
||||||
- **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). Also captures each certificate's SHA-256 fingerprint, issuer and days until expiry, flags a validation failure, and checks against an optional pinned fingerprint (`tls.pinned_fingerprints` in `config.yaml`) to catch a corporate/ISP TLS-intercepting middlebox or a genuine MITM.
|
- **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). Also captures each certificate's SHA-256 fingerprint, issuer and days until expiry, flags a validation failure, and checks against an optional pinned fingerprint (`tls.pinned_fingerprints` in `config.yaml`) to catch a corporate/ISP TLS-intercepting middlebox or a genuine MITM.
|
||||||
- **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.
|
- **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.
|
||||||
|
|
@ -92,6 +92,7 @@ None of this is required — netsleuth degrades gracefully without any of it —
|
||||||
| `--path-diversity` | Compare client geo against the Cloudflare edge PoP actually serving traffic. 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 |
|
| `--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 |
|
||||||
| `--ecmp` | Trace a couple of reference hosts multiple times (`ecmp.runs`, capped at 5) to detect per-hop load balancing and measure how unequal the branches are. **Not** included in `--full` — it multiplies traceroute traffic to the same targets, opt in explicitly |
|
| `--ecmp` | Trace a couple of reference hosts multiple times (`ecmp.runs`, capped at 5) to detect per-hop load balancing and measure how unequal the branches are. **Not** included in `--full` — it multiplies traceroute traffic to the same targets, opt in explicitly |
|
||||||
|
| `--ipv6` / `--no-ipv6` | Force IPv6 reference-host latency measurement on or off, overriding `probing.ipv6` (default `auto`: only on a dual-stack connection). Free to leave at auto — a no-op on a v4-only host |
|
||||||
| `--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 |
|
| `--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 |
|
| `--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 |
|
| `--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 |
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ netsleuth
|
||||||
- **Тест утечки DNS** — перечисляет резолверы *по каждому сетевому адаптеру*, поэтому ловит классический случай, когда туннельный адаптер чистый, а на Wi-Fi-адаптере остался резолвер провайдера. Также определяет утечку EDNS Client Subnet.
|
- **Тест утечки DNS** — перечисляет резолверы *по каждому сетевому адаптеру*, поэтому ловит классический случай, когда туннельный адаптер чистый, а на Wi-Fi-адаптере остался резолвер провайдера. Также определяет утечку EDNS Client Subnet.
|
||||||
- **BGP-разведка** — аплинки, пиры, клиенты, анонсируемые префиксы, стабильность маршрутов, присутствие на IX и размер клиентского конуса по CAIDA ASRank.
|
- **BGP-разведка** — аплинки, пиры, клиенты, анонсируемые префиксы, стабильность маршрутов, присутствие на IX и размер клиентского конуса по CAIDA ASRank.
|
||||||
- **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL.
|
- **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL.
|
||||||
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора. Первый хоп диагностируется отдельно от остального маршрута, поэтому потери или медлительность там правильно относятся к вашему роутеру/Wi-Fi, а не к провайдеру. Каждый хоп размечен своим ASN, именем AS и страной, а маршрут, уходящий крюком через третью страну, отмечается отдельно.
|
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора. Первый хоп диагностируется отдельно от остального маршрута, поэтому потери или медлительность там правильно относятся к вашему роутеру/Wi-Fi, а не к провайдеру. Каждый хоп размечен своим ASN, именем AS и страной, а маршрут, уходящий крюком через третью страну, отмечается отдельно. На dual-stack соединении задержка до эталонных хостов IPv6 измеряется наравне с IPv4 и сравнивается бок о бок — ловит туннелированный или плохо пропиренный путь IPv6, невидимый для инструментов, показывающих лишь тот стек, который случайно выбрала ОС.
|
||||||
- **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу и отметкой, если сервер замера оказался в другой стране.
|
- **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу и отметкой, если сервер замера оказался в другой стране.
|
||||||
- **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть). Также фиксирует SHA-256-отпечаток сертификата, издателя и дни до истечения, отмечает провал валидации и сверяет с опциональным закреплённым отпечатком (`tls.pinned_fingerprints` в `config.yaml`), чтобы поймать корпоративный/провайдерский TLS-перехватывающий middlebox или настоящий MITM.
|
- **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть). Также фиксирует SHA-256-отпечаток сертификата, издателя и дни до истечения, отмечает провал валидации и сверяет с опциональным закреплённым отпечатком (`tls.pinned_fingerprints` в `config.yaml`), чтобы поймать корпоративный/провайдерский TLS-перехватывающий middlebox или настоящий MITM.
|
||||||
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
|
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
|
||||||
|
|
@ -92,6 +92,7 @@ netsleuth
|
||||||
| `--path-diversity` | Сравнить гео клиента с реальной точкой Cloudflare, обслуживающей трафик. Входит в `--full` |
|
| `--path-diversity` | Сравнить гео клиента с реальной точкой Cloudflare, обслуживающей трафик. Входит в `--full` |
|
||||||
| `--prefix-bench` | Пропинговать первый хост нескольких анонсируемых префиксов своей AS в поисках точки с наименьшей задержкой. **Не** входит в `--full` — включается явно |
|
| `--prefix-bench` | Пропинговать первый хост нескольких анонсируемых префиксов своей AS в поисках точки с наименьшей задержкой. **Не** входит в `--full` — включается явно |
|
||||||
| `--ecmp` | Трассировать пару эталонных хостов несколько раз (`ecmp.runs`, потолок 5) для детекта балансировки нагрузки по хопам и замера, насколько неравноценны ветки. **Не** входит в `--full` — умножает трафик трассировки к тем же целям, включается явно |
|
| `--ecmp` | Трассировать пару эталонных хостов несколько раз (`ecmp.runs`, потолок 5) для детекта балансировки нагрузки по хопам и замера, насколько неравноценны ветки. **Не** входит в `--full` — умножает трафик трассировки к тем же целям, включается явно |
|
||||||
|
| `--ipv6` / `--no-ipv6` | Принудительно включить или выключить замер задержки до эталонных хостов IPv6, переопределяя `probing.ipv6` (по умолчанию `auto`: только на dual-stack соединении). На v4-only хосте `auto` — это no-op, можно не трогать |
|
||||||
| `--my-server <host>` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно |
|
| `--my-server <host>` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно |
|
||||||
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
|
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
|
||||||
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |
|
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ probing:
|
||||||
- { label: "cloudflare-dns", host: "1.1.1.1" }
|
- { label: "cloudflare-dns", host: "1.1.1.1" }
|
||||||
- { label: "google-dns", host: "8.8.8.8" }
|
- { label: "google-dns", host: "8.8.8.8" }
|
||||||
- { label: "quad9-dns", host: "9.9.9.9" }
|
- { label: "quad9-dns", host: "9.9.9.9" }
|
||||||
|
# Only probed when the host is dual-stack (auto), always (on), or never (off).
|
||||||
|
# Latency to these is measured alongside the IPv4 reference hosts, labeled
|
||||||
|
# "<label>-v6", so a report can compare the two stacks side by side.
|
||||||
|
reference_hosts_v6:
|
||||||
|
- { label: "cloudflare-dns", host: "2606:4700:4700::1111" }
|
||||||
|
- { label: "google-dns", host: "2001:4860:4860::8888" }
|
||||||
|
- { label: "quad9-dns", host: "2620:fe::fe" }
|
||||||
|
ipv6: "auto"
|
||||||
service_hosts:
|
service_hosts:
|
||||||
- { label: "cloudflare", host: "cloudflare.com" }
|
- { label: "cloudflare", host: "cloudflare.com" }
|
||||||
- { label: "google", host: "google.com" }
|
- { label: "google", host: "google.com" }
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from netsleuth.interpret import (
|
||||||
cgnat_findings,
|
cgnat_findings,
|
||||||
dns_advanced_findings,
|
dns_advanced_findings,
|
||||||
dpi_findings,
|
dpi_findings,
|
||||||
|
dual_family_findings,
|
||||||
dual_stack_findings,
|
dual_stack_findings,
|
||||||
ecmp_findings,
|
ecmp_findings,
|
||||||
gather_vpn_signals,
|
gather_vpn_signals,
|
||||||
|
|
@ -91,6 +92,7 @@ class Options:
|
||||||
path_diversity: bool = False
|
path_diversity: bool = False
|
||||||
prefix_bench: bool = False
|
prefix_bench: bool = False
|
||||||
ecmp: bool = False
|
ecmp: bool = False
|
||||||
|
ipv6: Optional[bool] = None
|
||||||
dpi_target: str | None = None
|
dpi_target: str | None = None
|
||||||
formats: frozenset[str] = frozenset({"md"})
|
formats: frozenset[str] = frozenset({"md"})
|
||||||
interface: str | None = None
|
interface: str | None = None
|
||||||
|
|
@ -611,7 +613,10 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
hosts.append(("target", options.target_value))
|
hosts.append(("target", options.target_value))
|
||||||
count = settings.probing.quick_ping_count if options.quick else settings.probing.ping_count
|
count = settings.probing.quick_ping_count if options.quick else settings.probing.ping_count
|
||||||
cycles = settings.probing.quick_mtr_cycles if options.quick else settings.probing.mtr_cycles
|
cycles = settings.probing.quick_mtr_cycles if options.quick else settings.probing.mtr_cycles
|
||||||
modules["latency"], modules["path"], modules["tls"] = await gather_modules(
|
ipv6_policy = {"auto": local.is_dual_stack, "on": True, "off": False}[settings.probing.ipv6]
|
||||||
|
ipv6_enabled = ipv6_policy if options.ipv6 is None else options.ipv6
|
||||||
|
v6_hosts = [(f"{h.label}-v6", h.host) for h in settings.probing.reference_hosts_v6] if ipv6_enabled else []
|
||||||
|
modules["latency"], modules["path"], modules["tls"], v6_result = await gather_modules(
|
||||||
run_module(
|
run_module(
|
||||||
"latency",
|
"latency",
|
||||||
ping_fanout(
|
ping_fanout(
|
||||||
|
|
@ -626,7 +631,23 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
),
|
),
|
||||||
run_module("path", _traces(hosts, caps, settings, cycles, options), timeout=timeouts.subprocess_seconds),
|
run_module("path", _traces(hosts, caps, settings, cycles, options), timeout=timeouts.subprocess_seconds),
|
||||||
run_module("tls", _tls_section(settings, options), timeout=timeouts.module_seconds),
|
run_module("tls", _tls_section(settings, options), timeout=timeouts.module_seconds),
|
||||||
|
run_module(
|
||||||
|
"latency_v6",
|
||||||
|
ping_fanout(
|
||||||
|
v6_hosts,
|
||||||
|
caps,
|
||||||
|
count,
|
||||||
|
settings.probing.ping_interval_seconds,
|
||||||
|
settings.probing.ping_timeout_seconds,
|
||||||
|
source_ip=options.bind.ipv6 if options.bind else None,
|
||||||
|
),
|
||||||
|
timeout=timeouts.subprocess_seconds,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
# v6 rows are tagged onto the same Latency section via a "<label>-v6" label
|
||||||
|
# rather than a parallel section, so exporter/metrics/compare need no changes.
|
||||||
|
modules["latency"].data = (modules["latency"].data or []) + (v6_result.data or [])
|
||||||
|
modules["latency"].warnings += v6_result.warnings
|
||||||
|
|
||||||
# Phase 3b — prefix benchmark and DPI check, opt-in and exclusive: each is a
|
# Phase 3b — prefix benchmark and DPI check, opt-in and exclusive: each is a
|
||||||
# single-target/self-scoped probe that should not share the wire with the
|
# single-target/self-scoped probe that should not share the wire with the
|
||||||
|
|
@ -696,6 +717,7 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
+ dual_stack_findings(bundle.get("nat64_prefix"), local)
|
+ dual_stack_findings(bundle.get("nat64_prefix"), local)
|
||||||
+ captive_portal_findings(modules["captive_portal"].data or CaptivePortal())
|
+ captive_portal_findings(modules["captive_portal"].data or CaptivePortal())
|
||||||
+ [f for report in (modules["ecmp"].data or []) for f in ecmp_findings(report)]
|
+ [f for report in (modules["ecmp"].data or []) for f in ecmp_findings(report)]
|
||||||
|
+ dual_family_findings(pings)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 6 — export
|
# Phase 6 — export
|
||||||
|
|
@ -836,6 +858,11 @@ def run(
|
||||||
"--ecmp",
|
"--ecmp",
|
||||||
help="Trace a handful of reference hosts multiple times to detect per-hop load balancing (ECMP).",
|
help="Trace a handful of reference hosts multiple times to detect per-hop load balancing (ECMP).",
|
||||||
),
|
),
|
||||||
|
ipv6: Optional[bool] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--ipv6/--no-ipv6",
|
||||||
|
help="Also measure latency to IPv6 reference hosts (default: auto, only on a dual-stack connection).",
|
||||||
|
),
|
||||||
my_server: Optional[str] = typer.Option(
|
my_server: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--my-server",
|
"--my-server",
|
||||||
|
|
@ -908,6 +935,7 @@ def run(
|
||||||
path_diversity=path_diversity or full,
|
path_diversity=path_diversity or full,
|
||||||
prefix_bench=prefix_bench,
|
prefix_bench=prefix_bench,
|
||||||
ecmp=ecmp,
|
ecmp=ecmp,
|
||||||
|
ipv6=ipv6,
|
||||||
dpi_target=my_server,
|
dpi_target=my_server,
|
||||||
formats=formats,
|
formats=formats,
|
||||||
webhook=webhook,
|
webhook=webhook,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ class Probing(BaseModel):
|
||||||
HostSpec(label="quad9-dns", host="9.9.9.9"),
|
HostSpec(label="quad9-dns", host="9.9.9.9"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
reference_hosts_v6: list[HostSpec] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
HostSpec(label="cloudflare-dns", host="2606:4700:4700::1111"),
|
||||||
|
HostSpec(label="google-dns", host="2001:4860:4860::8888"),
|
||||||
|
HostSpec(label="quad9-dns", host="2620:fe::fe"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ipv6: str = "auto"
|
||||||
service_hosts: list[HostSpec] = Field(
|
service_hosts: list[HostSpec] = Field(
|
||||||
default_factory=lambda: [
|
default_factory=lambda: [
|
||||||
HostSpec(label="cloudflare", host="cloudflare.com"),
|
HostSpec(label="cloudflare", host="cloudflare.com"),
|
||||||
|
|
@ -55,6 +63,14 @@ class Probing(BaseModel):
|
||||||
max_hops: int = 30
|
max_hops: int = 30
|
||||||
trace_concurrency: int = 2
|
trace_concurrency: int = 2
|
||||||
|
|
||||||
|
@field_validator("ipv6")
|
||||||
|
@classmethod
|
||||||
|
def _validate_ipv6_policy(cls, value: str) -> str:
|
||||||
|
allowed = {"auto", "on", "off"}
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(f"probing.ipv6 must be one of {sorted(allowed)}, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class Speedtest(BaseModel):
|
class Speedtest(BaseModel):
|
||||||
enabled_tiers: list[str] = Field(default_factory=lambda: ["ookla_bin", "cloudflare", "fastcom"])
|
enabled_tiers: list[str] = Field(default_factory=lambda: ["ookla_bin", "cloudflare", "fastcom"])
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,61 @@ def _first_hop_findings(hop: TraceHop, local: LocalNet, t: Thresholds) -> list[F
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
_V6_LABEL_SUFFIX = "-v6"
|
||||||
|
_V6_SLOWER_RATIO = 2.0
|
||||||
|
_V6_SLOWER_ABSOLUTE_MS = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def dual_family_findings(pings: list[PingResult]) -> list[Finding]:
|
||||||
|
by_label = {p.label: p for p in pings}
|
||||||
|
v6_labels = [label for label in by_label if label.endswith(_V6_LABEL_SUFFIX)]
|
||||||
|
if not v6_labels:
|
||||||
|
return []
|
||||||
|
if all(by_label[label].received == 0 for label in v6_labels):
|
||||||
|
return [
|
||||||
|
Finding(
|
||||||
|
id="net.ipv6_unreachable",
|
||||||
|
severity="info",
|
||||||
|
title="IPv6 reference hosts did not answer",
|
||||||
|
detail=f"{len(v6_labels)} IPv6 reference host(s) were tried and none replied.",
|
||||||
|
advice="Common and not necessarily a fault, but explains slow page loads where a browser's "
|
||||||
|
"happy-eyeballs logic falls back to IPv4 after a timeout.",
|
||||||
|
title_ru="Эталонные хосты IPv6 не ответили",
|
||||||
|
detail_ru=f"Опрошено {len(v6_labels)} эталонных хостов IPv6, ни один не ответил.",
|
||||||
|
advice_ru="Часто встречается и не обязательно неисправность, но объясняет медленную "
|
||||||
|
"загрузку страниц, когда happy-eyeballs в браузере откатывается на IPv4 после таймаута.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for v6_label in v6_labels:
|
||||||
|
base_label = v6_label[: -len(_V6_LABEL_SUFFIX)]
|
||||||
|
v4 = by_label.get(base_label)
|
||||||
|
v6 = by_label[v6_label]
|
||||||
|
if not v4 or v4.avg_ms is None or v6.avg_ms is None:
|
||||||
|
continue
|
||||||
|
gap = v6.avg_ms - v4.avg_ms
|
||||||
|
if v6.avg_ms > v4.avg_ms * _V6_SLOWER_RATIO and gap > _V6_SLOWER_ABSOLUTE_MS:
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id=f"latency.v6_much_slower.{base_label}",
|
||||||
|
severity="warn",
|
||||||
|
title=f"IPv6 to {v6.host} is much slower than IPv4",
|
||||||
|
detail=f"IPv6 averaged {v6.avg_ms} ms vs {v4.avg_ms} ms over IPv4, a {gap:.1f} ms gap.",
|
||||||
|
metric="avg_ms",
|
||||||
|
value=v6.avg_ms,
|
||||||
|
threshold=v4.avg_ms * _V6_SLOWER_RATIO,
|
||||||
|
advice="Often a tunneled or poorly-peered IPv6 path (6to4, a distant tunnel broker); "
|
||||||
|
"an invisible slowdown since most tools only report whichever stack the OS happened to pick.",
|
||||||
|
title_ru=f"IPv6 до {v6.host} намного медленнее IPv4",
|
||||||
|
detail_ru=f"IPv6 в среднем {v6.avg_ms} мс против {v4.avg_ms} мс по IPv4, разница {gap:.1f} мс.",
|
||||||
|
advice_ru="Часто туннелированный или плохо пропиренный путь IPv6 (6to4, далёкий туннельный "
|
||||||
|
"брокер); невидимое замедление, поскольку большинство инструментов показывают лишь тот "
|
||||||
|
"стек, который случайно выбрала ОС.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
def path_findings(trace: TraceResult, local: LocalNet | None = None, t: Thresholds | None = None) -> list[Finding]:
|
def path_findings(trace: TraceResult, local: LocalNet | None = None, t: Thresholds | None = None) -> list[Finding]:
|
||||||
if not trace.hops:
|
if not trace.hops:
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,21 @@ def test_watch_webhook_on_rejects_an_unknown_entry(tmp_path):
|
||||||
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
|
||||||
|
|
||||||
|
def test_probing_ipv6_rejects_an_unknown_value(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text('probing:\n ipv6: "sometimes"\n', encoding="utf-8")
|
||||||
|
with pytest.raises(ValidationError, match=r"auto.*off.*on"):
|
||||||
|
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
|
||||||
|
|
||||||
|
def test_probing_ipv6_accepts_each_known_value(tmp_path):
|
||||||
|
for value in ("auto", "on", "off"):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text(f'probing:\n ipv6: "{value}"\n', encoding="utf-8")
|
||||||
|
s = load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
assert s.probing.ipv6 == value
|
||||||
|
|
||||||
|
|
||||||
def test_ecmp_runs_are_capped(tmp_path):
|
def test_ecmp_runs_are_capped(tmp_path):
|
||||||
path = tmp_path / "config.yaml"
|
path = tmp_path / "config.yaml"
|
||||||
path.write_text("ecmp:\n runs: 10\n", encoding="utf-8")
|
path.write_text("ecmp:\n runs: 10\n", encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import pytest
|
||||||
|
|
||||||
from netsleuth.config import Band, Thresholds
|
from netsleuth.config import Band, Thresholds
|
||||||
from netsleuth.models import PingResult, TraceHop, TraceResult
|
from netsleuth.models import PingResult, TraceHop, TraceResult
|
||||||
from netsleuth.interpret import latency_findings, path_findings, severity_for
|
from netsleuth.interpret import dual_family_findings, latency_findings, path_findings, severity_for
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|
@ -91,6 +91,43 @@ def test_a_fully_dead_host_is_critical_regardless_of_bands(thresholds):
|
||||||
assert findings[0].id == "latency.unreachable.cloudflare-dns"
|
assert findings[0].id == "latency.unreachable.cloudflare-dns"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_family_findings_silent_when_no_v6_hosts_present():
|
||||||
|
assert dual_family_findings([ping()]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_family_findings_reports_unreachable_when_every_v6_host_is_dead():
|
||||||
|
pings = [
|
||||||
|
ping(),
|
||||||
|
ping(label="cloudflare-dns-v6", host="2606:4700:4700::1111", received=0, avg_ms=None, loss_pct=100.0),
|
||||||
|
]
|
||||||
|
findings = dual_family_findings(pings)
|
||||||
|
assert [f.id for f in findings] == ["net.ipv6_unreachable"]
|
||||||
|
assert findings[0].severity == "info"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_family_findings_flags_a_much_slower_v6_path():
|
||||||
|
pings = [
|
||||||
|
ping(avg_ms=10.0),
|
||||||
|
ping(label="cloudflare-dns-v6", host="2606:4700:4700::1111", avg_ms=80.0),
|
||||||
|
]
|
||||||
|
findings = dual_family_findings(pings)
|
||||||
|
assert [f.id for f in findings] == ["latency.v6_much_slower.cloudflare-dns"]
|
||||||
|
assert findings[0].severity == "warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_family_findings_silent_when_v6_is_only_slightly_slower():
|
||||||
|
pings = [
|
||||||
|
ping(avg_ms=10.0),
|
||||||
|
ping(label="cloudflare-dns-v6", host="2606:4700:4700::1111", avg_ms=15.0),
|
||||||
|
]
|
||||||
|
assert dual_family_findings(pings) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_family_findings_silent_when_there_is_no_v4_twin():
|
||||||
|
pings = [ping(label="cloudflare-dns-v6", host="2606:4700:4700::1111", avg_ms=80.0)]
|
||||||
|
assert dual_family_findings(pings) == []
|
||||||
|
|
||||||
|
|
||||||
def test_path_findings_highlight_the_first_sustained_loss_jump():
|
def test_path_findings_highlight_the_first_sustained_loss_jump():
|
||||||
trace = TraceResult(
|
trace = TraceResult(
|
||||||
target="1.1.1.1",
|
target="1.1.1.1",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue