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:
Andrew Pimenov 2026-08-12 23:15:59 +03:00
parent 10846e3017
commit bc8482cc99
16 changed files with 534 additions and 5 deletions

View file

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

View file

@ -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 <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 с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
## Хранение
@ -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` рядом с фикстурами других провайдеров.

View file

@ -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 <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 |
| `--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 <host>` | Check a server **you own** for TCP port blocking / RST injection. Single host only — rejects CIDR input outright. **Not** included in `--full` — opt in explicitly |
| `--ndt7` | Opt in to M-Lab NDT7 (publishes your measurement, including your IP, as CC0 open data). On an interactive terminal it prints the consent notice and asks first; on a non-interactive one it proceeds without asking |
| `--tcp-trace` | Opt in to a scapy TCP-SYN traceroute through ICMP-filtering middleboxes (needs the `tcptrace` extra plus Npcap on Windows or root on Unix). Falls through to the normal cascade when it cannot run |

View file

@ -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 <host>`) — одиночная 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 <host>` | Проверить сервер, **которым вы владеете**, на блокировку портов / инъекцию RST. Только один хост — CIDR отклоняется сразу. **Не** входит в `--full` — включается явно |
| `--ndt7` | Включить M-Lab NDT7 (замер публикуется как открытые данные CC0, включая ваш IP). В интерактивном терминале сначала печатает уведомление о согласии и спрашивает подтверждение; в неинтерактивном — продолжает без вопроса |
| `--tcp-trace` | Включить TCP-SYN-трассировку через scapy сквозь middlebox'ы, режущие ICMP (нужны extras `tcptrace` и Npcap на Windows или root на Unix). Если не может запуститься — откатывается на обычный каскад |

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("<I", socket.inet_aton(dest))[0]
if source_ip:

View 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

View file

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

View file

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

View file

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

118
tests/test_pmtud.py Normal file
View 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)) == []