OPNsense MCP fixes: O1,O2,O4,O6 - narrow except, health check, register host, VLAN tools

This commit is contained in:
Hermes CT107
2026-07-04 20:16:00 +00:00
parent 22e4f3a469
commit b62e05113d
7 changed files with 212 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ from opnsense_mcp.tools import (
dns, dns,
firewall, firewall,
firmware, firmware,
hosts,
interfaces, interfaces,
logs, logs,
nat, nat,
@@ -21,6 +22,7 @@ from opnsense_mcp.tools import (
def register_all(mcp: FastMCP) -> None: def register_all(mcp: FastMCP) -> None:
system.register(mcp) system.register(mcp)
hosts.register(mcp)
interfaces.register(mcp) interfaces.register(mcp)
firewall.register(mcp) firewall.register(mcp)
aliases.register(mcp) aliases.register(mcp)

View File

@@ -7,7 +7,7 @@ import json
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from opnsense_mcp.audit import audit_log from opnsense_mcp.audit import audit_log
from opnsense_mcp.client import get_client from opnsense_mcp.client import OpnsenseAPIError, get_client
from opnsense_mcp.config import settings from opnsense_mcp.config import settings
from opnsense_mcp.guards import is_dry_run_response, require_writes from opnsense_mcp.guards import is_dry_run_response, require_writes
@@ -21,7 +21,7 @@ def register(mcp: FastMCP) -> None:
result = client.search_get( result = client.search_get(
"dhcpv4", "leases", "searchLease", search_phrase, row_count "dhcpv4", "leases", "searchLease", search_phrase, row_count
) )
except Exception: except OpnsenseAPIError:
result = client.search_grid( result = client.search_grid(
"dhcpv4", "leases", "searchLease", search_phrase, row_count "dhcpv4", "leases", "searchLease", search_phrase, row_count
) )
@@ -65,7 +65,7 @@ def register(mcp: FastMCP) -> None:
result = client.search_grid( result = client.search_grid(
"kea", "dhcpv4", "searchReservation", search_phrase, row_count "kea", "dhcpv4", "searchReservation", search_phrase, row_count
) )
except Exception: except OpnsenseAPIError:
result = client.search_grid( result = client.search_grid(
"dhcpv4", "settings", "searchReservation", search_phrase, row_count "dhcpv4", "settings", "searchReservation", search_phrase, row_count
) )

View File

@@ -8,7 +8,7 @@ from typing import Any
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from opnsense_mcp.audit import audit_log from opnsense_mcp.audit import audit_log
from opnsense_mcp.client import get_client from opnsense_mcp.client import OpnsenseAPIError, get_client
from opnsense_mcp.config import settings from opnsense_mcp.config import settings
from opnsense_mcp.guards import ( from opnsense_mcp.guards import (
GuardError, GuardError,
@@ -38,7 +38,7 @@ def register(mcp: FastMCP) -> None:
result = client.search_get( result = client.search_get(
"firewall", "filter", "searchRule", search_phrase, row_count, **extra "firewall", "filter", "searchRule", search_phrase, row_count, **extra
) )
except Exception: except OpnsenseAPIError:
result = client.search_grid( result = client.search_grid(
"firewall", "filter", "searchRule", search_phrase, row_count, **extra "firewall", "filter", "searchRule", search_phrase, row_count, **extra
) )

View File

@@ -0,0 +1,125 @@
"""Host registration composite tool: DHCP reservation + DNS override."""
from __future__ import annotations
import json
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 register(mcp: FastMCP) -> None:
@mcp.tool()
def opnsense_register_host(
mac: str,
ip: str,
hostname: str,
domain: str = "",
description: str = "",
) -> str:
"""Register a host by creating a DHCP static mapping and DNS host override, then reconfigure both services.
Returns a combined result with dhcp_result, dns_result, dhcp_reconfigure, dns_reconfigure.
"""
payload = {
"mac": mac,
"ip": ip,
"hostname": hostname,
"domain": domain,
"description": description,
}
if settings.dry_run:
return json.dumps(
is_dry_run_response("register_host", payload), indent=2
)
require_writes("register_host", payload)
client = get_client()
# ── 1. Add DHCP static mapping (try Kea, fall back to ISC) ──
dhcp_payload = {
"reservation": {
"hw_address": mac,
"ip_address": ip,
"hostname": hostname,
"description": description,
}
}
dhcp_result = {}
try:
dhcp_result = client.post(
"kea", "dhcpv4", "addReservation", data=dhcp_payload
)
except OpnsenseAPIError:
try:
dhcp_result = client.post(
"dhcpv4", "settings", "addReservation", data=dhcp_payload
)
except OpnsenseAPIError as exc:
dhcp_result = {
"error": str(exc),
"status_code": exc.status_code,
}
# ── 2. Add DNS host override ──
dns_host = {
"enabled": "1",
"hostname": hostname,
"domain": domain,
"rr": "A",
"server": ip,
"description": description,
}
dns_payload = {"host": dns_host}
dns_result = {}
try:
dns_result = client.post(
"unbound", "settings", "addHostOverride", data=dns_payload
)
except OpnsenseAPIError as exc:
dns_result = {
"error": str(exc),
"status_code": exc.status_code,
}
# ── 3. Reconfigure DHCP service ──
dhcp_reconf = {}
try:
dhcp_reconf = client.post("dhcpv4", "service", "reconfigure")
except OpnsenseAPIError as exc:
dhcp_reconf = {
"error": str(exc),
"status_code": exc.status_code,
}
# ── 4. Reconfigure Unbound DNS ──
dns_reconf = {}
try:
dns_reconf = client.post("unbound", "service", "reconfigure")
except OpnsenseAPIError as exc:
dns_reconf = {
"error": str(exc),
"status_code": exc.status_code,
}
audit_log("register_host", payload | {
"dhcp_result": dhcp_result,
"dns_result": dns_result,
})
return json.dumps(
{
"host": {"mac": mac, "ip": ip, "hostname": hostname, "domain": domain},
"dhcp_result": dhcp_result,
"dns_result": dns_result,
"dhcp_reconfigure": dhcp_reconf,
"dns_reconfigure": dns_reconf,
},
indent=2,
default=str,
)

View File

@@ -59,3 +59,45 @@ def register(mcp: FastMCP) -> None:
client = get_client() client = get_client()
result = client.get("diagnostics", "interface", "getVipStatus") result = client.get("diagnostics", "interface", "getVipStatus")
return json.dumps(result, indent=2, default=str) return json.dumps(result, indent=2, default=str)
@mcp.tool()
def opnsense_add_vlan(
interface: str = "opt1",
vlan_tag: int = 0,
description: str = "",
) -> str:
"""Add a new VLAN interface on a parent interface (e.g. opt1). Returns the new UUID."""
from opnsense_mcp.audit import audit_log
from opnsense_mcp.config import settings
from opnsense_mcp.guards import is_dry_run_response, require_writes
vlan = {
"vlanif": interface,
"tag": str(vlan_tag),
"descr": description,
}
payload = {"vlan": vlan}
if settings.dry_run:
return json.dumps(is_dry_run_response("add_vlan", payload), indent=2)
require_writes("add_vlan", payload)
client = get_client()
result = client.post(
"interfaces", "vlan_settings", "addItem", data=payload
)
audit_log("add_vlan", {"vlan": vlan, "result": result})
return json.dumps(result, indent=2, default=str)
@mcp.tool()
def opnsense_delete_vlan(uuid: str) -> str:
"""Delete a VLAN interface configuration by UUID."""
from opnsense_mcp.audit import audit_log
from opnsense_mcp.config import settings
from opnsense_mcp.guards import is_dry_run_response, require_writes
payload = {"uuid": uuid}
if settings.dry_run:
return json.dumps(is_dry_run_response("delete_vlan", payload), indent=2)
require_writes("delete_vlan", payload)
client = get_client()
result = client.post("interfaces", "vlan_settings", "delItem", uuid)
audit_log("delete_vlan", payload | {"result": result})
return json.dumps(result, indent=2, default=str)

View File

@@ -8,7 +8,7 @@ from typing import Any
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from opnsense_mcp.audit import audit_log from opnsense_mcp.audit import audit_log
from opnsense_mcp.client import get_client from opnsense_mcp.client import OpnsenseAPIError, get_client
from opnsense_mcp.config import settings from opnsense_mcp.config import settings
from opnsense_mcp.guards import is_dry_run_response, require_writes from opnsense_mcp.guards import is_dry_run_response, require_writes
@@ -17,7 +17,7 @@ def _search_nat(nat_type: str, search_phrase: str, row_count: int) -> str:
client = get_client() client = get_client()
try: try:
result = client.search_get("firewall", nat_type, "searchRule", search_phrase, row_count) result = client.search_get("firewall", nat_type, "searchRule", search_phrase, row_count)
except Exception: except OpnsenseAPIError:
result = client.search_grid("firewall", nat_type, "searchRule", search_phrase, row_count) result = client.search_grid("firewall", nat_type, "searchRule", search_phrase, row_count)
return json.dumps(result, indent=2, default=str) return json.dumps(result, indent=2, default=str)

View File

@@ -67,3 +67,39 @@ def register(mcp: FastMCP) -> None:
resources = client.get("diagnostics", "system", "systemResources") resources = client.get("diagnostics", "system", "systemResources")
memory = client.get("diagnostics", "system", "memory") memory = client.get("diagnostics", "system", "memory")
return json.dumps({"resources": resources, "memory": memory}, indent=2, default=str) return json.dumps({"resources": resources, "memory": memory}, indent=2, default=str)
@mcp.tool()
def opnsense_health_check() -> str:
"""Comprehensive health snapshot: gateways, interfaces, services, system resources, firmware, WireGuard status."""
client = get_client()
try:
gateways = client.get("routes", "gateway", "status")
except Exception:
gateways = {"error": "gateway check failed"}
try:
interfaces = client.get("interfaces", "overview", "interfacesInfo")
except Exception:
interfaces = {"error": "interface check failed"}
try:
services = client.search_grid("core", "service", "search", "", 50)
except Exception:
services = {"error": "service check failed"}
try:
resources = client.get("diagnostics", "system", "systemResources")
except Exception:
resources = {"error": "resource check failed"}
try:
firmware = client.get("core", "firmware", "status")
except Exception:
firmware = {"error": "firmware check failed"}
try:
wg_status = client.get("wireguard", "service", "show")
except Exception:
wg_status = {"error": "WireGuard check failed or not installed"}
return json.dumps({
"gateways": gateways,
"interfaces": interfaces,
"services": services,
"resources": resources,
"firmware": firmware,
"wireguard": wg_status,
}, indent=2, default=str)