When creating a WireGuard peer on OPNsense: - Client config DNS now points to OPNsense's IP (not tunnel gateway) so DNS flows: client → OPNsense → Unbound → ctrld → ControlD - New dns_profile field: select which ControlD profile applies to VPN clients (default: "house" for VLAN 99) - Generates ctrld.toml instructions for WireGuard tunnel subnet routing — tells user what to add so ctrld routes VPN DNS queries to the correct ControlD profile - QR modal now shows ControlD setup instructions alongside the WireGuard config This solves the Android Private DNS conflict: WireGuard's DNS setting overrides Android's Private DNS, pointing to OPNsense which runs Unbound → ctrld. No Private DNS toggle needed on the phone. Multi-VLAN access for VPN peers works because the peer is on the WireGuard interface (not on any VLAN). OPNsense routes between the tunnel and VLANs per firewall rules. VLAN isolation preserved. https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
5488 lines
217 KiB
Python
5488 lines
217 KiB
Python
"""
|
||
ERS 59100GTS-PWR+ Switch Manager — Backend v3
|
||
────────────────────────────────────────────────────────────────────────
|
||
Changes from v2:
|
||
- Connection pool (30s lifetime, liveness check, auto-invalidate)
|
||
- Visitor-aware polling — starts when someone hits the page,
|
||
pauses when no active visitors, stops completely when idle
|
||
- Hard-block danger patterns (no force override for lethal commands)
|
||
- Session-based TOTP (one auth → session token → many pushes)
|
||
- Per-command execution with stop-on-error
|
||
- Config saved only on full success
|
||
|
||
Requirements:
|
||
pip install fastapi uvicorn paramiko pyotp
|
||
|
||
Run:
|
||
python switch_backend.py # start server
|
||
python switch_backend.py --setup-totp # first-time TOTP setup
|
||
────────────────────────────────────────────────────────────────────────
|
||
"""
|
||
|
||
import re, sys, time, secrets, logging, threading, os
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import paramiko, pyotp
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel, field_validator
|
||
|
||
logging.basicConfig(level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s")
|
||
log = logging.getLogger("switch-manager")
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# CONFIG
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
SWITCH_HOST = "192.168.99.1"
|
||
SWITCH_PORT = 22
|
||
SWITCH_USER = "admin"
|
||
KEY_PATH = "/etc/switch-manager/ers59100_key"
|
||
KNOWN_HOSTS = "/etc/switch-manager/known_hosts"
|
||
TOTP_FILE = "/etc/switch-manager/totp_secret"
|
||
STATIC_DIR = "./frontend/dist"
|
||
|
||
POLL_ACTIVE_S = 15 # poll interval when visitors present
|
||
POLL_BG_S = 60 # poll interval when tab backgrounded
|
||
POLL_IDLE_AFTER = 300 # stop polling after this many seconds with no visitors
|
||
CONN_POOL_MAX_S = 25 # max connection age before refresh (< switch idle timeout)
|
||
SESSION_TTL_S = 600 # TOTP session — 10 minutes, reset on activity
|
||
|
||
ALLOWED_ORIGINS = ["*"] # tighten to http://192.168.99.X in production
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# TOTP
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
def get_or_create_totp_secret() -> str:
|
||
p = Path(TOTP_FILE)
|
||
if p.exists():
|
||
return p.read_text().strip()
|
||
secret = pyotp.random_base32()
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(secret)
|
||
p.chmod(0o600)
|
||
log.info(f"New TOTP secret created at {TOTP_FILE}")
|
||
return secret
|
||
|
||
def setup_totp():
|
||
secret = get_or_create_totp_secret()
|
||
uri = pyotp.TOTP(secret).provisioning_uri(
|
||
name="ERS59100", issuer_name="SwitchManager")
|
||
print("\n══════════════════════════════════════════════════")
|
||
print(" ERS 59100GTS-PWR+ Switch Manager — TOTP Setup")
|
||
print("══════════════════════════════════════════════════")
|
||
print(f"\n Manual entry secret:\n {secret}")
|
||
print(f"\n Provisioning URI (paste into authenticator app):\n {uri}")
|
||
print("\n Or generate a QR code:")
|
||
print(f" python -c \"import qrcode; qrcode.make('{uri}').show()\"")
|
||
print("\n══════════════════════════════════════════════════\n")
|
||
|
||
TOTP_SECRET: str = ""
|
||
|
||
# ── Session tokens (one TOTP → session, multiple pushes) ──────────────
|
||
# { token: { expires: float, last_activity: float } }
|
||
_sessions: dict[str, dict] = {}
|
||
_sessions_lock = threading.Lock()
|
||
|
||
def create_session() -> str:
|
||
token = secrets.token_hex(32)
|
||
now = time.time()
|
||
with _sessions_lock:
|
||
_sessions[token] = {"expires": now + SESSION_TTL_S, "last_activity": now}
|
||
return token
|
||
|
||
def validate_session(token: str) -> bool:
|
||
"""Returns True and refreshes activity timestamp if session is valid."""
|
||
now = time.time()
|
||
with _sessions_lock:
|
||
# Prune expired
|
||
expired = [t for t, s in _sessions.items() if s["expires"] < now]
|
||
for t in expired:
|
||
del _sessions[t]
|
||
if token not in _sessions:
|
||
return False
|
||
# Refresh on activity
|
||
_sessions[token]["last_activity"] = now
|
||
_sessions[token]["expires"] = now + SESSION_TTL_S
|
||
return True
|
||
|
||
def session_remaining(token: str) -> Optional[int]:
|
||
with _sessions_lock:
|
||
s = _sessions.get(token)
|
||
if not s:
|
||
return None
|
||
return max(0, int(s["expires"] - time.time()))
|
||
|
||
def revoke_session(token: str):
|
||
with _sessions_lock:
|
||
_sessions.pop(token, None)
|
||
|
||
def require_session(token: str):
|
||
if not validate_session(token):
|
||
raise HTTPException(401, "Session expired or invalid — re-authenticate with TOTP")
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# DANGER DETECTION
|
||
# Two tiers:
|
||
# HARD_BLOCK — refused entirely, must run at console
|
||
# WARN — flagged, push still offered with explicit override
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
HARD_BLOCK_PATTERNS = [
|
||
# Management VLAN removal — all variants
|
||
(re.compile(r'no\s+vlan\s+99\b', re.I), "Deletes management VLAN 99"),
|
||
(re.compile(r'vlan\s+members\s+remove\b.*\b99\b', re.I), "Removes VLAN 99 from a port — kills management trunk"),
|
||
(re.compile(r'no\s+vlan\s+tagging\b.*\b99\b', re.I), "Removes VLAN 99 tagging — kills management trunk"),
|
||
(re.compile(r'vlan\s+pvid\s+\S+\s+\S+', re.I), "Changes native VLAN — verify not management port"),
|
||
# SSH / IP removal
|
||
(re.compile(r'no\s+ip\s+ssh', re.I), "Disables SSH entirely — permanent lockout"),
|
||
(re.compile(r'no\s+ip\s+address\b', re.I), "Removes IP address — will lose connectivity"),
|
||
# Management interface
|
||
(re.compile(r'interface\s+vlan\s+99\b', re.I), "Modifies management VLAN interface — run at console"),
|
||
# Boot / factory
|
||
(re.compile(r'boot\s+config\s+flags\s+factory', re.I), "Factory reset — run at console"),
|
||
]
|
||
|
||
WARN_PATTERNS = [
|
||
(re.compile(r'\bshutdown\b', re.I), "Shuts down an interface — confirm it is not your uplink"),
|
||
(re.compile(r'default\s+interface\b', re.I), "Resets interface to defaults"),
|
||
(re.compile(r'no\s+vlan\s+\d+\b', re.I), "Deletes a VLAN — confirm no active ports depend on it"),
|
||
(re.compile(r'spanning-tree\s+.*\s+disable', re.I), "Disables spanning tree — loop risk"),
|
||
]
|
||
|
||
def check_danger(commands: list[str]) -> dict:
|
||
"""
|
||
Scan a list of CLI commands for dangerous patterns.
|
||
|
||
Returns a dict with:
|
||
hard_blocked — commands that are refused entirely (e.g. no vlan 99, no ip ssh)
|
||
warnings — commands that are allowed but flagged (e.g. shutdown)
|
||
has_hard_block, has_warnings — convenience booleans
|
||
"""
|
||
hard, warn = [], []
|
||
for cmd in commands:
|
||
for pat, reason in HARD_BLOCK_PATTERNS:
|
||
if pat.search(cmd):
|
||
hard.append({"command": cmd, "reason": reason})
|
||
break
|
||
else:
|
||
for pat, reason in WARN_PATTERNS:
|
||
if pat.search(cmd):
|
||
warn.append({"command": cmd, "reason": reason})
|
||
break
|
||
return {
|
||
"hard_blocked": hard,
|
||
"warnings": warn,
|
||
"has_hard_block": bool(hard),
|
||
"has_warnings": bool(warn),
|
||
}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# SANITIZATION
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
_BAD_CHARS = re.compile(r'[;&|`$<>(){}\\"\']')
|
||
_BAD_PATS = [re.compile(p) for p in [r'\n', r'\r', r'--', r'/\*']]
|
||
_RE_VID = re.compile(r'^\d{1,4}$')
|
||
_RE_VNAME = re.compile(r'^[a-zA-Z0-9\-_]{1,32}$')
|
||
_RE_MODE = re.compile(r'^(access|trunk|disabled)$')
|
||
_RE_ANAME = re.compile(r'^[a-zA-Z0-9\-_]{1,32}$')
|
||
_RE_DIR = re.compile(r'^(in|out)$')
|
||
_RE_PROTO = re.compile(r'^(ip|tcp|udp|icmp)$')
|
||
_RE_ACTION = re.compile(r'^(permit|deny)$')
|
||
_RE_DESC = re.compile(r'^[a-zA-Z0-9\-_ ]{0,64}$')
|
||
_RE_IP = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
|
||
|
||
def _san(v: str, pat: re.Pattern, field: str) -> str:
|
||
"""Reject shell-injection characters and check value against an allow-list regex."""
|
||
if _BAD_CHARS.search(v):
|
||
raise ValueError(f"{field}: disallowed characters")
|
||
for p in _BAD_PATS:
|
||
if p.search(v):
|
||
raise ValueError(f"{field}: injection pattern")
|
||
if not pat.match(v):
|
||
raise ValueError(f"{field}: invalid format — {repr(v)}")
|
||
return v
|
||
|
||
def san_vid(v, field="vlan_id") -> int:
|
||
"""Validate and return a VLAN ID integer (1–4094)."""
|
||
_san(str(v), _RE_VID, field)
|
||
vid = int(v)
|
||
if not 1 <= vid <= 4094:
|
||
raise ValueError(f"{field}: must be 1–4094")
|
||
return vid
|
||
|
||
def san_port(v) -> int:
|
||
"""Validate and return a port number (1–100 for the ERS 59100GTS-PWR+)."""
|
||
p = int(v)
|
||
if not 1 <= p <= 100:
|
||
raise ValueError("port: must be 1–100")
|
||
return p
|
||
|
||
_ALLOWED_CMD_RE = [
|
||
re.compile(r'^vlan\s+(create|members|tagging|pvid)\s'),
|
||
re.compile(r'^no\s+vlan\s+\d+$'),
|
||
re.compile(r'^interface\s+(GigabitEthernet\s+1/\d+|vlan\s+\d+)$'),
|
||
re.compile(r'^\s+(name|no\s+shutdown|poe|speed|duplex|ip\s+access-group|shutdown)\b'),
|
||
re.compile(r'^hostname\s+\S+$'),
|
||
re.compile(r'^ip\s+access-list\s+extended\s'),
|
||
re.compile(r'^\s+\d+\s+(permit|deny)\s'),
|
||
re.compile(r'^!\s*'), re.compile(r'^\s*$'),
|
||
]
|
||
|
||
def is_allowed(cmd: str) -> bool:
|
||
"""Return True if cmd matches the CLI allow-list (whitelist of safe command patterns)."""
|
||
return any(p.match(cmd) for p in _ALLOWED_CMD_RE)
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# CONNECTION POOL
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
class SwitchConnectionPool:
|
||
"""
|
||
Keeps one SSH connection alive for up to CONN_POOL_MAX_S seconds.
|
||
On liveness check failure or expiry → opens a fresh connection.
|
||
Thread-safe.
|
||
"""
|
||
def __init__(self):
|
||
self._conn: Optional[paramiko.SSHClient] = None
|
||
self._born: float = 0
|
||
self._lock = threading.Lock()
|
||
|
||
def get(self) -> paramiko.SSHClient:
|
||
with self._lock:
|
||
now = time.time()
|
||
age = now - self._born
|
||
if self._conn and age < CONN_POOL_MAX_S:
|
||
try:
|
||
transport = self._conn.get_transport()
|
||
if transport and transport.is_active():
|
||
transport.send_ignore() # lightweight liveness ping
|
||
return self._conn
|
||
except Exception:
|
||
log.info("Pool: liveness check failed — opening fresh connection")
|
||
self._close_unsafe()
|
||
self._conn = _open_connection()
|
||
self._born = time.time()
|
||
log.info("Pool: new connection opened")
|
||
return self._conn
|
||
|
||
def invalidate(self):
|
||
with self._lock:
|
||
self._close_unsafe()
|
||
|
||
def _close_unsafe(self):
|
||
if self._conn:
|
||
try:
|
||
self._conn.close()
|
||
except Exception:
|
||
pass
|
||
self._conn = None
|
||
self._born = 0
|
||
|
||
_pool = SwitchConnectionPool()
|
||
|
||
|
||
def _open_connection() -> paramiko.SSHClient:
|
||
client = paramiko.SSHClient()
|
||
known = Path(KNOWN_HOSTS)
|
||
if known.exists():
|
||
client.load_host_keys(str(known))
|
||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||
else:
|
||
log.warning("No known_hosts — trust on first use")
|
||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||
try:
|
||
client.connect(
|
||
hostname=SWITCH_HOST, port=SWITCH_PORT, username=SWITCH_USER,
|
||
key_filename=KEY_PATH, look_for_keys=False, allow_agent=False,
|
||
timeout=10, banner_timeout=10,
|
||
)
|
||
except paramiko.AuthenticationException:
|
||
raise HTTPException(503, "SSH auth failed — is the public key loaded on the switch?")
|
||
except Exception as e:
|
||
raise HTTPException(503, f"Cannot reach switch at {SWITCH_HOST}: {e}")
|
||
if not known.exists():
|
||
known.parent.mkdir(parents=True, exist_ok=True)
|
||
client.save_host_keys(str(known))
|
||
log.info("Host key pinned to known_hosts")
|
||
return client
|
||
|
||
|
||
def read_cmd(cmd: str) -> str:
|
||
"""Run a read-only command in enable mode via interactive shell channel."""
|
||
try:
|
||
conn = _pool.get()
|
||
ch = conn.invoke_shell()
|
||
ch.settimeout(10)
|
||
time.sleep(0.4)
|
||
if ch.recv_ready():
|
||
ch.recv(4096) # drain login banner
|
||
for setup in ["terminal length 0", "enable"]:
|
||
ch.send(setup + "\n")
|
||
time.sleep(0.3)
|
||
if ch.recv_ready():
|
||
ch.recv(4096) # drain prompt output
|
||
ch.send(cmd + "\n")
|
||
out = ""
|
||
deadline = time.time() + 8
|
||
while time.time() < deadline:
|
||
if ch.recv_ready():
|
||
out += ch.recv(4096).decode("utf-8", errors="replace")
|
||
time.sleep(0.1)
|
||
else:
|
||
if out:
|
||
break
|
||
time.sleep(0.1)
|
||
ch.close()
|
||
return out
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
log.warning(f"read_cmd failed ({cmd}): {e} — invalidating pool")
|
||
_pool.invalidate()
|
||
raise HTTPException(503, f"Switch read failed: {e}")
|
||
|
||
|
||
# ── Per-command interactive push ───────────────────────────────────────
|
||
|
||
_SWITCH_ERR = re.compile(
|
||
r'%\s*(invalid|error|unknown|bad|failed|cannot|not\s+found|does\s+not\s+exist'
|
||
r'|incomplete|ambiguous|out\s+of\s+range|already\s+exists)',
|
||
re.I
|
||
)
|
||
|
||
def _run_one(channel, cmd: str) -> tuple[str, bool]:
|
||
channel.send(cmd + "\n")
|
||
time.sleep(0.35)
|
||
out, deadline = "", time.time() + 6
|
||
while time.time() < deadline:
|
||
if channel.recv_ready():
|
||
out += channel.recv(4096).decode("utf-8", errors="replace")
|
||
time.sleep(0.1)
|
||
else:
|
||
if out:
|
||
break
|
||
time.sleep(0.1)
|
||
return out.strip(), bool(_SWITCH_ERR.search(out))
|
||
|
||
|
||
def push_one_by_one(commands: list[str]) -> dict:
|
||
"""
|
||
Opens a FRESH dedicated connection for push (not from pool —
|
||
we don't want a push session to corrupt the pool's read connection).
|
||
Runs commands one at a time, stops on first error.
|
||
Saves config only on full success.
|
||
"""
|
||
_pool.invalidate() # invalidate pool — switch will be busy during push
|
||
client = _open_connection()
|
||
results = []
|
||
stopped_at = None
|
||
try:
|
||
ch = client.invoke_shell()
|
||
ch.settimeout(10)
|
||
time.sleep(0.5)
|
||
if ch.recv_ready():
|
||
ch.recv(4096) # drain banner
|
||
|
||
for setup in ["terminal length 0", "enable", "configure terminal"]:
|
||
out, err = _run_one(ch, setup)
|
||
if err:
|
||
return {"success": False, "saved": False,
|
||
"error": f"Failed entering config mode: {out}",
|
||
"results": [], "hint": "Check switch is reachable and credentials are correct"}
|
||
|
||
for i, cmd in enumerate(commands):
|
||
s = cmd.strip()
|
||
if not s or s.startswith("!"):
|
||
results.append({"index": i, "command": cmd,
|
||
"output": "", "success": True, "skipped": True})
|
||
continue
|
||
log.info(f" [{i+1}/{len(commands)}] {s}")
|
||
out, had_err = _run_one(ch, s)
|
||
results.append({"index": i, "command": cmd,
|
||
"output": out, "success": not had_err, "skipped": False})
|
||
if had_err:
|
||
stopped_at = i
|
||
log.warning(f" Error at command {i+1}: {out}")
|
||
break
|
||
|
||
_run_one(ch, "end")
|
||
saved = False
|
||
if stopped_at is None:
|
||
_, save_err = _run_one(ch, "save config")
|
||
saved = not save_err
|
||
if saved:
|
||
log.info("Config saved to NVRAM")
|
||
else:
|
||
log.warning("Config save may have failed — verify at console")
|
||
else:
|
||
log.warning("Config NOT saved — push stopped on error")
|
||
|
||
ch.close()
|
||
return {
|
||
"success": stopped_at is None,
|
||
"saved": saved,
|
||
"commands_total": len(commands),
|
||
"commands_sent": len([r for r in results if not r.get("skipped")]),
|
||
"stopped_at": stopped_at,
|
||
"error": results[stopped_at]["output"] if stopped_at is not None else None,
|
||
"hint": "Remaining commands must be run at the switch console" if stopped_at is not None else None,
|
||
"results": results,
|
||
}
|
||
finally:
|
||
client.close()
|
||
# Pool will reopen on next poll naturally
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# VISITOR-AWARE POLLER
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
_cache: dict = {
|
||
"port_status": None, "poe_status": None,
|
||
"vlan_members": None, "sys_info": None,
|
||
"last_poll": 0, "poll_error": None,
|
||
}
|
||
_cache_lock = threading.Lock()
|
||
|
||
# Visitor tracking
|
||
_visitors: dict[str, float] = {} # { visitor_id: last_seen }
|
||
_visitors_lock = threading.Lock()
|
||
_poll_mode = "idle" # idle | active | background
|
||
|
||
def heartbeat(visitor_id: str, mode: str = "active"):
|
||
"""Called by frontend to indicate a visitor is present."""
|
||
global _poll_mode
|
||
with _visitors_lock:
|
||
_visitors[visitor_id] = time.time()
|
||
_poll_mode = mode
|
||
|
||
def prune_visitors():
|
||
"""Remove visitors not seen for POLL_IDLE_AFTER seconds and set mode to idle if none remain."""
|
||
global _poll_mode
|
||
with _visitors_lock:
|
||
now = time.time()
|
||
gone = [v for v, t in _visitors.items() if now - t > POLL_IDLE_AFTER]
|
||
for v in gone:
|
||
del _visitors[v]
|
||
if not _visitors:
|
||
_poll_mode = "idle"
|
||
|
||
def _poll_loop():
|
||
"""
|
||
Background thread: polls the switch for live status at a visitor-adaptive interval.
|
||
|
||
When visitors are active: polls every POLL_ACTIVE_S seconds.
|
||
When visitors have the tab backgrounded: polls every POLL_BG_S seconds.
|
||
When no visitors for POLL_IDLE_AFTER seconds: sleeps without polling.
|
||
Results cached in _cache; poll_error set on SSH failure.
|
||
"""
|
||
log.info("Poller thread started")
|
||
while True:
|
||
prune_visitors()
|
||
with _visitors_lock:
|
||
mode = _poll_mode
|
||
has_visitors = bool(_visitors)
|
||
|
||
if not has_visitors:
|
||
# No visitors — sleep and check again, don't poll switch
|
||
time.sleep(30)
|
||
continue
|
||
|
||
# Poll the switch
|
||
try:
|
||
data = {
|
||
"port_status": read_cmd("show interfaces"),
|
||
"poe_status": read_cmd("show poe-main-status"),
|
||
"vlan_members": read_cmd("show vlan"),
|
||
"sys_info": read_cmd("show sys-info"),
|
||
}
|
||
with _cache_lock:
|
||
_cache.update(data)
|
||
_cache["last_poll"] = time.time()
|
||
_cache["poll_error"] = None
|
||
except Exception as e:
|
||
with _cache_lock:
|
||
_cache["poll_error"] = str(e)
|
||
log.warning(f"Poll error: {e}")
|
||
|
||
# Check alert conditions after each poll
|
||
try:
|
||
_check_and_alert()
|
||
except Exception:
|
||
pass # alerts are best-effort, never crash the poller
|
||
|
||
interval = POLL_ACTIVE_S if mode == "active" else POLL_BG_S
|
||
time.sleep(interval)
|
||
|
||
def start_poller():
|
||
"""Launch the background polling thread as a daemon (exits when main process exits)."""
|
||
t = threading.Thread(target=_poll_loop, daemon=True)
|
||
t.start()
|
||
log.info("Poller started")
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# PYDANTIC MODELS
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
class TotpVerify(BaseModel):
|
||
code: str
|
||
|
||
class SessionCheck(BaseModel):
|
||
token: str
|
||
|
||
class SessionRevoke(BaseModel):
|
||
token: str
|
||
|
||
class Heartbeat(BaseModel):
|
||
visitor_id: str
|
||
mode: str = "active" # active | background
|
||
|
||
class PushBatch(BaseModel):
|
||
token: str
|
||
commands: list[str]
|
||
|
||
class VlanCreate(BaseModel):
|
||
token: str
|
||
vlan_id: int
|
||
name: str
|
||
@field_validator("vlan_id")
|
||
@classmethod
|
||
def cv(cls, v): return san_vid(v)
|
||
@field_validator("name")
|
||
@classmethod
|
||
def cn(cls, v): return _san(v, _RE_VNAME, "name")
|
||
|
||
class VlanDelete(BaseModel):
|
||
token: str
|
||
vlan_id: int
|
||
@field_validator("vlan_id")
|
||
@classmethod
|
||
def cv(cls, v):
|
||
vid = san_vid(v)
|
||
if vid == 1: raise ValueError("Cannot delete VLAN 1")
|
||
return vid
|
||
|
||
class VlanProvision(BaseModel):
|
||
token: str
|
||
vlan_id: int
|
||
name: str
|
||
subnet: str # e.g. "192.168.20.0/24"
|
||
gateway: str # OPNsense IP on this VLAN, e.g. "192.168.20.1"
|
||
dhcp_start: str # e.g. "192.168.20.100"
|
||
dhcp_end: str # e.g. "192.168.20.200"
|
||
parent_if: str # OPNsense physical parent, e.g. "em0" or "igb0"
|
||
opnsense_if: Optional[str] = "" # assigned interface name, e.g. "opt2"
|
||
allow_internet: bool = True # add default allow-out firewall rule
|
||
@field_validator("vlan_id")
|
||
@classmethod
|
||
def cv(cls, v):
|
||
vid = san_vid(v)
|
||
if vid in (1, 99): raise ValueError("VLAN 1 and 99 are reserved")
|
||
return vid
|
||
@field_validator("name")
|
||
@classmethod
|
||
def cn(cls, v): return _san(v, _RE_VNAME, "name")
|
||
|
||
class AclRule(BaseModel):
|
||
action: str
|
||
proto: str
|
||
src: Optional[str] = "any"
|
||
src_mask: Optional[str] = "0.0.0.255"
|
||
src_any: Optional[bool] = True
|
||
dst: Optional[str] = "any"
|
||
dst_mask: Optional[str] = "0.0.0.255"
|
||
dst_any: Optional[bool] = True
|
||
port: Optional[str] = ""
|
||
port_end: Optional[str] = "" # when set, generates "range port port_end"
|
||
@field_validator("action")
|
||
@classmethod
|
||
def ca(cls, v): return _san(v, _RE_ACTION, "action")
|
||
@field_validator("proto")
|
||
@classmethod
|
||
def cp(cls, v): return _san(v, _RE_PROTO, "proto")
|
||
|
||
class AclCreate(BaseModel):
|
||
token: str
|
||
name: str
|
||
apply_vlan: int
|
||
direction: str
|
||
rules: list[AclRule]
|
||
@field_validator("name")
|
||
@classmethod
|
||
def cn(cls, v): return _san(v, _RE_ANAME, "name")
|
||
@field_validator("direction")
|
||
@classmethod
|
||
def cd(cls, v): return _san(v, _RE_DIR, "direction")
|
||
|
||
class PortConfig(BaseModel):
|
||
token: str
|
||
port: int
|
||
mode: str
|
||
access_vlan: Optional[int] = 1
|
||
tagged_vlans: Optional[list[int]] = []
|
||
native_vlan: Optional[int] = 1
|
||
poe: Optional[bool] = True
|
||
poe_limit_mw: Optional[int] = 30000
|
||
description: Optional[str] = ""
|
||
@field_validator("port")
|
||
@classmethod
|
||
def cp(cls, v): return san_port(v)
|
||
@field_validator("mode")
|
||
@classmethod
|
||
def cm(cls, v): return _san(v, _RE_MODE, "mode")
|
||
@field_validator("poe_limit_mw")
|
||
@classmethod
|
||
def cpoe(cls, v):
|
||
if v is not None and not 1000 <= v <= 30000:
|
||
raise ValueError("PoE limit: 1000–30000 mW")
|
||
return v
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# COMMAND BUILDERS
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
def build_port(cfg: PortConfig) -> list[str]:
|
||
"""
|
||
Generate ERS 59100GTS-PWR+ CLI commands for a port configuration change.
|
||
|
||
Ports 1–96 are GigabitEthernet copper (PoE capable); ports 97–100 are SFP+ uplinks (no PoE).
|
||
All interfaces use slot/port notation: GigabitEthernet 1/{p}.
|
||
Returns a list of CLI command strings ready for push_one_by_one().
|
||
"""
|
||
p = cfg.port
|
||
iface = f"FastEthernet {p}"
|
||
cmds = []
|
||
if cfg.description:
|
||
cmds += [f"interface {iface}", f' name "{cfg.description}"']
|
||
if cfg.mode == "disabled":
|
||
cmds += [f"interface {iface}", " shutdown"]
|
||
elif cfg.mode == "access":
|
||
vid = san_vid(cfg.access_vlan, "access_vlan")
|
||
cmds += [f"vlan members add {vid} {p}", f"vlan pvid {p} {vid}"]
|
||
elif cfg.mode == "trunk":
|
||
native = san_vid(cfg.native_vlan, "native_vlan")
|
||
tagged = [san_vid(v, f"tagged_{v}") for v in (cfg.tagged_vlans or [])]
|
||
if tagged:
|
||
ts = ",".join(str(v) for v in tagged)
|
||
cmds += [f"vlan members add {ts} {p}", f"vlan tagging {ts} {p}"]
|
||
cmds.append(f"vlan pvid {p} {native}")
|
||
if p <= 96:
|
||
cmds += [f"interface {iface}",
|
||
" poe enable" if cfg.poe else " no poe enable"]
|
||
if cfg.poe:
|
||
cmds.append(f" poe poe-limit {cfg.poe_limit_mw}")
|
||
return cmds
|
||
|
||
def build_acl(acl: AclCreate) -> list[str]:
|
||
"""
|
||
Generate ERS 59100GTS-PWR+ CLI commands to create an extended IP ACL and apply it to a VLAN interface.
|
||
|
||
Rules are numbered sequentially starting from 1.
|
||
The ACL is applied to the VLAN's Layer 3 interface in the specified direction (in/out).
|
||
"""
|
||
cmds = [f"ip access-list extended {acl.name}"]
|
||
for i, r in enumerate(acl.rules):
|
||
src = "any" if r.src_any else f"{r.src} {r.src_mask}"
|
||
dst = "any" if r.dst_any else f"{r.dst} {r.dst_mask}"
|
||
if r.port and r.port_end:
|
||
port_str = f" range {r.port} {r.port_end}"
|
||
elif r.port:
|
||
port_str = f" eq {r.port}"
|
||
else:
|
||
port_str = ""
|
||
cmds.append(f" {i+1} {r.action} {r.proto} {src} {dst}{port_str}")
|
||
vid = san_vid(acl.apply_vlan, "apply_vlan")
|
||
cmds += [f"interface vlan {vid}",
|
||
f" ip access-group {acl.name} {acl.direction}"]
|
||
return cmds
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# APP
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
app = FastAPI(title="ERS 59100GTS-PWR+ Switch Manager", version="3.0.0",
|
||
docs_url="/api/docs", redoc_url=None)
|
||
|
||
app.add_middleware(CORSMiddleware, allow_origins=ALLOWED_ORIGINS,
|
||
allow_methods=["GET","POST","DELETE"],
|
||
allow_headers=["Content-Type"])
|
||
|
||
@app.exception_handler(ValueError)
|
||
async def val_err(req: Request, exc: ValueError):
|
||
return JSONResponse(400, {"detail": str(exc)})
|
||
|
||
@app.on_event("startup")
|
||
def startup():
|
||
global TOTP_SECRET
|
||
TOTP_SECRET = get_or_create_totp_secret()
|
||
log.info("TOTP secret loaded")
|
||
start_poller()
|
||
|
||
# ── Heartbeat (visitor tracking) ──────────────────────────────────────
|
||
|
||
@app.post("/api/heartbeat")
|
||
def hb(body: Heartbeat):
|
||
"""
|
||
Frontend calls this every 30s to indicate visitor is present.
|
||
mode = 'active' when tab is visible, 'background' when hidden.
|
||
Poller adjusts interval accordingly.
|
||
"""
|
||
heartbeat(body.visitor_id, body.mode)
|
||
with _cache_lock:
|
||
last = _cache["last_poll"]
|
||
err = _cache["poll_error"]
|
||
return {
|
||
"poll_mode": _poll_mode,
|
||
"last_poll": last,
|
||
"poll_age": round(time.time() - last, 1) if last else None,
|
||
"poll_error": err,
|
||
}
|
||
|
||
# ── Auth ───────────────────────────────────────────────────────────────
|
||
|
||
@app.post("/api/auth/verify")
|
||
def verify_totp(body: TotpVerify):
|
||
"""Verify TOTP code — returns session token for multiple pushes."""
|
||
if not pyotp.TOTP(TOTP_SECRET).verify(body.code.strip(), valid_window=1):
|
||
log.warning("TOTP verify failed")
|
||
raise HTTPException(401, "Invalid TOTP code")
|
||
token = create_session()
|
||
log.info("TOTP OK — session created")
|
||
return {
|
||
"token": token,
|
||
"expires_in": SESSION_TTL_S,
|
||
"message": "Session active — re-authenticates on expiry or manual lock"
|
||
}
|
||
|
||
@app.post("/api/auth/check")
|
||
def check_session(body: SessionCheck):
|
||
"""Check if a session is still valid. Returns remaining seconds."""
|
||
remaining = session_remaining(body.token)
|
||
if remaining is None:
|
||
raise HTTPException(401, "Session expired")
|
||
return {"valid": True, "remaining": remaining}
|
||
|
||
@app.post("/api/auth/revoke")
|
||
def revoke(body: SessionRevoke):
|
||
"""Manually lock — invalidates the session immediately."""
|
||
revoke_session(body.token)
|
||
log.info("Session manually revoked")
|
||
return {"revoked": True}
|
||
|
||
# ── Read endpoints (no auth) ───────────────────────────────────────────
|
||
|
||
@app.get("/api/status")
|
||
def status():
|
||
"""Backend and switch connectivity summary (no auth required)."""
|
||
with _cache_lock:
|
||
return {
|
||
"backend": "online",
|
||
"switch_host": SWITCH_HOST,
|
||
"key_exists": Path(KEY_PATH).exists(),
|
||
"known_hosts_pinned": Path(KNOWN_HOSTS).exists(),
|
||
"last_poll": _cache["last_poll"],
|
||
"poll_age": round(time.time() - _cache["last_poll"], 1)
|
||
if _cache["last_poll"] else None,
|
||
"poll_error": _cache["poll_error"],
|
||
"poll_mode": _poll_mode,
|
||
}
|
||
|
||
@app.get("/api/live")
|
||
def live():
|
||
"""Cached live switch data — updated by background poller."""
|
||
with _cache_lock:
|
||
age = time.time() - _cache["last_poll"] if _cache["last_poll"] else None
|
||
return {
|
||
**_cache,
|
||
"stale": age is None or age > max(POLL_ACTIVE_S, POLL_BG_S) * 3
|
||
}
|
||
|
||
@app.get("/api/switch/config")
|
||
def running_config():
|
||
"""Fetch and return the full switch running config (read-only, no auth)."""
|
||
out = read_cmd("show config")
|
||
return {"config": out, "lines": len(out.splitlines())}
|
||
|
||
|
||
# ── Capability probe ────────────────────────────────────────────────────
|
||
|
||
_caps_cache: dict = {}
|
||
_caps_ts: float = 0.0
|
||
_caps_lock = threading.Lock()
|
||
_CAPS_TTL = 300 # seconds — re-probe every 5 min; license won't change mid-session
|
||
|
||
|
||
def _probe_capabilities() -> dict:
|
||
"""
|
||
Non-destructive read-only probes to detect which features the switch
|
||
supports under its current software license.
|
||
|
||
Base Software: ACL and L3 VLAN commands return '% Invalid input detected'.
|
||
Advanced License: commands succeed (may show empty output, but no error).
|
||
"""
|
||
acl_out = read_cmd("show ip access-list")
|
||
vlan_out = read_cmd("show interface vlan 1")
|
||
acl_ok = not _SWITCH_ERR.search(acl_out)
|
||
l3_ok = not _SWITCH_ERR.search(vlan_out)
|
||
return {
|
||
"acl": acl_ok,
|
||
"l3_vlan": l3_ok,
|
||
"dhcp_relay_config": l3_ok, # relay config uses 'interface vlan'
|
||
"management_pinholes": acl_ok,
|
||
"dns_enforce_acls": acl_ok,
|
||
"license_tier": "advanced" if acl_ok else "base",
|
||
}
|
||
|
||
|
||
def _require_advanced_license():
|
||
"""Raise 402 if the switch reports Base Software (no ACL/L3 support)."""
|
||
global _caps_ts
|
||
with _caps_lock:
|
||
cached = _caps_cache.copy() if _caps_cache else {}
|
||
# If we have a cached result use it; otherwise probe now
|
||
if not cached:
|
||
try:
|
||
cached = _probe_capabilities()
|
||
with _caps_lock:
|
||
_caps_cache.update(cached)
|
||
_caps_ts = time.time()
|
||
except HTTPException:
|
||
return # can't reach switch — let the push fail with its own error
|
||
if cached.get("license_tier") == "base":
|
||
raise HTTPException(
|
||
402,
|
||
"This feature requires the Advanced Software License. "
|
||
"The switch reported Base Software — ACLs and L3 VLAN interfaces are not available."
|
||
)
|
||
|
||
|
||
@app.get("/api/switch/capabilities")
|
||
def switch_capabilities():
|
||
"""
|
||
Probe the switch to determine which features are available under its
|
||
current software license. Results are cached for 5 minutes.
|
||
|
||
Base Software supports L2 only (VLANs, ports, PoE, show commands).
|
||
Advanced License adds ACLs and L3 VLAN interfaces.
|
||
|
||
Affected endpoints when license_tier == 'base':
|
||
- POST /api/switch/acl (acl)
|
||
- POST /api/ctrld/dns-enforce-acls (dns_enforce_acls)
|
||
"""
|
||
global _caps_cache, _caps_ts
|
||
with _caps_lock:
|
||
if time.time() - _caps_ts < _CAPS_TTL and _caps_cache:
|
||
return {**_caps_cache, "cached": True}
|
||
try:
|
||
caps = _probe_capabilities()
|
||
except HTTPException as e:
|
||
return {
|
||
"error": e.detail,
|
||
"acl": False, "l3_vlan": False,
|
||
"dhcp_relay_config": False,
|
||
"management_pinholes": False,
|
||
"dns_enforce_acls": False,
|
||
"license_tier": "unknown",
|
||
"cached": False,
|
||
}
|
||
with _caps_lock:
|
||
_caps_cache = caps
|
||
_caps_ts = time.time()
|
||
return {**caps, "cached": False}
|
||
|
||
# ── Danger pre-flight (no auth — check before prompting TOTP) ─────────
|
||
|
||
@app.post("/api/check/danger")
|
||
def danger_check(body: dict):
|
||
"""
|
||
Pre-flight danger check — call this before showing TOTP prompt.
|
||
|
||
Returns hard_blocked, warnings, and safe_to_push flag.
|
||
No auth required so the user sees danger info before authenticating.
|
||
"""
|
||
cmds = body.get("commands", [])
|
||
result = check_danger(cmds)
|
||
rejected = [c for c in cmds if not is_allowed(c)]
|
||
result["rejected_by_allowlist"] = rejected
|
||
result["safe_to_push"] = (
|
||
not result["has_hard_block"] and not rejected
|
||
)
|
||
return result
|
||
|
||
# ── Push endpoints (require session token) ────────────────────────────
|
||
|
||
@app.post("/api/switch/push")
|
||
def push(body: PushBatch):
|
||
"""
|
||
Push CLI batch one command at a time.
|
||
Requires valid session token.
|
||
Hard-blocked commands are refused — no override.
|
||
Warn-level commands proceed (user was already shown the warning).
|
||
Stops on first switch error. Config saved only on full success.
|
||
"""
|
||
require_session(body.token)
|
||
|
||
danger = check_danger(body.commands)
|
||
if danger["has_hard_block"]:
|
||
raise HTTPException(400, {
|
||
"message": "Hard-blocked commands detected — these must be run at the switch console",
|
||
"blocked": danger["hard_blocked"],
|
||
})
|
||
|
||
rejected = [c for c in body.commands if not is_allowed(c)]
|
||
if rejected:
|
||
raise HTTPException(400, {
|
||
"message": "Commands failed allowlist validation",
|
||
"rejected": rejected[:10],
|
||
})
|
||
|
||
log.info(f"Push: {len(body.commands)} commands")
|
||
return push_one_by_one(body.commands)
|
||
|
||
@app.post("/api/switch/vlan")
|
||
def create_vlan(body: VlanCreate):
|
||
"""Create a new VLAN on the switch (type port = standard Layer 2 VLAN)."""
|
||
require_session(body.token)
|
||
return push_one_by_one(
|
||
[f'vlan create {body.vlan_id} name "{body.name}" type port'])
|
||
|
||
@app.delete("/api/switch/vlan/{vlan_id}")
|
||
def delete_vlan(vlan_id: int, token: str):
|
||
"""Delete a VLAN by ID. VLAN 1 is blocked at model level; VLAN 99 is blocked by danger check."""
|
||
require_session(token)
|
||
return push_one_by_one([f"no vlan {san_vid(vlan_id)}"])
|
||
|
||
@app.post("/api/switch/port")
|
||
def configure_port(body: PortConfig):
|
||
"""Apply port configuration: mode (access/trunk/disabled), VLAN, PoE, description."""
|
||
require_session(body.token)
|
||
return push_one_by_one(build_port(body))
|
||
|
||
@app.post("/api/switch/acl")
|
||
def create_acl(body: AclCreate):
|
||
"""Create an extended IP ACL and apply it to a VLAN interface. Requires Advanced License."""
|
||
require_session(body.token)
|
||
_require_advanced_license()
|
||
return push_one_by_one(build_acl(body))
|
||
|
||
@app.post("/api/vlan/provision")
|
||
def provision_vlan(body: VlanProvision):
|
||
"""
|
||
End-to-end VLAN provisioning: switch VLAN + OPNsense interface tag +
|
||
DHCP scope + optional internet-allow firewall rule.
|
||
|
||
Steps performed:
|
||
1. Create VLAN on switch
|
||
2. Create VLAN tag on OPNsense (interfaces/vlan_settings)
|
||
3. Apply OPNsense VLAN config
|
||
4. If opnsense_if provided: create DHCP subnet + apply
|
||
5. If opnsense_if + allow_internet: add allow-outbound firewall rule + apply
|
||
|
||
Returns steps_done, pending_steps (anything needing manual finish in OPNsense UI).
|
||
"""
|
||
import ipaddress as _ipaddr
|
||
require_session(body.token)
|
||
|
||
steps_done: list[str] = []
|
||
pending_steps: list[str] = []
|
||
|
||
# Validate subnet/gateway/range are sane
|
||
try:
|
||
net = _ipaddr.ip_network(body.subnet, strict=False)
|
||
_ipaddr.ip_address(body.gateway)
|
||
_ipaddr.ip_address(body.dhcp_start)
|
||
_ipaddr.ip_address(body.dhcp_end)
|
||
except ValueError as e:
|
||
raise HTTPException(400, f"Invalid address: {e}")
|
||
|
||
# ── Step 1: Switch VLAN ────────────────────────────────────────────
|
||
result = push_one_by_one([f'vlan create {body.vlan_id} name "{body.name}" type port'])
|
||
if not result.get("success"):
|
||
raise HTTPException(502, {"message": "Switch VLAN create failed", "detail": result})
|
||
steps_done.append(f"switch: vlan {body.vlan_id} '{body.name}' created")
|
||
|
||
# ── Steps 2–5: OPNsense ───────────────────────────────────────────
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("key"):
|
||
pending_steps += [
|
||
f"OPNsense: create VLAN tag {body.vlan_id} on {body.parent_if}",
|
||
f"OPNsense: assign VLAN interface, set IP {body.gateway}/{net.prefixlen}",
|
||
f"OPNsense: create DHCP scope {body.dhcp_start}–{body.dhcp_end}",
|
||
]
|
||
if body.allow_internet:
|
||
pending_steps.append("OPNsense: add allow-outbound firewall rule for VLAN")
|
||
return {"success": True, "steps_done": steps_done, "pending_steps": pending_steps,
|
||
"note": "OPNsense not configured — connect it under DHCP settings to automate these steps"}
|
||
|
||
errors: list[str] = []
|
||
|
||
# Step 2: create VLAN tag
|
||
try:
|
||
vlan_r = _opnsense_request(cfg, "interfaces/vlan_settings/addItem", "POST", {
|
||
"vlan": {"if": body.parent_if, "tag": str(body.vlan_id), "pcp": "0", "descr": body.name}
|
||
})
|
||
vlan_uuid = vlan_r.get("uuid", "")
|
||
steps_done.append(f"OPNsense: VLAN tag {body.vlan_id} created on {body.parent_if} (uuid={vlan_uuid})")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense VLAN tag: {e}")
|
||
vlan_uuid = ""
|
||
|
||
# Step 3: apply VLAN config
|
||
if vlan_uuid:
|
||
try:
|
||
_opnsense_request(cfg, "interfaces/vlan_settings/reconfigure", "POST")
|
||
steps_done.append("OPNsense: VLAN config applied")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense VLAN apply: {e}")
|
||
|
||
# Interface assignment must be done in OPNsense UI unless opnsense_if is provided
|
||
if not body.opnsense_if:
|
||
pending_steps += [
|
||
f"OPNsense UI: assign {body.parent_if}.{body.vlan_id} as a new interface, "
|
||
f"set static IP {body.gateway}/{net.prefixlen}, note the interface name (e.g. opt2)",
|
||
f"OPNsense: create DHCP scope {body.dhcp_start}–{body.dhcp_end} once interface is assigned",
|
||
]
|
||
if body.allow_internet:
|
||
pending_steps.append("OPNsense: add allow-outbound firewall rule for new interface")
|
||
else:
|
||
# Step 4: DHCP scope
|
||
try:
|
||
_opnsense_request(cfg, "dhcpv4/settings/addSubnet", "POST", {
|
||
"subnet": {
|
||
"interface": body.opnsense_if,
|
||
"subnet": str(net),
|
||
"gateway": body.gateway,
|
||
"dns_servers": body.gateway,
|
||
"range": {"from": body.dhcp_start, "to": body.dhcp_end},
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
|
||
steps_done.append(
|
||
f"OPNsense: DHCP scope {body.dhcp_start}–{body.dhcp_end} on {body.opnsense_if} created")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense DHCP scope: {e}")
|
||
|
||
# Step 5: firewall allow-outbound
|
||
if body.allow_internet:
|
||
try:
|
||
fw_r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1",
|
||
"action": "pass",
|
||
"interface": body.opnsense_if,
|
||
"direction": "in",
|
||
"ipprotocol": "inet",
|
||
"protocol": "any",
|
||
"source": {"network": f"{body.opnsense_if}net"},
|
||
"destination":{"any": "1"},
|
||
"descr": f"Allow VLAN {body.vlan_id} {body.name} outbound",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
steps_done.append(
|
||
f"OPNsense: allow-outbound rule for {body.opnsense_if} added (uuid={fw_r.get('uuid','')})")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense firewall rule: {e}")
|
||
|
||
# Persist VLAN→interface mapping for push-reservation lookups
|
||
vmap = _load_vlan_if_map()
|
||
vmap[str(body.vlan_id)] = body.opnsense_if
|
||
_save_vlan_if_map(vmap)
|
||
|
||
return {
|
||
"success": len(errors) == 0,
|
||
"steps_done": steps_done,
|
||
"pending_steps": pending_steps,
|
||
"errors": errors,
|
||
}
|
||
|
||
# ── Serve React app ────────────────────────────────────────────────────
|
||
if os.path.isdir(STATIC_DIR):
|
||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="frontend")
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
if __name__ == "__main__":
|
||
if "--setup-totp" in sys.argv:
|
||
setup_totp()
|
||
sys.exit(0)
|
||
import uvicorn
|
||
uvicorn.run("switch_backend:app", host="0.0.0.0", port=8765,
|
||
log_level="info")
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# DEVICE ACCESS — DHCP LEASES, MAC RESERVATIONS, ACL PINHOLES
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
import json as _json
|
||
from pathlib import Path as _Path
|
||
|
||
DEVICES_FILE = _Path("/etc/switch-manager/devices.json")
|
||
|
||
def _load_devices() -> list:
|
||
"""Load the saved device list from devices.json, returning [] on missing or corrupt file."""
|
||
if DEVICES_FILE.exists():
|
||
try: return _json.loads(DEVICES_FILE.read_text())
|
||
except: pass
|
||
return []
|
||
|
||
def _save_devices(devices: list):
|
||
"""Persist the device list to devices.json with 2-space indentation."""
|
||
DEVICES_FILE.write_text(_json.dumps(devices, indent=2))
|
||
|
||
def _parse_dhcp_leases(raw: str) -> list:
|
||
"""Parse ERS 59100GTS-PWR+ 'show dhcp-server leases' output."""
|
||
import re
|
||
leases = []
|
||
for line in raw.splitlines():
|
||
# Format: IP MAC State Remaining Hostname
|
||
m = re.match(
|
||
r'\s*(\d+\.\d+\.\d+\.\d+)\s+'
|
||
r'([0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2}'
|
||
r'[:\-][0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2})\s+'
|
||
r'(\S+)\s+(\S+)\s*(.*)', line)
|
||
if m:
|
||
leases.append({
|
||
"ip": m.group(1),
|
||
"mac": m.group(2).lower().replace('-',':'),
|
||
"state": m.group(3),
|
||
"remaining": m.group(4),
|
||
"hostname": m.group(5).strip() or "unknown",
|
||
})
|
||
return leases
|
||
|
||
def _parse_arp_table(raw: str) -> list:
|
||
"""Parse 'show arp' for additional device discovery."""
|
||
import re
|
||
entries = []
|
||
for line in raw.splitlines():
|
||
m = re.match(
|
||
r'\s*(\d+\.\d+\.\d+\.\d+)\s+'
|
||
r'([0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2}'
|
||
r'[:\-][0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2}[:\-][0-9a-fA-F]{2})',
|
||
line)
|
||
if m:
|
||
entries.append({
|
||
"ip": m.group(1),
|
||
"mac": m.group(2).lower().replace('-',':'),
|
||
"state": "arp",
|
||
"remaining": "-",
|
||
"hostname": "",
|
||
})
|
||
return entries
|
||
|
||
class DeviceEntry(BaseModel):
|
||
name: str
|
||
mac: str
|
||
ip: str
|
||
vlan: int = 10
|
||
management_access: bool = False
|
||
static_ip: bool = False
|
||
notes: Optional[str] = ""
|
||
|
||
class DeviceUpdate(BaseModel):
|
||
token: str
|
||
device: DeviceEntry
|
||
|
||
class DeviceDelete(BaseModel):
|
||
token: str
|
||
mac: str
|
||
|
||
class PinholeRequest(BaseModel):
|
||
token: str
|
||
mac: str
|
||
allow: bool = True
|
||
|
||
def _build_dhcp_reservation_cmds(device: DeviceEntry) -> list:
|
||
"""Generate ERS 59100GTS-PWR+ CLI for DHCP static binding."""
|
||
mac_clean = device.mac.replace(':','-').upper()
|
||
return [
|
||
f"ip dhcp-server static-binding {device.ip}",
|
||
f" mac-address {mac_clean}",
|
||
f" client-name \"{device.name}\"",
|
||
]
|
||
|
||
def _build_pinhole_acl_cmds(device: DeviceEntry, mgmt_ip: str, allow: bool) -> list:
|
||
"""Generate ACL commands to allow/deny a device IP to reach management."""
|
||
acl_name = f"MGMT-ACCESS"
|
||
if allow:
|
||
return [
|
||
f"ip access-list extended {acl_name}",
|
||
f" permit tcp host {device.ip} host {mgmt_ip} eq 443",
|
||
f" permit tcp host {device.ip} host {mgmt_ip} eq 8765",
|
||
]
|
||
else:
|
||
return [
|
||
f"ip access-list extended {acl_name}",
|
||
f" no permit tcp host {device.ip} host {mgmt_ip}",
|
||
]
|
||
|
||
@app.get("/api/devices")
|
||
def get_devices():
|
||
"""Return saved device list plus live DHCP leases and ARP from switch."""
|
||
saved = _load_devices()
|
||
live_leases = []
|
||
try:
|
||
arp_raw = read_cmd("show arp")
|
||
live_leases = _parse_arp_table(arp_raw)
|
||
try:
|
||
dhcp_raw = read_cmd("show ip dhcp-server leases")
|
||
dhcp_leases = _parse_dhcp_leases(dhcp_raw)
|
||
lease_ips = {l["ip"] for l in dhcp_leases}
|
||
live_leases = dhcp_leases + [e for e in live_leases if e["ip"] not in lease_ips]
|
||
except Exception:
|
||
pass # DHCP server may not be enabled
|
||
except Exception as e:
|
||
log.warning(f"Could not pull ARP from switch: {e}")
|
||
return {
|
||
"saved": saved,
|
||
"live": live_leases,
|
||
"mgmt_ip": SWITCH_HOST,
|
||
}
|
||
|
||
@app.post("/api/devices/save")
|
||
def save_device(body: DeviceUpdate):
|
||
"""Save or update a device entry (upsert by MAC address)."""
|
||
require_session(body.token)
|
||
devices = _load_devices()
|
||
existing = next((i for i, d in enumerate(devices) if d["mac"] == body.device.mac), None)
|
||
device_dict = body.device.dict()
|
||
if existing is not None:
|
||
devices[existing] = device_dict
|
||
else:
|
||
devices.append(device_dict)
|
||
_save_devices(devices)
|
||
log.info(f"Device saved: {body.device.name} ({body.device.mac})")
|
||
return {"success": True, "devices": devices}
|
||
|
||
@app.post("/api/devices/delete")
|
||
def delete_device(body: DeviceDelete):
|
||
"""Remove a device from the saved list by MAC address."""
|
||
require_session(body.token)
|
||
devices = [d for d in _load_devices() if d["mac"] != body.mac]
|
||
_save_devices(devices)
|
||
return {"success": True}
|
||
|
||
@app.post("/api/devices/push-reservation")
|
||
def push_reservation(body: DeviceUpdate):
|
||
"""
|
||
Push a DHCP static reservation for this device.
|
||
|
||
Routes to OPNsense if configured (preferred — no license required).
|
||
Falls back to switch DHCP CLI only if OPNsense is not configured, which
|
||
requires Advanced License on the switch.
|
||
"""
|
||
require_session(body.token)
|
||
device = body.device
|
||
cfg = _load_opnsense_cfg()
|
||
|
||
if cfg.get("key"):
|
||
# Derive OPNsense interface from stored VLAN→interface map
|
||
vmap = _load_vlan_if_map()
|
||
iface = vmap.get(str(device.vlan), "")
|
||
try:
|
||
result = _opnsense_request(cfg, "dhcpv4/reservations/addReservation", "POST", {
|
||
"reservation": {
|
||
"interface": iface,
|
||
"mac": device.mac,
|
||
"ipaddr": device.ip,
|
||
"hostname": device.name,
|
||
"descr": f"Added by switch-manager",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
|
||
log.info(f"OPNsense DHCP reservation pushed: {device.name} ({device.mac}) → {device.ip}")
|
||
return {"success": True, "target": "opnsense", "result": result}
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e))
|
||
else:
|
||
# Switch DHCP — requires Advanced License
|
||
cmds = _build_dhcp_reservation_cmds(device)
|
||
danger = check_danger(cmds)
|
||
if danger["has_hard_block"]:
|
||
raise HTTPException(400, {"message": "Blocked", "blocked": danger["hard_blocked"]})
|
||
log.info(f"Switch DHCP reservation pushed: {device.name}")
|
||
result = push_one_by_one(cmds)
|
||
result["target"] = "switch"
|
||
return result
|
||
|
||
@app.post("/api/devices/push-pinhole")
|
||
def push_pinhole(body: PinholeRequest):
|
||
"""
|
||
Add or remove a management-access firewall pinhole for a device.
|
||
|
||
Uses OPNsense firewall API if configured. Rule UUIDs are stored locally
|
||
so the same device can be cleanly de-pinholed later.
|
||
"""
|
||
require_session(body.token)
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("key"):
|
||
raise HTTPException(503, "OPNsense not configured — connect it under DHCP settings")
|
||
|
||
devices = _load_devices()
|
||
device = next((DeviceEntry(**d) for d in devices if d["mac"] == body.mac), None)
|
||
if not device:
|
||
raise HTTPException(404, "Device not found — save it first")
|
||
|
||
import socket
|
||
try:
|
||
mgmt_ip = socket.gethostbyname(socket.gethostname())
|
||
except Exception:
|
||
mgmt_ip = SWITCH_HOST.rsplit('.', 1)[0] + '.50'
|
||
|
||
pinholes = _load_pinholes()
|
||
|
||
if body.allow:
|
||
# Look up OPNsense interface for device VLAN
|
||
vmap = _load_vlan_if_map()
|
||
iface = vmap.get(str(device.vlan), "")
|
||
try:
|
||
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1",
|
||
"action": "pass",
|
||
"interface": iface,
|
||
"direction": "in",
|
||
"ipprotocol": "inet",
|
||
"protocol": "tcp",
|
||
"source": {"address": device.ip},
|
||
"destination": {"address": mgmt_ip, "port": "8765"},
|
||
"descr": f"switch-manager pinhole {device.name}",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
pinholes[device.mac] = r.get("uuid", "")
|
||
_save_pinholes(pinholes)
|
||
log.info(f"Pinhole allow: {device.name} ({device.ip}) → {mgmt_ip}:8765")
|
||
return {"success": True, "action": "allow", "uuid": r.get("uuid", "")}
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e))
|
||
else:
|
||
uuid = pinholes.get(device.mac, "")
|
||
if not uuid:
|
||
raise HTTPException(404, "No pinhole rule found for this device")
|
||
try:
|
||
_opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
pinholes.pop(device.mac, None)
|
||
_save_pinholes(pinholes)
|
||
log.info(f"Pinhole removed: {device.name} ({device.ip})")
|
||
return {"success": True, "action": "deny", "uuid": uuid}
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e))
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# WIREGUARD PEER MANAGEMENT
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
WG_CLIENT_DIR_API = _Path("/etc/switch-manager/clients")
|
||
WG_CONF_PATH = _Path("/etc/wireguard/wg0.conf")
|
||
|
||
class WGClientRequest(BaseModel):
|
||
token: str
|
||
name: str
|
||
|
||
class WGRevokeRequest(BaseModel):
|
||
token: str
|
||
name: str
|
||
|
||
def _wg_genkey_api():
|
||
"""Generate a WireGuard private/public keypair using the system wg tool."""
|
||
import subprocess as _sp
|
||
priv = _sp.run(["wg","genkey"], capture_output=True, text=True).stdout.strip()
|
||
pub = _sp.run(["wg","pubkey"], input=priv, capture_output=True, text=True).stdout.strip()
|
||
return priv, pub
|
||
|
||
def _wg_status() -> dict:
|
||
"""Return parsed WireGuard interface status including connected peers."""
|
||
import subprocess as _sp
|
||
try:
|
||
raw = _sp.run(["wg","show"], capture_output=True, text=True).stdout
|
||
peers = []
|
||
current = {}
|
||
for line in raw.splitlines():
|
||
line = line.strip()
|
||
if line.startswith("peer:"):
|
||
if current: peers.append(current)
|
||
current = {"public_key": line.split(":",1)[1].strip()}
|
||
elif line.startswith("endpoint:"):
|
||
current["endpoint"] = line.split(":",1)[1].strip()
|
||
elif line.startswith("latest handshake:"):
|
||
current["last_handshake"] = line.split(":",1)[1].strip()
|
||
elif line.startswith("transfer:"):
|
||
current["transfer"] = line.split(":",1)[1].strip()
|
||
elif line.startswith("allowed ips:"):
|
||
current["allowed_ips"] = line.split(":",1)[1].strip()
|
||
if current: peers.append(current)
|
||
|
||
# Match peers to named client files
|
||
named = {}
|
||
if WG_CLIENT_DIR_API.exists():
|
||
for f in WG_CLIENT_DIR_API.glob("*.conf"):
|
||
txt = f.read_text()
|
||
import re
|
||
m = re.search(r'PublicKey\s*=\s*(\S+)', txt)
|
||
if m: named[m.group(1)] = f.stem
|
||
|
||
for p in peers:
|
||
p["name"] = named.get(p.get("public_key",""), "unknown")
|
||
|
||
return {"running": True, "peers": peers}
|
||
except Exception as e:
|
||
return {"running": False, "error": str(e), "peers": []}
|
||
|
||
@app.get("/api/wireguard/status")
|
||
def wg_status():
|
||
return _wg_status()
|
||
|
||
@app.get("/api/wireguard/clients")
|
||
def wg_clients():
|
||
clients = []
|
||
if WG_CLIENT_DIR_API.exists():
|
||
for f in sorted(WG_CLIENT_DIR_API.glob("*.conf")):
|
||
clients.append({"name": f.stem, "file": str(f)})
|
||
return {"clients": clients}
|
||
|
||
@app.post("/api/wireguard/add-client")
|
||
def wg_add_client(body: WGClientRequest):
|
||
require_session(body.token)
|
||
if not WG_CONF_PATH.exists():
|
||
raise HTTPException(503, "WireGuard not configured on this machine")
|
||
|
||
import re, subprocess as _sp, socket
|
||
|
||
# Next available IP
|
||
conf_text = WG_CONF_PATH.read_text()
|
||
used = set()
|
||
for m in re.finditer(r'AllowedIPs\s*=\s*(\S+)', conf_text):
|
||
used.add(m.group(1).split('/')[0])
|
||
|
||
subnet = "10.99.0"
|
||
num = 2
|
||
while f"{subnet}.{num}" in used and num < 254: num += 1
|
||
client_ip = f"{subnet}.{num}"
|
||
|
||
# Server public key
|
||
server_pub_path = _Path("/etc/switch-manager/wg_server_public")
|
||
if not server_pub_path.exists():
|
||
raise HTTPException(503, "Server public key not found")
|
||
server_pub = server_pub_path.read_text().strip()
|
||
|
||
# Client keys
|
||
c_priv, c_pub = _wg_genkey_api()
|
||
|
||
# Peer entry in server config
|
||
peer = f"\n[Peer]\n# {body.name}\nPublicKey = {c_pub}\nAllowedIPs = {client_ip}/32\n"
|
||
with open(WG_CONF_PATH, 'a') as f:
|
||
f.write(peer)
|
||
|
||
# Reload live
|
||
_sp.run(["wg","addconf","wg0","/dev/stdin"],
|
||
input=f"[Peer]\nPublicKey = {c_pub}\nAllowedIPs = {client_ip}/32\n",
|
||
capture_output=True, text=True)
|
||
|
||
# Public IP for endpoint
|
||
try:
|
||
pub_ip = _sp.run(["curl","-s","--max-time","5","https://api.ipify.org"],
|
||
capture_output=True, text=True).stdout.strip()
|
||
except Exception:
|
||
pub_ip = socket.gethostbyname(socket.gethostname())
|
||
|
||
# Get management IP from switch config
|
||
mgmt_subnet = '.'.join(SWITCH_HOST.split('.')[:3]) + '.0/24'
|
||
|
||
client_conf = (
|
||
f"[Interface]\nPrivateKey = {c_priv}\nAddress = {client_ip}/24\n"
|
||
f"DNS = {subnet}.1\n\n"
|
||
f"[Peer]\nPublicKey = {server_pub}\n"
|
||
f"Endpoint = {pub_ip}:51820\n"
|
||
f"AllowedIPs = {mgmt_subnet}, {subnet}.0/24\n"
|
||
f"PersistentKeepalive = 25\n"
|
||
)
|
||
|
||
WG_CLIENT_DIR_API.mkdir(exist_ok=True)
|
||
client_file = WG_CLIENT_DIR_API / f"{body.name}.conf"
|
||
client_file.write_text(client_conf)
|
||
client_file.chmod(0o600)
|
||
|
||
log.info(f"WireGuard client added: {body.name} → {client_ip}")
|
||
return {
|
||
"success": True,
|
||
"name": body.name,
|
||
"client_ip": client_ip,
|
||
"config": client_conf,
|
||
"file": str(client_file),
|
||
}
|
||
|
||
@app.post("/api/wireguard/revoke-client")
|
||
def wg_revoke_client(body: WGRevokeRequest):
|
||
require_session(body.token)
|
||
import re, subprocess as _sp
|
||
|
||
client_file = WG_CLIENT_DIR_API / f"{body.name}.conf"
|
||
if not client_file.exists():
|
||
raise HTTPException(404, f"Client '{body.name}' not found")
|
||
|
||
# Get client public key
|
||
txt = client_file.read_text()
|
||
m = re.search(r'\[Peer\].*?PublicKey\s*=\s*(\S+)', txt, re.DOTALL)
|
||
client_pub = m.group(1) if m else None
|
||
|
||
# Remove from server config
|
||
if WG_CONF_PATH.exists():
|
||
conf = WG_CONF_PATH.read_text()
|
||
# Remove the [Peer] block for this client
|
||
cleaned = re.sub(
|
||
rf'\n\[Peer\]\n# {re.escape(body.name)}\n.*?(?=\n\[Peer\]|\Z)',
|
||
'', conf, flags=re.DOTALL
|
||
)
|
||
WG_CONF_PATH.write_text(cleaned)
|
||
|
||
# Remove live peer
|
||
if client_pub:
|
||
_sp.run(["wg","set","wg0","peer",client_pub,"remove"],
|
||
capture_output=True, text=True)
|
||
|
||
# Delete client file
|
||
client_file.unlink()
|
||
log.info(f"WireGuard client revoked: {body.name}")
|
||
return {"success": True}
|
||
|
||
@app.get("/api/wireguard/client-qr/{name}")
|
||
def wg_client_qr(name: str):
|
||
"""Return client config as text for QR generation in frontend."""
|
||
client_file = WG_CLIENT_DIR_API / f"{name}.conf"
|
||
if not client_file.exists():
|
||
raise HTTPException(404, f"Client '{name}' not found")
|
||
return {"name": name, "config": client_file.read_text()}
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# DHCP MANAGEMENT — SWITCH + OPNSENSE UNIFIED VIEW
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
import urllib.request as _urlreq
|
||
import urllib.error as _urlerr
|
||
import ssl as _ssl
|
||
import base64 as _b64
|
||
|
||
OPNSENSE_FILE = _Path("/etc/switch-manager/opnsense.json")
|
||
VLAN_IF_MAP_FILE = _Path("/etc/switch-manager/vlan-if-map.json")
|
||
PINHOLE_FILE = _Path("/etc/switch-manager/pinholes.json")
|
||
|
||
def _load_vlan_if_map() -> dict:
|
||
"""Return {vlan_id_str: opnsense_if_name} e.g. {"20": "opt2"}."""
|
||
if VLAN_IF_MAP_FILE.exists():
|
||
try: return _json.loads(VLAN_IF_MAP_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_vlan_if_map(m: dict):
|
||
VLAN_IF_MAP_FILE.write_text(_json.dumps(m, indent=2))
|
||
|
||
def _load_pinholes() -> dict:
|
||
"""Return {mac: rule_uuid} for OPNsense firewall pinholes."""
|
||
if PINHOLE_FILE.exists():
|
||
try: return _json.loads(PINHOLE_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_pinholes(m: dict):
|
||
PINHOLE_FILE.write_text(_json.dumps(m, indent=2))
|
||
PINHOLE_FILE.chmod(0o600)
|
||
|
||
def _load_opnsense_cfg() -> dict:
|
||
"""Load saved OPNsense API credentials from opnsense.json, returning {} if absent."""
|
||
if OPNSENSE_FILE.exists():
|
||
try: return _json.loads(OPNSENSE_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_opnsense_cfg(cfg: dict):
|
||
"""Persist OPNsense API credentials to opnsense.json (chmod 600 — contains secrets)."""
|
||
OPNSENSE_FILE.write_text(_json.dumps(cfg, indent=2))
|
||
OPNSENSE_FILE.chmod(0o600)
|
||
|
||
def _opnsense_request(cfg: dict, path: str, method="GET", body=None) -> dict:
|
||
"""Make an authenticated request to the OPNsense API."""
|
||
host = cfg.get("host","")
|
||
key = cfg.get("key","")
|
||
secret = cfg.get("secret","")
|
||
if not host or not key or not secret:
|
||
raise ValueError("OPNsense not configured")
|
||
url = f"https://{host}/api/{path}"
|
||
creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()
|
||
ctx = _ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = _ssl.CERT_NONE
|
||
headers = {
|
||
"Authorization": f"Basic {creds}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
data = _json.dumps(body).encode() if body else None
|
||
req = _urlreq.Request(url, data=data, headers=headers, method=method)
|
||
try:
|
||
with _urlreq.urlopen(req, timeout=5, context=ctx) as r:
|
||
return _json.loads(r.read().decode())
|
||
except _urlerr.HTTPError as e:
|
||
raise ValueError(f"OPNsense API error {e.code}: {e.reason}")
|
||
except Exception as e:
|
||
raise ValueError(f"OPNsense unreachable: {e}")
|
||
|
||
def _detect_opnsense_host(gateway_ip: str) -> str | None:
|
||
"""Try to reach OPNsense API at the gateway IP."""
|
||
try:
|
||
ctx = _ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = _ssl.CERT_NONE
|
||
url = f"https://{gateway_ip}/api/core/firmware/status"
|
||
req = _urlreq.Request(url, headers={"User-Agent":"switch-manager/1"})
|
||
_urlreq.urlopen(req, timeout=3, context=ctx)
|
||
return gateway_ip
|
||
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:
|
||
data = _opnsense_request(cfg, "dhcpv4/leases/searchReservation")
|
||
rows = data.get("rows", [])
|
||
return [
|
||
{
|
||
"ip": r.get("ipaddr",""),
|
||
"mac": r.get("mac","").lower(),
|
||
"hostname": r.get("hostname",""),
|
||
"descr": r.get("descr",""),
|
||
"if": r.get("if",""),
|
||
"source": "opnsense",
|
||
"uuid": r.get("uuid",""),
|
||
}
|
||
for r in rows if r.get("mac")
|
||
]
|
||
except Exception as e:
|
||
log.warning(f"OPNsense reservations fetch failed: {e}")
|
||
return []
|
||
|
||
def _get_opnsense_leases(cfg: dict) -> list:
|
||
"""Fetch active DHCP leases from OPNsense."""
|
||
try:
|
||
data = _opnsense_request(cfg, "dhcpv4/leases/searchLease")
|
||
rows = data.get("rows", [])
|
||
return [
|
||
{
|
||
"ip": r.get("address",""),
|
||
"mac": r.get("mac","").lower(),
|
||
"hostname": r.get("hostname",""),
|
||
"state": r.get("state",""),
|
||
"if": r.get("if",""),
|
||
"source": "opnsense_lease",
|
||
}
|
||
for r in rows if r.get("mac")
|
||
]
|
||
except Exception as e:
|
||
log.warning(f"OPNsense leases fetch failed: {e}")
|
||
return []
|
||
|
||
def _get_switch_reservations() -> list:
|
||
"""Fetch DHCP static bindings from ERS 59100GTS-PWR+.
|
||
|
||
Requires ip dhcp-server to be enabled on the switch.
|
||
Returns empty list if DHCP server is not enabled/licensed.
|
||
"""
|
||
import re as _re
|
||
try:
|
||
raw = read_cmd("show ip dhcp-server static-binding")
|
||
bindings = []
|
||
current = {}
|
||
for line in raw.splitlines():
|
||
m = _re.match(r'\s*IP Address:\s*(\S+)', line)
|
||
if m:
|
||
if current: bindings.append(current)
|
||
current = {"ip": m.group(1), "mac":"", "hostname":"", "source":"switch"}
|
||
m2 = _re.match(r'\s*MAC Address:\s*(\S+)', line)
|
||
if m2 and current:
|
||
current["mac"] = m2.group(1).lower().replace('-',':')
|
||
m3 = _re.match(r'\s*Client Name:\s*(\S+)', line)
|
||
if m3 and current:
|
||
current["hostname"] = m3.group(1)
|
||
if current and current.get("ip"):
|
||
bindings.append(current)
|
||
return bindings
|
||
except Exception as e:
|
||
log.warning(f"Switch DHCP reservation fetch failed: {e}")
|
||
return []
|
||
|
||
def _get_switch_dhcp_status() -> dict:
|
||
"""Check if switch DHCP server is running and which VLANs it serves.
|
||
|
||
Requires ip dhcp-server to be enabled on the switch.
|
||
"""
|
||
import re as _re
|
||
try:
|
||
raw = read_cmd("show ip dhcp-server")
|
||
running = "enabled" in raw.lower() or "active" in raw.lower()
|
||
vlans = _re.findall(r'VLAN\s+(\d+)', raw, _re.I)
|
||
return {"running": running, "vlans": list(set(vlans))}
|
||
except Exception:
|
||
return {"running": False, "vlans": []}
|
||
|
||
def _find_conflicts(switch_res: list, opnsense_res: list) -> list:
|
||
"""
|
||
Find same MAC in both switch and OPNsense.
|
||
Flag if IPs differ (conflict) or same (duplicate — harmless but messy).
|
||
"""
|
||
switch_by_mac = {r["mac"]: r for r in switch_res if r.get("mac")}
|
||
conflicts = []
|
||
for r in opnsense_res:
|
||
mac = r.get("mac","")
|
||
if mac and mac in switch_by_mac:
|
||
sw = switch_by_mac[mac]
|
||
conflicts.append({
|
||
"mac": mac,
|
||
"hostname": r.get("hostname") or sw.get("hostname",""),
|
||
"switch_ip": sw["ip"],
|
||
"opnsense_ip": r["ip"],
|
||
"ip_conflict": sw["ip"] != r["ip"],
|
||
"opnsense_uuid": r.get("uuid",""),
|
||
})
|
||
return conflicts
|
||
|
||
# ── OPNsense config models ─────────────────────────────────────────────────
|
||
|
||
class OPNsenseConfig(BaseModel):
|
||
host: str
|
||
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
|
||
ip: str
|
||
hostname: str
|
||
descr: Optional[str] = ""
|
||
iface: Optional[str] = "lan"
|
||
|
||
class SyncRequest(BaseModel):
|
||
token: str
|
||
mac: str
|
||
direction: str # "to_switch" | "to_opnsense" | "remove_switch" | "remove_opnsense"
|
||
|
||
|
||
# ── DHCP endpoints ─────────────────────────────────────────────────────────
|
||
|
||
@app.get("/api/dhcp/overview")
|
||
def dhcp_overview():
|
||
"""
|
||
Unified DHCP view:
|
||
- Switch static bindings + active leases
|
||
- OPNsense reservations + leases (if configured)
|
||
- Conflicts (same MAC, different IP)
|
||
- Which DHCP server is active per VLAN
|
||
"""
|
||
switch_res = _get_switch_reservations()
|
||
switch_leases = []
|
||
switch_status = _get_switch_dhcp_status()
|
||
|
||
# Discover devices via DHCP leases + ARP
|
||
try:
|
||
arp_raw = read_cmd("show arp")
|
||
switch_leases = _parse_arp_table(arp_raw)
|
||
try:
|
||
dhcp_raw = read_cmd("show ip dhcp-server leases")
|
||
dhcp_leases = _parse_dhcp_leases(dhcp_raw)
|
||
lease_ips = {l["ip"] for l in dhcp_leases}
|
||
# Merge ARP entries not already in leases
|
||
switch_leases = dhcp_leases + [e for e in switch_leases if e["ip"] not in lease_ips]
|
||
except Exception:
|
||
pass # DHCP server may not be enabled
|
||
except Exception as e:
|
||
log.warning(f"Switch ARP fetch failed: {e}")
|
||
|
||
cfg = _load_opnsense_cfg()
|
||
opnsense_res = _get_opnsense_reservations(cfg) if cfg.get("key") else []
|
||
opnsense_leases = _get_opnsense_leases(cfg) if cfg.get("key") else []
|
||
conflicts = _find_conflicts(switch_res, opnsense_res)
|
||
|
||
opnsense_ifaces = list({r.get("if","") for r in opnsense_res + opnsense_leases if r.get("if")})
|
||
|
||
return {
|
||
"switch": {
|
||
"status": switch_status,
|
||
"reservations": switch_res,
|
||
"leases": switch_leases,
|
||
},
|
||
"opnsense": {
|
||
"configured": bool(cfg.get("key")),
|
||
"host": cfg.get("host",""),
|
||
"reservations": opnsense_res,
|
||
"leases": opnsense_leases,
|
||
"interfaces": opnsense_ifaces,
|
||
},
|
||
"conflicts": conflicts,
|
||
"has_conflicts": len(conflicts) > 0,
|
||
}
|
||
|
||
@app.get("/api/dhcp/detect-opnsense")
|
||
def detect_opnsense_endpoint():
|
||
"""Auto-detect OPNsense at the gateway IP."""
|
||
import re as _re
|
||
try:
|
||
route = read_cmd("show ip route")
|
||
# Look for default route: DST=0.0.0.0, MASK=0.0.0.0 — NEXT column is gateway
|
||
m = _re.search(r'^0\.0\.0\.0\s+0\.0\.0\.0\s+(\d+\.\d+\.\d+\.\d+)', route, _re.MULTILINE)
|
||
gateway = m.group(1) if m else None
|
||
except Exception:
|
||
gateway = None
|
||
|
||
if not gateway:
|
||
# Try from switch management IP
|
||
parts = SWITCH_HOST.split('.')
|
||
parts[-1] = '1'
|
||
gateway = '.'.join(parts)
|
||
|
||
host = _detect_opnsense_host(gateway)
|
||
return {
|
||
"detected": host is not None,
|
||
"host": host,
|
||
"gateway": gateway,
|
||
"configured": bool(_load_opnsense_cfg().get("key")),
|
||
}
|
||
|
||
@app.post("/api/dhcp/configure-opnsense")
|
||
def configure_opnsense(body: OPNsenseConfig):
|
||
"""Save OPNsense API credentials. Tests connectivity first."""
|
||
cfg = {"host": body.host, "key": body.key, "secret": body.secret}
|
||
try:
|
||
result = _opnsense_request(cfg, "core/firmware/status")
|
||
_save_opnsense_cfg(cfg)
|
||
log.info(f"OPNsense configured: {body.host}")
|
||
return {"success": True, "version": result.get("product_version","unknown")}
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e))
|
||
|
||
@app.delete("/api/dhcp/configure-opnsense")
|
||
def remove_opnsense_config():
|
||
"""Remove OPNsense API credentials."""
|
||
if OPNSENSE_FILE.exists():
|
||
OPNSENSE_FILE.unlink()
|
||
return {"success": True}
|
||
|
||
@app.post("/api/dhcp/push-to-opnsense")
|
||
def push_reservation_to_opnsense(body: OPNsenseReservationPush):
|
||
"""Create a DHCP static mapping in OPNsense."""
|
||
require_session(body.token)
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("key"):
|
||
raise HTTPException(503, "OPNsense not configured")
|
||
try:
|
||
result = _opnsense_request(cfg, "dhcpv4/reservations/addReservation", "POST", {
|
||
"reservation": {
|
||
"interface": body.iface,
|
||
"mac": body.mac,
|
||
"ipaddr": body.ip,
|
||
"hostname": body.hostname,
|
||
"descr": body.descr or f"Added by switch-manager",
|
||
}
|
||
})
|
||
# Apply changes
|
||
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
|
||
log.info(f"OPNsense reservation pushed: {body.mac} → {body.ip}")
|
||
return {"success": True, "result": result}
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e))
|
||
|
||
@app.post("/api/dhcp/sync")
|
||
def sync_reservation(body: SyncRequest):
|
||
"""
|
||
Sync a reservation between switch and OPNsense.
|
||
Directions:
|
||
to_switch — copy OPNsense reservation to switch
|
||
to_opnsense — copy switch reservation to OPNsense
|
||
remove_switch — remove from switch only
|
||
remove_opnsense — remove from OPNsense only
|
||
"""
|
||
require_session(body.token)
|
||
cfg = _load_opnsense_cfg()
|
||
overview = dhcp_overview()
|
||
|
||
# Find the device in both sources
|
||
sw_res = next((r for r in overview["switch"]["reservations"] if r["mac"]==body.mac), None)
|
||
ops_res = next((r for r in overview["opnsense"]["reservations"] if r["mac"]==body.mac), None)
|
||
|
||
if body.direction == "to_switch":
|
||
if not ops_res:
|
||
raise HTTPException(404, "OPNsense reservation not found")
|
||
cmds = _build_dhcp_reservation_cmds(type('D',(),{
|
||
"ip": ops_res["ip"], "mac": ops_res["mac"], "name": ops_res.get("hostname","")
|
||
})())
|
||
return push_one_by_one(cmds)
|
||
|
||
elif body.direction == "to_opnsense":
|
||
if not sw_res:
|
||
raise HTTPException(404, "Switch reservation not found")
|
||
if not cfg.get("key"):
|
||
raise HTTPException(503, "OPNsense not configured")
|
||
result = _opnsense_request(cfg, "dhcpv4/reservations/addReservation", "POST", {
|
||
"reservation": {
|
||
"mac": sw_res["mac"], "ipaddr": sw_res["ip"],
|
||
"hostname": sw_res.get("hostname",""), "descr": "Synced from switch",
|
||
"interface": "lan",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
|
||
return {"success": True, "result": result}
|
||
|
||
elif body.direction == "remove_switch":
|
||
if not sw_res:
|
||
raise HTTPException(404, "Switch reservation not found")
|
||
return push_one_by_one([f"no ip dhcp-server static-binding {sw_res['ip']}"])
|
||
|
||
elif body.direction == "remove_opnsense":
|
||
if not ops_res or not ops_res.get("uuid"):
|
||
raise HTTPException(404, "OPNsense reservation not found or missing UUID")
|
||
if not cfg.get("key"):
|
||
raise HTTPException(503, "OPNsense not configured")
|
||
_opnsense_request(cfg, f"dhcpv4/reservations/delReservation/{ops_res['uuid']}", "DELETE")
|
||
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
|
||
return {"success": True}
|
||
|
||
raise HTTPException(400, f"Unknown direction: {body.direction}")
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# CONTROL D / ctrld DNS MANAGEMENT
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
CTRLD_FILE = _Path("/etc/switch-manager/ctrld.json")
|
||
# NOTE: /usr/local/bin/ctrld is the Linux default path.
|
||
# On OPNsense (FreeBSD) ctrld installs to /usr/local/sbin/ctrld.
|
||
# For local-mode installs this path is checked at runtime, so it's fine.
|
||
# For OPNsense mode the binary runs on the router, not here — the path is irrelevant.
|
||
CTRLD_BIN = _Path("/usr/local/bin/ctrld")
|
||
|
||
def _load_ctrld_cfg() -> dict:
|
||
"""Load saved ctrld configuration (mode, vlan_profiles) from ctrld.json."""
|
||
if CTRLD_FILE.exists():
|
||
try: return _json.loads(CTRLD_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_ctrld_cfg(cfg: dict):
|
||
"""Persist ctrld configuration to ctrld.json (chmod 600 — contains Resolver IDs)."""
|
||
CTRLD_FILE.write_text(_json.dumps(cfg, indent=2))
|
||
CTRLD_FILE.chmod(0o600)
|
||
|
||
def _ctrld_status() -> dict:
|
||
"""Check if ctrld is running and which mode."""
|
||
import subprocess as _sp
|
||
try:
|
||
r = _sp.run([str(CTRLD_BIN), "status"],
|
||
capture_output=True, text=True, timeout=5)
|
||
running = r.returncode == 0
|
||
return {
|
||
"installed": CTRLD_BIN.exists(),
|
||
"running": running,
|
||
"output": r.stdout.strip() or r.stderr.strip(),
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"installed": CTRLD_BIN.exists(),
|
||
"running": False,
|
||
"output": str(e),
|
||
}
|
||
|
||
def _ctrld_config_path() -> _Path:
|
||
"""Find ctrld config file location."""
|
||
candidates = [
|
||
_Path("/etc/controld/ctrld.toml"),
|
||
_Path("/usr/local/etc/controld/ctrld.toml"),
|
||
_Path.home() / ".config" / "controld" / "ctrld.toml",
|
||
]
|
||
for p in candidates:
|
||
if p.exists(): return p
|
||
return candidates[0] # default for new install
|
||
|
||
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
||
deploy_mode: str = "router") -> str:
|
||
"""
|
||
Build a ctrld.toml using flat dotted-key section headers.
|
||
|
||
Confirmed working architecture on OPNsense (verified after reboot):
|
||
Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
|
||
|
||
Unbound stays on port 53 — never moved. ctrld binds to localhost only
|
||
on port 5354 so it cannot conflict with Unbound at startup. Unbound's
|
||
Query Forwarding sends all external queries through ctrld. Local DNS
|
||
(host overrides, local zones) is handled entirely by Unbound before any
|
||
query reaches ctrld, so no split-horizon rules are needed here.
|
||
|
||
deploy_mode="router" — single listener on 127.0.0.1:ctrld_port.
|
||
Unbound forwards upstream queries here via Query Forwarding.
|
||
Per-VLAN policy differentiation is handled by Unbound (forward different
|
||
domains to different ctrld instances on different ports if needed).
|
||
|
||
deploy_mode="proxy" — single listener on 0.0.0.0:ctrld_port.
|
||
Use when ctrld runs on a management host (not the router) and clients
|
||
point directly at ctrld — CIDR-based policy routing applies.
|
||
|
||
Flat header rule: never write a parent [listener] / [network] / [upstream]
|
||
before the dotted subtables — Go's TOML v2 panics on table redefinition.
|
||
"""
|
||
BOOTSTRAP = "76.76.2.0"
|
||
|
||
active = [vp for vp in vlan_profiles
|
||
if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()]
|
||
|
||
lines = [
|
||
"# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager",
|
||
"# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
|
||
"# Docs: https://docs.controld.com/docs/ctrld",
|
||
"",
|
||
"[service]",
|
||
" log_level = \'info\'",
|
||
" log_path = \'/tmp/ctrld.log\'",
|
||
"",
|
||
]
|
||
|
||
# ── Upstream sections ─────────────────────────────────────────────────────
|
||
for i, vp in enumerate(active):
|
||
vid = vp["vlan_id"]
|
||
name = vp.get("name", f"VLAN{vid}")
|
||
protocol = (vp.get("protocol") or "doh3").strip()
|
||
rid = vp.get("resolver_id", "").strip()
|
||
endpoint = (
|
||
vp.get("endpoint_url", "").strip()
|
||
or f"https://dns.controld.com/{rid}"
|
||
)
|
||
lines += [
|
||
f"# VLAN {vid} — {name}",
|
||
f"[upstream.{i}]",
|
||
f" name = \'VLAN {vid} {name}\'",
|
||
f" type = \'{protocol}\'",
|
||
f" endpoint = \'{endpoint}\'",
|
||
f" bootstrap_ip = \'{BOOTSTRAP}\'",
|
||
f" timeout = 5000",
|
||
"",
|
||
]
|
||
|
||
# ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────
|
||
if deploy_mode == "router":
|
||
# Single listener on localhost — Unbound's Query Forwarding points here.
|
||
# No per-VLAN listeners needed: Unbound handles all local resolution
|
||
# before queries arrive; ctrld just proxies external queries upstream.
|
||
lines += [
|
||
"# Listens on localhost only — Unbound Query Forwarding sends external queries here",
|
||
"[listener.0]",
|
||
f" ip = \'127.0.0.1\'",
|
||
f" port = {ctrld_port}",
|
||
"",
|
||
" [listener.0.policy]",
|
||
" name = \'Default Policy\'",
|
||
" networks = []",
|
||
" rules = []",
|
||
]
|
||
if active:
|
||
lines[-1] = f" default = [\'upstream.0\']"
|
||
lines.append("")
|
||
|
||
# ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────
|
||
else:
|
||
lines += [
|
||
"[listener.0]",
|
||
f" ip = \'0.0.0.0\'",
|
||
f" port = {ctrld_port}",
|
||
"",
|
||
" [listener.0.policy]",
|
||
" name = \'VLAN Policy\'",
|
||
]
|
||
|
||
if active:
|
||
net_entries = [
|
||
" { " + f"\'network.{i}\' = [\'upstream.{i}\']" + " },"
|
||
for i in range(len(active))
|
||
]
|
||
lines += [" networks = ["] + net_entries + [" ]"]
|
||
else:
|
||
lines += [" networks = []"]
|
||
|
||
lines += [" rules = []", ""]
|
||
|
||
# Network sections for CIDR routing
|
||
for i, vp in enumerate(active):
|
||
vid = vp["vlan_id"]
|
||
name = vp.get("name", f"VLAN{vid}")
|
||
subnet = vp.get("subnet", f"192.168.{vid}.0/24")
|
||
lines += [
|
||
f"# VLAN {vid} — {name}",
|
||
f"[network.{i}]",
|
||
f" name = \'{name}\'",
|
||
f" cidrs = [\'{subnet}\']",
|
||
"",
|
||
]
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
|
||
# ── ctrld API models ─────────────────────────────────────────────────────────
|
||
|
||
class CtrldVlanProfile(BaseModel):
|
||
vlan_id: int
|
||
name: str
|
||
subnet: str
|
||
resolver_id: str # ControlD Resolver ID (path suffix)
|
||
endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set
|
||
protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy
|
||
gateway: Optional[str] = "" # VLAN gateway IP on the router (e.g. "192.168.10.1")
|
||
# Required for router-mode multi-listener TOML
|
||
|
||
class CtrldConfig(BaseModel):
|
||
mode: str # "local" | "opnsense" | "manual"
|
||
deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0)
|
||
vlan_profiles: list[CtrldVlanProfile]
|
||
opnsense_host: Optional[str] = ""
|
||
ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)
|
||
local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)
|
||
|
||
class CtrldInstallRequest(BaseModel):
|
||
token: str
|
||
config: CtrldConfig
|
||
|
||
class CtrldUpdateProfile(BaseModel):
|
||
token: str
|
||
vlan_profiles: list[CtrldVlanProfile]
|
||
|
||
# ── ctrld endpoints ───────────────────────────────────────────────────────────
|
||
|
||
@app.get("/api/ctrld/status")
|
||
def ctrld_status():
|
||
"""Return ctrld installation and running status."""
|
||
cfg = _load_ctrld_cfg()
|
||
status = _ctrld_status()
|
||
return {
|
||
**status,
|
||
"configured": bool(cfg.get("mode")),
|
||
"mode": cfg.get("mode",""),
|
||
"vlan_profiles": cfg.get("vlan_profiles",[]),
|
||
"opnsense_host": cfg.get("opnsense_host",""),
|
||
"config_path": str(_ctrld_config_path()),
|
||
"docs_url": "https://docs.controld.com/docs/ctrld",
|
||
}
|
||
|
||
def _validate_doh_endpoint(endpoint_url: str) -> dict:
|
||
"""
|
||
Test a DoH endpoint using a plain HTTPS GET query (RFC 8484).
|
||
|
||
Works for both DoH and DoH3 endpoints — ControlD and most public resolvers
|
||
serve DoH over HTTPS/2 at the same URL they use for DoH3, so a successful
|
||
HTTP response proves the URL is live and well-formed before ctrld ever
|
||
touches it.
|
||
|
||
Sends: GET {url}?dns=<base64url(A? ping.controld.com)>
|
||
Expects: 200, Content-Type: application/dns-message
|
||
|
||
Returns: {ok, latency_ms, status_code, error}
|
||
"""
|
||
import struct, base64, time, urllib.request, urllib.error
|
||
|
||
# Build a minimal DNS A query for "ping.controld.com" in wire format
|
||
def _make_query(domain: str = "ping.controld.com") -> bytes:
|
||
hdr = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)
|
||
qname = b""
|
||
for label in domain.rstrip(".").split("."):
|
||
qname += bytes([len(label)]) + label.encode()
|
||
qname += b"\x00"
|
||
qtype = struct.pack(">HH", 1, 1) # A IN
|
||
return hdr + qname + qtype
|
||
|
||
dns_bytes = _make_query()
|
||
dns_b64 = base64.urlsafe_b64encode(dns_bytes).rstrip(b"=").decode()
|
||
test_url = f"{endpoint_url.rstrip('/')}?dns={dns_b64}"
|
||
t0 = time.monotonic()
|
||
|
||
try:
|
||
req = urllib.request.Request(
|
||
test_url,
|
||
headers={"Accept": "application/dns-message"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||
ct = resp.headers.get("Content-Type", "")
|
||
body = resp.read(12) # just enough to check it's a DNS response
|
||
ok = resp.status == 200 and "dns-message" in ct
|
||
return {
|
||
"ok": ok,
|
||
"latency_ms": latency_ms,
|
||
"status_code": resp.status,
|
||
"error": "" if ok else f"Unexpected Content-Type: {ct}",
|
||
}
|
||
except urllib.error.HTTPError as e:
|
||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||
return {"ok": False, "latency_ms": latency_ms,
|
||
"status_code": e.code, "error": str(e)}
|
||
except Exception as e:
|
||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||
return {"ok": False, "latency_ms": latency_ms,
|
||
"status_code": 0, "error": str(e)}
|
||
|
||
|
||
class CtrldValidateRequest(BaseModel):
|
||
vlan_profiles: list[CtrldVlanProfile]
|
||
|
||
|
||
@app.post("/api/ctrld/validate-endpoints")
|
||
def ctrld_validate_endpoints(body: CtrldValidateRequest):
|
||
"""
|
||
Test each profile's DoH/DoH3 endpoint URL before writing any config.
|
||
Returns per-profile results plus an overall ok flag.
|
||
Intended to be called from the UI before calling save-config.
|
||
"""
|
||
results = []
|
||
for vp in body.vlan_profiles:
|
||
p = vp.dict()
|
||
url = (p.get("endpoint_url") or "").strip()
|
||
rid = (p.get("resolver_id") or "").strip()
|
||
if not url and rid:
|
||
url = f"https://dns.controld.com/{rid}"
|
||
if not url:
|
||
results.append({
|
||
"vlan_id": p["vlan_id"],
|
||
"name": p.get("name", ""),
|
||
"ok": False,
|
||
"error": "No endpoint URL or resolver_id provided",
|
||
})
|
||
continue
|
||
probe = _validate_doh_endpoint(url)
|
||
results.append({
|
||
"vlan_id": p["vlan_id"],
|
||
"name": p.get("name", ""),
|
||
"url": url,
|
||
**probe,
|
||
})
|
||
|
||
# Also validate the generated TOML can be parsed (catches structural issues)
|
||
toml_ok = True
|
||
toml_error = ""
|
||
try:
|
||
import sys
|
||
if sys.version_info >= (3, 11):
|
||
import tomllib
|
||
tomllib.loads(_build_ctrld_toml([p.dict() for p in body.vlan_profiles]))
|
||
# tomllib not available in 3.10 — skip structural check
|
||
except Exception as e:
|
||
toml_ok = False
|
||
toml_error = str(e)
|
||
|
||
return {
|
||
"all_ok": all(r["ok"] for r in results) and toml_ok,
|
||
"results": results,
|
||
"toml_ok": toml_ok,
|
||
"toml_error": toml_error,
|
||
"toml_preview": _build_ctrld_toml([p.dict() for p in body.vlan_profiles]),
|
||
}
|
||
|
||
|
||
@app.get("/api/ctrld/toml-preview")
|
||
def ctrld_toml_preview():
|
||
"""Generate and return the ctrld.toml without installing it."""
|
||
cfg = _load_ctrld_cfg()
|
||
profiles = cfg.get("vlan_profiles", [])
|
||
deploy_mode = cfg.get("deploy_mode", "router")
|
||
ctrld_port = cfg.get("ctrld_port", 5354)
|
||
if not profiles:
|
||
raise HTTPException(400, "No VLAN profiles configured yet")
|
||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||
return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}
|
||
|
||
@app.post("/api/ctrld/save-config")
|
||
def ctrld_save_config(body: CtrldInstallRequest):
|
||
"""
|
||
Save ctrld configuration.
|
||
Validates all endpoints before writing anything — returns 400 with per-profile
|
||
probe results if any endpoint is unreachable so the user can fix it first.
|
||
For 'local' mode: installs ctrld on this machine, writes config, starts service.
|
||
For 'opnsense' mode: generates the SSH install command.
|
||
For 'manual' mode: saves config for reference, generates toml only.
|
||
"""
|
||
require_session(body.token)
|
||
|
||
profiles = [p.dict() for p in body.config.vlan_profiles]
|
||
|
||
# ── Endpoint validation — block on failure ────────────────────────────────
|
||
validation = ctrld_validate_endpoints(
|
||
CtrldValidateRequest(vlan_profiles=body.config.vlan_profiles)
|
||
)
|
||
if not validation["all_ok"]:
|
||
raise HTTPException(400, {
|
||
"message": "One or more DNS endpoints failed validation — fix before saving",
|
||
"results": validation["results"],
|
||
"toml_error": validation.get("toml_error", ""),
|
||
})
|
||
|
||
deploy_mode = body.config.deploy_mode or "router"
|
||
ctrld_port = body.config.ctrld_port or 5354
|
||
local_domain = body.config.local_domain or "lan"
|
||
cfg_dict = {
|
||
"mode": body.config.mode,
|
||
"deploy_mode": deploy_mode,
|
||
"vlan_profiles": profiles,
|
||
"opnsense_host": body.config.opnsense_host,
|
||
"ctrld_port": ctrld_port,
|
||
"local_domain": local_domain,
|
||
}
|
||
_save_ctrld_cfg(cfg_dict)
|
||
|
||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||
|
||
if body.config.mode == "local":
|
||
return _ctrld_install_local(toml, profiles)
|
||
elif body.config.mode == "opnsense":
|
||
return _ctrld_generate_opnsense_cmd(
|
||
body.config.opnsense_host, profiles, deploy_mode,
|
||
ctrld_port=ctrld_port, local_domain=local_domain,
|
||
)
|
||
else:
|
||
# Manual — just return the toml and instructions
|
||
return {
|
||
"success": True,
|
||
"mode": "manual",
|
||
"toml": toml,
|
||
"message": "Config saved. Install ctrld manually and use the toml below.",
|
||
"install_cmd": "sh -c 'sh -c \"$(curl -sL https://api.controld.com/dl)\"'",
|
||
"config_path": str(_ctrld_config_path()),
|
||
}
|
||
|
||
def _fix_port53_conflict() -> dict:
|
||
"""
|
||
Detect and fix systemd-resolved holding port 53 (common on Ubuntu/Debian).
|
||
|
||
systemd-resolved's stub listener binds 127.0.0.53:53 and sometimes 0.0.0.0:53,
|
||
which blocks ctrld from binding port 53. The right fix is to disable only the
|
||
stub listener — NOT the service itself (the service still handles /etc/resolv.conf
|
||
and local hostname resolution).
|
||
|
||
Returns a dict with keys: needed (bool), fixed (bool), message (str).
|
||
"""
|
||
import subprocess as _sp
|
||
|
||
# Check if systemd-resolved is running and holding port 53
|
||
try:
|
||
ss_out = _sp.run(
|
||
["ss", "-tlnp", "sport", "=", ":53"],
|
||
capture_output=True, text=True, timeout=5
|
||
).stdout
|
||
if "systemd-resolve" not in ss_out and "resolved" not in ss_out:
|
||
return {"needed": False, "fixed": False,
|
||
"message": "No port 53 conflict detected"}
|
||
except Exception:
|
||
return {"needed": False, "fixed": False,
|
||
"message": "Could not check port 53 status (ss not available)"}
|
||
|
||
log.info("systemd-resolved is holding port 53 — disabling stub listener")
|
||
|
||
resolved_conf = _Path("/etc/systemd/resolved.conf")
|
||
try:
|
||
current = resolved_conf.read_text() if resolved_conf.exists() else ""
|
||
except Exception as e:
|
||
return {"needed": True, "fixed": False,
|
||
"message": f"Cannot read {resolved_conf}: {e}"}
|
||
|
||
# Already fixed?
|
||
if "DNSStubListener=no" in current:
|
||
_sp.run(["systemctl", "restart", "systemd-resolved"], capture_output=True)
|
||
return {"needed": True, "fixed": True,
|
||
"message": "DNSStubListener=no already present — restarted systemd-resolved"}
|
||
|
||
# Add the setting under [Resolve], creating the section if needed
|
||
if "[Resolve]" in current:
|
||
new_conf = current.rstrip() + "\nDNSStubListener=no\n"
|
||
else:
|
||
new_conf = current.rstrip() + "\n[Resolve]\nDNSStubListener=no\n"
|
||
|
||
try:
|
||
resolved_conf.write_text(new_conf)
|
||
except PermissionError:
|
||
return {"needed": True, "fixed": False,
|
||
"message": "Permission denied writing /etc/systemd/resolved.conf — run backend as root or with sudo"}
|
||
|
||
restart = _sp.run(["systemctl", "restart", "systemd-resolved"],
|
||
capture_output=True, text=True)
|
||
if restart.returncode != 0:
|
||
return {"needed": True, "fixed": False,
|
||
"message": f"Added DNSStubListener=no but systemd-resolved restart failed: {restart.stderr}"}
|
||
|
||
log.info("Port 53 conflict resolved — systemd-resolved stub listener disabled")
|
||
return {"needed": True, "fixed": True,
|
||
"message": "Disabled systemd-resolved stub listener (DNSStubListener=no) and restarted service"}
|
||
|
||
|
||
def _ctrld_install_local(toml: str, profiles: list) -> dict:
|
||
"""Download and install ctrld on this machine, write config, start service."""
|
||
import subprocess as _sp, platform as _platform
|
||
|
||
# Detect architecture
|
||
arch = _platform.machine().lower()
|
||
os_name = _platform.system().lower()
|
||
|
||
if os_name != "linux":
|
||
return {
|
||
"success": False,
|
||
"mode": "local",
|
||
"message": f"Auto-install only supported on Linux. "
|
||
f"Download ctrld from https://github.com/Control-D-Inc/ctrld/releases",
|
||
"toml": toml,
|
||
}
|
||
|
||
# Use ctrld's own installer with the first profile's resolver ID
|
||
first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
|
||
if not first_rid:
|
||
return {"success": False, "message": "No Resolver ID provided"}
|
||
|
||
# Fix port 53 conflict BEFORE installing ctrld — on Ubuntu/Debian, systemd-resolved
|
||
# holds port 53 and ctrld cannot bind. Disabling the stub listener is safe:
|
||
# systemd-resolved keeps running for /etc/resolv.conf management.
|
||
port53_fix = _fix_port53_conflict()
|
||
log.info(f"Port 53 check: {port53_fix['message']}")
|
||
|
||
# Download the binary directly (more reliable than the shell installer for service control)
|
||
log.info("Installing ctrld...")
|
||
|
||
# Use the official installer
|
||
install_result = _sp.run(
|
||
f'sh -c \'sh -c "$(curl -sL https://api.controld.com/dl)" -s {first_rid} forced\'',
|
||
shell=True, capture_output=True, text=True, timeout=120
|
||
)
|
||
|
||
if install_result.returncode != 0 and not CTRLD_BIN.exists():
|
||
return {
|
||
"success": False,
|
||
"mode": "local",
|
||
"message": f"Install failed: {install_result.stderr or install_result.stdout}",
|
||
"toml": toml,
|
||
"port53": port53_fix,
|
||
}
|
||
|
||
# Write our multi-VLAN config (overrides the default single-profile config)
|
||
cfg_path = _ctrld_config_path()
|
||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||
cfg_path.write_text(toml)
|
||
log.info(f"ctrld config written to {cfg_path}")
|
||
|
||
# Restart ctrld to pick up new config
|
||
_sp.run([str(CTRLD_BIN), "stop"], capture_output=True)
|
||
_sp.run([str(CTRLD_BIN), "start"], capture_output=True)
|
||
|
||
# Update DHCP on switch — set DNS option 6 per VLAN pool to this machine's IP
|
||
import socket as _sock
|
||
try:
|
||
mgmt_ip = _sock.gethostbyname(_sock.gethostname())
|
||
except Exception:
|
||
mgmt_ip = "192.168.99.50"
|
||
|
||
status = _ctrld_status()
|
||
return {
|
||
"success": status["running"],
|
||
"mode": "local",
|
||
"message": "ctrld installed and running" if status["running"] else "ctrld installed but may not be running — check logs",
|
||
"dns_ip": mgmt_ip,
|
||
"dhcp_action": f"Set DNS (option 6) to {mgmt_ip} on each VLAN pool in the DHCP tab",
|
||
"toml": toml,
|
||
"config_path": str(cfg_path),
|
||
"docs": "https://docs.controld.com/docs/ctrld",
|
||
"port53": port53_fix,
|
||
}
|
||
|
||
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||
deploy_mode: str = "router",
|
||
ctrld_port: int = 5354,
|
||
local_domain: str = "lan") -> dict:
|
||
"""
|
||
Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.
|
||
|
||
Confirmed working architecture (verified after reboot — no manual intervention needed):
|
||
Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
|
||
|
||
Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot
|
||
conflict with Unbound at startup regardless of service start order.
|
||
Unbound's Query Forwarding sends external queries through ctrld.
|
||
Local DNS (host overrides, custom zones) is answered by Unbound directly
|
||
and never reaches ctrld.
|
||
|
||
NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is
|
||
a common tutorial artifact that causes PTR/reverse DNS failures.
|
||
"""
|
||
first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
|
||
if not first_rid:
|
||
return {"success": False, "message": "No Resolver ID provided"}
|
||
|
||
install_cmd = (
|
||
f"sh -c 'sh -c \"$(curl -sL https://api.controld.com/dl)\" "
|
||
f"-s {first_rid} forced'"
|
||
)
|
||
|
||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||
|
||
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
|
||
write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF"
|
||
|
||
setup_steps = [
|
||
"Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
|
||
"",
|
||
"STEP 1 — Install ctrld on OPNsense (SSH or shell):",
|
||
f" {install_cmd}",
|
||
"",
|
||
"STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port),
|
||
f" {write_toml_cmd}",
|
||
" Then restart ctrld: ctrld restart",
|
||
"",
|
||
"STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):",
|
||
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
|
||
" • Enable Query Forwarding: checked",
|
||
f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}",
|
||
" • Use TLS: No (ctrld handles DoH/DoT upstream; plain DNS locally is fine)",
|
||
" • Click Apply / Save",
|
||
"",
|
||
"STEP 4 — Remove 'home.arpa' local-zone from Unbound if present:",
|
||
" OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:",
|
||
" Remove any line containing: local-zone: \"home.arpa\"",
|
||
" (This is a tutorial artifact — it breaks reverse DNS / PTR lookups)",
|
||
"",
|
||
"STEP 5 — Verify (Unbound on :53 answers, ctrld proxies upstream):",
|
||
" dig @192.168.1.1 google.com # external — goes through ctrld → ControlD",
|
||
f" dig @192.168.1.1 myhost.{local_domain} # local — answered by Unbound directly",
|
||
" dig @192.168.1.1 -x 192.168.1.1 # reverse PTR — answered by Unbound directly",
|
||
]
|
||
|
||
return {
|
||
"success": True,
|
||
"mode": "opnsense",
|
||
"message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port),
|
||
"setup_steps": setup_steps,
|
||
"architecture": "Unbound stays on :53. ctrld binds 127.0.0.1:{} only — no port conflict possible.".format(ctrld_port),
|
||
"step1_install": install_cmd,
|
||
"step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'",
|
||
"step2_config": f"Write {opnsense_cfg} with the TOML below, then: ctrld restart",
|
||
"step3_unbound": (
|
||
f"Services → Unbound DNS → Query Forwarding: "
|
||
f"Enable, add zone '.' → 127.0.0.1:{ctrld_port}, no TLS, Apply"
|
||
),
|
||
"step4_cleanup": "Remove 'home.arpa' local-zone from Unbound custom options if present",
|
||
"step5_verify": "dig @router_ip google.com && dig @router_ip -x 192.168.1.1",
|
||
"toml": toml,
|
||
"toml_write_cmd": write_toml_cmd,
|
||
"config_path": opnsense_cfg,
|
||
"ctrld_port": ctrld_port,
|
||
"local_domain": local_domain,
|
||
"docs": "https://docs.controld.com/docs/routers-platform",
|
||
}
|
||
|
||
@app.post("/api/ctrld/update-profiles")
|
||
def ctrld_update_profiles(body: CtrldUpdateProfile):
|
||
"""Update VLAN profiles and regenerate/reload ctrld config."""
|
||
require_session(body.token)
|
||
cfg = _load_ctrld_cfg()
|
||
cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles]
|
||
_save_ctrld_cfg(cfg)
|
||
|
||
deploy_mode = cfg.get("deploy_mode", "router")
|
||
ctrld_port = cfg.get("ctrld_port", 5354)
|
||
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||
cfg_path = _ctrld_config_path()
|
||
|
||
if cfg.get("mode") == "local" and cfg_path.exists():
|
||
cfg_path.write_text(toml)
|
||
import subprocess as _sp
|
||
_sp.run([str(CTRLD_BIN), "stop"], capture_output=True)
|
||
_sp.run([str(CTRLD_BIN), "start"], capture_output=True)
|
||
return {"success": True, "message": "Profiles updated and ctrld reloaded", "toml": toml}
|
||
|
||
return {"success": True, "message": "Profiles saved", "toml": toml,
|
||
"note": "Restart ctrld manually to apply changes" if cfg.get("mode") == "opnsense" else ""}
|
||
|
||
@app.delete("/api/ctrld/uninstall")
|
||
def ctrld_uninstall(token: str):
|
||
"""Stop ctrld, remove its binary, and delete saved config."""
|
||
require_session(token)
|
||
import subprocess as _sp
|
||
if CTRLD_BIN.exists():
|
||
_sp.run([str(CTRLD_BIN), "uninstall", "--cleanup"], capture_output=True)
|
||
if CTRLD_FILE.exists():
|
||
CTRLD_FILE.unlink()
|
||
return {"success": True}
|
||
|
||
|
||
# ── DNS enforcement ACL generation ──────────────────────────────────────────
|
||
|
||
class DnsEnforceRequest(BaseModel):
|
||
"""Request body for generating DNS enforcement ACLs."""
|
||
token: str
|
||
ctrld_ip: str # IP of the machine running ctrld (becomes the only allowed DNS target)
|
||
vlan_ids: list[int] # VLANs to enforce (excludes VLAN 99 management)
|
||
|
||
|
||
def _build_dns_enforce_acls(ctrld_ip: str, vlans_info: list[dict]) -> list[str]:
|
||
"""
|
||
Generate CLI commands for DNS enforcement ACLs on each VLAN interface.
|
||
|
||
For each VLAN the ACL:
|
||
- Permits UDP/TCP port 53 to ctrld_ip (allows DHCP-assigned DNS)
|
||
- Denies UDP/TCP port 53 to anywhere (blocks direct DNS bypass e.g. 8.8.8.8)
|
||
- Denies TCP port 853 to anywhere (blocks DNS-over-TLS bypass)
|
||
- Permits everything else (internet still works)
|
||
|
||
Without these rules a device can ignore DHCP-assigned DNS and use 8.8.8.8
|
||
directly, bypassing all ctrld filtering entirely.
|
||
|
||
vlans_info: list of { vlan_id: int, subnet: str } e.g. { vlan_id: 10, subnet: "192.168.10.0/24" }
|
||
"""
|
||
# Validate ctrld IP — must be a bare IP address, no injection
|
||
import ipaddress as _ip
|
||
try:
|
||
ctrld_addr = str(_ip.ip_address(ctrld_ip))
|
||
except ValueError:
|
||
raise ValueError(f"ctrld_ip: invalid IP address {repr(ctrld_ip)}")
|
||
|
||
cmds = []
|
||
for vi in vlans_info:
|
||
vid = san_vid(vi["vlan_id"])
|
||
subnet = vi.get("subnet", f"192.168.{vid}.0/24")
|
||
|
||
# Parse subnet into network/wildcard for ERS ACL syntax
|
||
try:
|
||
net = _ip.ip_network(subnet, strict=False)
|
||
net_str = str(net.network_address)
|
||
wild = str(_ip.ip_address(int(net.hostmask)))
|
||
except ValueError:
|
||
net_str = f"192.168.{vid}.0"
|
||
wild = "0.0.0.255"
|
||
|
||
acl_name = f"DNS-ENFORCE-VLAN{vid}"
|
||
cmds += [
|
||
f"ip access-list extended {acl_name}",
|
||
# 1 & 2: permit DNS to ctrld only (DHCP-assigned resolver)
|
||
f" 1 permit udp {net_str} {wild} host {ctrld_addr} eq 53",
|
||
f" 2 permit tcp {net_str} {wild} host {ctrld_addr} eq 53",
|
||
# 3 & 4: deny DNS to anywhere else (block 8.8.8.8 and friends)
|
||
f" 3 deny udp {net_str} {wild} any eq 53",
|
||
f" 4 deny tcp {net_str} {wild} any eq 53",
|
||
# 5: deny DNS-over-TLS (port 853) so devices can't use DoT as bypass
|
||
f" 5 deny tcp {net_str} {wild} any eq 853",
|
||
# 6: permit everything else — internet still works
|
||
f" 6 permit ip any any",
|
||
# Apply inbound on the VLAN interface
|
||
f"interface vlan {vid}",
|
||
f" ip access-group {acl_name} in",
|
||
]
|
||
|
||
return cmds
|
||
|
||
|
||
@app.post("/api/ctrld/dns-enforce-acls")
|
||
def ctrld_dns_enforce_acls(body: DnsEnforceRequest):
|
||
"""
|
||
Generate DNS enforcement ACL commands for the requested VLANs.
|
||
|
||
Returns the raw CLI commands for review — the caller then pushes them
|
||
via the normal TOTP-gated push endpoint. This endpoint only generates;
|
||
it does NOT push anything to the switch itself.
|
||
"""
|
||
require_session(body.token)
|
||
_require_advanced_license()
|
||
|
||
# Refuse to touch VLAN 99 (management) — a broken ACL there = lockout
|
||
safe_vlans = [v for v in body.vlan_ids if v != 99]
|
||
if not safe_vlans:
|
||
raise HTTPException(400, "No safe VLANs to enforce — VLAN 99 is excluded automatically")
|
||
|
||
vlans_info = [{"vlan_id": v} for v in safe_vlans]
|
||
try:
|
||
cmds = _build_dns_enforce_acls(body.ctrld_ip, vlans_info)
|
||
except ValueError as e:
|
||
raise HTTPException(400, str(e))
|
||
|
||
return {
|
||
"success": True,
|
||
"commands": cmds,
|
||
"count": len(cmds),
|
||
"note": "Review these commands then push via the Review & Push tab",
|
||
"vlans": safe_vlans,
|
||
"ctrld_ip": body.ctrld_ip,
|
||
}
|
||
|
||
|
||
# ── Local hostname resolution (dnsmasq) ──────────────────────────────────────
|
||
|
||
LOCAL_HOSTNAMES_FILE = _Path("/etc/switch-manager/local-hostnames.json")
|
||
DNSMASQ_CONF_PATH = _Path("/etc/switch-manager/dnsmasq.conf")
|
||
|
||
|
||
def _load_local_hostnames() -> list:
|
||
"""Load user-defined hostname→IP mappings for .lan resolution."""
|
||
if LOCAL_HOSTNAMES_FILE.exists():
|
||
try: return _json.loads(LOCAL_HOSTNAMES_FILE.read_text())
|
||
except: pass
|
||
return []
|
||
|
||
|
||
def _save_local_hostnames(entries: list):
|
||
"""Persist hostname→IP mappings (used to generate dnsmasq.conf)."""
|
||
LOCAL_HOSTNAMES_FILE.write_text(_json.dumps(entries, indent=2))
|
||
|
||
|
||
def _generate_dnsmasq_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str:
|
||
"""
|
||
Build a dnsmasq.conf for local .lan hostname resolution.
|
||
|
||
dnsmasq runs on port 5353 inside Docker alongside the switch manager.
|
||
ctrld.toml forwards *.lan and *.local queries to 127.0.0.1:5353.
|
||
This keeps local names working even when all external DNS goes through ctrld.
|
||
|
||
entries: list of { name: str, ip: str }
|
||
mgmt_ip: IP of the management computer (switch.mgmt.lan and management.lan point here)
|
||
"""
|
||
import ipaddress as _ip
|
||
|
||
lines = [
|
||
"# dnsmasq — local .lan hostname resolution",
|
||
"# Generated by Avaya 59100GTS-PWR+ Switch Manager",
|
||
"# Listens on port 5353 (mapped from Docker container port 53)",
|
||
"# ctrld forwards *.lan and *.local here",
|
||
"",
|
||
"port=53", # dnsmasq internal port (Docker maps host:5353 → container:53)
|
||
"no-resolv", # don't use /etc/resolv.conf — this is a local-only resolver
|
||
"no-hosts", # don't use /etc/hosts
|
||
"domain-needed", # never forward bare names upstream
|
||
"bogus-priv", # don't forward RFC1918 PTR queries upstream
|
||
"",
|
||
"# Management computer — always present",
|
||
f"address=/switch.mgmt.lan/{mgmt_ip}",
|
||
f"address=/management.lan/{mgmt_ip}",
|
||
"",
|
||
]
|
||
|
||
if entries:
|
||
lines += ["# User-defined hostnames"]
|
||
for e in entries:
|
||
hostname = e.get("name","").strip()
|
||
ip_addr = e.get("ip","").strip()
|
||
if not hostname or not ip_addr:
|
||
continue
|
||
# Validate the IP — skip malformed entries
|
||
try:
|
||
_ip.ip_address(ip_addr)
|
||
except ValueError:
|
||
continue
|
||
# Strip leading/trailing dots, sanitise hostname
|
||
hostname = hostname.strip(".")
|
||
if not hostname:
|
||
continue
|
||
lines.append(f"address=/{hostname}/{ip_addr}")
|
||
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _generate_ctrld_split_horizon_block(local_domain: str = "lan",
|
||
dnsmasq_port: int = 5353) -> str:
|
||
"""
|
||
Generate an example ctrld.toml for split-horizon DNS with a local resolver.
|
||
|
||
In the correct ctrld format, split-horizon is done by:
|
||
1. Adding [upstream.local] with type='legacy' pointing to dnsmasq/Unbound
|
||
2. Adding rules in [listener.0.policy].rules that send *.lan → upstream.local
|
||
|
||
Because the format uses indexed table sections ([network.N], [upstream.N]),
|
||
you can't simply append a fragment — the full toml must be regenerated via
|
||
_build_ctrld_toml(vlan_profiles). Note: on OPNsense, Unbound handles
|
||
local resolution — ctrld does not need split-horizon rules at all.
|
||
|
||
This function returns a plain-English example for display only.
|
||
"""
|
||
port = dnsmasq_port
|
||
domain = local_domain.strip(".")
|
||
return "\n".join([
|
||
"# Add to your ctrld.toml — regenerate via DNS tab for correct indexing",
|
||
"",
|
||
"# In [listener.0.policy], add to the rules array:",
|
||
f"# {{ '*.{domain}' = ['upstream.local'] }},",
|
||
"# { '*.local' = ['upstream.local'] },",
|
||
"",
|
||
"# Add a new upstream section (increment index as needed):",
|
||
"[upstream.local]",
|
||
f" type = 'legacy'",
|
||
f" endpoint = '127.0.0.1:{port}'",
|
||
f" timeout = 2000",
|
||
"",
|
||
f"# Then restart ctrld: ctrld restart",
|
||
])
|
||
|
||
|
||
class LocalHostnameEntry(BaseModel):
|
||
name: str # e.g. "printer.lan"
|
||
ip: str # e.g. "192.168.10.50"
|
||
|
||
|
||
class LocalHostnamesUpdate(BaseModel):
|
||
token: str
|
||
entries: list[LocalHostnameEntry]
|
||
local_domain: Optional[str] = "lan"
|
||
|
||
|
||
@app.get("/api/dns/local-hostnames")
|
||
def get_local_hostnames():
|
||
"""Return saved local hostname mappings and the generated dnsmasq.conf."""
|
||
entries = _load_local_hostnames()
|
||
import socket as _sock
|
||
try:
|
||
mgmt_ip = _sock.gethostbyname(_sock.gethostname())
|
||
except Exception:
|
||
mgmt_ip = "192.168.99.50"
|
||
conf = _generate_dnsmasq_conf(entries, mgmt_ip)
|
||
return {
|
||
"entries": entries,
|
||
"dnsmasq_conf": conf,
|
||
"conf_path": str(DNSMASQ_CONF_PATH),
|
||
}
|
||
|
||
|
||
@app.post("/api/dns/local-hostnames")
|
||
def save_local_hostnames(body: LocalHostnamesUpdate):
|
||
"""
|
||
Save local hostname mappings, write dnsmasq.conf, and return the updated
|
||
ctrld.toml split-horizon block to append (user applies it via the DNS tab).
|
||
"""
|
||
require_session(body.token)
|
||
entries = [e.dict() for e in body.entries]
|
||
_save_local_hostnames(entries)
|
||
|
||
import socket as _sock
|
||
try:
|
||
mgmt_ip = _sock.gethostbyname(_sock.gethostname())
|
||
except Exception:
|
||
mgmt_ip = "192.168.99.50"
|
||
|
||
conf = _generate_dnsmasq_conf(entries, mgmt_ip)
|
||
DNSMASQ_CONF_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
DNSMASQ_CONF_PATH.write_text(conf)
|
||
|
||
# Regenerate ctrld.toml with split-horizon enabled (if ctrld is configured)
|
||
ctrld_cfg = _load_ctrld_cfg()
|
||
split_horizon_toml = None
|
||
if ctrld_cfg.get("vlan_profiles"):
|
||
split_horizon_toml = _build_ctrld_toml(
|
||
ctrld_cfg["vlan_profiles"],
|
||
ctrld_port=ctrld_cfg.get("ctrld_port", 5354),
|
||
deploy_mode=ctrld_cfg.get("deploy_mode", "router"),
|
||
)
|
||
# Write new toml if running locally
|
||
if ctrld_cfg.get("mode") == "local":
|
||
cfg_path = _ctrld_config_path()
|
||
if cfg_path.parent.exists():
|
||
cfg_path.write_text(split_horizon_toml)
|
||
|
||
split_horizon = _generate_ctrld_split_horizon_block(
|
||
local_domain=body.local_domain or "lan"
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"entries": entries,
|
||
"dnsmasq_conf": conf,
|
||
"conf_path": str(DNSMASQ_CONF_PATH),
|
||
"split_horizon": split_horizon,
|
||
"full_toml": split_horizon_toml,
|
||
"docker_compose_snippet": (
|
||
" dnsmasq:\n"
|
||
" image: andyshinn/dnsmasq:latest\n"
|
||
" ports:\n"
|
||
" - \"5353:53/udp\"\n"
|
||
" - \"5353:53/tcp\"\n"
|
||
" volumes:\n"
|
||
" - /etc/switch-manager/dnsmasq.conf:/etc/dnsmasq.conf:ro\n"
|
||
" restart: unless-stopped\n"
|
||
" cap_add:\n"
|
||
" - NET_ADMIN\n"
|
||
),
|
||
"message": (
|
||
f"Saved {len(entries)} hostname(s). "
|
||
"Add the docker-compose snippet and split_horizon block to ctrld.toml, "
|
||
"then run: docker compose up -d dnsmasq"
|
||
),
|
||
}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# OPNSENSE WIREGUARD — ROUTER-LEVEL VPN WITH PER-VLAN ACCESS CONTROL
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Moves WireGuard from the management computer onto OPNsense so any
|
||
# device on any VLAN can VPN home without touching the management PC.
|
||
# Each peer is granted access only to the VLANs you choose.
|
||
#
|
||
# Architecture:
|
||
# OPNsense wg1 interface (10.99.2.0/24 — separate from local wg0)
|
||
# Peer Alice → tunnel IP 10.99.2.2 → allowed VLAN 10 + VLAN 20
|
||
# Peer Bob → tunnel IP 10.99.2.3 → allowed VLAN 10 only
|
||
# Private keys are generated here and stored only on the mgmt PC.
|
||
# OPNsense receives only the public key (standard WireGuard practice).
|
||
|
||
OPN_WG_FILE = _Path("/etc/switch-manager/opnsense_wg.json")
|
||
|
||
|
||
def _load_opnsense_wg() -> dict:
|
||
if OPN_WG_FILE.exists():
|
||
try:
|
||
return _json.loads(OPN_WG_FILE.read_text())
|
||
except Exception:
|
||
pass
|
||
return {}
|
||
|
||
|
||
def _save_opnsense_wg(cfg: dict):
|
||
OPN_WG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
OPN_WG_FILE.write_text(_json.dumps(cfg, indent=2))
|
||
OPN_WG_FILE.chmod(0o600)
|
||
|
||
|
||
class OPNWGServerSetup(BaseModel):
|
||
token: str
|
||
server_name: str = "switch-mgmt-vpn"
|
||
listen_port: int = 51820
|
||
tunnel_subnet: str = "10.99.2.0/24"
|
||
public_endpoint: str = "" # public IP or DDNS hostname for client configs
|
||
|
||
|
||
class OPNWGAddPeer(BaseModel):
|
||
token: str
|
||
name: str
|
||
allowed_vlans: list # list of VLAN IDs: [10, 20, 30]
|
||
vlan_subnets: dict # {10: "192.168.10.0/24", 20: "192.168.20.0/24", ...}
|
||
dns_profile: Optional[str] = "" # ControlD profile name to apply (matches a ctrld upstream)
|
||
dns_server: Optional[str] = "" # Override DNS server IP in client config (default: OPNsense VLAN 99 IP)
|
||
|
||
|
||
@app.get("/api/opnsense/wireguard/status")
|
||
def opnsense_wg_status():
|
||
"""Check OPNsense WireGuard plugin, server, and peer state."""
|
||
opn_cfg = _load_opnsense_cfg()
|
||
if not opn_cfg:
|
||
return {"opnsense_configured": False}
|
||
|
||
wg = _load_opnsense_wg()
|
||
|
||
# Probe for the WireGuard plugin — a 404 means the plugin isn't installed
|
||
try:
|
||
_opnsense_request(opn_cfg, "wireguard/server/searchServer")
|
||
plugin_ok = True
|
||
except ValueError as e:
|
||
msg = str(e)
|
||
# 404 → plugin absent; other errors → reachability / auth issue
|
||
plugin_ok = False
|
||
return {
|
||
"opnsense_configured": True,
|
||
"plugin_installed": False,
|
||
"error": msg,
|
||
"server": None,
|
||
"peers": [],
|
||
}
|
||
|
||
# If we have a saved server UUID, fetch live info
|
||
server_info = None
|
||
if wg.get("server_uuid"):
|
||
try:
|
||
s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{wg['server_uuid']}")
|
||
srv = s.get("server", {})
|
||
server_info = {
|
||
"uuid": wg["server_uuid"],
|
||
"name": srv.get("name", wg.get("server_name","")),
|
||
"pubkey": srv.get("pubkey", wg.get("server_pubkey","")),
|
||
"tunnel_ip": wg.get("server_tunnel_ip",""),
|
||
"listen_port": wg.get("listen_port", 51820),
|
||
"public_endpoint": wg.get("public_endpoint",""),
|
||
}
|
||
except Exception:
|
||
# Server UUID no longer valid (e.g. OPNsense was reset)
|
||
server_info = None
|
||
|
||
# Merge OPNsense peer list with local metadata (which holds allowed_vlans)
|
||
local_peers = {p["name"]: p for p in wg.get("peers", [])}
|
||
opn_peers = []
|
||
try:
|
||
resp = _opnsense_request(opn_cfg, "wireguard/client/searchClient")
|
||
opn_peers = resp.get("rows", [])
|
||
except Exception:
|
||
pass
|
||
|
||
merged = []
|
||
for p in opn_peers:
|
||
name = p.get("name", "")
|
||
loc = local_peers.get(name, {})
|
||
merged.append({
|
||
"uuid": p.get("uuid", ""),
|
||
"name": name,
|
||
"enabled": p.get("enabled", "0") == "1",
|
||
"tunnel_ip": p.get("tunneladdress", ""),
|
||
"allowed_vlans": loc.get("allowed_vlans", []),
|
||
})
|
||
|
||
return {
|
||
"opnsense_configured": True,
|
||
"plugin_installed": plugin_ok,
|
||
"server": server_info,
|
||
"peers": merged,
|
||
}
|
||
|
||
|
||
@app.post("/api/opnsense/wireguard/setup-server")
|
||
def opnsense_wg_setup_server(body: OPNWGServerSetup):
|
||
"""Create (or replace) a WireGuard server on OPNsense via its API."""
|
||
require_session(body.token)
|
||
opn_cfg = _load_opnsense_cfg()
|
||
if not opn_cfg:
|
||
raise HTTPException(400, "OPNsense not configured — connect it in the DHCP tab first")
|
||
|
||
import ipaddress as _ip, time as _time
|
||
|
||
try:
|
||
net = _ip.ip_network(body.tunnel_subnet, strict=False)
|
||
except Exception:
|
||
raise HTTPException(400, "Invalid tunnel_subnet — use CIDR notation e.g. 10.99.2.0/24")
|
||
|
||
server_tunnel_ip = f"{list(net.hosts())[0]}/{net.prefixlen}"
|
||
|
||
wg = _load_opnsense_wg()
|
||
|
||
# Tear down any pre-existing server so we start clean
|
||
if wg.get("server_uuid"):
|
||
try:
|
||
_opnsense_request(opn_cfg,
|
||
f"wireguard/server/delServer/{wg['server_uuid']}", method="POST")
|
||
except Exception:
|
||
pass
|
||
|
||
payload = {
|
||
"server": {
|
||
"enabled": "1",
|
||
"name": body.server_name,
|
||
"instance": "1", # creates wg1 — leaves wg0 (local) untouched
|
||
"port": str(body.listen_port),
|
||
"tunneladdress": server_tunnel_ip,
|
||
"dns": "",
|
||
"peers": "",
|
||
}
|
||
}
|
||
try:
|
||
result = _opnsense_request(opn_cfg, "wireguard/server/addServer",
|
||
method="POST", body=payload)
|
||
except Exception as e:
|
||
raise HTTPException(500, f"OPNsense rejected server creation: {e}")
|
||
|
||
server_uuid = result.get("uuid","")
|
||
if not server_uuid:
|
||
raise HTTPException(500, "OPNsense did not return a server UUID")
|
||
|
||
# Apply so OPNsense generates the keypair, then read it back
|
||
try:
|
||
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
|
||
except Exception:
|
||
pass
|
||
|
||
_time.sleep(1.5) # give the daemon a moment to generate keys
|
||
server_pubkey = ""
|
||
try:
|
||
s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{server_uuid}")
|
||
server_pubkey = s.get("server", {}).get("pubkey", "")
|
||
except Exception:
|
||
pass
|
||
|
||
wg = {
|
||
"server_uuid": server_uuid,
|
||
"server_name": body.server_name,
|
||
"listen_port": body.listen_port,
|
||
"tunnel_subnet": body.tunnel_subnet,
|
||
"server_tunnel_ip": server_tunnel_ip,
|
||
"server_pubkey": server_pubkey,
|
||
"public_endpoint": body.public_endpoint,
|
||
"peers": [],
|
||
}
|
||
_save_opnsense_wg(wg)
|
||
|
||
log.info(f"OPNsense WG server created: {body.server_name} uuid={server_uuid}")
|
||
return {
|
||
"success": True,
|
||
"server_uuid": server_uuid,
|
||
"server_pubkey": server_pubkey,
|
||
"server_tunnel_ip": server_tunnel_ip,
|
||
"listen_port": body.listen_port,
|
||
}
|
||
|
||
|
||
@app.delete("/api/opnsense/wireguard/server")
|
||
def opnsense_wg_delete_server(token: str):
|
||
"""Remove the WireGuard server from OPNsense and clear local state."""
|
||
require_session(token)
|
||
opn_cfg = _load_opnsense_cfg()
|
||
if not opn_cfg:
|
||
raise HTTPException(400, "OPNsense not configured")
|
||
wg = _load_opnsense_wg()
|
||
if not wg.get("server_uuid"):
|
||
raise HTTPException(404, "No server is configured")
|
||
try:
|
||
_opnsense_request(opn_cfg,
|
||
f"wireguard/server/delServer/{wg['server_uuid']}", method="POST")
|
||
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Failed to delete server: {e}")
|
||
_save_opnsense_wg({})
|
||
return {"success": True}
|
||
|
||
|
||
@app.post("/api/opnsense/wireguard/add-peer")
|
||
def opnsense_wg_add_peer(body: OPNWGAddPeer):
|
||
"""
|
||
Generate a WireGuard keypair, register the peer on OPNsense,
|
||
link it to the server, and return a ready-to-use .conf for the client.
|
||
The private key is stored only on the management PC (never sent to OPNsense).
|
||
"""
|
||
require_session(body.token)
|
||
opn_cfg = _load_opnsense_cfg()
|
||
if not opn_cfg:
|
||
raise HTTPException(400, "OPNsense not configured")
|
||
wg = _load_opnsense_wg()
|
||
if not wg.get("server_uuid"):
|
||
raise HTTPException(400, "Set up the WireGuard server on OPNsense first")
|
||
|
||
import ipaddress as _ip, re as _re
|
||
|
||
# ── Allocate next free IP in the tunnel subnet ────────────────────
|
||
net = _ip.ip_network(wg["tunnel_subnet"], strict=False)
|
||
hosts = list(net.hosts())
|
||
used = set()
|
||
# Reserve the server's own tunnel IP
|
||
m = _re.match(r'(\S+)/\d+', wg.get("server_tunnel_ip", ""))
|
||
if m:
|
||
used.add(m.group(1))
|
||
for p in wg.get("peers", []):
|
||
m2 = _re.match(r'(\S+)/\d+', p.get("tunnel_ip", ""))
|
||
if m2:
|
||
used.add(m2.group(1))
|
||
|
||
peer_ip_obj = next((h for h in hosts if str(h) not in used), None)
|
||
if not peer_ip_obj:
|
||
raise HTTPException(400, "Tunnel subnet is full — no IPs available for new peer")
|
||
peer_ip = f"{peer_ip_obj}/{net.prefixlen}"
|
||
|
||
# ── Build the AllowedIPs list from chosen VLANs ───────────────────
|
||
vlan_cidrs = []
|
||
for vid in body.allowed_vlans:
|
||
subnet = (body.vlan_subnets.get(str(vid))
|
||
or body.vlan_subnets.get(int(vid))
|
||
or f"192.168.{vid}.0/24")
|
||
vlan_cidrs.append(subnet)
|
||
# Always include the tunnel subnet so the client can reach the server
|
||
allowed_ips = ", ".join([str(net)] + vlan_cidrs) if vlan_cidrs else str(net)
|
||
|
||
# ── Generate keypair (private key stays on mgmt PC only) ─────────
|
||
c_priv, c_pub = _wg_genkey_api()
|
||
|
||
# ── Register peer (client) on OPNsense ───────────────────────────
|
||
peer_payload = {
|
||
"client": {
|
||
"enabled": "1",
|
||
"name": body.name,
|
||
"pubkey": c_pub,
|
||
"psk": "",
|
||
"tunneladdress": peer_ip,
|
||
"serveraddress": "",
|
||
"serverport": "",
|
||
"keepalive": "25",
|
||
}
|
||
}
|
||
try:
|
||
result = _opnsense_request(opn_cfg, "wireguard/client/addClient",
|
||
method="POST", body=peer_payload)
|
||
except Exception as e:
|
||
raise HTTPException(500, f"OPNsense rejected peer creation: {e}")
|
||
|
||
peer_uuid = result.get("uuid", "")
|
||
if not peer_uuid:
|
||
raise HTTPException(500, "OPNsense did not return a peer UUID")
|
||
|
||
# ── Link peer to server (append to server's peers list) ──────────
|
||
try:
|
||
s = _opnsense_request(opn_cfg,
|
||
f"wireguard/server/getServer/{wg['server_uuid']}")
|
||
srv = s.get("server", {})
|
||
existing = srv.get("peers", "")
|
||
new_peers = f"{existing},{peer_uuid}" if existing else peer_uuid
|
||
_opnsense_request(opn_cfg,
|
||
f"wireguard/server/setServer/{wg['server_uuid']}", method="POST",
|
||
body={"server": {**srv, "peers": new_peers}})
|
||
except Exception as e:
|
||
log.warning(f"Could not link peer to server (peer still registered): {e}")
|
||
|
||
# Apply config
|
||
try:
|
||
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Determine DNS server for client config ─────────────────────
|
||
# Use OPNsense's VLAN 99 gateway IP so DNS goes through:
|
||
# Unbound (:53) → ctrld (127.0.0.1:5354) → ControlD
|
||
# This applies the correct ControlD profile based on source IP.
|
||
if body.dns_server:
|
||
dns_ip = body.dns_server
|
||
else:
|
||
# Use OPNsense's host IP (typically its VLAN 99 gateway)
|
||
dns_ip = opn_cfg.get("host", "")
|
||
if not dns_ip:
|
||
# Fallback to tunnel gateway
|
||
dns_ip = wg.get("server_tunnel_ip", "").split("/")[0]
|
||
|
||
# ── Build client .conf ────────────────────────────────────────────
|
||
server_pubkey = wg.get("server_pubkey", "")
|
||
endpoint_host = wg.get("public_endpoint", "") or "<YOUR-OPNSENSE-PUBLIC-IP>"
|
||
endpoint_port = wg.get("listen_port", 51820)
|
||
|
||
client_conf = (
|
||
f"[Interface]\n"
|
||
f"PrivateKey = {c_priv}\n"
|
||
f"Address = {peer_ip}\n"
|
||
f"DNS = {dns_ip}\n"
|
||
f"# DNS goes to OPNsense → Unbound → ctrld → ControlD\n"
|
||
f"# ControlD profile applied by source IP (WireGuard tunnel subnet)\n\n"
|
||
f"[Peer]\n"
|
||
f"PublicKey = {server_pubkey or '<SERVER_PUBKEY>'}\n"
|
||
f"Endpoint = {endpoint_host}:{endpoint_port}\n"
|
||
f"AllowedIPs = {allowed_ips}\n"
|
||
f"PersistentKeepalive = 25\n"
|
||
)
|
||
|
||
# ── Add ctrld network rule for WireGuard tunnel subnet ───────────
|
||
# So ctrld can route DNS queries from VPN clients to the right
|
||
# ControlD profile (e.g. "house" profile for VLAN 99 users)
|
||
ctrld_note = ""
|
||
if body.dns_profile:
|
||
tunnel_subnet = wg.get("tunnel_subnet", "10.99.2.0/24")
|
||
ctrld_note = (
|
||
f"Add this to your ctrld.toml (proxy mode) or configure via DNS tab:\n"
|
||
f" [network.wg]\n"
|
||
f" name = 'WireGuard VPN'\n"
|
||
f" cidrs = ['{tunnel_subnet}']\n\n"
|
||
f" Then map network.wg to the '{body.dns_profile}' upstream in "
|
||
f"listener.0.policy.networks.\n"
|
||
f" This applies the '{body.dns_profile}' ControlD profile to all VPN clients."
|
||
)
|
||
|
||
# ── Persist peer metadata locally ────────────────────────────────
|
||
peer_meta = {
|
||
"uuid": peer_uuid,
|
||
"name": body.name,
|
||
"pub_key": c_pub,
|
||
"priv_key": c_priv, # NEVER sent to OPNsense
|
||
"tunnel_ip": peer_ip,
|
||
"allowed_vlans": body.allowed_vlans,
|
||
"allowed_ips": allowed_ips,
|
||
"dns_server": dns_ip,
|
||
"dns_profile": body.dns_profile,
|
||
"config": client_conf,
|
||
}
|
||
peers = [p for p in wg.get("peers", []) if p.get("name") != body.name]
|
||
peers.append(peer_meta)
|
||
wg["peers"] = peers
|
||
_save_opnsense_wg(wg)
|
||
|
||
log.info(f"OPNsense WG peer added: {body.name} → {peer_ip} VLANs={body.allowed_vlans} DNS={dns_ip}")
|
||
return {
|
||
"success": True,
|
||
"uuid": peer_uuid,
|
||
"name": body.name,
|
||
"tunnel_ip": peer_ip,
|
||
"allowed_vlans": body.allowed_vlans,
|
||
"dns_server": dns_ip,
|
||
"dns_profile": body.dns_profile,
|
||
"config": client_conf,
|
||
"ctrld_note": ctrld_note,
|
||
}
|
||
|
||
|
||
@app.delete("/api/opnsense/wireguard/peer/{uuid}")
|
||
def opnsense_wg_remove_peer(uuid: str, token: str):
|
||
"""Remove a peer from OPNsense and from local metadata."""
|
||
require_session(token)
|
||
opn_cfg = _load_opnsense_cfg()
|
||
if not opn_cfg:
|
||
raise HTTPException(400, "OPNsense not configured")
|
||
wg = _load_opnsense_wg()
|
||
|
||
# Remove from OPNsense
|
||
try:
|
||
_opnsense_request(opn_cfg,
|
||
f"wireguard/client/delClient/{uuid}", method="POST")
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Failed to remove peer from OPNsense: {e}")
|
||
|
||
# Unlink from server peers list
|
||
if wg.get("server_uuid"):
|
||
try:
|
||
s = _opnsense_request(opn_cfg,
|
||
f"wireguard/server/getServer/{wg['server_uuid']}")
|
||
srv = s.get("server", {})
|
||
existing = srv.get("peers", "")
|
||
updated = ",".join(p for p in existing.split(",") if p and p != uuid)
|
||
_opnsense_request(opn_cfg,
|
||
f"wireguard/server/setServer/{wg['server_uuid']}", method="POST",
|
||
body={"server": {**srv, "peers": updated}})
|
||
except Exception:
|
||
pass
|
||
|
||
# Apply
|
||
try:
|
||
_opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")
|
||
except Exception:
|
||
pass
|
||
|
||
wg["peers"] = [p for p in wg.get("peers", []) if p.get("uuid") != uuid]
|
||
_save_opnsense_wg(wg)
|
||
return {"success": True}
|
||
|
||
|
||
@app.get("/api/opnsense/wireguard/peer-config/{name}")
|
||
def opnsense_wg_peer_config(name: str):
|
||
"""Return the saved .conf text for a named peer (includes private key)."""
|
||
wg = _load_opnsense_wg()
|
||
peer = next((p for p in wg.get("peers", []) if p["name"] == name), None)
|
||
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}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# BACKUP / RESTORE — both OPNsense and switch
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
import datetime as _dt
|
||
import shutil as _shutil
|
||
|
||
BACKUP_DIR = _Path("/etc/switch-manager/backups")
|
||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||
MAX_BACKUPS = 50 # keep last N backups per device
|
||
|
||
def _ts() -> str:
|
||
return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
|
||
def _prune_backups(subdir: _Path):
|
||
"""Keep only the last MAX_BACKUPS files in a backup subdirectory."""
|
||
files = sorted(subdir.glob("*"), key=lambda f: f.stat().st_mtime)
|
||
while len(files) > MAX_BACKUPS:
|
||
files.pop(0).unlink()
|
||
|
||
# ── OPNsense backup (XML config export) ─────────────────────────────
|
||
|
||
def _opnsense_backup(cfg: dict, reason: str = "") -> dict:
|
||
"""Download OPNsense config.xml via API and save locally."""
|
||
host = cfg.get("host", "")
|
||
key = cfg.get("key", "")
|
||
secret = cfg.get("secret", "")
|
||
if not host or not key:
|
||
return {"ok": False, "error": "OPNsense not configured"}
|
||
bdir = BACKUP_DIR / "opnsense"
|
||
bdir.mkdir(parents=True, exist_ok=True)
|
||
ts = _ts()
|
||
fname = f"opnsense-{ts}.xml"
|
||
fpath = bdir / fname
|
||
try:
|
||
creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()
|
||
ctx = _ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = _ssl.CERT_NONE
|
||
url = f"https://{host}/api/core/backup/download/this"
|
||
req = _urlreq.Request(url, headers={
|
||
"Authorization": f"Basic {creds}",
|
||
}, method="POST")
|
||
with _urlreq.urlopen(req, timeout=30, context=ctx) as r:
|
||
xml_data = r.read()
|
||
fpath.write_bytes(xml_data)
|
||
fpath.chmod(0o600)
|
||
# Write metadata
|
||
meta = {"timestamp": ts, "reason": reason, "file": fname,
|
||
"size": len(xml_data), "host": host}
|
||
(bdir / f"opnsense-{ts}.meta.json").write_text(_json.dumps(meta, indent=2))
|
||
_prune_backups(bdir)
|
||
log.info(f"OPNsense backup saved: {fname} ({len(xml_data)} bytes) reason={reason}")
|
||
return {"ok": True, "file": fname, "size": len(xml_data), "timestamp": ts}
|
||
except Exception as e:
|
||
log.warning(f"OPNsense backup failed: {e}")
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
def _opnsense_restore(cfg: dict, filename: str) -> dict:
|
||
"""Upload a config.xml backup to OPNsense."""
|
||
bdir = BACKUP_DIR / "opnsense"
|
||
fpath = bdir / filename
|
||
if not fpath.exists():
|
||
return {"ok": False, "error": f"Backup file not found: {filename}"}
|
||
# Sanity: must be XML
|
||
content = fpath.read_bytes()
|
||
if b"<opnsense>" not in content and b"<OPNsense>" not in content:
|
||
return {"ok": False, "error": "File does not look like an OPNsense config"}
|
||
host = cfg.get("host", "")
|
||
key = cfg.get("key", "")
|
||
secret = cfg.get("secret", "")
|
||
if not host or not key:
|
||
return {"ok": False, "error": "OPNsense not configured"}
|
||
try:
|
||
creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()
|
||
ctx = _ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = _ssl.CERT_NONE
|
||
# OPNsense restore API expects multipart form upload
|
||
import mimetypes
|
||
boundary = f"----BackupRestore{_ts()}"
|
||
body = (
|
||
f"--{boundary}\r\n"
|
||
f'Content-Disposition: form-data; name="conf"; filename="{filename}"\r\n'
|
||
f"Content-Type: application/xml\r\n\r\n"
|
||
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
|
||
url = f"https://{host}/api/core/backup/restore"
|
||
req = _urlreq.Request(url, data=body, headers={
|
||
"Authorization": f"Basic {creds}",
|
||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||
}, method="POST")
|
||
with _urlreq.urlopen(req, timeout=60, context=ctx) as r:
|
||
result = _json.loads(r.read().decode())
|
||
log.info(f"OPNsense restore from {filename}: {result}")
|
||
return {"ok": True, "result": result, "file": filename}
|
||
except Exception as e:
|
||
log.warning(f"OPNsense restore failed: {e}")
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
# ── Switch backup (running-config capture) ───────────────────────────
|
||
|
||
def _switch_backup(reason: str = "") -> dict:
|
||
"""Capture switch running-config via SSH and save locally."""
|
||
bdir = BACKUP_DIR / "switch"
|
||
bdir.mkdir(parents=True, exist_ok=True)
|
||
ts = _ts()
|
||
fname = f"switch-{ts}.cfg"
|
||
fpath = bdir / fname
|
||
try:
|
||
raw = read_cmd("show running-config")
|
||
if not raw or len(raw) < 50:
|
||
return {"ok": False, "error": "Empty or too-short running-config output"}
|
||
fpath.write_text(raw)
|
||
fpath.chmod(0o600)
|
||
meta = {"timestamp": ts, "reason": reason, "file": fname, "size": len(raw)}
|
||
(bdir / f"switch-{ts}.meta.json").write_text(_json.dumps(meta, indent=2))
|
||
_prune_backups(bdir)
|
||
log.info(f"Switch backup saved: {fname} ({len(raw)} bytes) reason={reason}")
|
||
return {"ok": True, "file": fname, "size": len(raw), "timestamp": ts}
|
||
except Exception as e:
|
||
log.warning(f"Switch backup failed: {e}")
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
# ── Pre-change backup (called automatically before any push) ─────────
|
||
|
||
def _pre_change_backup(reason: str) -> dict:
|
||
"""Backup both devices before making changes. Returns status for each."""
|
||
results = {"switch": None, "opnsense": None}
|
||
# Always backup switch
|
||
results["switch"] = _switch_backup(reason=reason)
|
||
# Backup OPNsense if configured
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key"):
|
||
results["opnsense"] = _opnsense_backup(cfg, reason=reason)
|
||
return results
|
||
|
||
|
||
# ── Connectivity safety check ────────────────────────────────────────
|
||
|
||
def _check_connectivity() -> dict:
|
||
"""Verify SSH reachability to switch and OPNsense. Non-destructive probe."""
|
||
result = {"switch": {"ok": False, "error": ""}, "opnsense": {"ok": False, "error": "", "configured": False}}
|
||
|
||
# Check switch
|
||
try:
|
||
conn = _pool.get()
|
||
transport = conn.get_transport()
|
||
if transport and transport.is_active():
|
||
transport.send_ignore()
|
||
result["switch"]["ok"] = True
|
||
else:
|
||
result["switch"]["error"] = "SSH transport not active"
|
||
except Exception as e:
|
||
result["switch"]["error"] = str(e)
|
||
|
||
# Check OPNsense (if configured)
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key"):
|
||
result["opnsense"]["configured"] = True
|
||
try:
|
||
_opnsense_request(cfg, "core/firmware/status")
|
||
result["opnsense"]["ok"] = True
|
||
except Exception as e:
|
||
result["opnsense"]["error"] = str(e)
|
||
if cfg.get("ssh_key_path"):
|
||
try:
|
||
test = _opnsense_ssh_test(cfg)
|
||
result["opnsense"]["ssh_ok"] = test.get("ok", False)
|
||
except Exception:
|
||
result["opnsense"]["ssh_ok"] = False
|
||
|
||
return result
|
||
|
||
|
||
# ── API endpoints ────────────────────────────────────────────────────
|
||
|
||
@app.get("/api/backup/list")
|
||
def backup_list():
|
||
"""List all available backups for both devices."""
|
||
backups = {"switch": [], "opnsense": []}
|
||
for device in ["switch", "opnsense"]:
|
||
bdir = BACKUP_DIR / device
|
||
if not bdir.exists():
|
||
continue
|
||
for meta_file in sorted(bdir.glob("*.meta.json"), reverse=True):
|
||
try:
|
||
meta = _json.loads(meta_file.read_text())
|
||
meta["exists"] = (bdir / meta["file"]).exists()
|
||
backups[device].append(meta)
|
||
except Exception:
|
||
continue
|
||
return backups
|
||
|
||
|
||
@app.post("/api/backup/create")
|
||
def backup_create(body: dict):
|
||
"""Manually trigger a backup of one or both devices."""
|
||
require_session(body.get("token", ""))
|
||
device = body.get("device", "both") # "switch", "opnsense", or "both"
|
||
reason = body.get("reason", "manual backup")
|
||
results = {}
|
||
if device in ("switch", "both"):
|
||
results["switch"] = _switch_backup(reason=reason)
|
||
if device in ("opnsense", "both"):
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key"):
|
||
results["opnsense"] = _opnsense_backup(cfg, reason=reason)
|
||
else:
|
||
results["opnsense"] = {"ok": False, "error": "OPNsense not configured"}
|
||
return results
|
||
|
||
|
||
@app.post("/api/backup/restore")
|
||
def backup_restore(body: dict):
|
||
"""Restore a backup to a device. Creates a new backup first as safety net."""
|
||
require_session(body.get("token", ""))
|
||
device = body.get("device", "")
|
||
filename = body.get("filename", "")
|
||
if not device or not filename:
|
||
raise HTTPException(400, "device and filename required")
|
||
if device not in ("switch", "opnsense"):
|
||
raise HTTPException(400, "device must be 'switch' or 'opnsense'")
|
||
|
||
# Safety: backup current state first
|
||
safety = _pre_change_backup(reason=f"pre-restore safety backup before restoring {filename}")
|
||
|
||
if device == "opnsense":
|
||
cfg = _load_opnsense_cfg()
|
||
result = _opnsense_restore(cfg, filename)
|
||
return {"result": result, "safety_backup": safety}
|
||
elif device == "switch":
|
||
# Switch restore = parse config and push commands
|
||
bdir = BACKUP_DIR / "switch"
|
||
fpath = bdir / filename
|
||
if not fpath.exists():
|
||
raise HTTPException(404, f"Backup file not found: {filename}")
|
||
return {
|
||
"result": {"ok": True, "note": "Switch config restore requires manual review. "
|
||
"Download the backup file and apply commands via Review & Push tab."},
|
||
"safety_backup": safety,
|
||
"config_preview": fpath.read_text()[:5000],
|
||
}
|
||
|
||
|
||
@app.get("/api/backup/download/{device}/{filename}")
|
||
def backup_download(device: str, filename: str):
|
||
"""Download a backup file."""
|
||
if device not in ("switch", "opnsense"):
|
||
raise HTTPException(400, "device must be 'switch' or 'opnsense'")
|
||
# Prevent path traversal
|
||
if "/" in filename or ".." in filename:
|
||
raise HTTPException(400, "Invalid filename")
|
||
fpath = BACKUP_DIR / device / filename
|
||
if not fpath.exists():
|
||
raise HTTPException(404, "Backup not found")
|
||
from fastapi.responses import FileResponse
|
||
return FileResponse(fpath, filename=filename)
|
||
|
||
|
||
@app.get("/api/connectivity/check")
|
||
def connectivity_check():
|
||
"""Check SSH/API reachability to both switch and OPNsense."""
|
||
return _check_connectivity()
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# UNIFIED NETWORK PROVISIONING — one operation for both devices
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
class UnifiedVlanProvision(BaseModel):
|
||
token: str
|
||
vlan_id: int
|
||
name: str
|
||
subnet: str # e.g. "192.168.60.0/24"
|
||
gateway: str # e.g. "192.168.60.1"
|
||
dhcp_start: str # e.g. "192.168.60.100"
|
||
dhcp_end: str # e.g. "192.168.60.200"
|
||
parent_if: str # OPNsense physical parent, e.g. "igb0"
|
||
opnsense_if: Optional[str] = ""
|
||
allow_internet: bool = True
|
||
# Port assignments
|
||
ports: list[dict] = [] # [{"port": 1, "poe": true}, {"port": 5, "poe": false}]
|
||
# Trunk uplink ports (these get the new VLAN tagged)
|
||
trunk_ports: list[int] = []
|
||
|
||
@field_validator("vlan_id")
|
||
@classmethod
|
||
def cv(cls, v):
|
||
vid = san_vid(v)
|
||
if vid in (1, 99):
|
||
raise ValueError("VLAN 1 and 99 are reserved")
|
||
return vid
|
||
@field_validator("name")
|
||
@classmethod
|
||
def cn(cls, v): return _san(v, _RE_VNAME, "name")
|
||
|
||
|
||
@app.post("/api/network/provision")
|
||
def unified_provision(body: UnifiedVlanProvision):
|
||
"""
|
||
Unified VLAN + port + OPNsense provisioning in one operation.
|
||
|
||
1. Pre-change backup of both devices
|
||
2. Connectivity check
|
||
3. Create VLAN on switch + assign ports + set PoE
|
||
4. Create VLAN on OPNsense + DHCP scope + firewall rule
|
||
5. Post-change connectivity verify
|
||
"""
|
||
import ipaddress as _ipaddr
|
||
require_session(body.token)
|
||
|
||
steps_done: list[str] = []
|
||
errors: list[str] = []
|
||
pending_steps: list[str] = []
|
||
|
||
# Validate addresses
|
||
try:
|
||
net = _ipaddr.ip_network(body.subnet, strict=False)
|
||
_ipaddr.ip_address(body.gateway)
|
||
_ipaddr.ip_address(body.dhcp_start)
|
||
_ipaddr.ip_address(body.dhcp_end)
|
||
except ValueError as e:
|
||
raise HTTPException(400, f"Invalid address: {e}")
|
||
|
||
# ── Step 0: Pre-change connectivity check ────────────────────────
|
||
conn = _check_connectivity()
|
||
if not conn["switch"]["ok"]:
|
||
raise HTTPException(503, f"Switch unreachable — aborting: {conn['switch']['error']}")
|
||
steps_done.append("connectivity: switch reachable")
|
||
|
||
# ── Step 1: Pre-change backup ────────────────────────────────────
|
||
backup = _pre_change_backup(reason=f"pre-provision VLAN {body.vlan_id} '{body.name}'")
|
||
if backup["switch"] and backup["switch"].get("ok"):
|
||
steps_done.append(f"backup: switch config saved ({backup['switch']['file']})")
|
||
else:
|
||
errors.append(f"backup: switch backup failed — {backup['switch'].get('error', 'unknown')}")
|
||
# Non-fatal but warn
|
||
if backup.get("opnsense") and backup["opnsense"].get("ok"):
|
||
steps_done.append(f"backup: OPNsense config saved ({backup['opnsense']['file']})")
|
||
|
||
# ── Step 2: Create VLAN on switch ────────────────────────────────
|
||
switch_cmds = [f'vlan create {body.vlan_id} name "{body.name}" type port']
|
||
|
||
# Assign access ports
|
||
for pa in body.ports:
|
||
p = san_port(pa["port"])
|
||
vid = body.vlan_id
|
||
switch_cmds += [
|
||
f"vlan members add {vid} {p}",
|
||
f"vlan pvid {p} {vid}",
|
||
]
|
||
iface = f"FastEthernet {p}" if p <= 48 else f"GigabitEthernet {p}"
|
||
if p <= 96:
|
||
switch_cmds.append(f"interface {iface}")
|
||
if pa.get("poe", True):
|
||
switch_cmds += [" poe enable", f" poe poe-limit {pa.get('poe_limit', 30000)}"]
|
||
else:
|
||
switch_cmds += [" no poe enable"]
|
||
|
||
# Add VLAN to trunk uplinks
|
||
for tp in body.trunk_ports:
|
||
tp = san_port(tp)
|
||
switch_cmds += [
|
||
f"vlan members add {body.vlan_id} {tp}",
|
||
f"vlan tagging {body.vlan_id} {tp}",
|
||
]
|
||
|
||
# Danger check before push
|
||
danger = check_danger(switch_cmds)
|
||
if danger["has_hard_block"]:
|
||
raise HTTPException(400, {
|
||
"message": "Hard-blocked commands detected",
|
||
"blocked": danger["hard_blocked"],
|
||
})
|
||
|
||
result = push_one_by_one(switch_cmds)
|
||
if result.get("success"):
|
||
steps_done.append(f"switch: VLAN {body.vlan_id} created, {len(body.ports)} ports assigned, "
|
||
f"{len(body.trunk_ports)} trunk ports updated")
|
||
else:
|
||
errors.append(f"switch: push failed at command {result.get('stopped_at', '?')}: "
|
||
f"{result.get('error', 'unknown')}")
|
||
# Return early — don't configure OPNsense for a VLAN the switch doesn't have
|
||
return {
|
||
"success": False, "steps_done": steps_done, "errors": errors,
|
||
"pending_steps": [], "backup": backup, "switch_result": result,
|
||
}
|
||
|
||
# ── Step 3: OPNsense VLAN + DHCP + firewall ─────────────────────
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("key"):
|
||
pending_steps += [
|
||
f"OPNsense: create VLAN tag {body.vlan_id} on {body.parent_if}",
|
||
f"OPNsense: assign interface, set IP {body.gateway}/{net.prefixlen}",
|
||
f"OPNsense: create DHCP scope {body.dhcp_start}–{body.dhcp_end}",
|
||
]
|
||
if body.allow_internet:
|
||
pending_steps.append("OPNsense: add allow-outbound firewall rule")
|
||
else:
|
||
# Create VLAN tag
|
||
try:
|
||
vlan_r = _opnsense_request(cfg, "interfaces/vlan_settings/addItem", "POST", {
|
||
"vlan": {"if": body.parent_if, "tag": str(body.vlan_id),
|
||
"pcp": "0", "descr": body.name}
|
||
})
|
||
_opnsense_request(cfg, "interfaces/vlan_settings/reconfigure", "POST")
|
||
steps_done.append(f"OPNsense: VLAN tag {body.vlan_id} created on {body.parent_if}")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense VLAN tag: {e}")
|
||
|
||
if body.opnsense_if:
|
||
# DHCP scope
|
||
try:
|
||
_opnsense_request(cfg, "dhcpv4/settings/addSubnet", "POST", {
|
||
"subnet": {
|
||
"interface": body.opnsense_if,
|
||
"subnet": str(net),
|
||
"gateway": body.gateway,
|
||
"dns_servers": body.gateway,
|
||
"range": {"from": body.dhcp_start, "to": body.dhcp_end},
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
|
||
steps_done.append(f"OPNsense: DHCP scope {body.dhcp_start}–{body.dhcp_end}")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense DHCP: {e}")
|
||
|
||
# Firewall rule
|
||
if body.allow_internet:
|
||
try:
|
||
_opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"interface": body.opnsense_if, "direction": "in",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{body.opnsense_if}net"},
|
||
"destination": {"any": "1"},
|
||
"descr": f"Allow VLAN {body.vlan_id} {body.name} outbound",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
steps_done.append(f"OPNsense: allow-outbound firewall rule added")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense firewall: {e}")
|
||
|
||
# Persist mapping
|
||
vmap = _load_vlan_if_map()
|
||
vmap[str(body.vlan_id)] = body.opnsense_if
|
||
_save_vlan_if_map(vmap)
|
||
else:
|
||
pending_steps += [
|
||
f"OPNsense UI: assign {body.parent_if}.{body.vlan_id} as interface, "
|
||
f"set IP {body.gateway}/{net.prefixlen}",
|
||
f"Then re-run with opnsense_if set to create DHCP + firewall",
|
||
]
|
||
|
||
# ── Step 4: Post-change connectivity verify ──────────────────────
|
||
post_conn = _check_connectivity()
|
||
if not post_conn["switch"]["ok"]:
|
||
errors.append("POST-CHANGE WARNING: switch SSH connectivity lost! "
|
||
f"Backup available: {backup['switch'].get('file', 'N/A')}")
|
||
|
||
return {
|
||
"success": len(errors) == 0,
|
||
"steps_done": steps_done,
|
||
"pending_steps": pending_steps,
|
||
"errors": errors,
|
||
"backup": backup,
|
||
"switch_result": result,
|
||
"post_connectivity": post_conn,
|
||
}
|
||
|
||
|
||
# ── Wrap existing push to auto-backup ────────────────────────────────
|
||
|
||
_original_push = push_one_by_one
|
||
|
||
def push_one_by_one_with_backup(commands: list[str]) -> dict:
|
||
"""Wraps push_one_by_one to create a backup before pushing."""
|
||
backup = _pre_change_backup(reason=f"pre-push ({len(commands)} commands)")
|
||
result = _original_push(commands)
|
||
result["backup"] = backup
|
||
return result
|
||
|
||
# Monkey-patch: the push endpoint calls push_one_by_one directly
|
||
# We leave push_one_by_one as-is (it's used internally) and
|
||
# add the backup in the API endpoint wrapper below.
|
||
|
||
@app.post("/api/switch/push-safe")
|
||
def push_safe(body: PushBatch):
|
||
"""Push with automatic pre-change backup and post-change connectivity check."""
|
||
require_session(body.token)
|
||
|
||
# Danger check
|
||
danger = check_danger(body.commands)
|
||
if danger["has_hard_block"]:
|
||
raise HTTPException(400, {
|
||
"message": "Hard-blocked commands — must be run at console",
|
||
"blocked": danger["hard_blocked"],
|
||
})
|
||
|
||
# Pre-check
|
||
conn = _check_connectivity()
|
||
if not conn["switch"]["ok"]:
|
||
raise HTTPException(503, "Switch unreachable — refusing to push")
|
||
|
||
# Backup
|
||
backup = _pre_change_backup(reason=f"pre-push ({len(body.commands)} commands)")
|
||
|
||
# Push
|
||
result = push_one_by_one(body.commands)
|
||
|
||
# Post-check
|
||
post_conn = _check_connectivity()
|
||
if not post_conn["switch"]["ok"]:
|
||
result["connectivity_warning"] = (
|
||
"Switch SSH lost after push! "
|
||
f"Backup: {backup['switch'].get('file', 'N/A')}"
|
||
)
|
||
|
||
result["backup"] = backup
|
||
result["post_connectivity"] = post_conn
|
||
return result
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# FIREWALL POLICY MATRIX — inter-VLAN access control
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json")
|
||
|
||
# Policy types:
|
||
# "block" — deny all traffic between VLANs
|
||
# "allow" — permit all traffic between VLANs
|
||
# "one-way" — src VLAN can reach dst VLAN, but not reverse
|
||
# "services" — src can reach dst on specific ports only
|
||
# "printer" — other VLANs can print (reach ports 9100,631,443), printer can't initiate
|
||
|
||
POLICY_PRESETS = {
|
||
"block": {
|
||
"label": "Blocked",
|
||
"description": "No traffic allowed between these VLANs",
|
||
},
|
||
"allow": {
|
||
"label": "Full Access",
|
||
"description": "All traffic permitted between these VLANs",
|
||
},
|
||
"one-way": {
|
||
"label": "One-Way Access",
|
||
"description": "Source VLAN can reach destination, but not reverse",
|
||
},
|
||
"printer": {
|
||
"label": "Printer Access",
|
||
"description": "Other VLANs can reach printers (ports 9100/631/443/515), printers cannot initiate connections back",
|
||
"ports": [9100, 631, 443, 515],
|
||
},
|
||
"services": {
|
||
"label": "Service Ports Only",
|
||
"description": "Access limited to specified TCP/UDP ports",
|
||
},
|
||
}
|
||
|
||
|
||
def _load_policies() -> list:
|
||
if POLICIES_FILE.exists():
|
||
try: return _json.loads(POLICIES_FILE.read_text())
|
||
except: pass
|
||
return []
|
||
|
||
|
||
def _save_policies(policies: list):
|
||
POLICIES_FILE.write_text(_json.dumps(policies, indent=2))
|
||
POLICIES_FILE.chmod(0o600)
|
||
|
||
|
||
def _build_policy_acls(policy: dict) -> dict:
|
||
"""
|
||
Generate switch ACL commands AND OPNsense firewall rule payloads for a policy.
|
||
|
||
Returns {switch_cmds: [...], opnsense_rules: [...], description: str}
|
||
"""
|
||
ptype = policy.get("type", "block")
|
||
src_vid = policy.get("src_vlan")
|
||
dst_vid = policy.get("dst_vlan")
|
||
ports = policy.get("ports", [])
|
||
src_sub = f"192.168.{src_vid}.0"
|
||
dst_sub = f"192.168.{dst_vid}.0"
|
||
mask = "0.0.0.255"
|
||
acl_name = f"POLICY-V{src_vid}-V{dst_vid}"
|
||
|
||
switch_cmds = []
|
||
opnsense_rules = []
|
||
|
||
if ptype == "block":
|
||
switch_cmds = [
|
||
f"ip access-list extended {acl_name}",
|
||
f" 1 deny ip {src_sub} {mask} {dst_sub} {mask}",
|
||
f" 2 permit ip any any",
|
||
f"interface vlan {src_vid}",
|
||
f" ip access-group {acl_name} in",
|
||
]
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "block",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{src_sub}/{24}"},
|
||
"destination": {"network": f"{dst_sub}/24"},
|
||
"descr": f"Block VLAN {src_vid} → VLAN {dst_vid}",
|
||
}
|
||
})
|
||
|
||
elif ptype == "allow":
|
||
switch_cmds = [
|
||
f"ip access-list extended {acl_name}",
|
||
f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}",
|
||
f" 2 permit ip any any",
|
||
f"interface vlan {src_vid}",
|
||
f" ip access-group {acl_name} in",
|
||
]
|
||
# OPNsense: explicit allow (usually default, but good to be explicit)
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{src_sub}/24"},
|
||
"destination": {"network": f"{dst_sub}/24"},
|
||
"descr": f"Allow VLAN {src_vid} → VLAN {dst_vid}",
|
||
}
|
||
})
|
||
|
||
elif ptype == "one-way":
|
||
# Allow src→dst, block dst→src (reverse ACL on dst VLAN)
|
||
switch_cmds = [
|
||
f"ip access-list extended {acl_name}",
|
||
f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}",
|
||
f" 2 permit ip any any",
|
||
f"interface vlan {src_vid}",
|
||
f" ip access-group {acl_name} in",
|
||
f"ip access-list extended {acl_name}-REV",
|
||
f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}",
|
||
f" 2 permit ip any any",
|
||
f"interface vlan {dst_vid}",
|
||
f" ip access-group {acl_name}-REV in",
|
||
]
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{src_sub}/24"},
|
||
"destination": {"network": f"{dst_sub}/24"},
|
||
"descr": f"Allow VLAN {src_vid} → VLAN {dst_vid} (one-way)",
|
||
}
|
||
})
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "block",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{dst_sub}/24"},
|
||
"destination": {"network": f"{src_sub}/24"},
|
||
"descr": f"Block VLAN {dst_vid} → VLAN {src_vid} (one-way reverse)",
|
||
}
|
||
})
|
||
|
||
elif ptype == "printer":
|
||
# Other VLANs can reach printer VLAN on print ports; printers can't initiate
|
||
printer_ports = ports or [9100, 631, 443, 515]
|
||
rule_num = 1
|
||
switch_cmds = [f"ip access-list extended {acl_name}"]
|
||
for port in printer_ports:
|
||
switch_cmds.append(
|
||
f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}")
|
||
rule_num += 1
|
||
switch_cmds += [
|
||
f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}",
|
||
f" {rule_num+1} permit ip any any",
|
||
f"interface vlan {src_vid}",
|
||
f" ip access-group {acl_name} in",
|
||
]
|
||
# Reverse: block printers from initiating to src VLAN
|
||
switch_cmds += [
|
||
f"ip access-list extended {acl_name}-REV",
|
||
f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}",
|
||
f" 2 permit ip any any",
|
||
f"interface vlan {dst_vid}",
|
||
f" ip access-group {acl_name}-REV in",
|
||
]
|
||
# OPNsense rules
|
||
for port in printer_ports:
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"ipprotocol": "inet", "protocol": "tcp",
|
||
"source": {"network": f"{src_sub}/24"},
|
||
"destination": {"network": f"{dst_sub}/24", "port": str(port)},
|
||
"descr": f"VLAN {src_vid} → printer VLAN {dst_vid} port {port}",
|
||
}
|
||
})
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "block",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{dst_sub}/24"},
|
||
"destination": {"network": f"{src_sub}/24"},
|
||
"descr": f"Block printer VLAN {dst_vid} → VLAN {src_vid}",
|
||
}
|
||
})
|
||
|
||
elif ptype == "services":
|
||
rule_num = 1
|
||
switch_cmds = [f"ip access-list extended {acl_name}"]
|
||
for port in ports:
|
||
switch_cmds.append(
|
||
f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}")
|
||
rule_num += 1
|
||
switch_cmds += [
|
||
f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}",
|
||
f" {rule_num+1} permit ip any any",
|
||
f"interface vlan {src_vid}",
|
||
f" ip access-group {acl_name} in",
|
||
]
|
||
for port in ports:
|
||
opnsense_rules.append({
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"ipprotocol": "inet", "protocol": "tcp",
|
||
"source": {"network": f"{src_sub}/24"},
|
||
"destination": {"network": f"{dst_sub}/24", "port": str(port)},
|
||
"descr": f"VLAN {src_vid} → VLAN {dst_vid} port {port}",
|
||
}
|
||
})
|
||
|
||
return {
|
||
"switch_cmds": switch_cmds,
|
||
"opnsense_rules": opnsense_rules,
|
||
"acl_name": acl_name,
|
||
"description": f"{POLICY_PRESETS.get(ptype,{}).get('label','Custom')} — "
|
||
f"VLAN {src_vid} → VLAN {dst_vid}",
|
||
}
|
||
|
||
|
||
@app.get("/api/firewall/policies")
|
||
def get_policies():
|
||
"""Return saved inter-VLAN policies and available presets."""
|
||
return {"policies": _load_policies(), "presets": POLICY_PRESETS}
|
||
|
||
|
||
@app.post("/api/firewall/policies")
|
||
def save_policy(body: dict):
|
||
"""Save or update an inter-VLAN policy."""
|
||
require_session(body.get("token", ""))
|
||
policy = body.get("policy", {})
|
||
if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
|
||
raise HTTPException(400, "src_vlan, dst_vlan, and type required")
|
||
|
||
policies = _load_policies()
|
||
# Replace existing policy for this VLAN pair
|
||
policies = [p for p in policies
|
||
if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])]
|
||
policies.append(policy)
|
||
_save_policies(policies)
|
||
return {"success": True, "policies": policies}
|
||
|
||
|
||
@app.delete("/api/firewall/policies")
|
||
def delete_policy(body: dict):
|
||
"""Remove an inter-VLAN policy."""
|
||
require_session(body.get("token", ""))
|
||
src = body.get("src_vlan")
|
||
dst = body.get("dst_vlan")
|
||
policies = _load_policies()
|
||
policies = [p for p in policies if not (p["src_vlan"] == src and p["dst_vlan"] == dst)]
|
||
_save_policies(policies)
|
||
return {"success": True, "policies": policies}
|
||
|
||
|
||
@app.post("/api/firewall/preview")
|
||
def preview_policy(body: dict):
|
||
"""Preview generated ACLs/rules for a policy without pushing."""
|
||
policy = body.get("policy", {})
|
||
if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
|
||
raise HTTPException(400, "src_vlan, dst_vlan, and type required")
|
||
return _build_policy_acls(policy)
|
||
|
||
|
||
@app.post("/api/firewall/push")
|
||
def push_policy(body: dict):
|
||
"""Push a firewall policy to both switch and OPNsense."""
|
||
require_session(body.get("token", ""))
|
||
policy = body.get("policy", {})
|
||
if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
|
||
raise HTTPException(400, "src_vlan, dst_vlan, and type required")
|
||
|
||
generated = _build_policy_acls(policy)
|
||
steps_done = []
|
||
errors = []
|
||
|
||
# Pre-backup
|
||
backup = _pre_change_backup(
|
||
reason=f"pre-policy VLAN {policy['src_vlan']}→{policy['dst_vlan']} ({policy['type']})")
|
||
|
||
# Push switch ACLs
|
||
if generated["switch_cmds"]:
|
||
danger = check_danger(generated["switch_cmds"])
|
||
if danger["has_hard_block"]:
|
||
raise HTTPException(400, {"message": "Hard-blocked", "blocked": danger["hard_blocked"]})
|
||
result = push_one_by_one(generated["switch_cmds"])
|
||
if result.get("success"):
|
||
steps_done.append(f"switch: ACL {generated['acl_name']} applied")
|
||
else:
|
||
errors.append(f"switch: {result.get('error', 'push failed')}")
|
||
|
||
# Push OPNsense rules
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key") and generated["opnsense_rules"]:
|
||
vmap = _load_vlan_if_map()
|
||
src_if = vmap.get(str(policy["src_vlan"]), "")
|
||
for rule_data in generated["opnsense_rules"]:
|
||
if src_if:
|
||
rule_data["rule"]["interface"] = src_if
|
||
rule_data["rule"]["direction"] = "in"
|
||
try:
|
||
_opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_data)
|
||
steps_done.append(f"OPNsense: {rule_data['rule']['descr']}")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense: {e}")
|
||
try:
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
steps_done.append("OPNsense: firewall rules applied")
|
||
except ValueError as e:
|
||
errors.append(f"OPNsense apply: {e}")
|
||
|
||
# Save policy to local state
|
||
policies = _load_policies()
|
||
policies = [p for p in policies
|
||
if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])]
|
||
policy["pushed"] = True
|
||
policy["pushed_at"] = _ts()
|
||
policies.append(policy)
|
||
_save_policies(policies)
|
||
|
||
return {
|
||
"success": len(errors) == 0,
|
||
"steps_done": steps_done,
|
||
"errors": errors,
|
||
"backup": backup,
|
||
"generated": generated,
|
||
}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# SERVICE ACCESS — manage Caddy config + OPNsense port forwards + NAT reflection
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Architecture:
|
||
# Caddy runs on the LAN management computer. It is the reverse proxy for
|
||
# all services — only port 443 is forwarded from WAN, and Caddy routes
|
||
# by hostname (SNI) to the correct backend. Service ports (32400, 8123,
|
||
# etc.) are NEVER exposed on WAN.
|
||
#
|
||
# For LAN devices: they reach services directly via Caddy on the LAN.
|
||
# For isolated VLANs (IoT, Guest, etc.): they use the public FQDN
|
||
# (e.g. plex.mydomain.com). OPNsense NAT reflection handles this
|
||
# internally — traffic never actually leaves the network. The isolated
|
||
# VLAN device is treated exactly like an external user.
|
||
#
|
||
# This preserves full VLAN isolation. No pinholes, no cross-VLAN access.
|
||
# IoT = untrusted = same access as someone on the internet.
|
||
|
||
SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json")
|
||
CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services")
|
||
|
||
def _load_services() -> list:
|
||
if SERVICES_FILE.exists():
|
||
try: return _json.loads(SERVICES_FILE.read_text())
|
||
except: pass
|
||
return []
|
||
|
||
def _save_services(services: list):
|
||
SERVICES_FILE.write_text(_json.dumps(services, indent=2))
|
||
SERVICES_FILE.chmod(0o600)
|
||
|
||
|
||
def _generate_caddyfile_services(services: list) -> str:
|
||
"""Generate Caddyfile blocks for the LAN management computer's Caddy.
|
||
|
||
Each service FQDN gets a reverse_proxy block. Caddy handles TLS
|
||
termination and routes by hostname. Only port 443 needs to be
|
||
forwarded from WAN to this machine.
|
||
"""
|
||
blocks = ["# Auto-generated by switch-manager — service reverse proxy entries\n",
|
||
"# Add to your Caddyfile or use: import /etc/switch-manager/Caddyfile.services\n"]
|
||
for svc in services:
|
||
fqdn = svc.get("fqdn", "")
|
||
backend_url = svc.get("backend_url", "")
|
||
if not fqdn or not backend_url:
|
||
continue
|
||
blocks.append(f"{fqdn} {{")
|
||
blocks.append(f" reverse_proxy {backend_url}")
|
||
blocks.append(f"}}\n")
|
||
return "\n".join(blocks)
|
||
|
||
|
||
@app.get("/api/services")
|
||
def get_services():
|
||
"""List configured service proxies."""
|
||
return {"services": _load_services()}
|
||
|
||
|
||
@app.post("/api/services")
|
||
def save_service(body: dict):
|
||
"""Add or update a service proxy entry."""
|
||
require_session(body.get("token", ""))
|
||
svc = body.get("service", {})
|
||
if not svc.get("fqdn") or not svc.get("backend_url"):
|
||
raise HTTPException(400, "fqdn and backend_url required")
|
||
|
||
services = _load_services()
|
||
services = [s for s in services if s["fqdn"] != svc["fqdn"]]
|
||
services.append(svc)
|
||
_save_services(services)
|
||
return {"success": True, "services": services}
|
||
|
||
|
||
@app.delete("/api/services")
|
||
def delete_service(body: dict):
|
||
"""Remove a service proxy entry."""
|
||
require_session(body.get("token", ""))
|
||
fqdn = body.get("fqdn", "")
|
||
services = _load_services()
|
||
services = [s for s in services if s["fqdn"] != fqdn]
|
||
_save_services(services)
|
||
return {"success": True, "services": services}
|
||
|
||
|
||
SERVICES_RULE_FILE = _Path("/etc/switch-manager/service-nat-rules.json")
|
||
|
||
def _load_service_rules() -> dict:
|
||
"""Load tracked OPNsense NAT rule UUIDs for service port forwards."""
|
||
if SERVICES_RULE_FILE.exists():
|
||
try: return _json.loads(SERVICES_RULE_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_service_rules(rules: dict):
|
||
SERVICES_RULE_FILE.write_text(_json.dumps(rules, indent=2))
|
||
SERVICES_RULE_FILE.chmod(0o600)
|
||
|
||
|
||
def _get_mgmt_ip() -> str:
|
||
"""Best-effort detection of management computer LAN IP."""
|
||
import socket as _sock
|
||
try:
|
||
s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM)
|
||
s.connect(("8.8.8.8", 80))
|
||
ip = s.getsockname()[0]
|
||
s.close()
|
||
return ip
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
@app.get("/api/services/status")
|
||
def services_status():
|
||
"""Full status check: Caddy import, NAT reflection, port forward, services."""
|
||
services = _load_services()
|
||
cfg = _load_opnsense_cfg()
|
||
result = {
|
||
"services": services,
|
||
"mgmt_ip": _get_mgmt_ip(),
|
||
"caddy_file_exists": CADDYFILE_EXTRA.exists(),
|
||
"nat_reflection": None,
|
||
"port_forward_443": None,
|
||
"opnsense_configured": bool(cfg.get("key")),
|
||
"opnsense_ssh": bool(cfg.get("ssh_key_path")),
|
||
}
|
||
|
||
# Check NAT reflection
|
||
if cfg.get("ssh_key_path"):
|
||
try:
|
||
out, _, _ = _opnsense_ssh_run(cfg,
|
||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||
result["nat_reflection"] = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||
except Exception as e:
|
||
result["nat_reflection_error"] = str(e)
|
||
|
||
# Check for existing WAN port forward to 443
|
||
if cfg.get("key"):
|
||
try:
|
||
nat_rules = _opnsense_request(cfg, "firewall/source_nat/searchRule")
|
||
# OPNsense 24+ uses source_nat; older uses legacy — try both
|
||
except Exception:
|
||
nat_rules = {}
|
||
if not nat_rules:
|
||
try:
|
||
nat_rules = _opnsense_request(cfg, "firewall/filter/searchRule")
|
||
except Exception:
|
||
nat_rules = {}
|
||
# We can't reliably parse NAT rules from the filter API —
|
||
# mark as "needs verification" unless we've created one ourselves
|
||
tracked = _load_service_rules()
|
||
result["port_forward_443"] = bool(tracked.get("wan_443_uuid"))
|
||
result["tracked_rules"] = tracked
|
||
|
||
return result
|
||
|
||
|
||
@app.post("/api/services/enable-nat-reflection")
|
||
def enable_nat_reflection(body: dict):
|
||
"""Enable NAT reflection on OPNsense via SSH (modifies config.xml)."""
|
||
require_session(body.get("token", ""))
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("ssh_key_path"):
|
||
raise HTTPException(503, "OPNsense SSH not configured")
|
||
|
||
backup = _pre_change_backup(reason="pre-NAT-reflection-enable")
|
||
|
||
# OPNsense stores NAT reflection settings in /conf/config.xml under <system>
|
||
# The cleanest way is via the API if available, or configctl
|
||
try:
|
||
# Try the OPNsense API approach first (Firewall > Settings)
|
||
# The setting is under system > disablenatreflection (absent = enabled)
|
||
# and system > enablenatreflectionhelper (present = enabled)
|
||
out, err, code = _opnsense_ssh_run(cfg, (
|
||
"configctl firmware configure 2>/dev/null; "
|
||
"echo 'NAT reflection: checking current state'; "
|
||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0"
|
||
))
|
||
already_enabled = "1" in out.strip().split('\n')[-1]
|
||
if already_enabled:
|
||
return {"success": True, "already_enabled": True, "backup": backup}
|
||
|
||
# Enable via configctl / direct XML edit
|
||
# OPNsense 24+: use pluginctl or direct config edit
|
||
cmds = [
|
||
# Add enablenatreflectionhelper if not present
|
||
"sed -i '' '/<\\/system>/i\\ <enablenatreflectionhelper>1<\\/enablenatreflectionhelper>' /conf/config.xml 2>/dev/null || "
|
||
"sed -i '/<\\/system>/i\\ <enablenatreflectionhelper>1<\\/enablenatreflectionhelper>' /conf/config.xml",
|
||
# Remove disablenatreflection if present
|
||
"sed -i '' '/<disablenatreflection>/d' /conf/config.xml 2>/dev/null || "
|
||
"sed -i '/<disablenatreflection>/d' /conf/config.xml",
|
||
# Reload filter
|
||
"configctl filter reload",
|
||
]
|
||
for cmd in cmds:
|
||
_opnsense_ssh_run(cfg, cmd)
|
||
|
||
# Verify
|
||
out, _, _ = _opnsense_ssh_run(cfg,
|
||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||
enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||
|
||
return {"success": enabled, "backup": backup,
|
||
"note": "Firewall filter reloaded" if enabled else "May need manual verification"}
|
||
except Exception as e:
|
||
raise HTTPException(500, f"NAT reflection enable failed: {e}")
|
||
|
||
|
||
@app.post("/api/services/create-port-forward")
|
||
def create_wan_port_forward(body: dict):
|
||
"""
|
||
Create WAN port forward: TCP 443 → management computer (Caddy).
|
||
|
||
Uses OPNsense firewall NAT API. Only creates the rule if we haven't
|
||
already (tracked by UUID in service-nat-rules.json).
|
||
"""
|
||
require_session(body.get("token", ""))
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("key"):
|
||
raise HTTPException(503, "OPNsense API not configured")
|
||
|
||
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip())
|
||
if not mgmt_ip:
|
||
raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip")
|
||
|
||
tracked = _load_service_rules()
|
||
if tracked.get("wan_443_uuid"):
|
||
return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"],
|
||
"mgmt_ip": mgmt_ip}
|
||
|
||
backup = _pre_change_backup(reason="pre-WAN-port-forward-443")
|
||
|
||
try:
|
||
# Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443
|
||
r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1",
|
||
"interface": "wan",
|
||
"protocol": "tcp",
|
||
"source": {"any": "1"},
|
||
"destination": {"any": "1", "port": "443"},
|
||
"target": {"address": mgmt_ip, "port": "443"},
|
||
"descr": "switch-manager: WAN 443 → Caddy reverse proxy",
|
||
"nordr": "0",
|
||
}
|
||
})
|
||
uuid = r.get("uuid", "")
|
||
|
||
# If source_nat didn't work, try legacy firewall NAT API
|
||
if not uuid:
|
||
# OPNsense legacy NAT — different endpoint
|
||
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1",
|
||
"action": "pass",
|
||
"interface": "wan",
|
||
"direction": "in",
|
||
"ipprotocol": "inet",
|
||
"protocol": "tcp",
|
||
"source": {"any": "1"},
|
||
"destination": {"address": mgmt_ip, "port": "443"},
|
||
"descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)",
|
||
}
|
||
})
|
||
uuid = r.get("uuid", "")
|
||
|
||
# Apply changes
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
|
||
tracked["wan_443_uuid"] = uuid
|
||
tracked["mgmt_ip"] = mgmt_ip
|
||
_save_service_rules(tracked)
|
||
|
||
return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup,
|
||
"note": "If this is the first time, also verify in OPNsense UI: "
|
||
"Firewall > NAT > Port Forward that the rule looks correct. "
|
||
"The OPNsense NAT API varies between versions."}
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Port forward creation failed: {e}")
|
||
|
||
|
||
@app.post("/api/services/deploy")
|
||
def deploy_services(body: dict):
|
||
"""
|
||
Full deploy: write Caddyfile, reload Caddy, ensure NAT reflection
|
||
and port forward are configured on OPNsense.
|
||
|
||
Steps:
|
||
1. Pre-change backup
|
||
2. Write Caddyfile.services with reverse proxy entries
|
||
3. Reload Caddy (docker compose exec or systemctl)
|
||
4. Check/enable NAT reflection on OPNsense
|
||
5. Check/create WAN port forward 443 → Caddy
|
||
6. Return status of each step
|
||
"""
|
||
require_session(body.get("token", ""))
|
||
services = _load_services()
|
||
if not services:
|
||
raise HTTPException(400, "No services configured")
|
||
|
||
steps_done = []
|
||
errors = []
|
||
pending_steps = []
|
||
|
||
backup = _pre_change_backup(reason="pre-service-deploy")
|
||
|
||
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip())
|
||
|
||
# ── Step 1: Write Caddyfile.services ─────────────────────────────
|
||
caddy_content = _generate_caddyfile_services(services)
|
||
try:
|
||
CADDYFILE_EXTRA.write_text(caddy_content)
|
||
steps_done.append(f"Wrote Caddyfile.services ({len(services)} services)")
|
||
except Exception as e:
|
||
errors.append(f"Caddyfile write: {e}")
|
||
|
||
# ── Step 2: Reload Caddy ─────────────────────────────────────────
|
||
import subprocess as _sp
|
||
caddy_reloaded = False
|
||
# Try docker compose first
|
||
try:
|
||
r = _sp.run(["docker", "compose", "exec", "caddy", "caddy", "reload",
|
||
"--config", "/etc/caddy/Caddyfile"],
|
||
capture_output=True, text=True, timeout=15,
|
||
cwd=str(_Path(__file__).parent))
|
||
if r.returncode == 0:
|
||
steps_done.append("Caddy reloaded via docker compose")
|
||
caddy_reloaded = True
|
||
else:
|
||
# Try docker exec with container name pattern
|
||
r2 = _sp.run(["docker", "compose", "restart", "caddy"],
|
||
capture_output=True, text=True, timeout=30,
|
||
cwd=str(_Path(__file__).parent))
|
||
if r2.returncode == 0:
|
||
steps_done.append("Caddy restarted via docker compose")
|
||
caddy_reloaded = True
|
||
else:
|
||
errors.append(f"Docker caddy reload failed: {r.stderr.strip()}")
|
||
except Exception:
|
||
pass
|
||
|
||
if not caddy_reloaded:
|
||
# Try systemctl
|
||
try:
|
||
r = _sp.run(["systemctl", "reload", "caddy"],
|
||
capture_output=True, text=True, timeout=10)
|
||
if r.returncode == 0:
|
||
steps_done.append("Caddy reloaded via systemctl")
|
||
caddy_reloaded = True
|
||
except Exception:
|
||
pass
|
||
|
||
if not caddy_reloaded:
|
||
pending_steps.append(
|
||
"Reload Caddy manually: docker compose restart caddy "
|
||
"(or: systemctl reload caddy)")
|
||
|
||
# ── Step 3: NAT reflection ───────────────────────────────────────
|
||
cfg = _load_opnsense_cfg()
|
||
nat_status = None
|
||
if cfg.get("ssh_key_path"):
|
||
try:
|
||
out, _, _ = _opnsense_ssh_run(cfg,
|
||
"grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
||
nat_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||
nat_status = nat_enabled
|
||
if nat_enabled:
|
||
steps_done.append("NAT reflection: already enabled")
|
||
else:
|
||
pending_steps.append(
|
||
"Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > "
|
||
"Reflection for port forwards = Enable. "
|
||
"Or use the 'Enable NAT Reflection' button above.")
|
||
except Exception as e:
|
||
errors.append(f"NAT reflection check: {e}")
|
||
else:
|
||
pending_steps.append("Configure OPNsense SSH to auto-check NAT reflection")
|
||
|
||
# ── Step 4: WAN port forward ─────────────────────────────────────
|
||
tracked = _load_service_rules()
|
||
if tracked.get("wan_443_uuid"):
|
||
steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)")
|
||
else:
|
||
pending_steps.append(
|
||
f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
|
||
f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). "
|
||
f"Or use the 'Create Port Forward' button above.")
|
||
|
||
return {
|
||
"success": len(errors) == 0,
|
||
"steps_done": steps_done,
|
||
"pending_steps": pending_steps,
|
||
"errors": errors,
|
||
"backup": backup,
|
||
"caddy_content": caddy_content,
|
||
"mgmt_ip": mgmt_ip,
|
||
"nat_reflection_enabled": nat_status,
|
||
}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# NTFY ALERTS — push notifications for network events
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
NTFY_FILE = _Path("/etc/switch-manager/ntfy.json")
|
||
|
||
def _load_ntfy_cfg() -> dict:
|
||
if NTFY_FILE.exists():
|
||
try: return _json.loads(NTFY_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_ntfy_cfg(cfg: dict):
|
||
NTFY_FILE.write_text(_json.dumps(cfg, indent=2))
|
||
NTFY_FILE.chmod(0o600)
|
||
|
||
|
||
def _ntfy_send(title: str, message: str, priority: str = "default", tags: str = ""):
|
||
"""Send a notification via ntfy. Non-blocking, fire-and-forget."""
|
||
cfg = _load_ntfy_cfg()
|
||
url = cfg.get("url", "")
|
||
topic = cfg.get("topic", "")
|
||
if not url or not topic:
|
||
return
|
||
try:
|
||
full_url = f"{url.rstrip('/')}/{topic}"
|
||
headers = {
|
||
"Title": title,
|
||
"Priority": priority,
|
||
}
|
||
if tags:
|
||
headers["Tags"] = tags
|
||
token = cfg.get("token", "")
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
data = message.encode("utf-8")
|
||
req = _urlreq.Request(full_url, data=data, headers=headers, method="POST")
|
||
ctx = _ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = _ssl.CERT_NONE
|
||
_urlreq.urlopen(req, timeout=5, context=ctx)
|
||
log.info(f"ntfy alert sent: {title}")
|
||
except Exception as e:
|
||
log.warning(f"ntfy send failed: {e}")
|
||
|
||
|
||
@app.get("/api/alerts/config")
|
||
def get_ntfy_config():
|
||
"""Return ntfy configuration (without token)."""
|
||
cfg = _load_ntfy_cfg()
|
||
return {
|
||
"url": cfg.get("url", ""),
|
||
"topic": cfg.get("topic", ""),
|
||
"has_token": bool(cfg.get("token", "")),
|
||
"enabled": cfg.get("enabled", False),
|
||
"events": cfg.get("events", {
|
||
"connectivity_lost": True,
|
||
"backup_failed": True,
|
||
"push_failed": True,
|
||
"poe_budget_warning": True,
|
||
"port_down": False,
|
||
}),
|
||
}
|
||
|
||
|
||
@app.post("/api/alerts/config")
|
||
def save_ntfy_config(body: dict):
|
||
"""Save ntfy configuration."""
|
||
require_session(body.get("token_session", body.get("token", "")))
|
||
cfg = {
|
||
"url": body.get("url", "https://ntfy.sh"),
|
||
"topic": body.get("topic", ""),
|
||
"token": body.get("ntfy_token", ""),
|
||
"enabled": body.get("enabled", False),
|
||
"events": body.get("events", {}),
|
||
}
|
||
_save_ntfy_cfg(cfg)
|
||
return {"success": True}
|
||
|
||
|
||
@app.post("/api/alerts/test")
|
||
def test_ntfy(body: dict):
|
||
"""Send a test notification."""
|
||
require_session(body.get("token", ""))
|
||
_ntfy_send(
|
||
title="Switch Manager Test",
|
||
message="If you see this, ntfy alerts are working!",
|
||
priority="low",
|
||
tags="white_check_mark,test_tube",
|
||
)
|
||
return {"success": True}
|
||
|
||
|
||
# ── Alert integration into polling ───────────────────────────────────
|
||
|
||
_last_alert_state: dict = {}
|
||
|
||
def _check_and_alert():
|
||
"""Called from the poll loop to detect alertable conditions."""
|
||
cfg = _load_ntfy_cfg()
|
||
if not cfg.get("enabled"):
|
||
return
|
||
events = cfg.get("events", {})
|
||
global _last_alert_state
|
||
|
||
with _cache_lock:
|
||
poll_err = _cache.get("poll_error")
|
||
port_status = _cache.get("port_status", "")
|
||
poe_status = _cache.get("poe_status", "")
|
||
|
||
# Connectivity lost
|
||
if events.get("connectivity_lost") and poll_err:
|
||
if not _last_alert_state.get("conn_lost"):
|
||
_ntfy_send("Switch Offline", f"Cannot reach switch: {poll_err}",
|
||
priority="urgent", tags="rotating_light,warning")
|
||
_last_alert_state["conn_lost"] = True
|
||
else:
|
||
if _last_alert_state.get("conn_lost"):
|
||
_ntfy_send("Switch Back Online", "Connectivity restored",
|
||
priority="default", tags="white_check_mark")
|
||
_last_alert_state["conn_lost"] = False
|
||
|
||
# PoE budget warning (parse from poe_status if available)
|
||
if events.get("poe_budget_warning") and poe_status:
|
||
import re as _re_alert
|
||
watts_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed)', poe_status)
|
||
budget_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum)', poe_status)
|
||
if watts_match and budget_match:
|
||
try:
|
||
used = int(watts_match[0][0])
|
||
budget = int(budget_match[0][0])
|
||
pct = (used / budget * 100) if budget > 0 else 0
|
||
if pct > 85 and not _last_alert_state.get("poe_warn"):
|
||
_ntfy_send("PoE Budget Warning",
|
||
f"PoE usage at {pct:.0f}% ({used}W / {budget}W)",
|
||
priority="high", tags="zap,warning")
|
||
_last_alert_state["poe_warn"] = True
|
||
elif pct <= 80:
|
||
_last_alert_state["poe_warn"] = False
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# SCHEDULED OPERATIONS — cron-like scheduler for backups and VLAN ops
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
SCHEDULES_FILE = _Path("/etc/switch-manager/schedules.json")
|
||
_scheduler_thread = None
|
||
|
||
def _load_schedules() -> list:
|
||
if SCHEDULES_FILE.exists():
|
||
try: return _json.loads(SCHEDULES_FILE.read_text())
|
||
except: pass
|
||
return []
|
||
|
||
def _save_schedules(schedules: list):
|
||
SCHEDULES_FILE.write_text(_json.dumps(schedules, indent=2))
|
||
SCHEDULES_FILE.chmod(0o600)
|
||
|
||
|
||
def _should_run_now(schedule: dict) -> bool:
|
||
"""Check if a schedule should run based on current time and its cron-like fields."""
|
||
now = _dt.datetime.now()
|
||
hour = schedule.get("hour", "*")
|
||
minute = schedule.get("minute", "0")
|
||
days = schedule.get("days", "*") # "mon,tue,wed" or "*"
|
||
|
||
if hour != "*" and now.hour != int(hour):
|
||
return False
|
||
if minute != "*" and now.minute != int(minute):
|
||
return False
|
||
if days != "*":
|
||
day_names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||
today = day_names[now.weekday()]
|
||
if today not in days.lower().split(","):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _run_scheduled_task(schedule: dict):
|
||
"""Execute a scheduled task."""
|
||
action = schedule.get("action", "")
|
||
name = schedule.get("name", "unnamed")
|
||
log.info(f"Scheduler: running '{name}' (action={action})")
|
||
|
||
try:
|
||
if action == "backup":
|
||
device = schedule.get("device", "both")
|
||
result = {}
|
||
if device in ("switch", "both"):
|
||
result["switch"] = _switch_backup(reason=f"scheduled: {name}")
|
||
if device in ("opnsense", "both"):
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key"):
|
||
result["opnsense"] = _opnsense_backup(cfg, reason=f"scheduled: {name}")
|
||
log.info(f"Scheduled backup '{name}': {result}")
|
||
_ntfy_send(f"Scheduled Backup: {name}",
|
||
f"Switch: {'OK' if result.get('switch',{}).get('ok') else 'FAIL'}, "
|
||
f"OPNsense: {'OK' if result.get('opnsense',{}).get('ok') else 'N/A'}",
|
||
tags="floppy_disk")
|
||
|
||
elif action == "connectivity_check":
|
||
conn = _check_connectivity()
|
||
if not conn["switch"]["ok"]:
|
||
_ntfy_send("Scheduled Check: Switch Offline",
|
||
f"Switch unreachable: {conn['switch'].get('error','')}",
|
||
priority="urgent", tags="rotating_light")
|
||
|
||
elif action == "vlan_enable":
|
||
# Re-enable a VLAN's internet access on OPNsense by adding allow-out rule
|
||
vlan_id = schedule.get("vlan_id")
|
||
vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
|
||
cfg = _load_opnsense_cfg()
|
||
vmap = _load_vlan_if_map()
|
||
iface = vmap.get(str(vlan_id), "")
|
||
if cfg.get("key") and iface:
|
||
try:
|
||
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"interface": iface, "direction": "in",
|
||
"ipprotocol": "inet", "protocol": "any",
|
||
"source": {"network": f"{iface}net"},
|
||
"destination": {"any": "1"},
|
||
"descr": f"Scheduled: allow {vlan_name} outbound",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
# Track the rule UUID for later disable
|
||
_vlan_schedule_rules = _load_vlan_schedule_rules()
|
||
_vlan_schedule_rules[str(vlan_id)] = r.get("uuid", "")
|
||
_save_vlan_schedule_rules(_vlan_schedule_rules)
|
||
log.info(f"Scheduled VLAN enable: {vlan_name} ({vlan_id})")
|
||
_ntfy_send(f"VLAN Enabled: {vlan_name}",
|
||
f"Internet access restored for {vlan_name} (scheduled)",
|
||
tags="white_check_mark,globe_with_meridians")
|
||
except Exception as e:
|
||
log.warning(f"VLAN enable failed: {e}")
|
||
_ntfy_send(f"VLAN Enable Failed: {vlan_name}", str(e),
|
||
priority="high", tags="x")
|
||
|
||
elif action == "vlan_disable":
|
||
# Disable a VLAN's internet access by removing allow-out rule
|
||
vlan_id = schedule.get("vlan_id")
|
||
vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key"):
|
||
_vlan_schedule_rules = _load_vlan_schedule_rules()
|
||
uuid = _vlan_schedule_rules.get(str(vlan_id), "")
|
||
if uuid:
|
||
try:
|
||
_opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
_vlan_schedule_rules.pop(str(vlan_id), None)
|
||
_save_vlan_schedule_rules(_vlan_schedule_rules)
|
||
log.info(f"Scheduled VLAN disable: {vlan_name} ({vlan_id})")
|
||
_ntfy_send(f"VLAN Disabled: {vlan_name}",
|
||
f"Internet access blocked for {vlan_name} (scheduled)",
|
||
tags="no_entry,moon")
|
||
except Exception as e:
|
||
log.warning(f"VLAN disable failed: {e}")
|
||
_ntfy_send(f"VLAN Disable Failed: {vlan_name}", str(e),
|
||
priority="high", tags="x")
|
||
else:
|
||
# No tracked rule — try to find and disable by description
|
||
log.warning(f"No tracked rule UUID for VLAN {vlan_id} — "
|
||
f"block rule must be added manually or via firewall policy")
|
||
|
||
except Exception as e:
|
||
log.warning(f"Scheduled task '{name}' failed: {e}")
|
||
_ntfy_send(f"Scheduled Task Failed: {name}", str(e),
|
||
priority="high", tags="x")
|
||
|
||
|
||
# VLAN schedule rule tracking (which firewall rules we created for enable/disable)
|
||
VLAN_SCHED_RULES_FILE = _Path("/etc/switch-manager/vlan-schedule-rules.json")
|
||
|
||
def _load_vlan_schedule_rules() -> dict:
|
||
if VLAN_SCHED_RULES_FILE.exists():
|
||
try: return _json.loads(VLAN_SCHED_RULES_FILE.read_text())
|
||
except: pass
|
||
return {}
|
||
|
||
def _save_vlan_schedule_rules(rules: dict):
|
||
VLAN_SCHED_RULES_FILE.write_text(_json.dumps(rules, indent=2))
|
||
VLAN_SCHED_RULES_FILE.chmod(0o600)
|
||
|
||
|
||
def _scheduler_loop():
|
||
"""Background thread: check schedules every 60 seconds."""
|
||
log.info("Scheduler thread started")
|
||
last_runs: dict[str, str] = {} # {schedule_name: "YYYYMMDD-HHMM"}
|
||
while True:
|
||
time.sleep(60)
|
||
schedules = _load_schedules()
|
||
now_key = _dt.datetime.now().strftime("%Y%m%d-%H%M")
|
||
for sched in schedules:
|
||
if not sched.get("enabled", True):
|
||
continue
|
||
name = sched.get("name", "")
|
||
# Don't run the same schedule twice in the same minute
|
||
if last_runs.get(name) == now_key:
|
||
continue
|
||
if _should_run_now(sched):
|
||
last_runs[name] = now_key
|
||
try:
|
||
_run_scheduled_task(sched)
|
||
except Exception as e:
|
||
log.warning(f"Scheduler error for '{name}': {e}")
|
||
|
||
|
||
def start_scheduler():
|
||
global _scheduler_thread
|
||
if _scheduler_thread is None or not _scheduler_thread.is_alive():
|
||
_scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler")
|
||
_scheduler_thread.start()
|
||
|
||
|
||
# Start scheduler on import (alongside poller)
|
||
start_scheduler()
|
||
|
||
|
||
@app.get("/api/schedules")
|
||
def get_schedules():
|
||
return {"schedules": _load_schedules()}
|
||
|
||
|
||
@app.post("/api/schedules")
|
||
def save_schedule(body: dict):
|
||
require_session(body.get("token", ""))
|
||
sched = body.get("schedule", {})
|
||
if not sched.get("name") or not sched.get("action"):
|
||
raise HTTPException(400, "name and action required")
|
||
|
||
schedules = _load_schedules()
|
||
schedules = [s for s in schedules if s["name"] != sched["name"]]
|
||
schedules.append(sched)
|
||
_save_schedules(schedules)
|
||
return {"success": True, "schedules": schedules}
|
||
|
||
|
||
@app.delete("/api/schedules")
|
||
def delete_schedule(body: dict):
|
||
require_session(body.get("token", ""))
|
||
name = body.get("name", "")
|
||
schedules = _load_schedules()
|
||
schedules = [s for s in schedules if s["name"] != name]
|
||
_save_schedules(schedules)
|
||
return {"success": True, "schedules": schedules}
|
||
|
||
|
||
@app.post("/api/schedules/run-now")
|
||
def run_schedule_now(body: dict):
|
||
"""Manually trigger a scheduled task immediately."""
|
||
require_session(body.get("token", ""))
|
||
name = body.get("name", "")
|
||
schedules = _load_schedules()
|
||
sched = next((s for s in schedules if s["name"] == name), None)
|
||
if not sched:
|
||
raise HTTPException(404, f"Schedule '{name}' not found")
|
||
_run_scheduled_task(sched)
|
||
return {"success": True, "ran": name}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# PORT FORWARDING — manage OPNsense NAT port forwards
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
PORT_FWD_FILE = _Path("/etc/switch-manager/port-forwards.json")
|
||
|
||
def _load_port_forwards() -> list:
|
||
if PORT_FWD_FILE.exists():
|
||
try: return _json.loads(PORT_FWD_FILE.read_text())
|
||
except: pass
|
||
return []
|
||
|
||
def _save_port_forwards(fwds: list):
|
||
PORT_FWD_FILE.write_text(_json.dumps(fwds, indent=2))
|
||
PORT_FWD_FILE.chmod(0o600)
|
||
|
||
|
||
@app.get("/api/port-forwards")
|
||
def get_port_forwards():
|
||
return {"forwards": _load_port_forwards()}
|
||
|
||
|
||
@app.post("/api/port-forwards")
|
||
def create_port_forward(body: dict):
|
||
"""Create a NAT port forward on OPNsense + companion firewall rule."""
|
||
require_session(body.get("token", ""))
|
||
fwd = body.get("forward", {})
|
||
proto = fwd.get("proto", "tcp")
|
||
wan_port = fwd.get("wan_port", "")
|
||
target_ip = fwd.get("target_ip", "")
|
||
target_port = fwd.get("target_port", wan_port)
|
||
description = fwd.get("description", "")
|
||
|
||
if not wan_port or not target_ip:
|
||
raise HTTPException(400, "wan_port and target_ip required")
|
||
|
||
cfg = _load_opnsense_cfg()
|
||
if not cfg.get("key"):
|
||
raise HTTPException(503, "OPNsense API not configured")
|
||
|
||
backup = _pre_change_backup(reason=f"pre-port-forward {proto}/{wan_port}→{target_ip}:{target_port}")
|
||
|
||
try:
|
||
# Create firewall pass rule for the forwarded traffic
|
||
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||
"rule": {
|
||
"enabled": "1", "action": "pass",
|
||
"interface": "wan", "direction": "in",
|
||
"ipprotocol": "inet", "protocol": proto,
|
||
"source": {"any": "1"},
|
||
"destination": {"address": target_ip, "port": str(target_port)},
|
||
"descr": f"Port forward: WAN {proto}/{wan_port} → {target_ip}:{target_port}"
|
||
f"{' — ' + description if description else ''}",
|
||
}
|
||
})
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
uuid = r.get("uuid", "")
|
||
|
||
fwd_entry = {
|
||
"proto": proto, "wan_port": wan_port,
|
||
"target_ip": target_ip, "target_port": target_port,
|
||
"description": description, "uuid": uuid,
|
||
"created_at": _ts(),
|
||
}
|
||
fwds = _load_port_forwards()
|
||
fwds.append(fwd_entry)
|
||
_save_port_forwards(fwds)
|
||
|
||
return {"success": True, "forward": fwd_entry, "backup": backup,
|
||
"note": "Firewall rule created. Also verify NAT port forward exists: "
|
||
f"OPNsense > Firewall > NAT > Port Forward — WAN {proto} {wan_port} → {target_ip}:{target_port}"}
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Port forward creation failed: {e}")
|
||
|
||
|
||
@app.delete("/api/port-forwards")
|
||
def delete_port_forward(body: dict):
|
||
"""Remove a port forward and its firewall rule."""
|
||
require_session(body.get("token", ""))
|
||
uuid = body.get("uuid", "")
|
||
if not uuid:
|
||
raise HTTPException(400, "uuid required")
|
||
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("key") and uuid:
|
||
try:
|
||
_opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
|
||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||
except Exception as e:
|
||
log.warning(f"Port forward rule delete failed: {e}")
|
||
|
||
fwds = _load_port_forwards()
|
||
fwds = [f for f in fwds if f.get("uuid") != uuid]
|
||
_save_port_forwards(fwds)
|
||
return {"success": True}
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# NETWORK TOPOLOGY — auto-generated from live data
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
@app.get("/api/topology")
|
||
def get_topology():
|
||
"""Build network topology from live switch + OPNsense data."""
|
||
topology = {
|
||
"router": {"ip": "", "hostname": "OPNsense", "connected": False},
|
||
"switch": {"ip": SWITCH_HOST, "hostname": "ERS-5952", "connected": False},
|
||
"vlans": [],
|
||
"ports": [],
|
||
"devices": [],
|
||
}
|
||
|
||
# Switch connectivity
|
||
try:
|
||
conn = _pool.get()
|
||
transport = conn.get_transport()
|
||
if transport and transport.is_active():
|
||
topology["switch"]["connected"] = True
|
||
except Exception:
|
||
pass
|
||
|
||
# OPNsense
|
||
cfg = _load_opnsense_cfg()
|
||
if cfg.get("host"):
|
||
topology["router"]["ip"] = cfg["host"]
|
||
try:
|
||
fw = _opnsense_request(cfg, "core/firmware/status")
|
||
topology["router"]["connected"] = True
|
||
topology["router"]["version"] = fw.get("product_version", "")
|
||
except Exception:
|
||
pass
|
||
|
||
# VLANs from cache
|
||
with _cache_lock:
|
||
vlan_raw = _cache.get("vlan_members", "")
|
||
port_raw = _cache.get("port_status", "")
|
||
|
||
# Parse port status for link state
|
||
if port_raw:
|
||
for line in port_raw.splitlines():
|
||
import re as _re_topo
|
||
m = _re_topo.match(r'\s*(\d+)\s+(\S+)\s+(\S+)\s+(\S+)', line)
|
||
if m:
|
||
port_id = int(m.group(1))
|
||
link = m.group(3).lower()
|
||
topology["ports"].append({
|
||
"id": port_id,
|
||
"link": "up" if "up" in link else "down",
|
||
})
|
||
|
||
# Devices from saved list
|
||
try:
|
||
topology["devices"] = _load_devices()[:50] # Cap at 50
|
||
except Exception:
|
||
pass
|
||
|
||
# VLAN info
|
||
vmap = _load_vlan_if_map()
|
||
topology["vlan_interface_map"] = vmap
|
||
|
||
return topology
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
# POE BUDGET DASHBOARD — power consumption overview
|
||
# ══════════════════════════════════════════════════════════════════════
|
||
|
||
@app.get("/api/poe/budget")
|
||
def poe_budget():
|
||
"""Parse PoE status from cached switch data."""
|
||
with _cache_lock:
|
||
poe_raw = _cache.get("poe_status", "")
|
||
|
||
if not poe_raw:
|
||
return {"available": False, "error": "No PoE data cached — switch may be offline"}
|
||
|
||
import re as _re_poe
|
||
result = {
|
||
"available": True,
|
||
"raw": poe_raw[:2000],
|
||
"total_watts": None,
|
||
"used_watts": None,
|
||
"remaining_watts": None,
|
||
"percent_used": None,
|
||
"ports": [],
|
||
}
|
||
|
||
# Parse total/used from various ERS output formats
|
||
budget_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum|total)', poe_raw, _re_poe.I)
|
||
used_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed|delivering)', poe_raw, _re_poe.I)
|
||
if budget_m:
|
||
result["total_watts"] = int(budget_m[0][0])
|
||
if used_m:
|
||
result["used_watts"] = int(used_m[0][0])
|
||
if result["total_watts"] and result["used_watts"]:
|
||
result["remaining_watts"] = result["total_watts"] - result["used_watts"]
|
||
result["percent_used"] = round(result["used_watts"] / result["total_watts"] * 100, 1)
|
||
|
||
# Parse per-port PoE
|
||
for line in poe_raw.splitlines():
|
||
pm = _re_poe.match(
|
||
r'\s*(\d+)\s+\S+\s+(\S+)\s+\S+\s+(\d+(?:\.\d+)?)\s*[Ww]', line)
|
||
if pm:
|
||
result["ports"].append({
|
||
"port": int(pm.group(1)),
|
||
"status": pm.group(2),
|
||
"watts": float(pm.group(3)),
|
||
})
|
||
|
||
return result
|