Skip to main content

pmtu-diag.py

Bidirectional Path MTU diagnostics via raw ICMP, built on scapy.

pmtu-diag.py measures the Path MTU separately for each direction of a link and tells you whether PMTU Discovery actually works on the path — something a plain ping cannot do. It crafts and parses packets as structured objects (pkt[IP].flags.MF, pkt[ICMP].type, pkt[IP].len) rather than scraping ping/tcpdump text, so behaviour is deterministic and identical on Linux and macOS.

Run modes: interactive CLI use (colored, full header), a quiet mode for embedding behind a web frontend (--quiet-header), and an unprivileged ping sweep mode (-p) — see below.


Install/Update

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/ewaldj/pmtu-diag/main/e-install.sh)"

e-install.sh installs the pmtu-diag PyPI package via pipx install --global, so the pmtu-diag command lands in a shared location (typically /usr/local/bin) usable by every user on the machine, while scapy stays isolated in pipx's own venv rather than the system Python. It installs pipx itself first if missing (via apt or brew), uses sudo automatically if the global location isn't writable, and re-running it updates an existing install in place. No prompts, no options.

Equivalent manual command: pipx install pmtu-diag --global (add sudo if the global pipx location isn't writable by your user).

What it measures

Direction Method
Forward (us → target) DF echo probes + binary search, cross-checked against the next-hop MTU reported in ICMP Fragmentation Needed (Type 3 / Code 4, RFC 1191).
Reply (target → us) A non-DF request whose echo reply is large enough to force the target/return path to fragment; inbound fragments are captured at link level before kernel reassembly, and their IP total length is read directly.
PMTUD health Detects whether the ICMP Fragmentation Needed signal is actually returned, distinguishing a working path from an ICMP-filtered black hole.

This separation matters: a path can carry a large forward MTU while the return path silently fragments (asymmetric MTU), and a NAT/VPN gateway can swallow the ICMP signal entirely, causing TCP to stall instead of failing cleanly.


The kernel reassembles inbound IP fragments before a normal socket (scapy's sr()) ever sees them, so sr() reports one clean packet and hides the fragmentation. pmtu-diag.py runs an AsyncSniffer (BPF / AF_PACKET, non-promiscuous) to capture the raw fragments on the wire. The IP total length of the first fragment (MF set, offset 0) is the return-path MTU at the narrowest hop.

Note on precision: a fragment's first part is rounded down to an 8-byte boundary, so the captured value is a lower bound — the true MTU lies in reply_mtu … reply_mtu + 7.


Requirements

  • Python 3.8+
  • scapy
  • Raw socket access: root, or a process with effective CAP_NET_RAW (Linux only). The script checks for this itself (have_cap_net_raw(), via /proc/self/status) and auto-elevates via sudo only if neither is present.

Install scapy

# Debian / Ubuntu
sudo apt install python3-scapy        # or: pip install scapy

# macOS (Homebrew Python)
pip3 install scapy

# generic / PEP 668 environments
pip install scapy --break-system-packages

Running without root (CAP_NET_RAW), e.g. for a web backend

For non-interactive use (cron, a web backend calling the script) you don't want a sudo password prompt. Grant CAP_NET_RAW to a dedicated copy of the Python interpreter instead — have_cap_net_raw() picks this up automatically and skips the sudo re-exec.

Capabilities only work on filesystems supporting the security.capability xattr (ext4/xfs/btrfs — not tmpfs/overlayfs/NFS/9p).

Installed via e-install.sh/pipx? The steps below target the system python3. With a pipx install, the interpreter that actually runs pmtu-diag lives inside pipx's own venv instead — find it with pipx list --global (or pipx list for a per-user install) and use that path (e.g. /opt/pipx/venvs/pmtu-diag/bin/python3) in step 2 below instead of the system interpreter.

# 1. Locate the real interpreter binary (not a venv shim/symlink)
readlink -f "$(which python3)"

# 2. Dedicated copy — do NOT grant the capability to the system-wide python3,
#    or every script run by it could open raw sockets.
sudo cp /usr/bin/python3.11 /usr/local/bin/python3-pmtudiag

# 3. Grant the capability
sudo setcap cap_net_raw+eip /usr/local/bin/python3-pmtudiag

# 4. Verify
getcap /usr/local/bin/python3-pmtudiag
# -> /usr/local/bin/python3-pmtudiag cap_net_raw=eip

# 5. Restrict who can use it
sudo groupadd pmtudiag
sudo chown root:pmtudiag /usr/local/bin/python3-pmtudiag
sudo chmod 750 /usr/local/bin/python3-pmtudiag
sudo usermod -aG pmtudiag <user>       # re-login (or `newgrp pmtudiag`) to apply

Run with the dedicated interpreter instead of python3/sudo:

/usr/local/bin/python3-pmtudiag pmtu-diag.py <target-IP>

Troubleshooting:

  • setcap silently not taking effect → confirm the filesystem (df -T <path>) supports xattrs; check echo $? after setcap; confirm libcap2-bin is installed.
  • Module not found only through the copy → check sys.path/sys.prefix (/usr/local/bin/python3-pmtudiag -c "import sys; print(sys.prefix, sys.path)"); fix via PYTHONPATH/PYTHONHOME.
  • Root but still failing → in a container, root can lack CAP_NET_RAW in the effective set even though euid == 0; the script detects and reports this case explicitly rather than silently falling back to sudo.
  • The capability is tied to that exact file; re-copy and re-setcap after any interpreter upgrade/reinstall.

Usage

./pmtu-diag.py <target-IP> [-i IFACE] [-c COUNT] [--max MTU] [--timeout SEC] [--quiet-header]
./pmtu-diag.py <target> -p [-r LO-HI] [-ndf] [--timeout SEC] [--quiet-header]
Option Default Description
target — Destination IP address or hostname (required).
-i, --iface scapy route lookup Network interface to probe from.
-c, --count 3 Probes per test; a majority (count//2 + 1) must succeed to confirm a size.
--max local MTU Upper MTU bound for the forward binary search.
--timeout 2.0 Per-probe reply timeout in seconds.
-p, --ping-sweep off Ping sweep mode (see below).
-r 1200-1500 -p only: MTU range LO-HI (total IP size, payload = MTU − 28).
-ndf, --no-df off (DF set) -p only: do not set the DF bit.
--quiet-header off Suppress the banner/timestamp/interface/local-MTU/source-IP header lines. For embedding behind a web frontend (e.g. pmtu.php): those server-internal details aren't meaningful to a visitor, and the target is already shown on the page. The 1/5 … 5/5 section headers still print.

The script auto-elevates with sudo when neither root nor CAP_NET_RAW is available, so a leading sudo is optional interactively. Ping sweep mode (-p) never elevates.

Examples

# Probe a remote gateway over the default route
./pmtu-diag.py 192.0.2.1

# Force a specific interface and a wider search ceiling
./pmtu-diag.py 198.51.100.10 -i en0 --max 9000

# Higher confidence on a lossy/mobile link
./pmtu-diag.py 203.0.113.5 -c 5 --timeout 3

# Web-embedded call (no banner/interface/local-IP details)
./pmtu-diag.py 192.0.2.1 --quiet-header

Ping sweep mode (-p)

Sends one plain ICMP echo request per MTU in the range (default 1200-1500, DF bit set unless -ndf) and prints OK / DROP per size plus the largest size that was answered. No other checks, no sniffing.

  • Runs without root and without scapy: Linux uses an unprivileged ICMP datagram socket (stdlib only), macOS uses the system ping (-D for DF).
  • DROP (frag-needed): the OS refused the size (local MTU / cached path MTU) or an ICMP Fragmentation Needed came back. DROP (silent): no reply.
  • Linux requires the user's group to be inside net.ipv4.ping_group_range (check: sysctl net.ipv4.ping_group_range; allow all: sudo sysctl -w net.ipv4.ping_group_range="0 2147483647").
  • With a silent-drop path, each dropped size waits --timeout seconds.
./pmtu-diag.py 192.0.2.1 -p                   # 1200-1500, DF set
./pmtu-diag.py 192.0.2.1 -p -r 1000-1500      # custom range
./pmtu-diag.py 192.0.2.1 -p -ndf              # DF not set

Output

The run is structured in five stages:

  1. Reachability — basic ICMP echo sanity check.
  2. PMTUD function test — small DF must pass; oversized DF should return Fragmentation Needed.
  3. Forward path MTU — verified by DF binary search.
  4. Reply path MTU — measured from captured inbound fragments.
  5. Result — effective bidirectional PMTU, path symmetry, PMTUD verdict, and an MSS suggestion.

The final report includes a ready-to-use MSS clamp, e.g.:

Cisco IOS:   ip tcp adjust-mss 1452   (on the interface)

PMTUD verdicts

Verdict Meaning
WORKING ICMP Fragmentation Needed was returned — PMTUD functions.
ICMP_FILTERED DF packets dropped silently — black-hole risk, clamp MSS.
NOT_TRIGGERED No bottleneck below the search ceiling — raise --max to test.
OFFLOAD_BYPASS DF appears bypassed (NIC offload / path fragmenting).

A suspected PMTUD black hole / NAT issue is flagged separately when DF packets vanish without any ICMP signal — typical of NAT, CGNAT, or VPN gateways that fail to relay ICMP back to the host.


Notes & limitations

  • IPv4 only. ICMPv6 Packet Too Big is not yet handled.
  • Target must be a remote IP. A loopback / local address is detected and rejected — local traffic never crosses the NIC, so raw probes see no reply.
  • On macOS/BPF the OS rejects DF packets larger than the egress NIC MTU locally (Message too long). The script caps probes at the local MTU and reports such cases as a local send limit, not a path result.
  • The reply-path MTU is a lower bound (8-byte fragment rounding, see above).
  • CAP_NET_RAW auto-detection (have_cap_net_raw()) is Linux-only; on macOS the script always relies on euid == 0 / sudo.

Versioning

Current version: VERSION at the top of pmtu-diag.py. Each change bumps it by 0.01; the previous version is archived to Archive/pmtu-diag-v<old-version>.py.


License / attribution

by ewald@jeitler.cc — https://www.jeitler.guru

When I wrote this code, only God and I knew how it worked. Now only God and the AI know it. And since the AI helped write it… good luck to all of us.

Metadata

Release files for pmtu-diag 1.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pmtu-diag 1.5
File Size Uploaded Provenance
pmtu_diag-1.5.tar.gz 713.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for pmtu-diag 1.5
File Interpreter ABI Platform
pmtu_diag-1.5-py3-none-any.whl Python 3 none any Details

Total release size: 746.5 kB

Release files / pmtu_diag-1.5.tar.gz

Download URL pmtu_diag-1.5.tar.gz
Size 713.6 kB
Tags Source
SHA-256 checksum
How to use checksums
86546c96c7cee235fe46e795e0b0bedb7f03e34c894e907325fd866271768ea0
BLAKE2b-256 checksum
How to use checksums
6527b8b0cde5f83e059e131e6122308939844d32f061514b97038ef99ace1345
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.13

Release files / pmtu_diag-1.5-py3-none-any.whl

Download URL pmtu_diag-1.5-py3-none-any.whl
Size 33.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8870afde75def6953d2eeb4f5fc50e0dec1a0c3f2962a4832347c94ba4df88b0
BLAKE2b-256 checksum
How to use checksums
48450bed1953cc0bd2008ab70b33f5bfc2614e4c76f6149d8833a23b9a5bce6f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.13

Security for pmtu-diag 1.5

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page