mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 19:12:03 +03:00
detect PMTUD blackholes via a DF-flag binary search (--pmtud)
Stayed unprivileged on both platforms deliberately. Windows: echo_once() gained a df: bool param setting IP_FLAG_DF on the IP_OPTION_INFORMATION struct's Flags field (present but unused until now), and classify_status() learned IP_PACKET_TOO_BIG (11009) - the same unprivileged IcmpSendEcho2 API the rest of the ICMP tier already uses, no new capability needed. Unix deliberately does not hand-roll raw ICMP echo requests - unprivileged SOCK_DGRAM/IPPROTO_ICMP checksum and identifier handling varies enough across kernel versions that shipping it without a Linux/macOS box to verify on felt like the wrong risk to take. Shells out to system ping -M do -s <size> (Linux) / ping -D -s <size> (macOS/BSD) instead, the same subprocess-and-parse pattern traceroute.py's system tiers already use. Matches "frag"/"too long" loosely in the output rather than an exact phrase, since a locally-cached-PMTU failure and a genuine on-path ICMP response word themselves differently across ping implementations - same "parse by shape" principle traceparse.py already established. classify_pmtu() distinguishes a path that's just narrower (a frag-needed signal was seen - PMTUD is working, normal behind a VPN) from an actual blackhole (large packets vanish with no ICMP reply at all - the "SSH connects then hangs" symptom). path.pmtu_below_iface_mtu flags separately when the path MTU is narrower than the local NIC's own MTU, which is exactly what makes this class of problem invisible locally. Opt-in --pmtud, excluded from --full. Live-verified on Windows against real cloudflare.com/google.com - found the full 1500-byte MTU in 11 probes each, matching the expected binary-search depth, correct probe history in the JSON output. Part of plans/zesty-zooming-wilkinson.md (gitignored).
This commit is contained in:
parent
10846e3017
commit
bc8482cc99
16 changed files with 534 additions and 5 deletions
|
|
@ -40,6 +40,7 @@ src/netsleuth/
|
||||||
prefix_benchmark.py --prefix-bench: ping the first host of a capped sample of the AS's announced prefixes.
|
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.
|
dpi_check.py --my-server: single-host TCP RST/filtering self-diagnostic. Not a scanner — see Key decisions.
|
||||||
ecmp.py --ecmp: pure N-run traceroute diff to spot per-hop load balancing. Backend-agnostic by design.
|
ecmp.py --ecmp: pure N-run traceroute diff to spot per-hop load balancing. Backend-agnostic by design.
|
||||||
|
pmtud.py --pmtud: binary-searches the largest DF-set packet that reaches a target, to catch a PMTUD blackhole.
|
||||||
nat64.py One RFC 7050 AAAA query (ipv4only.arpa) to detect NAT64/464XLAT translation.
|
nat64.py One RFC 7050 AAAA query (ipv4only.arpa) to detect NAT64/464XLAT translation.
|
||||||
hop_asn.py Per-hop ASN/AS-name/country/PTR enrichment via Team Cymru DNS, in-run deduped.
|
hop_asn.py Per-hop ASN/AS-name/country/PTR enrichment via Team Cymru DNS, in-run deduped.
|
||||||
captive_portal.py Phase-0 gate: three generate_204-style probes for a public-wifi login wall.
|
captive_portal.py Phase-0 gate: three generate_204-style probes for a public-wifi login wall.
|
||||||
|
|
@ -172,6 +173,12 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
**PMTUD stays unprivileged by reusing the same DF-flag hook the ICMP capability detection already carries, and falls back to the system `ping` binary rather than hand-rolled raw sockets on Unix.** On Windows, `IcmpSendEcho2`'s `IP_OPTION_INFORMATION.Flags` field already existed in `icmp_win.py` (unused until now) — `echo_once()` gained a `df: bool` parameter that sets `IP_FLAG_DF` (0x02), and `classify_status()` gained `IP_PACKET_TOO_BIG` (11009), the status Windows returns when a router replies "fragmentation needed." No new privilege is required: this is the same unprivileged `IcmpSendEcho2` API the rest of the ICMP tier already uses. On Linux/macOS, rather than constructing raw ICMP echo requests by hand (version-dependent kernel checksum/identifier quirks on unprivileged `SOCK_DGRAM`/`IPPROTO_ICMP` sockets make that fragile in ways this project's author had no way to verify without those platforms on hand), `probes/pmtud.py` shells out to the system `ping` binary with `-M do -s <size>` (Linux) or `-D -s <size>` (macOS/BSD) — the same subprocess-and-parse pattern `traceroute.py`'s system tiers already use, and `ping`'s DF/size flags are stable, well-documented ping(8) behavior rather than something this project needs to reverse-engineer.
|
||||||
|
|
||||||
|
**A local "frag needed" failure and a genuine on-path ICMP response are both treated as the same "reduced" signal, not distinguished.** Linux `ping -M do` fails immediately with a local error when the kernel already has the path MTU cached, without ever sending the oversized packet; when it doesn't have a cached value, an actual router's "fragmentation needed" ICMP produces a different message. `_probe_unix()` matches loosely (`"frag"` or `"too long"` anywhere in the combined stdout/stderr, case-insensitive) precisely because the exact wording differs across ping implementations and situations — matching on the presence of the signal, not its precise phrasing, is the same "parse by shape, not exact wording" principle `traceparse.py` already established for traceroute output.
|
||||||
|
|
||||||
|
**The classic PMTUD blackhole distinction is "did anything tell us why."** `classify_pmtu()` calls a discovered MTU `"reduced"` when a frag-needed signal was seen at any point during the search (PMTUD is working, the path is just narrower — normal behind a VPN/tunnel) and `"blackhole"` only when large packets simply vanish with no ICMP reply at all (the actual VPN/PPPoE-router-misconfiguration symptom: "SSH connects then hangs"). `path.pmtu_below_iface_mtu` is a separate, always-checked finding — a path MTU narrower than the local NIC's own MTU is exactly what makes this class of problem invisible to every locally-run tool.
|
||||||
|
|
||||||
**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
|
||||||
|
|
@ -190,4 +197,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 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.
|
`uv run pytest -q`. Roughly 868 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.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ src/netsleuth/
|
||||||
prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS.
|
prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS.
|
||||||
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
|
dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения.
|
||||||
ecmp.py --ecmp: чистый diff N прогонов трассировки для обнаружения балансировки нагрузки по хопам. Не зависит от бэкенда по дизайну.
|
ecmp.py --ecmp: чистый diff N прогонов трассировки для обнаружения балансировки нагрузки по хопам. Не зависит от бэкенда по дизайну.
|
||||||
|
pmtud.py --pmtud: бинарный поиск самого большого пакета с DF, доходящего до цели, для обнаружения чёрной дыры PMTUD.
|
||||||
nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT.
|
nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT.
|
||||||
hop_asn.py Обогащение хопов ASN/именем AS/страной/PTR через DNS Team Cymru, дедуп в рамках прогона.
|
hop_asn.py Обогащение хопов ASN/именем AS/страной/PTR через DNS Team Cymru, дедуп в рамках прогона.
|
||||||
captive_portal.py Фаза 0: три generate_204-пробы на признаки логин-стены публичного wifi.
|
captive_portal.py Фаза 0: три generate_204-пробы на признаки логин-стены публичного wifi.
|
||||||
|
|
@ -172,6 +173,12 @@ src/netsleuth/
|
||||||
|
|
||||||
**Трассировка IPv6 и `--path-diversity` намеренно вне рамок этой фазы.** Расширение каскада трассировки и `bgp_path.py` на второе семейство адресов — это реальная дополнительная работа (уровень трассировки `icmp_win` — IPv4-only по той же причине `gethostbyname()` выше, хотя обработка исключений по уровням в `run_cascade()` означает, что в худшем случае это просто откатится на `system_traceroute`, а не испортит отчёт), которая не разделяет трюк «пометить меткой, ничего не трогать ниже по потоку», сработавший для latency — оставлено как задокументированное продолжение, а не раздувание этой фазы под него.
|
**Трассировка IPv6 и `--path-diversity` намеренно вне рамок этой фазы.** Расширение каскада трассировки и `bgp_path.py` на второе семейство адресов — это реальная дополнительная работа (уровень трассировки `icmp_win` — IPv4-only по той же причине `gethostbyname()` выше, хотя обработка исключений по уровням в `run_cascade()` означает, что в худшем случае это просто откатится на `system_traceroute`, а не испортит отчёт), которая не разделяет трюк «пометить меткой, ничего не трогать ниже по потоку», сработавший для latency — оставлено как задокументированное продолжение, а не раздувание этой фазы под него.
|
||||||
|
|
||||||
|
**PMTUD остаётся непривилегированным, переиспользуя тот же DF-флаг хук, что уже несёт определение возможностей ICMP, и откатывается на системный бинарник `ping` вместо ручных сырых сокетов на Unix.** На Windows поле `IP_OPTION_INFORMATION.Flags` для `IcmpSendEcho2` уже существовало в `icmp_win.py` (неиспользуемое до этого) — `echo_once()` получил параметр `df: bool`, устанавливающий `IP_FLAG_DF` (0x02), а `classify_status()` получил `IP_PACKET_TOO_BIG` (11009) — статус, который Windows возвращает, когда роутер отвечает «нужна фрагментация». Новых привилегий не требуется: это тот же непривилегированный API `IcmpSendEcho2`, что уже использует остальной уровень ICMP. На Linux/macOS вместо ручного конструирования сырых ICMP echo-запросов (версионно-зависимые причуды checksum/identifier ядра на непривилегированных сокетах `SOCK_DGRAM`/`IPPROTO_ICMP` делают это хрупким способом, который автор проекта не мог проверить без этих платформ под рукой) `probes/pmtud.py` шеллится в системный `ping` с `-M do -s <size>` (Linux) или `-D -s <size>` (macOS/BSD) — тот же паттерн subprocess-и-разбор, что уже используют системные уровни `traceroute.py`, а флаги DF/размера у `ping` — стабильное, документированное поведение ping(8), а не то, что этому проекту нужно реверс-инжинирить.
|
||||||
|
|
||||||
|
**Локальный отказ «нужна фрагментация» и настоящий ответ ICMP на пути трактуются как один и тот же сигнал «снижен», без различения.** Linux `ping -M do` падает немедленно с локальной ошибкой, если у ядра уже закэширован MTU пути, вообще не отправляя завышенный пакет; когда закэшированного значения нет, настоящий ICMP «нужна фрагментация» от роутера даёт другое сообщение. `_probe_unix()` сопоставляет нестрого (`"frag"` или `"too long"` где угодно в объединённых stdout/stderr, без учёта регистра) именно потому, что точная формулировка отличается между реализациями ping и ситуациями — сопоставление по наличию сигнала, а не по точной формулировке, тот же принцип «разбирать по форме, не по точным словам», что уже установлен `traceparse.py` для вывода трассировки.
|
||||||
|
|
||||||
|
**Классическое различие чёрной дыры PMTUD — «сказал ли нам хоть кто-то почему».** `classify_pmtu()` называет найденный MTU `"reduced"`, когда сигнал «нужна фрагментация» был замечен в любой момент поиска (PMTUD работает, путь просто уже — нормально за VPN/туннелем), и `"blackhole"` только когда крупные пакеты просто исчезают вообще без ответа ICMP (настоящий симптом неверно настроенного VPN/PPPoE-роутера: «SSH подключается и зависает»). `path.pmtu_below_iface_mtu` — отдельная, всегда проверяемая находка: MTU пути уже, чем MTU самого локального сетевого адаптера — именно это делает такой класс проблем невидимым для любого локально запущенного инструмента.
|
||||||
|
|
||||||
**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 с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
|
||||||
|
|
||||||
## Хранение
|
## Хранение
|
||||||
|
|
@ -190,4 +197,4 @@ src/netsleuth/
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
`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` рядом с фикстурами других провайдеров.
|
`uv run pytest -q`. Около 868 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ None of this is required — netsleuth degrades gracefully without any of it —
|
||||||
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **ECMP / multipath detection** (`--ecmp`) — traces a couple of reference hosts multiple times and diffs which next hop showed up at each TTL, catching per-hop load balancing that makes traceroutes look inconsistent, and flagging when one branch of a load-balanced pair is measurably worse than the other.
|
- **ECMP / multipath detection** (`--ecmp`) — traces a couple of reference hosts multiple times and diffs which next hop showed up at each TTL, catching per-hop load balancing that makes traceroutes look inconsistent, and flagging when one branch of a load-balanced pair is measurably worse than the other.
|
||||||
|
- **Path MTU discovery** (`--pmtud`) — binary-searches the largest packet with the Don't Fragment bit set that actually reaches a target, distinguishing a path that's simply narrower (a router correctly says "fragmentation needed") from a PMTUD blackhole (large packets vanish silently — the classic "SSH connects then hangs" VPN/PPPoE symptom).
|
||||||
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ None of this is required — netsleuth degrades gracefully without any of it —
|
||||||
| `--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 |
|
| `--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 |
|
||||||
|
| `--pmtud` | Binary-search the largest DF-set packet that reaches each `pmtud.targets` host (default: 2 hosts) to catch a path-MTU blackhole. **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 |
|
| `--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 |
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ netsleuth
|
||||||
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
|
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
|
||||||
- **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент.
|
- **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент.
|
||||||
- **Детект ECMP / нескольких маршрутов** (`--ecmp`) — трассирует пару эталонных хостов несколько раз и сравнивает, какой следующий хоп появлялся на каждом TTL, ловя балансировку нагрузки по хопам, из-за которой трассировки выглядят непоследовательными, и отмечая, если одна из веток балансировки заметно хуже другой.
|
- **Детект ECMP / нескольких маршрутов** (`--ecmp`) — трассирует пару эталонных хостов несколько раз и сравнивает, какой следующий хоп появлялся на каждом TTL, ловя балансировку нагрузки по хопам, из-за которой трассировки выглядят непоследовательными, и отмечая, если одна из веток балансировки заметно хуже другой.
|
||||||
|
- **Обнаружение MTU пути** (`--pmtud`) — бинарным поиском находит самый большой пакет с флагом Don't Fragment, реально доходящий до цели, отличая путь, который просто уже (роутер корректно говорит «нужна фрагментация»), от чёрной дыры PMTUD (крупные пакеты молча исчезают — классический симптом VPN/PPPoE «SSH подключается и зависает»).
|
||||||
- **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование.
|
- **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование.
|
||||||
- **Самопроверка DPI / портов** (`--my-server <host>`) — одиночная TCP-проба по 6 фиксированным портам **сервера, которым вы владеете**, с классификацией инъекции TCP RST, тихой фильтрации или чистого ответа. Это не портсканер: один хост за запуск, фиксированный короткий список портов, и цель никогда не берётся из вашего собственного внешнего IP или из данных BGP — почему именно так, см. [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
- **Самопроверка DPI / портов** (`--my-server <host>`) — одиночная TCP-проба по 6 фиксированным портам **сервера, которым вы владеете**, с классификацией инъекции TCP RST, тихой фильтрации или чистого ответа. Это не портсканер: один хост за запуск, фиксированный короткий список портов, и цель никогда не берётся из вашего собственного внешнего IP или из данных BGP — почему именно так, см. [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md).
|
||||||
|
|
||||||
|
|
@ -93,6 +94,7 @@ netsleuth
|
||||||
| `--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, можно не трогать |
|
| `--ipv6` / `--no-ipv6` | Принудительно включить или выключить замер задержки до эталонных хостов IPv6, переопределяя `probing.ipv6` (по умолчанию `auto`: только на dual-stack соединении). На v4-only хосте `auto` — это no-op, можно не трогать |
|
||||||
|
| `--pmtud` | Бинарным поиском найти самый большой пакет с DF, доходящий до каждого хоста из `pmtud.targets` (по умолчанию: 2 хоста), для обнаружения чёрной дыры MTU пути. **Не** входит в `--full` — включается явно |
|
||||||
| `--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). Если не может запуститься — откатывается на обычный каскад |
|
||||||
|
|
|
||||||
11
config.yaml
11
config.yaml
|
|
@ -132,6 +132,17 @@ path_diversity:
|
||||||
# Runs first, before any other probe. Checks a handful of known generate_204-style
|
# Runs first, before any other probe. Checks a handful of known generate_204-style
|
||||||
# endpoints for a redirect/tampered response, the classic signature of a public-wifi
|
# endpoints for a redirect/tampered response, the classic signature of a public-wifi
|
||||||
# login wall. check_urls is capped at 4 in code.
|
# login wall. check_urls is capped at 4 in code.
|
||||||
|
# Opt-in via --pmtud. Binary-searches for the largest DF-set packet that gets
|
||||||
|
# through to each target, to catch a VPN/PPPoE-style PMTUD blackhole. targets is
|
||||||
|
# capped at 2 in code: this is a path-MTU sweep, not a scan.
|
||||||
|
pmtud:
|
||||||
|
targets:
|
||||||
|
- { label: "cloudflare", host: "cloudflare.com" }
|
||||||
|
- { label: "google", host: "google.com" }
|
||||||
|
min_bytes: 576
|
||||||
|
max_bytes: 1500
|
||||||
|
probe_timeout_seconds: 2.0
|
||||||
|
|
||||||
# Opt-in via --ecmp. Traces the first few reference hosts multiple times to spot
|
# Opt-in via --ecmp. Traces the first few reference hosts multiple times to spot
|
||||||
# per-TTL load balancing (different next hops across runs). runs/max_targets are
|
# per-TTL load balancing (different next hops across runs). runs/max_targets are
|
||||||
# capped in code: this diffs a handful of runs, not a path-sampling campaign.
|
# capped in code: this diffs a handful of runs, not a path-sampling campaign.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from netsleuth.interpret import (
|
||||||
path_asn_findings,
|
path_asn_findings,
|
||||||
path_diversity_findings,
|
path_diversity_findings,
|
||||||
path_findings,
|
path_findings,
|
||||||
|
pmtud_findings,
|
||||||
prefix_findings,
|
prefix_findings,
|
||||||
speed_findings,
|
speed_findings,
|
||||||
tls_findings,
|
tls_findings,
|
||||||
|
|
@ -92,6 +93,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
|
||||||
|
pmtud: bool = False
|
||||||
ipv6: Optional[bool] = None
|
ipv6: Optional[bool] = None
|
||||||
dpi_target: str | None = None
|
dpi_target: str | None = None
|
||||||
formats: frozenset[str] = frozenset({"md"})
|
formats: frozenset[str] = frozenset({"md"})
|
||||||
|
|
@ -401,6 +403,27 @@ async def _dpi_section(settings: Settings, options: Options) -> ModuleResult:
|
||||||
return ModuleResult(name="dpi_check", status="ok", data=result)
|
return ModuleResult(name="dpi_check", status="ok", data=result)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pmtud_section(settings: Settings, options: Options, iface_mtu: int | None) -> ModuleResult:
|
||||||
|
if not options.pmtud:
|
||||||
|
return ModuleResult(name="pmtud", status="skipped", warnings=["pmtud probe skipped: not requested"])
|
||||||
|
from netsleuth.probes.pmtud import probe_pmtu
|
||||||
|
|
||||||
|
cfg = settings.pmtud
|
||||||
|
results = []
|
||||||
|
for target in cfg.targets:
|
||||||
|
results.append(
|
||||||
|
await probe_pmtu(
|
||||||
|
target.host,
|
||||||
|
low=cfg.min_bytes,
|
||||||
|
high=cfg.max_bytes,
|
||||||
|
timeout=cfg.probe_timeout_seconds,
|
||||||
|
iface_mtu=iface_mtu,
|
||||||
|
source_ip=options.bind.ipv4 if options.bind else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ModuleResult(name="pmtud", status="ok", data=results)
|
||||||
|
|
||||||
|
|
||||||
async def _ecmp_section(caps, settings: Settings, options: Options, cycles: int) -> ModuleResult:
|
async def _ecmp_section(caps, settings: Settings, options: Options, cycles: int) -> ModuleResult:
|
||||||
if not options.ecmp:
|
if not options.ecmp:
|
||||||
return ModuleResult(name="ecmp", status="skipped", warnings=["ecmp probe skipped: not requested"])
|
return ModuleResult(name="ecmp", status="skipped", warnings=["ecmp probe skipped: not requested"])
|
||||||
|
|
@ -663,6 +686,9 @@ async def diagnose(settings: Settings, options: Options) -> tuple[dict, list[Pat
|
||||||
modules["ecmp"] = await run_module(
|
modules["ecmp"] = await run_module(
|
||||||
"ecmp", _ecmp_section(caps, settings, options, cycles), timeout=timeouts.subprocess_seconds
|
"ecmp", _ecmp_section(caps, settings, options, cycles), timeout=timeouts.subprocess_seconds
|
||||||
)
|
)
|
||||||
|
modules["pmtud"] = await run_module(
|
||||||
|
"pmtud", _pmtud_section(settings, options, local.iface_mtu), timeout=timeouts.subprocess_seconds
|
||||||
|
)
|
||||||
|
|
||||||
# Phase 3c — per-hop ASN/AS-name/country enrichment. Mutates the trace hops
|
# Phase 3c — per-hop ASN/AS-name/country enrichment. Mutates the trace hops
|
||||||
# in place; a lookup failure must never downgrade the trace itself, so only
|
# in place; a lookup failure must never downgrade the trace itself, so only
|
||||||
|
|
@ -717,6 +743,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)]
|
||||||
|
+ [f for result in (modules["pmtud"].data or []) for f in pmtud_findings(result)]
|
||||||
+ dual_family_findings(pings)
|
+ dual_family_findings(pings)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -863,6 +890,11 @@ def run(
|
||||||
"--ipv6/--no-ipv6",
|
"--ipv6/--no-ipv6",
|
||||||
help="Also measure latency to IPv6 reference hosts (default: auto, only on a dual-stack connection).",
|
help="Also measure latency to IPv6 reference hosts (default: auto, only on a dual-stack connection).",
|
||||||
),
|
),
|
||||||
|
pmtud: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--pmtud",
|
||||||
|
help="Binary-search the largest DF-set packet that reaches each pmtud.target, to catch a PMTUD blackhole.",
|
||||||
|
),
|
||||||
my_server: Optional[str] = typer.Option(
|
my_server: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--my-server",
|
"--my-server",
|
||||||
|
|
@ -935,6 +967,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,
|
||||||
|
pmtud=pmtud,
|
||||||
ipv6=ipv6,
|
ipv6=ipv6,
|
||||||
dpi_target=my_server,
|
dpi_target=my_server,
|
||||||
formats=formats,
|
formats=formats,
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,35 @@ class DnsAdvancedConfig(BaseModel):
|
||||||
bogus_probe_name: str = "cloudflare.com"
|
bogus_probe_name: str = "cloudflare.com"
|
||||||
|
|
||||||
|
|
||||||
|
class PmtudConfig(BaseModel):
|
||||||
|
targets: list[HostSpec] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
HostSpec(label="cloudflare", host="cloudflare.com"),
|
||||||
|
HostSpec(label="google", host="google.com"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
min_bytes: int = 576
|
||||||
|
max_bytes: int = 1500
|
||||||
|
probe_timeout_seconds: float = 2.0
|
||||||
|
|
||||||
|
@field_validator("targets")
|
||||||
|
@classmethod
|
||||||
|
def _cap_targets(cls, value: list[HostSpec]) -> list[HostSpec]:
|
||||||
|
if len(value) > 2:
|
||||||
|
raise ValueError("pmtud.targets may list at most 2 hosts (this is a path-MTU sweep, not a scan)")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("max_bytes")
|
||||||
|
@classmethod
|
||||||
|
def _validate_max_bytes(cls, value: int, info) -> int:
|
||||||
|
if value > 9000:
|
||||||
|
raise ValueError("pmtud.max_bytes may not exceed 9000 (jumbo-frame ceiling)")
|
||||||
|
min_bytes = info.data.get("min_bytes")
|
||||||
|
if min_bytes is not None and value <= min_bytes:
|
||||||
|
raise ValueError("pmtud.max_bytes must be greater than pmtud.min_bytes")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class EcmpConfig(BaseModel):
|
class EcmpConfig(BaseModel):
|
||||||
runs: int = 3
|
runs: int = 3
|
||||||
max_targets: int = 2
|
max_targets: int = 2
|
||||||
|
|
@ -384,6 +413,7 @@ class Settings(BaseSettings):
|
||||||
path_diversity: PathDiversityConfig = PathDiversityConfig()
|
path_diversity: PathDiversityConfig = PathDiversityConfig()
|
||||||
captive_portal: CaptivePortalConfig = CaptivePortalConfig()
|
captive_portal: CaptivePortalConfig = CaptivePortalConfig()
|
||||||
ecmp: EcmpConfig = EcmpConfig()
|
ecmp: EcmpConfig = EcmpConfig()
|
||||||
|
pmtud: PmtudConfig = PmtudConfig()
|
||||||
|
|
||||||
ipinfo_token: SecretStr | None = Field(default=None, alias="IPINFO_TOKEN")
|
ipinfo_token: SecretStr | None = Field(default=None, alias="IPINFO_TOKEN")
|
||||||
peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY")
|
peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY")
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ SECTION_ORDER = (
|
||||||
"tls",
|
"tls",
|
||||||
"path",
|
"path",
|
||||||
"ecmp",
|
"ecmp",
|
||||||
|
"pmtud",
|
||||||
"path_diversity",
|
"path_diversity",
|
||||||
"prefix_benchmark",
|
"prefix_benchmark",
|
||||||
"dpi_check",
|
"dpi_check",
|
||||||
|
|
@ -129,6 +130,7 @@ _WARNING_RU = {
|
||||||
"no throughput measured": "пропускная способность не измерена",
|
"no throughput measured": "пропускная способность не измерена",
|
||||||
"captive portal check disabled in config": "проверка captive portal отключена в конфиге",
|
"captive portal check disabled in config": "проверка captive portal отключена в конфиге",
|
||||||
"ecmp probe skipped: not requested": "проверка ECMP пропущена: не запрошена",
|
"ecmp probe skipped: not requested": "проверка ECMP пропущена: не запрошена",
|
||||||
|
"pmtud probe skipped: not requested": "проверка PMTUD пропущена: не запрошена",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -258,6 +260,7 @@ _SECTION_TITLES = {
|
||||||
"tls": "## TLS handshake",
|
"tls": "## TLS handshake",
|
||||||
"path": "## Path",
|
"path": "## Path",
|
||||||
"ecmp": "## ECMP / multipath",
|
"ecmp": "## ECMP / multipath",
|
||||||
|
"pmtud": "## Path MTU discovery",
|
||||||
"path_diversity": "## Path diversity (Anycast)",
|
"path_diversity": "## Path diversity (Anycast)",
|
||||||
"prefix_benchmark": "## AS prefix benchmark",
|
"prefix_benchmark": "## AS prefix benchmark",
|
||||||
"dpi_check": "## DPI / port check",
|
"dpi_check": "## DPI / port check",
|
||||||
|
|
@ -276,6 +279,7 @@ _SECTION_TITLES_RU = {
|
||||||
"tls": "## TLS-рукопожатие",
|
"tls": "## TLS-рукопожатие",
|
||||||
"path": "## Маршрут",
|
"path": "## Маршрут",
|
||||||
"ecmp": "## ECMP / несколько маршрутов",
|
"ecmp": "## ECMP / несколько маршрутов",
|
||||||
|
"pmtud": "## Обнаружение MTU пути",
|
||||||
"path_diversity": "## Диверсификация маршрута (Anycast)",
|
"path_diversity": "## Диверсификация маршрута (Anycast)",
|
||||||
"prefix_benchmark": "## Бенчмарк префиксов AS",
|
"prefix_benchmark": "## Бенчмарк префиксов AS",
|
||||||
"dpi_check": "## Проверка DPI / портов",
|
"dpi_check": "## Проверка DPI / портов",
|
||||||
|
|
@ -689,6 +693,38 @@ def _ecmp(report: dict, lang: str = "en") -> list[str]:
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
_PMTU_VERDICT_RU = {"ok": "ок", "reduced": "снижен", "blackhole": "чёрная дыра", "unknown": "неизвестно"}
|
||||||
|
|
||||||
|
|
||||||
|
def _pmtud(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("pmtud", lang)
|
||||||
|
note = unavailable(report, "pmtud", lang)
|
||||||
|
if note:
|
||||||
|
return [title, "", note, ""]
|
||||||
|
results = _data(report, "pmtud") or []
|
||||||
|
lines = [title, ""] + _table(
|
||||||
|
[
|
||||||
|
_t(lang, "Host", "Хост"),
|
||||||
|
_t(lang, "Verdict", "Вердикт"),
|
||||||
|
_t(lang, "Discovered MTU", "Найденный MTU"),
|
||||||
|
_t(lang, "Iface MTU", "MTU интерфейса"),
|
||||||
|
_t(lang, "Note", "Примечание"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
r.get("host", ""),
|
||||||
|
r.get("verdict", "unknown") if lang == "en" else _PMTU_VERDICT_RU.get(r.get("verdict"), r.get("verdict")),
|
||||||
|
_num(r.get("discovered_mtu")),
|
||||||
|
_num(r.get("iface_mtu")),
|
||||||
|
(r.get("note_ru") or r.get("note") or "") if lang == "ru" else (r.get("note") or ""),
|
||||||
|
]
|
||||||
|
for r in results
|
||||||
|
],
|
||||||
|
lang,
|
||||||
|
)
|
||||||
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def _speed(report: dict, lang: str = "en") -> list[str]:
|
def _speed(report: dict, lang: str = "en") -> list[str]:
|
||||||
title = _section_title("speed", lang)
|
title = _section_title("speed", lang)
|
||||||
note = unavailable(report, "speed", lang)
|
note = unavailable(report, "speed", lang)
|
||||||
|
|
@ -993,6 +1029,7 @@ def render_markdown(
|
||||||
lines += _tls(report, lang)
|
lines += _tls(report, lang)
|
||||||
lines += _path(report, lang)
|
lines += _path(report, lang)
|
||||||
lines += _ecmp(report, lang)
|
lines += _ecmp(report, lang)
|
||||||
|
lines += _pmtud(report, lang)
|
||||||
lines += _path_diversity(report, lang)
|
lines += _path_diversity(report, lang)
|
||||||
lines += _prefix_benchmark(report, lang)
|
lines += _prefix_benchmark(report, lang)
|
||||||
lines += _dpi_check(report, lang)
|
lines += _dpi_check(report, lang)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from netsleuth.models import (
|
||||||
LocalNet,
|
LocalNet,
|
||||||
PathDiversity,
|
PathDiversity,
|
||||||
PingResult,
|
PingResult,
|
||||||
|
PmtuResult,
|
||||||
PrefixBenchmark,
|
PrefixBenchmark,
|
||||||
Signal,
|
Signal,
|
||||||
SpeedResult,
|
SpeedResult,
|
||||||
|
|
@ -373,6 +374,67 @@ def dual_stack_findings(nat64_prefix: str | None, local: LocalNet) -> list[Findi
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pmtud_findings(result: PmtuResult) -> list[Finding]:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
if result.verdict == "blackhole":
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id=f"path.pmtu_blackhole.{result.host}",
|
||||||
|
severity="crit",
|
||||||
|
title=f"Path MTU to {result.host} is blackholed",
|
||||||
|
detail=result.note,
|
||||||
|
metric="discovered_mtu",
|
||||||
|
value=result.discovered_mtu,
|
||||||
|
advice="Symptom: SSH connects then hangs, some sites load partially. "
|
||||||
|
"Clamp MSS on the router or lower the tunnel MTU.",
|
||||||
|
title_ru=f"MTU пути до {result.host} — чёрная дыра",
|
||||||
|
detail_ru=result.note_ru or result.note,
|
||||||
|
advice_ru="Симптом: SSH подключается и зависает, некоторые сайты грузятся частично. "
|
||||||
|
"Зажмите MSS на роутере или снизьте MTU туннеля.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif result.verdict == "reduced":
|
||||||
|
below = _STANDARD_MTU - (result.discovered_mtu or _STANDARD_MTU)
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id=f"path.pmtu_reduced.{result.host}",
|
||||||
|
severity="warn" if below > 100 else "info",
|
||||||
|
title=f"Path MTU to {result.host} is reduced",
|
||||||
|
detail=result.note,
|
||||||
|
metric="discovered_mtu",
|
||||||
|
value=result.discovered_mtu,
|
||||||
|
threshold=_STANDARD_MTU,
|
||||||
|
advice="Expected behind a VPN/tunnel; if unexpected, a middlebox on the path is clamping MTU.",
|
||||||
|
title_ru=f"MTU пути до {result.host} снижен",
|
||||||
|
detail_ru=result.note_ru or result.note,
|
||||||
|
advice_ru="Ожидаемо за VPN/туннелем; если неожиданно — на пути есть middlebox, урезающий MTU.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
result.discovered_mtu is not None
|
||||||
|
and result.iface_mtu is not None
|
||||||
|
and result.discovered_mtu < result.iface_mtu
|
||||||
|
):
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
id=f"path.pmtu_below_iface_mtu.{result.host}",
|
||||||
|
severity="info",
|
||||||
|
title=f"Path MTU to {result.host} is narrower than the local interface",
|
||||||
|
detail=f"Path MTU is {result.discovered_mtu} bytes, local interface MTU is {result.iface_mtu} bytes.",
|
||||||
|
metric="discovered_mtu",
|
||||||
|
value=result.discovered_mtu,
|
||||||
|
advice="This is exactly what makes a path MTU problem invisible locally — "
|
||||||
|
"everything on this machine looks fine.",
|
||||||
|
title_ru=f"MTU пути до {result.host} уже, чем у локального интерфейса",
|
||||||
|
detail_ru=f"MTU пути — {result.discovered_mtu} байт, MTU локального интерфейса — {result.iface_mtu} байт.",
|
||||||
|
advice_ru="Именно поэтому проблема MTU пути невидима локально — "
|
||||||
|
"на этой машине всё выглядит нормально.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
_STANDARD_MTU = 1500
|
||||||
_ECMP_ASYMMETRIC_MS = 20.0
|
_ECMP_ASYMMETRIC_MS = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ DPI_VERDICTS = ("clean", "partial_filtering", "reset_injection", "unreachable",
|
||||||
RESOLVER_KINDS = ("system", "doh")
|
RESOLVER_KINDS = ("system", "doh")
|
||||||
EDGE_SOURCES = ("cf_ray", "cf_trace", "server_timing", "none")
|
EDGE_SOURCES = ("cf_ray", "cf_trace", "server_timing", "none")
|
||||||
PORTAL_VERDICTS = ("clean", "portal", "suspect", "error")
|
PORTAL_VERDICTS = ("clean", "portal", "suspect", "error")
|
||||||
|
PMTU_VERDICTS = ("ok", "reduced", "blackhole", "unknown")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -522,6 +523,23 @@ class EcmpReport:
|
||||||
note_ru: str | None = None
|
note_ru: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PmtuResult:
|
||||||
|
host: str
|
||||||
|
resolved_ip: str | None = None
|
||||||
|
method: str = "none"
|
||||||
|
discovered_mtu: int | None = None
|
||||||
|
iface_mtu: int | None = None
|
||||||
|
verdict: str = "unknown"
|
||||||
|
probes: list[tuple[int, bool]] = field(default_factory=list)
|
||||||
|
note: str = ""
|
||||||
|
note_ru: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.verdict not in PMTU_VERDICTS:
|
||||||
|
raise ValueError(f"unknown PmtuResult verdict: {self.verdict!r}")
|
||||||
|
|
||||||
|
|
||||||
def to_jsonable(obj: Any) -> Any:
|
def to_jsonable(obj: Any) -> Any:
|
||||||
if obj is None or isinstance(obj, (str, bool, int)):
|
if obj is None or isinstance(obj, (str, bool, int)):
|
||||||
return obj
|
return obj
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ IP_DEST_NET_UNREACHABLE = 11002
|
||||||
IP_DEST_HOST_UNREACHABLE = 11003
|
IP_DEST_HOST_UNREACHABLE = 11003
|
||||||
IP_REQ_TIMED_OUT = 11010
|
IP_REQ_TIMED_OUT = 11010
|
||||||
IP_TTL_EXPIRED_TRANSIT = 11013
|
IP_TTL_EXPIRED_TRANSIT = 11013
|
||||||
|
IP_PACKET_TOO_BIG = 11009
|
||||||
|
IP_FLAG_DF = 0x02
|
||||||
|
|
||||||
_UNREACHABLE = {IP_DEST_NET_UNREACHABLE, IP_DEST_HOST_UNREACHABLE, 11001, 11004}
|
_UNREACHABLE = {IP_DEST_NET_UNREACHABLE, IP_DEST_HOST_UNREACHABLE, 11001, 11004}
|
||||||
_HEAD = struct.Struct("<4BIIHH")
|
_HEAD = struct.Struct("<4BIIHH")
|
||||||
|
|
@ -27,6 +29,8 @@ def classify_status(status: int) -> str:
|
||||||
return "ttl_expired"
|
return "ttl_expired"
|
||||||
if status == IP_REQ_TIMED_OUT:
|
if status == IP_REQ_TIMED_OUT:
|
||||||
return "timeout"
|
return "timeout"
|
||||||
|
if status == IP_PACKET_TOO_BIG:
|
||||||
|
return "packet_too_big"
|
||||||
if status in _UNREACHABLE:
|
if status in _UNREACHABLE:
|
||||||
return "unreachable"
|
return "unreachable"
|
||||||
return "error"
|
return "error"
|
||||||
|
|
@ -125,11 +129,18 @@ def _handle():
|
||||||
|
|
||||||
|
|
||||||
def echo_once(
|
def echo_once(
|
||||||
dest: str, ttl: int, timeout_ms: int, payload: bytes = b"netsleuth", source_ip: str | None = None
|
dest: str,
|
||||||
|
ttl: int,
|
||||||
|
timeout_ms: int,
|
||||||
|
payload: bytes = b"netsleuth",
|
||||||
|
source_ip: str | None = None,
|
||||||
|
df: bool = False,
|
||||||
) -> IcmpReply:
|
) -> IcmpReply:
|
||||||
iphlpapi, handle = _handle()
|
iphlpapi, handle = _handle()
|
||||||
try:
|
try:
|
||||||
options = _IpOptionInformation(Ttl=ttl, Tos=0, Flags=0, OptionsSize=0, OptionsData=None)
|
options = _IpOptionInformation(
|
||||||
|
Ttl=ttl, Tos=0, Flags=IP_FLAG_DF if df else 0, OptionsSize=0, OptionsData=None
|
||||||
|
)
|
||||||
buffer = ctypes.create_string_buffer(_REPLY_BUFFER_SIZE)
|
buffer = ctypes.create_string_buffer(_REPLY_BUFFER_SIZE)
|
||||||
address = struct.unpack("<I", socket.inet_aton(dest))[0]
|
address = struct.unpack("<I", socket.inet_aton(dest))[0]
|
||||||
if source_ip:
|
if source_ip:
|
||||||
|
|
|
||||||
162
src/netsleuth/probes/pmtud.py
Normal file
162
src/netsleuth/probes/pmtud.py
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from netsleuth.models import PmtuResult
|
||||||
|
|
||||||
|
_IPV4_ICMP_OVERHEAD = 28 # 20-byte IPv4 header + 8-byte ICMP header
|
||||||
|
_STANDARD_MTU = 1500
|
||||||
|
_FRAG_NEEDED_MARKERS = ("frag", "too long")
|
||||||
|
|
||||||
|
|
||||||
|
def next_probe_size(low: int, high: int) -> int:
|
||||||
|
return (low + high) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def mtu_from_search(low: int, high: int, overhead: int = _IPV4_ICMP_OVERHEAD) -> int:
|
||||||
|
return low + overhead
|
||||||
|
|
||||||
|
|
||||||
|
def classify_pmtu(
|
||||||
|
discovered: int | None, iface_mtu: int | None, saw_frag_needed: bool
|
||||||
|
) -> tuple[str, str, str]:
|
||||||
|
floor = iface_mtu or _STANDARD_MTU
|
||||||
|
if discovered is None:
|
||||||
|
return "unknown", "path MTU could not be determined", "не удалось определить MTU пути"
|
||||||
|
if discovered >= floor:
|
||||||
|
return (
|
||||||
|
"ok",
|
||||||
|
f"the path supports the full {discovered}-byte MTU",
|
||||||
|
f"путь поддерживает полный MTU {discovered} байт",
|
||||||
|
)
|
||||||
|
if saw_frag_needed:
|
||||||
|
return (
|
||||||
|
"reduced",
|
||||||
|
f"path MTU is {discovered} bytes; the path correctly signals fragmentation needed",
|
||||||
|
f"MTU пути — {discovered} байт; путь корректно сигнализирует о необходимости фрагментации",
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"blackhole",
|
||||||
|
f"packets above {discovered} bytes vanish with no ICMP reply -- PMTUD appears blocked",
|
||||||
|
f"пакеты крупнее {discovered} байт исчезают без ответа ICMP -- похоже, PMTUD заблокирован",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unix_ping_df_argv(binary: str, host: str, payload_size: int, timeout: float, os_name: str) -> list[str]:
|
||||||
|
timeout_s = max(1, int(timeout + 0.999))
|
||||||
|
if os_name == "Darwin":
|
||||||
|
return [binary, "-D", "-s", str(payload_size), "-c", "1", "-t", str(timeout_s), host]
|
||||||
|
return [binary, "-M", "do", "-s", str(payload_size), "-c", "1", "-W", str(timeout_s), host]
|
||||||
|
|
||||||
|
|
||||||
|
def supports_df() -> tuple[bool, str]:
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
from netsleuth.probes.icmp_win import win_icmp_available
|
||||||
|
|
||||||
|
if win_icmp_available():
|
||||||
|
return True, "icmp_win"
|
||||||
|
return False, "Windows ICMP API unavailable"
|
||||||
|
binary = shutil.which("ping")
|
||||||
|
if not binary:
|
||||||
|
return False, "no ping binary found"
|
||||||
|
return True, binary
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_windows(
|
||||||
|
resolved_ip: str, size: int, timeout: float, source_ip: str | None
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
from netsleuth.probes.icmp_win import classify_status, echo_once
|
||||||
|
|
||||||
|
payload = b"\x00" * max(0, size - _IPV4_ICMP_OVERHEAD)
|
||||||
|
timeout_ms = int(timeout * 1000)
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
return echo_once(resolved_ip, ttl=64, timeout_ms=timeout_ms, payload=payload, source_ip=source_ip, df=True)
|
||||||
|
|
||||||
|
reply = await asyncio.to_thread(_run)
|
||||||
|
kind = classify_status(reply.status)
|
||||||
|
return kind == "ok", kind == "packet_too_big"
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe_unix(binary: str, host: str, size: int, timeout: float, os_name: str) -> tuple[bool, bool]:
|
||||||
|
payload_size = max(0, size - _IPV4_ICMP_OVERHEAD)
|
||||||
|
args = unix_ping_df_argv(binary, host, payload_size, timeout, os_name)
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=timeout + 2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
return False, False
|
||||||
|
if process.returncode == 0:
|
||||||
|
return True, False
|
||||||
|
text = stdout.decode("utf-8", "replace").lower()
|
||||||
|
return False, any(marker in text for marker in _FRAG_NEEDED_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_pmtu(
|
||||||
|
host: str,
|
||||||
|
*,
|
||||||
|
low: int = 576,
|
||||||
|
high: int = 1500,
|
||||||
|
timeout: float = 2.0,
|
||||||
|
iface_mtu: int | None = None,
|
||||||
|
source_ip: str | None = None,
|
||||||
|
) -> PmtuResult:
|
||||||
|
result = PmtuResult(host=host, iface_mtu=iface_mtu)
|
||||||
|
available, detail = supports_df()
|
||||||
|
if not available:
|
||||||
|
result.note = f"PMTUD probe unavailable: {detail}"
|
||||||
|
result.note_ru = f"Проба PMTUD недоступна: {detail}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
infos = await loop.getaddrinfo(host, None, family=socket.AF_INET)
|
||||||
|
resolved_ip = infos[0][4][0]
|
||||||
|
except (OSError, IndexError) as exc:
|
||||||
|
result.note = f"could not resolve {host}: {exc}"
|
||||||
|
result.note_ru = f"не удалось разрешить {host}: {exc}"
|
||||||
|
return result
|
||||||
|
result.resolved_ip = resolved_ip
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
result.method = "icmp_win" if system == "Windows" else "system_ping"
|
||||||
|
|
||||||
|
async def _probe(size: int) -> tuple[bool, bool]:
|
||||||
|
if system == "Windows":
|
||||||
|
return await _probe_windows(resolved_ip, size, timeout, source_ip)
|
||||||
|
return await _probe_unix(detail, host, size, timeout, system)
|
||||||
|
|
||||||
|
probes: list[tuple[int, bool]] = []
|
||||||
|
saw_frag_needed = False
|
||||||
|
|
||||||
|
ok, frag = await _probe(low)
|
||||||
|
probes.append((low, ok))
|
||||||
|
saw_frag_needed = saw_frag_needed or frag
|
||||||
|
if not ok:
|
||||||
|
result.probes = probes
|
||||||
|
result.note = f"even the {low}-byte floor failed; cannot determine the path MTU"
|
||||||
|
result.note_ru = f"даже минимальный размер {low} байт не прошёл; определить MTU пути не удалось"
|
||||||
|
return result
|
||||||
|
|
||||||
|
working_low, failing_high = low, high + 1
|
||||||
|
while failing_high - working_low > 1:
|
||||||
|
size = next_probe_size(working_low, failing_high)
|
||||||
|
ok, frag = await _probe(size)
|
||||||
|
probes.append((size, ok))
|
||||||
|
saw_frag_needed = saw_frag_needed or frag
|
||||||
|
if ok:
|
||||||
|
working_low = size
|
||||||
|
else:
|
||||||
|
failing_high = size
|
||||||
|
|
||||||
|
result.probes = probes
|
||||||
|
result.discovered_mtu = working_low
|
||||||
|
result.verdict, result.note, result.note_ru = classify_pmtu(working_low, iface_mtu, saw_frag_needed)
|
||||||
|
return result
|
||||||
|
|
@ -267,6 +267,30 @@ def test_probing_ipv6_accepts_each_known_value(tmp_path):
|
||||||
assert s.probing.ipv6 == value
|
assert s.probing.ipv6 == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_targets_are_capped(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text(
|
||||||
|
'pmtud:\n targets: [{label: a, host: a.com}, {label: b, host: b.com}, {label: c, host: c.com}]\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError, match="at most 2"):
|
||||||
|
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_max_bytes_is_capped_at_jumbo_frame_ceiling(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text("pmtud:\n max_bytes: 20000\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValidationError, match="9000"):
|
||||||
|
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_max_bytes_must_exceed_min_bytes(tmp_path):
|
||||||
|
path = tmp_path / "config.yaml"
|
||||||
|
path.write_text("pmtud:\n min_bytes: 1000\n max_bytes: 900\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValidationError, match="greater than"):
|
||||||
|
load_settings(config_path=path, env_file=tmp_path / "nope.env")
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ def test_missing_modules_are_rendered_as_skipped_sections_not_dropped():
|
||||||
"tls",
|
"tls",
|
||||||
"path",
|
"path",
|
||||||
"ecmp",
|
"ecmp",
|
||||||
|
"pmtud",
|
||||||
"path_diversity",
|
"path_diversity",
|
||||||
"prefix_benchmark",
|
"prefix_benchmark",
|
||||||
"dpi_check",
|
"dpi_check",
|
||||||
|
|
@ -602,6 +603,7 @@ def test_markdown_has_every_section_from_the_spec_in_order():
|
||||||
"## TLS handshake",
|
"## TLS handshake",
|
||||||
"## Path",
|
"## Path",
|
||||||
"## ECMP / multipath",
|
"## ECMP / multipath",
|
||||||
|
"## Path MTU discovery",
|
||||||
"## Path diversity (Anycast)",
|
"## Path diversity (Anycast)",
|
||||||
"## AS prefix benchmark",
|
"## AS prefix benchmark",
|
||||||
"## DPI / port check",
|
"## DPI / port check",
|
||||||
|
|
@ -804,7 +806,7 @@ def test_diagnostics_translates_module_warnings_into_russian():
|
||||||
def test_an_all_modules_failed_run_still_renders_every_section():
|
def test_an_all_modules_failed_run_still_renders_every_section():
|
||||||
text = render_markdown(dead_report())
|
text = render_markdown(dead_report())
|
||||||
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
||||||
assert len(headings) == 17
|
assert len(headings) == 18
|
||||||
assert text.count("Not available") == len(SECTION_ORDER)
|
assert text.count("Not available") == len(SECTION_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -911,6 +913,7 @@ def test_render_markdown_default_call_is_byte_identical_to_before_the_lang_featu
|
||||||
"## TLS handshake",
|
"## TLS handshake",
|
||||||
"## Path",
|
"## Path",
|
||||||
"## ECMP / multipath",
|
"## ECMP / multipath",
|
||||||
|
"## Path MTU discovery",
|
||||||
"## Path diversity (Anycast)",
|
"## Path diversity (Anycast)",
|
||||||
"## AS prefix benchmark",
|
"## AS prefix benchmark",
|
||||||
"## DPI / port check",
|
"## DPI / port check",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import pytest
|
||||||
|
|
||||||
from netsleuth.probes.icmp_win import (
|
from netsleuth.probes.icmp_win import (
|
||||||
IP_DEST_HOST_UNREACHABLE,
|
IP_DEST_HOST_UNREACHABLE,
|
||||||
|
IP_PACKET_TOO_BIG,
|
||||||
IP_REQ_TIMED_OUT,
|
IP_REQ_TIMED_OUT,
|
||||||
IP_SUCCESS,
|
IP_SUCCESS,
|
||||||
IP_TTL_EXPIRED_TRANSIT,
|
IP_TTL_EXPIRED_TRANSIT,
|
||||||
|
|
@ -76,6 +77,7 @@ def test_a_truncated_buffer_is_reported_as_an_error_not_an_exception():
|
||||||
(IP_TTL_EXPIRED_TRANSIT, "ttl_expired"),
|
(IP_TTL_EXPIRED_TRANSIT, "ttl_expired"),
|
||||||
(IP_REQ_TIMED_OUT, "timeout"),
|
(IP_REQ_TIMED_OUT, "timeout"),
|
||||||
(IP_DEST_HOST_UNREACHABLE, "unreachable"),
|
(IP_DEST_HOST_UNREACHABLE, "unreachable"),
|
||||||
|
(IP_PACKET_TOO_BIG, "packet_too_big"),
|
||||||
(-1, "error"),
|
(-1, "error"),
|
||||||
(11050, "error"),
|
(11050, "error"),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
118
tests/test_pmtud.py
Normal file
118
tests/test_pmtud.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from netsleuth.interpret import pmtud_findings
|
||||||
|
from netsleuth.models import PmtuResult
|
||||||
|
from netsleuth.probes.pmtud import classify_pmtu, mtu_from_search, next_probe_size, unix_ping_df_argv
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_probe_size_bisects():
|
||||||
|
assert next_probe_size(576, 1500) == 1038
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_probe_size_converges_over_repeated_bisection():
|
||||||
|
low, high = 576, 1500
|
||||||
|
for _ in range(20):
|
||||||
|
if high - low <= 1:
|
||||||
|
break
|
||||||
|
mid = next_probe_size(low, high)
|
||||||
|
assert low < mid < high
|
||||||
|
low = mid # simulate "always succeeds" to force convergence toward high
|
||||||
|
assert high - low <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_probe_size_off_by_one_at_the_lower_bound():
|
||||||
|
assert next_probe_size(576, 577) == 576
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_probe_size_off_by_one_at_the_upper_bound():
|
||||||
|
assert next_probe_size(1499, 1500) == 1499
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_probe_size_handles_a_reversed_range_without_raising():
|
||||||
|
# low > high should never happen in the real search loop, but must not crash.
|
||||||
|
assert isinstance(next_probe_size(1500, 576), int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mtu_from_search_adds_the_overhead():
|
||||||
|
assert mtu_from_search(1472, 1473, overhead=28) == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_mtu_from_search_default_overhead():
|
||||||
|
assert mtu_from_search(1472, 1473) == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_pmtu_ok_when_discovered_meets_the_floor():
|
||||||
|
verdict, note, note_ru = classify_pmtu(1500, None, False)
|
||||||
|
assert verdict == "ok"
|
||||||
|
assert note and note_ru
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_pmtu_reduced_when_frag_needed_was_seen():
|
||||||
|
verdict, note, note_ru = classify_pmtu(1400, None, True)
|
||||||
|
assert verdict == "reduced"
|
||||||
|
assert "1400" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_pmtu_blackhole_when_no_frag_needed_signal():
|
||||||
|
verdict, note, note_ru = classify_pmtu(1400, None, False)
|
||||||
|
assert verdict == "blackhole"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_pmtu_unknown_when_discovered_is_none():
|
||||||
|
verdict, note, note_ru = classify_pmtu(None, None, False)
|
||||||
|
assert verdict == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_pmtu_uses_iface_mtu_as_the_floor_when_smaller_than_standard():
|
||||||
|
verdict, _note, _note_ru = classify_pmtu(1400, 1400, False)
|
||||||
|
assert verdict == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unix_ping_df_argv_linux():
|
||||||
|
argv = unix_ping_df_argv("/bin/ping", "example.com", 1472, 2.0, "Linux")
|
||||||
|
assert argv == ["/bin/ping", "-M", "do", "-s", "1472", "-c", "1", "-W", "2", "example.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unix_ping_df_argv_darwin():
|
||||||
|
argv = unix_ping_df_argv("/sbin/ping", "example.com", 1472, 2.0, "Darwin")
|
||||||
|
assert argv == ["/sbin/ping", "-D", "-s", "1472", "-c", "1", "-t", "2", "example.com"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unix_ping_df_argv_rounds_a_fractional_timeout_up():
|
||||||
|
argv = unix_ping_df_argv("/bin/ping", "example.com", 1472, 1.2, "Linux")
|
||||||
|
assert "-W" in argv
|
||||||
|
assert argv[argv.index("-W") + 1] == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def pmtu(**kw) -> PmtuResult:
|
||||||
|
base = dict(host="example.com", resolved_ip="1.2.3.4", method="icmp_win", verdict="unknown")
|
||||||
|
base.update(kw)
|
||||||
|
return PmtuResult(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_findings_silent_when_unknown():
|
||||||
|
assert pmtud_findings(pmtu(verdict="unknown")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_findings_crit_on_blackhole():
|
||||||
|
findings = pmtud_findings(pmtu(verdict="blackhole", discovered_mtu=1400, note="x", note_ru="y"))
|
||||||
|
assert [f.id for f in findings] == ["path.pmtu_blackhole.example.com"]
|
||||||
|
assert findings[0].severity == "crit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_findings_on_reduced_mtu():
|
||||||
|
findings = pmtud_findings(pmtu(verdict="reduced", discovered_mtu=1400, note="x", note_ru="y"))
|
||||||
|
ids = [f.id for f in findings]
|
||||||
|
assert "path.pmtu_reduced.example.com" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_findings_also_flags_when_below_local_iface_mtu():
|
||||||
|
findings = pmtud_findings(
|
||||||
|
pmtu(verdict="reduced", discovered_mtu=1400, iface_mtu=1500, note="x", note_ru="y")
|
||||||
|
)
|
||||||
|
ids = [f.id for f in findings]
|
||||||
|
assert "path.pmtu_below_iface_mtu.example.com" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_pmtud_findings_silent_on_ok_verdict():
|
||||||
|
assert pmtud_findings(pmtu(verdict="ok", discovered_mtu=1500)) == []
|
||||||
Loading…
Add table
Reference in a new issue