Skip to main content
RunBook Academy

OPNsenseXLII · API and AutomationAPI operations

API firewall rule automation — building rules safely from a script

Advanced⏱ ~16 mincurljqpfctl

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

Not yet marked complete on this device.

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:

  1. Search. GET /api/firewall/filter/searchRule returns the existing rules, their UUIDs and their order. The new rule’s position relative to existing rules is decided before any write happens.
  2. Add. POST /api/firewall/filter/addRule with a JSON body containing the new rule. The framework assigns a UUID; the response includes it.
  3. Apply. POST /api/firewall/filter/apply. Until this call runs, the rule exists in /conf/config.xml and does nothing to traffic.
  4. Verify. GET /api/firewall/filter/getRule/{uuid} returns the saved rule; pfctl -sr shows 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.

Read-only / Safeinspect the automation rule set
$ 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, not descr. The legacy per-interface rules use descr in config.xml; the automation model uses description. Sending descr does not error — the model ignores unknown fields — so the rule saves with an empty description and the mistake is only visible later.
  • source_net and destination_net take a host, a network in CIDR, an alias name, or any. Ports are separate fields, source_port and destination_port, and are left empty to mean “all ports”. source_not and destination_not invert the match.
  • interface is a comma-separated list of interface names, and it may be empty. An empty interface makes 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.
  • quick defaults to 1. 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.
  • direction defaults to in and accepts in, out, or any. ipprotocol defaults to inet and accepts inet, inet6, or inet46.
  • sequence orders the rule within the automation set; it defaults to 1, so a script that never sets it produces a pile of rules all claiming the same position.
  • log defaults to 0. 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.

Configuration changeadd an automation rule
$ 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:

  1. sequence is the position, and it has a default. Order within the automation set comes from the sequence field, sorted ascending. Its model default is 1, 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.
  2. Quick rules stop evaluation; the first match wins. A pass any → any above a block for a specific pair makes the block unreachable. Before adding a rule, searchRule and check what already matches the traffic the new rule is about. Adding a correct rule below an existing general allow changes nothing.
  3. An empty interface means floating. A rule with no interface is evaluated everywhere, and floating rules are placed ahead of interface-bound rules. A script that omits interface because 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:

  1. 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.
  2. The apply succeeded. POST /api/firewall/filter/apply returns 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.
  3. pf sees the rule. pfctl -sr shows it in the running ruleset. Search by the rule’s UUID, not by its description: OPNsense attaches the automation rule’s UUID as the pf label, 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.
Service impact possibleapply and verify the rule in pf
$ 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:

  1. 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.
  2. The configuration history. OPNsense keeps a versioned history of /conf/config.xml under /conf/backup/, with a new revision written on every change and the change’s origin recorded against it. GET /api/core/backup/backups/this lists the revisions and GET /api/core/backup/diff/this/{rev1}/{rev2} returns the difference between two of them — the precise change, as the firewall recorded it.
  3. The running ruleset. pfctl -sr is 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}, then apply.
  • Save and apply are separate calls. {"result": "saved"} means the configuration changed and pf did not. Batch many saves behind one apply.
  • The payload is one flat object under rulesource_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 to 1. Leave gaps. An empty interface makes 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

  1. 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?

  2. 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.

  3. Q3. Which of the following are safe-add practices for API-driven firewall rule automation? Select all that apply.

  4. 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.