Initial import from Proxmox host
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Copy to .env or set in ~/.hermes/config.yaml under mcp_servers.opnsense.env
|
||||||
|
# Create keys in OPNsense: System → Access → Users → API keys
|
||||||
|
|
||||||
|
OPNSENSE_URL=https://10.77.30.1:40443
|
||||||
|
OPNSENSE_API_KEY=REPLACE_WITH_YOUR_API_KEY
|
||||||
|
OPNSENSE_API_SECRET=REPLACE_WITH_YOUR_API_SECRET
|
||||||
|
OPNSENSE_VERIFY_SSL=false
|
||||||
|
OPNSENSE_TIMEOUT=60
|
||||||
|
|
||||||
|
# Safety — enable writes only when ready
|
||||||
|
OPNSENSE_ALLOW_WRITES=false
|
||||||
|
OPNSENSE_DRY_RUN=false
|
||||||
|
OPNSENSE_REQUIRE_DESCRIPTION_PREFIX=mcp:
|
||||||
|
OPNSENSE_BLOCK_WAN_INBOUND_ANY=true
|
||||||
|
OPNSENSE_AUDIT_LOG_PATH=/root/.hermes/logs/opnsense-mcp-audit.jsonl
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
150
README.md
Normal file
150
README.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# OPNsense MCP
|
||||||
|
|
||||||
|
Model Context Protocol server for [OPNsense](https://opnsense.org/) firewall management. Designed for use with [Hermes Agent](https://github.com/NousResearch/hermes-agent) and any MCP-compatible client.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **60+ tools** covering system info, interfaces, firewall rules, aliases, NAT, DHCP, diagnostics, routes, and gateways
|
||||||
|
- **Safety guardrails**: write operations disabled by default, dry-run mode, WAN rule blocking, required description prefix
|
||||||
|
- **Savepoint workflow**: create savepoint → apply → cancel rollback (matches OPNsense official API pattern)
|
||||||
|
- **Audit logging**: all mutations logged to JSONL
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create an OPNsense API key
|
||||||
|
|
||||||
|
In OPNsense: **System → Access → Users** → edit user → add API key. Download the key/secret pair.
|
||||||
|
|
||||||
|
Grant only the privileges needed. For read-only use, limit to read privileges. For writes, include firewall, aliases, routes, etc.
|
||||||
|
|
||||||
|
### 2. Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e /path/to/opnsense-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with the Hermes venv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/local/lib/hermes-agent/venv/bin/pip install -e /root/opnsense-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your API key, secret, and firewall URL
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `OPNSENSE_URL` | `https://10.77.30.1:40443` | Firewall base URL |
|
||||||
|
| `OPNSENSE_API_KEY` | — | API key (required) |
|
||||||
|
| `OPNSENSE_API_SECRET` | — | API secret (required) |
|
||||||
|
| `OPNSENSE_VERIFY_SSL` | `false` | Verify TLS certificate |
|
||||||
|
| `OPNSENSE_ALLOW_WRITES` | `false` | Enable mutating operations |
|
||||||
|
| `OPNSENSE_DRY_RUN` | `false` | Log writes without executing |
|
||||||
|
| `OPNSENSE_REQUIRE_DESCRIPTION_PREFIX` | `mcp:` | Prefix for new firewall rules |
|
||||||
|
| `OPNSENSE_BLOCK_WAN_INBOUND_ANY` | `true` | Block WAN allow-all rules |
|
||||||
|
|
||||||
|
### 4. Register with Hermes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes mcp add opnsense \
|
||||||
|
--command /usr/local/lib/hermes-agent/venv/bin/opnsense-mcp \
|
||||||
|
--env OPNSENSE_URL=https://10.77.30.1:40443 \
|
||||||
|
--env OPNSENSE_API_KEY=your_key \
|
||||||
|
--env OPNSENSE_API_SECRET=your_secret \
|
||||||
|
--env OPNSENSE_VERIFY_SSL=false \
|
||||||
|
--env OPNSENSE_ALLOW_WRITES=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add to `~/.hermes/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
opnsense:
|
||||||
|
command: "/usr/local/lib/hermes-agent/venv/bin/opnsense-mcp"
|
||||||
|
env:
|
||||||
|
OPNSENSE_URL: "https://10.77.30.1:40443"
|
||||||
|
OPNSENSE_API_KEY: "your_key"
|
||||||
|
OPNSENSE_API_SECRET: "your_secret"
|
||||||
|
OPNSENSE_VERIFY_SSL: "false"
|
||||||
|
OPNSENSE_ALLOW_WRITES: "false"
|
||||||
|
timeout: 120
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Hermes gateway after adding.
|
||||||
|
|
||||||
|
### 5. Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes mcp test opnsense
|
||||||
|
```
|
||||||
|
|
||||||
|
Or ask Hermes: *"Use opnsense_test_connection to verify firewall access"*
|
||||||
|
|
||||||
|
## Tool Categories
|
||||||
|
|
||||||
|
### Read-only (always available with valid API key)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `opnsense_test_connection` | Verify API auth and connectivity |
|
||||||
|
| `opnsense_get_system_info` | Version, uptime, health |
|
||||||
|
| `opnsense_list_interfaces` | Interface status and IPs |
|
||||||
|
| `opnsense_search_firewall_rules` | Search filter rules |
|
||||||
|
| `opnsense_search_aliases` | Search aliases |
|
||||||
|
| `opnsense_search_dhcp_leases` | DHCP lease table |
|
||||||
|
| `opnsense_get_arp_table` | ARP / IP→MAC |
|
||||||
|
| `opnsense_get_gateway_status` | WAN gateway health |
|
||||||
|
| `opnsense_get_firewall_log` | Recent blocked/ passed traffic |
|
||||||
|
| `opnsense_get_pf_states` | Active connection states |
|
||||||
|
| `opnsense_search_port_forwards` | Port forward rules |
|
||||||
|
|
||||||
|
### Write operations (require `OPNSENSE_ALLOW_WRITES=true`)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `opnsense_add_firewall_rule` | Add filter rule (prefix required) |
|
||||||
|
| `opnsense_toggle_firewall_rule` | Enable/disable rule |
|
||||||
|
| `opnsense_create_firewall_savepoint` | Snapshot before changes |
|
||||||
|
| `opnsense_apply_firewall` | Apply pending changes |
|
||||||
|
| `opnsense_cancel_firewall_rollback` | Confirm changes (cancel 60s rollback) |
|
||||||
|
| `opnsense_safe_apply_firewall_rule_change` | Savepoint + toggle + apply workflow |
|
||||||
|
| `opnsense_alias_add_entry` | Add IP to alias |
|
||||||
|
| `opnsense_add_d_nat_rule` | Add port forward |
|
||||||
|
|
||||||
|
## Safe Change Workflow
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. opnsense_create_firewall_savepoint → returns revision
|
||||||
|
2. opnsense_toggle_firewall_rule(uuid) → make change
|
||||||
|
3. opnsense_apply_firewall(revision) → apply with 60s auto-rollback
|
||||||
|
4. Verify connectivity
|
||||||
|
5. opnsense_cancel_firewall_rollback(revision) → keep change
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `opnsense_safe_apply_firewall_rule_change` which combines steps 1–3.
|
||||||
|
|
||||||
|
## Standalone Usage
|
||||||
|
|
||||||
|
Run directly as an MCP stdio server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPNSENSE_API_KEY=... OPNSENSE_API_SECRET=... opnsense-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatible with Cursor, Claude Desktop, and other MCP clients.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit API keys. Use environment variables or Hermes secrets.
|
||||||
|
- Start with read-only (`ALLOW_WRITES=false`) until you trust the agent workflows.
|
||||||
|
- Use a dedicated OPNsense user with minimal privileges.
|
||||||
|
- Review audit log at `OPNSENSE_AUDIT_LOG_PATH`.
|
||||||
|
- Enable Hermes `approvals: mode: manual` for production use.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "opnsense-mcp"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Model Context Protocol server for OPNsense firewall management"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [{ name = "OPNsense MCP" }]
|
||||||
|
keywords = ["mcp", "opnsense", "firewall", "hermes"]
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.0.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
opnsense-mcp = "opnsense_mcp.server:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/opnsense_mcp"]
|
||||||
3
src/opnsense_mcp/__init__.py
Normal file
3
src/opnsense_mcp/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""OPNsense MCP server — Model Context Protocol integration for OPNsense firewalls."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
4
src/opnsense_mcp/__main__.py
Normal file
4
src/opnsense_mcp/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from opnsense_mcp.server import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
23
src/opnsense_mcp/audit.py
Normal file
23
src/opnsense_mcp/audit.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Append-only audit log for mutating operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def audit_log(action: str, details: dict[str, Any], *, dry_run: bool = False) -> None:
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"action": action,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
**details,
|
||||||
|
}
|
||||||
|
path = Path(settings.audit_log_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(entry, default=str) + "\n")
|
||||||
177
src/opnsense_mcp/client.py
Normal file
177
src/opnsense_mcp/client.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""HTTP client for the OPNsense REST API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
from opnsense_mcp.guards import GuardError, require_credentials
|
||||||
|
|
||||||
|
|
||||||
|
class OpnsenseAPIError(Exception):
|
||||||
|
"""Raised when the OPNsense API returns an error."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None, body: Any = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
class OpnsenseClient:
|
||||||
|
"""Thin wrapper around OPNsense /api/{module}/{controller}/{command} endpoints."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_secret: str | None = None,
|
||||||
|
*,
|
||||||
|
verify_ssl: bool | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
):
|
||||||
|
self.base_url = (url or settings.url).rstrip("/")
|
||||||
|
self.api_key = api_key if api_key is not None else settings.api_key
|
||||||
|
self.api_secret = api_secret if api_secret is not None else settings.api_secret
|
||||||
|
self.verify_ssl = verify_ssl if verify_ssl is not None else settings.verify_ssl
|
||||||
|
self.timeout = timeout if timeout is not None else settings.timeout
|
||||||
|
|
||||||
|
def _build_url(
|
||||||
|
self,
|
||||||
|
module: str,
|
||||||
|
controller: str,
|
||||||
|
command: str,
|
||||||
|
*path_parts: str | int,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
segments = [self.base_url, "api", module, controller, command]
|
||||||
|
for part in path_parts:
|
||||||
|
if part is not None and str(part) != "":
|
||||||
|
segments.append(str(part))
|
||||||
|
url = "/".join(segments)
|
||||||
|
if params:
|
||||||
|
clean = {k: v for k, v in params.items() if v is not None}
|
||||||
|
if clean:
|
||||||
|
url = f"{url}?{urlencode(clean)}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
module: str,
|
||||||
|
controller: str,
|
||||||
|
command: str,
|
||||||
|
*path_parts: str | int,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
require_credentials()
|
||||||
|
url = self._build_url(module, controller, command, *path_parts, params=params)
|
||||||
|
with httpx.Client(verify=self.verify_ssl, timeout=self.timeout) as client:
|
||||||
|
response = client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
auth=(self.api_key, self.api_secret),
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
body = response.text
|
||||||
|
raise OpnsenseAPIError(
|
||||||
|
f"OPNsense API error {response.status_code} for {method} {url}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
if not response.content:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"raw": response.text}
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
module: str,
|
||||||
|
controller: str,
|
||||||
|
command: str,
|
||||||
|
*path_parts: str | int,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
return self._request("GET", module, controller, command, *path_parts, params=params)
|
||||||
|
|
||||||
|
def post(
|
||||||
|
self,
|
||||||
|
module: str,
|
||||||
|
controller: str,
|
||||||
|
command: str,
|
||||||
|
*path_parts: str | int,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
return self._request(
|
||||||
|
"POST", module, controller, command, *path_parts, params=params, data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Search helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search_body(
|
||||||
|
search_phrase: str = "",
|
||||||
|
current: int = 1,
|
||||||
|
row_count: int = 100,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"current": current,
|
||||||
|
"rowCount": row_count,
|
||||||
|
"sort": {},
|
||||||
|
"searchPhrase": search_phrase,
|
||||||
|
}
|
||||||
|
body.update(extra)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def search_grid(
|
||||||
|
self,
|
||||||
|
module: str,
|
||||||
|
controller: str,
|
||||||
|
command: str,
|
||||||
|
search_phrase: str = "",
|
||||||
|
row_count: int = 100,
|
||||||
|
**extra: Any,
|
||||||
|
) -> Any:
|
||||||
|
"""POST to a search* endpoint returning {rows, total, ...}."""
|
||||||
|
return self.post(
|
||||||
|
module,
|
||||||
|
controller,
|
||||||
|
command,
|
||||||
|
data=self.search_body(search_phrase, row_count=row_count, **extra),
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_get(
|
||||||
|
self,
|
||||||
|
module: str,
|
||||||
|
controller: str,
|
||||||
|
command: str,
|
||||||
|
search_phrase: str = "",
|
||||||
|
row_count: int = 100,
|
||||||
|
**extra: Any,
|
||||||
|
) -> Any:
|
||||||
|
"""GET variant used by some firewall search endpoints."""
|
||||||
|
params = {"current": 1, "rowCount": row_count, "searchPhrase": search_phrase}
|
||||||
|
params.update(extra)
|
||||||
|
return self.get(module, controller, command, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
_client: OpnsenseClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client() -> OpnsenseClient:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = OpnsenseClient()
|
||||||
|
return _client
|
||||||
25
src/opnsense_mcp/config.py
Normal file
25
src/opnsense_mcp/config.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Configuration loaded from environment variables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="OPNSENSE_", extra="ignore")
|
||||||
|
|
||||||
|
url: str = "https://10.77.30.1:40443"
|
||||||
|
api_key: str = ""
|
||||||
|
api_secret: str = ""
|
||||||
|
verify_ssl: bool = False
|
||||||
|
timeout: float = 60.0
|
||||||
|
|
||||||
|
# Safety controls
|
||||||
|
allow_writes: bool = False
|
||||||
|
dry_run: bool = False
|
||||||
|
require_description_prefix: str = "mcp:"
|
||||||
|
block_wan_inbound_any: bool = True
|
||||||
|
audit_log_path: str = "/root/.hermes/logs/opnsense-mcp-audit.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
77
src/opnsense_mcp/guards.py
Normal file
77
src/opnsense_mcp/guards.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Safety guardrails for mutating firewall operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from opnsense_mcp.audit import audit_log
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class GuardError(Exception):
|
||||||
|
"""Raised when a safety guard blocks an operation."""
|
||||||
|
|
||||||
|
|
||||||
|
def require_credentials() -> None:
|
||||||
|
if not settings.api_key or not settings.api_secret:
|
||||||
|
raise GuardError(
|
||||||
|
"OPNSENSE_API_KEY and OPNSENSE_API_SECRET must be set. "
|
||||||
|
"Create an API key in OPNsense: System → Access → Users."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_writes(action: str, payload: dict[str, Any] | None = None) -> None:
|
||||||
|
require_credentials()
|
||||||
|
if settings.dry_run:
|
||||||
|
audit_log(action, {"payload": payload or {}, "blocked": "dry_run"}, dry_run=True)
|
||||||
|
return
|
||||||
|
if not settings.allow_writes:
|
||||||
|
raise GuardError(
|
||||||
|
f"Write operation '{action}' blocked. Set OPNSENSE_ALLOW_WRITES=true to enable."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_dry_run_response(action: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
audit_log(action, {"payload": payload or {}}, dry_run=True)
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"action": action,
|
||||||
|
"payload": payload,
|
||||||
|
"message": "Dry run — no changes applied. Set OPNSENSE_DRY_RUN=false to execute.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_firewall_rule(rule: dict[str, Any], *, is_new: bool = False) -> None:
|
||||||
|
"""Reject dangerous or mis-tagged firewall rules."""
|
||||||
|
description = str(rule.get("description", ""))
|
||||||
|
if is_new and settings.require_description_prefix:
|
||||||
|
prefix = settings.require_description_prefix
|
||||||
|
if prefix and not description.lower().startswith(prefix.lower()):
|
||||||
|
raise GuardError(
|
||||||
|
f"New rules must have description starting with '{prefix}'. "
|
||||||
|
f"Got: '{description}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not settings.block_wan_inbound_any:
|
||||||
|
return
|
||||||
|
|
||||||
|
interface = str(rule.get("interface", rule.get("interface_name", ""))).lower()
|
||||||
|
action = str(rule.get("action", "pass")).lower()
|
||||||
|
source = str(rule.get("source_net", rule.get("source", "any"))).lower()
|
||||||
|
dest = str(rule.get("destination_net", rule.get("destination", "any"))).lower()
|
||||||
|
|
||||||
|
wan_markers = ("wan", "opt", "pppoe", "dhcp")
|
||||||
|
is_wan = any(m in interface for m in wan_markers) or interface in ("", "any")
|
||||||
|
|
||||||
|
if action in ("pass", "allow") and is_wan:
|
||||||
|
if source in ("any", "", "0.0.0.0/0", "::/0") and dest not in ("any", "", "this firewall"):
|
||||||
|
raise GuardError(
|
||||||
|
"Blocked: inbound WAN allow rule with source 'any' is not permitted. "
|
||||||
|
"Use a specific source CIDR or disable OPNSENSE_BLOCK_WAN_INBOUND_ANY."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_alias_mutation(name: str) -> None:
|
||||||
|
protected = {"bogons", "bogonsv6", "virusprot", "sshlockout", "__wan_network"}
|
||||||
|
if name.lower() in protected:
|
||||||
|
raise GuardError(f"Alias '{name}' is protected and cannot be modified via MCP.")
|
||||||
83
src/opnsense_mcp/server.py
Normal file
83
src/opnsense_mcp/server.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""OPNsense MCP server entrypoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from opnsense_mcp import __version__
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
from opnsense_mcp.tools import register_all
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"opnsense",
|
||||||
|
instructions=(
|
||||||
|
"OPNsense firewall management MCP. Use read tools freely for diagnostics. "
|
||||||
|
"Write tools require OPNSENSE_ALLOW_WRITES=true. New firewall rules must use "
|
||||||
|
f"description prefix '{settings.require_description_prefix}'. "
|
||||||
|
"For risky changes, use savepoint + apply + cancel_rollback workflow."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_config() -> str:
|
||||||
|
"""Get current MCP server configuration (no secrets exposed)."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"url": settings.url,
|
||||||
|
"verify_ssl": settings.verify_ssl,
|
||||||
|
"allow_writes": settings.allow_writes,
|
||||||
|
"dry_run": settings.dry_run,
|
||||||
|
"require_description_prefix": settings.require_description_prefix,
|
||||||
|
"block_wan_inbound_any": settings.block_wan_inbound_any,
|
||||||
|
"api_key_configured": bool(settings.api_key),
|
||||||
|
"version": __version__,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_test_connection() -> str:
|
||||||
|
"""Test connectivity and authentication to the OPNsense API."""
|
||||||
|
from opnsense_mcp.client import OpnsenseAPIError, get_client
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
try:
|
||||||
|
firmware = client.get("core", "firmware", "status")
|
||||||
|
gateways = client.get("routes", "gateway", "status")
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"url": settings.url,
|
||||||
|
"firmware": firmware,
|
||||||
|
"gateways": gateways,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
except OpnsenseAPIError as exc:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"url": settings.url,
|
||||||
|
"message": str(exc),
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"body": exc.body,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
register_all(mcp)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
25
src/opnsense_mcp/tools/__init__.py
Normal file
25
src/opnsense_mcp/tools/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tool package registration."""
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from opnsense_mcp.tools import (
|
||||||
|
aliases,
|
||||||
|
dhcp,
|
||||||
|
diagnostics,
|
||||||
|
firewall,
|
||||||
|
interfaces,
|
||||||
|
nat,
|
||||||
|
routes,
|
||||||
|
system,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_all(mcp: FastMCP) -> None:
|
||||||
|
system.register(mcp)
|
||||||
|
interfaces.register(mcp)
|
||||||
|
firewall.register(mcp)
|
||||||
|
aliases.register(mcp)
|
||||||
|
nat.register(mcp)
|
||||||
|
dhcp.register(mcp)
|
||||||
|
diagnostics.register(mcp)
|
||||||
|
routes.register(mcp)
|
||||||
148
src/opnsense_mcp/tools/aliases.py
Normal file
148
src/opnsense_mcp/tools/aliases.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Firewall alias 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 get_client
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
from opnsense_mcp.guards import is_dry_run_response, require_writes, validate_alias_mutation
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_aliases(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search firewall aliases (host, network, port, URL, etc.)."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.search_grid("firewall", "alias", "searchItem", search_phrase, row_count)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_alias(uuid: str = "", name: str = "") -> str:
|
||||||
|
"""Get alias details by UUID or by name."""
|
||||||
|
client = get_client()
|
||||||
|
if name:
|
||||||
|
result = client.get("firewall", "alias", "getAliasUUID", name)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
result = client.get("firewall", "alias", "getItem", uuid if uuid else None)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_list_network_aliases() -> str:
|
||||||
|
"""List all network-type aliases."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("firewall", "alias", "listNetworkAliases")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_add_alias(alias: dict[str, Any]) -> str:
|
||||||
|
"""Add a firewall alias. Fields: name, type (host/network/port/url/etc), content, description, enabled."""
|
||||||
|
name = str(alias.get("name", ""))
|
||||||
|
validate_alias_mutation(name)
|
||||||
|
payload = {"alias": alias}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("add_alias", payload), indent=2)
|
||||||
|
require_writes("add_alias", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias", "addItem", data=payload)
|
||||||
|
audit_log("add_alias", {"alias": alias, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_update_alias(uuid: str, alias: dict[str, Any]) -> str:
|
||||||
|
"""Update an existing alias by UUID."""
|
||||||
|
validate_alias_mutation(str(alias.get("name", "")))
|
||||||
|
payload = {"alias": alias}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("update_alias", {"uuid": uuid, **payload}), indent=2)
|
||||||
|
require_writes("update_alias", {"uuid": uuid, **payload})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias", "setItem", uuid, data=payload)
|
||||||
|
audit_log("update_alias", {"uuid": uuid, "alias": alias, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_alias(uuid: str) -> str:
|
||||||
|
"""Delete an alias by UUID."""
|
||||||
|
payload = {"uuid": uuid}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("delete_alias", payload), indent=2)
|
||||||
|
require_writes("delete_alias", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias", "delItem", uuid)
|
||||||
|
audit_log("delete_alias", {"uuid": uuid, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_toggle_alias(uuid: str, enabled: bool = True) -> str:
|
||||||
|
"""Enable or disable an alias."""
|
||||||
|
payload = {"uuid": uuid, "enabled": enabled}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("toggle_alias", payload), indent=2)
|
||||||
|
require_writes("toggle_alias", payload)
|
||||||
|
client = get_client()
|
||||||
|
flag = "1" if enabled else "0"
|
||||||
|
result = client.post("firewall", "alias", "toggleItem", uuid, flag)
|
||||||
|
audit_log("toggle_alias", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_alias_add_entry(alias_name: str, entry: str) -> str:
|
||||||
|
"""Add a host/IP/network entry to an existing alias (alias_util/add)."""
|
||||||
|
validate_alias_mutation(alias_name)
|
||||||
|
payload = {"alias": alias_name, "entry": entry}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("alias_add_entry", payload), indent=2)
|
||||||
|
require_writes("alias_add_entry", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias_util", "add", alias_name, data={"address": entry})
|
||||||
|
audit_log("alias_add_entry", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_alias_remove_entry(alias_name: str, entry: str) -> str:
|
||||||
|
"""Remove an entry from an alias (alias_util/delete)."""
|
||||||
|
validate_alias_mutation(alias_name)
|
||||||
|
payload = {"alias": alias_name, "entry": entry}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("alias_remove_entry", payload), indent=2)
|
||||||
|
require_writes("alias_remove_entry", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias_util", "delete", alias_name, data={"address": entry})
|
||||||
|
audit_log("alias_remove_entry", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_alias_list_entries(alias_name: str) -> str:
|
||||||
|
"""List all entries/contents of a specific alias."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("firewall", "alias_util", "list", alias_name)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_alias_flush(alias_name: str) -> str:
|
||||||
|
"""Remove all entries from an alias (alias_util/flush)."""
|
||||||
|
validate_alias_mutation(alias_name)
|
||||||
|
payload = {"alias": alias_name}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("alias_flush", payload), indent=2)
|
||||||
|
require_writes("alias_flush", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias_util", "flush", alias_name)
|
||||||
|
audit_log("alias_flush", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_reconfigure_aliases() -> str:
|
||||||
|
"""Apply/reconfigure alias changes."""
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("reconfigure_aliases", {}), indent=2)
|
||||||
|
require_writes("reconfigure_aliases", {})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "alias", "reconfigure")
|
||||||
|
audit_log("reconfigure_aliases", {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
58
src/opnsense_mcp/tools/dhcp.py
Normal file
58
src/opnsense_mcp/tools/dhcp.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""DHCP lease and service tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from opnsense_mcp.audit import audit_log
|
||||||
|
from opnsense_mcp.client import 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_search_dhcp_leases(search_phrase: str = "", row_count: int = 200) -> str:
|
||||||
|
"""Search active DHCPv4 leases (IP, MAC, hostname, expiry)."""
|
||||||
|
client = get_client()
|
||||||
|
try:
|
||||||
|
result = client.search_get(
|
||||||
|
"dhcpv4", "leases", "searchLease", search_phrase, row_count
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
result = client.search_grid(
|
||||||
|
"dhcpv4", "leases", "searchLease", search_phrase, row_count
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_dhcp_lease(ip: str) -> str:
|
||||||
|
"""Delete/release a DHCP lease by IP address."""
|
||||||
|
payload = {"ip": ip}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("delete_dhcp_lease", payload), indent=2)
|
||||||
|
require_writes("delete_dhcp_lease", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("dhcpv4", "leases", "delLease", ip)
|
||||||
|
audit_log("delete_dhcp_lease", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_dhcp_service_status() -> str:
|
||||||
|
"""Get DHCPv4 service status."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("dhcpv4", "service", "status")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_dhcp_service_restart() -> str:
|
||||||
|
"""Restart the DHCPv4 service."""
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("dhcp_restart", {}), indent=2)
|
||||||
|
require_writes("dhcp_restart", {})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("dhcpv4", "service", "restart")
|
||||||
|
audit_log("dhcp_restart", {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
147
src/opnsense_mcp/tools/diagnostics.py
Normal file
147
src/opnsense_mcp/tools/diagnostics.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Diagnostics: logs, states, ARP, ping, traffic."""
|
||||||
|
|
||||||
|
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 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_get_arp_table(search_phrase: str = "") -> str:
|
||||||
|
"""Get the ARP table (IP to MAC mappings). Optionally filter with search_phrase."""
|
||||||
|
client = get_client()
|
||||||
|
if search_phrase:
|
||||||
|
result = client.get(
|
||||||
|
"diagnostics", "interface", "searchArp", params={"searchPhrase": search_phrase}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = client.get("diagnostics", "interface", "getArp")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_routes() -> str:
|
||||||
|
"""Get the routing table."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "interface", "getRoutes")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firewall_log(limit: int = 100) -> str:
|
||||||
|
"""Get recent firewall log entries."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "firewall", "log", params={"limit": limit})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firewall_log_filters() -> str:
|
||||||
|
"""Get available firewall log filter options."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "firewall", "logFilters")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_pf_states(limit: int = 100) -> str:
|
||||||
|
"""Get active PF firewall state table entries."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "firewall", "pfStates", params={"limit": limit})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_query_pf_states(filter_text: str = "", limit: int = 100) -> str:
|
||||||
|
"""Query/filter the PF state table."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.post(
|
||||||
|
"diagnostics",
|
||||||
|
"firewall",
|
||||||
|
"queryStates",
|
||||||
|
data={"filter": filter_text, "limit": limit},
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firewall_stats() -> str:
|
||||||
|
"""Get PF firewall statistics."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "firewall", "stats")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_pf_statistics(section: str = "") -> str:
|
||||||
|
"""Get detailed PF statistics, optionally filtered by section."""
|
||||||
|
client = get_client()
|
||||||
|
params = {"section": section} if section else None
|
||||||
|
result = client.get("diagnostics", "firewall", "pfStatistics", params=params)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_flush_firewall_states() -> str:
|
||||||
|
"""Flush all firewall state table entries. Disrupts active connections."""
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("flush_states", {}), indent=2)
|
||||||
|
require_writes("flush_states", {})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("diagnostics", "firewall", "flushStates")
|
||||||
|
audit_log("flush_states", {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_kill_firewall_states(source: str = "", destination: str = "") -> str:
|
||||||
|
"""Kill firewall states matching source and/or destination IP."""
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if source:
|
||||||
|
payload["source"] = source
|
||||||
|
if destination:
|
||||||
|
payload["destination"] = destination
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("kill_states", payload), indent=2)
|
||||||
|
require_writes("kill_states", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("diagnostics", "firewall", "killStates", data=payload or None)
|
||||||
|
audit_log("kill_states", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_flush_arp_table() -> str:
|
||||||
|
"""Flush the ARP cache."""
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("flush_arp", {}), indent=2)
|
||||||
|
require_writes("flush_arp", {})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("diagnostics", "interface", "flushArp")
|
||||||
|
audit_log("flush_arp", {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_dns_reverse_lookup(hostname: str = "", ip: str = "") -> str:
|
||||||
|
"""Perform reverse DNS lookup from the firewall."""
|
||||||
|
client = get_client()
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if hostname:
|
||||||
|
params["hostname"] = hostname
|
||||||
|
if ip:
|
||||||
|
params["ip"] = ip
|
||||||
|
result = client.get("diagnostics", "dns", "reverseLookup", params=params or None)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_traffic_top(interfaces: str = "") -> str:
|
||||||
|
"""Get top talkers by traffic. interfaces: comma-separated interface names."""
|
||||||
|
client = get_client()
|
||||||
|
params = {"interfaces": interfaces} if interfaces else None
|
||||||
|
result = client.get("diagnostics", "traffic", "top", params=params)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_activity() -> str:
|
||||||
|
"""Get current system activity/process snapshot."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "activity", "getActivity")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
238
src/opnsense_mcp/tools/firewall.py
Normal file
238
src/opnsense_mcp/tools/firewall.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Firewall filter rule 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 get_client
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
from opnsense_mcp.guards import (
|
||||||
|
GuardError,
|
||||||
|
is_dry_run_response,
|
||||||
|
require_writes,
|
||||||
|
validate_firewall_rule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_payload(rule: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"rule": rule}
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_firewall_rules(
|
||||||
|
search_phrase: str = "",
|
||||||
|
interface: str = "",
|
||||||
|
row_count: int = 100,
|
||||||
|
) -> str:
|
||||||
|
"""Search firewall filter rules. Optionally filter by interface (e.g. lan, wan)."""
|
||||||
|
client = get_client()
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
if interface:
|
||||||
|
extra["interface"] = interface
|
||||||
|
try:
|
||||||
|
result = client.search_get(
|
||||||
|
"firewall", "filter", "searchRule", search_phrase, row_count, **extra
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
result = client.search_grid(
|
||||||
|
"firewall", "filter", "searchRule", search_phrase, row_count, **extra
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firewall_rule(uuid: str) -> str:
|
||||||
|
"""Get a single firewall rule by UUID."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("firewall", "filter", "getRule", uuid)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firewall_interface_list() -> str:
|
||||||
|
"""List interfaces available for firewall rule assignment."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("firewall", "filter", "getInterfaceList")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firewall_rule_stats() -> str:
|
||||||
|
"""Get per-rule hit counters and statistics."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("firewall", "filter_util", "ruleStats")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_add_firewall_rule(rule: dict[str, Any]) -> str:
|
||||||
|
"""Add a new firewall filter rule. Rule dict uses OPNsense field names (description, interface, source_net, destination_net, protocol, action, etc.). Descriptions must start with the configured prefix (default 'mcp:')."""
|
||||||
|
validate_firewall_rule(rule, is_new=True)
|
||||||
|
payload = _rule_payload(rule)
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("add_firewall_rule", payload), indent=2)
|
||||||
|
require_writes("add_firewall_rule", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "addRule", data=payload)
|
||||||
|
audit_log("add_firewall_rule", {"rule": rule, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_update_firewall_rule(uuid: str, rule: dict[str, Any]) -> str:
|
||||||
|
"""Update an existing firewall rule by UUID."""
|
||||||
|
validate_firewall_rule(rule, is_new=False)
|
||||||
|
payload = _rule_payload(rule)
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(
|
||||||
|
is_dry_run_response("update_firewall_rule", {"uuid": uuid, **payload}), indent=2
|
||||||
|
)
|
||||||
|
require_writes("update_firewall_rule", {"uuid": uuid, **payload})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "setRule", uuid, data=payload)
|
||||||
|
audit_log("update_firewall_rule", {"uuid": uuid, "rule": rule, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_firewall_rule(uuid: str) -> str:
|
||||||
|
"""Delete a firewall rule by UUID."""
|
||||||
|
payload = {"uuid": uuid}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("delete_firewall_rule", payload), indent=2)
|
||||||
|
require_writes("delete_firewall_rule", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "delRule", uuid)
|
||||||
|
audit_log("delete_firewall_rule", {"uuid": uuid, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_toggle_firewall_rule(uuid: str, enabled: bool = True) -> str:
|
||||||
|
"""Enable or disable a firewall rule. enabled=true to enable, false to disable."""
|
||||||
|
payload = {"uuid": uuid, "enabled": enabled}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("toggle_firewall_rule", payload), indent=2)
|
||||||
|
require_writes("toggle_firewall_rule", payload)
|
||||||
|
client = get_client()
|
||||||
|
flag = "1" if enabled else "0"
|
||||||
|
result = client.post("firewall", "filter", "toggleRule", uuid, flag)
|
||||||
|
audit_log("toggle_firewall_rule", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_create_firewall_savepoint() -> str:
|
||||||
|
"""Create a firewall configuration savepoint for rollback. Returns revision ID — call cancel_rollback within 60s to keep changes, or let it auto-revert."""
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("create_savepoint", {}), indent=2)
|
||||||
|
require_writes("create_savepoint", {})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "savepoint")
|
||||||
|
audit_log("create_savepoint", {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_apply_firewall(rollback_revision: str = "") -> str:
|
||||||
|
"""Apply pending firewall changes. Pass rollback_revision from savepoint to enable 60s auto-rollback."""
|
||||||
|
payload = {"rollback_revision": rollback_revision}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("apply_firewall", payload), indent=2)
|
||||||
|
require_writes("apply_firewall", payload)
|
||||||
|
client = get_client()
|
||||||
|
if rollback_revision:
|
||||||
|
result = client.post("firewall", "filter", "apply", rollback_revision)
|
||||||
|
else:
|
||||||
|
result = client.post("firewall", "filter", "apply")
|
||||||
|
audit_log("apply_firewall", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_cancel_firewall_rollback(rollback_revision: str) -> str:
|
||||||
|
"""Confirm firewall changes by cancelling the auto-rollback timer for a savepoint revision."""
|
||||||
|
payload = {"rollback_revision": rollback_revision}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("cancel_rollback", payload), indent=2)
|
||||||
|
require_writes("cancel_rollback", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "cancelRollback", rollback_revision)
|
||||||
|
audit_log("cancel_rollback", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_revert_firewall(revision: str) -> str:
|
||||||
|
"""Revert firewall configuration to a specific revision."""
|
||||||
|
payload = {"revision": revision}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("revert_firewall", payload), indent=2)
|
||||||
|
require_writes("revert_firewall", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "revert", revision)
|
||||||
|
audit_log("revert_firewall", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_safe_apply_firewall_rule_change(
|
||||||
|
uuid: str,
|
||||||
|
enabled: bool,
|
||||||
|
confirm: bool = False,
|
||||||
|
rollback_revision: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Safely toggle a rule with savepoint + apply + 60s rollback window. First call with confirm=false to apply; then call again with confirm=true and the returned rollback_revision to keep changes."""
|
||||||
|
if confirm:
|
||||||
|
if not rollback_revision:
|
||||||
|
raise GuardError(
|
||||||
|
"rollback_revision is required when confirm=true. "
|
||||||
|
"Pass the revision returned from the initial safe_apply call."
|
||||||
|
)
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(
|
||||||
|
is_dry_run_response(
|
||||||
|
"safe_apply_confirm", {"uuid": uuid, "rollback_revision": rollback_revision}
|
||||||
|
),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
require_writes("safe_apply_confirm", {"uuid": uuid, "rollback_revision": rollback_revision})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", "filter", "cancelRollback", rollback_revision)
|
||||||
|
audit_log(
|
||||||
|
"safe_apply_confirm",
|
||||||
|
{"uuid": uuid, "rollback_revision": rollback_revision, "result": result},
|
||||||
|
)
|
||||||
|
return json.dumps(
|
||||||
|
{"status": "confirmed", "rollback_revision": rollback_revision, "result": result},
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(
|
||||||
|
is_dry_run_response(
|
||||||
|
"safe_apply",
|
||||||
|
{"uuid": uuid, "enabled": enabled, "step": "would savepoint+toggle+apply"},
|
||||||
|
),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
require_writes("safe_apply", {"uuid": uuid, "enabled": enabled})
|
||||||
|
client = get_client()
|
||||||
|
sp = client.post("firewall", "filter", "savepoint")
|
||||||
|
revision = sp.get("revision", "")
|
||||||
|
flag = "1" if enabled else "0"
|
||||||
|
client.post("firewall", "filter", "toggleRule", uuid, flag)
|
||||||
|
apply_result = (
|
||||||
|
client.post("firewall", "filter", "apply", revision)
|
||||||
|
if revision
|
||||||
|
else client.post("firewall", "filter", "apply")
|
||||||
|
)
|
||||||
|
audit_log("safe_apply", {"uuid": uuid, "enabled": enabled, "revision": revision})
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"status": "applied_with_rollback",
|
||||||
|
"rollback_revision": revision,
|
||||||
|
"rollback_seconds": 60,
|
||||||
|
"message": (
|
||||||
|
"Verify connectivity, then call opnsense_safe_apply_firewall_rule_change "
|
||||||
|
"with confirm=true and rollback_revision set to keep changes."
|
||||||
|
),
|
||||||
|
"apply_result": apply_result,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
61
src/opnsense_mcp/tools/interfaces.py
Normal file
61
src/opnsense_mcp/tools/interfaces.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Network interface tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from opnsense_mcp.client import get_client
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_list_interfaces(details: bool = True) -> str:
|
||||||
|
"""List all network interfaces with status, IP addresses, and traffic stats."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get(
|
||||||
|
"interfaces", "overview", "interfacesInfo", params={"details": str(details).lower()}
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_interface(identifier: str) -> str:
|
||||||
|
"""Get detailed info for a single interface by name (e.g. lan, wan, opt1)."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("interfaces", "overview", "getInterface", params={"if": identifier})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_reload_interface(identifier: str) -> str:
|
||||||
|
"""Reload/restart a network interface."""
|
||||||
|
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 = {"identifier": identifier}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("reload_interface", payload), indent=2)
|
||||||
|
require_writes("reload_interface", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post(
|
||||||
|
"interfaces", "overview", "reloadInterface", params={"identifier": identifier}
|
||||||
|
)
|
||||||
|
audit_log("reload_interface", {"identifier": identifier, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_list_vlans(search_phrase: str = "", row_count: int = 50) -> str:
|
||||||
|
"""List VLAN interface configurations."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.search_grid(
|
||||||
|
"interfaces", "vlan_settings", "searchItem", search_phrase, row_count
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_vip_status() -> str:
|
||||||
|
"""Get virtual IP (CARP/VIP) status."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "interface", "getVipStatus")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
173
src/opnsense_mcp/tools/nat.py
Normal file
173
src/opnsense_mcp/tools/nat.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""NAT rule tools: source NAT, destination NAT, 1:1, NPT."""
|
||||||
|
|
||||||
|
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 get_client
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
from opnsense_mcp.guards import is_dry_run_response, require_writes
|
||||||
|
|
||||||
|
|
||||||
|
def _search_nat(nat_type: str, search_phrase: str, row_count: int) -> str:
|
||||||
|
client = get_client()
|
||||||
|
try:
|
||||||
|
result = client.search_get("firewall", nat_type, "searchRule", search_phrase, row_count)
|
||||||
|
except Exception:
|
||||||
|
result = client.search_grid("firewall", nat_type, "searchRule", search_phrase, row_count)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_nat(nat_type: str, uuid: str) -> str:
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("firewall", nat_type, "getRule", uuid)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_nat(nat_type: str, action: str, rule: dict[str, Any]) -> str:
|
||||||
|
payload = {"rule": rule}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response(action, payload), indent=2)
|
||||||
|
require_writes(action, payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", nat_type, "addRule", data=payload)
|
||||||
|
audit_log(action, {"rule": rule, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_nat(nat_type: str, action: str, uuid: str, rule: dict[str, Any]) -> str:
|
||||||
|
payload = {"rule": rule}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response(action, {"uuid": uuid, **payload}), indent=2)
|
||||||
|
require_writes(action, {"uuid": uuid, **payload})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", nat_type, "setRule", uuid, data=payload)
|
||||||
|
audit_log(action, {"uuid": uuid, "rule": rule, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_nat(nat_type: str, action: str, uuid: str) -> str:
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response(action, {"uuid": uuid}), indent=2)
|
||||||
|
require_writes(action, {"uuid": uuid})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("firewall", nat_type, "delRule", uuid)
|
||||||
|
audit_log(action, {"uuid": uuid, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_nat(nat_type: str, action: str, uuid: str, enabled: bool) -> str:
|
||||||
|
payload = {"uuid": uuid, "enabled": enabled}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response(action, payload), indent=2)
|
||||||
|
require_writes(action, payload)
|
||||||
|
client = get_client()
|
||||||
|
flag = "1" if enabled else "0"
|
||||||
|
result = client.post("firewall", nat_type, "toggleRule", uuid, flag)
|
||||||
|
audit_log(action, payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
# ── Source NAT ──────────────────────────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_source_nat_rules(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search outbound / source NAT rules."""
|
||||||
|
return _search_nat("source_nat", search_phrase, row_count)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_source_nat_rule(uuid: str) -> str:
|
||||||
|
"""Get an outbound NAT rule by UUID."""
|
||||||
|
return _get_nat("source_nat", uuid)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_add_source_nat_rule(rule: dict[str, Any]) -> str:
|
||||||
|
"""Add an outbound / source NAT rule."""
|
||||||
|
return _add_nat("source_nat", "add_source_nat_rule", rule)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_update_source_nat_rule(uuid: str, rule: dict[str, Any]) -> str:
|
||||||
|
"""Update an outbound NAT rule by UUID."""
|
||||||
|
return _update_nat("source_nat", "update_source_nat_rule", uuid, rule)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_source_nat_rule(uuid: str) -> str:
|
||||||
|
"""Delete an outbound NAT rule by UUID."""
|
||||||
|
return _delete_nat("source_nat", "delete_source_nat_rule", uuid)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_toggle_source_nat_rule(uuid: str, enabled: bool = True) -> str:
|
||||||
|
"""Enable or disable an outbound NAT rule."""
|
||||||
|
return _toggle_nat("source_nat", "toggle_source_nat_rule", uuid, enabled)
|
||||||
|
|
||||||
|
# ── Destination NAT / Port Forwards ───────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_d_nat_rules(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search destination NAT / port forward rules."""
|
||||||
|
return _search_nat("d_nat", search_phrase, row_count)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_port_forwards(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search port forward rules (alias for destination NAT search)."""
|
||||||
|
return _search_nat("d_nat", search_phrase, row_count)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_d_nat_rule(uuid: str) -> str:
|
||||||
|
"""Get a destination NAT / port forward rule by UUID."""
|
||||||
|
return _get_nat("d_nat", uuid)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_add_d_nat_rule(rule: dict[str, Any]) -> str:
|
||||||
|
"""Add a destination NAT / port forward rule."""
|
||||||
|
return _add_nat("d_nat", "add_d_nat_rule", rule)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_update_d_nat_rule(uuid: str, rule: dict[str, Any]) -> str:
|
||||||
|
"""Update a destination NAT rule by UUID."""
|
||||||
|
return _update_nat("d_nat", "update_d_nat_rule", uuid, rule)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_d_nat_rule(uuid: str) -> str:
|
||||||
|
"""Delete a destination NAT rule by UUID."""
|
||||||
|
return _delete_nat("d_nat", "delete_d_nat_rule", uuid)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_toggle_d_nat_rule(uuid: str, enabled: bool = True) -> str:
|
||||||
|
"""Enable or disable a destination NAT rule."""
|
||||||
|
return _toggle_nat("d_nat", "toggle_d_nat_rule", uuid, enabled)
|
||||||
|
|
||||||
|
# ── 1:1 NAT ─────────────────────────────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_one_to_one_rules(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search 1:1 NAT rules."""
|
||||||
|
return _search_nat("one_to_one", search_phrase, row_count)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_one_to_one_rule(uuid: str) -> str:
|
||||||
|
"""Get a 1:1 NAT rule by UUID."""
|
||||||
|
return _get_nat("one_to_one", uuid)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_add_one_to_one_rule(rule: dict[str, Any]) -> str:
|
||||||
|
"""Add a 1:1 NAT rule."""
|
||||||
|
return _add_nat("one_to_one", "add_one_to_one_rule", rule)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_one_to_one_rule(uuid: str) -> str:
|
||||||
|
"""Delete a 1:1 NAT rule by UUID."""
|
||||||
|
return _delete_nat("one_to_one", "delete_one_to_one_rule", uuid)
|
||||||
|
|
||||||
|
# ── NPT (IPv6) ──────────────────────────────────────────────────────
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_npt_rules(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search NPT (IPv6 prefix translation) rules."""
|
||||||
|
return _search_nat("npt", search_phrase, row_count)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_npt_rule(uuid: str) -> str:
|
||||||
|
"""Get an NPT rule by UUID."""
|
||||||
|
return _get_nat("npt", uuid)
|
||||||
87
src/opnsense_mcp/tools/routes.py
Normal file
87
src/opnsense_mcp/tools/routes.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Routing and gateway 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 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_get_gateway_status() -> str:
|
||||||
|
"""Get WAN/gateway status including latency, packet loss, and online/offline state."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("routes", "gateway", "status")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_search_routes(search_phrase: str = "", row_count: int = 100) -> str:
|
||||||
|
"""Search static route entries."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.search_grid("routes", "routes", "searchRoute", search_phrase, row_count)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_route(uuid: str = "") -> str:
|
||||||
|
"""Get a static route by UUID, or route configuration template if uuid empty."""
|
||||||
|
client = get_client()
|
||||||
|
if uuid:
|
||||||
|
result = client.get("routes", "routes", "getRoute", uuid)
|
||||||
|
else:
|
||||||
|
result = client.get("routes", "routes", "get")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_add_route(route: dict[str, Any]) -> str:
|
||||||
|
"""Add a static route. Fields depend on OPNsense route model (network, gateway, description, etc.)."""
|
||||||
|
payload = {"route": route}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("add_route", payload), indent=2)
|
||||||
|
require_writes("add_route", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("routes", "routes", "addRoute", data=payload)
|
||||||
|
audit_log("add_route", {"route": route, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_delete_route(uuid: str) -> str:
|
||||||
|
"""Delete a static route by UUID."""
|
||||||
|
payload = {"uuid": uuid}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("delete_route", payload), indent=2)
|
||||||
|
require_writes("delete_route", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("routes", "routes", "delRoute", uuid)
|
||||||
|
audit_log("delete_route", {"uuid": uuid, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_toggle_route(uuid: str, enabled: bool = True) -> str:
|
||||||
|
"""Enable or disable a static route."""
|
||||||
|
payload = {"uuid": uuid, "enabled": enabled}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("toggle_route", payload), indent=2)
|
||||||
|
require_writes("toggle_route", payload)
|
||||||
|
client = get_client()
|
||||||
|
flag = "1" if enabled else "0"
|
||||||
|
result = client.post("routes", "routes", "toggleRoute", uuid, flag)
|
||||||
|
audit_log("toggle_route", payload | {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_reconfigure_routes() -> str:
|
||||||
|
"""Apply static route configuration changes."""
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("reconfigure_routes", {}), indent=2)
|
||||||
|
require_writes("reconfigure_routes", {})
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("routes", "routes", "reconfigure")
|
||||||
|
audit_log("reconfigure_routes", {"result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
69
src/opnsense_mcp/tools/system.py
Normal file
69
src/opnsense_mcp/tools/system.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""System, firmware, and service tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from opnsense_mcp.client import get_client
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp: FastMCP) -> None:
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_system_info() -> str:
|
||||||
|
"""Get OPNsense system information: version, hostname, uptime, CPU, memory, disk."""
|
||||||
|
client = get_client()
|
||||||
|
info = client.get("diagnostics", "system", "systemInformation")
|
||||||
|
health = client.get("diagnostics", "systemhealth", "getSystemHealth")
|
||||||
|
return json.dumps({"system": info, "health": health}, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_firmware_status() -> str:
|
||||||
|
"""Get firmware version and update status."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("core", "firmware", "status")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_list_services(search_phrase: str = "", row_count: int = 50) -> str:
|
||||||
|
"""List system services and their running/stopped status."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.search_grid("core", "service", "search", search_phrase, row_count)
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_service_action(service: str, action: str) -> str:
|
||||||
|
"""Control a system service. action: start, stop, restart, reconfigure."""
|
||||||
|
from opnsense_mcp.audit import audit_log
|
||||||
|
from opnsense_mcp.config import settings
|
||||||
|
from opnsense_mcp.guards import GuardError, is_dry_run_response, require_writes
|
||||||
|
|
||||||
|
allowed = {"start", "stop", "restart", "reconfigure"}
|
||||||
|
if action not in allowed:
|
||||||
|
raise GuardError(f"action must be one of: {', '.join(sorted(allowed))}")
|
||||||
|
|
||||||
|
payload = {"service": service, "action": action}
|
||||||
|
if settings.dry_run:
|
||||||
|
return json.dumps(is_dry_run_response("service_action", payload), indent=2)
|
||||||
|
require_writes("service_action", payload)
|
||||||
|
client = get_client()
|
||||||
|
result = client.post("core", "service", action, data={"service": service})
|
||||||
|
audit_log("service_action", {"service": service, "action": action, "result": result})
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_system_time() -> str:
|
||||||
|
"""Get current system time on the firewall."""
|
||||||
|
client = get_client()
|
||||||
|
result = client.get("diagnostics", "system", "systemTime")
|
||||||
|
return json.dumps(result, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def opnsense_get_system_resources() -> str:
|
||||||
|
"""Get CPU load, memory usage, and mbuf statistics."""
|
||||||
|
client = get_client()
|
||||||
|
resources = client.get("diagnostics", "system", "systemResources")
|
||||||
|
memory = client.get("diagnostics", "system", "memory")
|
||||||
|
return json.dumps({"resources": resources, "memory": memory}, indent=2, default=str)
|
||||||
Reference in New Issue
Block a user