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:
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user