Troubleshooting · python · 66 copies
Return Path Verifier
Every routing table you look at is the path OUT. Traffic has to come back, and nothing says it comes back the same way -- when it does not, a stateful firewall sees a SYN and never a SYN-ACK and drops a flow that routing says should work. This walks both directions, marks where they part company, and then reads two clocks at that point: how old the route is and how old the adjacency under it is. A route much younger than its adjacency means nothing failed -- somebody changed a policy.
- Cisco ASA
- Arista EOS
- FortiOS
- 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 -*-
"""return-path-verifier -- walk a path BOTH ways and compare them.
SAFE TO RUN: read-only. It sends `show` commands and `traceroute`. It
configures nothing.
EXIT CODES: 0 symmetric, 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)
FIREWALL_OS = {"panos", "fortios", "asa", "junos"}
def parse_model(text, osn=None):
"""(model, role). Either may be None, and None prints as a dash.
Inventing a model is worse than leaving it blank."""
role = ROLE_BY_OS.get(osn)
if not text:
return None, role
model = None
for pat in MODEL_PATTERNS:
m = re.search(pat, text, re.I | re.M)
if m:
model = m.group(1).strip().rstrip(",;")
break
if role is None and model:
for r, pat in ROLE_HINTS:
if re.search(pat, model, re.I):
role = r
break
if role is None:
m = re.search(r"Capabilities:\s*([^\n]*)", text, re.I)
if m:
caps = m.group(1).lower()
role = "router" if "router" in caps else (
"switch" if ("switch" in caps or "bridge" in caps) else None)
return model, role
def parse_hostname(text):
"""The device's own name. It is in its configuration and its prompt, so
an inventory is not needed to print something readable."""
if not text:
return None
for pat in (r"^hostname\s+(\S+)", r"^\s*hostname:\s*(\S+)",
r"^(\S+)\s+uptime is", r"^Hostname:\s*(\S+)",
r"Host name:\s*(\S+)", r"^Model:\s*\S+\s*$"):
m = re.search(pat, text, re.I | re.M)
if m and m.lastindex:
return m.group(1).strip().rstrip(",;")
return None
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
# --------------------------------------------------------------- routing model
# The commands that answer WHY a path is the way it is. A divergence point
# without a cause is half an answer: it says where to look and leaves the
# reader to work out what happened, which is the part that takes the time.
RP_CMD = {
"ios": {
"bgp_prefix": "show ip bgp {dst}",
"bgp_sum": "show ip bgp summary",
"ospf_nbr": "show ip ospf neighbor detail",
"ospf_db": "show ip ospf database router | include Age",
"eigrp_topo": "show ip eigrp topology {dst}",
"eigrp_nbr": "show ip eigrp neighbors",
"proto_logs": "show logging | include ADJCHANGE|ADJCHG|NBRCHANGE|DUAL-5",
"nat": "show ip nat translations | include {dst}",
},
"nxos": {
"bgp_prefix": "show ip bgp {dst}",
"bgp_sum": "show ip bgp summary",
"ospf_nbr": "show ip ospf neighbors detail",
"ospf_db": "show ip ospf database router | include Age",
"eigrp_topo": "show ip eigrp topology {dst}",
"eigrp_nbr": "show ip eigrp neighbors",
"proto_logs": "show logging logfile | include ADJCHANGE|ADJCHG|NBRCHANGE",
"nat": None,
},
"iosxr": {
"bgp_prefix": "show bgp {dst}",
"bgp_sum": "show bgp summary",
"ospf_nbr": "show ospf neighbor detail",
"ospf_db": "show ospf database router",
"eigrp_topo": None, "eigrp_nbr": None,
"proto_logs": "show logging | include ADJCHANGE|ADJCHG",
"nat": None,
},
"eos": {
"bgp_prefix": "show ip bgp {dst}",
"bgp_sum": "show ip bgp summary",
"ospf_nbr": "show ip ospf neighbor detail",
"ospf_db": "show ip ospf database router",
"eigrp_topo": None, "eigrp_nbr": None,
"proto_logs": "show logging | include ADJCHANGE|ADJCHG",
"nat": None,
},
"junos": {
"bgp_prefix": "show route {dst} detail",
"bgp_sum": "show bgp summary",
"ospf_nbr": "show ospf neighbor detail",
"ospf_db": "show ospf database router",
"eigrp_topo": None, "eigrp_nbr": None,
"proto_logs": "show log messages | match \"BGP|OSPF\"",
"nat": None,
},
"panos": {"bgp_prefix": "show routing protocol bgp loc-rib prefix {dst}",
"bgp_sum": "show routing protocol bgp summary",
"ospf_nbr": "show routing protocol ospf neighbor",
"ospf_db": None, "eigrp_topo": None, "eigrp_nbr": None,
"proto_logs": None, "nat": None},
"asa": {"bgp_prefix": "show bgp {dst}", "bgp_sum": "show bgp summary",
"ospf_nbr": "show ospf neighbor detail", "ospf_db": None,
"eigrp_topo": "show eigrp topology {dst}", "eigrp_nbr": "show eigrp neighbors",
"proto_logs": None, "nat": "show xlate | include {dst}"},
"fortios": {"bgp_prefix": "get router info bgp network {dst}",
"bgp_sum": "get router info bgp summary",
"ospf_nbr": "get router info ospf neighbor",
"ospf_db": None, "eigrp_topo": None, "eigrp_nbr": None,
"proto_logs": None, "nat": None},
}
RP_CMD["iosxe"] = RP_CMD["ios"]
# "1w2d", "04:12:55", "2d04h", "3w1d 04:12" -- every platform writes an age
# differently and they all mean seconds. Normalising them is what lets a route
# age and a neighbour uptime be COMPARED, which is the whole convergence
# argument: a route younger than the adjacency it came from means the path
# moved without the neighbour ever dropping.
AGE_RE = re.compile(
r"(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$|"
r"^(\d+):(\d{2}):(\d{2})$")
def age_seconds(text):
"""Seconds from any of the age formats these platforms print.
Returns None when it cannot be read, and None is not zero: "the route is
brand new" and "I could not read how old the route is" lead to opposite
conclusions about whether the network just converged.
"""
if not text:
return None
t = text.strip()
m = re.match(r"^(\d+):(\d{2}):(\d{2})$", t)
if m:
h, mi, s = (int(x) for x in m.groups())
return h * 3600 + mi * 60 + s
m = re.match(r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", t, re.I)
if m and any(m.groups()):
w, d, h, mi, s = (int(x) if x else 0 for x in m.groups())
return ((w * 7 + d) * 24 + h) * 3600 + mi * 60 + s
# "3w1d 04:12:55" -- a compound of both forms
parts = t.split()
if len(parts) == 2:
a, b = age_seconds(parts[0]), age_seconds(parts[1])
if a is not None and b is not None:
return a + b
return None
def human_age(sec):
if sec is None:
return "-"
if sec < 120:
return "%ds" % sec
if sec < 7200:
return "%dm" % (sec // 60)
if sec < 172800:
return "%dh%dm" % (sec // 3600, (sec % 3600) // 60)
return "%dd%dh" % (sec // 86400, (sec % 86400) // 3600)
def parse_route_age(text):
"""How long the route to this prefix has been installed.
This is the clock that matters. A prefix installed eleven minutes ago on a
path that has existed for two years is not a coincidence -- it is the
convergence event, and everything else in the report is downstream of it.
"""
if not text:
return None, None
for pat, src in ((r",\s*([0-9wdhms:]+)\s+ago", "route age"),
(r"Last update from \S+ ([0-9wdhms:]+) ago", "bgp last update"),
(r"Age:\s*([0-9wdhms:]+)", "junos age"),
(r"age\s+([0-9wdhms:]+)", "age")):
m = re.search(pat, text, re.I)
if m:
return age_seconds(m.group(1)), src
return None, None
def parse_bgp_best(text):
"""(best_path_attributes, why_it_won) from a BGP prefix lookup.
The attributes are the argument. "It is asymmetric" is an observation;
"it is asymmetric because the return direction prefers a path with
local-preference 200 that appeared this morning" is a cause, and only the
second one tells anybody what to change.
"""
if not text:
return {}, None
out = {}
# ONLY the best path's attributes. A BGP prefix lookup prints every path
# it knows, and the losers come first as often as not -- taking the first
# local-preference in the output reported 100 for a path that won with
# 200, which is an argument for the opposite conclusion. Split into
# blocks and keep the one that says "best".
#
# And the block must be a PATH, not the header. "Paths: (2 available,
# best #2, table default)" contains the word best and nothing else --
# selecting it returned a block with no attributes in it at all, which
# read as "the device printed no reason" when the device had printed the
# reason two lines further down. The same mistake as reading an HSRP
# state out of a column heading, and it is worth naming twice.
blocks = [b for b in re.split(r"\n(?=\s*Refresh Epoch)", text)
if re.search(r"\bfrom\b|localpref|local preference", b, re.I)]
best = next((b for b in blocks if re.search(r"\bbest\b", b, re.I)),
blocks[0] if blocks else text)
m = re.search(r"localpref\s+(\d+)|local preference\s+(\d+)|Localpref:\s*(\d+)",
best, re.I)
if m:
out["local_pref"] = int(next(g for g in m.groups() if g))
m = re.search(r"\bmetric\s+(\d+)|\bMED:\s*(\d+)", best, re.I)
if m:
out["med"] = int(next(g for g in m.groups() if g))
m = re.search(r"\bweight\s+(\d+)", best, re.I)
if m:
out["weight"] = int(m.group(1))
m = re.search(r"^\s*((?:\d+\s+)+)(?:from|$)", best, re.M)
if m:
out["as_path"] = m.group(1).strip()
text = best
# Newer releases print the reason outright. Take it when it is there
# rather than inferring one -- an inferred reason that disagrees with the
# device is worse than no reason at all.
why = None
m = re.search(r"Best path:\s*(.+?)\s*$", best, re.I | re.M)
if m:
why = m.group(1).rstrip(".")
else:
# Otherwise work down the decision process in its real order, which
# is also the order these attributes are worth reading in. Each
# pattern is tested on its own, with no alternation inside it: an
# embedded `|` reassociates against the surrounding expression and
# matched things it was never meant to.
for pat, reason in ((r"\bweight\b", "highest weight"),
(r"local ?pref", "highest local-preference"),
(r"as.?path", "shortest AS path"),
(r"\bmed\b", "lowest MED"),
(r"external|ebgp", "eBGP preferred over iBGP"),
(r"igp metric", "lowest IGP metric to the next hop"),
(r"oldest", "oldest path -- the tie-break that means "
"nothing here changed recently"),
(r"router.?id", "lowest router-id, the last tie-break")):
if re.search(pat, best, re.I):
why = reason
break
if why is None and re.search(r"\bbest\b", best, re.I):
why = "declared best; this release printed no reason line"
return out, why
def parse_neighbor_uptime(text, peer=None):
"""[(neighbour, uptime_seconds, state)] from a protocol neighbour table.
An adjacency younger than the route that depends on it is the signature
of a reconvergence. An adjacency OLDER than the route means the path
changed while the neighbours stayed up -- which points at a policy or a
metric change rather than a link failure, and those are different
investigations.
"""
out = []
if not text:
return out
lines = text.split(chr(10))
for i, line in enumerate(lines):
ips = IP_RE.findall(line)
if not ips:
continue
if peer and peer not in ips:
continue
state = None
m = re.search(r"\b(FULL|2WAY|INIT|EXSTART|EXCHANGE|LOADING|DOWN|"
r"Established|Active|Idle|Connect|OpenSent|OpenConfirm)\b",
line, re.I)
if m:
state = m.group(1)
# A DEAD TIME IS NOT AN UPTIME, and `show ip ospf neighbor` prints a
# dead timer in exactly the place a loose "last age-shaped token on
# the line" rule picks up. That rule reported adjacencies "up 36s"
# that had been up three weeks -- and 36 seconds would have been
# evidence of the reconvergence this tool exists to find, so the
# wrong column did not just lose the answer, it manufactured the
# opposite one. Same mistake as reading an HSRP state out of a
# column header. Take the uptime only where it is LABELLED, or from
# the Up/Down column of a BGP summary, which is positional and known.
sec = None
for src in (line, lines[i + 1] if i + 1 < len(lines) else ""):
m = re.search(r"(?:up(?:time)?(?:\s+for)?|Up/Down|since)\s*[:\s]\s*"
r"((?:\d+[wdhms])+|\d+:\d{2}:\d{2})", src, re.I)
if m:
sec = age_seconds(m.group(1))
break
if sec is None and state and state.lower() == "established":
# BGP summary: the Up/Down column is the last field before the
# prefix count, and there is no dead timer on the line to confuse
# it with.
ages = re.findall(r"\b((?:\d+[wdhms])+|\d+:\d{2}:\d{2})\b", line)
sec = age_seconds(ages[-1]) if ages else None
if sec is None and re.match(r"^\s*(?:\d{1,3}\.){3}\d{1,3}\s+\d+\s+\d+\s", line):
# A BGP summary row with no state word means the session is up:
# the state column holds the prefix count instead.
ages = re.findall(r"\b((?:\d+[wdhms])+|\d+:\d{2}:\d{2})\b", line)
sec = age_seconds(ages[-1]) if ages else None
out.append((ips[0], sec, state))
return out
PROTO_EVENT = re.compile(
r"(?P<stamp>\w{3}\s+\d+\s[\d:.]+)?.*?"
r"%(?P<proto>BGP|OSPF|DUAL|EIGRP)[-\w]*-\d-(?P<kind>ADJCHANGE|ADJCHG|NBRCHANGE|"
r"NBRCHANGE_DUAL|NBR_CHANGE)[:\s](?P<rest>.*)", re.I)
def parse_proto_events(text, limit=8):
"""Adjacency changes from the log, newest first.
The log is the only place that says WHEN, and when is the question. But it
only holds what fits: no events is NOT evidence that nothing happened, and
the report says so rather than letting silence read as stability.
"""
out = []
if not text:
return out
for line in text.split(chr(10)):
m = PROTO_EVENT.search(line)
if m:
out.append((m.group("stamp") or "", m.group("proto").upper(),
m.group("rest").strip()[:90]))
out.reverse()
return out[:limit]
# ----------------------------------------------------------------- the walk
class RpHop(object):
def __init__(self, order, name, ip, osn):
self.order, self.name, self.ip, self.osn = order, name, ip, osn
self.role = None
self.model = None
self.notes = []
class Verifier(object):
"""Walk both directions and compare them.
network-path-analyzer flags asymmetry per hop, by comparing a reverse FIB
lookup with the address a hop answered from. That finds divergence at a
device. It does NOT find a return path that leaves by a different set of
devices entirely, because it never walks the return path -- and that is
the case that matters, since a stateful firewall only has to be missing
from ONE of the two directions to drop the flow.
THE LIMITATION, stated here because the documentation must not be the
only place it appears: walking the return path requires a device at the
B end. With only A's gateway the return can be INFERRED from FIB
lookups, which is useful and is a different, weaker claim. The report
never presents one as the other.
"""
def __init__(self, args, transport):
self.a, self.t = args, transport
self.inv = load_inventory(args.inventory) if args.inventory else {}
self.forward, self.reverse = [], []
self.findings, self.unchecked = [], []
self.inferred = False
self.diverge = None # index where the two paths part company
self.cause = [] # (device, what, evidence)
# -- 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 = RP_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))
def identify(self, h):
if not h.osn or not h.ip:
return
out, _ = self.ask(h.ip, h.osn, "version")
h.model, h.role = parse_model(out, h.osn)
if h.role is None and h.osn in FIREWALL_OS:
h.role = "firewall"
if h.name == h.ip:
real = parse_hostname(out)
if real:
h.name = real
# -- one direction -------------------------------------------------------
def walk(self, start, start_os, dst, label):
hops = []
name, osn, _vrf = self.dev(start, start_os)
if osn is None:
self.unchecked.append(
"%s: the platform of %s is unknown, so this direction was not walked."
% (label, start))
return hops
out, err = self.ask(start, osn, "traceroute", dst=dst)
if not out:
self.unchecked.append(
"%s: the traceroute from %s was not readable (%s)."
% (label, name, err or "output not recognised"))
return hops
h0 = RpHop(0, name, start, osn)
self.identify(h0)
hops.append(h0)
for n, ip in parse_traceroute(out):
if ip is None:
h = RpHop(n, "(no reply)", None, None)
h.notes.append("did not answer; common where ICMP is rate-limited. "
"A silent hop is a hop this comparison cannot see")
hops.append(h)
continue
inv = self.inv.get(ip)
h = RpHop(n, inv["device_name"] if inv else ip, ip,
inv["os"] if inv else None)
self.identify(h)
hops.append(h)
return hops
def infer_reverse(self, dst, src):
"""The return path from FIB lookups, when there is no device at B.
A weaker claim and labelled as one. It asks each forward hop where it
would send traffic back toward the source -- which is what that hop
believes, not what the far end actually does. Where the far end has a
different opinion, this will not see it.
"""
self.inferred = True
hops = []
for h in self.forward:
if not h.ip or not h.osn:
continue
out, _ = self.ask(h.ip, h.osn, "route", dst=src, vrf="default", src=dst)
eg, nh = parse_route_egress(out)
if nh:
inv = self.inv.get(nh)
r = RpHop(len(hops), inv["device_name"] if inv else nh, nh,
inv["os"] if inv else None)
r.notes.append("inferred from %s's own route back toward %s, not "
"walked" % (h.name, src))
hops.append(r)
return hops
# -- the comparison ------------------------------------------------------
def compare(self):
"""Where the two directions part company, and what it costs."""
fwd = [h for h in self.forward if h.ip]
rev = [h for h in self.reverse if h.ip]
if not fwd or not rev:
return
fset = set(h.ip for h in fwd)
rset = set(h.ip for h in rev)
# The return path read backwards should visit the same devices. The
# FIRST one it does not is the divergence point -- everything after it
# is a consequence, not a second finding.
expect = [h.ip for h in reversed(fwd)]
got = [h.ip for h in rev]
for i, ip in enumerate(got):
if i < len(expect) and ip != expect[i]:
self.diverge = (i, expect[i], ip)
break
if self.diverge is None and len(got) != len(expect):
self.diverge = (min(len(got), len(expect)), "(path ends)", "(path ends)")
if self.diverge:
i, want, have = self.diverge
how = "inferred" if self.inferred else "walked"
self.finding("HIGH", "path",
"asymmetric: the return leaves the forward path at hop %d" % i,
"going out the traffic crosses %s at that point; coming back "
"it goes through %s. Everything after this is a consequence of "
"it, not a separate problem. The return direction was %s."
% (want, have, how))
# What the asymmetry actually costs. These are the reasons it matters;
# asymmetry on its own is a fact, not a fault.
for h in fwd:
if h.ip in rset or not h.role:
continue
if h.role == "firewall":
self.finding("HIGH", h.name,
"the return traffic does not cross %s" % h.name,
"a stateful firewall has to see BOTH directions. It sees "
"the SYN here and never the SYN-ACK, so the connection is "
"torn down or silently dropped -- and every routing table "
"involved says the path is fine. This is the commonest "
"cause of that exact complaint")
elif h.role in ("ips", "ids"):
self.finding("HIGH", h.name,
"the return traffic bypasses %s" % h.name,
"this is a SECURITY finding, not a connectivity one. "
"Traffic is inspected in one direction and not the other, "
"so anything that only shows in the server's replies -- "
"exfiltration, a C2 beacon's responses -- is not being seen")
elif h.role in ("loadbalancer", "proxy"):
self.finding("MEDIUM", h.name,
"the return traffic does not cross %s" % h.name,
"a device that rewrites or optimises a flow needs both "
"directions. One-way, it degrades quietly rather than "
"failing loudly")
for h in rev:
if h.ip not in fset and h.role == "firewall":
self.finding("HIGH", h.name,
"%s is only in the return path" % h.name,
"it sees replies to a request it never saw. Stateful "
"inspection has nothing to match them against")
# -- the cause -----------------------------------------------------------
def explain(self, dst, src):
"""WHY the paths differ, and WHEN it started.
Where the divergence is tells you which device to look at. What
changed on it, and when, is the answer -- and it is the difference
between a report and a diagnosis.
"""
targets = []
if self.diverge:
i, want, have = self.diverge
targets = [h for h in self.forward + self.reverse
if h.ip in (want, have) and h.osn]
if not targets:
targets = [h for h in self.forward if h.osn][:2]
for h in targets:
ev = []
# The clock that matters: how long this route has been installed.
rt, _ = self.ask(h.ip, h.osn, "route", dst=src, vrf="default", src=dst)
age, how = parse_route_age(rt)
if age is not None:
ev.append(("route toward %s installed" % src,
"%s ago (%s)" % (human_age(age), how)))
bgp, _ = self.ask(h.ip, h.osn, "bgp_prefix", dst=src)
if bgp:
attrs, why = parse_bgp_best(bgp)
if why:
ev.append(("BGP chose this path by", why))
for k, lbl in (("local_pref", "local-preference"), ("med", "MED"),
("weight", "weight"), ("as_path", "AS path")):
if k in attrs:
ev.append((" " + lbl, str(attrs[k])))
bage, _ = parse_route_age(bgp)
if bage is not None and age is None:
age = bage
ev.append(("BGP last update", "%s ago" % human_age(bage)))
# The adjacencies underneath it. The comparison between the two
# ages is the argument, not either number on its own.
# The YOUNGEST adjacency, once. Reporting every successive
# improvement listed three lines where one fact belongs, and the
# first two of them were wrong by the time the third printed.
nbr_age, youngest = None, None
for key, proto in (("ospf_nbr", "OSPF"), ("eigrp_nbr", "EIGRP"),
("bgp_sum", "BGP")):
out, _ = self.ask(h.ip, h.osn, key)
for peer, sec, state in parse_neighbor_uptime(out):
if sec is None:
continue
if nbr_age is None or sec < nbr_age:
nbr_age, youngest = sec, (proto, peer, sec, state)
if youngest:
proto, peer, sec, state = youngest
ev.append(("youngest %s adjacency" % proto,
"%s, up %s" % (peer, human_age(sec))
+ (" [%s]" % state if state else "")))
logs, _ = self.ask(h.ip, h.osn, "proto_logs")
events = parse_proto_events(logs)
for stamp, proto, rest in events[:4]:
ev.append(("%s event" % proto, ("%s %s" % (stamp, rest)).strip()))
if logs is not None and not events:
ev.append(("adjacency log",
"no adjacency changes in the buffer -- which is not proof "
"none happened; the buffer only holds what fits"))
# The conclusion, which is the comparison rather than either clock.
if age is not None and nbr_age is not None:
# abs(), not "<= nbr_age + 60". With the one-sided test a
# route eleven minutes old under an adjacency up three weeks
# satisfied it -- 684 <= 1900860 is true -- so the tool
# announced a link event for the one case that is NOT one.
# The two clocks have to be the SAME AGE to mean a link came
# back; a route much younger than its adjacency means the
# opposite thing.
if abs(age - nbr_age) <= 60:
self.finding("INFO", h.name,
"the path here converged about %s ago" % human_age(age),
"the route and the adjacency under it are the same "
"age, so this moved because the neighbour came back "
"or came up. A link or device event, not a policy one")
elif age < nbr_age:
self.finding("MEDIUM", h.name,
"the route moved %s ago while the adjacencies stayed up"
% human_age(age),
"the neighbours have been up %s, so nothing failed. A "
"route that moves under a stable adjacency is a POLICY "
"or METRIC change -- someone edited a local-preference, "
"a prefix-list, a metric or a cost. Look at the change "
"record for that window, not at the links"
% human_age(nbr_age))
self.cause.append((h, ev))
# ----------------------------------------------------------------- report
def _row(h, mark=""):
what = " ".join(x for x in (h.role, h.model) if x)
return " %-2s %-24s %-14s %-20s" % (mark, h.name[:24], h.ip or "-", what[:20] or "-")
def report(v, args):
line = "=" * 78
print(line)
print("return-path-verifier %s <-> %s" % (args.src, args.dst))
print(line)
print("")
print("FORWARD %s -> %s" % (args.src, args.dst))
print("-" * 78)
fset = set(h.ip for h in v.reverse if h.ip)
for h in v.forward:
# The mark is the whole comparison in one column: < means this device
# is on both paths, and a blank means the return traffic never sees
# it. A firewall with a blank here is the finding.
mark = "<" if h.ip and h.ip in fset else " "
print(_row(h, mark))
for n in h.notes:
print(" note %s" % n)
print("")
print("RETURN %s -> %s%s"
% (args.dst, args.src, " (INFERRED, not walked)" if v.inferred else ""))
print("-" * 78)
rset = set(h.ip for h in v.forward if h.ip)
if not v.reverse:
print(" not established")
for i, h in enumerate(v.reverse):
mark = "<" if h.ip and h.ip in rset else " "
if v.diverge and i == v.diverge[0]:
mark = "!"
print(_row(h, mark))
for n in h.notes:
print(" note %s" % n)
print("")
print(" < on both paths ! where the two paths part company")
print(" blank: this device is on ONE path only")
print("")
if v.cause:
print("WHY, AND WHEN")
print("-" * 78)
for h, ev in v.cause:
print(" %s (%s)" % (h.name, h.ip))
if not ev:
print(" nothing readable -- the routing protocol commands are "
"not available on %s" % h.osn)
for what, detail in ev:
print(" %-32s %s" % (what, detail))
print("")
for sev in ("HIGH", "MEDIUM", "INFO"):
group = [f for f in v.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))
for ln in textwrap.wrap(why, 72):
print(" %s" % ln)
print("")
if not v.findings:
print("The return path matches the forward path. No asymmetry found.")
print("")
if v.unchecked:
print("NOT CHECKED")
print("-" * 78)
for u in v.unchecked:
for i, ln in enumerate(textwrap.wrap(u, 74)):
print(" %s%s" % ("- " if i == 0 else " ", ln))
print("")
print("WHAT THIS DOES NOT PROVE")
print("-" * 78)
caveats = [
"A traceroute is ICMP or UDP. Your application is neither, and under "
"ECMP it may hash to a different path in either direction. Asymmetry "
"found here is real; asymmetry NOT found here is not a guarantee.",
"Both directions were sampled at different moments. A path that "
"reconverges between the two walks looks asymmetric and is not.",
"A hop that does not answer is invisible to this comparison. A silent "
"device on one path only will not be reported, because it cannot be "
"seen on either.",
"Asymmetry is not a fault by itself. Plenty of networks run "
"asymmetric deliberately and correctly. It becomes a fault when a "
"device that needs both directions is in only one of them, which is "
"what the findings above are about.",
]
if v.inferred:
caveats.insert(0,
"THE RETURN PATH WAS INFERRED, NOT WALKED. It was built from what each "
"forward hop says it would do with traffic going back, which is that "
"hop's own opinion. The far end was never asked. Give --return-from a "
"device at the destination end for the real answer.")
for cav in caveats:
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="Walk a path in both directions, find where they diverge, "
"and say what changed and when.")
ap.add_argument("--src", required=True, help="source address")
ap.add_argument("--dst", required=True, help="destination address")
ap.add_argument("--from", dest="start", help="device at the SOURCE end to walk from")
ap.add_argument("--return-from", dest="rstart",
help="device at the DESTINATION end. Without it the return path "
"is inferred from FIB lookups, which is a weaker claim")
ap.add_argument("--from-os", help="platform of --from, if not in the inventory")
ap.add_argument("--return-from-os", help="platform of --return-from")
ap.add_argument("--inventory", help="optional devices.yaml")
ap.add_argument("--csv", help="write both paths side by side")
ap.add_argument("--offline-dir", help="replay saved command output")
args = ap.parse_args(argv)
if not args.offline_dir and not args.start:
ap.error("--from is required unless you are replaying with --offline-dir")
v = Verifier(args, Transport(args))
v.forward = v.walk(args.start, args.from_os, args.dst, "forward")
if args.rstart:
v.reverse = v.walk(args.rstart, args.return_from_os, args.src, "return")
else:
# Walking beats inferring, so try the far end of the forward path
# before falling back: the last hop that answered is usually the
# destination's own gateway, and it is the right place to turn round.
tail = next((h for h in reversed(v.forward) if h.ip and h.osn), None)
if tail is not None and tail.ip != args.start:
v.reverse = v.walk(tail.ip, tail.osn, args.src, "return")
if v.reverse:
v.unchecked.append(
"--return-from was not given, so the return walk started from %s, "
"the last hop of the forward path that could be reached. That is "
"the destination's gateway, not the destination -- the L2 segment "
"in front of the host is not covered in this direction."
% tail.name)
if not v.reverse:
v.reverse = v.infer_reverse(args.dst, args.src)
v.compare()
v.explain(args.dst, args.src)
report(v, args)
if args.csv:
with io.open(args.csv, "w", encoding="utf-8", newline="") as fh:
cw = csv.writer(fh)
cw.writerow(["index", "direction", "device", "ip", "os", "role",
"model", "on_both_paths", "notes"])
both_f = set(h.ip for h in v.reverse if h.ip)
both_r = set(h.ip for h in v.forward if h.ip)
for label, hops, both in (("forward", v.forward, both_f),
("return", v.reverse, both_r)):
for i, h in enumerate(hops):
cw.writerow([i, label, h.name, h.ip or "", h.osn or "",
h.role or "", h.model or "",
"yes" if h.ip in both else "no",
" | ".join(h.notes)])
if any(f[0] == "HIGH" for f in v.findings):
return 2
if any(f[0] == "MEDIUM" for f in v.findings):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
--srctextrequiredSource address.
--dsttextrequiredDestination address.
--fromtextDevice at the source end to walk from, normally the gateway.
--return-fromtextDevice at the destination end. Without it the return path is inferred from FIB lookups, which is a weaker claim.
--inventorytextOptional devices.yaml for SSH ports, management addresses and VRFs.
--csvtextWrite both paths side by side.
What This Solves
The connection to the server times out. Every routing table you look at says the path is fine, because every routing table you look at is the path OUT. Traffic has to come back, and nothing says it comes back the same way. When it does not, a stateful firewall somewhere sees a SYN and never a SYN-ACK, and it drops a flow that routing says should work. That is the commonest cause of exactly that complaint, and it is invisible to every tool that only walks one direction. This walks BOTH, compares them, and then does the part that makes it a diagnosis instead of an observation: it says WHERE the two paths part company, WHAT changed in BGP, OSPF or EIGRP to make them, and WHEN. Asymmetry on its own is not a fault. Plenty of networks run asymmetric deliberately and correctly. It becomes a fault when a device that NEEDS both directions is in only one of them -- and that is what the findings are about.
Is It Safe to Run
Read-only. It sends `show` commands and `traceroute`. It configures nothing. IOS / IOS-XE traceroute, show version, show ip route, show ip bgp, show ip bgp summary, show ip ospf neighbor detail, show ip eigrp topology, show ip eigrp neighbors, show logging | include ADJCHANGE|ADJCHG|NBRCHANGE NX-OS as above, with show logging logfile IOS-XR / EOS show route / show bgp / show ospf neighbor and the equivalents Junos show route detail, show bgp summary, show ospf neighbor detail, show log messages PAN-OS show routing protocol bgp / ospf -- most protocol detail does not exist there, and the report says so rather than leaving an empty section `traceroute` is NOT a show command. A profile that authorises only `show *` cannot walk either direction, so it cannot run this at all.
Before You Start
Python 3.8 or newer pip install netmiko pyyaml You need SSH to a device at the SOURCE end -- normally the source host's gateway -- and, for the real answer, to a device at the DESTINATION end. The one limitation, said up front Walking the return path requires a device at the far end. With only the source's gateway, the return can be INFERRED from FIB lookups -- asking each forward hop where it would send traffic back -- and that is a WEAKER CLAIM: it is what those hops believe, not what the far end actually does. The report labels it INFERRED everywhere it appears and never presents one as the other. Give it --return-from and you get the real answer.
Your First Run
1. Credentials in the environment. Not on the command line: an argument is visible in `ps auxww` and lands in your shell history. export NET_USER=your-username read -s NET_PASS; export NET_PASS 2. Find the two gateways -- the source host's and the destination host's. On the host: `ip route` on Linux, `route print` on Windows. 3. Run it. python3 return_path_verifier.py \ --src 10.10.10.50 --dst 10.20.20.80 \ --from 10.10.10.1 --return-from 10.20.20.1 If you do not have the far-end device, leave --return-from out. It will try the last hop of the forward path that answers, and fall back to inferring.
What the Output Means
============================================================================== return-path-verifier 10.10.10.50 <-> 10.20.20.80 ============================================================================== FORWARD 10.10.10.50 -> 10.20.20.80 ------------------------------------------------------------------------------ < gw-01 10.10.10.1 switch C9300-48U core-01 10.0.0.1 router ASR-920-24SZ- fw-dc-01 10.0.0.9 firewall PA-3220 < dist-01 10.20.20.1 switch C9300-24T RETURN 10.20.20.80 -> 10.10.10.50 ------------------------------------------------------------------------------ < dist-01 10.20.20.1 switch C9300-24T ! core-02 10.0.0.21 router ASR-920-24SZ- < gw-01 10.10.10.1 switch C9300-48U < on both paths ! where the two paths part company blank: this device is on ONE path only WHY, AND WHEN ------------------------------------------------------------------------------ fw-dc-01 (10.0.0.9) nothing readable -- the routing protocol commands are not available on panos core-02 (10.0.0.21) route toward 10.10.10.50 installed 11m ago (route age) BGP chose this path by local preference 200 is higher local-preference 200 MED 0 youngest BGP adjacency 10.255.0.9, up 22d0h OSPF event Sep 28 04:12:02.118: Process 1, Nbr 10.255.0.9 on Te0/0/2 from LOADING to FULL, Loading Done BGP event Sep 28 04:12:09.441: neighbor 10.255.0.9 Up HIGH (2) ------------------------------------------------------------------------------ [path] asymmetric: the return leaves the forward path at hop 1 going out the traffic crosses 10.0.0.9 at that point; coming back it goes through 10.0.0.21. Everything after this is a consequence of it, not a separate problem. The return direction was walked. [fw-dc-01] the return traffic does not cross fw-dc-01 a stateful firewall has to see BOTH directions. It sees the SYN here and never the SYN-ACK, so the connection is torn down or silently dropped -- and every routing table involved says the path is fine. This is the commonest cause of that exact complaint MEDIUM (1) ------------------------------------------------------------------------------ [core-02] the route moved 11m ago while the adjacencies stayed up the neighbours have been up 22d0h, so nothing failed. A route that moves under a stable adjacency is a POLICY or METRIC change -- someone edited a local-preference, a prefix-list, a metric or a cost. Look at the change record for that window, not at the links WHAT THIS DOES NOT PROVE ------------------------------------------------------------------------------ - A traceroute is ICMP or UDP. Your application is neither, and under ECMP it may hash to a different path in either direction. Asymmetry found here is real; asymmetry NOT found here is not a guarantee. - Both directions were sampled at different moments. A path that reconverges between the two walks looks asymmetric and is not. - A hop that does not answer is invisible to this comparison. A silent device on one path only will not be reported, because it cannot be seen on either. - Asymmetry is not a fault by itself. Plenty of networks run asymmetric deliberately and correctly. It becomes a fault when a device that needs both directions is in only one of them, which is what the findings above are about. The mark in the left column is the whole comparison < this device is on BOTH paths ! this is where the two paths part company blank this device is on ONE path only A firewall with a blank beside it is the finding. Everything after the `!` is a consequence of the divergence, not a second problem -- which is why only the first one is reported. "WHY, AND WHEN" IS THE PART THAT ENDS THE ARGUMENT Knowing the paths differ tells you which device to look at. It does not tell you what happened, and that is the half that takes the time. So at the divergence point the tool reads two clocks and compares them: HOW OLD THE ROUTE IS how long this path has been installed HOW OLD THE ADJACENCY IS how long the neighbour under it has been up THE COMPARISON IS THE ANSWER, not either number: SAME AGE the route moved because the neighbour came back. A link or device event. Go and look at the link. ROUTE MUCH nothing failed. The adjacencies have been up for weeks and YOUNGER the route moved anyway -- so somebody CONFIGURED something: a local-preference, a prefix-list, a metric, a cost. Look at the change record for that window, not at the links. In the run above the route is eleven minutes old and the adjacencies have been up twenty-two days, and the BGP lookup names the cause outright: a path with local-preference 200. Nothing broke. Somebody changed a policy, and the return traffic stopped crossing the firewall.
Exit Codes for Cron and CI
0 the return path matches the forward path 1 a MEDIUM finding -- a route that moved under stable adjacencies, a one-way load balancer or proxy 2 a HIGH finding -- asymmetry, or a firewall or IPS in one direction only Suitable for a change-window gate: run it against the flows that matter after a routing change and let a non-zero exit stop the pipeline. What it checks, AND why each one matters A FIREWALL IN ONE DIRECTION ONLY. Stateful inspection needs both halves. It sees the SYN and never the SYN-ACK, so the connection is torn down or silently dropped -- and every routing table involved says the path is fine. AN IPS OR IDS BYPASSED ON THE RETURN. This is a SECURITY finding, not a connectivity one. Traffic is inspected one way and not the other, so anything that only shows in the server's replies -- exfiltration, the responses in a C2 beacon -- is not being seen at all. Nothing breaks, which is why nobody notices. A FIREWALL THAT IS ONLY IN THE RETURN PATH. It sees replies to a request it never saw, and has nothing to match them against. A LOAD BALANCER OR PROXY ONE-WAY. A device that rewrites or optimises a flow needs both directions. One-way it degrades quietly rather than failing loudly, which is worse to find. WHAT MOVED THE ROUTE. BGP best-path reason and the attribute that won -- local-preference, MED, weight, AS path. OSPF and EIGRP adjacency uptime. The adjacency-change log. And the two clocks above. HOW THIS DIFFERS FROM network-path-analyzer They answer different questions and both are worth running. network-path-analyzer why does THIS FLOW not work? It walks one direction in depth -- interfaces, counters, firewall policy, VRFs -- and flags asymmetry PER HOP, by comparing a reverse FIB lookup against the address each hop answered from. return-path-verifier does the return go where it should? It walks the whole return path as its own path and compares the two device lists. That is the case the per-hop check cannot see: a return that leaves by a completely different set of devices. A stateful firewall only has to be missing from ONE of the two directions.
The Account
Two ways, and the second is better. YOUR OWN CREDENTIALS work and need no preparation. Everything is attributed to you in the device logs, which is honest and noisy. A DEDICATED SERVICE ACCOUNT in TACACS+ or RADIUS, read-only, is the right answer for three reasons: its authorisation can be scoped to exactly what the tool needs; 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 for this tool, and the gap is total rather than partial: `traceroute` is not a show command, and without it neither direction can be walked at all. show version / show ip route / show route read show ip bgp / show bgp summary read show ip ospf neighbor detail read show ip eigrp topology / neighbors read show logging read traceroute NOT a show command 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. Only for what cannot be discovered: a non-standard SSH port, a management address when the transit address refuses SSH, or a VRF. 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 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
Every Flag
--src ADDRESS source address (required) --dst ADDRESS destination address (required) --from HOST device at the SOURCE end to walk from --return-from HOST device at the DESTINATION end. Without it the return is inferred, which is a weaker claim --from-os OS platform of --from, if not in the inventory --return-from-os OS platform of --return-from --inventory FILE optional devices.yaml --csv FILE write both paths side by side --offline-dir DIR replay saved output instead of connecting
When Something Fails
"the traceroute from <device> was not readable" Either `traceroute` is not authorised for your account -- it is not a show command -- or the output format is one this tool does not know. Report the platform and release. the return path is labelled INFERRED --return-from was not given and the last forward hop could not be reached. You are reading what the forward hops believe about the return, not the return. Find a device at the destination end. "nothing readable" under a device in WHY, AND WHEN That platform does not have the routing-protocol commands -- PAN-OS and FortiOS are the usual ones. The divergence is still correct; the cause has to come from the device on the other side of it. no asymmetry found, but the flow still fails Then this is not the problem, which is itself worth knowing. Two things this cannot see: a path that reconverged between the two walks, and a flow that hashes differently under ECMP than the traceroute did. network-path-analyzer is the next tool -- it asks the firewalls what they would do with your actual five-tuple.