Categories
AI OPNsense

Managing OPNsense from the Terminal with Claude Code

Use Claude Code with the OPNsense API to document your firewall in Git and make changes in plain English, staged then applied behind two approval gates.

I run an OPNsense firewall at the centre of my own network, and the API is extensive enough that there’s a real case for automation. Claude Code sits in the middle of that: I can ask it to keep firewall documentation up to date in Git, and to make operational changes based on plain English requests, and have it construct the actual API calls for my review before anything goes live.

This is not an AI agent making autonomous decisions about your firewall rules. It is a tool for writing and applying configuration faster, with the same human approval step that always existed between “I want to make this change” and “the change is live”. Nothing in this setup removes that step. It just moves the tedious bit, working out which endpoint and which JSON body, onto something that can type faster than I can and doesn’t mistype a UUID.

Authentication and getting a key

OPNsense’s API uses HTTP Basic authentication, but the credentials aren’t your web login. The API key is the username and the API secret is the password. You create a key pair at System > Access > Users, edit the relevant user, find the API keys section, and click the plus icon. OPNsense downloads a text file containing a key= and a secret= line. That file is the only place the secret is ever shown in full, so save it somewhere sensible before you close the tab.

The certificate on the management interface is self-signed by default, so every curl call needs -k. I keep the key and secret in environment variables rather than typing them into a prompt or a script argument, partly out of habit and partly because it means Claude Code never sees the secret as text it has to reproduce, retype or accidentally echo into a log. It just references $OPN_KEY and $OPN_SECRET in the command line, and the shell does the substitution.

export OPN_KEY="your-api-key"
export OPN_SECRET="your-api-secret"

curl -k -u "$OPN_KEY:$OPN_SECRET" \
  https://fw1.lan/api/core/firmware/status

That call is a good first test because it’s read-only and returns something meaningful straight away: the currently installed firmware version, available updates, and package status. If it works, the credentials are good and Claude Code can start pulling real data.

Scoping what Claude Code can touch

I run this with --allowedTools "Bash(curl:*),Read,Write". That scopes Claude Code to the curl binary plus local file reads and writes, so it can’t reach for ssh, scp, or anything else on the box. It’s a useful first fence, but it’s worth being honest about what it doesn’t do: it scopes the binary, not the HTTP verb. curl making a GET and curl making a POST look identical to an allowedTools rule, so this setting alone will happily let a POST that changes a rule through the same gate as a GET that reads firmware status.

That’s fine, because the actual safety here comes from three things working together rather than from the allowedTools flag on its own. The first is the prompt itself: when I’m asking Claude Code to document the firewall, I tell it explicitly that this is a read-only pass, GET calls to search and get endpoints, nothing that adds, sets, deletes or applies. The second is that I read every command before it runs. Claude Code doesn’t fire curl invocations into the void, it proposes them, and I can see the method, the endpoint and the body before deciding whether it matches what I asked for. The third, and the one that actually stops anything happening without my say-so, is the permission prompt itself. Every Bash invocation needs my explicit approval, so even if the first two layers failed, either because I misread a command or the prompt was ambiguous, nothing reaches the firewall until I click approve. Three layers, only one of which is a hard technical control, and that’s the one that matters most.

Documenting the firewall into Git

The read side of this is the easy win. I ask Claude Code to pull the firewall’s current state and write it into a Markdown file that lives in the same Git repository as the rest of my network documentation. Three GET calls cover most of what’s useful: core/firmware/status for the platform version, firewall/alias/searchItem for the alias table, and firewall/filter/searchRule for the rule base. All three are read-only, so this is the safest thing to automate and the first thing I got comfortable running unattended.

The output is a firewall.md that gets committed alongside a changelog entry. Here’s roughly what it looks like, with a sample of what the firmware endpoint returned on the day I generated it:

# fw1.lan

## Firmware (sample output, check the API for current state)

Reported version at time of writing: OPNsense 24.1.6_1
Update available: no

## Aliases

That firmware block is deliberately a sample, not a claim about what version any given firewall is running today. The whole point of documenting via the API rather than by hand is that the number in the file is whatever the API returned on that run, and it goes stale the moment somebody pushes an update. Treat it as illustrative of the format, not as a fact about a real box.

The alias table that follows is built the same way, one row per alias returned by searchItem, with only the fields the API actually gives you:

Alias Type Content Description
BlockedNets network 192.0.2.0/24, 198.51.100.0/24 Manually blocked networks
WebServers host 10.0.10.10, 10.0.10.11 DMZ web servers
MgmtHosts host 10.0.0.5, 10.0.0.6 Admin workstations

Those three aliases are illustrative rather than my actual configuration, but the shape is exactly what comes back: a name, a type, the content list, and whatever description was set when the alias was created. That’s genuinely useful data to have in Git rather than locked inside a web UI. When I’m mid-change and wondering “is 198.51.100.0/24 still blocked”, I don’t need to log into the firewall to answer, I can grep the repo. When I want to check what’s actually permitted through the DMZ before I touch a rule, the rule table and alias table sitting in version control with commit history attached is a considerably better answer than clicking through the web UI.

Running this on a schedule and diffing the output against the previous commit turns it into a drift detector almost for free. If a rule or alias changes outside of the documented process, whether that’s me hacking around an outage at 2am and forgetting to write it up or something I did not expect to find, the next scheduled run produces a diff that says exactly what changed and when. I don’t have to remember to go looking for it. The commit history becomes the audit trail, and because it’s plain text, reviewing six months of firewall changes is a git log rather than a request to the vendor for a config export.

Claude Code authenticating to the OPNsense API with key and secret, reading aliases, filter rules and firmware status, and writing firewall.md into Git
Figure 1 – Claude Code authenticating with key and secret, reading aliases, rules and firmware status, and writing firewall.md into Git.

Making changes in plain English

This is the part where the two step model in OPNsense’s API actually matters, and it’s worth being precise about it because it’s easy to get wrong. Every endpoint that changes state, addItem, setItem, addRule, setRule, only stages the change into a pending configuration. Nothing is live until a separate reconfigure (for aliases) or apply (for filter rules) call is made. That separation exists in the API regardless of whether Claude Code is involved, and it’s the reason this whole workflow is safe to attempt in the first place: staging and applying are two different HTTP calls, which means there are two separate points where a human can look at what’s about to happen and say no.

Say I ask Claude Code to add a couple of subnets to the BlockedNets alias. It doesn’t know the alias’s UUID up front, aliases are addressed by UUID everywhere except the search endpoint, so the first call is a GET to firewall/alias/searchItem, which returns every alias as a set of rows. Claude Code matches the row where the name field equals BlockedNets and pulls the UUID out of it. The search endpoint does support a searchPhrase parameter for server side filtering, but I’ve found the more reliable pattern is to just list everything and match by name in the returned rows, since that avoids any ambiguity about how the phrase is matched on the OPNsense side.

With the UUID in hand, the staging call is a POST to setItem/{uuid}. OPNsense wraps the model under its type key in the request body, so the alias fields sit inside an alias object rather than at the top level:

curl -k -u "$OPN_KEY:$OPN_SECRET" -X POST \
  -H "Content-Type: application/json" \
  -d '{"alias":{"content":"192.0.2.0/24\n198.51.100.0/24\n10.0.5.0/24","description":"Manually blocked networks"}}' \
  https://fw1.lan/api/firewall/alias/setItem/<uuid>

addItem follows the same convention, the new alias’s fields go inside an alias object too. Miss that wrapper and the API will accept the call and quietly do nothing useful with it, which is a worse failure mode than an outright error.

That request stages the change. It does not go live. The pending state sits in the configuration until a second, separate POST to reconfigure applies it:

curl -k -u "$OPN_KEY:$OPN_SECRET" -X POST \
  https://fw1.lan/api/firewall/alias/reconfigure

This matters operationally, not just as an API quirk. It means the moment where I approve “yes, stage that change” and the moment where I approve “yes, make it live” are two distinct permission prompts, and I get to look at the actual staged content in between if I want to, by reading the alias back with getItem before I let the apply call through. A typo in a subnet, or a change that doesn’t match what I actually asked for, gets caught while it’s still sitting in pending state and affecting nothing. If Claude Code stages something wrong and I never approve the reconfigure, the firewall’s live behaviour hasn’t changed at all.

Plain English request turned into an OPNsense alias change that is staged then applied through two separate human approval gates
Figure 2 – Plain English request, a UUID lookup, the staged change approved, then a separate apply that puts it live on the firewall.

A safety net for rule changes

Filter rules get an extra layer of protection on top of stage-then-apply, and it’s worth understanding what it actually guards against. The risk with rule changes specifically, as opposed to alias changes, is that a rule edit can cut off your own management access. Alter the wrong rule and the console you’re using to manage the firewall stops being reachable, at which point you’re driving to site or waiting for someone with local access, neither of which is a good use of an afternoon.

OPNsense’s filter apply endpoint can run against a savepoint, which is effectively a timed checkpoint of the ruleset as it was before the apply. If the apply goes through and you can still reach the management interface, you confirm it with a call to cancelRollback, which tells the firewall the change is good and the checkpoint isn’t needed. If you can’t reach the interface, because the new rule broke the very path you’re using to manage it, you do nothing, and the firewall reverts to the pre-apply ruleset on its own once the savepoint’s timer expires. Nobody has to notice the failure and manually roll back. The default behaviour if nothing happens is safe.

That’s the actual value of the rollback mechanism: it converts “I might lock myself out” from a real risk you have to be careful about every single time into a non-event, because the failure mode is an automatic revert rather than a phone call to whoever’s physically near the rack. I still watch the interface stay reachable after every rule apply before I confirm, but the mechanism exists precisely for the case where I can’t.

What comes next

The pattern here, search to find identity, stage a change, review it, apply it separately, generalises well beyond OPNsense. Most infrastructure APIs worth automating against split writes into a staged and a committed step somewhere, precisely because “propose then confirm” is a sound design regardless of what’s on the other end of the API. The same shape of workflow applies to things like switch configuration staging on network operating systems that support candidate configs, or Kubernetes manifests applied via a plan step before a real apply. Wherever that split exists in an API, the same three layers of prompt, review and permission prompt back it up equally well.

The obvious next piece of work is broader test coverage before any of this runs unattended against my live firewall: a lab box where staged changes, applies and rollbacks all get exercised against known scenarios, so I have evidence the automation behaves correctly under failure conditions and not just the happy path. After that, the documentation half is the natural candidate for a scheduled run, weekly or after significant change windows, rather than something I trigger by hand, so the Git history of firewall.md keeps building even on weeks where nobody thinks to ask for it.

Leave a Reply

Your email address will not be published. Required fields are marked *