OPNsense MCP medium: O3 ruleset export/import, O5 move firewall rule

This commit is contained in:
Hermes CT107
2026-07-04 20:41:27 +00:00
parent b62e05113d
commit 2b4257ff03
4 changed files with 1109 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ from opnsense_mcp.tools import (
logs,
nat,
routes,
ruleset,
system,
vpn,
)
@@ -25,6 +26,7 @@ def register_all(mcp: FastMCP) -> None:
hosts.register(mcp)
interfaces.register(mcp)
firewall.register(mcp)
ruleset.register(mcp)
aliases.register(mcp)
nat.register(mcp)
dhcp.register(mcp)

View File

@@ -168,6 +168,18 @@ def register(mcp: FastMCP) -> None:
audit_log("revert_firewall", payload | {"result": result})
return json.dumps(result, indent=2, default=str)
@mcp.tool()
def opnsense_move_firewall_rule(uuid: str, position: int) -> str:
"""Move a firewall rule to a specific 0-based position."""
payload = {"uuid": uuid, "position": position}
if settings.dry_run:
return json.dumps(is_dry_run_response("move_firewall_rule", payload), indent=2)
require_writes("move_firewall_rule", payload)
client = get_client()
result = client.post("firewall", "filter", "setRule", uuid, data={"rule": {"sequence": position}})
audit_log("move_firewall_rule", {"uuid": uuid, "position": position, "result": result})
return json.dumps(result, indent=2, default=str)
@mcp.tool()
def opnsense_safe_apply_firewall_rule_change(
uuid: str,

View File

@@ -0,0 +1,199 @@
"""Ruleset export / import tools."""
from __future__ import annotations
import json
from typing import Any
from mcp.server.fastmcp import FastMCP
from opnsense_mcp.audit import audit_log
from opnsense_mcp.client import OpnsenseAPIError, get_client
from opnsense_mcp.config import settings
from opnsense_mcp.guards import is_dry_run_response, require_writes
def _fetch_all(client: Any, module: str, controller: str, command: str) -> list[dict[str, Any]]:
"""Fetch all rows from a search endpoint via the search_get path."""
all_rows: list[dict[str, Any]] = []
current = 1
row_count = 200
while True:
try:
result = client.search_get(module, controller, command, "", row_count, current=current)
except OpnsenseAPIError:
result = client.search_grid(module, controller, command, "", row_count, current=current)
rows = result.get("rows", [])
if not rows:
break
all_rows.extend(rows)
total = int(result.get("total", 0))
if current * row_count >= total:
break
current += 1
return all_rows
def _fetch_all_grid(client: Any, module: str, controller: str, command: str) -> list[dict[str, Any]]:
"""Fetch all rows from a search endpoint via the search_grid path."""
all_rows: list[dict[str, Any]] = []
current = 1
row_count = 200
while True:
result = client.search_grid(module, controller, command, "", row_count, current=current)
rows = result.get("rows", [])
if not rows:
break
all_rows.extend(rows)
total = int(result.get("total", 0))
if current * row_count >= total:
break
current += 1
return all_rows
def register(mcp: FastMCP) -> None:
@mcp.tool()
def opnsense_export_ruleset() -> str:
"""Export all firewall rules, aliases, and NAT rules as a single JSON document for backup."""
client = get_client()
# Firewall filter rules
try:
rules = _fetch_all(client, "firewall", "filter", "searchRule")
except Exception:
rules = _fetch_all_grid(client, "firewall", "filter", "searchRule")
# Aliases
try:
aliases = _fetch_all_grid(client, "firewall", "alias", "searchItem")
except Exception:
aliases = []
# Source NAT rules
try:
source_nat = _fetch_all(client, "firewall", "source_nat", "searchRule")
except OpnsenseAPIError:
try:
source_nat = _fetch_all_grid(client, "firewall", "source_nat", "searchRule")
except Exception:
source_nat = []
except Exception:
source_nat = []
# Destination NAT rules
try:
dest_nat = _fetch_all(client, "firewall", "d_nat", "searchRule")
except OpnsenseAPIError:
try:
dest_nat = _fetch_all_grid(client, "firewall", "d_nat", "searchRule")
except Exception:
dest_nat = []
except Exception:
dest_nat = []
# 1:1 NAT rules
try:
one_to_one = _fetch_all(client, "firewall", "one_to_one", "searchRule")
except OpnsenseAPIError:
try:
one_to_one = _fetch_all_grid(client, "firewall", "one_to_one", "searchRule")
except Exception:
one_to_one = []
except Exception:
one_to_one = []
# NPT rules
try:
npt = _fetch_all(client, "firewall", "npt", "searchRule")
except OpnsenseAPIError:
try:
npt = _fetch_all_grid(client, "firewall", "npt", "searchRule")
except Exception:
npt = []
except Exception:
npt = []
ruleset: dict[str, Any] = {
"version": 1,
"firewall_rules": rules,
"aliases": aliases,
"source_nat": source_nat,
"dest_nat": dest_nat,
"one_to_one": one_to_one,
"npt": npt,
}
return json.dumps(ruleset, indent=2, default=str)
@mcp.tool()
def opnsense_import_ruleset(ruleset_json: str) -> str:
"""Import a previously exported ruleset (writes enabled only). Import adds rules -- it does not delete existing configuration."""
payload_preview = {"action": "import_ruleset", "json_size": len(ruleset_json)}
if settings.dry_run:
return json.dumps(is_dry_run_response("import_ruleset", payload_preview), indent=2)
require_writes("import_ruleset", payload_preview)
ruleset = json.loads(ruleset_json)
client = get_client()
stats: dict[str, int] = {}
# Import aliases first (they may be referenced by rules)
for alias in ruleset.get("aliases", []):
try:
existing = client.get("firewall", "alias", "getAliasUUID", alias.get("name", ""))
if existing.get("uuid"):
continue # alias already exists
except OpnsenseAPIError:
pass # doesn't exist, create it
try:
client.post("firewall", "alias", "addItem", data={"alias": alias})
stats["aliases_imported"] = stats.get("aliases_imported", 0) + 1
except OpnsenseAPIError:
pass
# Import firewall rules
for rule in ruleset.get("firewall_rules", []):
try:
client.post("firewall", "filter", "addRule", data={"rule": rule})
stats["firewall_rules_imported"] = stats.get("firewall_rules_imported", 0) + 1
except OpnsenseAPIError:
pass
# Import source NAT
for rule in ruleset.get("source_nat", []):
try:
client.post("firewall", "source_nat", "addRule", data={"rule": rule})
stats["source_nat_imported"] = stats.get("source_nat_imported", 0) + 1
except OpnsenseAPIError:
pass
# Import dest NAT
for rule in ruleset.get("dest_nat", []):
try:
client.post("firewall", "d_nat", "addRule", data={"rule": rule})
stats["dest_nat_imported"] = stats.get("dest_nat_imported", 0) + 1
except OpnsenseAPIError:
pass
# Import 1:1 NAT
for rule in ruleset.get("one_to_one", []):
try:
client.post("firewall", "one_to_one", "addRule", data={"rule": rule})
stats["one_to_one_imported"] = stats.get("one_to_one_imported", 0) + 1
except OpnsenseAPIError:
pass
# Import NPT
for rule in ruleset.get("npt", []):
try:
client.post("firewall", "npt", "addRule", data={"rule": rule})
stats["npt_imported"] = stats.get("npt_imported", 0) + 1
except OpnsenseAPIError:
pass
audit_log("import_ruleset", stats)
return json.dumps(
{"status": "imported", "stats": stats, "message": "Import complete. Review and apply firewall changes manually."},
indent=2,
)