If you've spent any time configuring user authentication on... Full Story
By Manny Fernandez
September 24, 2026
SED and AWK for FortiGate Configs: Surgical Edits, Inventories, and Migrations from the Shell
Read, audit, rewrite, and generate FortiOS configuration with two tools you already have.
Executive Summary
Objective: Use two tools that ship on every Linux box and every Mac, sed and awk, to read, audit, rewrite, and generate FortiGate configuration safely. By the end you will be able to pull a single table out of a 40,000-line backup, change a value inside one block without touching the rest of the file, inventory address objects to CSV, find unused objects, remap interfaces for a hardware migration, bulk-generate CLI from a spreadsheet, and prove that your edited file is still structurally valid before it goes anywhere near a firewall.
Target audience: FortiGate administrators, SEs, and consultants who live in the CLI and are tired of scrolling through show full-configuration output or hand-editing backups in a text editor.
The core idea is simple. A FortiOS config file is not free-form text. It is a strictly indented tree of config, edit, set, next, and end statements. Once you treat it that way, sed and awk become precise instruments instead of blunt ones.
Prerequisites and Architecture
Assumed knowledge
- Comfort with the FortiOS CLI hierarchy (
config/edit/set/next/end) - Basic shell usage: pipes, redirection, and quoting
- Basic regular expressions (anchors
^and$,.*, character classes)
Lab requirements
| Component | Purpose | Notes |
|---|---|---|
FortiGate backup (.conf) |
Source data | Taken from System > Configuration > Backup or execute backup config |
| Linux or macOS workstation | Where all editing happens | Never edit on the only copy |
GNU sed |
Stream editing | macOS ships BSD sed; brew install gnu-sed gives you gsed |
awk (gawk, mawk, or BSD awk) |
Parsing and reporting | Every snippet below is POSIX awk and was tested with mawk |
diff |
Change review | Built in everywhere |
| Lab addressing | Examples | 198.18.0.0/15 for public/transit, 10.0.0.0/16 for internal LANs |
Why FortiOS configs are easy to parse
Look at a small slice of a backup:
#config-version=FGT120G-7.4.8-FW-build2795-250523:opmode=0:vdom=0:user=admin
#conf_file_ver=12345678901234
#buildno=2795
#global_vdom=1
config system dns
set primary 198.18.0.53
set secondary 198.18.1.53
end
config firewall address
edit "SRV-WEB01"
set subnet 10.0.20.10 255.255.255.255
set comment "Web server"
next
edit "NET-USERS"
set subnet 10.0.10.0 255.255.255.0
config tagging
edit "zone"
set category "site"
next
end
next
end
Three properties make this friendly to text tools:
- Top-level tables start in column 0.
config firewall addressand its closingendalways sit at the left margin, so^config firewall address$and^end$bracket the table exactly. - Indentation is exactly 4 spaces per level. Table entries (
edit) are at 4 spaces, their settings at 8, nested sub-tables deeper. Anchoring on indentation (^ edit) lets you ignore nestededit/nextpairs like theconfig taggingblock above. - Object names are double-quoted. Splitting a line on
"gives you clean object names, including names with spaces, in the even-numbered fields.
Keep those three rules in mind. Every technique below is built on them.
Multi-VDOM note: In a multi-VDOM backup, each VDOM’s configuration is wrapped in
config vdom/edit <name>and the tables inside are indented. The column-0 anchors in this post then match only global tables. Either extract one VDOM first (Step 2) and de-indent it, or widen the anchors, for example^ config firewall address$.
Step-by-Step Implementation Workflow
Step 1: Take a clean backup and a working copy
Goal: Never edit the only copy, and make every change reviewable.
Action: Pull the backup, normalize line endings, and keep the original read-only.
# From the FortiGate CLI (TFTP example)
execute backup config tftp MIA-FW01.conf 10.0.0.25
# On the workstation
cp MIA-FW01.conf MIA-FW01.orig.conf
chmod 444 MIA-FW01.orig.conf
sed 's/\r$//' MIA-FW01.orig.conf > fgt.conf
sha256sum MIA-FW01.orig.conf fgt.conf
The sed 's/\r$//' strips Windows carriage returns. A backup that has passed through a Windows share or email client often picks up CRLF endings, and every $ anchor in this post will silently fail to match until you remove them.
Portable in-place editing: GNU sed accepts sed -i 's/a/b/', BSD sed on macOS requires sed -i '' 's/a/b/'. The form sed -i.bak 's/a/b/' file works on both and leaves a backup. Use it.
GUI verification: None needed yet. Confirm the file opens and the first line begins with #config-version= and names the correct model and build.
Step 2: Extract one table with a sed range
Goal: Read one table without scrolling the whole file.
Action: A sed address range /start/,/end/ prints everything between two matching lines, inclusive.
sed -n '/^config system dns$/,/^end$/p' fgt.conf
Output:
config system dns
set primary 198.18.0.53
set secondary 198.18.1.53
end
-n suppresses default output, and p prints only the range. Because both anchors are column 0, a nested end inside the table (like the config tagging block) is indented and cannot close the range early.
Handy variations:
# Every table name in the file, in order
sed -n 's/^config //p' fgt.conf
# Just the firewall policy table, saved for review
sed -n '/^config firewall policy$/,/^end$/p' fgt.conf > policy.txt
# One VDOM out of a multi-VDOM backup (adjust the VDOM name)
sed -n '/^edit "BRANCH"$/,/^next$/p' fgt.conf
Step 3: Change a value only inside one table
Goal: Replace a value without touching identical text elsewhere.
Action: Prefix the substitution with a range. The s command then runs only on lines inside that range.
sed -i.bak '/^config system dns$/,/^end$/ s/^ set primary .*/ set primary 10.0.0.53/' fgt.conf
Without the range, s/set primary .*/.../ would also rewrite set primary lines in other tables (DNS database, HA, SD-WAN members, and so on). Scoping is the difference between a surgical edit and an outage.
To delete a whole object, nest a second range inside the first. This removes only the OLD-SRV entry from the address table:
sed -i.bak '/^config firewall address$/,/^end$/{
/^ edit "OLD-SRV"$/,/^ next$/d
}' fgt.conf
The 4-space anchors matter here: ^ next$ matches the entry’s own closing next, not the 12-space next of a nested sub-table.
GUI verification: Not applicable offline. Verify with diff -u MIA-FW01.orig.conf fgt.conf and confirm the diff contains only the lines you intended.
Step 4: Inventory address objects to CSV with awk
Goal: Turn the address table into something you can sort, filter, or paste into a spreadsheet.
Action: awk works as a small state machine: set a flag when you enter the table, collect fields per entry, print on the entry’s next.
awk '
/^config firewall address$/ { blk = 1; next }
blk && /^end$/ { blk = 0; next }
!blk { next }
/^ edit / { split($0, q, "\""); name = q[2]; type = "ipmask"; val = ""; next }
/^ set type / { type = $3 }
/^ set subnet / { val = $3 "/" $4 }
/^ set fqdn / { split($0, q, "\""); val = q[2] }
/^ set start-ip / { val = $3 }
/^ set end-ip / { val = val "-" $3 }
/^ next$/ { printf "\"%s\",%s,%s\n", name, type, val }
' fgt.conf > addresses.csv
Output:
"SRV-WEB01",ipmask,10.0.20.10/255.255.255.255
"NET-USERS",ipmask,10.0.10.0/255.255.255.0
"FQDN-UPDATES",fqdn,updates.example.com
"RANGE-PRINTERS",iprange,10.0.30.10-10.0.30.40
"OLD-SRV",ipmask,10.0.20.99/255.255.255.255
Two details worth calling out. First, type defaults to ipmask because FortiOS omits settings that are at their default value, so a subnet object has no set type line at all. Second, split($0, q, "\"") splits on double quotes, which puts the object name in q[2] even when it contains spaces.
Step 5: Find address objects nothing references
Goal: Build a cleanup candidate list.
Action: Read the file twice. Pass one records every address name. Pass two records every quoted token used in a set line outside the address table. Anything defined but never used is a candidate.
awk '
FNR == NR {
if ($0 ~ /^config firewall address$/) inblk = 1
else if (inblk && $0 ~ /^end$/) inblk = 0
else if (inblk && $0 ~ /^ edit /) { split($0, q, "\""); defined[q[2]] = 1 }
next
}
/^config firewall address$/ { skip = 1 }
skip && /^end$/ { skip = 0; next }
skip { next }
$1 == "set" {
n = split($0, q, "\"")
for (i = 2; i <= n; i += 2) used[q[i]] = 1
}
END { for (a in defined) if (!(a in used)) print a }
' fgt.conf fgt.conf | sort
FNR == NR is only true while awk reads the first file argument, which is the classic idiom for a two-pass job. Passing the same file twice gives you both passes.
Treat the output as candidates, not a delete list. Factory objects such as none will appear, and references can hide in places this heuristic does not see (other VDOMs, FortiManager-managed ADOM objects, automation stitches that match by name). Confirm each one with the Ref. column in Policy & Objects > Addresses before deleting.
Step 6: Remap interfaces for a hardware migration
Goal: Move a config between models (for example, a 100F to a 120G) where port names change.
Action: Put the mapping in a two-column file and let awk replace only exact, quoted tokens.
map.txt:
port1 port9
port10 port1
awk 'FNR == NR { m[$1] = $2; next }
{
n = split($0, p, "\"")
out = p[1]
for (i = 2; i <= n; i++) {
tok = p[i]
if (i % 2 == 0 && (tok in m)) tok = m[tok]
out = out "\"" tok
}
print out
}' map.txt fgt.conf > migrated.conf
Why not just sed 's/port1/port9/g'? Two reasons, and both will burn you:
- Substring collisions:
s/port1/port9/also rewritesport10,port11, andport12intoport90,port91, andport92. - Chained swaps: Running
s/"port1"/"port9"/gthens/"port10"/"port1"/gis fine, but the reverse order (or any mapping where a target is also a source) turns earlier replacements into later ones.
The awk version compares each quoted token as a whole string and replaces it at most once in a single pass, so neither problem can happen.
GUI verification: After loading the migrated config on the new unit, check Network > Interfaces and confirm addressing, and check that policies show the expected source and destination interfaces.
Step 7: Flatten the config for meaningful diffs
Goal: Compare two configs where a bare set dstintf "port1" line tells you nothing about which policy it belongs to.
Action: Keep a stack of the config and edit lines above each setting and print the full path on every set line. Save this as flatten.awk:
{ sub(/\r$/, ""); line = $0; sub(/^ +/, "", line) }
line ~ /^config / || line ~ /^edit / { stack[++d] = line; next }
line == "next" || line == "end" { d--; next }
line ~ /^(set|unset) / {
path = ""
for (i = 1; i <= d; i++) path = path stack[i] " | "
print path line
}
awk -f flatten.awk fgt.conf | head -3
config system global | set hostname "MIA-FW01"
config system global | set timezone "America/New_York"
config system dns | set primary 198.18.0.53
Now diff two versions with full context on every line:
diff <(awk -f flatten.awk fgt.conf | sort) <(awk -f flatten.awk migrated.conf | sort)
< config firewall policy | edit 1 | set dstintf "port1"
---
> config firewall policy | edit 1 | set dstintf "port9"
The flattened format also greps beautifully. grep 'set action deny' flat.txt gives you every deny policy with its ID in one line each.
Step 8: Generate CLI from a CSV
Goal: Stop hand-typing 200 address objects.
Action: awk reads the CSV and prints valid FortiOS CLI, including a group containing everything it created.
addrs.csv:
name,subnet,comment
SRV-DB01,10.0.20.20/32,Database primary
SRV-DB02,10.0.20.21/32,Database replica
NET-GUEST,10.0.50.0/24,Guest Wi-Fi
awk -F, '
BEGIN { print "config firewall address" }
NR == 1 { next }
{
printf " edit \"%s\"\n", $1
printf " set subnet %s\n", $2
printf " set comment \"%s\"\n", $3
printf " next\n"
members = members " \"" $1 "\""
}
END {
print "end"
print "config firewall addrgrp"
print " edit \"GRP-DATABASE\""
print " set member" members
print " next"
print "end"
}' addrs.csv > addrs.cli
Output:
config firewall address
edit "SRV-DB01"
set subnet 10.0.20.20/32
set comment "Database primary"
next
edit "SRV-DB02"
set subnet 10.0.20.21/32
set comment "Database replica"
next
edit "NET-GUEST"
set subnet 10.0.50.0/24
set comment "Guest Wi-Fi"
next
end
config firewall addrgrp
edit "GRP-DATABASE"
set member "SRV-DB01" "SRV-DB02" "NET-GUEST"
next
end
FortiOS accepts CIDR notation on set subnet and converts it to a dotted mask on save. This simple parser splits on commas, so keep commas out of the comment column or switch to a tab-separated file with -F'\t'.
Push a delta, not a whole config. A generated .cli fragment like this is the safest way to apply bulk changes. Paste it into the CLI console, or run it as a configuration script from the admin menu (Configuration > Scripts). Restoring an entire edited backup reboots the unit and replaces everything, so reserve that for migrations.
Step 9: Prove the file is still structurally valid
Goal: Catch a deleted next or a missing end before FortiOS does.
Action: Count openings and closings. Save as balance.awk:
{ sub(/\r$/, "") }
$1 == "config" { c++ }
$1 == "end" { c-- }
$1 == "edit" { e++ }
$1 == "next" { e-- }
c < 0 || e < 0 {
printf "Underflow at line %d: %s\n", NR, $0
bad = 1; if (c < 0) c = 0; if (e < 0) e = 0
}
END {
if (c || e) { printf "Unbalanced: config/end=%d edit/next=%d\n", c, e; bad = 1 }
if (!bad) print "OK: blocks balanced"
exit bad
}
The script exits non-zero on failure, so it drops straight into a pipeline or a pre-push check:
awk -f balance.awk fgt.conf && echo "safe to review"
Verification and Validation
Run these three checks on every edited file before it leaves your workstation.
# 1. Structure
awk -f balance.awk fgt.conf
# 2. Header intact (model and build must match the target unit)
head -1 fgt.conf
# 3. Only the intended changes
diff -u MIA-FW01.orig.conf fgt.conf
Expected success output:
OK: blocks balanced
#config-version=FGT120G-7.4.8-FW-build2795-250523:opmode=0:vdom=0:user=admin
followed by a diff that contains nothing you did not mean to change.
After applying a script or restoring a config on the FortiGate, ask FortiOS what it rejected:
diagnose debug config-error-log read
An empty result means every line was accepted. Anything listed there was skipped, and the lines after it in the same block may have landed in an unexpected context. Then spot-check the objects you touched:
show firewall address SRV-DB01
show firewall addrgrp GRP-DATABASE
show system dns
Troubleshooting and Gotchas
Anchors never match
Symptom: sed -n '/^config system dns$/,/^end$/p' prints nothing, or prints to the end of the file.
Cause: CRLF line endings (the invisible \r sits before $), or a multi-VDOM file where the table is indented.
Resolution:
# Look for carriage returns (shows \r before \n)
head -3 fgt.conf | od -c | head
# Strip them
sed -i.bak 's/\r$//' fgt.conf
# Check how the table is indented
grep -n 'config firewall address$' fgt.conf
A replacement hit more than you intended
Symptom: port10 became port90, or a DNS change also changed an SD-WAN or HA setting.
Cause: An unscoped s///g, or a pattern that matches substrings.
Resolution: Scope every substitution with a table range (Step 3), match whole quoted tokens (s/"port1"/"port9"/g at minimum, or the awk mapper from Step 6), and always review with the flattened diff from Step 7.
The restored config loads but secrets or settings are missing
Symptom: VPN pre-shared keys, admin passwords, or LDAP bind passwords fail after a restore; some settings silently revert to defaults.
Cause: ENC strings were edited or copied between units that use different private-data-encryption keys, or FortiOS rejected lines that were valid on the source model or build.
Resolution: Never modify set ... ENC <string> values with sed or awk. If private-data-encryption is enabled on the source, the target needs the same key or you must re-enter the secrets. Keep the header line untouched, restore only onto the same model family and firmware build (upgrade or downgrade first if needed), and read diagnose debug config-error-log read immediately after the unit comes back up.
Quick Reference
| Task | Command |
|---|---|
| Print one table | sed -n '/^config X$/,/^end$/p' fgt.conf |
| List all tables | sed -n 's/^config //p' fgt.conf |
| Scoped replace | sed -i.bak '/^config X$/,/^end$/ s/old/new/' fgt.conf |
| Delete one entry | sed '/^config X$/,/^end$/{ /^ edit "NAME"$/,/^ next$/d }' (GNU sed) |
| Strip CRLF | sed -i.bak 's/\r$//' fgt.conf |
| Object name from a line | split($0, q, "\""); name = q[2] |
| Two-pass awk | awk 'FNR == NR { ...; next } { ... }' file file |
| Flatten for diff | awk -f flatten.awk fgt.conf | sort |
| Validate structure | awk -f balance.awk fgt.conf |
| Check what FortiOS rejected | diagnose debug config-error-log read |
Recent posts
-
-
DNS is one of those technologies that quietly underpins... Full Story
-
BGP issues on FortiGate firewalls usually trace back to... Full Story
-
Every time your laptop talks to your router, a... Full Story
-
If you've spent any time configuring NAT on a... Full Story
-
If you have spent any time configuring firewall policies... Full Story
-
High availability on FortiGate is one of those features... Full Story
-
If you've configured SD-WAN on a FortiGate, you've almost... Full Story
-
FortiLink is the management protocol that turns a FortiSwitch... Full Story
-
FortiSwitches are pretty rock solid from Mean Time Between... Full Story
-
This is a quicky tip. Have you ever gone... Full Story
-
DNS is one of those quiet pieces of internet... Full Story
-
This article is an updated version of the previous... Full Story
-
You will add ns2 as a secondary (slave) BIND9... Full Story
-
In the process of deploying my lab, I needed... Full Story
-
RFC 8805, used to be known as Self-Correcting IP... Full Story
-
Years back, I wrote an article about certificate pinning. ... Full Story
-
FortiGates have the ability to send alerts to Microsoft... Full Story
-
In this post, I am going to walk through... Full Story
-
Troubleshooting VoIP on a FortiGate can feel like trying... Full Story
-
Prior to FortiOS 7.0, there were three commands to... Full Story
-
In this post, I am going to go over... Full Story
-
What we are going to do: We are going... Full Story
-
Choosing between FGCP (FortiGate Clustering Protocol) and FGSP (FortiGate... Full Story
-
Creating a VLAN on macOS (The "Pro" Move) A... Full Story
-
This blog post explores the logic behind how macOS... Full Story
-
Pretty Fly for a Wi-Fi Tell My Wi-Fi Love... Full Story
-
Part of my daily gig is creating BoMs (Bill-of-Materials)... Full Story
-
ICMP introduces several security risks, but careful filtering, rate... Full Story
-
The command diag debug application dhcps -1 enables full... Full Story
-
In the world of FortiOS, execute tac report is... Full Story
-
LLDP; What is it The Link Layer Discovery Protocol... Full Story
-
What it actually does When you run diagnose fdsm... Full Story
-
Monkey Bites are bite-sized, high-impact security insights designed for... Full Story
-
I have run macOS in macOS with Parallels but... Full Story
-
Don't be confused with my other FortiNAC posts where... Full Story
-
This is the third session in a multi-part article... Full Story
-
Today I was configuring key-based authentication on a FortiGate... Full Story
-
Netcat, often called the "Swiss Army knife" of networking,... Full Story
-
At its core, IEEE 802.1X is a network layer... Full Story
-
In case you did not see the previous FortiNAC... Full Story
-
This is our 5th session where we are going... Full Story
-
Now that we have Wireshark installed and somewhat configured,... Full Story
-
The Philosophy of Packet Analysis Troubleshooting isn't about looking... Full Story
-
Read, audit, rewrite, and generate FortiOS configuration with two... Full Story
-
Executive Summary Objective: Get HopMatrix installed, verified, and working... Full Story
-
Executive Summary Objective: Stand up RIPv2 between two FortiGates,... Full Story