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:
+2
-2
File diff suppressed because one or more lines are too long
@@ -1639,6 +1639,77 @@ def _detect_opnsense_host(gateway_ip: str) -> str | None:
|
||||
except Exception:
|
||||
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:
|
||||
"""Fetch DHCP static mappings from OPNsense."""
|
||||
try:
|
||||
@@ -1751,6 +1822,11 @@ class OPNsenseConfig(BaseModel):
|
||||
key: str
|
||||
secret: str
|
||||
|
||||
class OPNsenseSSHConfig(BaseModel):
|
||||
ssh_key_path: str
|
||||
ssh_user: str = "root"
|
||||
pin_host_key: bool = True
|
||||
|
||||
class OPNsenseReservationPush(BaseModel):
|
||||
token: str
|
||||
mac: str
|
||||
@@ -3314,3 +3390,226 @@ def opnsense_wg_peer_config(name: str):
|
||||
if not peer:
|
||||
raise HTTPException(404, f"Peer '{name}' not found in local store")
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user