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:
Andrew Pimenov 2026-08-12 22:55:51 +03:00
parent a64022b12a
commit 10846e3017
10 changed files with 179 additions and 6 deletions

View file

@ -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.

View file

@ -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` рядом с фикстурами других провайдеров.

View file

@ -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 |

View file

@ -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). Если не может запуститься — откатывается на обычный каскад |

View file

@ -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" }

View file

@ -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,

View file

@ -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"])

View file

@ -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 [

View file

@ -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")

View file

@ -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",