opnsense-mcp
# opnsense-mcp
An MCP server for the OPNsense firewall REST API. It gives an LLM structured,
paginated access to rules, aliases, live pf state, logs, interfaces, DNS and VPN
status — and keeps every write behind an explicit gate.
Built for a firewall at `https://192.168.1.1`, but the address is configuration,
not a constant.
---
## Requirements
- Python 3.11+
- Network reachability to the OPNsense WebGUI port
- An OPNsense API key pair
## Install
```bash
git clone <this repo> opnsense-mcp && cd opnsense-mcp
python3 -m venv .venv && . .venv/bin/activate
pip install -e .
```
Verify the package loads before wiring it into a client:
```bash
opnsense-mcp --version
```
## Create an API key
1. In the OPNsense GUI: **System → Access → Users**
2. Edit the user the server should act as (create a dedicated one — see
[Least privilege](#least-privilege))
3. Under **API keys**, press **+**. A `apikey.txt` downloads containing `key` and
`secret`. It is shown once.
## TLS
OPNsense ships a self-signed WebGUI certificate, so plain verification fails out of
the box. Two options, in order of preference:
**Pin the firewall's certificate.** Export it from **System → Trust → Certificates**
and point at the PEM file:
```bash
export OPNSENSE_CA_BUNDLE=/etc/ssl/opnsense.pem
```
**Disable verification.** Works, but the API key travels over a path anyone in the
middle can read. Acceptable on a trusted management segment; not otherwise:
```bash
export OPNSENSE_VERIFY_TLS=false
```
## Configuration
All configuration is environment-only. Credentials never appear in tool arguments,
so they cannot end up in a conversation transcript.
| Variable | Default | Purpose |
| --- | --- | --- |
| `OPNSENSE_URL` | `https://192.168.1.1` | Base URL including scheme |
| `OPNSENSE_API_KEY` | *(unset)* | API key |
| `OPNSENSE_API_SECRET` | *(unset)* | API secret |
| `OPNSENSE_CA_BUNDLE` | *(unset)* | Path to a PEM file; takes precedence over `VERIFY_TLS` |
| `OPNSENSE_VERIFY_TLS` | `true` | Set `false` to skip verification |
| `OPNSENSE_TIMEOUT` | `30` | Request timeout in seconds |
| `OPNSENSE_ALLOW_WRITE` | `false` | Master switch for every write tool |
`.env.example` has the same set with commentary.
On startup the server prints its resolved configuration to **stderr** (never stdout —
stdio transport uses stdout for the protocol itself) and warns about missing
credentials, disabled TLS verification, and enabled writes.
## Wiring it into a client
> **Hand it to an agent:** [`docs/AGENT-SETUP-PROMPT.md`](docs/AGENT-SETUP-PROMPT.md)
> is a paste-ready prompt that does the whole Windows setup with verification gates.
>
> **Windows:** see [`docs/WINDOWS.md`](docs/WINDOWS.md) — Claude Desktop does not
> inherit your shell PATH, which breaks the bundle's `uv` lookup.
>
> **Claude Desktop:** install `opnsense-mcp-0.1.1.mcpb` via Settings -> Extensions.
> Credentials go to your OS keychain, not a config file. Build it with
> `cp -r opnsense_mcp mcpb/ && npx @anthropic-ai/mcpb pack mcpb`.
>
> Full walkthrough for every surface, including why claude.ai remote connectors are
> the wrong choice here: [`docs/CONNECTING.md`](docs/CONNECTING.md).
>
> Use `scripts/opnsense-mcp-env.sh` rather than putting the API secret directly in
> client config — both Claude Code and Claude Desktop store those values in plaintext.
### stdio (Claude Desktop, Claude Code)
```json
{
"mcpServers": {
"opnsense": {
"command": "/path/to/opnsense-mcp/.venv/bin/opnsense-mcp",
"env": {
"OPNSENSE_URL": "https://192.168.1.1",
"OPNSENSE_API_KEY": "…",
"OPNSENSE_API_SECRET": "…",
"OPNSENSE_CA_BUNDLE": "/etc/ssl/opnsense.pem"
}
}
}
}
```
### Streamable HTTP (OpenWebUI and other remote clients)
```bash
opnsense-mcp --transport streamable-http --host 127.0.0.1 --port 8000 --path /mcp
```
Runs stateless with JSON responses, which is the simpler thing to put behind a proxy.
It binds to loopback by default and warns loudly if you bind wider: **the server
itself has no authentication**, so anything that can reach the port can drive your
firewall API. For remote access, terminate auth in a reverse proxy in front of it, or
reach it over Tailscale/WireGuard rather than exposing the port.
---
## Tools
Twenty-six tools, all prefixed `opnsense_`. Every collection tool takes `limit`,
`offset` and `response_format` (`markdown` for reading, `json` for chaining values
into a follow-up call) and returns `total` / `count` / `has_more` / `next_offset`.
### System
| Tool | Notes |
| --- | --- |
| `opnsense_get_system_status` | Version, uptime, load, memory, temperature, disk. Sections whose endpoint is missing are omitted rather than failing the call |
| `opnsense_get_firmware_status` | Pending updates and installed vs latest version |
| `opnsense_list_services` | Core and plugin services with running state |
| `opnsense_control_service` | **write** — start / stop / restart |
### Network
| Tool | Notes |
| --- | --- |
| `opnsense_list_interfaces` | Maps config names (`lan`, `opt2`) to device names (`igb0`, `vlan0.30`). Call this first whenever a question spans rules and diagnostics |
| `opnsense_get_interface_statistics` | Cumulative counters, not rates — call twice and difference |
| `opnsense_search_neighbors` | ARP and NDP tables, `family` = `ipv4` / `ipv6` / `both` |
| `opnsense_get_routes` | Active routing table |
| `opnsense_list_dhcp_leases` | Kea with automatic fallback to legacy ISC |
### Firewall rules
| Tool | Notes |
| --- | --- |
| `opnsense_search_firewall_rules` | Rules in evaluation order with action, interface, source, destination |
| `opnsense_get_firewall_rule` | Full record by UUID; enum fields flattened from OPNsense's `{value, selected}` form |
| `opnsense_get_rule_statistics` | Per-rule evaluations / packets / bytes / states. `unused_only=true` finds dead rules — but counters reset on every apply |
| `opnsense_toggle_firewall_rule` | **write** — stages only; nothing is live until applied |
| `opnsense_apply_firewall_changes` | **write** — savepoint / apply / cancel_rollback / revert |
### Aliases
| Tool | Notes |
| --- | --- |
| `opnsense_search_aliases` | Alias *configuration* |
| `opnsense_get_alias_contents` | What the pf table *actually holds* — the two diverge when a URL table or GeoIP feed fails to refresh |
| `opnsense_find_alias_references` | What breaks if this alias changes |
| `opnsense_update_alias_entries` | **write** — add/remove entries; live immediately, per-entry outcomes reported |
### Live diagnostics
| Tool | Notes |
| --- | --- |
| `opnsense_get_firewall_log` | Recent entries filtered by verdict / interface / address / port / protocol |
| `opnsense_query_firewall_states` | pf state table — what is happening *now* |
| `opnsense_get_pf_statistics` | State table usage, memory limits, drop counters |
| `opnsense_kill_states` | **write** — drop states matching an address; wildcards rejected |
### DNS and VPN
| Tool | Notes |
| --- | --- |
| `opnsense_get_dns_overview` | Unbound service state, blocklist status, cache counters |
| `opnsense_search_dns_queries` | Recent lookups per client; needs Reporting enabled under Services → Unbound DNS |
| `opnsense_get_vpn_status` | WireGuard peers and OpenVPN sessions |
### Escape hatch
| Tool | Notes |
| --- | --- |
| `opnsense_api_request` | Any endpoint. GET always allowed; POST needs `OPNSENSE_ALLOW_WRITE` **and** `confirm=true`. Use for NAT (`firewall/source_nat`, `firewall/d_nat`), traffic shaping, IDS, certificates, CrowdSec |
Command names in the published API reference are snake_case; URLs use camelCase.
`get_interface_names` → `getInterfaceNames`.
### Resources
- `opnsense://interfaces` — the interface map as JSON
- `opnsense://aliases` — all configured aliases
---
## Safety model
Three layers, because a firewall is the one box where a bad automated change can
remove your ability to fix it.
**1. Writes fail closed.** With `OPNSENSE_ALLOW_WRITE` unset, every write tool
returns an explanation of how to enable it and issues no HTTP request at all. There
is a test asserting zero requests reach the firewall in that state.
**2. Rule changes go through savepoints.** OPNsense's savepoint mechanism applies a
change with a 60-second dead-man's switch: if nobody confirms, the firewall reverts
itself. `opnsense_apply_firewall_changes` wraps it and each response states the next
step:
```
1. mode='savepoint' → returns a revision
2. make changes → e.g. toggle a rule
3. mode='apply', revision=<from step 1> → 60s auto-revert armed
4. verify you can still reach the firewall
5. mode='cancel_rollback', same revision → change becomes permanent
```
Skipping step 5 is safe. Skipping step 1 is not: an apply with no savepoint cannot be
undone remotely.
**3. Some endpoints are refused outright**, regardless of the write flag: reboot,
halt, factory reset, config revert, snapshot activation, user account changes. Those
need a human who can reach the console. `opnsense_kill_states` additionally rejects
wildcard filters, so flushing the entire state table is not reachable by accident.
Note the asymmetry that is *not* a bug: alias edits and state kills are live
immediately with no savepoint, because that is how OPNsense implements them. The tool
descriptions say so. Blocking an address by adding it to an alias is the fast path;
it is also the one with no undo.
### Least privilege
Create a dedicated user for the API key rather than reusing an admin account. Grant
only the pages the tools you actually want need, then check the result under
**System → Access → Users → (user) → Effective Privileges**.
A useful signal when tuning this: a `403` on one endpoint while other calls succeed
always means a missing privilege, never bad credentials. The error messages say this
explicitly so an agent stops trying to re-authenticate.
---
## Troubleshooting
| Symptom | Cause |
| --- | --- |
| `TLS verification failed` | Self-signed WebGUI cert. Set `OPNSENSE_CA_BUNDLE` or `OPNSENSE_VERIFY_TLS=false` |
| `HTTP 401/403` on everything | Wrong key/secret, or the key's user has no API access |
| `HTTP 403` on one endpoint only | Missing ACL for that page. Check Effective Privileges |
| `HTTP 404` on an endpoint | Plugin not installed, or this OPNsense release predates it |
| Returned HTML instead of JSON | `OPNSENSE_URL` points somewhere that is not the API |
| Read timeout | Large state tables and log queries are slow. Lower `limit` or raise `OPNSENSE_TIMEOUT` |
| Firewall log returns nothing | Only rules with logging enabled produce entries. Absence is not evidence of a block |
| DNS query search is empty | Reporting is off under Services → Unbound DNS → Reporting |
| Alias contents empty | An alias is only loaded into pf when an enabled rule references it |
## Tests
```bash
pip install -e ".[dev]"
pytest -q
```
Twenty-two tests run against mocked HTTP (`respx`) — no firewall required. They cover
the parts that are expensive to debug live: grid pagination arithmetic including the
unaligned-offset page stitch, the write gate, endpoint refusal, path-traversal
rejection, error translation, and graceful degradation when one section of a
multi-endpoint call fails.
## Known gaps
Endpoint paths taken from the published OPNsense API reference are solid. The
following were **inferred** and should be confirmed on first run against a live
firewall — each has a specific reason it could differ:
- `kea/leases4/search` — the reference documents only the abstract `leases`
controller, so the concrete v4 controller name is an inference
- `dhcpv4/leases/searchLease` (ISC fallback) and `openvpn/service/searchSessions`
- Request body key shapes: `alias_util add`/`delete` (`address`), `killStates`
(`filter`), `findReferences` (`alias`)
- `diagnostics/firewall/log` server-side filter parameters — rather than guess, the
tool fetches a window and filters locally, which is why it reports `window_size`
and warns when a narrow filter over a busy firewall returns nothing
Two tools already degrade rather than fail: DHCP leases falls back Kea → ISC, and
alias references falls back from the API endpoint to a filter-rule scan, labelling
its own reduced coverage in the output.
Not yet wrapped, reachable via `opnsense_api_request`: NAT rules, traffic shaping,
IDS/Suricata, captive portal, certificates, CrowdSec and other plugin endpoints.
See `evaluation.xml` for the evaluation harness and what needs filling in.
TDQS
Scored across 26 tools
Each tool targets a distinct resource or action: aliases are split into config, live table, references, and updates; firewall diagnostics are split into log, states, statistics, and kill; rules are split into search, detail, statistics, toggle, and apply. Cross-references explicitly warn against using the wrong tool, so an agent should rarely select the wrong one.
All tools use the opnsense_verb_noun pattern with snake_case, which is consistent. However, verbs vary for similar operations: search_aliases vs list_interfaces vs get_routes, and query_states vs search_dns_queries. The differences are mostly intentional (search for filtered, list for enumeration, get for single item), but the boundary is not perfectly uniform.
26 tools is slightly above the typical well-scoped range, but the breadth of the OPNsense domain justifies the number. Each tool covers a distinct feature area (aliases, rules, states, DNS, VPN, interfaces, DHCP, system, services), so none feels redundant. The count is high but not excessive given the scope.
The tool surface covers most core firewall workflows: searching and modifying rules, inspecting aliases and live tables, querying states and logs, checking DNS and VPN status, and managing services. Gaps exist for creating/deleting rules and aliases, but these are intentionally left out for safety, and opnsense_api_request provides a raw fallback for any missing endpoint.