Batfish for Cisco IOS XE: Catch a Bad ACL and a Null Route Before They Go Live

Batfish models IOS XE configs offline. It caught a shadowed ACL line and a static route that null-routed a LAN before deployment, and it is honest about the 23 lines it couldn't parse.

Batfish for Cisco IOS XE: Catch a Bad ACL and a Null Route Before They Go Live

The Oxidized post gave me a Git history of every router and switch config. That tells you what changed after it happened. Batfish works the other way round: you give it the configs you're about to push, and it builds a model of the network offline and answers questions about it. Will this ACL do what I think? Does this new route break reachability? You find out before anything touches a device.

I tested it against the configs Oxidized pulled from my home lab: two Cisco IOS XE 17.18 WAN routers running OSPF and eBGP between them, and an IOS XE access switch behind wan-rtr1. Everything here was built in my home lab.

Lab diagram: configs from the Oxidized Git repo become a base snapshot, which is copied and edited into a candidate snapshot for change CHG-1043. Batfish parses both and models the control and data plane, then answers fileParseStatus, parseWarning, filterLineReachability, testFilters, routes, traceroute and differentialReachability. A clean result means push the change; a failure loops back to fix the candidate and re-run. A lab topology inset shows acc-sw1 at 10.0.10.2 on Et0/2 of wan-rtr1 (AS 65001), linked by OSPF and eBGP to wan-rtr2 (AS 65002), with two findings in orange: EDGE-IN line 40 shadowed inbound on Et0/2, and a Null0 static for 10.0.10.0/24 on wan-rtr2. Callouts say Batfish never logs into devices and that its 23 parse warnings should be read first.
Batfish compares a base snapshot with the edited candidate offline. In CHG-1043 it found a shadowed ACL line and a Null0 static before anything was pushed.

Versions and licence

  • Batfish, container image batfish/allinone:latest, built 2026-08-27 (about 2.2 GB). Apache-2.0.
  • pybatfish 2026.08.27.3685 (bundled in the allinone image; 2026.9.17 is on PyPI). Apache-2.0.

The allinone image bundles the Batfish service, pybatfish and Jupyter. I didn't use Jupyter; I ran a plain Python script inside the container.

Setup

docker run -d --name pl-batfish batfish/allinone:latest

Batfish works on snapshots. A snapshot is just a directory with a configs/ folder holding one file per device. I exported the latest version of each device from the Oxidized Git repo:

for n in wan-rtr1 wan-rtr2 acc-sw1; do
  git --git-dir=configs.git show HEAD:$n > base/configs/$n.cfg
done

That's the base snapshot, the network as it is now. Then I copied it to a candidate snapshot and made the change I wanted to test. Snapshots live in Git and Batfish never logs into anything, so this whole workflow can run in a CI job.

First, be honest about parsing

Before trusting any answer, check how much of the configs Batfish understood. It's the step people skip:

from pybatfish.client.session import Session
bf = Session(host="localhost")
bf.set_network("pinglabz")
bf.init_snapshot("/snap/base", name="base", overwrite=True)
bf.q.fileParseStatus().answer().frame()
bf.q.initIssues().answer().frame()
bf.q.parseWarning().answer().frame()
Batfish fileParseStatus showing PARTIALLY_UNRECOGNIZED for all three IOS XE configs, with the list of unrecognized lines
All three IOS XE configs parse as PARTIALLY_UNRECOGNIZED, with 23 warnings in total.

None of the three files parsed cleanly. Every one came back PARTIALLY_UNRECOGNIZED, with 23 parse warnings in total. Here's what Batfish couldn't read:

  • option interface-table and option sampler-table under the Flexible NetFlow exporter. Batfish doesn't model NetFlow at all.
  • snmp-server host ... vrf ... version 2c. It skipped the SNMP trap host with a VRF.
  • logging trap syslog-format rfc5424, no logging btrace, ip ssh bulk-mode, hash sha512. These are newer IOS XE knobs that the grammar hasn't caught up with.
  • ip default-gateway on the switch, which it flags as "not currently supported".

None of that touches forwarding, so I was fine. The rule I'd use: read every warning, and if one lands on routing, an ACL, NAT or an interface, don't trust the answers until you've dealt with it. On older or more exotic platforms the list gets longer.

The part that matters did parse. Batfish worked out the eBGP session (AS 65001 to 65002) and the OSPF adjacency on its own, and both show ESTABLISHED, matching the real routers:

Batfish bgpSessionStatus and ospfSessionCompatibility showing established sessions, and the routes on wan-rtr2 for 10.0.10.0/24 before and after the change
Batfish models the eBGP and OSPF sessions, then shows the 10.0.10.0/24 route on wan-rtr2 before and after the change.

The change: CHG-1043

Here's the candidate change. It contains two mistakes I've seen in real change windows.

On wan-rtr1, apply EDGE-IN inbound on the LAN-facing interface, with the lines in the wrong order. The intent is to block telnet, but the catch-all permit sits above the deny:

ip access-list extended EDGE-IN
 10 permit tcp 10.0.0.0 0.255.255.255 any eq 22
 20 permit icmp any any echo-reply
 30 permit ip any any
 40 deny tcp any any eq telnet log
!
interface Ethernet0/2
 ip access-group EDGE-IN in

On wan-rtr2, someone adds a "summary" discard route for the LAN, thinking it only matters when the more specific route is gone:

ip route 10.0.10.0 255.255.255.0 Null0 name CHG-1043-summary

That route is the same /24 that wan-rtr2 already learns over eBGP, not a shorter summary. A static has admin distance 1 and eBGP has 20, so the static wins and the traffic gets dropped.

The questions

bf.init_snapshot("/snap/cand", name="cand", overwrite=True)

# ACL lines that can never match
bf.q.filterLineReachability(nodes="wan-rtr1").answer(snapshot="cand")

# would telnet from the LAN be blocked?
bf.q.testFilters(nodes="wan-rtr1", filters="EDGE-IN",
    headers=HeaderConstraints(srcIps="10.0.10.2", dstIps="10.255.0.1",
                              ipProtocols=["tcp"], dstPorts="23")).answer(snapshot="cand")

# what does wan-rtr2 use for the LAN, before and after?
bf.q.routes(nodes="wan-rtr2", network="10.0.10.0/24").answer(snapshot="base")
bf.q.routes(nodes="wan-rtr2", network="10.0.10.0/24").answer(snapshot="cand")

# trace SSH from wan-rtr2's loopback to the switch, in both snapshots
bf.q.traceroute(startLocation="wan-rtr2",
    headers=HeaderConstraints(srcIps="10.255.0.2", dstIps="10.0.10.2",
                              applications=["ssh"])).answer(snapshot="cand")

# every flow to the LAN that behaves differently after the change
bf.q.differentialReachability(headers=HeaderConstraints(dstIps="10.0.10.0/24")
    ).answer(snapshot="cand", reference_snapshot="base")

What Batfish found

Batfish output showing an unreachable ACL line, telnet permitted, and traces that are null-routed in the candidate snapshot
Both mistakes caught before deployment: a shadowed ACL line and a null route.
### filterLineReachability cand
Unreachable_Line                    Blocking_Lines              Reason
40 deny tcp any any eq telnet log   ['30 permit ip any any']    BLOCKING_LINES

### testFilters telnet cand
Flow                                              Action   Line_Content
start=wan-rtr1 [10.0.10.2:49152->10.255.0.1:23 TCP (SYN)]   PERMIT   30 permit ip any any

### routes wan-rtr2 10.0.10.0/24
base:  10.0.10.0/24  ip 10.0.12.1  bgp     AD 20
cand:  10.0.10.0/24  discard       static  AD 1

### traceroute wan-rtr2 -> acc-sw1
base:  ACCEPTED     wan-rtr2 -> wan-rtr1 -> acc-sw1
cand:  NULL_ROUTED  wan-rtr2:ORIGINATED,NULL_ROUTED

Both mistakes were caught:

  • The ACL. Line 40 can never match because line 30 already permits everything, and testFilters confirms that telnet from the LAN is permitted by line 30. On a real router, IOS XE would accept that ACL without a word of complaint.
  • The route. wan-rtr2 swaps a BGP route for a discard route, and the traceroute that worked in the base snapshot is now null-routed. differentialReachability finds the broken flow without me having to guess which source and destination to test.

Fixing both is a one-line change each: move the deny above the permit (or renumber it to 25), and either drop the static route or give it a higher admin distance (ip route 10.0.10.0 255.255.255.0 Null0 250) so it's only used when BGP loses the route. Then re-run the script against the fixed candidate. That's the loop: edit the candidate, re-run, push only when it's clean.

What Batfish can't tell you

  • It only knows what's in the configs. It can't see link state, the hardware, or the routes your neighbours advertise unless you model them, and it doesn't know about traffic volumes.
  • Anything it doesn't parse is ignored silently in the answers. That's why the parse check comes first. In my configs, NetFlow, some SNMP and logging lines, and ip default-gateway were skipped.
  • It doesn't model NetFlow, SNMP, AAA or the management plane, so it can't tell you whether your monitoring will still work after the change.
  • The image is about 2.2 GB, and it wants a few GB of RAM for a small network. That's nothing on a server, but you'll notice it on a laptop.

What's next

Oxidized gives you the history and Batfish checks the candidate. Next, the configs need a source of truth: the next post uses NetBox or Nautobot to hold the intended state and feed Ansible, so the candidate config is generated rather than typed by hand.

Now live: NetBox as a source of truth for Ansible, with NetBox 4.7 driving the inventory and the push.

Read next