Another migration post-mortem, you ask? Not this time. This one's about the quiet horror that lives *after* the migration, in the land of "operational tweaks" and "minor config adjustments." You know the drill: you finally get the new FortiGate cluster stood up, data flows, everyone breathes a sigh of relief. Then the first change window arrives. You make your little `set` commands, you hit save, and a cold dread settles in. *What else did that change?*
I've spent more hours than I care to admit eyeballing `show full-configuration` outputs, trying to spot the diff between version A and version B, only to miss that one critical `set` command buried in a firewall policy object because the human brain is terrible at this. So, I finally built a thing to do it for me. It's not pretty, but it is brutally effective.
The core idea is simple: pull the configs (via SSH, or from backups), normalize them a bit (strip out timestamps, dynamic data), and then run a proper diff. The value isn't in the diff itself, but in the presentation—isolating exactly which stanzas changed, and showing the before/after side-by-side.
Here's the heart of the script. It uses `paramiko` for SSH and `difflib` for the heavy lifting.
```python
import paramiko
import re
from difflib import unified_diff
def normalize_config(config_text):
"""Strip lines that are pure noise for comparison."""
lines = config_text.splitlines()
cleaned = []
for line in lines:
# Remove lines with edit timestamps, build numbers, etc.
if re.match(r'^s*set (time|uuid|last-modified|last-policy-index)', line):
continue
cleaned.append(line)
return 'n'.join(cleaned)
def get_config_via_ssh(host, user, key_path):
"""Grab running config from a FortiGate."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=host, username=user, key_filename=key_path)
stdin, stdout, stderr = client.exec_command('show full-configuration')
config = stdout.read().decode()
client.close()
return normalize_config(config)
# Imagine you've pulled config 'before' and 'after' a change
before = get_config_via_ssh('fw-primary', 'admin', '/path/to/key')
after = get_config_via_ssh('fw-primary', 'admin', '/path/to/key')
diff = unified_diff(before.splitlines(), after.splitlines(), lineterm='')
for line in diff:
# Highlight adds/removes in terminal output
if line.startswith('+'):
print(f"33[92m{line}33[0m") # Green
elif line.startswith('-'):
print(f"33[91m{line}33[0m") # Red
else:
print(line)
```
The real utility comes from wrapping this in something that can:
* Handle multiple devices (the active-passive cluster headache).
* Diff specific sections only (e.g., only `config firewall policy`).
* Output to something more permanent than a terminal (HTML for change tickets, I'm looking at you).
Pitfalls this has saved me from? Let's list a few:
* A "simple" DNS server update that also quietly changed the `set source-ip` parameter on a VIP because the lines were adjacent in the stanza.
* A policy edit that, due to FortiGate's magical re-ordering, inadvertently changed the sequence of rules, affecting traffic flow.
* Spotting when a colleague's "backout" did not, in fact, return the config to its original state, leaving a stray permit rule behind.
It's a simple tool. It's a dumb tool. But in the chaos of post-migration stabilization, where every change feels like you're poking a sleeping dragon with a stick, it gives you a slightly longer stick. Now if only it could explain *why* the NGFW policy decided to reinterpret my perfectly good rule.
-warrior
Expect the unexpected