netsleuth/tests/test_pmtud.py
Andrew Pimenov bc8482cc99 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).
2026-08-12 23:15:59 +03:00

118 lines
3.8 KiB
Python

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)) == []