OPNsenseXLII · API and AutomationAPI operations
API firewall rule automation — building rules safely from a script
What you'll learn
- Build an API workflow that adds a rule to the automation rule set with validation and rollback paths
- Write a correct flat rule payload and recognise the ordering invariants a script must respect
- Verify that a new rule reached pf after the apply call, distinguishing success from silent failure
- Produce an audit trail that ties every API rule change to the credential that made it
Prerequisites
Verified against OPNsense 25.x · FreeBSD 14.x · PF (FreeBSD packet filter) FreeBSD 14.x · Unbound 1.20+ · Kea DHCP OPNsense 25.x plugin · WireGuard in-kernel + OPNsense plugin · strongSwan (IPsec plugin) OPNsense 25.x plugin · OpenVPN 2.6.x · Suricata 7.x · 2026-08-14
A firewall rule is the most consequential object a script can add to an OPNsense firewall. The rule’s correct evaluation order, its anti-lockout implications, and the difference between pass, block and reject are all the same as in the GUI. What the API adds is automation at scale — a CI pipeline that promotes a rule, an Ansible role that configures a fleet, a ticketing system that opens a port when a change is approved. The lesson here is how to keep that automation safe.
This lesson walks the full lifecycle of an API-driven rule change: the search-then-add-then-verify pattern, the rule-ordering invariants, the verification step that distinguishes success from silent failure, and the audit trail that ties every change to the credential that made it.
The search-then-add-then-apply-then-verify pattern
Automation writes to the automation rule set — Firewall → Automation → Filter, reached through /api/firewall/filter/*. That set is separate from the per-interface rules an operator edits by hand, and it is the only firewall rule set with a model-backed API. The disciplined pattern is four steps:
- Search.
GET /api/firewall/filter/searchRulereturns the existing rules, their UUIDs and their order. The new rule’s position relative to existing rules is decided before any write happens. - Add.
POST /api/firewall/filter/addRulewith a JSON body containing the new rule. The framework assigns a UUID; the response includes it. - Apply.
POST /api/firewall/filter/apply. Until this call runs, the rule exists in/conf/config.xmland does nothing to traffic. - Verify.
GET /api/firewall/filter/getRule/{uuid}returns the saved rule;pfctl -srshows it loaded into pf.
Each step has a defined outcome: success proceeds to the next step; a failure aborts and rolls back to the pre-change backup. There is no “best effort” rule automation; either the rule is applied or the change-set is rolled back.
$ curl -sk -u "$KEY:$SECRET" 'https://localhost/api/firewall/filter/searchRule' | jq '{total, last: (.rows | map(select(.legacy != true)) | .[-1] | {uuid, sequence, description})}'{
"total": 12,
"last": {
"uuid": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"sequence": "40",
"description": "CHG-2026-1180 monitoring scrape"
}
}Illustrative output
Rule payload anatomy
The payload is one flat object under a single rule key. There are no nested source or destination sub-objects — source and destination are separate top-level fields with source_/destination_ prefixes. A typical outbound allow:
{
"rule": {
"enabled": "1",
"sequence": "50",
"action": "pass",
"quick": "1",
"interface": "lan",
"direction": "in",
"ipprotocol": "inet",
"protocol": "any",
"source_net": "lan",
"destination_net": "any",
"log": "1",
"description": "CHG-2026-1314: LAN hosts to internet"
}
}
The fields that surprise an author coming from the GUI:
description, notdescr. The legacy per-interface rules usedescrinconfig.xml; the automation model usesdescription. Sendingdescrdoes not error — the model ignores unknown fields — so the rule saves with an empty description and the mistake is only visible later.source_netanddestination_nettake a host, a network in CIDR, an alias name, orany. Ports are separate fields,source_portanddestination_port, and are left empty to mean “all ports”.source_notanddestination_notinvert the match.interfaceis a comma-separated list of interface names, and it may be empty. An emptyinterfacemakes the rule floating — it is evaluated on every interface — which is rarely what an automation intends and is easy to produce by omitting the field.quickdefaults to1. A non-quick rule does not stop evaluation when it matches; the last matching rule wins instead of the first. Most rules want the default.directiondefaults toinand acceptsin,out, orany.ipprotocoldefaults toinetand acceptsinet,inet6, orinet46.sequenceorders the rule within the automation set; it defaults to1, so a script that never sets it produces a pile of rules all claiming the same position.logdefaults to0. An automation-created rule that nobody can see in the log is a rule nobody can debug, so set it explicitly.
NAT is not part of this model. Each kind of translation has its own controller under the same module — /api/firewall/source_nat/* for outbound NAT, /api/firewall/d_nat/* for port forwards, /api/firewall/one_to_one/* and /api/firewall/npt/* for the address-mapping cases — and each follows the same search/add/set/del/apply shape as the filter controller. A filter rule neither performs nor suppresses translation.
$ curl -sk -u "$KEY:$SECRET" -X POST -H "Content-Type: application/json" https://localhost/api/firewall/filter/addRule -d '{"rule":{"enabled":"1","sequence":"50","action":"pass","quick":"1","interface":"lan","direction":"in","ipprotocol":"inet","protocol":"any","source_net":"lan","destination_net":"any","log":"1","description":"CHG-2026-1314 outbound"}}'{
"result": "saved",
"uuid": "f1e2d3c4-b5a6-7890-1234-567890abcdef"
}Illustrative output
Rule-ordering invariants
Three invariants that every safe rule-automation script must respect:
sequenceis the position, and it has a default. Order within the automation set comes from thesequencefield, sorted ascending. Its model default is1, so a script that never sets it stacks every rule it has ever created at the same position, in whatever order the configuration happens to hold them. Leave gaps — 10, 20, 30 — so a later rule can be inserted between two existing ones without renumbering.- Quick rules stop evaluation; the first match wins. A
pass any → anyabove ablockfor a specific pair makes the block unreachable. Before adding a rule,searchRuleand check what already matches the traffic the new rule is about. Adding a correct rule below an existing general allow changes nothing. - An empty
interfacemeans floating. A rule with no interface is evaluated everywhere, and floating rules are placed ahead of interface-bound rules. A script that omitsinterfacebecause it seemed optional has not written a LAN rule; it has written a rule that applies to WAN as well.
addRule does not choose a position for you — it stores whatever sequence you send, defaulting to 1. To place a rule between two existing ones, read their sequences with searchRule and pick a number in the gap; if there is no gap, setRule/{uuid} on the neighbours to renumber them first.
Verification: distinguishing success from silent failure
Three verification steps after each change-set:
- The rule is in the configuration.
GET /api/firewall/filter/getRule/{uuid}returns the rule with the expected values. If it does not, the write did not land and there is nothing to apply. - The apply succeeded.
POST /api/firewall/filter/applyreturns a status body. A ruleset that fails to compile — a bad alias reference, an interface that no longer exists — is reported here, and pf keeps running the previous ruleset. - pf sees the rule.
pfctl -srshows it in the running ruleset. Search by the rule’s UUID, not by its description: OPNsense attaches the automation rule’s UUID as the pflabel, whereas the description is emitted as a comment and pf does not retain comments.pfctl -sr | grep <description>finds nothing even when everything worked, which is a confusing way to conclude a change failed.
$ curl -sk -u "$KEY:$SECRET" -X POST https://localhost/api/firewall/filter/apply; sleep 2; pfctl -sr | grep 'f1e2d3c4-b5a6-7890-1234-567890abcdef'{
"status": "OK
"
}
pass in quick on igb1 inet from 192.0.2.0/24 to any flags S/SA keep state label "f1e2d3c4-b5a6-7890-1234-567890abcdef"Illustrative output
Audit trail
Every rule change leaves a trail in three places:
- The authentication log. The web server and framework log the authenticated user for each request. Because an API key belongs to a user, the log names the user, not the key — which is the argument for one service account per automation tool rather than one shared account.
- The configuration history. OPNsense keeps a versioned history of
/conf/config.xmlunder/conf/backup/, with a new revision written on every change and the change’s origin recorded against it.GET /api/core/backup/backups/thislists the revisions andGET /api/core/backup/diff/this/{rev1}/{rev2}returns the difference between two of them — the precise change, as the firewall recorded it. - The running ruleset.
pfctl -sris what pf is enforcing right now. Comparing it against the configuration is what catches a save that was never applied.
A post-change audit report combines the three: who made the change, what the configuration diff shows, and what the firewall is running. A script that omits the audit trail is invisible to the post-incident reviewer.
Summary
- Automation writes to the automation rule set through
/api/firewall/filter/*:searchRule,addRule,setRule/{uuid},delRule/{uuid}, thenapply. - Save and apply are separate calls.
{"result": "saved"}means the configuration changed and pf did not. Batch many saves behind oneapply. - The payload is one flat object under
rule—source_net,destination_net,source_port,destination_port,description— with no nested source or destination sub-objects and no NAT fields. - Order comes from
sequence, which defaults to1. Leave gaps. An emptyinterfacemakes the rule floating rather than interface-bound. - Verify against pf by the rule’s UUID, which is its pf label. The description never reaches pf, so grepping for it always fails.
- The audit trail is the authentication log, the versioned configuration history under
/conf/backup/, and the running ruleset.
Knowledge check · 4 questions
Q1. addRule returned {"result": "saved"} with a UUID, and getRule returns the rule with the values you sent. pfctl -sr does not show it. What do you check first?
Q2. A script can delete the anti-lockout rule with POST /api/firewall/filter/delRule/{uuid}, so every rule-automation script needs a guard against doing so.
Q3. Which of the following are safe-add practices for API-driven firewall rule automation? Select all that apply.
Q4. A script posts {"rule":{"action":"pass","source":{"address":"10.0.0.0/8"},"destination":{"address":"any"},"descr":"CHG-1"}} to addRule. The call returns saved, apply succeeds, and the rule does nothing useful. Why?
Passing score: 75%. Answers are checked in this browser.