Skip to main content

opnsense-guard-mcp

A safety-first MCP server for OPNsense 26.7+.

Read-only by default. Every write is planned, policy-checked, logged, and auto-reverted unless you confirm it.

Status: alpha. Tested against a live OPNsense 26.7.4 with least-privilege API users: read tools, policy file, change planning with diffs, applying changes with verification and automatic revert, crash recovery, and a hash-chained audit log, over stdio or authenticated HTTPS. See DESIGN.md.

Not affiliated with Deciso B.V. "OPNsense" is a trademark of Deciso B.V.

What makes it different

opnsense-guard-mcp
Default mode read-only
Read path GET, plus POST to exactly two search endpoints (states, rules) with a fixed three-field body; path allowlist, verb denylist, traversal/encoding rejected, redirects not followed
Writes separate API user, exact endpoint allowlist, plan → policy check → human approval → apply → verify → auto-revert unless confirmed
Rollback on 26.7+ own object-level snapshots + revert (the savepoint API is gone in 26.7); survives restarts
Guardrails allowlist policy file: interfaces, networks, rule limits, anti-lockout
Audit hash-chained JSONL (audit verify detects edits); syslog planned
Transport stdio, or Streamable HTTP that always needs a bearer token; optional TLS / mTLS
Dangerous tools none exist: no reboot, firmware, service control, SSH or XML edits

Quick start (read-only)

  1. In OPNsense (System → Access → Users), create a user with a scrambled password, no group (not admins) and these privileges, then create an API key for it. Skip any that aren't installed; the matching tool just reports "forbidden".

    Privilege Used by
    Firewall: Rules [new] firewall_rules, firewall_rule_get, planning
    Firewall: Aliases (view-only) firewall_aliases, planning
    Status: Interfaces interfaces
    Status: Services service_list
    Diagnostics: ARP Table arp_table
    Diagnostics: Routing tables routing_table
    Diagnostics: Show States firewall_states
    Diagnostics: Logs: Firewall: Live View firewall_log
    Diagnostics: Logs: System system_log
    Lobby: Dashboard system_information, system_resources, health probe
    Services: Intrusion Detection suricata_*
    Services: DHCP: Kea(v4) / Services: Dnsmasq DNS/DHCP: Settings / Status: DHCP leases (ISC) dhcp_leases: only the one you use
    System: Firmware (optional) system_status. It also allows installing updates, so leave it out unless you need it.

    Most OPNsense privileges are not view-only (Firewall: Aliases is an exception): several of these also allow edits through the API. The server only sends GET requests with this key, except a search POST to two exact endpoints (diagnostics/firewall/query_states, firewall/filter/searchRule) with a body it builds itself (current, rowCount, searchPhrase); OPNsense answers those lists properly only over POST. Still keep the list short: the privileges are what OPNsense itself enforces.

  2. Install:

    pipx install opnsense-guard-mcp
    
  3. Configure (copy .env.example to .env, or set env vars):

    OPNSENSE_URL=https://firewall.example.internal
    OPNSENSE_API_KEY=...
    OPNSENSE_API_SECRET=...
    OPNSENSE_CA_BUNDLE=/path/to/firewall-ca.pem   # or OPNSENSE_VERIFY_SSL=false
    
  4. Check the config (does not contact the firewall):

    opnsense-guard-mcp --check
    
  5. Add to your MCP client, e.g. Claude Code:

    claude mcp add opnsense -- opnsense-guard-mcp --env-file /path/to/.env
    

HTTP mode (shared server)

HTTP never runs without authentication. The server refuses to start if:

  • there are no tokens, or
  • it binds to a non-loopback address without TLS (override with MCP_ALLOW_PLAINTEXT=true only behind a TLS proxy).
  1. Create a token per client. It is printed once; only its SHA-256 is stored:

    opnsense-guard-mcp token new --name laptop --scope read
    

    token list shows names and scopes; token revoke --name laptop takes effect on the next request, no restart needed.

  2. Start the server:

    MCP_TRANSPORT=http MCP_HOST=0.0.0.0 MCP_PORT=8848 \
    MCP_TLS_CERT=server.pem MCP_TLS_KEY=server.key \
    opnsense-guard-mcp
    
  3. Point the client at https://<host>:8848/mcp with header Authorization: Bearer <token>.

Variable Default Purpose
MCP_HOST / MCP_PORT 127.0.0.1 / 8848 listen address
OPNSENSE_GUARD_TOKENS_FILE ./tokens.json token hashes
MCP_TLS_CERT / MCP_TLS_KEY – serve HTTPS
MCP_TLS_CLIENT_CA – require client certificates (mTLS)
MCP_ALLOWED_HOSTS localhost/127.0.0.1/MCP_HOST on the port accepted Host headers (DNS-rebinding guard)
MCP_ALLOWED_ORIGINS none browser origins allowed; any other Origin is refused
MCP_RATE_LIMIT_PER_MIN 120 requests per token per minute
MCP_ALLOW_PLAINTEXT false allow plain HTTP on a non-loopback address

Repeated failed logins from one IP (10 a minute) are throttled with HTTP 429.

Claude Code and a private CA: Claude Code (Node) ignores the Windows/macOS certificate store for MCP servers. Point NODE_EXTRA_CA_CERTS at your CA file in the env block of ~/.claude/settings.json, then fully quit and reopen Claude, or the connection fails with UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Running in Docker

The image runs as a non-root user and keeps its audit log, change journal and tokens in /data.

docker build -t opnsense-guard-mcp .

# stdio, for a local MCP client
docker run -i --rm --env-file .env -v guard-data:/data opnsense-guard-mcp

# HTTP: create a token, then serve (put the TLS files in the volume or mount them)
docker run --rm -v guard-data:/data opnsense-guard-mcp token new --name laptop --scope read
docker run -d --name opnsense-guard --env-file .env -v guard-data:/data -p 8848:8848 \
  -e MCP_TRANSPORT=http -e MCP_HOST=0.0.0.0 -e MCP_ALLOWED_HOSTS=mcp.example.internal:8848 \
  -e MCP_TLS_CERT=/data/tls/server.pem -e MCP_TLS_KEY=/data/tls/server.key \
  opnsense-guard-mcp

Mount a policy file and set OPNSENSE_GUARD_POLICY to enable writes. Stop the container with docker stop (not kill) so unconfirmed changes are reverted.

Policy and planning

Without a policy.yaml the server is read-only. To enable planning, copy policy.example.yaml to policy.yaml (or point OPNSENSE_GUARD_POLICY at it) and edit it. Unknown keys are an error.

Planning tools (rule_plan, alias_plan) read the current state, apply every policy check, and return a diff plus the reasons for any refusal. They never change the firewall. Example result for a refused rule:

+ [wan] pass in inet any from any to any '[mcp]'
  interface 'wan' is not in the allowed interfaces
  any -> any pass rules are forbidden
  inbound pass rules on WAN are forbidden
  every rule written by this server must have logging on

Built-in guardrails: only listed interfaces/actions/directions; every IP inside allow_networks; no any→any pass, WAN inbound pass, floating rules or rules without logging; edit only rules tagged [mcp] (unless only_modify_own: false); anti-lockout (no block rule that matches this server's own IP, and only new pass rules on the interface it reaches the API through).

Applying changes

Change tools appear only when both the policy is in write mode and a second, write-capable API user is configured:

OPNSENSE_WRITE_API_KEY=...      # a different OPNsense user than the read one
OPNSENSE_WRITE_API_SECRET=...

Give that user exactly two privileges: Firewall: Rules [new] and Firewall: Alias: Edit, no group. (Plain Firewall: Aliases is view-only: it allows search/get but not addItem/setItem/delItem/reconfigure.) Over HTTP, the change tools need a token with --scope write.

The flow:

  1. rule_plan / alias_plan → diff + plan_id (changes nothing).
  2. change_apply(plan_id) → asks the human to approve the diff (MCP elicitation where the client supports it; otherwise the tool returns the diff and must be called again with user_confirmed=true). Then it re-reads the firewall (refuses if anything drifted since planning), stores the before-state, writes, applies, reads everything back to verify, and checks the API is still reachable. Any failure → everything is reverted.
  3. The change is pending: it is reverted automatically after confirm_timeout_s (default 60 s) unless change_confirm is called.
  4. change_rollback undoes a pending or confirmed change. A revert never overwrites an object someone else edited meanwhile (status conflict).

Unconfirmed changes are also reverted when the server stops and, after a crash, when it starts again. Only one change can be open at a time, and max_changes_per_hour is enforced.

Limits, stated plainly

  • The revert timer runs on the MCP host. If a change cuts that host off from the firewall API, the revert cannot reach it. The anti-lockout policy and the health probe right after apply exist to prevent and catch this, but they are not the same as the firewall-side savepoint older OPNsense versions had.
  • Without elicitation, user_confirmed=true is the model's word that the user agreed. The audit log records which method was used.

Audit log

Every plan (allowed or refused) and server start is written to $OPNSENSE_GUARD_DATA_DIR/audit.jsonl (default ./data). Each line holds the hash of the previous one, so edits, deletions and reordering are detectable:

opnsense-guard-mcp audit verify
opnsense-guard-mcp audit tail -n 20

Credentials are never written to it.

Tools (read-only)

system_status, system_information, system_resources, service_list, posture_check, suricata_status, suricata_settings, suricata_alert_status, firewall_rules, firewall_rule_get, firewall_aliases, firewall_log, firewall_states, system_log, interfaces, arp_table, routing_table, dhcp_leases, raw_get, policy_show, security_audit.

In write mode also: rule_plan, alias_plan, plan_get, audit_tail. With write credentials also: change_apply, change_confirm, change_rollback, change_list, change_get.

firewall_rules returns one line per rule (50 by default) so a full ruleset fits in a chat; pass detail=true for the raw OPNsense rows, or use firewall_rule_get for one rule.

Over HTTP, a read token still sees the change tools in the tool list, but calling one is refused (needs a token with scope 'write').

Development

python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
ruff check . && mypy && pytest

Security

See SECURITY.md. Please report vulnerabilities privately.

Licence

MIT. See LICENSE.

Metadata

Release files for opnsense-guard-mcp 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for opnsense-guard-mcp 0.1.0
File Size Uploaded Provenance
opnsense_guard_mcp-0.1.0.tar.gz 72.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for opnsense-guard-mcp 0.1.0
File Interpreter ABI Platform
opnsense_guard_mcp-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 124.1 kB

Release files / opnsense_guard_mcp-0.1.0.tar.gz

Download URL opnsense_guard_mcp-0.1.0.tar.gz
Size 72.3 kB
Tags Source
SHA-256 checksum
How to use checksums
55f1a2815c4566dd8324c1d12382a600ff60dc4375693d74a3a9ea19bbe5282d
BLAKE2b-256 checksum
How to use checksums
0c309a4651dffd2ee198c73a110bc1f5f4546da45e2bd7b97859c75285bdb053
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.

Transparency log

Release files / opnsense_guard_mcp-0.1.0-py3-none-any.whl

Download URL opnsense_guard_mcp-0.1.0-py3-none-any.whl
Size 51.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
62a64d770dcd22f9165b489a62acf9ff3c2d3fb9d06050a67cefac268c72d56c
BLAKE2b-256 checksum
How to use checksums
878d2cf966b657c8e93724e5b7d20b972b146bad2fe7baac07f977e8f3181a26
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.

Transparency log

Security for opnsense-guard-mcp 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for opnsense_guard_mcp-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page