Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Regel-Analyse: Bereinigung (nur Ansicht):** Auf **Regelwerk › Regel-Analyse** gibt es den Tab **Bereinigung** (`?tab=cleanup`). Er listet je Firewall die
Regeln, die einen Blick wert sind: **ungenutzt** (OPNexus hat in 7/30/90/180 Tagen keinen Treffer gesehen), **verdeckt** (eine frühere Quick-Regel
trifft schon alles, was sie trifft), **doppelt** und **lange deaktiviert**. Jeder Befund trägt eine Sicherheit (**sicher / vermutlich / unbekannt**, Tooltip mit
dem Grund): „sicher“ gibt es nur mit vollständigen, frischen Belegen, bei einer Regel mit Zeitplan, ohne geladenen pf-Regelsatz oder bei Aliasen bleibt es
höchstens „vermutlich“. Die Tabelle zeigt auch den letzten Treffer und ob die Regel entfernbar wäre (nicht: automatische Regeln, Regeln im Altformat, von OPNexus
verwaltete Regeln – die entfernt man über die Regelseite). Es wird nur gelesen; **Entfernen folgt in einer späteren Version**. Die Sicherheitslage verlinkt ihre
Befunde „ungenutzt/verdeckt/doppelt“ direkt dorthin; per API `GET /api/hosts/{id}/rule-analysis/cleanup?unused_days=30`, per MCP `rule_cleanup_candidates`.
- **Änderungsverlauf je Regel:** OPNexus liest alle 15 Minuten die Regeln jeder Firewall und merkt sich, was sich geändert hat – auch bei Regeln, die nicht von OPNexus
stammen: angelegt, geändert (mit Vorher/Nachher), aktiviert, deaktiviert, entfernt, jeweils mit der Herkunft „über OPNexus“ (passender Audit-Eintrag) oder „extern“.
Auf der Live-Regel-Ansicht (Regelwerk › Live über alle Hosts) öffnet die Zeilenaktion **Verlauf** die Ereignisse zusammen mit den gespeicherten Fassungen und dem Audit der
verwalteten Regel. Die erste Beobachtung speichert nur den Ausgangszustand; der Verlauf wird 365 Tage aufbewahrt (`GET /api/hosts/{id}/live-rules/{uuid}/history`).
Eine Umsortierung (andere Reihenfolge oder Gruppe) gilt als „verschoben“, nicht als Änderung. Eine leere oder unvollständige Antwort der Firewall (ohne
automatische Regeln) wird verworfen; verschwinden mehr als die Hälfte der Regeln auf einmal, zählt erst die zweite gleiche Beobachtung. Die Herkunft „über OPNexus“
erklärt nur Audit-Einträge seit der vorherigen Beobachtung, und jeder Eintrag nur eine Änderung.
Schemaänderungen 0027 und 0028 (Tabellen und ein Feld für den Beobachtungszeitraum) laufen beim Start.
- **Regelnutzung genauer:** Die Zähler kommen jetzt aus `ruleStats` (von der Firewall schon je Regel summiert; ältere Versionen weiterhin über den pf-Regelsatz) und werden alle
15 Minuten statt alle 6 Stunden gelesen. Regeln im Altformat (noch nicht migriert) werden ebenfalls erfasst. Das Beobachtungsfenster für „ungenutzt“ beginnt neu, wenn eine Regel eingeschaltet oder geändert wird, und nach einem pf-Neuladen mit langer Lücke bei den Zählern.
- **Verdeckt/doppelt in der Sicherheitslage genauer:** Der Vergleich kennt jetzt die echte Reihenfolge (Floating vor Interface-Gruppen vor Interface-Regeln), Netze und
Portbereiche als Mengen (z. B. 10.1.0.0/16 liegt in 10.0.0.0/8), `inet46` und `tcp/udp`, und übergeht Regeln mit Zeitplan, Markierung oder Limits. Es findet dadurch
mehr verdeckte Regeln als bisher, nie weniger belegte.
245 lines
12 KiB
Python
245 lines
12 KiB
Python
"""Read-only MCP server (Model Context Protocol, streamable HTTP transport).
|
|
|
|
Lets an AI assistant (Claude Code, Claude Desktop, ...) look at the fleet:
|
|
|
|
claude mcp add --transport http opnexus https://<opnexus>/api/mcp \
|
|
--header "Authorization: Bearer opx_..."
|
|
|
|
Stateless JSON-RPC over POST (the spec allows plain ``application/json``
|
|
replies; there is no server-initiated stream, GET answers 405). Every tool is a
|
|
GET against OPNexus' own API, sent through the app itself with the caller's own
|
|
credentials, so exactly the caller's permissions apply: a ``read`` token sees
|
|
what a ``read`` token sees, secret-bearing endpoints stay closed. Nothing here
|
|
can change state.
|
|
"""
|
|
import json
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.version import APP_VERSION
|
|
|
|
router = APIRouter(tags=["mcp"])
|
|
|
|
SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
MAX_RESULT_CHARS = 60_000
|
|
# Not reachable through the generic reader (credentials management, the MCP endpoint itself).
|
|
_BLOCKED_PREFIXES = ("/api/tokens", "/api/auth", "/api/mcp")
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "fleet_overview",
|
|
"description": "Fleet status at a glance: every firewall with online state and CARP role, items that need "
|
|
"attention (failed or unconfirmed deployments, stuck maintenance) and the latest activity.",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
"path": "/api/dashboard",
|
|
},
|
|
{
|
|
"name": "list_hosts",
|
|
"description": "All managed firewalls with name, URL, cluster and whether monitoring is enabled.",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
"path": "/api/hosts",
|
|
},
|
|
{
|
|
"name": "host_resources",
|
|
"description": "Recent CPU load, memory and disk usage samples of one firewall (a sample per minute).",
|
|
"inputSchema": {
|
|
"type": "object", "required": ["host_id"],
|
|
"properties": {
|
|
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
|
|
"since_hours": {"type": "integer", "minimum": 1, "maximum": 720, "default": 1},
|
|
},
|
|
},
|
|
"path": "/api/hosts/{host_id}/resource-samples",
|
|
"query": ("since_hours",),
|
|
# The raw per-sample payload (every interface counter) would drown the useful numbers.
|
|
"drop": ("raw",),
|
|
},
|
|
{
|
|
"name": "carp_events",
|
|
"description": "History of CARP role changes (MASTER/BACKUP) across the clusters.",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
"path": "/api/cluster/carp-events",
|
|
},
|
|
{
|
|
"name": "recent_audit",
|
|
"description": "The audit trail: who changed or deployed what, newest first.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25},
|
|
"kind": {"type": "string", "description": "e.g. rule, alias, host, user"},
|
|
"status": {"type": "string", "description": "e.g. accepted, rejected, denied"},
|
|
"q": {"type": "string", "description": "free-text filter"},
|
|
},
|
|
},
|
|
"path": "/api/audit",
|
|
"query": ("limit", "kind", "status", "q"),
|
|
},
|
|
{
|
|
"name": "search",
|
|
"description": "Search rules, aliases, NAT, VPN, certificates, groups and hosts by text.",
|
|
"inputSchema": {
|
|
"type": "object", "required": ["q"],
|
|
"properties": {"q": {"type": "string", "description": "at least 2 characters"}},
|
|
},
|
|
"path": "/api/search",
|
|
"query": ("q",),
|
|
},
|
|
{
|
|
"name": "rule_match",
|
|
"description": "Which firewall rule decides a packet (first packet of a new connection), per firewall, "
|
|
"with the verdict (pass/block/reject), the deciding rule and an honest confidence "
|
|
"(sure/probable/unknown plus reason codes). Reads only; nothing is changed.",
|
|
"inputSchema": {
|
|
"type": "object", "required": ["src", "dst"],
|
|
"properties": {
|
|
"src": {"type": "string", "description": "source IP address"},
|
|
"dst": {"type": "string", "description": "destination IP address"},
|
|
"proto": {"type": "string", "description": "tcp, udp, icmp, ipv6-icmp or a protocol number", "default": "tcp"},
|
|
"dport": {"type": "integer", "minimum": 0, "maximum": 65535},
|
|
"sport": {"type": "integer", "minimum": 0, "maximum": 65535, "description": "default: any ephemeral port"},
|
|
"icmptype": {"type": "string", "description": "e.g. echoreq or 8"},
|
|
"iface": {"type": "string", "description": "ingress interface identifier (e.g. lan); default: guessed per firewall"},
|
|
"egress": {"type": "string", "description": "also check the outgoing side: auto or an interface identifier"},
|
|
"host_ids": {"type": "string", "description": "comma separated firewall ids (default: all)"},
|
|
"group_id": {"type": "string", "description": "device group id instead of host_ids"},
|
|
},
|
|
},
|
|
"path": "/api/rule-analysis/match",
|
|
"query": ("src", "dst", "proto", "dport", "sport", "icmptype", "iface", "egress", "host_ids", "group_id"),
|
|
# The per-rule trace is for the UI; the verdict, deciding rule and reasons are what matters here.
|
|
"drop": ("trace",),
|
|
},
|
|
{
|
|
"name": "rule_cleanup_candidates",
|
|
"description": "Cleanup candidates of one firewall: rules that are unused (no hit for N days), shadowed or "
|
|
"duplicate (an earlier quick rule already decides everything they match), or disabled for a "
|
|
"long time. Each with its confidence (sure/probable/unknown), the last observed hit and "
|
|
"whether it could be removed (not for automatic, legacy or OPNexus-managed rules). Reads only.",
|
|
"inputSchema": {
|
|
"type": "object", "required": ["host_id"],
|
|
"properties": {
|
|
"host_id": {"type": "string", "description": "Firewall id from list_hosts"},
|
|
"unused_days": {"type": "integer", "minimum": 1, "maximum": 3650, "default": 30,
|
|
"description": "observation window for unused / disabled rules"},
|
|
},
|
|
},
|
|
"path": "/api/hosts/{host_id}/rule-analysis/cleanup",
|
|
"query": ("unused_days",),
|
|
},
|
|
{
|
|
"name": "api_get",
|
|
"description": "Read any other OPNexus API endpoint (GET only), e.g. /api/rules, /api/aliases, /api/logs. "
|
|
"The schema of all endpoints is at /api/openapi.json.",
|
|
"inputSchema": {
|
|
"type": "object", "required": ["path"],
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Starts with /api/"},
|
|
"query": {"type": "object", "description": "Query parameters", "additionalProperties": {"type": "string"}},
|
|
},
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _tool_list() -> list[dict]:
|
|
return [{k: v for k, v in tool.items() if k in ("name", "description", "inputSchema")} for tool in TOOLS]
|
|
|
|
|
|
def _text(text: str, is_error: bool = False) -> dict:
|
|
if len(text) > MAX_RESULT_CHARS:
|
|
text = text[:MAX_RESULT_CHARS] + f"\n… truncated ({len(text)} characters in total); narrow the request."
|
|
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
|
|
|
|
async def _get(request: Request, path: str, query: dict, drop: tuple = ()) -> dict:
|
|
if not path.startswith("/api/") or ".." in path or "?" in path or "#" in path or path.startswith(_BLOCKED_PREFIXES):
|
|
return _text("This path cannot be read through MCP.", True)
|
|
headers = {k: v for k in ("authorization", "cookie") if (v := request.headers.get(k))}
|
|
url = path + (("?" + urlencode(query)) if query else "")
|
|
transport = httpx.ASGITransport(app=request.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://opnexus", headers=headers, timeout=30) as client:
|
|
response = await client.get(url)
|
|
body = response.text
|
|
try:
|
|
data = response.json()
|
|
if drop and isinstance(data, list):
|
|
data = [{k: v for k, v in item.items() if k not in drop} if isinstance(item, dict) else item for item in data]
|
|
body = json.dumps(data, ensure_ascii=False, indent=1)
|
|
except ValueError:
|
|
pass
|
|
return _text(body, response.status_code >= 400)
|
|
|
|
|
|
async def _call_tool(request: Request, name: str, arguments: dict) -> dict:
|
|
tool = next((t for t in TOOLS if t["name"] == name), None)
|
|
if tool is None:
|
|
return _text(f"Unknown tool: {name}", True)
|
|
if name == "api_get":
|
|
query = arguments.get("query") or {}
|
|
if not isinstance(arguments.get("path"), str) or not isinstance(query, dict):
|
|
return _text("path (string) is required.", True)
|
|
return await _get(request, arguments["path"], {str(k): str(v) for k, v in query.items()})
|
|
path = tool["path"]
|
|
if "{host_id}" in path:
|
|
host_id = str(arguments.get("host_id", ""))
|
|
if not host_id.replace("-", "").isalnum():
|
|
return _text("host_id is required (see list_hosts).", True)
|
|
path = path.format(host_id=host_id)
|
|
query = {key: arguments[key] for key in tool.get("query", ()) if arguments.get(key) not in (None, "")}
|
|
return await _get(request, path, query, tool.get("drop", ()))
|
|
|
|
|
|
def _error(request_id, code: int, message: str) -> dict:
|
|
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
|
|
|
|
async def _handle(request: Request, message: dict) -> dict | None:
|
|
"""The JSON-RPC response for one message, None for a notification."""
|
|
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or "method" not in message:
|
|
return _error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request")
|
|
method, request_id = message["method"], message.get("id")
|
|
params = message.get("params") or {}
|
|
if "id" not in message: # notification (e.g. notifications/initialized): nothing to answer
|
|
return None
|
|
if method == "initialize":
|
|
requested = params.get("protocolVersion")
|
|
return {"jsonrpc": "2.0", "id": request_id, "result": {
|
|
"protocolVersion": requested if requested in SUPPORTED_VERSIONS else SUPPORTED_VERSIONS[0],
|
|
"capabilities": {"tools": {"listChanged": False}},
|
|
"serverInfo": {"name": "opnexus", "version": APP_VERSION},
|
|
"instructions": "Read-only access to an OPNsense fleet managed by OPNexus.",
|
|
}}
|
|
if method == "ping":
|
|
return {"jsonrpc": "2.0", "id": request_id, "result": {}}
|
|
if method == "tools/list":
|
|
return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": _tool_list()}}
|
|
if method == "tools/call":
|
|
arguments = params.get("arguments") or {}
|
|
if not isinstance(params.get("name"), str) or not isinstance(arguments, dict):
|
|
return _error(request_id, -32602, "name (string) and arguments (object) are required")
|
|
return {"jsonrpc": "2.0", "id": request_id, "result": await _call_tool(request, params["name"], arguments)}
|
|
return _error(request_id, -32601, f"Method not found: {method}")
|
|
|
|
|
|
@router.post("/mcp")
|
|
async def mcp_endpoint(request: Request):
|
|
try:
|
|
payload = await request.json()
|
|
except ValueError:
|
|
return JSONResponse(_error(None, -32700, "Parse error"), status_code=400)
|
|
if isinstance(payload, list):
|
|
replies = [reply for message in payload if (reply := await _handle(request, message)) is not None]
|
|
return JSONResponse(replies) if replies else Response(status_code=202)
|
|
reply = await _handle(request, payload)
|
|
return JSONResponse(reply) if reply is not None else Response(status_code=202)
|
|
|
|
|
|
@router.get("/mcp")
|
|
async def mcp_no_stream():
|
|
# No server-initiated messages: tell clients not to expect an SSE stream.
|
|
return Response(status_code=405, headers={"Allow": "POST"})
|