Add OPNsense SSH shell access and Unbound management endpoints

Paramiko exec_command() bypasses the OPNsense console menu automatically
(menu only appears for interactive logins) so no human needs to press 8.

New API surface:
  POST /api/opnsense/ssh/generate-key        — create ed25519 key for OPNsense
  POST /api/opnsense/configure-ssh           — save SSH settings + pin host key
  GET  /api/opnsense/ssh-status              — test SSH connectivity
  POST /api/opnsense/ssh/run                 — run arbitrary command (auth-gated)
  GET  /api/opnsense/unbound/status          — read config files + .lan leak test
  POST /api/opnsense/unbound/reload          — unbound-control reload
  POST /api/opnsense/unbound/fix-lan-zone    — write correct local-lan-zone.conf,
                                               verify with unbound-checkconf,
                                               reload, confirm no ControlD leak
  POST /api/opnsense/unbound/write-forward-ctrld — enable/disable ctrld forwarding

SSH key stored at /etc/switch-manager/opnsense_key
Host key pinned to /etc/switch-manager/opnsense_known_hosts
SSH config (key_path, ssh_user) stored alongside existing API creds in opnsense.json

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-24 18:43:02 +00:00
parent 8a3ef94310
commit d9b6d05862
2 changed files with 301 additions and 2 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+299
View File
@@ -1639,6 +1639,77 @@ def _detect_opnsense_host(gateway_ip: str) -> str | None:
except Exception: except Exception:
return None return None
OPNSENSE_KNOWN_HOSTS = _Path("/etc/switch-manager/opnsense_known_hosts")
OPNSENSE_SSH_KEY = _Path("/etc/switch-manager/opnsense_key")
UNBOUND_ETC = "/var/unbound/etc"
def _opnsense_ssh_run(cfg: dict, cmd: str, timeout: int = 30) -> tuple:
"""Run a shell command on OPNsense via SSH. Returns (stdout, stderr, exit_code).
Uses exec_command() which bypasses the OPNsense console menu — the menu
only appears for interactive login sessions, not for exec_command calls.
"""
host = cfg.get("host", "")
ssh_user = cfg.get("ssh_user", "root")
key_path = cfg.get("ssh_key_path", "")
if not host or not key_path:
raise ValueError("OPNsense SSH not configured — set host and ssh_key_path")
client = paramiko.SSHClient()
if OPNSENSE_KNOWN_HOSTS.exists():
client.load_host_keys(str(OPNSENSE_KNOWN_HOSTS))
else:
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=host,
username=ssh_user,
key_filename=key_path,
timeout=10,
look_for_keys=False,
allow_agent=False,
)
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
exit_code = stdout.channel.recv_exit_status()
return stdout.read().decode(), stderr.read().decode(), exit_code
finally:
client.close()
def _opnsense_sftp_write(cfg: dict, remote_path: str, content: str):
"""Write a file on OPNsense via SFTP (avoids shell quoting issues)."""
host = cfg.get("host", "")
ssh_user = cfg.get("ssh_user", "root")
key_path = cfg.get("ssh_key_path", "")
if not host or not key_path:
raise ValueError("OPNsense SSH not configured")
client = paramiko.SSHClient()
if OPNSENSE_KNOWN_HOSTS.exists():
client.load_host_keys(str(OPNSENSE_KNOWN_HOSTS))
else:
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=host,
username=ssh_user,
key_filename=key_path,
timeout=10,
look_for_keys=False,
allow_agent=False,
)
sftp = client.open_sftp()
with sftp.open(remote_path, "w") as f:
f.write(content)
sftp.close()
finally:
client.close()
def _opnsense_ssh_test(cfg: dict) -> dict:
"""Test SSH connectivity to OPNsense. Returns {ok, version, error}."""
try:
out, err, code = _opnsense_ssh_run(cfg, "uname -sr")
return {"ok": code == 0, "version": out.strip(), "error": err.strip() if code != 0 else ""}
except Exception as e:
return {"ok": False, "version": "", "error": str(e)}
def _get_opnsense_reservations(cfg: dict) -> list: def _get_opnsense_reservations(cfg: dict) -> list:
"""Fetch DHCP static mappings from OPNsense.""" """Fetch DHCP static mappings from OPNsense."""
try: try:
@@ -1751,6 +1822,11 @@ class OPNsenseConfig(BaseModel):
key: str key: str
secret: str secret: str
class OPNsenseSSHConfig(BaseModel):
ssh_key_path: str
ssh_user: str = "root"
pin_host_key: bool = True
class OPNsenseReservationPush(BaseModel): class OPNsenseReservationPush(BaseModel):
token: str token: str
mac: str mac: str
@@ -3314,3 +3390,226 @@ def opnsense_wg_peer_config(name: str):
if not peer: if not peer:
raise HTTPException(404, f"Peer '{name}' not found in local store") raise HTTPException(404, f"Peer '{name}' not found in local store")
return {"name": name, "config": peer.get("config", "")} return {"name": name, "config": peer.get("config", "")}
# ══════════════════════════════════════════════════════════════════════
# OPNSENSE SSH SHELL ACCESS + UNBOUND MANAGEMENT
# ══════════════════════════════════════════════════════════════════════
# SSH via exec_command() bypasses the OPNsense console menu automatically —
# the menu only appears for interactive login sessions.
@app.post("/api/opnsense/ssh/generate-key")
def opnsense_ssh_generate_key():
"""Generate an ed25519 key pair for SSH access to OPNsense."""
import subprocess as _sp2
key = OPNSENSE_SSH_KEY
if key.exists():
pub = key.with_suffix(".pub")
return {
"generated": False,
"key_path": str(key),
"public_key": pub.read_text().strip() if pub.exists() else "",
"note": "Key already exists — use existing or delete to regenerate",
}
key.parent.mkdir(parents=True, exist_ok=True)
r = _sp2.run(
["ssh-keygen", "-t", "ed25519", "-f", str(key), "-N", "", "-C", "switch-manager@opnsense"],
capture_output=True, text=True,
)
if r.returncode != 0:
raise HTTPException(500, f"ssh-keygen failed: {r.stderr}")
key.chmod(0o600)
pub = key.with_suffix(".pub")
return {
"generated": True,
"key_path": str(key),
"public_key": pub.read_text().strip(),
"note": "Add this public key to OPNsense: System → Access → Users → root → Authorized Keys",
}
@app.post("/api/opnsense/configure-ssh")
def configure_opnsense_ssh(body: OPNsenseSSHConfig):
"""Save SSH key path and test connectivity. Pins the host key if pin_host_key=True."""
cfg = _load_opnsense_cfg()
if not cfg.get("host"):
raise HTTPException(400, "OPNsense API must be configured first (needs host)")
cfg["ssh_key_path"] = body.ssh_key_path
cfg["ssh_user"] = body.ssh_user
if body.pin_host_key:
# Connect with AutoAdd to capture and pin the host key
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=cfg["host"],
username=cfg["ssh_user"],
key_filename=cfg["ssh_key_path"],
timeout=10,
look_for_keys=False,
allow_agent=False,
)
OPNSENSE_KNOWN_HOSTS.parent.mkdir(parents=True, exist_ok=True)
client.save_host_keys(str(OPNSENSE_KNOWN_HOSTS))
OPNSENSE_KNOWN_HOSTS.chmod(0o600)
client.close()
except Exception as e:
raise HTTPException(400, f"SSH connect failed: {e}")
result = _opnsense_ssh_test(cfg)
if not result["ok"]:
raise HTTPException(400, f"SSH test failed: {result['error']}")
_save_opnsense_cfg(cfg)
log.info(f"OPNsense SSH configured: {cfg['host']} user={body.ssh_user}")
return {"success": True, "version": result["version"]}
@app.get("/api/opnsense/ssh-status")
def opnsense_ssh_status():
"""Check SSH connectivity to OPNsense."""
cfg = _load_opnsense_cfg()
if not cfg.get("ssh_key_path"):
return {"configured": False, "connected": False, "error": "SSH not configured"}
result = _opnsense_ssh_test(cfg)
return {
"configured": True,
"connected": result["ok"],
"version": result.get("version", ""),
"error": result.get("error", ""),
"key_path": cfg.get("ssh_key_path", ""),
"ssh_user": cfg.get("ssh_user", "root"),
}
@app.post("/api/opnsense/ssh/run")
def opnsense_ssh_run(body: dict):
"""Run a shell command on OPNsense. Requires TOTP session for write commands."""
import re as _re2
cmd = body.get("cmd", "").strip()
token = body.get("token", "")
if not cmd:
raise HTTPException(400, "cmd required")
# Read-only commands (no token needed); anything else needs auth
readonly = bool(_re2.match(r'^(cat|ls|uname|drill|dig|unbound-control\s+status|service\s+unbound\s+status)', cmd))
if not readonly:
require_session(token)
cfg = _load_opnsense_cfg()
if not cfg.get("ssh_key_path"):
raise HTTPException(503, "OPNsense SSH not configured")
out, err, code = _opnsense_ssh_run(cfg, cmd)
return {"stdout": out, "stderr": err, "exit_code": code, "ok": code == 0}
@app.get("/api/opnsense/unbound/status")
def opnsense_unbound_status():
"""Read Unbound config files and test for .lan DNS leak."""
cfg = _load_opnsense_cfg()
if not cfg.get("ssh_key_path"):
raise HTTPException(503, "OPNsense SSH not configured")
result = {}
# Read each relevant config file
for fname in ("local-lan-zone.conf", "forward_to_ctrld.conf", "dot.conf"):
out, _, code = _opnsense_ssh_run(cfg, f"cat {UNBOUND_ETC}/{fname} 2>/dev/null")
result[fname] = out.strip() if code == 0 else None
# Test for .lan leak — check if SOA answer comes from ControlD
out, _, _ = _opnsense_ssh_run(cfg, "drill @127.0.0.1 nonexistent.lan 2>/dev/null")
result["lan_leak_test_raw"] = out.strip()
result["lan_leak_detected"] = "controld" in out.lower()
result["lan_handled_locally"] = "lan." in out.lower() and "controld" not in out.lower()
# Unbound running?
out, _, code = _opnsense_ssh_run(cfg, "unbound-control status 2>/dev/null | head -2")
result["unbound_running"] = code == 0
result["unbound_status"] = out.strip()
return result
@app.post("/api/opnsense/unbound/reload")
def opnsense_unbound_reload():
"""Reload Unbound on OPNsense to apply config changes."""
cfg = _load_opnsense_cfg()
if not cfg.get("ssh_key_path"):
raise HTTPException(503, "OPNsense SSH not configured")
out, err, code = _opnsense_ssh_run(cfg, "unbound-control reload 2>&1")
if code != 0:
raise HTTPException(500, f"unbound-control reload failed: {err or out}")
return {"success": True, "output": out.strip()}
@app.post("/api/opnsense/unbound/fix-lan-zone")
def opnsense_unbound_fix_lan_zone():
"""
Ensure .lan queries are handled locally and never forwarded to ControlD.
Writes local-lan-zone.conf with the correct static zone declaration,
reloads Unbound, and runs a leak test to confirm the fix works.
"""
cfg = _load_opnsense_cfg()
if not cfg.get("ssh_key_path"):
raise HTTPException(503, "OPNsense SSH not configured")
steps = []
errors = []
# Write the correct local-lan-zone.conf via SFTP
lan_zone_conf = 'local-zone: "lan." static\n'
try:
_opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf)
steps.append("Wrote local-lan-zone.conf: local-zone \"lan.\" static")
except Exception as e:
errors.append(f"Write local-lan-zone.conf: {e}")
raise HTTPException(500, "; ".join(errors))
# Verify unbound-checkconf before reloading
out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1")
if code != 0:
errors.append(f"unbound-checkconf: {err or out}")
raise HTTPException(500, "; ".join(errors))
steps.append("unbound-checkconf: OK")
# Reload
out, err, code = _opnsense_ssh_run(cfg, "unbound-control reload 2>&1")
if code != 0:
errors.append(f"unbound-control reload: {err or out}")
raise HTTPException(500, "; ".join(errors))
steps.append("Unbound reloaded")
# Leak test (give Unbound a moment to come back up)
import time as _time2
_time2.sleep(1)
out, _, _ = _opnsense_ssh_run(cfg, "drill @127.0.0.1 nonexistent.lan 2>/dev/null")
leak = "controld" in out.lower()
if leak:
steps.append("DNS test: LEAK STILL DETECTED — check dot.conf for conflicting forward-zones")
else:
steps.append("DNS test: PASS — nonexistent.lan answered locally (no ControlD leak)")
return {
"success": not leak,
"steps": steps,
"leak_detected": leak,
"dns_test_output": out.strip(),
}
@app.post("/api/opnsense/unbound/write-forward-ctrld")
def opnsense_unbound_write_forward_ctrld(body: dict):
"""
Write forward_to_ctrld.conf with the correct forward-zone for ctrld.
Body: {token, ctrld_port (default 5354), enabled (default true)}
Reloads Unbound after writing.
"""
require_session(body.get("token", ""))
cfg = _load_opnsense_cfg()
if not cfg.get("ssh_key_path"):
raise HTTPException(503, "OPNsense SSH not configured")
port = int(body.get("ctrld_port", 5354))
enabled = body.get("enabled", True)
if enabled:
content = (
"forward-zone:\n"
f" name: \".\"\n"
f" forward-addr: 127.0.0.1@{port}\n"
)
else:
content = (
"# forward-zone disabled\n"
"# forward-zone:\n"
f"# name: \".\"\n"
f"# forward-addr: 127.0.0.1@{port}\n"
)
try:
_opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/forward_to_ctrld.conf", content)
except Exception as e:
raise HTTPException(500, f"Write failed: {e}")
out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1")
if code != 0:
raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}")
_opnsense_ssh_run(cfg, "unbound-control reload 2>&1")
return {"success": True, "content": content, "enabled": enabled, "port": port}