From bc8482cc9919f9e1abc7be64ab4ba8fd5863ce8c Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Wed, 12 Aug 2026 23:15:59 +0300 Subject: [PATCH] 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 (Linux) / ping -D -s (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). --- ARCHITECTURE.md | 9 +- ARCHITECTURE.ru.md | 9 +- README.md | 2 + README.ru.md | 2 + config.yaml | 11 +++ src/netsleuth/cli.py | 33 +++++++ src/netsleuth/config.py | 30 ++++++ src/netsleuth/exporter.py | 37 +++++++ src/netsleuth/interpret.py | 62 ++++++++++++ src/netsleuth/models.py | 18 ++++ src/netsleuth/probes/icmp_win.py | 15 ++- src/netsleuth/probes/pmtud.py | 162 +++++++++++++++++++++++++++++++ tests/test_config.py | 24 +++++ tests/test_exporter.py | 5 +- tests/test_icmp_win_parse.py | 2 + tests/test_pmtud.py | 118 ++++++++++++++++++++++ 16 files changed, 534 insertions(+), 5 deletions(-) create mode 100644 src/netsleuth/probes/pmtud.py create mode 100644 tests/test_pmtud.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 53ef182..3d12659 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. 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. + 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. 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. @@ -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. +**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 ` (Linux) or `-D -s ` (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. ## Storage @@ -190,4 +197,4 @@ Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a ## 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. diff --git a/ARCHITECTURE.ru.md b/ARCHITECTURE.ru.md index 03518c0..be53bf0 100644 --- a/ARCHITECTURE.ru.md +++ b/ARCHITECTURE.ru.md @@ -40,6 +40,7 @@ src/netsleuth/ prefix_benchmark.py --prefix-bench: пинг первого хоста ограниченной выборки анонсируемых префиксов AS. dpi_check.py --my-server: одиночная самопроверка TCP RST/фильтрации. Не сканер — см. Ключевые решения. ecmp.py --ecmp: чистый diff N прогонов трассировки для обнаружения балансировки нагрузки по хопам. Не зависит от бэкенда по дизайну. + pmtud.py --pmtud: бинарный поиск самого большого пакета с DF, доходящего до цели, для обнаружения чёрной дыры PMTUD. nat64.py Один AAAA-запрос по RFC 7050 (ipv4only.arpa) для детекта трансляции NAT64/464XLAT. hop_asn.py Обогащение хопов ASN/именем AS/страной/PTR через DNS Team Cymru, дедуп в рамках прогона. 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 — оставлено как задокументированное продолжение, а не раздувание этой фазы под него. +**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 ` (Linux) или `-D -s ` (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 с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции. ## Хранение @@ -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` рядом с фикстурами других провайдеров. diff --git a/README.md b/README.md index 4e78149..79f4c84 100644 --- a/README.md +++ b/README.md @@ -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. - **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. +- **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. - **DPI / port self-check** (`--my-server `) — 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 | | `--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 | +| `--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 ` | Check a server **you own** for TCP port blocking / RST injection. Single host only — rejects CIDR input outright. **Not** included in `--full` — opt in explicitly | | `--ndt7` | Opt in to M-Lab NDT7 (publishes your measurement, including your IP, as CC0 open data). On an interactive terminal it prints the consent notice and asks first; on a non-interactive one it proceeds without asking | | `--tcp-trace` | Opt in to a scapy TCP-SYN traceroute through ICMP-filtering middleboxes (needs the `tcptrace` extra plus Npcap on Windows or root on Unix). Falls through to the normal cascade when it cannot run | diff --git a/README.ru.md b/README.ru.md index 9b81ea0..6d21550 100644 --- a/README.ru.md +++ b/README.ru.md @@ -69,6 +69,7 @@ netsleuth - **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт. - **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент. - **Детект ECMP / нескольких маршрутов** (`--ecmp`) — трассирует пару эталонных хостов несколько раз и сравнивает, какой следующий хоп появлялся на каждом TTL, ловя балансировку нагрузки по хопам, из-за которой трассировки выглядят непоследовательными, и отмечая, если одна из веток балансировки заметно хуже другой. +- **Обнаружение MTU пути** (`--pmtud`) — бинарным поиском находит самый большой пакет с флагом Don't Fragment, реально доходящий до цели, отличая путь, который просто уже (роутер корректно говорит «нужна фрагментация»), от чёрной дыры PMTUD (крупные пакеты молча исчезают — классический симптом VPN/PPPoE «SSH подключается и зависает»). - **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование. - **Самопроверка DPI / портов** (`--my-server `) — одиночная TCP-проба по 6 фиксированным портам **сервера, которым вы владеете**, с классификацией инъекции TCP RST, тихой фильтрации или чистого ответа. Это не портсканер: один хост за запуск, фиксированный короткий список портов, и цель никогда не берётся из вашего собственного внешнего IP или из данных BGP — почему именно так, см. [ARCHITECTURE.ru.md](ARCHITECTURE.ru.md). @@ -93,6 +94,7 @@ netsleuth | `--prefix-bench` | Пропинговать первый хост нескольких анонсируемых префиксов своей AS в поисках точки с наименьшей задержкой. **Не** входит в `--full` — включается явно | | `--ecmp` | Трассировать пару эталонных хостов несколько раз (`ecmp.runs`, потолок 5) для детекта балансировки нагрузки по хопам и замера, насколько неравноценны ветки. **Не** входит в `--full` — умножает трафик трассировки к тем же целям, включается явно | | `--ipv6` / `--no-ipv6` | Принудительно включить или выключить замер задержки до эталонных хостов IPv6, переопределяя `probing.ipv6` (по умолчанию `auto`: только на dual-stack соединении). На v4-only хосте `auto` — это no-op, можно не трогать | +| `--pmtud` | Бинарным поиском найти самый большой пакет с DF, доходящий до каждого хоста из `pmtud.targets` (по умолчанию: 2 хоста), для обнаружения чёрной дыры MTU пути. **Не** входит в `--full` — включается явно | | `--my-server ` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно | | `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса | | `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад | diff --git a/config.yaml b/config.yaml index c054e37..8d8e5ae 100644 --- a/config.yaml +++ b/config.yaml @@ -132,6 +132,17 @@ path_diversity: # 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 # 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 # 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. diff --git a/src/netsleuth/cli.py b/src/netsleuth/cli.py index 391baf7..b88dc56 100644 --- a/src/netsleuth/cli.py +++ b/src/netsleuth/cli.py @@ -35,6 +35,7 @@ from netsleuth.interpret import ( path_asn_findings, path_diversity_findings, path_findings, + pmtud_findings, prefix_findings, speed_findings, tls_findings, @@ -92,6 +93,7 @@ class Options: path_diversity: bool = False prefix_bench: bool = False ecmp: bool = False + pmtud: bool = False ipv6: Optional[bool] = None dpi_target: str | None = None 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) +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: if not options.ecmp: 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( "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 # 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) + 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 result in (modules["pmtud"].data or []) for f in pmtud_findings(result)] + dual_family_findings(pings) ) @@ -863,6 +890,11 @@ def run( "--ipv6/--no-ipv6", 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( None, "--my-server", @@ -935,6 +967,7 @@ def run( path_diversity=path_diversity or full, prefix_bench=prefix_bench, ecmp=ecmp, + pmtud=pmtud, ipv6=ipv6, dpi_target=my_server, formats=formats, diff --git a/src/netsleuth/config.py b/src/netsleuth/config.py index 5fae61b..10a88f8 100644 --- a/src/netsleuth/config.py +++ b/src/netsleuth/config.py @@ -238,6 +238,35 @@ class DnsAdvancedConfig(BaseModel): 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): runs: int = 3 max_targets: int = 2 @@ -384,6 +413,7 @@ class Settings(BaseSettings): path_diversity: PathDiversityConfig = PathDiversityConfig() captive_portal: CaptivePortalConfig = CaptivePortalConfig() ecmp: EcmpConfig = EcmpConfig() + pmtud: PmtudConfig = PmtudConfig() ipinfo_token: SecretStr | None = Field(default=None, alias="IPINFO_TOKEN") peeringdb_api_key: SecretStr | None = Field(default=None, alias="PEERINGDB_API_KEY") diff --git a/src/netsleuth/exporter.py b/src/netsleuth/exporter.py index a61c75e..909b6b9 100644 --- a/src/netsleuth/exporter.py +++ b/src/netsleuth/exporter.py @@ -23,6 +23,7 @@ SECTION_ORDER = ( "tls", "path", "ecmp", + "pmtud", "path_diversity", "prefix_benchmark", "dpi_check", @@ -129,6 +130,7 @@ _WARNING_RU = { "no throughput measured": "пропускная способность не измерена", "captive portal check disabled in config": "проверка captive portal отключена в конфиге", "ecmp probe skipped: not requested": "проверка ECMP пропущена: не запрошена", + "pmtud probe skipped: not requested": "проверка PMTUD пропущена: не запрошена", } @@ -258,6 +260,7 @@ _SECTION_TITLES = { "tls": "## TLS handshake", "path": "## Path", "ecmp": "## ECMP / multipath", + "pmtud": "## Path MTU discovery", "path_diversity": "## Path diversity (Anycast)", "prefix_benchmark": "## AS prefix benchmark", "dpi_check": "## DPI / port check", @@ -276,6 +279,7 @@ _SECTION_TITLES_RU = { "tls": "## TLS-рукопожатие", "path": "## Маршрут", "ecmp": "## ECMP / несколько маршрутов", + "pmtud": "## Обнаружение MTU пути", "path_diversity": "## Диверсификация маршрута (Anycast)", "prefix_benchmark": "## Бенчмарк префиксов AS", "dpi_check": "## Проверка DPI / портов", @@ -689,6 +693,38 @@ def _ecmp(report: dict, lang: str = "en") -> list[str]: 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]: title = _section_title("speed", lang) note = unavailable(report, "speed", lang) @@ -993,6 +1029,7 @@ def render_markdown( lines += _tls(report, lang) lines += _path(report, lang) lines += _ecmp(report, lang) + lines += _pmtud(report, lang) lines += _path_diversity(report, lang) lines += _prefix_benchmark(report, lang) lines += _dpi_check(report, lang) diff --git a/src/netsleuth/interpret.py b/src/netsleuth/interpret.py index 6837404..63f507c 100644 --- a/src/netsleuth/interpret.py +++ b/src/netsleuth/interpret.py @@ -14,6 +14,7 @@ from netsleuth.models import ( LocalNet, PathDiversity, PingResult, + PmtuResult, PrefixBenchmark, Signal, 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 diff --git a/src/netsleuth/models.py b/src/netsleuth/models.py index bb4d293..7c88f09 100644 --- a/src/netsleuth/models.py +++ b/src/netsleuth/models.py @@ -25,6 +25,7 @@ DPI_VERDICTS = ("clean", "partial_filtering", "reset_injection", "unreachable", RESOLVER_KINDS = ("system", "doh") EDGE_SOURCES = ("cf_ray", "cf_trace", "server_timing", "none") PORTAL_VERDICTS = ("clean", "portal", "suspect", "error") +PMTU_VERDICTS = ("ok", "reduced", "blackhole", "unknown") @dataclass @@ -522,6 +523,23 @@ class EcmpReport: 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: if obj is None or isinstance(obj, (str, bool, int)): return obj diff --git a/src/netsleuth/probes/icmp_win.py b/src/netsleuth/probes/icmp_win.py index 578bb23..1b242f5 100644 --- a/src/netsleuth/probes/icmp_win.py +++ b/src/netsleuth/probes/icmp_win.py @@ -8,6 +8,8 @@ IP_DEST_NET_UNREACHABLE = 11002 IP_DEST_HOST_UNREACHABLE = 11003 IP_REQ_TIMED_OUT = 11010 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} _HEAD = struct.Struct("<4BIIHH") @@ -27,6 +29,8 @@ def classify_status(status: int) -> str: return "ttl_expired" if status == IP_REQ_TIMED_OUT: return "timeout" + if status == IP_PACKET_TOO_BIG: + return "packet_too_big" if status in _UNREACHABLE: return "unreachable" return "error" @@ -125,11 +129,18 @@ def _handle(): 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: iphlpapi, handle = _handle() 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) address = struct.unpack(" 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 diff --git a/tests/test_config.py b/tests/test_config.py index c79f361..4b0342f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -267,6 +267,30 @@ def test_probing_ipv6_accepts_each_known_value(tmp_path): 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): path = tmp_path / "config.yaml" path.write_text("ecmp:\n runs: 10\n", encoding="utf-8") diff --git a/tests/test_exporter.py b/tests/test_exporter.py index 2b5b31b..df0053f 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -92,6 +92,7 @@ def test_missing_modules_are_rendered_as_skipped_sections_not_dropped(): "tls", "path", "ecmp", + "pmtud", "path_diversity", "prefix_benchmark", "dpi_check", @@ -602,6 +603,7 @@ def test_markdown_has_every_section_from_the_spec_in_order(): "## TLS handshake", "## Path", "## ECMP / multipath", + "## Path MTU discovery", "## Path diversity (Anycast)", "## AS prefix benchmark", "## 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(): text = render_markdown(dead_report()) 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) @@ -911,6 +913,7 @@ def test_render_markdown_default_call_is_byte_identical_to_before_the_lang_featu "## TLS handshake", "## Path", "## ECMP / multipath", + "## Path MTU discovery", "## Path diversity (Anycast)", "## AS prefix benchmark", "## DPI / port check", diff --git a/tests/test_icmp_win_parse.py b/tests/test_icmp_win_parse.py index 9827345..5683146 100644 --- a/tests/test_icmp_win_parse.py +++ b/tests/test_icmp_win_parse.py @@ -6,6 +6,7 @@ import pytest from netsleuth.probes.icmp_win import ( IP_DEST_HOST_UNREACHABLE, + IP_PACKET_TOO_BIG, IP_REQ_TIMED_OUT, IP_SUCCESS, 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_REQ_TIMED_OUT, "timeout"), (IP_DEST_HOST_UNREACHABLE, "unreachable"), + (IP_PACKET_TOO_BIG, "packet_too_big"), (-1, "error"), (11050, "error"), ], diff --git a/tests/test_pmtud.py b/tests/test_pmtud.py new file mode 100644 index 0000000..23d8987 --- /dev/null +++ b/tests/test_pmtud.py @@ -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)) == []