Giving Claude a way into the firewall

2.8.2026

New phase of the project. Up until now Claude Code has been a very good pair of hands for writing this website and reading through configs I paste at it. Today's goal was to give it actual hands on the homelab itself, starting with Torii, my OPNsense firewall, over something called MCP (Model Context Protocol). MCP is basically a standard way for an LLM to call real tools instead of just talking about them. In theory: ask Claude "what firewall rules do I have on the WAN interface" and it goes and looks, instead of me tabbing over to the OPNsense GUI myself.

In practice this turned into a full day of two-factor-shaped confusion, a codebase patch, a security bug I didn't cause but had to work around, and one embarrassing copy-paste mistake. Here's the whole thing, warts included.

Part 1 - Picking a tool I didn't fully trust

First step was finding an existing OPNsense MCP server rather than writing one from scratch. There's a project rule on this site that I don't use Python for scripting, so I went looking at the Node.js/TypeScript options on GitHub first. Found one that worked, but it had zero safety rails - full read and write access to the firewall, including the reboot and firmware upgrade endpoints, no read-only mode anywhere in sight. Handing an LLM the ability to reboot my firewall on a bad day felt like a bad idea.

The one project that actually had real guardrails - read-only by default, writes auto-revert after 60 seconds unless confirmed, destructive operations permanently blocked - was Python only.

So: break my own no-Python rule, or hand an agent unrestricted firewall access. Neither felt right. Ended up solving it a level up instead - use the Node.js server, but enforce read-only access at the OPNsense layer itself, through a dedicated, scoped API user. The guardrail doesn't need to live in the client tool's code if the firewall itself refuses to let that user write anything. In hindsight this is probably the more correct place for it anyway - you shouldn't have to trust that every tool touching your infrastructure was written carefully.

Part 2 - Almost committing secrets to a public repo

Cloned and built the server. Partway through I realized the working folder I'd been using for this whole "agentic control plane" idea lived inside this public Luke.yt repo. That's the kind of mistake that only needs to happen once. Moved everything MCP-server-related out to its own folder entirely outside any public repo, specifically so no API key, secret, or certificate could ever end up committed to a portfolio site by accident. Small thing, but worth writing down since it's exactly the kind of thing that's obvious in hindsight and invisible in the moment.

Part 3 - A "read-only" checkbox that doesn't exist

Next was actually configuring that read-only API user in OPNsense. I expected a simple checkbox somewhere. It's not that simple - OPNsense's permission model is page-based, plus there's one specific global privilege called user-config-readonly that blocks all config writes regardless of what else the user can otherwise touch. Not something you'd stumble onto without digging.

While digging I found a live security advisory that made the whole exercise feel worthwhile: that privilege only actually works when assigned via a group. Assign it directly to a user on older OPNsense versions and a comma-parsing bug silently lets writes through anyway - fixed in 26.1.11 and 26.4.1p1. So the control can look correctly configured in the UI and still be quietly bypassed under the hood. Good reminder that "I checked the box" and "the box does what I think it does" are two different claims. Made sure to assign it via a group, and made sure my OPNsense version was patched.

Part 4 - A nice surprise about my own certificate

Before wiring anything up I went to sort out SSL verification and went to check whether Torii's certificate was self-signed, expecting the usual "yes, deal with it" answer. It wasn't - it's issued by Luke.YT Internal Intermediate CA, which is itself signed by a root CA I apparently set up at some earlier point in this project and then promptly forgot about. Small moment of "oh right, I actually did build that" - nice to be reminded that past-me's PKI work is still quietly doing its job.

Part 5 - Wait, there are two locks on this door?

Then, almost as an aside, I remembered that the connection to Torii doesn't go straight to OPNsense - it goes through HAProxy, and HAProxy is doing mutual TLS. Meaning the client has to present its own certificate too, not just trust the server's.

This is where I got genuinely confused for a while. I kept conflating "the OPNsense API key and secret" with "the mTLS client certificate and private key" as if they were the same kind of credential. At one point I straight up asked "there's two different keys right? I only downloaded one so far" because it wasn't obvious these are two completely unrelated systems - one authenticates individual API calls, the other authenticates the TLS connection itself, before any API call has even happened. Once it was spelled out plainly it clicked, but I definitely spent longer than I'd like to admit stuck on it.

Went and read the MCP server's source to see how it handled client certificates. Answer: it didn't, at all. It would have failed cryptically at the TLS handshake, before ever reaching OPNsense's own authentication. So step one was no longer "configure the server" - it was "patch the server."

Part 6 - Patching a TypeScript client for mTLS

Went into src/api/client.ts and src/index.ts and added support for presenting a client certificate - both a PFX bundle and a separate PEM cert plus key, wired through new environment variables. Rebuilt. First time touching this codebase and already making changes to it before it had done a single useful thing for me. Fitting way to start.

Part 7 - Certificates, keys, and small landmines

Tried to reuse a client certificate I'd already issued for browser access, by exporting it from the Windows certificate store. Got hit with "Cannot export non-exportable private key." Turns out that key had been generated inside Windows' CNG store with the non-exportable flag set from the start - there was never going to be a way to get it out. Generated a fresh key pair with OpenSSL directly instead, entirely outside the Windows cert store, so the private key existed as a plain file from the moment it was created.

Then hit a classic Git Bash on Windows landmine - MSYS path conversion silently rewrote my certificate subject string, turning /CN=opnsense-mcp.luke.yt/... into something like C:/Program Files/Git/CN=opnsense-mcp.luke.yt/..., because Git Bash decided anything starting with a slash must be a filesystem path. Took a minute to even notice it was happening. Fixed with MSYS_NO_PATHCONV=1 in front of the command.

Also went back and regenerated the key and CSR to match the algorithm my existing certs use - ECDSA P-384 with SHA-384 - instead of OpenSSL's RSA-4096 default. Then actually verified the resulting CSR really was ECDSA P-384 before moving on, instead of just assuming the flags did what I meant.

Part 8 - Signing through the OPNsense UI

Took the CSR to OPNsense's Certificate Manager (System > Trust > Certificates > "Sign a Certificate Signing Request") to get it signed by my Intermediate CA. This form fought me a little - two separate PEM text boxes labelled "Certificate data" and "Certificate signing request" that aren't obviously different at a glance, and a "Key type" dropdown that seems to matter when generating a new cert but felt irrelevant when I already had a CSR in hand. Also had a genuinely confused moment trying to find "the PEM information" for a CSR that had already been generated and was just sitting in a file on disk the whole time - had to actually open it in Notepad before it registered that yes, this text file is the thing.

Got there in the end. Before trusting the signed certificate that came back, I hashed its public key and compared it against the hash of my locally held private key's public component, to make sure they actually matched before wiring it into anything. Cheap check, good habit - don't just trust that a downloaded file is the file you think it is.

For the other direction of mTLS - the client trusting the server - I pulled the full chain straight from the live TLS handshake with openssl s_client -showcerts rather than digging through OPNsense for a manual export. HAProxy happily handed over leaf through root in one go.

Part 9 - Wiring it into Claude Code

Registered the server with claude mcp add. Kept one rule strict the whole session: I never typed or looked at my actual API secrets or key passphrases directly through Claude. Every time a credential needed to go somewhere, I asked for a command template with placeholders instead, and filled the real values in myself.

First real test, run from a separate session, crashed immediately with Cannot read properties of null (reading 'list'). Not the most informative error in the world. Went digging in the server source and found an actual bug - the code marked the connection as "connected" before it had actually verified the connection worked. If that verification then failed, the client object stayed non-null, but none of the underlying resource objects - VLANs, firewall rules, interfaces - ever got initialized. So the server was stuck half-alive for the rest of that process's life, and every following tool call null-pointer-crashed instead of surfacing a real error.

Patched it in both places it happened - the automatic startup path and the interactive configure tool - so a failed connection can never get falsely marked as connected, and made sure the actual underlying error message reaches the user instead of a generic "not initialized." Small bug, but the kind that would have quietly wasted an hour of "why is nothing working" if I hadn't gone and read the source.

Part 10 - Two placeholders and a typo

Next attempt failed because the API key and secret environment variables were, very literally, still the placeholder text I'd left them as. Fixed that and got a real Authentication Failed straight from OPNsense - which sounds bad but was actually good news. It meant the entire chain up to that point worked: HAProxy accepted my client certificate and forwarded the request through to OPNsense. The only broken thing left was the credential value itself.

Turned out to be an extremely human mistake. I'd copy-pasted the secret out of the downloaded apikey.txt file and somehow typed "=secret" twice in the process. OPNsense only shows that secret once, at creation time, with no page to go look it up again afterward - so this wasn't a five-second fix, it was "regenerate the key and be more careful this time."

Part 11 - Progress, then silence

With the credentials actually correct, firewall rules listed out fine on the first try. Small victory. But list_interfaces and list_vlans came back silently empty - no error, just nothing, which is somehow more annoying than an error would have been.

Turned out to be two things stacking on top of each other. First, the dedicated read-only API user's permission group had firewall privileges checked but not the diagnostics/interfaces privileges those specific endpoints actually need. Second, and this one's on the tool rather than me - it wraps every fallback endpoint attempt in its own silent try/catch and just returns an empty array if everything fails, instead of surfacing what actually went wrong. Neither problem alone would have been that confusing. Together they looked exactly like "there's nothing here" instead of the permissions issue it actually was.

Fixed the permission group, and interfaces and VLANs started coming back too - except not quite cleanly. Turns out get_interfaces and interface_list_overview aren't the same call under the hood at all; they hit two completely different OPNsense controllers, so one worked the moment the permissions were widened and the other needed a second look. "The tool names sound similar" and "the tools do the same thing" turned out to be two different claims too.

By the end of it: Claude can read Torii's firewall rules, interfaces, and VLANs over a properly mutual-TLS'd connection, using a credential that physically cannot write anything even if it tried, and I understand a lot more about OPNsense's permission model, PKI hygiene, and my own tendency to mix up two credentials that were never the same thing to begin with.