Diffing FortiGate configs the way an admin reads them — fgt-config-diff

If you have ever opened two FortiGate show full-configuration dumps in vimdiff and tried to work out what actually changed, you already know the problem. A FortiGate config is a tree — VDOMs hold sections, sections hold edit blocks keyed by name or policy ID, edit blocks hold settings, and any of those can have sub-sections nested inside. Plain diff does not see any of that. It sees lines. So a single renamed object, or a policy that moved by two positions, lights up your terminal in red and green and tells you almost nothing about what was changed in the firewall.

fgt-config-diff is the tool I wrote to fix that. It parses both files into a tree, walks the tree, and reports differences by their FortiGate path — firewall policy > 17 > srcaddr rather than “line 412”. It comes as a CLI for scripts and pipelines, and a small Flask web UI for the times you just want to paste two configs into a browser and click a button.

The repo is on GitHub at MichealGarner/fgt-config-diff. This post walks through the gap it fills, how to use it, and how it works under the hood.

The gap that plain diff leaves

A trimmed FortiGate config looks like this:

config firewall policy
    edit 1
        set name "Allow-LAN-Out"
        set srcintf "internal"
        set dstintf "wan1"
        set srcaddr "Internal-LAN"
        set dstaddr "all"
        set action accept
        set schedule "always"
        set service "ALL"
    next
    edit 17
        set name "Allow-DNS-Out"
        set srcintf "internal"
        set dstintf "wan1"
        set srcaddr "Internal-LAN"
        set dstaddr "all"
        set action accept
        set service "DNS"
    next
end

Three things break a line-oriented diff against text like this.

The blocks are hierarchical. A change to set srcaddr inside edit 17 is meaningful in the context of “policy 17, name Allow-DNS-Out”. A unified diff strips that context away — you get a - and a + line and have to scroll up to find which edit you are inside.

The blocks are order-sensitive in places where it doesn’t matter. If someone reorders edit 1 and edit 17, or adds a new edit 5 between them, every subsequent line is “different” to a line-based diff even though no admin-level change has occurred. The right unit of comparison is the edit block, keyed by its identifier, not the position of its lines on the page.

The blocks are noisy by default. show full-configuration includes every default that has ever existed on every object. Two configs from different FortiOS versions will produce thousands of unrelated diff lines from defaults that flipped, fields that were renamed, or sub-sections that gained new optional members. Almost none of those are interesting. What you want to know is: which policy changed? Which address object was added? Which interface lost an IP?

fgt-config-diff aligns nodes by (kind, name) — section name for config X, edit key for edit Y — and only emits a diff entry when the content of an aligned pair differs, or when one side has a node the other doesn’t. Reordering an edit block produces zero noise. Adding a new policy produces a single, complete entry that names the policy and lists its fields. Changing one field on one policy produces one entry that says exactly which field, on exactly which policy, and what the old and new values were.

What you get out of it

A small pair of example configs lives under examples/ in the repo. The before file declares two firewall policies and three addresses; the after file renames the box, swaps the primary DNS, adds a new DMZ address, enables UTM on policy 1, and removes policy 17 entirely.

Run the CLI and you get this:

~ section system global
    ~ hostname: "FGT-A" -> "FGT-B"
    + strong-crypto: enable
~ section system dns
    ~ primary: 8.8.8.8 -> 9.9.9.9
+ edit firewall address > Internal-DMZ
    subnet: 10.0.20.0 255.255.255.0
~ edit firewall policy > 1
    + utm-status: enable
- edit firewall policy > 17
    name: "Allow-DNS-Out"
    srcintf: "internal"
    dstintf: "wan1"
    srcaddr: "Internal-LAN"
    dstaddr: "all"
    action: accept
    service: "DNS"

Six entries. Read them top to bottom and you have a complete change log: hostname change, strong-crypto on, DNS swap, new DMZ object, UTM on policy 1, policy 17 deleted. That is the report you actually wanted.

+ is added, - is removed, ~ is modified. On a TTY the CLI colours them green / red / yellow.

Using it

Clone, install, run.

git clone https://github.com/MichealGarner/fgt-config-diff.git
cd fgt-config-diff
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -e .[web]

The [web] extra pulls in Flask for the optional web UI. If you only need the CLI in a CI job, install with pip install -e . and you get zero runtime dependencies.

The CLI takes two paths and an optional format flag:

fgt-config-diff before.conf after.conf                  # text, the default
fgt-config-diff before.conf after.conf -f json          # structured JSON
fgt-config-diff before.conf after.conf -f html -o diff.html
fgt-config-diff before.conf after.conf --only firewall  # filter to one top-level section

Exit code is 0 when the configs are identical, 1 when they differ, 2 on a parse or file error. That makes it usable as a CI gate: a config-management pipeline can render the intended config, diff it against what’s actually deployed, and fail the build on a non-zero exit code.

Three things about the flags worth calling out.

The -f json output is a flat list of records, one per change, with a path array (so ["firewall policy", "17"] rather than a string), a kind (added / removed / modified), and field_changes for modified entries (each with name, before, after). It’s there for downstream tooling — a Slack notifier, a ticket creator, an audit log. Anything that wants to act on the diff rather than read it.

The -f html -o diff.html output is a single self-contained file with inline CSS and the same colour conventions as the terminal. I use it for posting to Confluence or attaching to change tickets where a screenshot of a terminal would be ugly.

The --only filter takes a top-level section prefix and discards every entry whose path doesn’t start with it. --only firewall is the one I reach for most often: when reviewing a policy change, I do not want to see that someone also enabled strong-crypto in system global on the same commit.

If you’d rather click than type, the web UI is one command:

fgt-config-diff-web        # http://127.0.0.1:5000

Paste the before config on the left, the after config on the right, click Diff. Same colour conventions, same path-based grouping, same output as the HTML CLI exporter — it just saves you the round trip through scp.

How it works

The tool is three files: a parser, a differ, and a formatter. They are deliberately separate so the parse step can be reused for other tooling — schema validation, search, redaction — without dragging the diff logic along.

Parse: text → tree

The parser reads the FortiGate grammar — config opens a section, edit opens a record inside a section, set defines a field, unset removes one, next closes an edit, end closes a config. Sections can nest inside sections; sections can nest inside edits. The result is a tree of ConfigNode objects.

@dataclass
class ConfigNode:
    kind: str                                          # "root" | "section" | "edit"
    name: str = ""                                     # section path or edit key
    settings: dict[str, tuple[str, ...]] = ...         # ordered map of field -> tokens
    unsets: list[str] = ...                            # fields explicitly unset
    children: list["ConfigNode"] = ...                 # nested sections / edits
    raw: list[str] = ...                               # unrecognised lines, kept verbatim

Three node kinds keep the structure honest. A root node holds top-level sections — it is synthetic, not present in the file. A section corresponds to config <name>. An edit corresponds to edit <key> inside a section.

The tokeniser respects double quotes and escaped quotes, because FortiGate freely uses both:

def _tokenize(line: str) -> list[str]:
    tokens: list[str] = []
    i, n = 0, len(line)
    while i < n:
        c = line[i]
        if c.isspace():
            i += 1
            continue
        if c == '"':
            start = i
            i += 1
            while i < n:
                if line[i] == "\\" and i + 1 < n:
                    i += 2
                    continue
                if line[i] == '"':
                    i += 1
                    break
                i += 1
            tokens.append(line[start:i])
            continue
        ...

A naive line.split() would have shredded quoted policy names. Keeping the surrounding quotes in the token (rather than stripping them) preserves the visual exactness of the field in the output — a renamed policy shows as "Allow-DNS-Out" -> "Allow-DNS-Out-EU", with the quotes intact, the way an admin recognises it.

The parser is permissive about anything it doesn’t understand. Comments are stripped, unknown directives are stored verbatim on the parent node’s raw list, and a missing closing keyword raises a ParseError with the line number and context. Unknown directives still show up in the diff — they are compared as raw strings — so a config dialect change between FortiOS versions degrades gracefully rather than swallowing edits.

Diff: align by (kind, name)

The differ walks two trees in parallel. At every aligned pair it does two things: a field-level comparison on the node’s own settings, and a recursive descent into children, aligning them by (kind, name) rather than by position.

a_index = {(c.kind, c.name): c for c in a.children}
b_index = {(c.kind, c.name): c for c in b.children}

for c in a.children:
    key = (c.kind, c.name)
    if key not in b_index:
        out.append(DiffEntry(kind=REMOVED, ...))
    else:
        _diff_pair(c, b_index[key], ...)

for c in b.children:
    if (c.kind, c.name) not in a_index:
        out.append(DiffEntry(kind=ADDED, ...))

That’s the whole alignment story. Edit 17 in before aligns to edit 17 in after, regardless of whether they are at line 12 or line 412 of either file. If 17 is missing on one side, you get a single REMOVED entry that summarises the whole subtree — its fields, its child count — instead of one diff line per setting.

A node with both sides present can have three things change: its own field values, its raw passthroughs (unrecognised directives), and its children. The first two produce a single MODIFIED entry on this node. The third recurses. So a change to set srcaddr on policy 17 produces one entry; a change that adds a new edit underneath it produces another. They stay separate, which keeps the output flat and grep-friendly.

The --only filter is implemented at the recursion gate, not as a post-filter on the result list. That means filtering by firewall doesn’t just hide non-firewall entries from the output — it skips the descent into system and log and vpn entirely. On a 10MB config that’s the difference between a millisecond and a few hundred.

Format: the same data, three ways

The formatter takes the list of DiffEntry records and renders them as text, JSON, or HTML. The text output is what most of this post has shown — glyph, kind, path, and a child block of fields. Color is opt-in (auto on a TTY, off when redirected or when --no-color is passed):

_KIND_GLYPH = {DiffKind.ADDED: "+", DiffKind.REMOVED: "-", DiffKind.MODIFIED: "~"}

color_for = {
    DiffKind.ADDED:    "\x1b[32m" if color else "",
    DiffKind.REMOVED:  "\x1b[31m" if color else "",
    DiffKind.MODIFIED: "\x1b[33m" if color else "",
}

JSON is the same data shape with no rendering decisions made — paths are arrays, field changes are (name, before, after) triples, glyphs aren’t included. HTML is a self-contained page with inline CSS and the same colour scheme as the terminal.

That separation pays off whenever someone asks for a slightly different view. A Markdown formatter for posting into a chat channel, a CSV exporter for an audit spreadsheet, a “summary only” formatter that prints one line per entry — none of them need to touch the parser or differ. They are pure functions of list[DiffEntry].

Why I wrote it

Three reasons, in order of how often they came up.

The first is change reviews. When a colleague tells me they made a change on a FortiGate, I want to see exactly what changed, not what their intent was. show | compare on the box itself is excellent if you have access at the right moment, but if the change has already been applied and the previous backup is sitting in a Git repo somewhere, you need an offline tool. git diff on the raw config file gives you an unreadable wall of red and green; fgt-config-diff gives you “policy 17 was deleted, policy 1 had UTM enabled”. That is the version a reviewer can sign off on.

The second is config drift detection. If you run any kind of automation against FortiGates — Ansible, FortiManager, custom Python — you eventually want to know whether what’s actually in /var/config/saved-config matches what the templates would produce. Render the template, fetch the live config, run fgt-config-diff, fail the CI job if the exit code isn’t zero. The exit-code contract was put in deliberately for that workflow.

The third is review-by-paste, which is what the web UI is for. Sometimes you don’t have a CLI handy — you’re in someone else’s environment, on a Windows jump host with no Python, or you’re walking a colleague through a change. The web UI runs on 127.0.0.1, takes two text areas, and gives you the same output as the CLI without anyone needing to install anything beyond the local Python they already have. It’s a small piece of code, but it removes friction at exactly the moment when friction loses you a reviewer.

A fourth, quieter reason is that writing this kind of tool is the right way to learn a config grammar properly. By the time you’ve written a parser that handles the messy bits — escaped quotes inside policy names, sub-sections nested inside edits, unset directives that have to undo previous sets — you understand FortiGate config in a way that reading the docs alone never quite gets you to. That part doesn’t ship in the repo, but it’s the part I got most out of.

Where to take it next

A few obvious extensions that would slot cleanly into the existing structure:

  • Multi-VDOM awareness. The parser already handles config vdom blocks because they use the same grammar as everything else, but the formatter doesn’t render the VDOM context at the top of each entry path. A two-line change to _path_label.
  • Field whitelists/blacklists. A --ignore-fields auditlog,timestamp flag for the cases where you genuinely don’t care about a noisy field (e.g. comparing two configs taken hours apart).
  • Reverse-engineering CLI commands. Given a MODIFIED or ADDED entry, it’s mechanical to emit the FortiGate CLI commands that would produce it. That turns a diff into a runbook.

The repo is at github.com/MichealGarner/fgt-config-diff. MIT-licensed. Issues and pull requests welcome — particularly if you have a config dialect that the parser chokes on.