Troubleshooting · python · 169 copies
Path MTU Analyzer
A ping is 100 bytes and the query result is 9000, so every diagnostic anyone reaches for first is the wrong size to find the problem. This measures the largest packet a path actually passes -- DF-bit binary search, hop by hop -- and compares it against what the devices believe. The gap between the two is an encapsulation nobody told you about. It also detects the PMTUD black hole, where ICMP fragmentation-needed is filtered and TCP opens a session it cannot use.
- Cisco ASA
- Arista EOS
- Cisco IOS
- Cisco IOS-XE
- Cisco IOS-XR
- Junos
- Cisco NX-OS
- PAN-OS
pip install "netmiko>=4.0" "PyYAML>=6.0"#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""path-mtu-analyzer -- measure and audit the MTU of a real path.
SAFE TO RUN: read-only on every device. It sends `show` commands and, unless
you pass --no-probe, ICMP echoes with the DF bit set from the device you start
from. It configures nothing and changes nothing.
EXIT CODES: 0 clean, 1 a MEDIUM finding, 2 a HIGH finding.
Credentials come from NET_USER / NET_PASS in the environment, never from the
command line -- an argument is visible in `ps auxww` and lands in shell
history.
"""
import argparse
import csv
import hashlib
import io
import os
import re
import sys
import textwrap
_SENTINEL = object()
NETMIKO_DRIVER = {
"ios": "cisco_ios", "iosxe": "cisco_ios", "iosxr": "cisco_xr",
"nxos": "cisco_nxos", "junos": "juniper_junos", "eos": "arista_eos",
"panos": "paloalto_panos", "fortios": "fortinet", "asa": "cisco_asa",
}
PROTO_NUM = {"tcp": "6", "udp": "17", "icmp": "1"}
CMD = {
"ios": {
"po_members": "show etherchannel summary | include {intf}",
"addr2intf": "show ip interface brief | include {ip}",
"vrf_list": "show vrf",
"version": "show version",
"logs": "show logging | include {intf}",
"arp": "show ip arp {ip}", "arp_vrf": "show ip arp vrf {vrf} {ip}",
"mac": "show mac address-table address {mac}",
"cdp": "show cdp neighbors {intf} detail",
"lldp": "show lldp neighbors {intf} detail",
"route": "show ip route {dst}", "route_vrf": "show ip route vrf {vrf} {dst}",
"exact": "show ip cef exact-route {src} {dst}",
"intf": "show interfaces {intf}", "traceroute": "traceroute {dst}",
"hsrp": "show standby brief", "vrrp": "show vrrp brief",
"session": None, "policy": None,
},
"nxos": {
"po_members": "show port-channel summary interface {intf}",
"addr2intf": "show ip interface brief | include {ip}",
"vrf_list": "show vrf",
"version": "show version",
"logs": "show logging logfile | include {intf}",
"arp": "show ip arp {ip}", "arp_vrf": "show ip arp {ip} vrf {vrf}",
"mac": "show mac address-table address {mac}",
"cdp": "show cdp neighbors interface {intf} detail",
"lldp": "show lldp neighbors interface {intf} detail",
"route": "show ip route {dst}", "route_vrf": "show ip route {dst} vrf {vrf}",
"exact": None, "intf": "show interface {intf}",
"traceroute": "traceroute {dst}",
"hsrp": "show hsrp brief", "vrrp": "show vrrp brief",
"session": None, "policy": None,
},
"iosxr": {
"po_members": "show bundle {intf}",
"addr2intf": "show ipv4 interface brief | include {ip}",
"vrf_list": "show vrf all",
"version": "show version",
"logs": "show logging | include {intf}",
"arp": "show arp {ip}", "arp_vrf": "show arp vrf {vrf} {ip}",
"mac": None, "cdp": "show cdp neighbors {intf} detail",
"lldp": "show lldp neighbors {intf} detail",
"route": "show route {dst}", "route_vrf": "show route vrf {vrf} {dst}",
"exact": "show cef exact-route {src} {dst}",
"intf": "show interfaces {intf}", "traceroute": "traceroute {dst}",
"hsrp": "show hsrp brief", "vrrp": "show vrrp brief",
"session": None, "policy": None,
},
"eos": {
"po_members": "show port-channel {intf} detailed",
"addr2intf": "show ip interface brief | include {ip}",
"vrf_list": "show vrf",
"version": "show version",
"logs": "show logging | include {intf}",
"arp": "show ip arp {ip}", "arp_vrf": "show ip arp vrf {vrf} {ip}",
"mac": "show mac address-table address {mac}",
"cdp": None, "lldp": "show lldp neighbors {intf} detail",
"route": "show ip route {dst}", "route_vrf": "show ip route vrf {vrf} {dst}",
"exact": None, "intf": "show interfaces {intf}",
"traceroute": "traceroute {dst}",
"hsrp": None, "vrrp": "show vrrp brief",
"session": None, "policy": None,
},
"junos": {
"po_members": "show interfaces {intf} extensive | match Link:",
"addr2intf": "show interfaces terse | match {ip}",
"vrf_list": "show route instance summary",
"version": "show version",
"logs": "show log messages | match {intf}",
"arp": "show arp hostname {ip}", "arp_vrf": "show arp hostname {ip}",
"mac": "show ethernet-switching table | match {mac}",
"cdp": None, "lldp": "show lldp neighbors interface {intf}",
"route": "show route {dst}", "route_vrf": "show route {dst} table {vrf}.inet.0",
"exact": None, "intf": "show interfaces {intf} extensive",
"traceroute": "traceroute {dst}",
"hsrp": None, "vrrp": "show vrrp summary",
"session": "show security flow session destination-prefix {dst} destination-port {dport}",
# The ONLY platform where the policy lookup is itself a show, so an SRX
# can be walked end to end with nothing but read access.
"policy": "show security match-policies from-zone {from_zone} to-zone {to_zone} "
"source-ip {src} destination-ip {dst} source-port 1024 "
"destination-port {dport} protocol {proto_name}",
"policy_class": "show", "needs_zones": True,
},
"panos": {
"po_members": None,
"addr2intf": "show interface all | match {ip}",
"vrf_list": "show routing summary",
"version": "show system info",
"logs": None,
"arp": "show arp all | match {ip}", "arp_vrf": "show arp all | match {ip}",
"mac": None, "cdp": None, "lldp": "show lldp neighbors all",
# Chained on purpose: the FIB lookup gives the egress interface, and
# the egress interface gives the zone that security-policy-match
# REQUIRES. Invented zones return a rule that is not the one traffic
# would match.
"route": "test routing fib-lookup virtual-router {vrf} ip {dst}",
"route_vrf": "test routing fib-lookup virtual-router {vrf} ip {dst}",
"exact": None, "intf": "show interface {intf}",
"traceroute": "traceroute host {dst}",
"hsrp": None, "vrrp": "show high-availability state",
"session": "show session all filter source {src} destination {dst} "
"destination-port {dport} protocol {proto}",
"policy": "test security-policy-match from {from_zone} to {to_zone} "
"source {src} destination {dst} destination-port {dport} protocol {proto}",
"policy_class": "test", "needs_zones": True,
},
"fortios": {
"po_members": None,
"addr2intf": "get system interface | grep {ip}",
"vrf_list": "get router info vrf",
"version": "get system status",
"logs": None,
"arp": "get system arp | grep {ip}", "arp_vrf": "get system arp | grep {ip}",
"mac": None, "cdp": None, "lldp": None,
"route": "get router info routing-table details {dst}",
"route_vrf": "get router info routing-table details {dst}",
"exact": None, "intf": "get system interface physical",
"traceroute": "execute traceroute {dst}",
"hsrp": None, "vrrp": "get system ha status",
"session": "diagnose sys session filter dst {dst}",
"policy": "diagnose firewall iprope lookup {src} 1024 {dst} {dport} {proto} {intf}",
"policy_class": "diagnose", "needs_zones": False,
},
"asa": {
"po_members": None,
"addr2intf": "show interface ip brief | include {ip}",
"vrf_list": None,
"version": "show version",
"logs": "show logging | include {intf}",
"arp": "show arp | include {ip}", "arp_vrf": "show arp | include {ip}",
"mac": None, "cdp": None, "lldp": None,
"route": "show route {dst}", "route_vrf": "show route {dst}",
"exact": None, "intf": "show interface {intf}",
"traceroute": "traceroute {dst}",
"hsrp": None, "vrrp": "show failover state",
"session": "show conn address {dst} port {dport}",
"policy": "packet-tracer input {intf} {proto_name} {src} 1024 {dst} {dport}",
"policy_class": "privileged-exec", "needs_zones": False,
},
}
CMD["iosxe"] = CMD["ios"]
FIREWALL_OS = {"panos", "fortios", "asa", "junos"}
AUTH_NOTES = {
"junos": "show only. 'show security match-policies' IS a show, so an SRX needs "
"nothing beyond read access",
"panos": "show plus the test class: 'test routing fib-lookup' and "
"'test security-policy-match'",
"asa": "show plus 'packet-tracer', which usually needs privilege 15",
"fortios": "show/get plus the SPECIFIC command 'diagnose firewall iprope lookup'. "
"Never authorise 'diagnose *' -- that family is not read-only and some "
"branches change state",
"ios": "show plus 'traceroute', which is not a show command",
}
for _k in ("iosxe", "nxos", "iosxr", "eos"):
AUTH_NOTES[_k] = AUTH_NOTES["ios"]
# ----------------------------------------------------------------- inventory
def load_inventory(path):
try:
import yaml
except ImportError:
raise SystemExit("PyYAML is not installed. Run: pip install pyyaml")
try:
with open(path, encoding="utf-8") as fh:
doc = yaml.safe_load(fh) or {}
except OSError as exc:
raise SystemExit("could not read the inventory: %s" % exc)
defaults = doc.get("defaults") or {}
devices = doc.get("devices")
if not devices:
raise SystemExit("the inventory has no 'devices:' list")
out = {}
for i, raw in enumerate(devices, 1):
d = dict(defaults)
d.update(raw or {})
if not d.get("host"):
raise SystemExit("device %d has no 'host'" % i)
osn = str(d.get("os", "")).lower()
if osn not in NETMIKO_DRIVER:
raise SystemExit(
"device %s has os '%s'. Valid values: %s"
% (d["host"], osn or "(missing)", ", ".join(sorted(NETMIKO_DRIVER))))
d["os"] = osn
d["port"] = int(d.get("port", 22))
d["device_name"] = d.get("device_name") or d["host"]
# Connect here; match on any of these.
d["mgmt"] = d.get("mgmt") or d["host"]
addrs = [d["host"], d["mgmt"]] + list(d.get("addresses") or [])
d["addresses"] = [a for a in addrs if a]
for a in d["addresses"]:
if a in out and out[a] is not d:
raise SystemExit(
"address %s is listed on two devices (%s and %s). An address "
"can only belong to one." % (a, out[a]["device_name"], d["device_name"]))
out[a] = d
return out
def credentials():
user, pwd = os.environ.get("NET_USER"), os.environ.get("NET_PASS")
if not user or not pwd:
raise SystemExit(
"NET_USER and NET_PASS must be set in the environment.\n"
" Linux/macOS : export NET_USER=admin; read -s NET_PASS; export NET_PASS\n"
" PowerShell : $env:NET_USER='admin'; $env:NET_PASS='...'\n"
"They are read from the environment on purpose: an argument is visible "
"in 'ps auxww' and lands in shell history.")
return user, pwd, os.environ.get("NET_ENABLE") or pwd
# ----------------------------------------------------------------- transport
def slug(host, command):
"""Filename for one command's output.
A readable prefix plus a short digest of the WHOLE command. The prefix
alone is not enough -- two policy lookups differ only in the port, far past
any sensible truncation -- and the full command blew past the Windows path
limit on the first run.
"""
h = "".join(c if c.isalnum() else "-" for c in host)
keep = "".join(c if c.isalnum() else "-" for c in command)[:48].strip("-")
digest = hashlib.sha1(command.encode("utf-8")).hexdigest()[:8]
return "%s__%s__%s.txt" % (h, keep, digest)
class Transport(object):
"""Live SSH, or a replay of saved command output. Same interface either way.
Offline replay is how this was tested without a lab, and it is how you hand
a colleague the exact output a finding came from.
"""
def __init__(self, args):
self.a = args
self.sessions = {}
def run(self, host, osn, command):
if self.a.offline_dir:
path = os.path.join(self.a.offline_dir, slug(host, command))
if not os.path.exists(path):
return None
with open(path, encoding="utf-8", errors="replace") as fh:
return fh.read()
conn = self.sessions.get(host)
if conn is None:
try:
from netmiko import ConnectHandler
except ImportError:
raise SystemExit("netmiko is not installed. Run: pip install 'netmiko>=4.0'")
user, pwd, secret = credentials()
conn = ConnectHandler(device_type=NETMIKO_DRIVER[osn], host=host,
username=user, password=pwd, secret=secret,
fast_cli=False, conn_timeout=20)
self.sessions[host] = conn
out = conn.send_command(command, read_timeout=120)
if self.a.save_dir:
if not os.path.isdir(self.a.save_dir):
os.makedirs(self.a.save_dir)
with open(os.path.join(self.a.save_dir, slug(host, command)),
"w", encoding="utf-8") as fh:
fh.write(out)
return out
def close(self):
for c in self.sessions.values():
try:
c.disconnect()
except Exception:
pass
# One interface, one name. `show ip interface brief` prints Gi1/0/12 while
# `show interfaces` prints GigabitEthernet1/0/12, so without this the same
# port appears under two names in one report and reads as two ports.
# Longest prefix first, or "Gi" would claim "GigabitEthernet" before the full
# form is tried. Junos (ge-0/0/0), PAN-OS (ethernet1/3) and FortiOS already
# print their canonical form, so they are absent on purpose.
ABBREV = [("TwentyFiveGigE", ("TwentyFiveGigE", "Twe")),
("HundredGigE", ("HundredGigE", "Hu")),
("TenGigabitEthernet", ("TenGigabitEthernet", "TenGigE", "TenGig", "Te")),
("FortyGigabitEthernet", ("FortyGigabitEthernet", "FortyGigE", "Fo")),
("GigabitEthernet", ("GigabitEthernet", "GigEthernet", "Gi")),
("FastEthernet", ("FastEthernet", "Fa")),
("Port-channel", ("Port-channel", "port-channel", "Po")),
("Ethernet", ("Ethernet", "Eth", "Et")),
("Vlan", ("Vlan", "Vl")),
("Tunnel", ("Tunnel", "Tu")),
("Loopback", ("Loopback", "Lo")),
("Management", ("Management", "Mgmt", "Ma"))]
# Logical interfaces. An SVI, a tunnel or a loopback has no link, so it has no
# negotiated speed and no duplex -- what a platform prints there is the
# CONFIGURED bandwidth, used for metric arithmetic. Printing it as if it were
# a link rate is how "Vlan220 up 1G full" ends up in a report about a port
# that does not exist. Receive errors cannot appear on one either.
LOGICAL = ("vlan", "loopback", "tunnel", "port-channel", "irb", "ae", "reth",
"bvi", "virtual-template", "management")
def canon_intf(name):
"""Expand an ABBREVIATION to its full form; leave a full name alone.
The distinction matters because this name is also what goes into
`show interfaces <x>`. PAN-OS writes ethernet1/3 and Junos writes
ge-0/0/0: those ARE the full names, and "correcting" the capitalisation
sent the device a command about an interface it does not have. Expansion
is for Gi/Te/Vl, which every platform that uses them also accepts in full.
"""
if not name:
return name
for full, forms in ABBREV:
if name.lower().startswith(full.lower()) and name[len(full):][:1].isdigit():
return name # already full -- the device's own spelling
for f in forms:
if f.lower() != full.lower() and name.lower().startswith(f.lower()):
rest = name[len(f):]
if rest[:1].isdigit():
return full + rest
return name
def intf_kind(name):
""""logical" for an SVI/tunnel/loopback/aggregate, else "physical".
A LAG is listed as logical deliberately: it has member links with their
own speeds, so the aggregate's own duplex is not a fact about any cable.
"""
if not name:
return "physical"
low = canon_intf(name).lower()
return "logical" if any(low.startswith(x) for x in LOGICAL) else "physical"
# ----------------------------------------------------------------- parsers
MAC_RE = re.compile(r"\b([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}|"
r"(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})\b")
IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
# Case-insensitive on purpose: PAN-OS writes "ethernet1/3" and Junos writes
# "ge-0/0/0". A case-sensitive version silently failed to find the egress on
# both and reported the output as unrecognised.
INTF_RE = re.compile(
r"\b((?:TenGigabitEthernet|GigabitEthernet|FortyGigE|HundredGigE|"
r"Port-channel|Ethernet|Vlan|Tunnel|Loopback|"
r"Gi|Te|Fo|Hu|Twe|Fa|Eth|Et|Po|Vl|Tu|Lo|"
r"xe-|ge-|et-|ae|irb|reth|lt-|st)"
r"[0-9][\w/.:-]*)\b", re.I)
def mac_for(osn, mac):
"""The MAC table is queried in the platform's own notation.
Cisco wants aaaa.bbbb.cccc, everyone else wants aa:bb:cc:dd:ee:ff. Sending
the wrong one returns an empty table, which reads exactly like "the host is
not there" -- a wrong answer that looks like a finding.
"""
hexs = re.sub(r"[^0-9a-fA-F]", "", mac).lower()
if len(hexs) != 12:
return mac
if osn in ("ios", "iosxe", "nxos", "iosxr"):
return "%s.%s.%s" % (hexs[0:4], hexs[4:8], hexs[8:12])
return ":".join(hexs[i:i + 2] for i in range(0, 12, 2))
def parse_route_egress(text):
"""Egress interface from a route lookup.
Returns None rather than a guess when the format is not recognised; the
caller turns that into COULD_NOT_VERIFY with the raw output attached.
"""
if not text:
return None, None
nh = None
m = re.search(r"via\s+(" + IP_RE.pattern + r")", text)
if m:
nh = m.group(1)
m = re.search(r"(?:via\s+\S+,\s*|,\s*|interface\s+|egress-interface\s+)"
r"(" + INTF_RE.pattern + r")", text, re.I)
if m:
return m.group(1), nh
hits = INTF_RE.findall(text)
return (hits[-1] if hits else None), nh
MODEL_PATTERNS = [
r"Model [Nn]umber\s*:\s*(\S+)",
r"cisco\s+(\S+)\s+\(.*?\)\s+processor",
r"cisco\s+(Nexus\S*(?:\s+\S+)?)\s+Chassis",
r"cisco\s+(ASR\S+|NCS\S+|CRS\S+)",
r"\bArista\s+(\S+)",
r"^Model:\s*(\S+)",
r"^model:\s*(\S+)",
r"Version:\s*(FortiGate-\S+)",
r"Hardware:\s+(\S+?),",
r"\bcisco\s+(WS-\S+|C9\d\S*|ISR\S*|CSR\S*)",
]
# The role changes how a line reads: a saturated uplink on an access switch
# and on a core router are different conversations.
ROLE_BY_OS = {"panos": "firewall", "fortios": "firewall", "asa": "firewall"}
ROLE_HINTS = [
("firewall", r"(ASA|FPR|PA-\d|FortiGate|SRX)"),
("switch", r"(WS-C|C9[23]\d\d|Nexus|DCS-|EX\d|QFX)"),
("router", r"(ISR|ASR|CSR|NCS|CRS|MX\d|C8\d\d\d)"),
]
def parse_po_members(text):
"""[(member, state)] from a port-channel summary.
The state letters matter as much as the names: a member that is (s)
suspended or (D) down is carrying nothing, and a flow hashed to a
suspended member is a flow that is being dropped by a port-channel the
aggregate counters call healthy.
"""
out = []
if not text:
return out
for line in text.split(chr(10)):
for m in re.finditer(r"(" + INTF_RE.pattern + r")\s*\(([A-Za-z]{1,2})\)", line):
name = canon_intf(m.group(1))
if intf_kind(name) == "physical":
out.append((name, m.group(3)))
if not out: # platforms that print no state letters
for line in text.split(chr(10)):
for hit in INTF_RE.findall(line):
nm = canon_intf(hit)
if intf_kind(nm) == "physical" and nm not in [x[0] for x in out]:
out.append((nm, ""))
return out
def parse_ecmp(text):
"""How many next hops the route offers.
exact-route says which one THIS flow takes. It does not say that four
others exist, so a report can be clean about one fifth of the topology
and read as clean about all of it.
"""
if not text:
return 0
hops = set(re.findall(r"(?:via|next-hop|nexthop|to)\s+((?:\d{1,3}\.){3}\d{1,3})",
text, re.I))
# IOS and IOS-XE write a route descriptor block per path, as
# "* 10.0.0.1, from 10.0.0.1, 1w2d ago, via Port-channel1" -- there "via"
# introduces the INTERFACE, so the pattern above finds nothing on the one
# format most of this catalogue runs against.
hops |= set(re.findall(r"^\s*\*?\s*((?:\d{1,3}\.){3}\d{1,3}),\s", text, re.M))
return len(hops)
def parse_interface(text):
"""Everything an engineer reads off one interface, and nothing invented.
A field that could not be read stays absent and prints as a dash. It must
never default to zero: "0 CRC errors" and "I could not find the CRC
counter" are opposite findings, and a tool that conflates them says a
broken link is healthy.
"""
if not text:
return {}
t, out = text, {}
# -- state ---------------------------------------------------------
m = re.search(r"line protocol is (\w+(?: \w+)?)", t, re.I)
if m:
out["status"] = m.group(1).lower()
else:
m = re.search(r"\b(?:is )?(administratively down|up|down)\b", t, re.I)
if m:
out["status"] = m.group(1).lower()
m = re.search(r"\b(Full|Half)-duplex", t, re.I)
if m:
out["duplex"] = m.group(1).lower()
# -- MTU. "Works for ping, fails for the application" is an MTU black
# hole, and the minimum along a path is only knowable per interface.
m = re.search(r"\bMTU\s+(\d{3,5})\b", t, re.I)
if m:
out["mtu"] = int(m.group(1))
# -- bandwidth. IOS prints Kbit; others print Mb/s or Gb/s ----------
m = re.search(r"\bBW\s+(\d+)\s*Kbit", t, re.I)
if m:
out["speed"] = int(m.group(1))
else:
m = re.search(r"(\d+)\s*([MG])b/s", t, re.I)
if m:
out["speed"] = int(m.group(1)) * (1000000 if m.group(2).upper() == "G" else 1000)
else:
m = re.search(r"Speed[ :]+(\d+)", t, re.I)
if m:
out["speed"] = int(m.group(1)) * 1000
# -- load ----------------------------------------------------------
m = re.search(r"input rate\s+(\d+)\s*bits/sec", t, re.I)
if m:
out["in_bps"] = int(m.group(1))
m = re.search(r"output rate\s+(\d+)\s*bits/sec", t, re.I)
if m:
out["out_bps"] = int(m.group(1))
# -- counters. Absent stays absent ---------------------------------
COUNTERS = (
("in_err", r"(\d+) input errors"),
("crc", r"(\d+) CRC"),
("frame", r"(\d+) frame\b"),
("overrun", r"(\d+) overrun"),
("ignored", r"(\d+) ignored"),
("runts", r"(\d+) runts"),
("giants", r"(\d+) giants"),
("out_err", r"(\d+) output errors"),
("collisions", r"(\d+) collisions"),
("late_coll", r"(\d+) late collision"),
("resets", r"(\d+) interface resets"),
("drops", r"output drops:?\s*(\d+)"),
("carrier", r"(\d+) lost carrier"),
("babbles", r"(\d+) babbles"),
)
for key, pat in COUNTERS:
m = re.search(pat, t, re.I)
if m:
out[key] = int(m.group(1))
# Input queue: size/max/drops/flushes -- queue drops are the punt path
# running out, which is a different fault from output congestion.
m = re.search(r"Input queue:\s*\d+/\d+/(\d+)/(\d+)", t, re.I)
if m:
out["inq_drops"], out["inq_flushes"] = int(m.group(1)), int(m.group(2))
# -- how old are those counters ------------------------------------
# Without this, a total is not a rate. 48,000 drops since Tuesday and
# since 2019 are different findings.
m = re.search(r"Last clearing of .*counters\s+(.+?)\s*$", t, re.I | re.M)
if m:
out["counters_age"] = m.group(1).strip()
# -- when it last changed state ------------------------------------
for pat in (r"last flapped\s*:?\s*(.+?)\s*$", # Junos, EOS
r"Last link flapped\s*:?\s*(\S+)", # NX-OS
r"Time since last interface status change:\s*(\S+)"): # EOS
m = re.search(pat, t, re.I | re.M)
if m:
out["last_flap"] = m.group(1).strip()
break
m = re.search(r"is up,? (?:line protocol is up )?\(?(?:connected)?\)?.*?"
r"for\s+(\S+)", t, re.I)
if m:
out["up_since"] = m.group(1)
# Traffic recency is not uptime, but on a quiet link it is the only clue
# the platform gives.
m = re.search(r"Last input\s+(\S+?),\s*output\s+(\S+?),", t, re.I)
if m:
out["last_in"], out["last_out"] = m.group(1).rstrip(","), m.group(2).rstrip(",")
return out
def parse_traceroute(text):
"""[(hop_number, ip)]. Unresponsive hops are kept as (n, None), because a
gap in the path is information rather than something to drop."""
hops = []
if not text:
return hops
for line in text.split(chr(10)):
m = re.match(r"\s*(\d{1,2})\s+(.*)$", line)
if not m:
continue
ips = IP_RE.findall(m.group(2))
hops.append((int(m.group(1)), ips[0] if ips else None))
return hops
# Every vendor words an empty session table differently, and getting this
# wrong is not cosmetic: the walker concludes sessions exist, skips the policy
# simulation, and reports NOT DETERMINED on a firewall that would have
# answered. "No Active Sessions" defeated the first version of this.
EMPTY_SESSION = re.compile(
r"no\s+(?:active\s+|matching\s+|running\s+)?sessions?\b"
r"|\b0\s+sessions?\b"
r"|total\s+session\s*[:=]?\s*0\b"
r"|^\s*$", re.I | re.M)
DENY_WORDS = re.compile(r"\b(deny|drop|denied|dropped|block|blocked)\b", re.I)
ALLOW_WORDS = re.compile(r"\b(allow|permit|accept|allowed|permitted)\b", re.I)
def parse_policy(text):
"""(action, rule_name) from a policy lookup, on any platform.
Returns (None, None) when the output is not recognised. An unparsed
policy answer must never become a verdict: "I could not read it" and
"the firewall permits this" are opposite findings.
"""
if not text:
return None, None
rule = None
# PAN-OS opens with "rule-name; index: 14" { so the name runs to the
# first quote or semicolon; taking the whole quoted string would report
# the index as part of the rule name.
for pat in (r'^\s*"([^";]+)', r"Rule:\s*(\S+)", r"policy name\s*[:=]\s*(\S+)",
r"Policy:\s*(\S+)", r"rule\s+id\s+(\d+)", r"policy_id=(\d+)",
r"Matched rule:\s*(.+?)\s*$", r"policy\s+id\s*[:=]\s*(\d+)",
r"access-group\s+(\S+)", r"access-list\s+(\S+)"):
m = re.search(pat, text, re.I | re.M)
if m:
# Trailing punctuation belongs to the surrounding syntax, not the
# name: Junos prints "Policy: allow-sql," and reporting the comma
# sends someone looking for a rule that does not exist.
rule = m.group(1).strip().rstrip(",;:\"'")
break
action = None
if DENY_WORDS.search(text):
action = "DENY"
if ALLOW_WORDS.search(text) and action is None:
action = "ALLOW"
return action, rule
# ----------------------------------------------------------------- MTU model
# What each platform calls the MTU, and the three different numbers it means.
#
# On IOS and IOS-XE `mtu` and `ip mtu` are DIFFERENT COMMANDS. `mtu` is the
# L2 frame payload the interface will carry; `ip mtu` is the largest IP packet
# it will forward, it defaults to follow `mtu`, and it can be set LOWER and
# then stays there when `mtu` changes. Setting one and assuming the other is
# the single most common MTU mistake on Cisco gear.
#
# On NX-OS `system jumbomtu` is a global ceiling. An L2 port takes the system
# value; setting a per-interface mtu above it does nothing at all, silently.
#
# And JUNOS COUNTS THE L2 HEADER. `mtu 1514` on Junos carries the same IP
# packet as `mtu 1500` on IOS -- 1514 with no VLAN tag, 1518 with one. A link
# between a Cisco and a Juniper with the same number written on both ends is a
# mismatch of 14 or 18 bytes, and it is the classic cross-vendor MTU bug.
L2_INCLUSIVE = {"junos"} # platforms whose MTU figure includes the L2 header
ETH_HEADER = 14 # DMAC 6 + SMAC 6 + ethertype 2
VLAN_TAG = 4
# Encapsulation overhead, in bytes added to the inner IP packet. These are the
# numbers the arithmetic has to use; getting them from memory is how a tunnel
# ends up 4 bytes short and fails only on full-size packets.
OVERHEAD = [
("GRE", 24, "outer IP 20 + GRE 4"),
("GRE + key + sequence", 32, "outer IP 20 + GRE 4 + key 4 + seq 4"),
("IPsec ESP tunnel, AES-CBC + SHA", 73, "worst case with padding and the ESP trailer"),
("IPsec ESP tunnel, AES-GCM", 57, "no separate auth trailer"),
("GRE over IPsec transport", 61, "GRE 24 + ESP transport 37"),
("VXLAN", 50, "outer IP 20 + UDP 8 + VXLAN 8 + inner Ethernet 14"),
("VXLAN with an inner VLAN tag", 54, "the inner tag rides inside"),
("MPLS", 4, "per label; an L3VPN carries two, so 8"),
("PPPoE", 8, "PPPoE 6 + PPP 2"),
("Q-in-Q", 4, "per additional tag"),
]
# `ping size N` does NOT mean the same thing everywhere, and this is the trap
# that makes a measured result wrong by exactly 28 bytes. On IOS, IOS-XE,
# NX-OS and EOS, N is the WHOLE IP DATAGRAM. On Junos and on anything using
# the Linux ping, N is the ICMP PAYLOAD, so the datagram is N + 28 (IP 20 +
# ICMP 8). A tool that sends the same number to both and compares the answers
# reports a 28-byte difference that does not exist.
PING_IS_PAYLOAD = {"junos", "panos", "fortios"}
IP_ICMP_HEADER = 28
MTU_CMD = {
"ios": {
"ping_df": "ping {dst} size {size} df-bit repeat 2 timeout 1",
"ip_mtu": "show ip interface {intf} | include MTU",
"run_intf": "show running-config interface {intf}",
"sys_jumbo": None,
"mpls": "show mpls interfaces {intf} detail",
},
"nxos": {
"ping_df": "ping {dst} packet-size {size} df-bit count 2 timeout 1",
"ip_mtu": "show ip interface {intf} | include MTU",
"run_intf": "show running-config interface {intf}",
"sys_jumbo": "show running-config | include jumbomtu",
"mpls": None,
},
"iosxr": {
"ping_df": "ping {dst} size {size} donnotfrag count 2",
"ip_mtu": "show ipv4 interface {intf} | include MTU",
"run_intf": "show running-config interface {intf}",
"sys_jumbo": None,
"mpls": "show mpls interfaces {intf} detail",
},
"eos": {
"ping_df": "ping {dst} size {size} df-bit repeat 2 timeout 1",
"ip_mtu": "show ip interface {intf} | include MTU",
"run_intf": "show running-config interfaces {intf}",
"sys_jumbo": None,
"mpls": None,
},
"junos": {
"ping_df": "ping {dst} size {size} do-not-fragment count 2 rapid",
"ip_mtu": "show interfaces {intf} | match MTU",
"run_intf": "show configuration interfaces {intf}",
"sys_jumbo": None,
"mpls": None,
},
"panos": {
"ping_df": "ping do-not-fragment yes count 2 size {size} host {dst}",
"ip_mtu": "show interface {intf}",
"run_intf": None, "sys_jumbo": None, "mpls": None,
},
"asa": {
# The ASA's ping takes no size argument in any supported release, so
# there is nothing to measure with. Saying so is the honest answer;
# the configured MTU is still readable.
"ping_df": None,
"ip_mtu": "show interface {intf}",
"run_intf": "show running-config mtu",
"sys_jumbo": None, "mpls": None,
},
"fortios": {
# FortiOS sets the size and the DF bit as separate stateful commands
# (`execute ping-options`) before the ping. That is three commands
# that CHANGE STATE on the device, so the probe is not run here: a
# read-only tool does not leave settings behind on a firewall.
"ping_df": None,
"ip_mtu": "get system interface",
"run_intf": None, "sys_jumbo": None, "mpls": None,
},
}
MTU_CMD["iosxe"] = MTU_CMD["ios"]
def parse_mtu_fields(text, osn):
"""Every MTU figure a platform states, kept separate because they differ.
Returns a dict with whichever of l2_mtu, ip_mtu, mpls_mtu, tunnel,
adjust_mss and sys_jumbo could actually be read. A field that was not
found is ABSENT, never zero -- "no ip mtu configured" and "ip mtu 0" are
not the same claim, and the second one is not a thing.
"""
out = {}
if not text:
return out
# ORDER MATTERS, and getting it wrong destroys the comparison silently.
# "MTU is 1400 bytes" from `show ip interface` also matches a loose
# /MTU \d+/, so reading the L3 figure as though it were the L2 one
# overwrote it -- and then `ip mtu` sitting under `mtu` could never be
# found, because by then both numbers had become the same number.
l3 = re.search(r"MTU is (\d{3,5}) bytes", text, re.I)
if l3:
out["ip_mtu"] = int(l3.group(1))
else:
# The lookbehind is not decoration. Without it this pattern matches
# the "mtu 1400" inside `ip mtu 1400` and inside `mpls mtu 1500`, so
# the L2 figure was being overwritten by the L3 one from the running
# config -- and the two then agreed, which is exactly the state the
# check below exists to detect the absence of.
m = re.search(r"(?<!ip )(?<!mpls )\bMTU:?\s+(\d{3,5})\b", text, re.I)
if m:
out["l2_mtu"] = int(m.group(1))
m = re.search(r"^\s*ip mtu (\d{3,5})", text, re.I | re.M)
if m:
out["ip_mtu"] = int(m.group(1))
m = re.search(r"^\s*mpls mtu (\d{3,5})", text, re.I | re.M)
if m:
out["mpls_mtu"] = int(m.group(1))
m = re.search(r"^\s*ip tcp adjust-mss (\d{3,5})", text, re.I | re.M)
if m:
out["adjust_mss"] = int(m.group(1))
m = re.search(r"^\s*tunnel mode (\S+)(?:\s+(\S+))?", text, re.I | re.M)
if m:
out["tunnel"] = " ".join(x for x in m.groups() if x)
m = re.search(r"system jumbomtu (\d{3,5})", text, re.I)
if m:
out["sys_jumbo"] = int(m.group(1))
return out
def ip_payload_mtu(fields, osn):
"""The largest IP packet this interface forwards, in comparable units.
Everything is normalised to the IP datagram, because that is the only
figure two platforms can be compared on: Junos states a number that
INCLUDES the Ethernet header and IOS states one that does not, so the raw
configured values are not the same quantity and comparing them directly
invents a mismatch or hides one.
"""
if fields.get("ip_mtu"):
return fields["ip_mtu"], "ip mtu"
l2 = fields.get("l2_mtu")
if not l2:
return None, None
if osn in L2_INCLUSIVE:
return l2 - ETH_HEADER, "mtu, less the %d-byte L2 header Junos counts" % ETH_HEADER
return l2, "mtu"
def parse_ping_result(text):
"""(ok, saw_frag_needed). Three outcomes, and conflating two of them is
the whole point of the probe.
A probe can SUCCEED, it can fail WITH an ICMP fragmentation-needed -- which
is PMTUD working correctly, the path telling you the packet is too big --
or it can fail with SILENCE. Silence with the DF bit set is the signature
of a PMTUD BLACK HOLE: something dropped the packet and did not send the
message that tells the sender to send smaller. TCP then opens a session
and hangs on the first full-size segment, which is the complaint that
takes three days to find.
"""
if not text:
return None, False
frag = bool(re.search(r"frag(?:mentation)?[ -]needed|too big|would require fragmentation|M\b",
text, re.I))
if re.search(r"\b(\d{1,3}) percent \((\d+)/(\d+)\)", text):
m = re.search(r"Success rate is (\d{1,3}) percent", text, re.I)
return (int(m.group(1)) > 0 if m else None), frag
m = re.search(r"(\d+) packets transmitted, (\d+) (?:packets )?received", text, re.I)
if m:
return int(m.group(2)) > 0, frag
if re.search(r"^!{1,}$", text.strip(), re.M):
return True, frag
if re.search(r"^[.U]{2,}$", text.strip(), re.M):
return False, frag
return None, frag
# ----------------------------------------------------------------- the walk
class MtuHop(object):
def __init__(self, order, name, ip, osn):
self.order, self.name, self.ip, self.osn = order, name, ip, osn
self.egress = None
self.fields = {} # everything the platform stated
self.mtu = None # normalised to the IP datagram
self.mtu_source = None
self.members = [] # (port, mtu) of a port-channel
self.notes = []
class MtuWalk(object):
"""Walk the path and establish its MTU two independent ways.
CONFIGURED is what the devices you administer believe. MEASURED is what
the path actually passes. They answer different questions and the gap
between them is the finding: a measured MTU below the configured minimum
means something in the path is not telling you about itself -- a tunnel,
a carrier, a device you do not own.
"""
def __init__(self, args, transport):
self.a, self.t = args, transport
self.inv = load_inventory(args.inventory) if args.inventory else {}
self.hops = []
self.findings = []
self.unchecked = []
self.measured = None
self.measured_how = None
self.blackhole = False
# -- plumbing -----------------------------------------------------------
def dev(self, host, osn=None):
d = self.inv.get(host)
if d:
return d["device_name"], d["os"], d.get("vrf")
return host, osn, None
def connect_addr(self, addr):
d = self.inv.get(addr)
return d["mgmt"] if d else addr
def ask(self, host, osn, key, **kw):
table = MTU_CMD.get(osn) or {}
cmd = table.get(key, _SENTINEL)
if cmd is _SENTINEL:
cmd = (CMD.get(osn) or {}).get(key)
if not cmd:
return None, "not available on %s" % osn
try:
return self.t.run(self.connect_addr(host), osn, cmd.format(**kw)), None
except Exception as exc: # noqa: BLE001 - reported, never swallowed
return None, str(exc)
def finding(self, sev, where, what, why):
self.findings.append((sev, where, what, why))
# -- measured ------------------------------------------------------------
def probe(self, host, osn, dst, lo=576, hi=9216):
"""Binary search for the largest IP datagram that crosses, DF set.
This is the half a configuration read cannot do. It measures the whole
path, including the hops nobody here administers and the tunnel in the
middle that re-encapsulates and never says so.
`size` is normalised first, because it does not mean the same thing on
every platform: on IOS, NX-OS and EOS it is the whole IP datagram, and
on Junos and anything using the Linux ping it is the ICMP payload. Send
the same number to both and the answers differ by exactly 28 bytes --
a discrepancy the tool would then report as real.
"""
def send(ip_size):
n = ip_size - IP_ICMP_HEADER if osn in PING_IS_PAYLOAD else ip_size
out, err = self.ask(host, osn, "ping_df", dst=dst, size=n)
if out is None:
return None, False, err
ok, frag = parse_ping_result(out)
return ok, frag, None
ok, frag, err = send(lo)
if ok is None:
self.unchecked.append(
"the DF-bit probe could not be run from %s: %s. Only the CONFIGURED "
"MTU is reported, which is what the devices believe rather than what "
"the path passes." % (host, err or "the output was not recognised"))
return
if not ok:
self.unchecked.append(
"even %d bytes did not cross from %s to %s, so there is nothing to "
"measure -- fix reachability first." % (lo, host, dst))
return
best, saw_frag = lo, False
while lo <= hi:
mid = (lo + hi) // 2
ok, frag, _ = send(mid)
saw_frag = saw_frag or frag
if ok:
best, lo = mid, mid + 1
else:
hi = mid - 1
self.measured = best
self.measured_how = "DF-bit binary search from %s" % host
# The black hole. A packet too large SHOULD come back as ICMP
# fragmentation-needed; that is PMTUD working. Silence instead means
# something dropped it and told nobody, so the sender keeps sending
# full-size segments into a hole. The session opens and then hangs,
# which is why this is the expensive one to find by hand.
if best < hi + 1 and not saw_frag:
self.blackhole = True
self.finding("HIGH", host, "PMTUD black hole toward %s" % dst,
"%d bytes crossed and larger did not, and NO ICMP "
"fragmentation-needed came back. Path MTU discovery cannot "
"work: TCP will negotiate an MSS it cannot deliver, the "
"session will open, and it will hang on the first full-size "
"segment. Someone is filtering ICMP type 3 code 4" % best)
# -- configured ----------------------------------------------------------
def inspect(self, h, dst):
if not h.osn or not h.ip:
return
out, _ = self.ask(h.ip, h.osn, "route", dst=dst, vrf="default", src=dst)
eg, _nh = parse_route_egress(out)
h.egress = canon_intf(eg) if eg else None
if not h.egress:
h.notes.append("the route toward %s was not readable, so this hop's "
"egress MTU was not read" % dst)
return
# Junos names the LOGICAL UNIT in a route -- ge-0/0/0.0 -- while the
# MTU that governs the frame lives on the PHYSICAL interface. They are
# also two different numbers: a unit can carry its own `family inet
# mtu` below the physical one, and nothing warns you. Ask for both,
# physical first so a unit-level setting wins where there is one.
targets = [h.egress]
if "." in h.egress:
targets.insert(0, h.egress.split(".")[0])
for intf in targets:
info, _ = self.ask(h.ip, h.osn, "intf", intf=intf)
h.fields.update(parse_mtu_fields(info, h.osn))
for key in ("ip_mtu", "run_intf", "sys_jumbo", "mpls"):
more, _ = self.ask(h.ip, h.osn, key, intf=intf)
h.fields.update(parse_mtu_fields(more, h.osn))
h.mtu, h.mtu_source = ip_payload_mtu(h.fields, h.osn)
if h.mtu is None:
self.unchecked.append("%s: no MTU figure could be read for %s"
% (h.name, h.egress))
return
# IOS and IOS-XE: ip mtu set below mtu. It is legal, it is invisible
# in `show interfaces`, and it stays put when someone later raises
# `mtu` and believes the job is done.
l2, ip = h.fields.get("l2_mtu"), h.fields.get("ip_mtu")
# Not on a tunnel. A GRE tunnel legitimately carries `ip mtu 1476`
# under an internal MTU of 17916 -- that is the encapsulation being
# accounted for correctly, and calling it a finding would train the
# reader to skip the one case where it is real.
if l2 and ip and ip < l2 and h.osn not in L2_INCLUSIVE \
and not h.fields.get("tunnel"):
self.finding("MEDIUM", h.name,
"%s has ip mtu %d under mtu %d" % (h.egress, ip, l2),
"`ip mtu` is a separate command that does not follow `mtu` "
"once it has been set. Raising `mtu` later does not raise "
"this, and `show interfaces` keeps printing the larger number")
# NX-OS: a per-interface MTU above the global ceiling does nothing,
# and does not say so.
sj = h.fields.get("sys_jumbo")
if sj and l2 and l2 > sj:
self.finding("HIGH", h.name,
"%s is set to %d but system jumbomtu is %d" % (h.egress, l2, sj),
"on NX-OS the system value is a ceiling. The interface is "
"carrying %d, not %d, and nothing in the interface output "
"says so" % (sj, l2))
# A tunnel with no MSS clamp. PMTUD is supposed to handle this and in
# practice it is filtered somewhere often enough that clamping is the
# standard mitigation -- its absence on a tunnel is worth saying.
if h.fields.get("tunnel") and not h.fields.get("adjust_mss"):
self.finding("MEDIUM", h.name,
"%s is a %s tunnel with no ip tcp adjust-mss"
% (h.egress, h.fields["tunnel"]),
"the encapsulation costs bytes the inner host never hears "
"about, and it relies on PMTUD -- which fails silently "
"wherever ICMP is filtered. Clamping the MSS removes the "
"dependency for TCP")
self.members_of(h)
def members_of(self, h):
"""A port-channel whose members disagree about MTU.
LACP will refuse to bundle a member whose MTU differs on most
platforms, so the usual outcome is a silently smaller bundle -- the
aggregate is up, the capacity is half, and nothing is logged as an
error.
"""
if not h.egress or not h.egress.lower().startswith(("port-channel", "ae", "bundle-ether")):
return
out, _ = self.ask(h.ip, h.osn, "po_members", intf=h.egress)
if not out:
return
for port, _state in parse_po_members(out):
info, _ = self.ask(h.ip, h.osn, "intf", intf=port)
f = parse_mtu_fields(info, h.osn)
mm, _src = ip_payload_mtu(f, h.osn)
h.members.append((port, mm))
vals = set(m for _p, m in h.members if m)
if len(vals) > 1:
self.finding("HIGH", h.name,
"%s members disagree about MTU (%s)"
% (h.egress, ", ".join(str(v) for v in sorted(vals))),
"LACP will not bundle a member whose MTU differs, so the "
"bundle comes up smaller than it looks and nothing logs an "
"error")
# -- across the path -----------------------------------------------------
def compare(self):
"""The checks that need two devices, which is where MTU actually breaks."""
known = [h for h in self.hops if h.mtu]
if not known:
return
lo = min(h.mtu for h in known)
self.configured = lo
self.narrowest = [h for h in known if h.mtu == lo]
prev = None
for h in self.hops:
if prev is not None and prev.mtu and h.mtu and h.mtu < prev.mtu:
# Narrowing is NORMAL where something explains it. A tunnel
# takes its bytes by design and so does a label stack; those
# are the profile of the path, not faults, and the minimum is
# already stated at the top of the report. A step with nothing
# behind it is the one worth waking someone for -- reporting
# the expected ones buried it.
explained = h.fields.get("tunnel") or h.fields.get("mpls_mtu")
cross = (prev.osn in L2_INCLUSIVE) != (h.osn in L2_INCLUSIVE)
if explained and not cross:
prev = h
continue
extra = ""
if cross:
# The classic. Both ends have the same number configured
# and they are not the same quantity.
extra = (" These are a %s and a %s, and they do not count the MTU "
"the same way: Junos includes the %d-byte Ethernet header "
"and the other does not. Check the CONFIGURED numbers, not "
"just these -- the same figure on both ends is already a "
"mismatch." % (prev.osn, h.osn, ETH_HEADER))
self.finding("HIGH", prev.name,
"MTU steps from %d to %d between %s and %s"
% (prev.mtu, h.mtu, prev.name, h.name),
"the smaller one is what the flow gets. Small packets, "
"including ping, cross anyway, which is why this is "
"normally found by an application and not by a test.%s" % extra)
if h.mtu:
prev = h
if self.measured and lo and self.measured < lo:
self.finding("HIGH", "path",
"measured %d, configured minimum %d" % (self.measured, lo),
"the path passes LESS than the devices here believe. Something "
"between them is not accounted for: a tunnel that "
"re-encapsulates, a carrier segment, or a hop nobody here "
"administers. The %d-byte gap is the size of the "
"encapsulation -- compare it against the overhead table"
% (lo - self.measured))
elif self.measured and lo and self.measured > lo:
self.finding("INFO", "path",
"measured %d, above the configured minimum %d" % (self.measured, lo),
"the narrowest interface read is not on the measured path. "
"Either the probe hashed to a different route, or an "
"interface read here does not carry this flow")
# ----------------------------------------------------------------- report
SEV = {"HIGH": 0, "MEDIUM": 1, "INFO": 2}
def report(w, args):
line = "=" * 78
print(line)
print("path-mtu-analyzer %s -> %s" % (args.start, args.dst))
print(line)
print("")
print("PATH MTU")
print("-" * 78)
cfg = getattr(w, "configured", None)
print(" %-26s %s" % ("measured end to end",
("%d bytes (%s)" % (w.measured, w.measured_how))
if w.measured else "- not measured"))
print(" %-26s %s" % ("configured minimum",
("%d bytes at %s" % (cfg, ", ".join(
"%s %s" % (h.name, h.egress) for h in w.narrowest)))
if cfg else "- not read"))
if w.measured and cfg and w.measured != cfg:
print(" %-26s %+d bytes" % ("gap", w.measured - cfg))
print(" %-26s %s" % ("PMTUD",
"BLACK HOLE -- no fragmentation-needed came back"
if w.blackhole else
("working" if w.measured else "not tested")))
if w.measured:
# The number anyone actually has to configure next.
print(" %-26s %d bytes (path MTU less IP 20 and TCP 20)"
% ("largest safe TCP MSS", w.measured - 40))
print("")
print("PER HOP")
print("-" * 78)
for h in w.hops:
tag = " "
conf = " [%s]" % h.osn if h.osn else ""
print(" %-2s %-24s %-14s%s" % (tag, h.name[:24], h.ip or "-", conf))
if h.egress:
bits = []
if h.mtu:
bits.append("%d" % h.mtu)
f = h.fields
# Every figure the platform states, kept separate. They are not
# the same number and a single "MTU" column is what lets an
# `ip mtu` sitting underneath go unnoticed for years.
for key, lbl in (("l2_mtu", "mtu"), ("ip_mtu", "ip mtu"),
("mpls_mtu", "mpls mtu"), ("sys_jumbo", "system jumbo"),
("adjust_mss", "adjust-mss")):
if f.get(key):
bits.append("%s %d" % (lbl, f[key]))
if f.get("tunnel"):
bits.append("tunnel %s" % f["tunnel"])
print(" %-9s %-24s %s"
% ("egress", h.egress, " ".join(bits) if bits else "-"))
if h.mtu_source and h.osn in L2_INCLUSIVE:
print(" %-9s %s" % ("", "read as %s" % h.mtu_source))
for port, mm in h.members:
print(" %-9s %-24s %s"
% (" member", port, ("%d" % mm) if mm else "-"))
for note in h.notes:
print(" %-9s %s" % ("note", note))
print("")
if w.findings:
for sev in ("HIGH", "MEDIUM", "INFO"):
group = [f for f in w.findings if f[0] == sev]
if not group:
continue
print("%s (%d)" % (sev, len(group)))
print("-" * 78)
for _s, where, what, why in group:
print(" [%s] %s" % (where, what))
if why:
for ln in textwrap.wrap(why, 72):
print(" %s" % ln)
print("")
else:
print("No MTU problem was found on this path.")
print("")
if w.unchecked:
print("NOT CHECKED")
print("-" * 78)
for u in w.unchecked:
for i, ln in enumerate(textwrap.wrap(u, 74)):
print(" %s%s" % ("- " if i == 0 else " ", ln))
print("")
print("ENCAPSULATION OVERHEAD")
print("-" * 78)
print(" If the measured MTU is below the configured minimum, the difference is")
print(" almost always one of these riding somewhere in the path:")
print("")
for name, cost, why in OVERHEAD:
print(" %-34s %3d %s" % (name, cost, why))
print("")
print("WHAT THIS DOES NOT PROVE")
print("-" * 78)
for cav in (
"The probe is ICMP. A path that passes a 9000-byte ICMP packet can still "
"drop a 9000-byte TCP segment, because a firewall or a policer may treat "
"them differently.",
"The measurement is of the path the probe took. Under ECMP your flow may "
"hash to a different one, and MTU is not required to be equal across an "
"ECMP set.",
"A configured MTU is what the device was told, not always what the "
"hardware does. Some platforms round to an internal buffer size.",
"An MTU that is correct today is correct until the next tunnel. Anything "
"that re-encapsulates -- a new VPN, a VXLAN rollout, an MPLS label -- "
"takes its bytes out of the same budget.",
):
for i, ln in enumerate(textwrap.wrap(cav, 74)):
print(" %s%s" % ("- " if i == 0 else " ", ln))
print("")
# ----------------------------------------------------------------- entry
def main(argv=None):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(
description="Measure and audit the MTU of a real path, hop by hop.")
ap.add_argument("--start", help="device to start from: the source's gateway")
ap.add_argument("--dst", required=True, help="destination address")
ap.add_argument("--start-os", help="platform of --start, if it is not in the inventory")
ap.add_argument("--inventory", help="optional devices.yaml")
ap.add_argument("--max-mtu", type=int, default=9216,
help="upper bound for the probe (default 9216)")
ap.add_argument("--no-probe", action="store_true",
help="read the configured MTU only; send nothing")
ap.add_argument("--csv", help="write the per-hop table here")
ap.add_argument("--offline-dir", help="replay saved command output instead of connecting")
args = ap.parse_args(argv)
args.start = args.start or ""
if not args.offline_dir and not args.start:
ap.error("--start is required unless you are replaying with --offline-dir")
t = Transport(args)
w = MtuWalk(args, t)
name, osn, _vrf = w.dev(args.start, args.start_os)
if osn is None:
raise SystemExit(
"could not work out what %s is. Pass --start-os, or put it in the "
"inventory. Known platforms: %s"
% (args.start, ", ".join(sorted(NETMIKO_DRIVER))))
out, err = w.ask(args.start, osn, "traceroute", dst=args.dst)
hops = parse_traceroute(out) if out else []
if not hops:
w.unchecked.append(
"the traceroute from %s was not readable (%s), so only that device was "
"examined." % (name, err or "output not recognised"))
hops = []
w.hops.append(MtuHop(0, name, args.start, osn))
for n, ip in hops:
if ip is None:
h = MtuHop(n, "(no reply)", None, None)
h.notes.append("hop did not answer; common where ICMP is rate-limited")
w.hops.append(h)
continue
inv = w.inv.get(ip)
w.hops.append(MtuHop(n, inv["device_name"] if inv else ip, ip,
inv["os"] if inv else None))
for h in w.hops:
if h.osn:
w.inspect(h, args.dst)
elif h.ip:
w.unchecked.append(
"%s was not identified -- not in the inventory and CDP/LLDP did not "
"reach it -- so its MTU is unknown. A hop you cannot read is exactly "
"where an unexpected encapsulation lives." % h.ip)
if not args.no_probe:
w.probe(args.start, osn, args.dst, hi=args.max_mtu)
else:
w.unchecked.append(
"--no-probe was given, so nothing was measured. Only what the devices "
"believe is reported, which is the half that misses a tunnel.")
w.compare()
report(w, args)
if args.csv:
with io.open(args.csv, "w", encoding="utf-8", newline="") as fh:
cw = csv.writer(fh)
cw.writerow(["order", "device", "ip", "os", "egress", "ip_payload_mtu",
"l2_mtu", "ip_mtu", "mpls_mtu", "system_jumbo",
"adjust_mss", "tunnel", "notes"])
for h in w.hops:
f = h.fields
cw.writerow([h.order, h.name, h.ip or "", h.osn or "", h.egress or "",
h.mtu or "", f.get("l2_mtu", ""), f.get("ip_mtu", ""),
f.get("mpls_mtu", ""), f.get("sys_jumbo", ""),
f.get("adjust_mss", ""), f.get("tunnel", ""),
" | ".join(h.notes)])
# Exit codes, so this can gate a change window or a CI job rather than
# only being read by a human.
if any(f[0] == "HIGH" for f in w.findings):
return 2
if any(f[0] == "MEDIUM" for f in w.findings):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
--starttextDevice to start from, normally the source host's gateway.
--dsttextrequiredDestination address.
--start-ostextPlatform of --start when it is not in the inventory.
--inventorytextOptional devices.yaml, for SSH ports, management addresses and VRFs.
--max-mtutextUpper bound for the probe. Default 9216.
--no-probetextRead the configured MTU only and send nothing.
--csvtextWrite the per-hop table to a file.
What This Solves
The application team says the database connection opens and then freezes. The network team pings the server, gets a reply, and says the network is fine. Both are telling the truth. A ping is 100 bytes. The query result is 9000. Somewhere between them is a link that will not carry the second one, and nothing either team ran would ever find it -- because every diagnostic anyone reaches for first uses small packets. This tool answers the MTU question two independent ways and compares them: MEASURED the largest IP packet that actually crosses, found by binary search with the DF bit set. This includes the hops nobody here administers and the tunnel in the middle that re-encapsulates and never mentions it. CONFIGURED what every device on the path believes, read hop by hop. THE GAP BETWEEN THE TWO IS THE FINDING. If the path measures 1400 and the narrowest interface you can read says 1476, then 76 bytes are being taken by something that is not in your configuration -- and 76 bytes is the size of an encapsulation. The report prints the overhead table so you can name it. It also answers the question a configuration read can never answer: whether path MTU discovery works at all.
Is It Safe to Run
Read-only on every device. It sends `show` commands and, unless you pass --no-probe, ICMP echo requests with the DF bit set from the device you start from. It configures nothing, changes nothing and leaves nothing behind. Two deliberate omissions, both for that reason: FortiOS sets the packet size and the DF bit with `execute ping-options`, which are STATEFUL commands that persist on the device. A read-only tool does not leave settings behind on a firewall, so the probe is not run there. The configured MTU is still read. ASA has no size argument on its ping in any supported release, so there is nothing to measure with. Said plainly rather than quietly skipped. Commands it sends, per platform: IOS / IOS-XE show ip route, show interfaces, show ip interface | include MTU, show running-config interface, show mpls interfaces, traceroute, ping ... df-bit NX-OS as above, plus show running-config | include jumbomtu IOS-XR show route, show ipv4 interface, ping ... donnotfrag EOS show ip route, show interfaces, ping ... df-bit Junos show route, show interfaces, show configuration interfaces, ping ... do-not-fragment PAN-OS show interface, ping do-not-fragment yes `traceroute` and `ping` are NOT show commands. A profile that authorises only `show *` will read every MTU on the path and measure nothing -- which is the half that catches the tunnel. See The Account below.
Before You Start
Python 3.8 or newer pip install netmiko pyyaml You need SSH to the device you start from -- normally the source host's default gateway -- and, to read the rest of the path, to the hops the traceroute returns. Read-only is enough for everything except the probe.
Your First Run
One device. No inventory. Three steps. 1. Put the credentials in the environment. Not on the command line: an argument is visible in `ps auxww` while the process runs, and it lands in your shell history. export NET_USER=your-username read -s NET_PASS; export NET_PASS 2. Find the source host's gateway. On the host: `ip route` on Linux, `route print` on Windows, `netstat -rn` on a Mac. 3. Run it. python3 path_mtu_analyzer.py --start 10.10.10.3 --dst 10.20.20.80 That is all. The inventory is optional and only exists for the two things that cannot be discovered: a non-standard SSH port, and a management address when the transit address refuses SSH. If you want to read the configuration without sending a single packet, add --no-probe. You will get the configured half only -- which is the half that misses a tunnel, and the report says so.
What the Output Means
============================================================================== path-mtu-analyzer 10.10.10.3 -> 10.20.20.80 ============================================================================== PATH MTU ------------------------------------------------------------------------------ measured end to end 1400 bytes (DF-bit binary search from 10.10.10.3) configured minimum 1400 bytes at dist-01 Vlan220 PMTUD BLACK HOLE -- no fragmentation-needed came back largest safe TCP MSS 1360 bytes (path MTU less IP 20 and TCP 20) PER HOP ------------------------------------------------------------------------------ gw-active 10.10.10.3 [ios] egress TenGigabitEthernet1/1/1 9216 mtu 9216 ip mtu 9216 core-01 10.0.0.1 [ios] egress Tunnel10 1476 mtu 17916 ip mtu 1476 tunnel gre ip pe-01 10.0.0.5 [junos] egress ge-0/0/0.0 1500 mtu 1514 read as mtu, less the 14-byte L2 header Junos counts dist-01 10.20.20.1 [ios] egress Vlan220 1400 mtu 1500 ip mtu 1400 HIGH (2) ------------------------------------------------------------------------------ [10.10.10.3] PMTUD black hole toward 10.20.20.80 1400 bytes crossed and larger did not, and NO ICMP fragmentation-needed came back. Path MTU discovery cannot work: TCP will negotiate an MSS it cannot deliver, the session will open, and it will hang on the first full-size segment. Someone is filtering ICMP type 3 code 4 [pe-01] MTU steps from 1500 to 1400 between pe-01 and dist-01 the smaller one is what the flow gets. Small packets, including ping, cross anyway, which is why this is normally found by an application and not by a test. These are a junos and a ios, and they do not count the MTU the same way: Junos includes the 14-byte Ethernet header and the other does not. Check the CONFIGURED numbers, not just these -- the same figure on both ends is already a mismatch. MEDIUM (2) ------------------------------------------------------------------------------ [core-01] Tunnel10 is a gre ip tunnel with no ip tcp adjust-mss the encapsulation costs bytes the inner host never hears about, and it relies on PMTUD -- which fails silently wherever ICMP is filtered. Clamping the MSS removes the dependency for TCP [dist-01] Vlan220 has ip mtu 1400 under mtu 1500 `ip mtu` is a separate command that does not follow `mtu` once it has been set. Raising `mtu` later does not raise this, and `show interfaces` keeps printing the larger number ENCAPSULATION OVERHEAD ------------------------------------------------------------------------------ If the measured MTU is below the configured minimum, the difference is almost always one of these riding somewhere in the path: GRE 24 outer IP 20 + GRE 4 GRE + key + sequence 32 outer IP 20 + GRE 4 + key 4 + seq 4 IPsec ESP tunnel, AES-CBC + SHA 73 worst case with padding and the ESP trailer IPsec ESP tunnel, AES-GCM 57 no separate auth trailer GRE over IPsec transport 61 GRE 24 + ESP transport 37 VXLAN 50 outer IP 20 + UDP 8 + VXLAN 8 + inner Ethernet 14 VXLAN with an inner VLAN tag 54 the inner tag rides inside MPLS 4 per label; an L3VPN carries two, so 8 PPPoE 8 PPPoE 6 + PPP 2 Q-in-Q 4 per additional tag WHAT THIS DOES NOT PROVE ------------------------------------------------------------------------------ - The probe is ICMP. A path that passes a 9000-byte ICMP packet can still drop a 9000-byte TCP segment, because a firewall or a policer may treat them differently. - The measurement is of the path the probe took. Under ECMP your flow may hash to a different one, and MTU is not required to be equal across an ECMP set. - A configured MTU is what the device was told, not always what the hardware does. Some platforms round to an internal buffer size. - An MTU that is correct today is correct until the next tunnel. Anything that re-encapsulates -- a new VPN, a VXLAN rollout, an MPLS label -- takes its bytes out of the same budget. The four lines at the top measured end to end what the path PASSES. The honest number. configured minimum what the devices BELIEVE, and where the narrowest one is. gap printed only when they disagree, which is the interesting case. PMTUD working, BLACK HOLE, or not tested. largest safe TCP MSS the measured MTU less 40. This is the number you put in `ip tcp adjust-mss` if you are clamping. The black hole is the expensive one A packet that is too large should come back as ICMP type 3 code 4, fragmentation-needed. That message is how the sender learns to send smaller; it IS path MTU discovery. When it does not come back -- because somebody filtered ICMP on a firewall, which happens constantly -- the sender never learns. TCP negotiates an MSS it cannot deliver, the three-way handshake succeeds because those packets are tiny, and the session hangs on the first full-size segment. That is why the symptom is "it connects and then freezes" rather than "it does not connect", and it is why it survives so many rounds of troubleshooting: everything anyone tests works. The tool distinguishes three outcomes, which is the entire point of probing: the packet CROSSED, it was REFUSED WITH a fragmentation-needed (PMTUD is working, the path is just narrow), or it VANISHED IN SILENCE. The second and third look identical in a ping output and mean opposite things. The traps it checks for These are the ones that cost real time, and most of them are invisible in the output everyone reads first. `ip mtu` UNDER `mtu`, ON IOS AND IOS-XE. They are two different commands. `mtu` is the L2 frame payload; `ip mtu` is the largest IP packet forwarded. `ip mtu` follows `mtu` by default -- but once set explicitly it stops following, and it STAYS when someone later raises `mtu` and believes the job is done. `show interfaces` keeps printing the larger number. JUNOS COUNTS THE L2 HEADER AND CISCO DOES NOT. `mtu 1514` on Junos carries the same IP packet as `mtu 1500` on IOS -- 1514 without a VLAN tag, 1518 with one. A link between a Cisco and a Juniper with THE SAME NUMBER written on both ends is already a 14- or 18-byte mismatch. Everything in this report is normalised to the IP datagram so the two can be compared at all, and a cross-vendor step is called out explicitly. NX-OS `system jumbomtu` IS A CEILING. An L2 port takes the system value. Setting a per-interface MTU above it does nothing whatsoever, and nothing in the interface output says so. A TUNNEL WITH NO `ip tcp adjust-mss`. The encapsulation costs bytes the inner host never hears about, and the design relies on PMTUD -- which fails silently wherever ICMP is filtered. Clamping removes the dependency for TCP. Its absence on a tunnel is worth knowing about before the outage, not during it. PORT-CHANNEL MEMBERS THAT DISAGREE. LACP will not bundle a member whose MTU differs from the others, so the bundle comes up SMALLER THAN IT LOOKS -- half the capacity, no error logged, and the aggregate reports up. THE 28-BYTE LIE IN `ping size N`. On IOS, IOS-XE, NX-OS and EOS, N is the whole IP datagram. On Junos, and on anything using the Linux ping, N is the ICMP PAYLOAD and the datagram is N + 28. A tool that sends the same number to both platforms and compares the answers reports a 28-byte discrepancy that does not exist. This one normalises before it sends. AND the one everyone meets without recognising it OSPF stuck in EXSTART or EXCHANGE is an MTU mismatch. The two routers agree on everything up to the point where they exchange database description packets, those are large, one end cannot receive them, and the adjacency never completes. If you are reading this because an adjacency will not come up, run the tool between the two routers: the answer is in the per-hop table.
Exit Codes for Cron and CI
0 nothing found 1 a MEDIUM finding 2 a HIGH finding -- a black hole, an unexplained step down, a bundle whose members disagree Suitable for a cron job or a CI gate: run it against the paths that matter after a change window and let a non-zero exit stop the pipeline.
The Account
Two ways, and the second is better. YOUR OWN CREDENTIALS work and need no preparation. Everything the tool does is attributed to you in the device logs, which is honest but noisy. A DEDICATED SERVICE ACCOUNT in TACACS+ or RADIUS, read-only, is the right answer for three reasons: its command authorisation can be scoped to exactly what the tool needs and nothing else; its activity is separable from a human's in an audit; and it does not stop working the day you change your password in the middle of an incident. WHAT TO AUTHORISE. `permit show *` is not enough and the gap is in the measurement, not the reading: show ip route / show route read show interfaces / show interface read show ip interface read show running-config interface read show configuration interfaces read, Junos traceroute NOT a show command ping NOT a show command A profile that authorises only `show *` produces a complete configured-MTU table and the words "not measured" where the answer was. Authorise the two exec commands, or accept that the tool only ever tells you what the devices believe. The full per-vendor authorisation table Every platform has a different model -- privilege levels, NX-OS RBAC rules evaluated highest-number-first, IOS-XR task IDs where "read-only" cannot ping, Junos login classes, FortiOS access profiles where `diagnose` is not a read-only family. The whole design, with the traps and the lockout failure modes, is in the PermitANY skill `network-aaa-command-authorization`: https://permitany.com/skills/network-aaa-command-authorization
The Inventory File
Optional. Create it only when you need one of the things that cannot be discovered: a non-standard SSH port, a management address when the transit address refuses SSH, or a VRF when a destination is reachable in more than one table. defaults: os: ios port: 22 devices: - host: 10.0.0.1 # as the traceroute returns it device_name: core-01 addresses: [10.99.0.1] # every address it may appear as mgmt: 10.99.0.1 # where to CONNECT os: ios vrf: PROD Valid `os` values, and the netmiko driver each one maps to: ios cisco_ios iosxe cisco_ios iosxr cisco_xr nxos cisco_nxos junos juniper_junos eos arista_eos panos paloalto_panos fortios fortinet asa cisco_asa Credentials -- environment only NET_USER username required NET_PASS password required NET_ENABLE enable secret optional; falls back to NET_PASS On Linux and macOS: export NET_USER=svc-netpath read -s NET_PASS; export NET_PASS On PowerShell: $env:NET_USER = "svc-netpath" $env:NET_PASS = Read-Host -AsSecureString | ConvertFrom-SecureString -AsPlainText
Every Flag
--start HOST device to start from; normally the source's gateway --dst ADDRESS the destination (required) --start-os OS platform of --start, if it is not in the inventory --inventory FILE optional devices.yaml --max-mtu N upper bound for the probe; default 9216 --no-probe read the configured MTU only; send nothing --csv FILE write the per-hop table, one row per hop --offline-dir DIR replay saved output instead of connecting
When Something Fails
"even 576 bytes did not cross" This is not an MTU problem. The destination is unreachable, or the path drops ICMP entirely. Fix reachability first; there is nothing to measure until something crosses. "the DF-bit probe could not be run" Either the platform has no usable probe (ASA, FortiOS -- see IS IT SAFE TO RUN), or `ping` is not authorised for your account. Only the configured half is reported, and the report says so rather than presenting half an answer as a whole one. "no MTU figure could be read for <interface>" The interface output was not in a form this tool recognises. The raw text is not discarded -- rerun with --csv and the row will show which fields came back empty. Report the platform and release. "<address> was not identified" It is not in the inventory and CDP/LLDP did not reach it. A hop you cannot read is EXACTLY where an unexpected encapsulation lives, which is why this is called out rather than skipped quietly. measured is HIGHER than the configured minimum The narrowest interface you read is not on the path the probe took. Under ECMP that is normal -- and MTU is not required to be equal across an ECMP set, which is its own problem.