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.
Why link-level capture
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 viasudoonly 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 systempython3. With a pipx install, the interpreter that actually runspmtu-diaglives inside pipx's own venv instead — find it withpipx list --global(orpipx listfor 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:
setcapsilently not taking effect → confirm the filesystem (df -T <path>) supports xattrs; checkecho $?aftersetcap; confirmlibcap2-binis 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 viaPYTHONPATH/PYTHONHOME. - Root but still failing → in a container, root can lack
CAP_NET_RAWin the effective set even thougheuid == 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-
setcapafter 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(-Dfor 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
--timeoutseconds.
./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:
- Reachability — basic ICMP echo sanity check.
- PMTUD function test — small DF must pass; oversized DF should return Fragmentation Needed.
- Forward path MTU — verified by DF binary search.
- Reply path MTU — measured from captured inbound fragments.
- 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_RAWauto-detection (have_cap_net_raw()) is Linux-only; on macOS the script always relies oneuid == 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.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| pmtu_diag-1.6.tar.gz | 713.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pmtu_diag-1.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 746.6 kB
Release files / pmtu_diag-1.6.tar.gz
| Download URL | pmtu_diag-1.6.tar.gz |
|---|---|
| Size | 713.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
67085a2563a21f93987f6b66caaf35c47220cb613293df28e6e243aeaf5061d6
|
|
BLAKE2b-256 checksum How to use checksums |
58dfa9171f8401705a279915b8a8fed8bc271a5ce8cc5670d1cfb9d95415c8b0
|
| 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.6-py3-none-any.whl
| Download URL | pmtu_diag-1.6-py3-none-any.whl |
|---|---|
| Size | 33.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fca124ed2a94ae5a8553494d5cbe5c046c7859f3c4f61a1e76505d23cd39c968
|
|
BLAKE2b-256 checksum How to use checksums |
b6112f1cb0d97927e04b606091c4e33278039db6b05b246e6d428c5af5b8d655
|
| 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.6
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.