Files
avaya_switch_management/Avaya_5952_setup.py
T
Claude d9b6d05862 Add OPNsense SSH shell access and Unbound management endpoints
Paramiko exec_command() bypasses the OPNsense console menu automatically
(menu only appears for interactive logins) so no human needs to press 8.

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

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

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
2026-03-24 18:43:02 +00:00

1702 lines
456 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Avaya / Extreme Avaya / Extreme ERS 5952 Switch Manager — One File Setup
─────────────────────────────────────────
Copy this single file to your always on management computer
(Raspberry Pi, HP T620 thin client, old computer or laptop) and run:
python Avaya_5952_setup.py
It will:
- Install Python dependencies
- Generate SSH keypair
- Guide you through loading the key on the switch (one manual step)
- Test SSH connectivity
- Pin the switch host key
- Set up TOTP authenticator
- Write switch_backend.py and build the React frontend
- Install and start the systemd service
- Open the browser
Re-running is safe — skips steps already done.
─────────────────────────────────────────
"""
import os, sys, time, shutil, subprocess, textwrap, socket
from pathlib import Path
# ── Colour helpers ──────────────────────────────────────────────────────────
def c(t, code): return f"\033[{code}m{t}\033[0m"
def green(t): return c(t, "32")
def yellow(t): return c(t, "33")
def red(t): return c(t, "31")
def cyan(t): return c(t, "36")
def bold(t): return c(t, "1")
def dim(t): return c(t, "2")
def ok(m): print(f" {green(chr(10003))} {m}")
def warn(m): print(f" {yellow(chr(9888))} {m}")
def err(m): print(f" {red(chr(10007))} {m}")
def info(m): print(f" {cyan(chr(9672))} {m}")
def step(n,m): print(f"\n{bold(cyan(f'[{n}]'))} {bold(m)}")
def sep(): print(dim(" " + chr(8212)*58))
def ask(prompt, default=""):
try:
v = input(f" {cyan(chr(63))} {prompt}" + (f" [{default}]" if default else "") + ": ").strip()
return v if v else default
except KeyboardInterrupt:
print("\n\nAborted."); sys.exit(0)
def ask_yn(prompt, default=True):
v = ask(f"{prompt} ({'Y/n' if default else 'y/N'})", "").lower()
return default if not v else v.startswith("y")
def pause(m="Press Enter to continue..."):
try: input(f"\n {yellow(chr(9654))} {m}")
except KeyboardInterrupt: print("\n\nAborted."); sys.exit(0)
def sudo(cmd):
prefix = "sudo " if os.geteuid() != 0 else ""
return subprocess.run(f"{prefix}{cmd}", shell=True, capture_output=True, text=True)
def run(cmd, capture=True, check=False):
return subprocess.run(cmd, shell=True, capture_output=capture, text=True, check=check)
def local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]; s.close(); return ip
except: return "localhost"
# ── Paths ────────────────────────────────────────────────────────────────────
HERE = Path(__file__).parent.absolute()
CONF_DIR = Path("/etc/switch-manager")
KEY_PATH = CONF_DIR / "ers5952_key"
KNOWN_HOSTS = CONF_DIR / "known_hosts"
TOTP_FILE = CONF_DIR / "totp_secret"
BACKEND = HERE / "switch_backend.py"
FRONTEND = HERE / "frontend"
DIST = FRONTEND / "dist"
APP_JSX = HERE / "ers5952-manager.jsx"
SERVICE = Path("/etc/systemd/system/switch-manager.service")
PACKAGES = ["fastapi", "uvicorn", "paramiko", "pyotp"]
# ── Console software download links ───────────────────────────────────────────
CONSOLE_SOFTWARE = {
"PuTTY (Windows)": "https://www.putty.org",
"TeraTerm (Windows)": "https://github.com/TeraTermProject/teraterm/releases",
"Serial (Mac)": "https://apps.apple.com/app/serial/id877615577",
"CoolTerm (Mac/Win)": "https://freeware.the-meiers.org",
"CP2102 USB driver": "https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers",
"FTDI USB driver": "https://ftdichip.com/drivers/vcp-drivers/",
}
CONSOLE_CABLE_SEARCH = "RJ45 console cable USB Cisco compatible"
BACKEND_SRC = '"""\nERS 59100GTS-PWR+ Switch Manager — Backend v3\n────────────────────────────────────────────────────────────────────────\nChanges from v2:\n - Connection pool (30s lifetime, liveness check, auto-invalidate)\n - Visitor-aware polling — starts when someone hits the page,\n pauses when no active visitors, stops completely when idle\n - Hard-block danger patterns (no force override for lethal commands)\n - Session-based TOTP (one auth → session token → many pushes)\n - Per-command execution with stop-on-error\n - Config saved only on full success\n\nRequirements:\n pip install fastapi uvicorn paramiko pyotp\n\nRun:\n python switch_backend.py # start server\n python switch_backend.py --setup-totp # first-time TOTP setup\n────────────────────────────────────────────────────────────────────────\n"""\n\nimport re, sys, time, secrets, logging, threading, os\nfrom pathlib import Path\nfrom typing import Optional\n\nimport paramiko, pyotp\nfrom fastapi import FastAPI, HTTPException, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.responses import JSONResponse\nfrom fastapi.staticfiles import StaticFiles\nfrom pydantic import BaseModel, field_validator\n\nlogging.basicConfig(level=logging.INFO,\n format="%(asctime)s [%(levelname)s] %(message)s")\nlog = logging.getLogger("switch-manager")\n\n# ══════════════════════════════════════════════════════════════════════\n# CONFIG\n# ══════════════════════════════════════════════════════════════════════\nSWITCH_HOST = "192.168.99.1"\nSWITCH_PORT = 22\nSWITCH_USER = "admin"\nKEY_PATH = "/etc/switch-manager/ers59100_key"\nKNOWN_HOSTS = "/etc/switch-manager/known_hosts"\nTOTP_FILE = "/etc/switch-manager/totp_secret"\nSTATIC_DIR = "./frontend/dist"\n\nPOLL_ACTIVE_S = 15 # poll interval when visitors present\nPOLL_BG_S = 60 # poll interval when tab backgrounded\nPOLL_IDLE_AFTER = 300 # stop polling after this many seconds with no visitors\nCONN_POOL_MAX_S = 25 # max connection age before refresh (< switch idle timeout)\nSESSION_TTL_S = 600 # TOTP session — 10 minutes, reset on activity\n\nALLOWED_ORIGINS = ["*"] # tighten to http://192.168.99.X in production\n\n# ══════════════════════════════════════════════════════════════════════\n# TOTP\n# ══════════════════════════════════════════════════════════════════════\n\ndef get_or_create_totp_secret() -> str:\n p = Path(TOTP_FILE)\n if p.exists():\n return p.read_text().strip()\n secret = pyotp.random_base32()\n p.parent.mkdir(parents=True, exist_ok=True)\n p.write_text(secret)\n p.chmod(0o600)\n log.info(f"New TOTP secret created at {TOTP_FILE}")\n return secret\n\ndef setup_totp():\n secret = get_or_create_totp_secret()\n uri = pyotp.TOTP(secret).provisioning_uri(\n name="ERS59100", issuer_name="SwitchManager")\n print("\\n══════════════════════════════════════════════════")\n print(" ERS 59100GTS-PWR+ Switch Manager — TOTP Setup")\n print("══════════════════════════════════════════════════")\n print(f"\\n Manual entry secret:\\n {secret}")\n print(f"\\n Provisioning URI (paste into authenticator app):\\n {uri}")\n print("\\n Or generate a QR code:")\n print(f" python -c \\"import qrcode; qrcode.make(\'{uri}\').show()\\"")\n print("\\n══════════════════════════════════════════════════\\n")\n\nTOTP_SECRET: str = ""\n\n# ── Session tokens (one TOTP → session, multiple pushes) ──────────────\n# { token: { expires: float, last_activity: float } }\n_sessions: dict[str, dict] = {}\n_sessions_lock = threading.Lock()\n\ndef create_session() -> str:\n token = secrets.token_hex(32)\n now = time.time()\n with _sessions_lock:\n _sessions[token] = {"expires": now + SESSION_TTL_S, "last_activity": now}\n return token\n\ndef validate_session(token: str) -> bool:\n """Returns True and refreshes activity timestamp if session is valid."""\n now = time.time()\n with _sessions_lock:\n # Prune expired\n expired = [t for t, s in _sessions.items() if s["expires"] < now]\n for t in expired:\n del _sessions[t]\n if token not in _sessions:\n return False\n # Refresh on activity\n _sessions[token]["last_activity"] = now\n _sessions[token]["expires"] = now + SESSION_TTL_S\n return True\n\ndef session_remaining(token: str) -> Optional[int]:\n with _sessions_lock:\n s = _sessions.get(token)\n if not s:\n return None\n return max(0, int(s["expires"] - time.time()))\n\ndef revoke_session(token: str):\n with _sessions_lock:\n _sessions.pop(token, None)\n\ndef require_session(token: str):\n if not validate_session(token):\n raise HTTPException(401, "Session expired or invalid — re-authenticate with TOTP")\n\n# ══════════════════════════════════════════════════════════════════════\n# DANGER DETECTION\n# Two tiers:\n# HARD_BLOCK — refused entirely, must run at console\n# WARN — flagged, push still offered with explicit override\n# ══════════════════════════════════════════════════════════════════════\n\nHARD_BLOCK_PATTERNS = [\n # Management VLAN removal — all variants\n (re.compile(r\'no\\s+vlan\\s+99\\b\', re.I), "Deletes management VLAN 99"),\n (re.compile(r\'vlan\\s+members\\s+remove\\b.*\\b99\\b\', re.I), "Removes VLAN 99 from a port — kills management trunk"),\n (re.compile(r\'no\\s+vlan\\s+tagging\\b.*\\b99\\b\', re.I), "Removes VLAN 99 tagging — kills management trunk"),\n (re.compile(r\'vlan\\s+pvid\\s+\\S+\\s+\\S+\', re.I), "Changes native VLAN — verify not management port"),\n # SSH / IP removal\n (re.compile(r\'no\\s+ip\\s+ssh\', re.I), "Disables SSH entirely — permanent lockout"),\n (re.compile(r\'no\\s+ip\\s+address\\b\', re.I), "Removes IP address — will lose connectivity"),\n # Management interface\n (re.compile(r\'interface\\s+vlan\\s+99\\b\', re.I), "Modifies management VLAN interface — run at console"),\n # Boot / factory\n (re.compile(r\'boot\\s+config\\s+flags\\s+factory\', re.I), "Factory reset — run at console"),\n]\n\nWARN_PATTERNS = [\n (re.compile(r\'\\bshutdown\\b\', re.I), "Shuts down an interface — confirm it is not your uplink"),\n (re.compile(r\'default\\s+interface\\b\', re.I), "Resets interface to defaults"),\n (re.compile(r\'no\\s+vlan\\s+\\d+\\b\', re.I), "Deletes a VLAN — confirm no active ports depend on it"),\n (re.compile(r\'spanning-tree\\s+.*\\s+disable\', re.I), "Disables spanning tree — loop risk"),\n]\n\ndef check_danger(commands: list[str]) -> dict:\n """\n Scan a list of CLI commands for dangerous patterns.\n\n Returns a dict with:\n hard_blocked — commands that are refused entirely (e.g. no vlan 99, no ip ssh)\n warnings — commands that are allowed but flagged (e.g. shutdown)\n has_hard_block, has_warnings — convenience booleans\n """\n hard, warn = [], []\n for cmd in commands:\n for pat, reason in HARD_BLOCK_PATTERNS:\n if pat.search(cmd):\n hard.append({"command": cmd, "reason": reason})\n break\n else:\n for pat, reason in WARN_PATTERNS:\n if pat.search(cmd):\n warn.append({"command": cmd, "reason": reason})\n break\n return {\n "hard_blocked": hard,\n "warnings": warn,\n "has_hard_block": bool(hard),\n "has_warnings": bool(warn),\n }\n\n# ══════════════════════════════════════════════════════════════════════\n# SANITIZATION\n# ══════════════════════════════════════════════════════════════════════\n\n_BAD_CHARS = re.compile(r\'[;&|`$<>(){}\\\\"\\\']\')\n_BAD_PATS = [re.compile(p) for p in [r\'\\n\', r\'\\r\', r\'--\', r\'/\\*\']]\n_RE_VID = re.compile(r\'^\\d{1,4}$\')\n_RE_VNAME = re.compile(r\'^[a-zA-Z0-9\\-_]{1,32}$\')\n_RE_MODE = re.compile(r\'^(access|trunk|disabled)$\')\n_RE_ANAME = re.compile(r\'^[a-zA-Z0-9\\-_]{1,32}$\')\n_RE_DIR = re.compile(r\'^(in|out)$\')\n_RE_PROTO = re.compile(r\'^(ip|tcp|udp|icmp)$\')\n_RE_ACTION = re.compile(r\'^(permit|deny)$\')\n_RE_DESC = re.compile(r\'^[a-zA-Z0-9\\-_ ]{0,64}$\')\n_RE_IP = re.compile(r\'^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$\')\n\ndef _san(v: str, pat: re.Pattern, field: str) -> str:\n """Reject shell-injection characters and check value against an allow-list regex."""\n if _BAD_CHARS.search(v):\n raise ValueError(f"{field}: disallowed characters")\n for p in _BAD_PATS:\n if p.search(v):\n raise ValueError(f"{field}: injection pattern")\n if not pat.match(v):\n raise ValueError(f"{field}: invalid format — {repr(v)}")\n return v\n\ndef san_vid(v, field="vlan_id") -> int:\n """Validate and return a VLAN ID integer (14094)."""\n _san(str(v), _RE_VID, field)\n vid = int(v)\n if not 1 <= vid <= 4094:\n raise ValueError(f"{field}: must be 14094")\n return vid\n\ndef san_port(v) -> int:\n """Validate and return a port number (1100 for the ERS 59100GTS-PWR+)."""\n p = int(v)\n if not 1 <= p <= 100:\n raise ValueError("port: must be 1100")\n return p\n\n_ALLOWED_CMD_RE = [\n re.compile(r\'^vlan\\s+(create|members|tagging|pvid)\\s\'),\n re.compile(r\'^no\\s+vlan\\s+\\d+$\'),\n re.compile(r\'^interface\\s+(GigabitEthernet\\s+1/\\d+|vlan\\s+\\d+)$\'),\n re.compile(r\'^\\s+(name|no\\s+shutdown|poe|speed|duplex|ip\\s+access-group|shutdown)\\b\'),\n re.compile(r\'^hostname\\s+\\S+$\'),\n re.compile(r\'^ip\\s+access-list\\s+extended\\s\'),\n re.compile(r\'^\\s+\\d+\\s+(permit|deny)\\s\'),\n re.compile(r\'^!\\s*\'), re.compile(r\'^\\s*$\'),\n]\n\ndef is_allowed(cmd: str) -> bool:\n """Return True if cmd matches the CLI allow-list (whitelist of safe command patterns)."""\n return any(p.match(cmd) for p in _ALLOWED_CMD_RE)\n\n# ══════════════════════════════════════════════════════════════════════\n# CONNECTION POOL\n# ══════════════════════════════════════════════════════════════════════\n\nclass SwitchConnectionPool:\n """\n Keeps one SSH connection alive for up to CONN_POOL_MAX_S seconds.\n On liveness check failure or expiry → opens a fresh connection.\n Thread-safe.\n """\n def __init__(self):\n self._conn: Optional[paramiko.SSHClient] = None\n self._born: float = 0\n self._lock = threading.Lock()\n\n def get(self) -> paramiko.SSHClient:\n with self._lock:\n now = time.time()\n age = now - self._born\n if self._conn and age < CONN_POOL_MAX_S:\n try:\n transport = self._conn.get_transport()\n if transport and transport.is_active():\n transport.send_ignore() # lightweight liveness ping\n return self._conn\n except Exception:\n log.info("Pool: liveness check failed — opening fresh connection")\n self._close_unsafe()\n self._conn = _open_connection()\n self._born = time.time()\n log.info("Pool: new connection opened")\n return self._conn\n\n def invalidate(self):\n with self._lock:\n self._close_unsafe()\n\n def _close_unsafe(self):\n if self._conn:\n try:\n self._conn.close()\n except Exception:\n pass\n self._conn = None\n self._born = 0\n\n_pool = SwitchConnectionPool()\n\n\ndef _open_connection() -> paramiko.SSHClient:\n client = paramiko.SSHClient()\n known = Path(KNOWN_HOSTS)\n if known.exists():\n client.load_host_keys(str(known))\n client.set_missing_host_key_policy(paramiko.RejectPolicy())\n else:\n log.warning("No known_hosts — trust on first use")\n client.set_missing_host_key_policy(paramiko.AutoAddPolicy())\n try:\n client.connect(\n hostname=SWITCH_HOST, port=SWITCH_PORT, username=SWITCH_USER,\n key_filename=KEY_PATH, look_for_keys=False, allow_agent=False,\n timeout=10, banner_timeout=10,\n )\n except paramiko.AuthenticationException:\n raise HTTPException(503, "SSH auth failed — is the public key loaded on the switch?")\n except Exception as e:\n raise HTTPException(503, f"Cannot reach switch at {SWITCH_HOST}: {e}")\n if not known.exists():\n known.parent.mkdir(parents=True, exist_ok=True)\n client.save_host_keys(str(known))\n log.info("Host key pinned to known_hosts")\n return client\n\n\ndef read_cmd(cmd: str) -> str:\n """Run a read-only command in enable mode via interactive shell channel."""\n try:\n conn = _pool.get()\n ch = conn.invoke_shell()\n ch.settimeout(10)\n time.sleep(0.4)\n if ch.recv_ready():\n ch.recv(4096) # drain login banner\n for setup in ["terminal length 0", "enable"]:\n ch.send(setup + "\\n")\n time.sleep(0.3)\n if ch.recv_ready():\n ch.recv(4096) # drain prompt output\n ch.send(cmd + "\\n")\n out = ""\n deadline = time.time() + 8\n while time.time() < deadline:\n if ch.recv_ready():\n out += ch.recv(4096).decode("utf-8", errors="replace")\n time.sleep(0.1)\n else:\n if out:\n break\n time.sleep(0.1)\n ch.close()\n return out\n except HTTPException:\n raise\n except Exception as e:\n log.warning(f"read_cmd failed ({cmd}): {e} — invalidating pool")\n _pool.invalidate()\n raise HTTPException(503, f"Switch read failed: {e}")\n\n\n# ── Per-command interactive push ───────────────────────────────────────\n\n_SWITCH_ERR = re.compile(\n r\'%\\s*(invalid|error|unknown|bad|failed|cannot|not\\s+found|does\\s+not\\s+exist\'\n r\'|incomplete|ambiguous|out\\s+of\\s+range|already\\s+exists)\',\n re.I\n)\n\ndef _run_one(channel, cmd: str) -> tuple[str, bool]:\n channel.send(cmd + "\\n")\n time.sleep(0.35)\n out, deadline = "", time.time() + 6\n while time.time() < deadline:\n if channel.recv_ready():\n out += channel.recv(4096).decode("utf-8", errors="replace")\n time.sleep(0.1)\n else:\n if out:\n break\n time.sleep(0.1)\n return out.strip(), bool(_SWITCH_ERR.search(out))\n\n\ndef push_one_by_one(commands: list[str]) -> dict:\n """\n Opens a FRESH dedicated connection for push (not from pool —\n we don\'t want a push session to corrupt the pool\'s read connection).\n Runs commands one at a time, stops on first error.\n Saves config only on full success.\n """\n _pool.invalidate() # invalidate pool — switch will be busy during push\n client = _open_connection()\n results = []\n stopped_at = None\n try:\n ch = client.invoke_shell()\n ch.settimeout(10)\n time.sleep(0.5)\n if ch.recv_ready():\n ch.recv(4096) # drain banner\n\n for setup in ["terminal length 0", "enable", "configure terminal"]:\n out, err = _run_one(ch, setup)\n if err:\n return {"success": False, "saved": False,\n "error": f"Failed entering config mode: {out}",\n "results": [], "hint": "Check switch is reachable and credentials are correct"}\n\n for i, cmd in enumerate(commands):\n s = cmd.strip()\n if not s or s.startswith("!"):\n results.append({"index": i, "command": cmd,\n "output": "", "success": True, "skipped": True})\n continue\n log.info(f" [{i+1}/{len(commands)}] {s}")\n out, had_err = _run_one(ch, s)\n results.append({"index": i, "command": cmd,\n "output": out, "success": not had_err, "skipped": False})\n if had_err:\n stopped_at = i\n log.warning(f" Error at command {i+1}: {out}")\n break\n\n _run_one(ch, "end")\n saved = False\n if stopped_at is None:\n _, save_err = _run_one(ch, "save config")\n saved = not save_err\n if saved:\n log.info("Config saved to NVRAM")\n else:\n log.warning("Config save may have failed — verify at console")\n else:\n log.warning("Config NOT saved — push stopped on error")\n\n ch.close()\n return {\n "success": stopped_at is None,\n "saved": saved,\n "commands_total": len(commands),\n "commands_sent": len([r for r in results if not r.get("skipped")]),\n "stopped_at": stopped_at,\n "error": results[stopped_at]["output"] if stopped_at is not None else None,\n "hint": "Remaining commands must be run at the switch console" if stopped_at is not None else None,\n "results": results,\n }\n finally:\n client.close()\n # Pool will reopen on next poll naturally\n\n# ══════════════════════════════════════════════════════════════════════\n# VISITOR-AWARE POLLER\n# ══════════════════════════════════════════════════════════════════════\n\n_cache: dict = {\n "port_status": None, "poe_status": None,\n "vlan_members": None, "sys_info": None,\n "last_poll": 0, "poll_error": None,\n}\n_cache_lock = threading.Lock()\n\n# Visitor tracking\n_visitors: dict[str, float] = {} # { visitor_id: last_seen }\n_visitors_lock = threading.Lock()\n_poll_mode = "idle" # idle | active | background\n\ndef heartbeat(visitor_id: str, mode: str = "active"):\n """Called by frontend to indicate a visitor is present."""\n global _poll_mode\n with _visitors_lock:\n _visitors[visitor_id] = time.time()\n _poll_mode = mode\n\ndef prune_visitors():\n """Remove visitors not seen for POLL_IDLE_AFTER seconds and set mode to idle if none remain."""\n global _poll_mode\n with _visitors_lock:\n now = time.time()\n gone = [v for v, t in _visitors.items() if now - t > POLL_IDLE_AFTER]\n for v in gone:\n del _visitors[v]\n if not _visitors:\n _poll_mode = "idle"\n\ndef _poll_loop():\n """\n Background thread: polls the switch for live status at a visitor-adaptive interval.\n\n When visitors are active: polls every POLL_ACTIVE_S seconds.\n When visitors have the tab backgrounded: polls every POLL_BG_S seconds.\n When no visitors for POLL_IDLE_AFTER seconds: sleeps without polling.\n Results cached in _cache; poll_error set on SSH failure.\n """\n log.info("Poller thread started")\n while True:\n prune_visitors()\n with _visitors_lock:\n mode = _poll_mode\n has_visitors = bool(_visitors)\n\n if not has_visitors:\n # No visitors — sleep and check again, don\'t poll switch\n time.sleep(30)\n continue\n\n # Poll the switch\n try:\n data = {\n "port_status": read_cmd("show interfaces"),\n "poe_status": read_cmd("show poe-main-status"),\n "vlan_members": read_cmd("show vlan"),\n "sys_info": read_cmd("show sys-info"),\n }\n with _cache_lock:\n _cache.update(data)\n _cache["last_poll"] = time.time()\n _cache["poll_error"] = None\n except Exception as e:\n with _cache_lock:\n _cache["poll_error"] = str(e)\n log.warning(f"Poll error: {e}")\n\n interval = POLL_ACTIVE_S if mode == "active" else POLL_BG_S\n time.sleep(interval)\n\ndef start_poller():\n """Launch the background polling thread as a daemon (exits when main process exits)."""\n t = threading.Thread(target=_poll_loop, daemon=True)\n t.start()\n log.info("Poller started")\n\n# ══════════════════════════════════════════════════════════════════════\n# PYDANTIC MODELS\n# ══════════════════════════════════════════════════════════════════════\n\nclass TotpVerify(BaseModel):\n code: str\n\nclass SessionCheck(BaseModel):\n token: str\n\nclass SessionRevoke(BaseModel):\n token: str\n\nclass Heartbeat(BaseModel):\n visitor_id: str\n mode: str = "active" # active | background\n\nclass PushBatch(BaseModel):\n token: str\n commands: list[str]\n\nclass VlanCreate(BaseModel):\n token: str\n vlan_id: int\n name: str\n @field_validator("vlan_id")\n @classmethod\n def cv(cls, v): return san_vid(v)\n @field_validator("name")\n @classmethod\n def cn(cls, v): return _san(v, _RE_VNAME, "name")\n\nclass VlanDelete(BaseModel):\n token: str\n vlan_id: int\n @field_validator("vlan_id")\n @classmethod\n def cv(cls, v):\n vid = san_vid(v)\n if vid == 1: raise ValueError("Cannot delete VLAN 1")\n return vid\n\nclass VlanProvision(BaseModel):\n token: str\n vlan_id: int\n name: str\n subnet: str # e.g. "192.168.20.0/24"\n gateway: str # OPNsense IP on this VLAN, e.g. "192.168.20.1"\n dhcp_start: str # e.g. "192.168.20.100"\n dhcp_end: str # e.g. "192.168.20.200"\n parent_if: str # OPNsense physical parent, e.g. "em0" or "igb0"\n opnsense_if: Optional[str] = "" # assigned interface name, e.g. "opt2"\n allow_internet: bool = True # add default allow-out firewall rule\n @field_validator("vlan_id")\n @classmethod\n def cv(cls, v):\n vid = san_vid(v)\n if vid in (1, 99): raise ValueError("VLAN 1 and 99 are reserved")\n return vid\n @field_validator("name")\n @classmethod\n def cn(cls, v): return _san(v, _RE_VNAME, "name")\n\nclass AclRule(BaseModel):\n action: str\n proto: str\n src: Optional[str] = "any"\n src_mask: Optional[str] = "0.0.0.255"\n src_any: Optional[bool] = True\n dst: Optional[str] = "any"\n dst_mask: Optional[str] = "0.0.0.255"\n dst_any: Optional[bool] = True\n port: Optional[str] = ""\n port_end: Optional[str] = "" # when set, generates "range port port_end"\n @field_validator("action")\n @classmethod\n def ca(cls, v): return _san(v, _RE_ACTION, "action")\n @field_validator("proto")\n @classmethod\n def cp(cls, v): return _san(v, _RE_PROTO, "proto")\n\nclass AclCreate(BaseModel):\n token: str\n name: str\n apply_vlan: int\n direction: str\n rules: list[AclRule]\n @field_validator("name")\n @classmethod\n def cn(cls, v): return _san(v, _RE_ANAME, "name")\n @field_validator("direction")\n @classmethod\n def cd(cls, v): return _san(v, _RE_DIR, "direction")\n\nclass PortConfig(BaseModel):\n token: str\n port: int\n mode: str\n access_vlan: Optional[int] = 1\n tagged_vlans: Optional[list[int]] = []\n native_vlan: Optional[int] = 1\n poe: Optional[bool] = True\n poe_limit_mw: Optional[int] = 30000\n description: Optional[str] = ""\n @field_validator("port")\n @classmethod\n def cp(cls, v): return san_port(v)\n @field_validator("mode")\n @classmethod\n def cm(cls, v): return _san(v, _RE_MODE, "mode")\n @field_validator("poe_limit_mw")\n @classmethod\n def cpoe(cls, v):\n if v is not None and not 1000 <= v <= 30000:\n raise ValueError("PoE limit: 100030000 mW")\n return v\n\n# ══════════════════════════════════════════════════════════════════════\n# COMMAND BUILDERS\n# ══════════════════════════════════════════════════════════════════════\n\ndef build_port(cfg: PortConfig) -> list[str]:\n """\n Generate ERS 59100GTS-PWR+ CLI commands for a port configuration change.\n\n Ports 196 are GigabitEthernet copper (PoE capable); ports 97100 are SFP+ uplinks (no PoE).\n All interfaces use slot/port notation: GigabitEthernet 1/{p}.\n Returns a list of CLI command strings ready for push_one_by_one().\n """\n p = cfg.port\n iface = f"FastEthernet {p}"\n cmds = []\n if cfg.description:\n cmds += [f"interface {iface}", f\' name "{cfg.description}"\']\n if cfg.mode == "disabled":\n cmds += [f"interface {iface}", " shutdown"]\n elif cfg.mode == "access":\n vid = san_vid(cfg.access_vlan, "access_vlan")\n cmds += [f"vlan members add {vid} {p}", f"vlan pvid {p} {vid}"]\n elif cfg.mode == "trunk":\n native = san_vid(cfg.native_vlan, "native_vlan")\n tagged = [san_vid(v, f"tagged_{v}") for v in (cfg.tagged_vlans or [])]\n if tagged:\n ts = ",".join(str(v) for v in tagged)\n cmds += [f"vlan members add {ts} {p}", f"vlan tagging {ts} {p}"]\n cmds.append(f"vlan pvid {p} {native}")\n if p <= 96:\n cmds += [f"interface {iface}",\n " poe enable" if cfg.poe else " no poe enable"]\n if cfg.poe:\n cmds.append(f" poe poe-limit {cfg.poe_limit_mw}")\n return cmds\n\ndef build_acl(acl: AclCreate) -> list[str]:\n """\n Generate ERS 59100GTS-PWR+ CLI commands to create an extended IP ACL and apply it to a VLAN interface.\n\n Rules are numbered sequentially starting from 1.\n The ACL is applied to the VLAN\'s Layer 3 interface in the specified direction (in/out).\n """\n cmds = [f"ip access-list extended {acl.name}"]\n for i, r in enumerate(acl.rules):\n src = "any" if r.src_any else f"{r.src} {r.src_mask}"\n dst = "any" if r.dst_any else f"{r.dst} {r.dst_mask}"\n if r.port and r.port_end:\n port_str = f" range {r.port} {r.port_end}"\n elif r.port:\n port_str = f" eq {r.port}"\n else:\n port_str = ""\n cmds.append(f" {i+1} {r.action} {r.proto} {src} {dst}{port_str}")\n vid = san_vid(acl.apply_vlan, "apply_vlan")\n cmds += [f"interface vlan {vid}",\n f" ip access-group {acl.name} {acl.direction}"]\n return cmds\n\n# ══════════════════════════════════════════════════════════════════════\n# APP\n# ══════════════════════════════════════════════════════════════════════\n\napp = FastAPI(title="ERS 59100GTS-PWR+ Switch Manager", version="3.0.0",\n docs_url="/api/docs", redoc_url=None)\n\napp.add_middleware(CORSMiddleware, allow_origins=ALLOWED_ORIGINS,\n allow_methods=["GET","POST","DELETE"],\n allow_headers=["Content-Type"])\n\n@app.exception_handler(ValueError)\nasync def val_err(req: Request, exc: ValueError):\n return JSONResponse(400, {"detail": str(exc)})\n\n@app.on_event("startup")\ndef startup():\n global TOTP_SECRET\n TOTP_SECRET = get_or_create_totp_secret()\n log.info("TOTP secret loaded")\n start_poller()\n\n# ── Heartbeat (visitor tracking) ──────────────────────────────────────\n\n@app.post("/api/heartbeat")\ndef hb(body: Heartbeat):\n """\n Frontend calls this every 30s to indicate visitor is present.\n mode = \'active\' when tab is visible, \'background\' when hidden.\n Poller adjusts interval accordingly.\n """\n heartbeat(body.visitor_id, body.mode)\n with _cache_lock:\n last = _cache["last_poll"]\n err = _cache["poll_error"]\n return {\n "poll_mode": _poll_mode,\n "last_poll": last,\n "poll_age": round(time.time() - last, 1) if last else None,\n "poll_error": err,\n }\n\n# ── Auth ───────────────────────────────────────────────────────────────\n\n@app.post("/api/auth/verify")\ndef verify_totp(body: TotpVerify):\n """Verify TOTP code — returns session token for multiple pushes."""\n if not pyotp.TOTP(TOTP_SECRET).verify(body.code.strip(), valid_window=1):\n log.warning("TOTP verify failed")\n raise HTTPException(401, "Invalid TOTP code")\n token = create_session()\n log.info("TOTP OK — session created")\n return {\n "token": token,\n "expires_in": SESSION_TTL_S,\n "message": "Session active — re-authenticates on expiry or manual lock"\n }\n\n@app.post("/api/auth/check")\ndef check_session(body: SessionCheck):\n """Check if a session is still valid. Returns remaining seconds."""\n remaining = session_remaining(body.token)\n if remaining is None:\n raise HTTPException(401, "Session expired")\n return {"valid": True, "remaining": remaining}\n\n@app.post("/api/auth/revoke")\ndef revoke(body: SessionRevoke):\n """Manually lock — invalidates the session immediately."""\n revoke_session(body.token)\n log.info("Session manually revoked")\n return {"revoked": True}\n\n# ── Read endpoints (no auth) ───────────────────────────────────────────\n\n@app.get("/api/status")\ndef status():\n """Backend and switch connectivity summary (no auth required)."""\n with _cache_lock:\n return {\n "backend": "online",\n "switch_host": SWITCH_HOST,\n "key_exists": Path(KEY_PATH).exists(),\n "known_hosts_pinned": Path(KNOWN_HOSTS).exists(),\n "last_poll": _cache["last_poll"],\n "poll_age": round(time.time() - _cache["last_poll"], 1)\n if _cache["last_poll"] else None,\n "poll_error": _cache["poll_error"],\n "poll_mode": _poll_mode,\n }\n\n@app.get("/api/live")\ndef live():\n """Cached live switch data — updated by background poller."""\n with _cache_lock:\n age = time.time() - _cache["last_poll"] if _cache["last_poll"] else None\n return {\n **_cache,\n "stale": age is None or age > max(POLL_ACTIVE_S, POLL_BG_S) * 3\n }\n\n@app.get("/api/switch/config")\ndef running_config():\n """Fetch and return the full switch running config (read-only, no auth)."""\n out = read_cmd("show config")\n return {"config": out, "lines": len(out.splitlines())}\n\n\n# ── Capability probe ────────────────────────────────────────────────────\n\n_caps_cache: dict = {}\n_caps_ts: float = 0.0\n_caps_lock = threading.Lock()\n_CAPS_TTL = 300 # seconds — re-probe every 5 min; license won\'t change mid-session\n\n\ndef _probe_capabilities() -> dict:\n """\n Non-destructive read-only probes to detect which features the switch\n supports under its current software license.\n\n Base Software: ACL and L3 VLAN commands return \'% Invalid input detected\'.\n Advanced License: commands succeed (may show empty output, but no error).\n """\n acl_out = read_cmd("show ip access-list")\n vlan_out = read_cmd("show interface vlan 1")\n acl_ok = not _SWITCH_ERR.search(acl_out)\n l3_ok = not _SWITCH_ERR.search(vlan_out)\n return {\n "acl": acl_ok,\n "l3_vlan": l3_ok,\n "dhcp_relay_config": l3_ok, # relay config uses \'interface vlan\'\n "management_pinholes": acl_ok,\n "dns_enforce_acls": acl_ok,\n "license_tier": "advanced" if acl_ok else "base",\n }\n\n\ndef _require_advanced_license():\n """Raise 402 if the switch reports Base Software (no ACL/L3 support)."""\n global _caps_ts\n with _caps_lock:\n cached = _caps_cache.copy() if _caps_cache else {}\n # If we have a cached result use it; otherwise probe now\n if not cached:\n try:\n cached = _probe_capabilities()\n with _caps_lock:\n _caps_cache.update(cached)\n _caps_ts = time.time()\n except HTTPException:\n return # can\'t reach switch — let the push fail with its own error\n if cached.get("license_tier") == "base":\n raise HTTPException(\n 402,\n "This feature requires the Advanced Software License. "\n "The switch reported Base Software — ACLs and L3 VLAN interfaces are not available."\n )\n\n\n@app.get("/api/switch/capabilities")\ndef switch_capabilities():\n """\n Probe the switch to determine which features are available under its\n current software license. Results are cached for 5 minutes.\n\n Base Software supports L2 only (VLANs, ports, PoE, show commands).\n Advanced License adds ACLs and L3 VLAN interfaces.\n\n Affected endpoints when license_tier == \'base\':\n - POST /api/switch/acl (acl)\n - POST /api/ctrld/dns-enforce-acls (dns_enforce_acls)\n """\n global _caps_cache, _caps_ts\n with _caps_lock:\n if time.time() - _caps_ts < _CAPS_TTL and _caps_cache:\n return {**_caps_cache, "cached": True}\n try:\n caps = _probe_capabilities()\n except HTTPException as e:\n return {\n "error": e.detail,\n "acl": False, "l3_vlan": False,\n "dhcp_relay_config": False,\n "management_pinholes": False,\n "dns_enforce_acls": False,\n "license_tier": "unknown",\n "cached": False,\n }\n with _caps_lock:\n _caps_cache = caps\n _caps_ts = time.time()\n return {**caps, "cached": False}\n\n# ── Danger pre-flight (no auth — check before prompting TOTP) ─────────\n\n@app.post("/api/check/danger")\ndef danger_check(body: dict):\n """\n Pre-flight danger check — call this before showing TOTP prompt.\n\n Returns hard_blocked, warnings, and safe_to_push flag.\n No auth required so the user sees danger info before authenticating.\n """\n cmds = body.get("commands", [])\n result = check_danger(cmds)\n rejected = [c for c in cmds if not is_allowed(c)]\n result["rejected_by_allowlist"] = rejected\n result["safe_to_push"] = (\n not result["has_hard_block"] and not rejected\n )\n return result\n\n# ── Push endpoints (require session token) ────────────────────────────\n\n@app.post("/api/switch/push")\ndef push(body: PushBatch):\n """\n Push CLI batch one command at a time.\n Requires valid session token.\n Hard-blocked commands are refused — no override.\n Warn-level commands proceed (user was already shown the warning).\n Stops on first switch error. Config saved only on full success.\n """\n require_session(body.token)\n\n danger = check_danger(body.commands)\n if danger["has_hard_block"]:\n raise HTTPException(400, {\n "message": "Hard-blocked commands detected — these must be run at the switch console",\n "blocked": danger["hard_blocked"],\n })\n\n rejected = [c for c in body.commands if not is_allowed(c)]\n if rejected:\n raise HTTPException(400, {\n "message": "Commands failed allowlist validation",\n "rejected": rejected[:10],\n })\n\n log.info(f"Push: {len(body.commands)} commands")\n return push_one_by_one(body.commands)\n\n@app.post("/api/switch/vlan")\ndef create_vlan(body: VlanCreate):\n """Create a new VLAN on the switch (type port = standard Layer 2 VLAN)."""\n require_session(body.token)\n return push_one_by_one(\n [f\'vlan create {body.vlan_id} name "{body.name}" type port\'])\n\n@app.delete("/api/switch/vlan/{vlan_id}")\ndef delete_vlan(vlan_id: int, token: str):\n """Delete a VLAN by ID. VLAN 1 is blocked at model level; VLAN 99 is blocked by danger check."""\n require_session(token)\n return push_one_by_one([f"no vlan {san_vid(vlan_id)}"])\n\n@app.post("/api/switch/port")\ndef configure_port(body: PortConfig):\n """Apply port configuration: mode (access/trunk/disabled), VLAN, PoE, description."""\n require_session(body.token)\n return push_one_by_one(build_port(body))\n\n@app.post("/api/switch/acl")\ndef create_acl(body: AclCreate):\n """Create an extended IP ACL and apply it to a VLAN interface. Requires Advanced License."""\n require_session(body.token)\n _require_advanced_license()\n return push_one_by_one(build_acl(body))\n\n@app.post("/api/vlan/provision")\ndef provision_vlan(body: VlanProvision):\n """\n End-to-end VLAN provisioning: switch VLAN + OPNsense interface tag +\n DHCP scope + optional internet-allow firewall rule.\n\n Steps performed:\n 1. Create VLAN on switch\n 2. Create VLAN tag on OPNsense (interfaces/vlan_settings)\n 3. Apply OPNsense VLAN config\n 4. If opnsense_if provided: create DHCP subnet + apply\n 5. If opnsense_if + allow_internet: add allow-outbound firewall rule + apply\n\n Returns steps_done, pending_steps (anything needing manual finish in OPNsense UI).\n """\n import ipaddress as _ipaddr\n require_session(body.token)\n\n steps_done: list[str] = []\n pending_steps: list[str] = []\n\n # Validate subnet/gateway/range are sane\n try:\n net = _ipaddr.ip_network(body.subnet, strict=False)\n _ipaddr.ip_address(body.gateway)\n _ipaddr.ip_address(body.dhcp_start)\n _ipaddr.ip_address(body.dhcp_end)\n except ValueError as e:\n raise HTTPException(400, f"Invalid address: {e}")\n\n # ── Step 1: Switch VLAN ────────────────────────────────────────────\n result = push_one_by_one([f\'vlan create {body.vlan_id} name "{body.name}" type port\'])\n if not result.get("success"):\n raise HTTPException(502, {"message": "Switch VLAN create failed", "detail": result})\n steps_done.append(f"switch: vlan {body.vlan_id} \'{body.name}\' created")\n\n # ── Steps 25: OPNsense ───────────────────────────────────────────\n cfg = _load_opnsense_cfg()\n if not cfg.get("key"):\n pending_steps += [\n f"OPNsense: create VLAN tag {body.vlan_id} on {body.parent_if}",\n f"OPNsense: assign VLAN interface, set IP {body.gateway}/{net.prefixlen}",\n f"OPNsense: create DHCP scope {body.dhcp_start}{body.dhcp_end}",\n ]\n if body.allow_internet:\n pending_steps.append("OPNsense: add allow-outbound firewall rule for VLAN")\n return {"success": True, "steps_done": steps_done, "pending_steps": pending_steps,\n "note": "OPNsense not configured — connect it under DHCP settings to automate these steps"}\n\n errors: list[str] = []\n\n # Step 2: create VLAN tag\n try:\n vlan_r = _opnsense_request(cfg, "interfaces/vlan_settings/addItem", "POST", {\n "vlan": {"if": body.parent_if, "tag": str(body.vlan_id), "pcp": "0", "descr": body.name}\n })\n vlan_uuid = vlan_r.get("uuid", "")\n steps_done.append(f"OPNsense: VLAN tag {body.vlan_id} created on {body.parent_if} (uuid={vlan_uuid})")\n except ValueError as e:\n errors.append(f"OPNsense VLAN tag: {e}")\n vlan_uuid = ""\n\n # Step 3: apply VLAN config\n if vlan_uuid:\n try:\n _opnsense_request(cfg, "interfaces/vlan_settings/reconfigure", "POST")\n steps_done.append("OPNsense: VLAN config applied")\n except ValueError as e:\n errors.append(f"OPNsense VLAN apply: {e}")\n\n # Interface assignment must be done in OPNsense UI unless opnsense_if is provided\n if not body.opnsense_if:\n pending_steps += [\n f"OPNsense UI: assign {body.parent_if}.{body.vlan_id} as a new interface, "\n f"set static IP {body.gateway}/{net.prefixlen}, note the interface name (e.g. opt2)",\n f"OPNsense: create DHCP scope {body.dhcp_start}{body.dhcp_end} once interface is assigned",\n ]\n if body.allow_internet:\n pending_steps.append("OPNsense: add allow-outbound firewall rule for new interface")\n else:\n # Step 4: DHCP scope\n try:\n _opnsense_request(cfg, "dhcpv4/settings/addSubnet", "POST", {\n "subnet": {\n "interface": body.opnsense_if,\n "subnet": str(net),\n "gateway": body.gateway,\n "dns_servers": body.gateway,\n "range": {"from": body.dhcp_start, "to": body.dhcp_end},\n }\n })\n _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")\n steps_done.append(\n f"OPNsense: DHCP scope {body.dhcp_start}{body.dhcp_end} on {body.opnsense_if} created")\n except ValueError as e:\n errors.append(f"OPNsense DHCP scope: {e}")\n\n # Step 5: firewall allow-outbound\n if body.allow_internet:\n try:\n fw_r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {\n "rule": {\n "enabled": "1",\n "action": "pass",\n "interface": body.opnsense_if,\n "direction": "in",\n "ipprotocol": "inet",\n "protocol": "any",\n "source": {"network": f"{body.opnsense_if}net"},\n "destination":{"any": "1"},\n "descr": f"Allow VLAN {body.vlan_id} {body.name} outbound",\n }\n })\n _opnsense_request(cfg, "firewall/filter/apply", "POST")\n steps_done.append(\n f"OPNsense: allow-outbound rule for {body.opnsense_if} added (uuid={fw_r.get(\'uuid\',\'\')})")\n except ValueError as e:\n errors.append(f"OPNsense firewall rule: {e}")\n\n # Persist VLAN→interface mapping for push-reservation lookups\n vmap = _load_vlan_if_map()\n vmap[str(body.vlan_id)] = body.opnsense_if\n _save_vlan_if_map(vmap)\n\n return {\n "success": len(errors) == 0,\n "steps_done": steps_done,\n "pending_steps": pending_steps,\n "errors": errors,\n }\n\n# ── Serve React app ────────────────────────────────────────────────────\nif os.path.isdir(STATIC_DIR):\n app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="frontend")\n\n# ══════════════════════════════════════════════════════════════════════\nif __name__ == "__main__":\n if "--setup-totp" in sys.argv:\n setup_totp()\n sys.exit(0)\n import uvicorn\n uvicorn.run("switch_backend:app", host="0.0.0.0", port=8765,\n log_level="info")\n\n# ══════════════════════════════════════════════════════════════════════\n# DEVICE ACCESS — DHCP LEASES, MAC RESERVATIONS, ACL PINHOLES\n# ══════════════════════════════════════════════════════════════════════\n\nimport json as _json\nfrom pathlib import Path as _Path\n\nDEVICES_FILE = _Path("/etc/switch-manager/devices.json")\n\ndef _load_devices() -> list:\n """Load the saved device list from devices.json, returning [] on missing or corrupt file."""\n if DEVICES_FILE.exists():\n try: return _json.loads(DEVICES_FILE.read_text())\n except: pass\n return []\n\ndef _save_devices(devices: list):\n """Persist the device list to devices.json with 2-space indentation."""\n DEVICES_FILE.write_text(_json.dumps(devices, indent=2))\n\ndef _parse_dhcp_leases(raw: str) -> list:\n """Parse ERS 59100GTS-PWR+ \'show dhcp-server leases\' output."""\n import re\n leases = []\n for line in raw.splitlines():\n # Format: IP MAC State Remaining Hostname\n m = re.match(\n r\'\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)\\s+\'\n r\'([0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2}\'\n r\'[:\\-][0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2})\\s+\'\n r\'(\\S+)\\s+(\\S+)\\s*(.*)\', line)\n if m:\n leases.append({\n "ip": m.group(1),\n "mac": m.group(2).lower().replace(\'-\',\':\'),\n "state": m.group(3),\n "remaining": m.group(4),\n "hostname": m.group(5).strip() or "unknown",\n })\n return leases\n\ndef _parse_arp_table(raw: str) -> list:\n """Parse \'show arp\' for additional device discovery."""\n import re\n entries = []\n for line in raw.splitlines():\n m = re.match(\n r\'\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)\\s+\'\n r\'([0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2}\'\n r\'[:\\-][0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2}[:\\-][0-9a-fA-F]{2})\',\n line)\n if m:\n entries.append({\n "ip": m.group(1),\n "mac": m.group(2).lower().replace(\'-\',\':\'),\n "state": "arp",\n "remaining": "-",\n "hostname": "",\n })\n return entries\n\nclass DeviceEntry(BaseModel):\n name: str\n mac: str\n ip: str\n vlan: int = 10\n management_access: bool = False\n static_ip: bool = False\n notes: Optional[str] = ""\n\nclass DeviceUpdate(BaseModel):\n token: str\n device: DeviceEntry\n\nclass DeviceDelete(BaseModel):\n token: str\n mac: str\n\nclass PinholeRequest(BaseModel):\n token: str\n mac: str\n allow: bool = True\n\ndef _build_dhcp_reservation_cmds(device: DeviceEntry) -> list:\n """Generate ERS 59100GTS-PWR+ CLI for DHCP static binding."""\n mac_clean = device.mac.replace(\':\',\'-\').upper()\n return [\n f"ip dhcp-server static-binding {device.ip}",\n f" mac-address {mac_clean}",\n f" client-name \\"{device.name}\\"",\n ]\n\ndef _build_pinhole_acl_cmds(device: DeviceEntry, mgmt_ip: str, allow: bool) -> list:\n """Generate ACL commands to allow/deny a device IP to reach management."""\n acl_name = f"MGMT-ACCESS"\n if allow:\n return [\n f"ip access-list extended {acl_name}",\n f" permit tcp host {device.ip} host {mgmt_ip} eq 443",\n f" permit tcp host {device.ip} host {mgmt_ip} eq 8765",\n ]\n else:\n return [\n f"ip access-list extended {acl_name}",\n f" no permit tcp host {device.ip} host {mgmt_ip}",\n ]\n\n@app.get("/api/devices")\ndef get_devices():\n """Return saved device list plus live DHCP leases and ARP from switch."""\n saved = _load_devices()\n live_leases = []\n try:\n arp_raw = read_cmd("show arp")\n live_leases = _parse_arp_table(arp_raw)\n try:\n dhcp_raw = read_cmd("show ip dhcp-server leases")\n dhcp_leases = _parse_dhcp_leases(dhcp_raw)\n lease_ips = {l["ip"] for l in dhcp_leases}\n live_leases = dhcp_leases + [e for e in live_leases if e["ip"] not in lease_ips]\n except Exception:\n pass # DHCP server may not be enabled\n except Exception as e:\n log.warning(f"Could not pull ARP from switch: {e}")\n return {\n "saved": saved,\n "live": live_leases,\n "mgmt_ip": SWITCH_HOST,\n }\n\n@app.post("/api/devices/save")\ndef save_device(body: DeviceUpdate):\n """Save or update a device entry (upsert by MAC address)."""\n require_session(body.token)\n devices = _load_devices()\n existing = next((i for i, d in enumerate(devices) if d["mac"] == body.device.mac), None)\n device_dict = body.device.dict()\n if existing is not None:\n devices[existing] = device_dict\n else:\n devices.append(device_dict)\n _save_devices(devices)\n log.info(f"Device saved: {body.device.name} ({body.device.mac})")\n return {"success": True, "devices": devices}\n\n@app.post("/api/devices/delete")\ndef delete_device(body: DeviceDelete):\n """Remove a device from the saved list by MAC address."""\n require_session(body.token)\n devices = [d for d in _load_devices() if d["mac"] != body.mac]\n _save_devices(devices)\n return {"success": True}\n\n@app.post("/api/devices/push-reservation")\ndef push_reservation(body: DeviceUpdate):\n """\n Push a DHCP static reservation for this device.\n\n Routes to OPNsense if configured (preferred — no license required).\n Falls back to switch DHCP CLI only if OPNsense is not configured, which\n requires Advanced License on the switch.\n """\n require_session(body.token)\n device = body.device\n cfg = _load_opnsense_cfg()\n\n if cfg.get("key"):\n # Derive OPNsense interface from stored VLAN→interface map\n vmap = _load_vlan_if_map()\n iface = vmap.get(str(device.vlan), "")\n try:\n result = _opnsense_request(cfg, "dhcpv4/reservations/addReservation", "POST", {\n "reservation": {\n "interface": iface,\n "mac": device.mac,\n "ipaddr": device.ip,\n "hostname": device.name,\n "descr": f"Added by switch-manager",\n }\n })\n _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")\n log.info(f"OPNsense DHCP reservation pushed: {device.name} ({device.mac}) → {device.ip}")\n return {"success": True, "target": "opnsense", "result": result}\n except ValueError as e:\n raise HTTPException(400, str(e))\n else:\n # Switch DHCP — requires Advanced License\n cmds = _build_dhcp_reservation_cmds(device)\n danger = check_danger(cmds)\n if danger["has_hard_block"]:\n raise HTTPException(400, {"message": "Blocked", "blocked": danger["hard_blocked"]})\n log.info(f"Switch DHCP reservation pushed: {device.name}")\n result = push_one_by_one(cmds)\n result["target"] = "switch"\n return result\n\n@app.post("/api/devices/push-pinhole")\ndef push_pinhole(body: PinholeRequest):\n """\n Add or remove a management-access firewall pinhole for a device.\n\n Uses OPNsense firewall API if configured. Rule UUIDs are stored locally\n so the same device can be cleanly de-pinholed later.\n """\n require_session(body.token)\n cfg = _load_opnsense_cfg()\n if not cfg.get("key"):\n raise HTTPException(503, "OPNsense not configured — connect it under DHCP settings")\n\n devices = _load_devices()\n device = next((DeviceEntry(**d) for d in devices if d["mac"] == body.mac), None)\n if not device:\n raise HTTPException(404, "Device not found — save it first")\n\n import socket\n try:\n mgmt_ip = socket.gethostbyname(socket.gethostname())\n except Exception:\n mgmt_ip = SWITCH_HOST.rsplit(\'.\', 1)[0] + \'.50\'\n\n pinholes = _load_pinholes()\n\n if body.allow:\n # Look up OPNsense interface for device VLAN\n vmap = _load_vlan_if_map()\n iface = vmap.get(str(device.vlan), "")\n try:\n r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {\n "rule": {\n "enabled": "1",\n "action": "pass",\n "interface": iface,\n "direction": "in",\n "ipprotocol": "inet",\n "protocol": "tcp",\n "source": {"address": device.ip},\n "destination": {"address": mgmt_ip, "port": "8765"},\n "descr": f"switch-manager pinhole {device.name}",\n }\n })\n _opnsense_request(cfg, "firewall/filter/apply", "POST")\n pinholes[device.mac] = r.get("uuid", "")\n _save_pinholes(pinholes)\n log.info(f"Pinhole allow: {device.name} ({device.ip}) → {mgmt_ip}:8765")\n return {"success": True, "action": "allow", "uuid": r.get("uuid", "")}\n except ValueError as e:\n raise HTTPException(400, str(e))\n else:\n uuid = pinholes.get(device.mac, "")\n if not uuid:\n raise HTTPException(404, "No pinhole rule found for this device")\n try:\n _opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")\n _opnsense_request(cfg, "firewall/filter/apply", "POST")\n pinholes.pop(device.mac, None)\n _save_pinholes(pinholes)\n log.info(f"Pinhole removed: {device.name} ({device.ip})")\n return {"success": True, "action": "deny", "uuid": uuid}\n except ValueError as e:\n raise HTTPException(400, str(e))\n\n# ══════════════════════════════════════════════════════════════════════\n# WIREGUARD PEER MANAGEMENT\n# ══════════════════════════════════════════════════════════════════════\n\nWG_CLIENT_DIR_API = _Path("/etc/switch-manager/clients")\nWG_CONF_PATH = _Path("/etc/wireguard/wg0.conf")\n\nclass WGClientRequest(BaseModel):\n token: str\n name: str\n\nclass WGRevokeRequest(BaseModel):\n token: str\n name: str\n\ndef _wg_genkey_api():\n """Generate a WireGuard private/public keypair using the system wg tool."""\n import subprocess as _sp\n priv = _sp.run(["wg","genkey"], capture_output=True, text=True).stdout.strip()\n pub = _sp.run(["wg","pubkey"], input=priv, capture_output=True, text=True).stdout.strip()\n return priv, pub\n\ndef _wg_status() -> dict:\n """Return parsed WireGuard interface status including connected peers."""\n import subprocess as _sp\n try:\n raw = _sp.run(["wg","show"], capture_output=True, text=True).stdout\n peers = []\n current = {}\n for line in raw.splitlines():\n line = line.strip()\n if line.startswith("peer:"):\n if current: peers.append(current)\n current = {"public_key": line.split(":",1)[1].strip()}\n elif line.startswith("endpoint:"):\n current["endpoint"] = line.split(":",1)[1].strip()\n elif line.startswith("latest handshake:"):\n current["last_handshake"] = line.split(":",1)[1].strip()\n elif line.startswith("transfer:"):\n current["transfer"] = line.split(":",1)[1].strip()\n elif line.startswith("allowed ips:"):\n current["allowed_ips"] = line.split(":",1)[1].strip()\n if current: peers.append(current)\n\n # Match peers to named client files\n named = {}\n if WG_CLIENT_DIR_API.exists():\n for f in WG_CLIENT_DIR_API.glob("*.conf"):\n txt = f.read_text()\n import re\n m = re.search(r\'PublicKey\\s*=\\s*(\\S+)\', txt)\n if m: named[m.group(1)] = f.stem\n\n for p in peers:\n p["name"] = named.get(p.get("public_key",""), "unknown")\n\n return {"running": True, "peers": peers}\n except Exception as e:\n return {"running": False, "error": str(e), "peers": []}\n\n@app.get("/api/wireguard/status")\ndef wg_status():\n return _wg_status()\n\n@app.get("/api/wireguard/clients")\ndef wg_clients():\n clients = []\n if WG_CLIENT_DIR_API.exists():\n for f in sorted(WG_CLIENT_DIR_API.glob("*.conf")):\n clients.append({"name": f.stem, "file": str(f)})\n return {"clients": clients}\n\n@app.post("/api/wireguard/add-client")\ndef wg_add_client(body: WGClientRequest):\n require_session(body.token)\n if not WG_CONF_PATH.exists():\n raise HTTPException(503, "WireGuard not configured on this machine")\n\n import re, subprocess as _sp, socket\n\n # Next available IP\n conf_text = WG_CONF_PATH.read_text()\n used = set()\n for m in re.finditer(r\'AllowedIPs\\s*=\\s*(\\S+)\', conf_text):\n used.add(m.group(1).split(\'/\')[0])\n\n subnet = "10.99.0"\n num = 2\n while f"{subnet}.{num}" in used and num < 254: num += 1\n client_ip = f"{subnet}.{num}"\n\n # Server public key\n server_pub_path = _Path("/etc/switch-manager/wg_server_public")\n if not server_pub_path.exists():\n raise HTTPException(503, "Server public key not found")\n server_pub = server_pub_path.read_text().strip()\n\n # Client keys\n c_priv, c_pub = _wg_genkey_api()\n\n # Peer entry in server config\n peer = f"\\n[Peer]\\n# {body.name}\\nPublicKey = {c_pub}\\nAllowedIPs = {client_ip}/32\\n"\n with open(WG_CONF_PATH, \'a\') as f:\n f.write(peer)\n\n # Reload live\n _sp.run(["wg","addconf","wg0","/dev/stdin"],\n input=f"[Peer]\\nPublicKey = {c_pub}\\nAllowedIPs = {client_ip}/32\\n",\n capture_output=True, text=True)\n\n # Public IP for endpoint\n try:\n pub_ip = _sp.run(["curl","-s","--max-time","5","https://api.ipify.org"],\n capture_output=True, text=True).stdout.strip()\n except Exception:\n pub_ip = socket.gethostbyname(socket.gethostname())\n\n # Get management IP from switch config\n mgmt_subnet = \'.\'.join(SWITCH_HOST.split(\'.\')[:3]) + \'.0/24\'\n\n client_conf = (\n f"[Interface]\\nPrivateKey = {c_priv}\\nAddress = {client_ip}/24\\n"\n f"DNS = {subnet}.1\\n\\n"\n f"[Peer]\\nPublicKey = {server_pub}\\n"\n f"Endpoint = {pub_ip}:51820\\n"\n f"AllowedIPs = {mgmt_subnet}, {subnet}.0/24\\n"\n f"PersistentKeepalive = 25\\n"\n )\n\n WG_CLIENT_DIR_API.mkdir(exist_ok=True)\n client_file = WG_CLIENT_DIR_API / f"{body.name}.conf"\n client_file.write_text(client_conf)\n client_file.chmod(0o600)\n\n log.info(f"WireGuard client added: {body.name}{client_ip}")\n return {\n "success": True,\n "name": body.name,\n "client_ip": client_ip,\n "config": client_conf,\n "file": str(client_file),\n }\n\n@app.post("/api/wireguard/revoke-client")\ndef wg_revoke_client(body: WGRevokeRequest):\n require_session(body.token)\n import re, subprocess as _sp\n\n client_file = WG_CLIENT_DIR_API / f"{body.name}.conf"\n if not client_file.exists():\n raise HTTPException(404, f"Client \'{body.name}\' not found")\n\n # Get client public key\n txt = client_file.read_text()\n m = re.search(r\'\\[Peer\\].*?PublicKey\\s*=\\s*(\\S+)\', txt, re.DOTALL)\n client_pub = m.group(1) if m else None\n\n # Remove from server config\n if WG_CONF_PATH.exists():\n conf = WG_CONF_PATH.read_text()\n # Remove the [Peer] block for this client\n cleaned = re.sub(\n rf\'\\n\\[Peer\\]\\n# {re.escape(body.name)}\\n.*?(?=\\n\\[Peer\\]|\\Z)\',\n \'\', conf, flags=re.DOTALL\n )\n WG_CONF_PATH.write_text(cleaned)\n\n # Remove live peer\n if client_pub:\n _sp.run(["wg","set","wg0","peer",client_pub,"remove"],\n capture_output=True, text=True)\n\n # Delete client file\n client_file.unlink()\n log.info(f"WireGuard client revoked: {body.name}")\n return {"success": True}\n\n@app.get("/api/wireguard/client-qr/{name}")\ndef wg_client_qr(name: str):\n """Return client config as text for QR generation in frontend."""\n client_file = WG_CLIENT_DIR_API / f"{name}.conf"\n if not client_file.exists():\n raise HTTPException(404, f"Client \'{name}\' not found")\n return {"name": name, "config": client_file.read_text()}\n\n# ══════════════════════════════════════════════════════════════════════\n# DHCP MANAGEMENT — SWITCH + OPNSENSE UNIFIED VIEW\n# ══════════════════════════════════════════════════════════════════════\n\nimport urllib.request as _urlreq\nimport urllib.error as _urlerr\nimport ssl as _ssl\nimport base64 as _b64\n\nOPNSENSE_FILE = _Path("/etc/switch-manager/opnsense.json")\nVLAN_IF_MAP_FILE = _Path("/etc/switch-manager/vlan-if-map.json")\nPINHOLE_FILE = _Path("/etc/switch-manager/pinholes.json")\n\ndef _load_vlan_if_map() -> dict:\n """Return {vlan_id_str: opnsense_if_name} e.g. {"20": "opt2"}."""\n if VLAN_IF_MAP_FILE.exists():\n try: return _json.loads(VLAN_IF_MAP_FILE.read_text())\n except: pass\n return {}\n\ndef _save_vlan_if_map(m: dict):\n VLAN_IF_MAP_FILE.write_text(_json.dumps(m, indent=2))\n\ndef _load_pinholes() -> dict:\n """Return {mac: rule_uuid} for OPNsense firewall pinholes."""\n if PINHOLE_FILE.exists():\n try: return _json.loads(PINHOLE_FILE.read_text())\n except: pass\n return {}\n\ndef _save_pinholes(m: dict):\n PINHOLE_FILE.write_text(_json.dumps(m, indent=2))\n PINHOLE_FILE.chmod(0o600)\n\ndef _load_opnsense_cfg() -> dict:\n """Load saved OPNsense API credentials from opnsense.json, returning {} if absent."""\n if OPNSENSE_FILE.exists():\n try: return _json.loads(OPNSENSE_FILE.read_text())\n except: pass\n return {}\n\ndef _save_opnsense_cfg(cfg: dict):\n """Persist OPNsense API credentials to opnsense.json (chmod 600 — contains secrets)."""\n OPNSENSE_FILE.write_text(_json.dumps(cfg, indent=2))\n OPNSENSE_FILE.chmod(0o600)\n\ndef _opnsense_request(cfg: dict, path: str, method="GET", body=None) -> dict:\n """Make an authenticated request to the OPNsense API."""\n host = cfg.get("host","")\n key = cfg.get("key","")\n secret = cfg.get("secret","")\n if not host or not key or not secret:\n raise ValueError("OPNsense not configured")\n url = f"https://{host}/api/{path}"\n creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()\n ctx = _ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = _ssl.CERT_NONE\n headers = {\n "Authorization": f"Basic {creds}",\n "Content-Type": "application/json",\n }\n data = _json.dumps(body).encode() if body else None\n req = _urlreq.Request(url, data=data, headers=headers, method=method)\n try:\n with _urlreq.urlopen(req, timeout=5, context=ctx) as r:\n return _json.loads(r.read().decode())\n except _urlerr.HTTPError as e:\n raise ValueError(f"OPNsense API error {e.code}: {e.reason}")\n except Exception as e:\n raise ValueError(f"OPNsense unreachable: {e}")\n\ndef _detect_opnsense_host(gateway_ip: str) -> str | None:\n """Try to reach OPNsense API at the gateway IP."""\n try:\n ctx = _ssl.create_default_context()\n ctx.check_hostname = False\n ctx.verify_mode = _ssl.CERT_NONE\n url = f"https://{gateway_ip}/api/core/firmware/status"\n req = _urlreq.Request(url, headers={"User-Agent":"switch-manager/1"})\n _urlreq.urlopen(req, timeout=3, context=ctx)\n return gateway_ip\n except Exception:\n return None\n\nOPNSENSE_KNOWN_HOSTS = _Path("/etc/switch-manager/opnsense_known_hosts")\nOPNSENSE_SSH_KEY = _Path("/etc/switch-manager/opnsense_key")\nUNBOUND_ETC = "/var/unbound/etc"\n\ndef _opnsense_ssh_run(cfg: dict, cmd: str, timeout: int = 30) -> tuple:\n """Run a shell command on OPNsense via SSH. Returns (stdout, stderr, exit_code).\n\n Uses exec_command() which bypasses the OPNsense console menu — the menu\n only appears for interactive login sessions, not for exec_command calls.\n """\n host = cfg.get("host", "")\n ssh_user = cfg.get("ssh_user", "root")\n key_path = cfg.get("ssh_key_path", "")\n if not host or not key_path:\n raise ValueError("OPNsense SSH not configured — set host and ssh_key_path")\n client = paramiko.SSHClient()\n if OPNSENSE_KNOWN_HOSTS.exists():\n client.load_host_keys(str(OPNSENSE_KNOWN_HOSTS))\n else:\n client.set_missing_host_key_policy(paramiko.AutoAddPolicy())\n try:\n client.connect(\n hostname=host,\n username=ssh_user,\n key_filename=key_path,\n timeout=10,\n look_for_keys=False,\n allow_agent=False,\n )\n _, stdout, stderr = client.exec_command(cmd, timeout=timeout)\n exit_code = stdout.channel.recv_exit_status()\n return stdout.read().decode(), stderr.read().decode(), exit_code\n finally:\n client.close()\n\ndef _opnsense_sftp_write(cfg: dict, remote_path: str, content: str):\n """Write a file on OPNsense via SFTP (avoids shell quoting issues)."""\n host = cfg.get("host", "")\n ssh_user = cfg.get("ssh_user", "root")\n key_path = cfg.get("ssh_key_path", "")\n if not host or not key_path:\n raise ValueError("OPNsense SSH not configured")\n client = paramiko.SSHClient()\n if OPNSENSE_KNOWN_HOSTS.exists():\n client.load_host_keys(str(OPNSENSE_KNOWN_HOSTS))\n else:\n client.set_missing_host_key_policy(paramiko.AutoAddPolicy())\n try:\n client.connect(\n hostname=host,\n username=ssh_user,\n key_filename=key_path,\n timeout=10,\n look_for_keys=False,\n allow_agent=False,\n )\n sftp = client.open_sftp()\n with sftp.open(remote_path, "w") as f:\n f.write(content)\n sftp.close()\n finally:\n client.close()\n\ndef _opnsense_ssh_test(cfg: dict) -> dict:\n """Test SSH connectivity to OPNsense. Returns {ok, version, error}."""\n try:\n out, err, code = _opnsense_ssh_run(cfg, "uname -sr")\n return {"ok": code == 0, "version": out.strip(), "error": err.strip() if code != 0 else ""}\n except Exception as e:\n return {"ok": False, "version": "", "error": str(e)}\n\ndef _get_opnsense_reservations(cfg: dict) -> list:\n """Fetch DHCP static mappings from OPNsense."""\n try:\n data = _opnsense_request(cfg, "dhcpv4/leases/searchReservation")\n rows = data.get("rows", [])\n return [\n {\n "ip": r.get("ipaddr",""),\n "mac": r.get("mac","").lower(),\n "hostname": r.get("hostname",""),\n "descr": r.get("descr",""),\n "if": r.get("if",""),\n "source": "opnsense",\n "uuid": r.get("uuid",""),\n }\n for r in rows if r.get("mac")\n ]\n except Exception as e:\n log.warning(f"OPNsense reservations fetch failed: {e}")\n return []\n\ndef _get_opnsense_leases(cfg: dict) -> list:\n """Fetch active DHCP leases from OPNsense."""\n try:\n data = _opnsense_request(cfg, "dhcpv4/leases/searchLease")\n rows = data.get("rows", [])\n return [\n {\n "ip": r.get("address",""),\n "mac": r.get("mac","").lower(),\n "hostname": r.get("hostname",""),\n "state": r.get("state",""),\n "if": r.get("if",""),\n "source": "opnsense_lease",\n }\n for r in rows if r.get("mac")\n ]\n except Exception as e:\n log.warning(f"OPNsense leases fetch failed: {e}")\n return []\n\ndef _get_switch_reservations() -> list:\n """Fetch DHCP static bindings from ERS 59100GTS-PWR+.\n\n Requires ip dhcp-server to be enabled on the switch.\n Returns empty list if DHCP server is not enabled/licensed.\n """\n import re as _re\n try:\n raw = read_cmd("show ip dhcp-server static-binding")\n bindings = []\n current = {}\n for line in raw.splitlines():\n m = _re.match(r\'\\s*IP Address:\\s*(\\S+)\', line)\n if m:\n if current: bindings.append(current)\n current = {"ip": m.group(1), "mac":"", "hostname":"", "source":"switch"}\n m2 = _re.match(r\'\\s*MAC Address:\\s*(\\S+)\', line)\n if m2 and current:\n current["mac"] = m2.group(1).lower().replace(\'-\',\':\')\n m3 = _re.match(r\'\\s*Client Name:\\s*(\\S+)\', line)\n if m3 and current:\n current["hostname"] = m3.group(1)\n if current and current.get("ip"):\n bindings.append(current)\n return bindings\n except Exception as e:\n log.warning(f"Switch DHCP reservation fetch failed: {e}")\n return []\n\ndef _get_switch_dhcp_status() -> dict:\n """Check if switch DHCP server is running and which VLANs it serves.\n\n Requires ip dhcp-server to be enabled on the switch.\n """\n import re as _re\n try:\n raw = read_cmd("show ip dhcp-server")\n running = "enabled" in raw.lower() or "active" in raw.lower()\n vlans = _re.findall(r\'VLAN\\s+(\\d+)\', raw, _re.I)\n return {"running": running, "vlans": list(set(vlans))}\n except Exception:\n return {"running": False, "vlans": []}\n\ndef _find_conflicts(switch_res: list, opnsense_res: list) -> list:\n """\n Find same MAC in both switch and OPNsense.\n Flag if IPs differ (conflict) or same (duplicate — harmless but messy).\n """\n switch_by_mac = {r["mac"]: r for r in switch_res if r.get("mac")}\n conflicts = []\n for r in opnsense_res:\n mac = r.get("mac","")\n if mac and mac in switch_by_mac:\n sw = switch_by_mac[mac]\n conflicts.append({\n "mac": mac,\n "hostname": r.get("hostname") or sw.get("hostname",""),\n "switch_ip": sw["ip"],\n "opnsense_ip": r["ip"],\n "ip_conflict": sw["ip"] != r["ip"],\n "opnsense_uuid": r.get("uuid",""),\n })\n return conflicts\n\n# ── OPNsense config models ─────────────────────────────────────────────────\n\nclass OPNsenseConfig(BaseModel):\n host: str\n key: str\n secret: str\n\nclass OPNsenseSSHConfig(BaseModel):\n ssh_key_path: str\n ssh_user: str = "root"\n pin_host_key: bool = True\n\nclass OPNsenseReservationPush(BaseModel):\n token: str\n mac: str\n ip: str\n hostname: str\n descr: Optional[str] = ""\n iface: Optional[str] = "lan"\n\nclass SyncRequest(BaseModel):\n token: str\n mac: str\n direction: str # "to_switch" | "to_opnsense" | "remove_switch" | "remove_opnsense"\n\n\n# ── DHCP endpoints ─────────────────────────────────────────────────────────\n\n@app.get("/api/dhcp/overview")\ndef dhcp_overview():\n """\n Unified DHCP view:\n - Switch static bindings + active leases\n - OPNsense reservations + leases (if configured)\n - Conflicts (same MAC, different IP)\n - Which DHCP server is active per VLAN\n """\n switch_res = _get_switch_reservations()\n switch_leases = []\n switch_status = _get_switch_dhcp_status()\n\n # Discover devices via DHCP leases + ARP\n try:\n arp_raw = read_cmd("show arp")\n switch_leases = _parse_arp_table(arp_raw)\n try:\n dhcp_raw = read_cmd("show ip dhcp-server leases")\n dhcp_leases = _parse_dhcp_leases(dhcp_raw)\n lease_ips = {l["ip"] for l in dhcp_leases}\n # Merge ARP entries not already in leases\n switch_leases = dhcp_leases + [e for e in switch_leases if e["ip"] not in lease_ips]\n except Exception:\n pass # DHCP server may not be enabled\n except Exception as e:\n log.warning(f"Switch ARP fetch failed: {e}")\n\n cfg = _load_opnsense_cfg()\n opnsense_res = _get_opnsense_reservations(cfg) if cfg.get("key") else []\n opnsense_leases = _get_opnsense_leases(cfg) if cfg.get("key") else []\n conflicts = _find_conflicts(switch_res, opnsense_res)\n\n opnsense_ifaces = list({r.get("if","") for r in opnsense_res + opnsense_leases if r.get("if")})\n\n return {\n "switch": {\n "status": switch_status,\n "reservations": switch_res,\n "leases": switch_leases,\n },\n "opnsense": {\n "configured": bool(cfg.get("key")),\n "host": cfg.get("host",""),\n "reservations": opnsense_res,\n "leases": opnsense_leases,\n "interfaces": opnsense_ifaces,\n },\n "conflicts": conflicts,\n "has_conflicts": len(conflicts) > 0,\n }\n\n@app.get("/api/dhcp/detect-opnsense")\ndef detect_opnsense_endpoint():\n """Auto-detect OPNsense at the gateway IP."""\n import re as _re\n try:\n route = read_cmd("show ip route")\n # Look for default route: DST=0.0.0.0, MASK=0.0.0.0 — NEXT column is gateway\n m = _re.search(r\'^0\\.0\\.0\\.0\\s+0\\.0\\.0\\.0\\s+(\\d+\\.\\d+\\.\\d+\\.\\d+)\', route, _re.MULTILINE)\n gateway = m.group(1) if m else None\n except Exception:\n gateway = None\n\n if not gateway:\n # Try from switch management IP\n parts = SWITCH_HOST.split(\'.\')\n parts[-1] = \'1\'\n gateway = \'.\'.join(parts)\n\n host = _detect_opnsense_host(gateway)\n return {\n "detected": host is not None,\n "host": host,\n "gateway": gateway,\n "configured": bool(_load_opnsense_cfg().get("key")),\n }\n\n@app.post("/api/dhcp/configure-opnsense")\ndef configure_opnsense(body: OPNsenseConfig):\n """Save OPNsense API credentials. Tests connectivity first."""\n cfg = {"host": body.host, "key": body.key, "secret": body.secret}\n try:\n result = _opnsense_request(cfg, "core/firmware/status")\n _save_opnsense_cfg(cfg)\n log.info(f"OPNsense configured: {body.host}")\n return {"success": True, "version": result.get("product_version","unknown")}\n except ValueError as e:\n raise HTTPException(400, str(e))\n\n@app.delete("/api/dhcp/configure-opnsense")\ndef remove_opnsense_config():\n """Remove OPNsense API credentials."""\n if OPNSENSE_FILE.exists():\n OPNSENSE_FILE.unlink()\n return {"success": True}\n\n@app.post("/api/dhcp/push-to-opnsense")\ndef push_reservation_to_opnsense(body: OPNsenseReservationPush):\n """Create a DHCP static mapping in OPNsense."""\n require_session(body.token)\n cfg = _load_opnsense_cfg()\n if not cfg.get("key"):\n raise HTTPException(503, "OPNsense not configured")\n try:\n result = _opnsense_request(cfg, "dhcpv4/reservations/addReservation", "POST", {\n "reservation": {\n "interface": body.iface,\n "mac": body.mac,\n "ipaddr": body.ip,\n "hostname": body.hostname,\n "descr": body.descr or f"Added by switch-manager",\n }\n })\n # Apply changes\n _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")\n log.info(f"OPNsense reservation pushed: {body.mac}{body.ip}")\n return {"success": True, "result": result}\n except ValueError as e:\n raise HTTPException(400, str(e))\n\n@app.post("/api/dhcp/sync")\ndef sync_reservation(body: SyncRequest):\n """\n Sync a reservation between switch and OPNsense.\n Directions:\n to_switch — copy OPNsense reservation to switch\n to_opnsense — copy switch reservation to OPNsense\n remove_switch — remove from switch only\n remove_opnsense — remove from OPNsense only\n """\n require_session(body.token)\n cfg = _load_opnsense_cfg()\n overview = dhcp_overview()\n\n # Find the device in both sources\n sw_res = next((r for r in overview["switch"]["reservations"] if r["mac"]==body.mac), None)\n ops_res = next((r for r in overview["opnsense"]["reservations"] if r["mac"]==body.mac), None)\n\n if body.direction == "to_switch":\n if not ops_res:\n raise HTTPException(404, "OPNsense reservation not found")\n cmds = _build_dhcp_reservation_cmds(type(\'D\',(),{\n "ip": ops_res["ip"], "mac": ops_res["mac"], "name": ops_res.get("hostname","")\n })())\n return push_one_by_one(cmds)\n\n elif body.direction == "to_opnsense":\n if not sw_res:\n raise HTTPException(404, "Switch reservation not found")\n if not cfg.get("key"):\n raise HTTPException(503, "OPNsense not configured")\n result = _opnsense_request(cfg, "dhcpv4/reservations/addReservation", "POST", {\n "reservation": {\n "mac": sw_res["mac"], "ipaddr": sw_res["ip"],\n "hostname": sw_res.get("hostname",""), "descr": "Synced from switch",\n "interface": "lan",\n }\n })\n _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")\n return {"success": True, "result": result}\n\n elif body.direction == "remove_switch":\n if not sw_res:\n raise HTTPException(404, "Switch reservation not found")\n return push_one_by_one([f"no ip dhcp-server static-binding {sw_res[\'ip\']}"])\n\n elif body.direction == "remove_opnsense":\n if not ops_res or not ops_res.get("uuid"):\n raise HTTPException(404, "OPNsense reservation not found or missing UUID")\n if not cfg.get("key"):\n raise HTTPException(503, "OPNsense not configured")\n _opnsense_request(cfg, f"dhcpv4/reservations/delReservation/{ops_res[\'uuid\']}", "DELETE")\n _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")\n return {"success": True}\n\n raise HTTPException(400, f"Unknown direction: {body.direction}")\n\n# ══════════════════════════════════════════════════════════════════════\n# CONTROL D / ctrld DNS MANAGEMENT\n# ══════════════════════════════════════════════════════════════════════\n\nCTRLD_FILE = _Path("/etc/switch-manager/ctrld.json")\n# NOTE: /usr/local/bin/ctrld is the Linux default path.\n# On OPNsense (FreeBSD) ctrld installs to /usr/local/sbin/ctrld.\n# For local-mode installs this path is checked at runtime, so it\'s fine.\n# For OPNsense mode the binary runs on the router, not here — the path is irrelevant.\nCTRLD_BIN = _Path("/usr/local/bin/ctrld")\n\ndef _load_ctrld_cfg() -> dict:\n """Load saved ctrld configuration (mode, vlan_profiles) from ctrld.json."""\n if CTRLD_FILE.exists():\n try: return _json.loads(CTRLD_FILE.read_text())\n except: pass\n return {}\n\ndef _save_ctrld_cfg(cfg: dict):\n """Persist ctrld configuration to ctrld.json (chmod 600 — contains Resolver IDs)."""\n CTRLD_FILE.write_text(_json.dumps(cfg, indent=2))\n CTRLD_FILE.chmod(0o600)\n\ndef _ctrld_status() -> dict:\n """Check if ctrld is running and which mode."""\n import subprocess as _sp\n try:\n r = _sp.run([str(CTRLD_BIN), "status"],\n capture_output=True, text=True, timeout=5)\n running = r.returncode == 0\n return {\n "installed": CTRLD_BIN.exists(),\n "running": running,\n "output": r.stdout.strip() or r.stderr.strip(),\n }\n except Exception as e:\n return {\n "installed": CTRLD_BIN.exists(),\n "running": False,\n "output": str(e),\n }\n\ndef _ctrld_config_path() -> _Path:\n """Find ctrld config file location."""\n candidates = [\n _Path("/etc/controld/ctrld.toml"),\n _Path("/usr/local/etc/controld/ctrld.toml"),\n _Path.home() / ".config" / "controld" / "ctrld.toml",\n ]\n for p in candidates:\n if p.exists(): return p\n return candidates[0] # default for new install\n\ndef _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,\n deploy_mode: str = "router") -> str:\n """\n Build a ctrld.toml using flat dotted-key section headers.\n\n Confirmed working architecture on OPNsense (verified after reboot):\n Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD\n\n Unbound stays on port 53 — never moved. ctrld binds to localhost only\n on port 5354 so it cannot conflict with Unbound at startup. Unbound\'s\n Query Forwarding sends all external queries through ctrld. Local DNS\n (host overrides, local zones) is handled entirely by Unbound before any\n query reaches ctrld, so no split-horizon rules are needed here.\n\n deploy_mode="router" — single listener on 127.0.0.1:ctrld_port.\n Unbound forwards upstream queries here via Query Forwarding.\n Per-VLAN policy differentiation is handled by Unbound (forward different\n domains to different ctrld instances on different ports if needed).\n\n deploy_mode="proxy" — single listener on 0.0.0.0:ctrld_port.\n Use when ctrld runs on a management host (not the router) and clients\n point directly at ctrld — CIDR-based policy routing applies.\n\n Flat header rule: never write a parent [listener] / [network] / [upstream]\n before the dotted subtables — Go\'s TOML v2 panics on table redefinition.\n """\n BOOTSTRAP = "76.76.2.0"\n\n active = [vp for vp in vlan_profiles\n if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()]\n\n lines = [\n "# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager",\n "# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),\n "# Docs: https://docs.controld.com/docs/ctrld",\n "",\n "[service]",\n " log_level = \\\'info\\\'",\n " log_path = \\\'/tmp/ctrld.log\\\'",\n "",\n ]\n\n # ── Upstream sections ─────────────────────────────────────────────────────\n for i, vp in enumerate(active):\n vid = vp["vlan_id"]\n name = vp.get("name", f"VLAN{vid}")\n protocol = (vp.get("protocol") or "doh3").strip()\n rid = vp.get("resolver_id", "").strip()\n endpoint = (\n vp.get("endpoint_url", "").strip()\n or f"https://dns.controld.com/{rid}"\n )\n lines += [\n f"# VLAN {vid}{name}",\n f"[upstream.{i}]",\n f" name = \\\'VLAN {vid} {name}\\\'",\n f" type = \\\'{protocol}\\\'",\n f" endpoint = \\\'{endpoint}\\\'",\n f" bootstrap_ip = \\\'{BOOTSTRAP}\\\'",\n f" timeout = 5000",\n "",\n ]\n\n # ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────\n if deploy_mode == "router":\n # Single listener on localhost — Unbound\'s Query Forwarding points here.\n # No per-VLAN listeners needed: Unbound handles all local resolution\n # before queries arrive; ctrld just proxies external queries upstream.\n lines += [\n "# Listens on localhost only — Unbound Query Forwarding sends external queries here",\n "[listener.0]",\n f" ip = \\\'127.0.0.1\\\'",\n f" port = {ctrld_port}",\n "",\n " [listener.0.policy]",\n " name = \\\'Default Policy\\\'",\n " networks = []",\n " rules = []",\n ]\n if active:\n lines[-1] = f" default = [\\\'upstream.0\\\']"\n lines.append("")\n\n # ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────\n else:\n lines += [\n "[listener.0]",\n f" ip = \\\'0.0.0.0\\\'",\n f" port = {ctrld_port}",\n "",\n " [listener.0.policy]",\n " name = \\\'VLAN Policy\\\'",\n ]\n\n if active:\n net_entries = [\n " { " + f"\\\'network.{i}\\\' = [\\\'upstream.{i}\\\']" + " },"\n for i in range(len(active))\n ]\n lines += [" networks = ["] + net_entries + [" ]"]\n else:\n lines += [" networks = []"]\n\n lines += [" rules = []", ""]\n\n # Network sections for CIDR routing\n for i, vp in enumerate(active):\n vid = vp["vlan_id"]\n name = vp.get("name", f"VLAN{vid}")\n subnet = vp.get("subnet", f"192.168.{vid}.0/24")\n lines += [\n f"# VLAN {vid}{name}",\n f"[network.{i}]",\n f" name = \\\'{name}\\\'",\n f" cidrs = [\\\'{subnet}\\\']",\n "",\n ]\n\n return "\\n".join(lines)\n\n\n\n# ── ctrld API models ─────────────────────────────────────────────────────────\n\nclass CtrldVlanProfile(BaseModel):\n vlan_id: int\n name: str\n subnet: str\n resolver_id: str # ControlD Resolver ID (path suffix)\n endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set\n protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy\n gateway: Optional[str] = "" # VLAN gateway IP on the router (e.g. "192.168.10.1")\n # Required for router-mode multi-listener TOML\n\nclass CtrldConfig(BaseModel):\n mode: str # "local" | "opnsense" | "manual"\n deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0)\n vlan_profiles: list[CtrldVlanProfile]\n opnsense_host: Optional[str] = ""\n ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)\n local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)\n\nclass CtrldInstallRequest(BaseModel):\n token: str\n config: CtrldConfig\n\nclass CtrldUpdateProfile(BaseModel):\n token: str\n vlan_profiles: list[CtrldVlanProfile]\n\n# ── ctrld endpoints ───────────────────────────────────────────────────────────\n\n@app.get("/api/ctrld/status")\ndef ctrld_status():\n """Return ctrld installation and running status."""\n cfg = _load_ctrld_cfg()\n status = _ctrld_status()\n return {\n **status,\n "configured": bool(cfg.get("mode")),\n "mode": cfg.get("mode",""),\n "vlan_profiles": cfg.get("vlan_profiles",[]),\n "opnsense_host": cfg.get("opnsense_host",""),\n "config_path": str(_ctrld_config_path()),\n "docs_url": "https://docs.controld.com/docs/ctrld",\n }\n\ndef _validate_doh_endpoint(endpoint_url: str) -> dict:\n """\n Test a DoH endpoint using a plain HTTPS GET query (RFC 8484).\n\n Works for both DoH and DoH3 endpoints — ControlD and most public resolvers\n serve DoH over HTTPS/2 at the same URL they use for DoH3, so a successful\n HTTP response proves the URL is live and well-formed before ctrld ever\n touches it.\n\n Sends: GET {url}?dns=<base64url(A? ping.controld.com)>\n Expects: 200, Content-Type: application/dns-message\n\n Returns: {ok, latency_ms, status_code, error}\n """\n import struct, base64, time, urllib.request, urllib.error\n\n # Build a minimal DNS A query for "ping.controld.com" in wire format\n def _make_query(domain: str = "ping.controld.com") -> bytes:\n hdr = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)\n qname = b""\n for label in domain.rstrip(".").split("."):\n qname += bytes([len(label)]) + label.encode()\n qname += b"\\x00"\n qtype = struct.pack(">HH", 1, 1) # A IN\n return hdr + qname + qtype\n\n dns_bytes = _make_query()\n dns_b64 = base64.urlsafe_b64encode(dns_bytes).rstrip(b"=").decode()\n test_url = f"{endpoint_url.rstrip(\'/\')}?dns={dns_b64}"\n t0 = time.monotonic()\n\n try:\n req = urllib.request.Request(\n test_url,\n headers={"Accept": "application/dns-message"},\n )\n with urllib.request.urlopen(req, timeout=8) as resp:\n latency_ms = int((time.monotonic() - t0) * 1000)\n ct = resp.headers.get("Content-Type", "")\n body = resp.read(12) # just enough to check it\'s a DNS response\n ok = resp.status == 200 and "dns-message" in ct\n return {\n "ok": ok,\n "latency_ms": latency_ms,\n "status_code": resp.status,\n "error": "" if ok else f"Unexpected Content-Type: {ct}",\n }\n except urllib.error.HTTPError as e:\n latency_ms = int((time.monotonic() - t0) * 1000)\n return {"ok": False, "latency_ms": latency_ms,\n "status_code": e.code, "error": str(e)}\n except Exception as e:\n latency_ms = int((time.monotonic() - t0) * 1000)\n return {"ok": False, "latency_ms": latency_ms,\n "status_code": 0, "error": str(e)}\n\n\nclass CtrldValidateRequest(BaseModel):\n vlan_profiles: list[CtrldVlanProfile]\n\n\n@app.post("/api/ctrld/validate-endpoints")\ndef ctrld_validate_endpoints(body: CtrldValidateRequest):\n """\n Test each profile\'s DoH/DoH3 endpoint URL before writing any config.\n Returns per-profile results plus an overall ok flag.\n Intended to be called from the UI before calling save-config.\n """\n results = []\n for vp in body.vlan_profiles:\n p = vp.dict()\n url = (p.get("endpoint_url") or "").strip()\n rid = (p.get("resolver_id") or "").strip()\n if not url and rid:\n url = f"https://dns.controld.com/{rid}"\n if not url:\n results.append({\n "vlan_id": p["vlan_id"],\n "name": p.get("name", ""),\n "ok": False,\n "error": "No endpoint URL or resolver_id provided",\n })\n continue\n probe = _validate_doh_endpoint(url)\n results.append({\n "vlan_id": p["vlan_id"],\n "name": p.get("name", ""),\n "url": url,\n **probe,\n })\n\n # Also validate the generated TOML can be parsed (catches structural issues)\n toml_ok = True\n toml_error = ""\n try:\n import sys\n if sys.version_info >= (3, 11):\n import tomllib\n tomllib.loads(_build_ctrld_toml([p.dict() for p in body.vlan_profiles]))\n # tomllib not available in 3.10 — skip structural check\n except Exception as e:\n toml_ok = False\n toml_error = str(e)\n\n return {\n "all_ok": all(r["ok"] for r in results) and toml_ok,\n "results": results,\n "toml_ok": toml_ok,\n "toml_error": toml_error,\n "toml_preview": _build_ctrld_toml([p.dict() for p in body.vlan_profiles]),\n }\n\n\n@app.get("/api/ctrld/toml-preview")\ndef ctrld_toml_preview():\n """Generate and return the ctrld.toml without installing it."""\n cfg = _load_ctrld_cfg()\n profiles = cfg.get("vlan_profiles", [])\n deploy_mode = cfg.get("deploy_mode", "router")\n ctrld_port = cfg.get("ctrld_port", 5354)\n if not profiles:\n raise HTTPException(400, "No VLAN profiles configured yet")\n toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)\n return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}\n\n@app.post("/api/ctrld/save-config")\ndef ctrld_save_config(body: CtrldInstallRequest):\n """\n Save ctrld configuration.\n Validates all endpoints before writing anything — returns 400 with per-profile\n probe results if any endpoint is unreachable so the user can fix it first.\n For \'local\' mode: installs ctrld on this machine, writes config, starts service.\n For \'opnsense\' mode: generates the SSH install command.\n For \'manual\' mode: saves config for reference, generates toml only.\n """\n require_session(body.token)\n\n profiles = [p.dict() for p in body.config.vlan_profiles]\n\n # ── Endpoint validation — block on failure ────────────────────────────────\n validation = ctrld_validate_endpoints(\n CtrldValidateRequest(vlan_profiles=body.config.vlan_profiles)\n )\n if not validation["all_ok"]:\n raise HTTPException(400, {\n "message": "One or more DNS endpoints failed validation — fix before saving",\n "results": validation["results"],\n "toml_error": validation.get("toml_error", ""),\n })\n\n deploy_mode = body.config.deploy_mode or "router"\n ctrld_port = body.config.ctrld_port or 5354\n local_domain = body.config.local_domain or "lan"\n cfg_dict = {\n "mode": body.config.mode,\n "deploy_mode": deploy_mode,\n "vlan_profiles": profiles,\n "opnsense_host": body.config.opnsense_host,\n "ctrld_port": ctrld_port,\n "local_domain": local_domain,\n }\n _save_ctrld_cfg(cfg_dict)\n\n toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)\n\n if body.config.mode == "local":\n return _ctrld_install_local(toml, profiles)\n elif body.config.mode == "opnsense":\n return _ctrld_generate_opnsense_cmd(\n body.config.opnsense_host, profiles, deploy_mode,\n ctrld_port=ctrld_port, local_domain=local_domain,\n )\n else:\n # Manual — just return the toml and instructions\n return {\n "success": True,\n "mode": "manual",\n "toml": toml,\n "message": "Config saved. Install ctrld manually and use the toml below.",\n "install_cmd": "sh -c \'sh -c \\"$(curl -sL https://api.controld.com/dl)\\"\'",\n "config_path": str(_ctrld_config_path()),\n }\n\ndef _fix_port53_conflict() -> dict:\n """\n Detect and fix systemd-resolved holding port 53 (common on Ubuntu/Debian).\n\n systemd-resolved\'s stub listener binds 127.0.0.53:53 and sometimes 0.0.0.0:53,\n which blocks ctrld from binding port 53. The right fix is to disable only the\n stub listener — NOT the service itself (the service still handles /etc/resolv.conf\n and local hostname resolution).\n\n Returns a dict with keys: needed (bool), fixed (bool), message (str).\n """\n import subprocess as _sp\n\n # Check if systemd-resolved is running and holding port 53\n try:\n ss_out = _sp.run(\n ["ss", "-tlnp", "sport", "=", ":53"],\n capture_output=True, text=True, timeout=5\n ).stdout\n if "systemd-resolve" not in ss_out and "resolved" not in ss_out:\n return {"needed": False, "fixed": False,\n "message": "No port 53 conflict detected"}\n except Exception:\n return {"needed": False, "fixed": False,\n "message": "Could not check port 53 status (ss not available)"}\n\n log.info("systemd-resolved is holding port 53 — disabling stub listener")\n\n resolved_conf = _Path("/etc/systemd/resolved.conf")\n try:\n current = resolved_conf.read_text() if resolved_conf.exists() else ""\n except Exception as e:\n return {"needed": True, "fixed": False,\n "message": f"Cannot read {resolved_conf}: {e}"}\n\n # Already fixed?\n if "DNSStubListener=no" in current:\n _sp.run(["systemctl", "restart", "systemd-resolved"], capture_output=True)\n return {"needed": True, "fixed": True,\n "message": "DNSStubListener=no already present — restarted systemd-resolved"}\n\n # Add the setting under [Resolve], creating the section if needed\n if "[Resolve]" in current:\n new_conf = current.rstrip() + "\\nDNSStubListener=no\\n"\n else:\n new_conf = current.rstrip() + "\\n[Resolve]\\nDNSStubListener=no\\n"\n\n try:\n resolved_conf.write_text(new_conf)\n except PermissionError:\n return {"needed": True, "fixed": False,\n "message": "Permission denied writing /etc/systemd/resolved.conf — run backend as root or with sudo"}\n\n restart = _sp.run(["systemctl", "restart", "systemd-resolved"],\n capture_output=True, text=True)\n if restart.returncode != 0:\n return {"needed": True, "fixed": False,\n "message": f"Added DNSStubListener=no but systemd-resolved restart failed: {restart.stderr}"}\n\n log.info("Port 53 conflict resolved — systemd-resolved stub listener disabled")\n return {"needed": True, "fixed": True,\n "message": "Disabled systemd-resolved stub listener (DNSStubListener=no) and restarted service"}\n\n\ndef _ctrld_install_local(toml: str, profiles: list) -> dict:\n """Download and install ctrld on this machine, write config, start service."""\n import subprocess as _sp, platform as _platform\n\n # Detect architecture\n arch = _platform.machine().lower()\n os_name = _platform.system().lower()\n\n if os_name != "linux":\n return {\n "success": False,\n "mode": "local",\n "message": f"Auto-install only supported on Linux. "\n f"Download ctrld from https://github.com/Control-D-Inc/ctrld/releases",\n "toml": toml,\n }\n\n # Use ctrld\'s own installer with the first profile\'s resolver ID\n first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)\n if not first_rid:\n return {"success": False, "message": "No Resolver ID provided"}\n\n # Fix port 53 conflict BEFORE installing ctrld — on Ubuntu/Debian, systemd-resolved\n # holds port 53 and ctrld cannot bind. Disabling the stub listener is safe:\n # systemd-resolved keeps running for /etc/resolv.conf management.\n port53_fix = _fix_port53_conflict()\n log.info(f"Port 53 check: {port53_fix[\'message\']}")\n\n # Download the binary directly (more reliable than the shell installer for service control)\n log.info("Installing ctrld...")\n\n # Use the official installer\n install_result = _sp.run(\n f\'sh -c \\\'sh -c "$(curl -sL https://api.controld.com/dl)" -s {first_rid} forced\\\'\',\n shell=True, capture_output=True, text=True, timeout=120\n )\n\n if install_result.returncode != 0 and not CTRLD_BIN.exists():\n return {\n "success": False,\n "mode": "local",\n "message": f"Install failed: {install_result.stderr or install_result.stdout}",\n "toml": toml,\n "port53": port53_fix,\n }\n\n # Write our multi-VLAN config (overrides the default single-profile config)\n cfg_path = _ctrld_config_path()\n cfg_path.parent.mkdir(parents=True, exist_ok=True)\n cfg_path.write_text(toml)\n log.info(f"ctrld config written to {cfg_path}")\n\n # Restart ctrld to pick up new config\n _sp.run([str(CTRLD_BIN), "stop"], capture_output=True)\n _sp.run([str(CTRLD_BIN), "start"], capture_output=True)\n\n # Update DHCP on switch — set DNS option 6 per VLAN pool to this machine\'s IP\n import socket as _sock\n try:\n mgmt_ip = _sock.gethostbyname(_sock.gethostname())\n except Exception:\n mgmt_ip = "192.168.99.50"\n\n status = _ctrld_status()\n return {\n "success": status["running"],\n "mode": "local",\n "message": "ctrld installed and running" if status["running"] else "ctrld installed but may not be running — check logs",\n "dns_ip": mgmt_ip,\n "dhcp_action": f"Set DNS (option 6) to {mgmt_ip} on each VLAN pool in the DHCP tab",\n "toml": toml,\n "config_path": str(cfg_path),\n "docs": "https://docs.controld.com/docs/ctrld",\n "port53": port53_fix,\n }\n\ndef _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,\n deploy_mode: str = "router",\n ctrld_port: int = 5354,\n local_domain: str = "lan") -> dict:\n """\n Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.\n\n Confirmed working architecture (verified after reboot — no manual intervention needed):\n Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD\n\n Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot\n conflict with Unbound at startup regardless of service start order.\n Unbound\'s Query Forwarding sends external queries through ctrld.\n Local DNS (host overrides, custom zones) is answered by Unbound directly\n and never reaches ctrld.\n\n NOTE: Remove any \'home.arpa\' local-zone from Unbound if present — it is\n a common tutorial artifact that causes PTR/reverse DNS failures.\n """\n first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)\n if not first_rid:\n return {"success": False, "message": "No Resolver ID provided"}\n\n install_cmd = (\n f"sh -c \'sh -c \\"$(curl -sL https://api.controld.com/dl)\\" "\n f"-s {first_rid} forced\'"\n )\n\n toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)\n\n opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"\n write_toml_cmd = f"cat > {opnsense_cfg} << \'CTRLDEOF\'\\n{toml}\\nCTRLDEOF"\n\n setup_steps = [\n "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),\n "",\n "STEP 1 — Install ctrld on OPNsense (SSH or shell):",\n f" {install_cmd}",\n "",\n "STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port),\n f" {write_toml_cmd}",\n " Then restart ctrld: ctrld restart",\n "",\n "STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):",\n " OPNsense GUI → Services → Unbound DNS → Query Forwarding:",\n " • Enable Query Forwarding: checked",\n f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}",\n " • Use TLS: No (ctrld handles DoH/DoT upstream; plain DNS locally is fine)",\n " • Click Apply / Save",\n "",\n "STEP 4 — Remove \'home.arpa\' local-zone from Unbound if present:",\n " OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:",\n " Remove any line containing: local-zone: \\"home.arpa\\"",\n " (This is a tutorial artifact — it breaks reverse DNS / PTR lookups)",\n "",\n "STEP 5 — Verify (Unbound on :53 answers, ctrld proxies upstream):",\n " dig @192.168.1.1 google.com # external — goes through ctrld → ControlD",\n f" dig @192.168.1.1 myhost.{local_domain} # local — answered by Unbound directly",\n " dig @192.168.1.1 -x 192.168.1.1 # reverse PTR — answered by Unbound directly",\n ]\n\n return {\n "success": True,\n "mode": "opnsense",\n "message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port),\n "setup_steps": setup_steps,\n "architecture": "Unbound stays on :53. ctrld binds 127.0.0.1:{} only — no port conflict possible.".format(ctrld_port),\n "step1_install": install_cmd,\n "step1_ssh": f"ssh root@{opnsense_host or \'your-opnsense-ip\'} \'{install_cmd}\'",\n "step2_config": f"Write {opnsense_cfg} with the TOML below, then: ctrld restart",\n "step3_unbound": (\n f"Services → Unbound DNS → Query Forwarding: "\n f"Enable, add zone \'.\' → 127.0.0.1:{ctrld_port}, no TLS, Apply"\n ),\n "step4_cleanup": "Remove \'home.arpa\' local-zone from Unbound custom options if present",\n "step5_verify": "dig @router_ip google.com && dig @router_ip -x 192.168.1.1",\n "toml": toml,\n "toml_write_cmd": write_toml_cmd,\n "config_path": opnsense_cfg,\n "ctrld_port": ctrld_port,\n "local_domain": local_domain,\n "docs": "https://docs.controld.com/docs/routers-platform",\n }\n\n@app.post("/api/ctrld/update-profiles")\ndef ctrld_update_profiles(body: CtrldUpdateProfile):\n """Update VLAN profiles and regenerate/reload ctrld config."""\n require_session(body.token)\n cfg = _load_ctrld_cfg()\n cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles]\n _save_ctrld_cfg(cfg)\n\n deploy_mode = cfg.get("deploy_mode", "router")\n ctrld_port = cfg.get("ctrld_port", 5354)\n toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)\n cfg_path = _ctrld_config_path()\n\n if cfg.get("mode") == "local" and cfg_path.exists():\n cfg_path.write_text(toml)\n import subprocess as _sp\n _sp.run([str(CTRLD_BIN), "stop"], capture_output=True)\n _sp.run([str(CTRLD_BIN), "start"], capture_output=True)\n return {"success": True, "message": "Profiles updated and ctrld reloaded", "toml": toml}\n\n return {"success": True, "message": "Profiles saved", "toml": toml,\n "note": "Restart ctrld manually to apply changes" if cfg.get("mode") == "opnsense" else ""}\n\n@app.delete("/api/ctrld/uninstall")\ndef ctrld_uninstall(token: str):\n """Stop ctrld, remove its binary, and delete saved config."""\n require_session(token)\n import subprocess as _sp\n if CTRLD_BIN.exists():\n _sp.run([str(CTRLD_BIN), "uninstall", "--cleanup"], capture_output=True)\n if CTRLD_FILE.exists():\n CTRLD_FILE.unlink()\n return {"success": True}\n\n\n# ── DNS enforcement ACL generation ──────────────────────────────────────────\n\nclass DnsEnforceRequest(BaseModel):\n """Request body for generating DNS enforcement ACLs."""\n token: str\n ctrld_ip: str # IP of the machine running ctrld (becomes the only allowed DNS target)\n vlan_ids: list[int] # VLANs to enforce (excludes VLAN 99 management)\n\n\ndef _build_dns_enforce_acls(ctrld_ip: str, vlans_info: list[dict]) -> list[str]:\n """\n Generate CLI commands for DNS enforcement ACLs on each VLAN interface.\n\n For each VLAN the ACL:\n - Permits UDP/TCP port 53 to ctrld_ip (allows DHCP-assigned DNS)\n - Denies UDP/TCP port 53 to anywhere (blocks direct DNS bypass e.g. 8.8.8.8)\n - Denies TCP port 853 to anywhere (blocks DNS-over-TLS bypass)\n - Permits everything else (internet still works)\n\n Without these rules a device can ignore DHCP-assigned DNS and use 8.8.8.8\n directly, bypassing all ctrld filtering entirely.\n\n vlans_info: list of { vlan_id: int, subnet: str } e.g. { vlan_id: 10, subnet: "192.168.10.0/24" }\n """\n # Validate ctrld IP — must be a bare IP address, no injection\n import ipaddress as _ip\n try:\n ctrld_addr = str(_ip.ip_address(ctrld_ip))\n except ValueError:\n raise ValueError(f"ctrld_ip: invalid IP address {repr(ctrld_ip)}")\n\n cmds = []\n for vi in vlans_info:\n vid = san_vid(vi["vlan_id"])\n subnet = vi.get("subnet", f"192.168.{vid}.0/24")\n\n # Parse subnet into network/wildcard for ERS ACL syntax\n try:\n net = _ip.ip_network(subnet, strict=False)\n net_str = str(net.network_address)\n wild = str(_ip.ip_address(int(net.hostmask)))\n except ValueError:\n net_str = f"192.168.{vid}.0"\n wild = "0.0.0.255"\n\n acl_name = f"DNS-ENFORCE-VLAN{vid}"\n cmds += [\n f"ip access-list extended {acl_name}",\n # 1 & 2: permit DNS to ctrld only (DHCP-assigned resolver)\n f" 1 permit udp {net_str} {wild} host {ctrld_addr} eq 53",\n f" 2 permit tcp {net_str} {wild} host {ctrld_addr} eq 53",\n # 3 & 4: deny DNS to anywhere else (block 8.8.8.8 and friends)\n f" 3 deny udp {net_str} {wild} any eq 53",\n f" 4 deny tcp {net_str} {wild} any eq 53",\n # 5: deny DNS-over-TLS (port 853) so devices can\'t use DoT as bypass\n f" 5 deny tcp {net_str} {wild} any eq 853",\n # 6: permit everything else — internet still works\n f" 6 permit ip any any",\n # Apply inbound on the VLAN interface\n f"interface vlan {vid}",\n f" ip access-group {acl_name} in",\n ]\n\n return cmds\n\n\n@app.post("/api/ctrld/dns-enforce-acls")\ndef ctrld_dns_enforce_acls(body: DnsEnforceRequest):\n """\n Generate DNS enforcement ACL commands for the requested VLANs.\n\n Returns the raw CLI commands for review — the caller then pushes them\n via the normal TOTP-gated push endpoint. This endpoint only generates;\n it does NOT push anything to the switch itself.\n """\n require_session(body.token)\n _require_advanced_license()\n\n # Refuse to touch VLAN 99 (management) — a broken ACL there = lockout\n safe_vlans = [v for v in body.vlan_ids if v != 99]\n if not safe_vlans:\n raise HTTPException(400, "No safe VLANs to enforce — VLAN 99 is excluded automatically")\n\n vlans_info = [{"vlan_id": v} for v in safe_vlans]\n try:\n cmds = _build_dns_enforce_acls(body.ctrld_ip, vlans_info)\n except ValueError as e:\n raise HTTPException(400, str(e))\n\n return {\n "success": True,\n "commands": cmds,\n "count": len(cmds),\n "note": "Review these commands then push via the Review & Push tab",\n "vlans": safe_vlans,\n "ctrld_ip": body.ctrld_ip,\n }\n\n\n# ── Local hostname resolution (dnsmasq) ──────────────────────────────────────\n\nLOCAL_HOSTNAMES_FILE = _Path("/etc/switch-manager/local-hostnames.json")\nDNSMASQ_CONF_PATH = _Path("/etc/switch-manager/dnsmasq.conf")\n\n\ndef _load_local_hostnames() -> list:\n """Load user-defined hostname→IP mappings for .lan resolution."""\n if LOCAL_HOSTNAMES_FILE.exists():\n try: return _json.loads(LOCAL_HOSTNAMES_FILE.read_text())\n except: pass\n return []\n\n\ndef _save_local_hostnames(entries: list):\n """Persist hostname→IP mappings (used to generate dnsmasq.conf)."""\n LOCAL_HOSTNAMES_FILE.write_text(_json.dumps(entries, indent=2))\n\n\ndef _generate_dnsmasq_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str:\n """\n Build a dnsmasq.conf for local .lan hostname resolution.\n\n dnsmasq runs on port 5353 inside Docker alongside the switch manager.\n ctrld.toml forwards *.lan and *.local queries to 127.0.0.1:5353.\n This keeps local names working even when all external DNS goes through ctrld.\n\n entries: list of { name: str, ip: str }\n mgmt_ip: IP of the management computer (switch.mgmt.lan and management.lan point here)\n """\n import ipaddress as _ip\n\n lines = [\n "# dnsmasq — local .lan hostname resolution",\n "# Generated by Avaya 59100GTS-PWR+ Switch Manager",\n "# Listens on port 5353 (mapped from Docker container port 53)",\n "# ctrld forwards *.lan and *.local here",\n "",\n "port=53", # dnsmasq internal port (Docker maps host:5353 → container:53)\n "no-resolv", # don\'t use /etc/resolv.conf — this is a local-only resolver\n "no-hosts", # don\'t use /etc/hosts\n "domain-needed", # never forward bare names upstream\n "bogus-priv", # don\'t forward RFC1918 PTR queries upstream\n "",\n "# Management computer — always present",\n f"address=/switch.mgmt.lan/{mgmt_ip}",\n f"address=/management.lan/{mgmt_ip}",\n "",\n ]\n\n if entries:\n lines += ["# User-defined hostnames"]\n for e in entries:\n hostname = e.get("name","").strip()\n ip_addr = e.get("ip","").strip()\n if not hostname or not ip_addr:\n continue\n # Validate the IP — skip malformed entries\n try:\n _ip.ip_address(ip_addr)\n except ValueError:\n continue\n # Strip leading/trailing dots, sanitise hostname\n hostname = hostname.strip(".")\n if not hostname:\n continue\n lines.append(f"address=/{hostname}/{ip_addr}")\n\n return "\\n".join(lines) + "\\n"\n\n\ndef _generate_ctrld_split_horizon_block(local_domain: str = "lan",\n dnsmasq_port: int = 5353) -> str:\n """\n Generate an example ctrld.toml for split-horizon DNS with a local resolver.\n\n In the correct ctrld format, split-horizon is done by:\n 1. Adding [upstream.local] with type=\'legacy\' pointing to dnsmasq/Unbound\n 2. Adding rules in [listener.0.policy].rules that send *.lan → upstream.local\n\n Because the format uses indexed table sections ([network.N], [upstream.N]),\n you can\'t simply append a fragment — the full toml must be regenerated via\n _build_ctrld_toml(vlan_profiles). Note: on OPNsense, Unbound handles\n local resolution — ctrld does not need split-horizon rules at all.\n\n This function returns a plain-English example for display only.\n """\n port = dnsmasq_port\n domain = local_domain.strip(".")\n return "\\n".join([\n "# Add to your ctrld.toml — regenerate via DNS tab for correct indexing",\n "",\n "# In [listener.0.policy], add to the rules array:",\n f"# {{ \'*.{domain}\' = [\'upstream.local\'] }},",\n "# { \'*.local\' = [\'upstream.local\'] },",\n "",\n "# Add a new upstream section (increment index as needed):",\n "[upstream.local]",\n f" type = \'legacy\'",\n f" endpoint = \'127.0.0.1:{port}\'",\n f" timeout = 2000",\n "",\n f"# Then restart ctrld: ctrld restart",\n ])\n\n\nclass LocalHostnameEntry(BaseModel):\n name: str # e.g. "printer.lan"\n ip: str # e.g. "192.168.10.50"\n\n\nclass LocalHostnamesUpdate(BaseModel):\n token: str\n entries: list[LocalHostnameEntry]\n local_domain: Optional[str] = "lan"\n\n\n@app.get("/api/dns/local-hostnames")\ndef get_local_hostnames():\n """Return saved local hostname mappings and the generated dnsmasq.conf."""\n entries = _load_local_hostnames()\n import socket as _sock\n try:\n mgmt_ip = _sock.gethostbyname(_sock.gethostname())\n except Exception:\n mgmt_ip = "192.168.99.50"\n conf = _generate_dnsmasq_conf(entries, mgmt_ip)\n return {\n "entries": entries,\n "dnsmasq_conf": conf,\n "conf_path": str(DNSMASQ_CONF_PATH),\n }\n\n\n@app.post("/api/dns/local-hostnames")\ndef save_local_hostnames(body: LocalHostnamesUpdate):\n """\n Save local hostname mappings, write dnsmasq.conf, and return the updated\n ctrld.toml split-horizon block to append (user applies it via the DNS tab).\n """\n require_session(body.token)\n entries = [e.dict() for e in body.entries]\n _save_local_hostnames(entries)\n\n import socket as _sock\n try:\n mgmt_ip = _sock.gethostbyname(_sock.gethostname())\n except Exception:\n mgmt_ip = "192.168.99.50"\n\n conf = _generate_dnsmasq_conf(entries, mgmt_ip)\n DNSMASQ_CONF_PATH.parent.mkdir(parents=True, exist_ok=True)\n DNSMASQ_CONF_PATH.write_text(conf)\n\n # Regenerate ctrld.toml with split-horizon enabled (if ctrld is configured)\n ctrld_cfg = _load_ctrld_cfg()\n split_horizon_toml = None\n if ctrld_cfg.get("vlan_profiles"):\n split_horizon_toml = _build_ctrld_toml(\n ctrld_cfg["vlan_profiles"],\n ctrld_port=ctrld_cfg.get("ctrld_port", 5354),\n deploy_mode=ctrld_cfg.get("deploy_mode", "router"),\n )\n # Write new toml if running locally\n if ctrld_cfg.get("mode") == "local":\n cfg_path = _ctrld_config_path()\n if cfg_path.parent.exists():\n cfg_path.write_text(split_horizon_toml)\n\n split_horizon = _generate_ctrld_split_horizon_block(\n local_domain=body.local_domain or "lan"\n )\n\n return {\n "success": True,\n "entries": entries,\n "dnsmasq_conf": conf,\n "conf_path": str(DNSMASQ_CONF_PATH),\n "split_horizon": split_horizon,\n "full_toml": split_horizon_toml,\n "docker_compose_snippet": (\n " dnsmasq:\\n"\n " image: andyshinn/dnsmasq:latest\\n"\n " ports:\\n"\n " - \\"5353:53/udp\\"\\n"\n " - \\"5353:53/tcp\\"\\n"\n " volumes:\\n"\n " - /etc/switch-manager/dnsmasq.conf:/etc/dnsmasq.conf:ro\\n"\n " restart: unless-stopped\\n"\n " cap_add:\\n"\n " - NET_ADMIN\\n"\n ),\n "message": (\n f"Saved {len(entries)} hostname(s). "\n "Add the docker-compose snippet and split_horizon block to ctrld.toml, "\n "then run: docker compose up -d dnsmasq"\n ),\n }\n\n\n# ══════════════════════════════════════════════════════════════════════\n# OPNSENSE WIREGUARD — ROUTER-LEVEL VPN WITH PER-VLAN ACCESS CONTROL\n# ══════════════════════════════════════════════════════════════════════\n#\n# Moves WireGuard from the management computer onto OPNsense so any\n# device on any VLAN can VPN home without touching the management PC.\n# Each peer is granted access only to the VLANs you choose.\n#\n# Architecture:\n# OPNsense wg1 interface (10.99.2.0/24 — separate from local wg0)\n# Peer Alice → tunnel IP 10.99.2.2 → allowed VLAN 10 + VLAN 20\n# Peer Bob → tunnel IP 10.99.2.3 → allowed VLAN 10 only\n# Private keys are generated here and stored only on the mgmt PC.\n# OPNsense receives only the public key (standard WireGuard practice).\n\nOPN_WG_FILE = _Path("/etc/switch-manager/opnsense_wg.json")\n\n\ndef _load_opnsense_wg() -> dict:\n if OPN_WG_FILE.exists():\n try:\n return _json.loads(OPN_WG_FILE.read_text())\n except Exception:\n pass\n return {}\n\n\ndef _save_opnsense_wg(cfg: dict):\n OPN_WG_FILE.parent.mkdir(parents=True, exist_ok=True)\n OPN_WG_FILE.write_text(_json.dumps(cfg, indent=2))\n OPN_WG_FILE.chmod(0o600)\n\n\nclass OPNWGServerSetup(BaseModel):\n token: str\n server_name: str = "switch-mgmt-vpn"\n listen_port: int = 51820\n tunnel_subnet: str = "10.99.2.0/24"\n public_endpoint: str = "" # public IP or DDNS hostname for client configs\n\n\nclass OPNWGAddPeer(BaseModel):\n token: str\n name: str\n allowed_vlans: list # list of VLAN IDs: [10, 20, 30]\n vlan_subnets: dict # {10: "192.168.10.0/24", 20: "192.168.20.0/24", ...}\n\n\n@app.get("/api/opnsense/wireguard/status")\ndef opnsense_wg_status():\n """Check OPNsense WireGuard plugin, server, and peer state."""\n opn_cfg = _load_opnsense_cfg()\n if not opn_cfg:\n return {"opnsense_configured": False}\n\n wg = _load_opnsense_wg()\n\n # Probe for the WireGuard plugin — a 404 means the plugin isn\'t installed\n try:\n _opnsense_request(opn_cfg, "wireguard/server/searchServer")\n plugin_ok = True\n except ValueError as e:\n msg = str(e)\n # 404 → plugin absent; other errors → reachability / auth issue\n plugin_ok = False\n return {\n "opnsense_configured": True,\n "plugin_installed": False,\n "error": msg,\n "server": None,\n "peers": [],\n }\n\n # If we have a saved server UUID, fetch live info\n server_info = None\n if wg.get("server_uuid"):\n try:\n s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{wg[\'server_uuid\']}")\n srv = s.get("server", {})\n server_info = {\n "uuid": wg["server_uuid"],\n "name": srv.get("name", wg.get("server_name","")),\n "pubkey": srv.get("pubkey", wg.get("server_pubkey","")),\n "tunnel_ip": wg.get("server_tunnel_ip",""),\n "listen_port": wg.get("listen_port", 51820),\n "public_endpoint": wg.get("public_endpoint",""),\n }\n except Exception:\n # Server UUID no longer valid (e.g. OPNsense was reset)\n server_info = None\n\n # Merge OPNsense peer list with local metadata (which holds allowed_vlans)\n local_peers = {p["name"]: p for p in wg.get("peers", [])}\n opn_peers = []\n try:\n resp = _opnsense_request(opn_cfg, "wireguard/client/searchClient")\n opn_peers = resp.get("rows", [])\n except Exception:\n pass\n\n merged = []\n for p in opn_peers:\n name = p.get("name", "")\n loc = local_peers.get(name, {})\n merged.append({\n "uuid": p.get("uuid", ""),\n "name": name,\n "enabled": p.get("enabled", "0") == "1",\n "tunnel_ip": p.get("tunneladdress", ""),\n "allowed_vlans": loc.get("allowed_vlans", []),\n })\n\n return {\n "opnsense_configured": True,\n "plugin_installed": plugin_ok,\n "server": server_info,\n "peers": merged,\n }\n\n\n@app.post("/api/opnsense/wireguard/setup-server")\ndef opnsense_wg_setup_server(body: OPNWGServerSetup):\n """Create (or replace) a WireGuard server on OPNsense via its API."""\n require_session(body.token)\n opn_cfg = _load_opnsense_cfg()\n if not opn_cfg:\n raise HTTPException(400, "OPNsense not configured — connect it in the DHCP tab first")\n\n import ipaddress as _ip, time as _time\n\n try:\n net = _ip.ip_network(body.tunnel_subnet, strict=False)\n except Exception:\n raise HTTPException(400, "Invalid tunnel_subnet — use CIDR notation e.g. 10.99.2.0/24")\n\n server_tunnel_ip = f"{list(net.hosts())[0]}/{net.prefixlen}"\n\n wg = _load_opnsense_wg()\n\n # Tear down any pre-existing server so we start clean\n if wg.get("server_uuid"):\n try:\n _opnsense_request(opn_cfg,\n f"wireguard/server/delServer/{wg[\'server_uuid\']}", method="POST")\n except Exception:\n pass\n\n payload = {\n "server": {\n "enabled": "1",\n "name": body.server_name,\n "instance": "1", # creates wg1 — leaves wg0 (local) untouched\n "port": str(body.listen_port),\n "tunneladdress": server_tunnel_ip,\n "dns": "",\n "peers": "",\n }\n }\n try:\n result = _opnsense_request(opn_cfg, "wireguard/server/addServer",\n method="POST", body=payload)\n except Exception as e:\n raise HTTPException(500, f"OPNsense rejected server creation: {e}")\n\n server_uuid = result.get("uuid","")\n if not server_uuid:\n raise HTTPException(500, "OPNsense did not return a server UUID")\n\n # Apply so OPNsense generates the keypair, then read it back\n try:\n _opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")\n except Exception:\n pass\n\n _time.sleep(1.5) # give the daemon a moment to generate keys\n server_pubkey = ""\n try:\n s = _opnsense_request(opn_cfg, f"wireguard/server/getServer/{server_uuid}")\n server_pubkey = s.get("server", {}).get("pubkey", "")\n except Exception:\n pass\n\n wg = {\n "server_uuid": server_uuid,\n "server_name": body.server_name,\n "listen_port": body.listen_port,\n "tunnel_subnet": body.tunnel_subnet,\n "server_tunnel_ip": server_tunnel_ip,\n "server_pubkey": server_pubkey,\n "public_endpoint": body.public_endpoint,\n "peers": [],\n }\n _save_opnsense_wg(wg)\n\n log.info(f"OPNsense WG server created: {body.server_name} uuid={server_uuid}")\n return {\n "success": True,\n "server_uuid": server_uuid,\n "server_pubkey": server_pubkey,\n "server_tunnel_ip": server_tunnel_ip,\n "listen_port": body.listen_port,\n }\n\n\n@app.delete("/api/opnsense/wireguard/server")\ndef opnsense_wg_delete_server(token: str):\n """Remove the WireGuard server from OPNsense and clear local state."""\n require_session(token)\n opn_cfg = _load_opnsense_cfg()\n if not opn_cfg:\n raise HTTPException(400, "OPNsense not configured")\n wg = _load_opnsense_wg()\n if not wg.get("server_uuid"):\n raise HTTPException(404, "No server is configured")\n try:\n _opnsense_request(opn_cfg,\n f"wireguard/server/delServer/{wg[\'server_uuid\']}", method="POST")\n _opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")\n except Exception as e:\n raise HTTPException(500, f"Failed to delete server: {e}")\n _save_opnsense_wg({})\n return {"success": True}\n\n\n@app.post("/api/opnsense/wireguard/add-peer")\ndef opnsense_wg_add_peer(body: OPNWGAddPeer):\n """\n Generate a WireGuard keypair, register the peer on OPNsense,\n link it to the server, and return a ready-to-use .conf for the client.\n The private key is stored only on the management PC (never sent to OPNsense).\n """\n require_session(body.token)\n opn_cfg = _load_opnsense_cfg()\n if not opn_cfg:\n raise HTTPException(400, "OPNsense not configured")\n wg = _load_opnsense_wg()\n if not wg.get("server_uuid"):\n raise HTTPException(400, "Set up the WireGuard server on OPNsense first")\n\n import ipaddress as _ip, re as _re\n\n # ── Allocate next free IP in the tunnel subnet ────────────────────\n net = _ip.ip_network(wg["tunnel_subnet"], strict=False)\n hosts = list(net.hosts())\n used = set()\n # Reserve the server\'s own tunnel IP\n m = _re.match(r\'(\\S+)/\\d+\', wg.get("server_tunnel_ip", ""))\n if m:\n used.add(m.group(1))\n for p in wg.get("peers", []):\n m2 = _re.match(r\'(\\S+)/\\d+\', p.get("tunnel_ip", ""))\n if m2:\n used.add(m2.group(1))\n\n peer_ip_obj = next((h for h in hosts if str(h) not in used), None)\n if not peer_ip_obj:\n raise HTTPException(400, "Tunnel subnet is full — no IPs available for new peer")\n peer_ip = f"{peer_ip_obj}/{net.prefixlen}"\n\n # ── Build the AllowedIPs list from chosen VLANs ───────────────────\n vlan_cidrs = []\n for vid in body.allowed_vlans:\n subnet = (body.vlan_subnets.get(str(vid))\n or body.vlan_subnets.get(int(vid))\n or f"192.168.{vid}.0/24")\n vlan_cidrs.append(subnet)\n # Always include the tunnel subnet so the client can reach the server\n allowed_ips = ", ".join([str(net)] + vlan_cidrs) if vlan_cidrs else str(net)\n\n # ── Generate keypair (private key stays on mgmt PC only) ─────────\n c_priv, c_pub = _wg_genkey_api()\n\n # ── Register peer (client) on OPNsense ───────────────────────────\n peer_payload = {\n "client": {\n "enabled": "1",\n "name": body.name,\n "pubkey": c_pub,\n "psk": "",\n "tunneladdress": peer_ip,\n "serveraddress": "",\n "serverport": "",\n "keepalive": "25",\n }\n }\n try:\n result = _opnsense_request(opn_cfg, "wireguard/client/addClient",\n method="POST", body=peer_payload)\n except Exception as e:\n raise HTTPException(500, f"OPNsense rejected peer creation: {e}")\n\n peer_uuid = result.get("uuid", "")\n if not peer_uuid:\n raise HTTPException(500, "OPNsense did not return a peer UUID")\n\n # ── Link peer to server (append to server\'s peers list) ──────────\n try:\n s = _opnsense_request(opn_cfg,\n f"wireguard/server/getServer/{wg[\'server_uuid\']}")\n srv = s.get("server", {})\n existing = srv.get("peers", "")\n new_peers = f"{existing},{peer_uuid}" if existing else peer_uuid\n _opnsense_request(opn_cfg,\n f"wireguard/server/setServer/{wg[\'server_uuid\']}", method="POST",\n body={"server": {**srv, "peers": new_peers}})\n except Exception as e:\n log.warning(f"Could not link peer to server (peer still registered): {e}")\n\n # Apply config\n try:\n _opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")\n except Exception:\n pass\n\n # ── Build client .conf ────────────────────────────────────────────\n server_pubkey = wg.get("server_pubkey", "")\n endpoint_host = wg.get("public_endpoint", "") or "<YOUR-OPNSENSE-PUBLIC-IP>"\n endpoint_port = wg.get("listen_port", 51820)\n tunnel_gw = wg.get("server_tunnel_ip", "").split("/")[0]\n\n client_conf = (\n f"[Interface]\\n"\n f"PrivateKey = {c_priv}\\n"\n f"Address = {peer_ip}\\n"\n f"DNS = {tunnel_gw}\\n\\n"\n f"[Peer]\\n"\n f"PublicKey = {server_pubkey or \'<SERVER_PUBKEY>\'}\\n"\n f"Endpoint = {endpoint_host}:{endpoint_port}\\n"\n f"AllowedIPs = {allowed_ips}\\n"\n f"PersistentKeepalive = 25\\n"\n )\n\n # ── Persist peer metadata locally ────────────────────────────────\n peer_meta = {\n "uuid": peer_uuid,\n "name": body.name,\n "pub_key": c_pub,\n "priv_key": c_priv, # NEVER sent to OPNsense\n "tunnel_ip": peer_ip,\n "allowed_vlans": body.allowed_vlans,\n "allowed_ips": allowed_ips,\n "config": client_conf,\n }\n peers = [p for p in wg.get("peers", []) if p.get("name") != body.name]\n peers.append(peer_meta)\n wg["peers"] = peers\n _save_opnsense_wg(wg)\n\n log.info(f"OPNsense WG peer added: {body.name}{peer_ip} VLANs={body.allowed_vlans}")\n return {\n "success": True,\n "uuid": peer_uuid,\n "name": body.name,\n "tunnel_ip": peer_ip,\n "allowed_vlans": body.allowed_vlans,\n "config": client_conf,\n }\n\n\n@app.delete("/api/opnsense/wireguard/peer/{uuid}")\ndef opnsense_wg_remove_peer(uuid: str, token: str):\n """Remove a peer from OPNsense and from local metadata."""\n require_session(token)\n opn_cfg = _load_opnsense_cfg()\n if not opn_cfg:\n raise HTTPException(400, "OPNsense not configured")\n wg = _load_opnsense_wg()\n\n # Remove from OPNsense\n try:\n _opnsense_request(opn_cfg,\n f"wireguard/client/delClient/{uuid}", method="POST")\n except Exception as e:\n raise HTTPException(500, f"Failed to remove peer from OPNsense: {e}")\n\n # Unlink from server peers list\n if wg.get("server_uuid"):\n try:\n s = _opnsense_request(opn_cfg,\n f"wireguard/server/getServer/{wg[\'server_uuid\']}")\n srv = s.get("server", {})\n existing = srv.get("peers", "")\n updated = ",".join(p for p in existing.split(",") if p and p != uuid)\n _opnsense_request(opn_cfg,\n f"wireguard/server/setServer/{wg[\'server_uuid\']}", method="POST",\n body={"server": {**srv, "peers": updated}})\n except Exception:\n pass\n\n # Apply\n try:\n _opnsense_request(opn_cfg, "wireguard/service/reconfigure", method="POST")\n except Exception:\n pass\n\n wg["peers"] = [p for p in wg.get("peers", []) if p.get("uuid") != uuid]\n _save_opnsense_wg(wg)\n return {"success": True}\n\n\n@app.get("/api/opnsense/wireguard/peer-config/{name}")\ndef opnsense_wg_peer_config(name: str):\n """Return the saved .conf text for a named peer (includes private key)."""\n wg = _load_opnsense_wg()\n peer = next((p for p in wg.get("peers", []) if p["name"] == name), None)\n if not peer:\n raise HTTPException(404, f"Peer \'{name}\' not found in local store")\n return {"name": name, "config": peer.get("config", "")}\n\n\n# ══════════════════════════════════════════════════════════════════════\n# OPNSENSE SSH SHELL ACCESS + UNBOUND MANAGEMENT\n# ══════════════════════════════════════════════════════════════════════\n# SSH via exec_command() bypasses the OPNsense console menu automatically —\n# the menu only appears for interactive login sessions.\n\n@app.post("/api/opnsense/ssh/generate-key")\ndef opnsense_ssh_generate_key():\n """Generate an ed25519 key pair for SSH access to OPNsense."""\n import subprocess as _sp2\n key = OPNSENSE_SSH_KEY\n if key.exists():\n pub = key.with_suffix(".pub")\n return {\n "generated": False,\n "key_path": str(key),\n "public_key": pub.read_text().strip() if pub.exists() else "",\n "note": "Key already exists — use existing or delete to regenerate",\n }\n key.parent.mkdir(parents=True, exist_ok=True)\n r = _sp2.run(\n ["ssh-keygen", "-t", "ed25519", "-f", str(key), "-N", "", "-C", "switch-manager@opnsense"],\n capture_output=True, text=True,\n )\n if r.returncode != 0:\n raise HTTPException(500, f"ssh-keygen failed: {r.stderr}")\n key.chmod(0o600)\n pub = key.with_suffix(".pub")\n return {\n "generated": True,\n "key_path": str(key),\n "public_key": pub.read_text().strip(),\n "note": "Add this public key to OPNsense: System → Access → Users → root → Authorized Keys",\n }\n\n@app.post("/api/opnsense/configure-ssh")\ndef configure_opnsense_ssh(body: OPNsenseSSHConfig):\n """Save SSH key path and test connectivity. Pins the host key if pin_host_key=True."""\n cfg = _load_opnsense_cfg()\n if not cfg.get("host"):\n raise HTTPException(400, "OPNsense API must be configured first (needs host)")\n cfg["ssh_key_path"] = body.ssh_key_path\n cfg["ssh_user"] = body.ssh_user\n if body.pin_host_key:\n # Connect with AutoAdd to capture and pin the host key\n client = paramiko.SSHClient()\n client.set_missing_host_key_policy(paramiko.AutoAddPolicy())\n try:\n client.connect(\n hostname=cfg["host"],\n username=cfg["ssh_user"],\n key_filename=cfg["ssh_key_path"],\n timeout=10,\n look_for_keys=False,\n allow_agent=False,\n )\n OPNSENSE_KNOWN_HOSTS.parent.mkdir(parents=True, exist_ok=True)\n client.save_host_keys(str(OPNSENSE_KNOWN_HOSTS))\n OPNSENSE_KNOWN_HOSTS.chmod(0o600)\n client.close()\n except Exception as e:\n raise HTTPException(400, f"SSH connect failed: {e}")\n result = _opnsense_ssh_test(cfg)\n if not result["ok"]:\n raise HTTPException(400, f"SSH test failed: {result[\'error\']}")\n _save_opnsense_cfg(cfg)\n log.info(f"OPNsense SSH configured: {cfg[\'host\']} user={body.ssh_user}")\n return {"success": True, "version": result["version"]}\n\n@app.get("/api/opnsense/ssh-status")\ndef opnsense_ssh_status():\n """Check SSH connectivity to OPNsense."""\n cfg = _load_opnsense_cfg()\n if not cfg.get("ssh_key_path"):\n return {"configured": False, "connected": False, "error": "SSH not configured"}\n result = _opnsense_ssh_test(cfg)\n return {\n "configured": True,\n "connected": result["ok"],\n "version": result.get("version", ""),\n "error": result.get("error", ""),\n "key_path": cfg.get("ssh_key_path", ""),\n "ssh_user": cfg.get("ssh_user", "root"),\n }\n\n@app.post("/api/opnsense/ssh/run")\ndef opnsense_ssh_run(body: dict):\n """Run a shell command on OPNsense. Requires TOTP session for write commands."""\n import re as _re2\n cmd = body.get("cmd", "").strip()\n token = body.get("token", "")\n if not cmd:\n raise HTTPException(400, "cmd required")\n # Read-only commands (no token needed); anything else needs auth\n readonly = bool(_re2.match(r\'^(cat|ls|uname|drill|dig|unbound-control\\s+status|service\\s+unbound\\s+status)\', cmd))\n if not readonly:\n require_session(token)\n cfg = _load_opnsense_cfg()\n if not cfg.get("ssh_key_path"):\n raise HTTPException(503, "OPNsense SSH not configured")\n out, err, code = _opnsense_ssh_run(cfg, cmd)\n return {"stdout": out, "stderr": err, "exit_code": code, "ok": code == 0}\n\n@app.get("/api/opnsense/unbound/status")\ndef opnsense_unbound_status():\n """Read Unbound config files and test for .lan DNS leak."""\n cfg = _load_opnsense_cfg()\n if not cfg.get("ssh_key_path"):\n raise HTTPException(503, "OPNsense SSH not configured")\n result = {}\n # Read each relevant config file\n for fname in ("local-lan-zone.conf", "forward_to_ctrld.conf", "dot.conf"):\n out, _, code = _opnsense_ssh_run(cfg, f"cat {UNBOUND_ETC}/{fname} 2>/dev/null")\n result[fname] = out.strip() if code == 0 else None\n # Test for .lan leak — check if SOA answer comes from ControlD\n out, _, _ = _opnsense_ssh_run(cfg, "drill @127.0.0.1 nonexistent.lan 2>/dev/null")\n result["lan_leak_test_raw"] = out.strip()\n result["lan_leak_detected"] = "controld" in out.lower()\n result["lan_handled_locally"] = "lan." in out.lower() and "controld" not in out.lower()\n # Unbound running?\n out, _, code = _opnsense_ssh_run(cfg, "unbound-control status 2>/dev/null | head -2")\n result["unbound_running"] = code == 0\n result["unbound_status"] = out.strip()\n return result\n\n@app.post("/api/opnsense/unbound/reload")\ndef opnsense_unbound_reload():\n """Reload Unbound on OPNsense to apply config changes."""\n cfg = _load_opnsense_cfg()\n if not cfg.get("ssh_key_path"):\n raise HTTPException(503, "OPNsense SSH not configured")\n out, err, code = _opnsense_ssh_run(cfg, "unbound-control reload 2>&1")\n if code != 0:\n raise HTTPException(500, f"unbound-control reload failed: {err or out}")\n return {"success": True, "output": out.strip()}\n\n@app.post("/api/opnsense/unbound/fix-lan-zone")\ndef opnsense_unbound_fix_lan_zone():\n """\n Ensure .lan queries are handled locally and never forwarded to ControlD.\n\n Writes local-lan-zone.conf with the correct static zone declaration,\n reloads Unbound, and runs a leak test to confirm the fix works.\n """\n cfg = _load_opnsense_cfg()\n if not cfg.get("ssh_key_path"):\n raise HTTPException(503, "OPNsense SSH not configured")\n steps = []\n errors = []\n # Write the correct local-lan-zone.conf via SFTP\n lan_zone_conf = \'local-zone: "lan." static\\n\'\n try:\n _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf)\n steps.append("Wrote local-lan-zone.conf: local-zone \\"lan.\\" static")\n except Exception as e:\n errors.append(f"Write local-lan-zone.conf: {e}")\n raise HTTPException(500, "; ".join(errors))\n # Verify unbound-checkconf before reloading\n out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1")\n if code != 0:\n errors.append(f"unbound-checkconf: {err or out}")\n raise HTTPException(500, "; ".join(errors))\n steps.append("unbound-checkconf: OK")\n # Reload\n out, err, code = _opnsense_ssh_run(cfg, "unbound-control reload 2>&1")\n if code != 0:\n errors.append(f"unbound-control reload: {err or out}")\n raise HTTPException(500, "; ".join(errors))\n steps.append("Unbound reloaded")\n # Leak test (give Unbound a moment to come back up)\n import time as _time2\n _time2.sleep(1)\n out, _, _ = _opnsense_ssh_run(cfg, "drill @127.0.0.1 nonexistent.lan 2>/dev/null")\n leak = "controld" in out.lower()\n if leak:\n steps.append("DNS test: LEAK STILL DETECTED — check dot.conf for conflicting forward-zones")\n else:\n steps.append("DNS test: PASS — nonexistent.lan answered locally (no ControlD leak)")\n return {\n "success": not leak,\n "steps": steps,\n "leak_detected": leak,\n "dns_test_output": out.strip(),\n }\n\n@app.post("/api/opnsense/unbound/write-forward-ctrld")\ndef opnsense_unbound_write_forward_ctrld(body: dict):\n """\n Write forward_to_ctrld.conf with the correct forward-zone for ctrld.\n\n Body: {token, ctrld_port (default 5354), enabled (default true)}\n Reloads Unbound after writing.\n """\n require_session(body.get("token", ""))\n cfg = _load_opnsense_cfg()\n if not cfg.get("ssh_key_path"):\n raise HTTPException(503, "OPNsense SSH not configured")\n port = int(body.get("ctrld_port", 5354))\n enabled = body.get("enabled", True)\n if enabled:\n content = (\n "forward-zone:\\n"\n f" name: \\".\\"\\n"\n f" forward-addr: 127.0.0.1@{port}\\n"\n )\n else:\n content = (\n "# forward-zone disabled\\n"\n "# forward-zone:\\n"\n f"# name: \\".\\"\\n"\n f"# forward-addr: 127.0.0.1@{port}\\n"\n )\n try:\n _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/forward_to_ctrld.conf", content)\n except Exception as e:\n raise HTTPException(500, f"Write failed: {e}")\n out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1")\n if code != 0:\n raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}")\n _opnsense_ssh_run(cfg, "unbound-control reload 2>&1")\n return {"success": True, "content": content, "enabled": enabled, "port": port}\n'
JSX_SRC = 'import { useState, useCallback, useEffect, useRef } from "react";\n\nconst FONT = `@import url(\'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow:wght@300;400;500;600;700&display=swap\');`;\nconst VLAN_COLORS = ["#00e5ff","#00e676","#ffea00","#ff6d00","#d500f9","#ff1744","#76ff03","#2979ff","#ff4081","#18ffff"];\n\nconst DEFAULT_VLANS = [\n { id: 1, name: "Management", color: VLAN_COLORS[0] },\n { id: 10, name: "Staff", color: VLAN_COLORS[1] },\n { id: 20, name: "Servers", color: VLAN_COLORS[2] },\n { id: 30, name: "IoT", color: VLAN_COLORS[3] },\n { id: 40, name: "Guest", color: VLAN_COLORS[4] },\n { id: 50, name: "Cameras", color: VLAN_COLORS[5] },\n];\n\nconst mkPort = n => ({\n id: n, mode: "access", accessVlan: 1, taggedVlans: [],\n nativeVlan: 1, poe: true, poeLimit: 30000, description: "",\n});\nconst DEFAULT_PORTS = Array.from({ length: 52 }, (_, i) => mkPort(i + 1));\nDEFAULT_PORTS.forEach((p, i) => {\n if (i >= 48) {\n p.mode = "trunk"; p.taggedVlans = [1,10,20,30,40,50];\n p.poe = false; p.description = `SFP+ Uplink ${i-47}`;\n }\n});\n\nconst VISITOR_ID = Math.random().toString(36).slice(2);\n\n// ── CLI Generator with explanations ────────────────────────────────────────\n// Each entry: { cmd: string, explain: string, group: string }\nfunction generateAnnotatedCLI({ ports, vlans, acls, hostname }) {\n const entries = [];\n const h = hostname || "ERS-5952";\n\n const add = (cmd, explain, group) => entries.push({ cmd, explain, group });\n\n add(`hostname ${h}`, `Set switch hostname to "${h}"`, "System");\n\n vlans.forEach(v => {\n add(`vlan create ${v.id} name "${v.name}" type port`,\n `Create VLAN ${v.id} named "${v.name}" — port-based VLAN`, "VLANs");\n });\n\n ports.forEach(p => {\n const iface = p.id <= 48 ? `FastEthernet ${p.id}` : `GigabitEthernet ${p.id}`;\n const portLabel = p.description ? `port ${p.id} (${p.description})` : `port ${p.id}`;\n const vlanName = vlans.find(v => v.id === p.accessVlan)?.name || `VLAN ${p.accessVlan}`;\n const group = `Port ${p.id}${p.description ? ` — ${p.description}` : ""}`;\n\n if (p.description) {\n add(`interface ${iface}`, `Select ${portLabel} for configuration`, group);\n add(` name "${p.description}"`, `Label ${portLabel} as "${p.description}"`, group);\n }\n\n if (p.mode === "disabled") {\n add(`interface ${iface}`, `Select ${portLabel}`, group);\n add(` shutdown`, `Disable ${portLabel} — no traffic will pass`, group);\n } else if (p.mode === "access") {\n add(`vlan members add ${p.accessVlan} ${p.id}`,\n `Assign ${portLabel} to ${vlanName} (VLAN ${p.accessVlan})`, group);\n add(`vlan pvid ${p.id} ${p.accessVlan}`,\n `Set ${portLabel} untagged VLAN to ${vlanName} — devices here join ${vlanName}`, group);\n } else if (p.mode === "trunk") {\n const tagged = p.taggedVlans;\n const names = tagged.map(id => vlans.find(v=>v.id===id)?.name || `VLAN ${id}`).join(", ");\n if (tagged.length) {\n add(`vlan members add ${tagged.join(",")} ${p.id}`,\n `Add ${portLabel} to VLANs: ${names}`, group);\n add(`vlan tagging ${tagged.join(",")} ${p.id}`,\n `Tag traffic on ${portLabel} for VLANs: ${names} — used for uplinks and inter-switch connections`, group);\n }\n const nativeName = vlans.find(v=>v.id===p.nativeVlan)?.name || `VLAN ${p.nativeVlan}`;\n add(`vlan pvid ${p.id} ${p.nativeVlan}`,\n `Set ${portLabel} native (untagged) VLAN to ${nativeName}`, group);\n }\n\n if (p.id <= 48) {\n add(`interface ${iface}`, `Select ${portLabel} for PoE configuration`, group);\n if (!p.poe) {\n add(` no poe enable`, `Disable Power over Ethernet on ${portLabel}`, group);\n } else {\n add(` poe enable`, `Enable Power over Ethernet on ${portLabel}`, group);\n add(` poe poe-limit ${p.poeLimit}`,\n `Limit PoE draw on ${portLabel} to ${(p.poeLimit/1000).toFixed(1)}W — protects switch power budget`, group);\n }\n }\n });\n\n acls.forEach(acl => {\n const vlanName = vlans.find(v=>v.id===acl.applyVlan)?.name || `VLAN ${acl.applyVlan}`;\n const group = `ACL: ${acl.name}`;\n add(`ip access-list extended ${acl.name}`,\n `Create access control list named "${acl.name}"`, group);\n acl.rules.forEach((r, i) => {\n const src = r.srcAny ? "any source" : `source ${r.src}`;\n const dst = r.dstAny ? "any destination" : `destination ${r.dst}`;\n const port = r.port ? ` on port ${r.port}` : "";\n add(` ${i+1} ${r.action} ${r.proto} ${r.srcAny?"any":`${r.src} ${r.srcMask||"0.0.0.255"}`} ${r.dstAny?"any":`${r.dst} ${r.dstMask||"0.0.0.255"}`}${r.port?` eq ${r.port}`:""}`,\n `Rule ${i+1}: ${r.action.toUpperCase()} ${r.proto.toUpperCase()} from ${src} to ${dst}${port}`, group);\n });\n add(`interface vlan ${acl.applyVlan}`,\n `Select ${vlanName} interface to apply the ACL`, group);\n add(` ip access-group ${acl.name} ${acl.direction}`,\n `Apply "${acl.name}" to ${vlanName} — filter traffic going ${acl.direction === "in" ? "INTO" : "OUT OF"} this VLAN`, group);\n });\n\n return entries;\n}\n\nfunction entriesToCLI(entries, hostname) {\n const lines = [\n `! ERS 5952 — ${hostname || "ERS-5952"}`,\n "enable", "configure terminal", "",\n ];\n entries.forEach(e => lines.push(e.cmd));\n lines.push("", "end", "copy running-config nvram:config.cfg");\n return lines.join("\\n");\n}\n\n// ── API ─────────────────────────────────────────────────────────────────────\nconst API = async (path, opts = {}) => {\n const r = await fetch(`/api${path}`, {\n headers: { "Content-Type": "application/json" },\n ...opts,\n body: opts.body ? JSON.stringify(opts.body) : undefined,\n });\n const data = await r.json().catch(() => ({}));\n if (!r.ok) throw new Error(data.detail?.message || data.detail || `HTTP ${r.status}`);\n return data;\n};\n\n// ══════════════════════════════════════════════════════════════════════════════\n// CSS\n// ══════════════════════════════════════════════════════════════════════════════\nconst css = `\n${FONT}\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root{\n --bg:#0a0c10;--sf:#111318;--b1:#1e2230;--b2:#2a2f42;\n --ac:#00e5ff;--ac2:#0077ff;--tx:#c8d0e0;--dm:#5a6070;\n --err:#ff1744;--ok:#00e676;--warn:#ffea00;--warn2:#ff6d00;\n --mono:\'Share Tech Mono\',monospace;--sans:\'Barlow\',sans-serif;\n}\nbody{background:var(--bg);color:var(--tx);font-family:var(--sans);font-size:13px;line-height:1.5;min-height:100vh}\n.app{display:flex;flex-direction:column;min-height:100vh}\n\n/* banner */\n.conn-banner{display:flex;align-items:center;gap:10px;padding:8px 20px;font-size:12px;font-family:var(--mono);border-bottom:1px solid;transition:all .4s}\n.conn-banner.connecting{background:rgba(255,234,0,.06);border-color:rgba(255,234,0,.2);color:var(--warn)}\n.conn-banner.connected{background:rgba(0,230,118,.06);border-color:rgba(0,230,118,.2);color:var(--ok)}\n.conn-banner.error{background:rgba(255,23,68,.06);border-color:rgba(255,23,68,.2);color:var(--err)}\n.conn-banner.hidden{display:none}\n.conn-spin{width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}\n@keyframes spin{to{transform:rotate(360deg)}}\n.conn-dot{width:8px;height:8px;border-radius:50%;background:currentColor;flex-shrink:0}\n\n/* topbar */\n.topbar{display:flex;align-items:center;gap:14px;padding:10px 20px;background:var(--sf);border-bottom:1px solid var(--b1);position:sticky;top:0;z-index:100;flex-wrap:wrap}\n.logo{font-family:var(--mono);font-size:14px;color:var(--ac);letter-spacing:2px}\n.logo-sub{font-size:10px;color:var(--dm);font-family:var(--mono)}\n.sp{flex:1}\n.ti{background:var(--bg);border:1px solid var(--b2);color:var(--tx);padding:4px 8px;font-family:var(--mono);font-size:12px;border-radius:3px;width:130px}\n.ti:focus{outline:none;border-color:var(--ac)}\n.tl{font-size:11px;color:var(--dm)}\n\n/* session */\n.sess-btn{display:flex;align-items:center;gap:8px;padding:5px 12px;border-radius:4px;border:1px solid;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;cursor:pointer;font-family:var(--sans);transition:all .15s}\n.sess-btn.locked{border-color:var(--b2);color:var(--dm);background:transparent}\n.sess-btn.locked:hover{border-color:var(--ac);color:var(--ac)}\n.sess-btn.unlocked{border-color:var(--ok);color:var(--ok);background:rgba(0,230,118,.08)}\n.sess-btn.unlocked:hover{background:rgba(255,23,68,.1);border-color:var(--err);color:var(--err)}\n.sess-timer{font-family:var(--mono);font-size:12px;letter-spacing:1px}\n\n/* settings icon */\n.settings-btn{background:none;border:1px solid var(--b2);color:var(--dm);border-radius:4px;padding:5px 9px;cursor:pointer;font-size:14px;transition:all .15s;line-height:1}\n.settings-btn:hover{border-color:var(--ac);color:var(--ac)}\n\n/* settings panel */\n.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:150;display:flex;align-items:flex-start;justify-content:flex-end}\n.settings-panel{background:var(--sf);border-left:1px solid var(--b2);width:320px;min-height:100vh;padding:20px}\n.settings-panel h2{font-family:var(--mono);font-size:13px;color:var(--ac);letter-spacing:2px;margin-bottom:16px}\n.settings-section{margin-bottom:20px;padding-bottom:20px;border-bottom:1px solid var(--b1)}\n.settings-section:last-child{border-bottom:none}\n.settings-section h3{font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--dm);margin-bottom:10px}\n.setting-row{display:flex;align-items:center;justify-content:space-between;padding:6px 0}\n.setting-label{font-size:12px}\n.setting-sub{font-size:11px;color:var(--dm);margin-top:2px}\n\n/* poll pill */\n.poll-pill{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--dm);font-family:var(--mono);padding:3px 8px;border:1px solid var(--b1);border-radius:20px}\n.dot{width:6px;height:6px;border-radius:50%}\n.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}.dot.err{background:var(--err)}.dot.idle{background:var(--dm)}\n\n/* tabs */\n.tabs{display:flex;gap:2px;padding:0 20px;background:var(--sf);border-bottom:1px solid var(--b1)}\n.tab{padding:10px 18px;font-size:12px;font-weight:600;letter-spacing:1px;text-transform:uppercase;cursor:pointer;border-bottom:2px solid transparent;color:var(--dm);transition:all .15s;background:none;border-top:none;border-left:none;border-right:none;font-family:var(--sans)}\n.tab:hover{color:var(--tx)}.tab.active{color:var(--ac);border-bottom-color:var(--ac)}\n\n/* main */\n.main{flex:1;padding:20px;display:flex;gap:16px;align-items:flex-start}\n\n/* panel */\n.panel{background:var(--sf);border:1px solid var(--b1);border-radius:6px;overflow:hidden}\n.ph{padding:10px 14px;border-bottom:1px solid var(--b1);font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--ac);font-family:var(--mono);display:flex;align-items:center;gap:8px}\n.pb{padding:14px}\n\n/* chassis */\n.chassis{background:#0d0f14;border:1px solid var(--b2);border-radius:8px;padding:16px;margin-bottom:12px}\n.cl{font-family:var(--mono);font-size:10px;color:var(--dm);letter-spacing:3px;text-transform:uppercase;margin-bottom:10px}\n.pgrid{display:grid;grid-template-columns:repeat(24,1fr);gap:4px}\n.pgrid-sfp{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:8px;padding-top:8px;border-top:1px solid var(--b1);width:calc(4*(100%/24)+3*4px)}\n.port{aspect-ratio:1;border-radius:3px;border:1px solid transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:8px;font-weight:700;transition:all .1s;position:relative}\n.port:hover{filter:brightness(1.3);transform:scale(1.1);z-index:2}\n.port.sel{border-color:white!important;box-shadow:0 0 0 2px white;z-index:3}\n.poe-dot{position:absolute;top:1px;right:1px;width:4px;height:4px;border-radius:50%;background:var(--ok)}\n.legend{display:flex;flex-wrap:wrap;gap:8px;padding:10px 14px;border-top:1px solid var(--b1)}\n.li{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--dm)}\n.ld{width:10px;height:10px;border-radius:2px}\n\n/* fields */\n.field{margin-bottom:12px}\n.field label{display:block;font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--dm);margin-bottom:4px}\n.field input,.field select{width:100%;background:var(--bg);border:1px solid var(--b2);color:var(--tx);padding:6px 8px;font-family:var(--mono);font-size:12px;border-radius:3px}\n.field input:focus,.field select:focus{outline:none;border-color:var(--ac)}\n.field select option{background:var(--bg)}\n.rgrp{display:flex;gap:6px}\n.rbtn{flex:1;padding:5px;text-align:center;border:1px solid var(--b2);border-radius:3px;cursor:pointer;font-size:11px;font-weight:600;letter-spacing:1px;text-transform:uppercase;transition:all .1s;background:none;color:var(--dm);font-family:var(--sans)}\n.rbtn:hover{border-color:var(--ac);color:var(--ac)}\n.rbtn.ra{background:var(--ac);border-color:var(--ac);color:#000}\n.rbtn.rt{background:var(--ac2);border-color:var(--ac2);color:#fff}\n.rbtn.rd{background:var(--err);border-color:var(--err);color:#fff}\n.trow{display:flex;align-items:center;justify-content:space-between;padding:6px 0}\n.tog{width:36px;height:20px;background:var(--b2);border-radius:10px;cursor:pointer;position:relative;transition:background .2s;border:none}\n.tog.on{background:var(--ok)}\n.tog::after{content:\'\';position:absolute;top:3px;left:3px;width:14px;height:14px;background:#fff;border-radius:50%;transition:transform .2s}\n.tog.on::after{transform:translateX(16px)}\n.vtags{display:flex;flex-wrap:wrap;gap:4px;padding:6px;background:var(--bg);border:1px solid var(--b2);border-radius:3px;min-height:34px}\n.vtag{padding:2px 7px;border-radius:2px;font-family:var(--mono);font-size:11px;cursor:pointer;font-weight:700;transition:opacity .1s}\n.vtag.von{opacity:1}.vtag.voff{opacity:.25}\n\n/* buttons */\n.btn{padding:7px 14px;border-radius:3px;border:none;cursor:pointer;font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;font-family:var(--sans);transition:all .1s;white-space:nowrap}\n.bp{background:var(--ac);color:#000}.bp:hover{background:#33ecff}\n.bd{background:var(--err);color:#fff}.bd:hover{background:#ff4569}\n.bg{background:transparent;border:1px solid var(--b2);color:var(--tx)}.bg:hover{border-color:var(--ac);color:var(--ac)}\n.bs{background:var(--ok);color:#000}.bs:hover{background:#33eb91}\n.bw{background:var(--warn);color:#000}.bw:hover{filter:brightness(1.1)}\n.btn:disabled{opacity:.4;cursor:not-allowed}\n.btn-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}\n\n/* vlan table */\n.vtbl{width:100%;border-collapse:collapse}\n.vtbl th{text-align:left;padding:6px 10px;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dm);border-bottom:1px solid var(--b1)}\n.vtbl td{padding:8px 10px;border-bottom:1px solid var(--b1);font-family:var(--mono);font-size:12px}\n.vtbl tr:last-child td{border-bottom:none}\n.vtbl tr:hover td{background:rgba(255,255,255,.02)}\n.sw{width:16px;height:16px;border-radius:3px;display:inline-block;vertical-align:middle}\n.ii{background:var(--bg);border:1px solid var(--b2);color:var(--tx);padding:3px 6px;font-family:var(--mono);font-size:12px;border-radius:3px;width:100%}\n.ii:focus{outline:none;border-color:var(--ac)}\n\n/* acl */\n.acl-card{background:var(--bg);border:1px solid var(--b2);border-radius:5px;overflow:hidden;margin-bottom:10px}\n.acl-hd{padding:8px 12px;display:flex;align-items:center;gap:8px;background:rgba(255,255,255,.03);border-bottom:1px solid var(--b1)}\n.acl-nm{font-family:var(--mono);font-size:13px;color:var(--ac);flex:1}\n.acl-rules{padding:8px 12px}\n.acl-rule{display:grid;grid-template-columns:60px 50px 1fr 1fr 60px 24px;gap:6px;align-items:center;margin-bottom:6px;font-size:11px}\n.acl-rule select,.acl-rule input{background:var(--sf);border:1px solid var(--b2);color:var(--tx);padding:3px 5px;font-family:var(--mono);font-size:11px;border-radius:3px;width:100%}\n\n/* ── ANNOTATED CLI TABLE ── */\n.cli-table{width:100%;border-collapse:collapse;font-size:12px;margin-bottom:12px}\n.cli-table th{text-align:left;padding:6px 10px;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dm);border-bottom:1px solid var(--b1);font-family:var(--sans)}\n.cli-group-header td{padding:8px 10px 4px;font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--ac);font-family:var(--mono);border-top:1px solid var(--b1);background:rgba(0,229,255,.04)}\n.cli-row{transition:background .1s}\n.cli-row:hover{background:rgba(255,255,255,.03)}\n.cli-row td{padding:5px 10px;border-bottom:1px solid rgba(255,255,255,.03);vertical-align:top}\n.cli-cmd{font-family:var(--mono);color:var(--tx);white-space:pre}\n.cli-explain{color:var(--dm);font-size:11px;padding-left:8px}\n.cli-row.pending td{opacity:.5}\n.cli-row.running td .cli-cmd{color:var(--warn)}\n.cli-row.running td .cli-explain{color:var(--warn)}\n.cli-row.success td .cli-cmd{color:var(--ok)}\n.cli-row.success td .cli-explain{color:#3a6040}\n.cli-row.failed td .cli-cmd{color:var(--err)}\n.cli-row.failed td .cli-explain{color:#6a2030}\n.cli-row.waiting td{opacity:.35}\n.cli-status{width:20px;text-align:center;font-size:13px}\n.cli-err-out{font-family:var(--mono);font-size:10px;color:var(--warn);padding:3px 10px 5px;background:rgba(255,23,68,.05);border-bottom:1px solid var(--b1)}\n\n/* raw cli textarea (cli mode) */\n.cli-raw{width:100%;background:#060809;border:1px solid var(--b1);color:#a0b0c0;padding:14px;font-family:var(--mono);font-size:12px;line-height:1.7;border-radius:4px;min-height:300px;resize:vertical}\n.cli-raw:focus{outline:none;border-color:var(--ac)}\n\n/* totp modal */\n.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;display:flex;align-items:center;justify-content:center}\n.modal{background:var(--sf);border:1px solid var(--b2);border-radius:8px;padding:28px;width:360px;text-align:center}\n.modal h2{font-family:var(--mono);color:var(--ac);font-size:14px;letter-spacing:2px;margin-bottom:8px}\n.modal p{font-size:12px;color:var(--dm);margin-bottom:20px;line-height:1.7}\n.totp-in{width:100%;background:var(--bg);border:2px solid var(--b2);color:var(--tx);padding:12px;font-family:var(--mono);font-size:28px;letter-spacing:10px;border-radius:4px;text-align:center;margin-bottom:12px}\n.totp-in:focus{outline:none;border-color:var(--ac)}\n\n/* danger */\n.hard-box{background:rgba(255,23,68,.07);border:1px solid rgba(255,23,68,.3);border-radius:4px;padding:10px 14px;margin-bottom:10px}\n.hard-box h4{color:var(--err);font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px}\n.warn-box{background:rgba(255,234,0,.07);border:1px solid rgba(255,234,0,.3);border-radius:4px;padding:10px 14px;margin-bottom:10px}\n.warn-box h4{color:var(--warn);font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:6px}\n.danger-item{font-size:11px;margin-bottom:5px}\n.danger-cmd{font-family:var(--mono);color:var(--tx)}\n.danger-why{font-size:10px;color:var(--dm);margin-top:1px}\n\n/* push mode choice */\n.push-choice{display:flex;gap:10px;margin-bottom:14px}\n.push-choice-btn{flex:1;padding:10px 12px;border-radius:5px;border:1px solid var(--b2);background:var(--bg);color:var(--tx);cursor:pointer;text-align:left;transition:all .15s;font-family:var(--sans)}\n.push-choice-btn:hover{border-color:var(--ac)}\n.push-choice-btn.chosen{border-color:var(--ac);background:rgba(0,229,255,.06)}\n.push-choice-btn h4{font-size:12px;font-weight:700;margin-bottom:3px;color:var(--ac)}\n.push-choice-btn p{font-size:11px;color:var(--dm);line-height:1.4}\n\n/* push summary bar */\n.push-summary{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:4px;font-size:12px;font-family:var(--mono);margin-bottom:10px}\n.push-summary.ok{background:rgba(0,230,118,.08);border:1px solid rgba(0,230,118,.2);color:var(--ok)}\n.push-summary.fail{background:rgba(255,23,68,.08);border:1px solid rgba(255,23,68,.2);color:var(--err)}\n.push-summary.running{background:rgba(255,234,0,.05);border:1px solid rgba(255,234,0,.15);color:var(--warn)}\n\n\n/* ── Device Access ── */\n.device-card{background:var(--bg);border:1px solid var(--b2);border-radius:4px;padding:10px 12px;margin-bottom:8px}\n.device-badges{display:flex;gap:6px;margin-top:6px;flex-wrap:wrap}\n/* misc */\n.sect{font-size:10px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--dm);margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--b1)}\n.empty{text-align:center;padding:40px 20px;color:var(--dm);font-size:12px}\n.badge{padding:1px 6px;border-radius:10px;font-size:10px;font-family:var(--mono);font-weight:700}\n.poe-bar-wrap{height:4px;background:var(--b2);border-radius:2px;margin-top:4px;overflow:hidden}\n.poe-bar{height:100%;border-radius:2px;background:var(--ok);transition:width .3s}\n`;\n\n// ══════════════════════════════════════════════════════════════════════════════\n// CONNECTION BANNER\n// ══════════════════════════════════════════════════════════════════════════════\nfunction ConnBanner({ state, info }) {\n if (state === "hidden") return null;\n return (\n <div className={`conn-banner ${state}`}>\n {state === "connecting" && <span className="conn-spin"/>}\n {state !== "connecting" && <span className="conn-dot"/>}\n <span>\n {state === "connecting" && "Connecting to switch..."}\n {state === "connected" && (info || "Connected")}\n {state === "error" && (info || "Switch unreachable — retrying...")}\n </span>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// TOTP MODAL\n// ══════════════════════════════════════════════════════════════════════════════\nfunction TotpModal({ onSuccess, onCancel, commandCount }) {\n const [code, setCode] = useState("");\n const [error, setError] = useState("");\n const [loading, setLoading] = useState(false);\n\n const verify = async () => {\n if (code.length !== 6) return;\n setLoading(true); setError("");\n try {\n const data = await API("/auth/verify", { method: "POST", body: { code } });\n onSuccess(data.token);\n } catch(e) {\n setError(e.message); setCode("");\n } finally { setLoading(false); }\n };\n\n return (\n <div className="modal-bg" onClick={onCancel}>\n <div className="modal" onClick={e => e.stopPropagation()}>\n <h2>◈ AUTHENTICATE</h2>\n <p>\n Enter your 6-digit TOTP code to authorize this push.<br/>\n <span style={{color:"var(--ac)"}}>{commandCount} command{commandCount!==1?"s":""}</span> ready to send.<br/>\n <span style={{color:"var(--dm)"}}>Session locks automatically when push completes.</span>\n </p>\n <input className="totp-in" value={code}\n onChange={e => setCode(e.target.value.replace(/\\D/g,"").slice(0,6))}\n onKeyDown={e => e.key==="Enter" && verify()}\n placeholder="000000" autoFocus maxLength={6}/>\n {error && <div style={{color:"var(--err)",fontSize:12,marginBottom:10}}>{error}</div>}\n <div style={{display:"flex",gap:8,justifyContent:"center"}}>\n <button className="btn bg" onClick={onCancel}>Cancel</button>\n <button className="btn bp" onClick={verify} disabled={code.length!==6||loading}>\n {loading ? "Verifying..." : "Authorize Push"}\n </button>\n </div>\n </div>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// SESSION BUTTON — shows "Authenticate to Push" only, no timer\n// Session is scoped to one push batch, lock happens automatically after push\n// ══════════════════════════════════════════════════════════════════════════════\nfunction SessionBtn({ session, onUnlock }) {\n if (session) {\n return (\n <div className="sess-btn unlocked" style={{cursor:"default"}}>\n 🔓 <span>Session Active</span>\n </div>\n );\n }\n return (\n <button className="sess-btn locked" onClick={onUnlock}>\n 🔒 <span>Authenticate to Push</span>\n </button>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// SETTINGS PANEL\n// ══════════════════════════════════════════════════════════════════════════════\nfunction SettingsPanel({ settings, setSettings, onClose }) {\n return (\n <div className="settings-overlay" onClick={onClose}>\n <div className="settings-panel" onClick={e => e.stopPropagation()}>\n <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:20}}>\n <h2>◈ SETTINGS</h2>\n <button className="btn bg" style={{padding:"3px 10px",fontSize:11}} onClick={onClose}>✕ Close</button>\n </div>\n\n <div className="settings-section">\n <h3>Interface</h3>\n <div className="setting-row">\n <div>\n <div className="setting-label">CLI Mode</div>\n <div className="setting-sub">Advanced — type raw ERS commands directly</div>\n </div>\n <button className={`tog ${settings.cliMode?"on":""}`}\n onClick={() => setSettings(s => ({...s, cliMode: !s.cliMode}))}/>\n </div>\n </div>\n\n <div className="settings-section">\n <h3>Push Behaviour</h3>\n <div className="setting-row">\n <div>\n <div className="setting-label">Default push mode</div>\n <div className="setting-sub">Batch or step-by-step</div>\n </div>\n <select value={settings.defaultPushMode}\n onChange={e => setSettings(s => ({...s, defaultPushMode: e.target.value}))}\n style={{background:"var(--bg)",border:"1px solid var(--b2)",color:"var(--tx)",padding:"4px 6px",fontFamily:"var(--mono)",fontSize:11,borderRadius:3}}>\n <option value="batch">Batch</option>\n <option value="step">Step-by-step</option>\n </select>\n </div>\n </div>\n\n <div className="settings-section">\n <h3>About</h3>\n <div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7}}>\n ERS 5952 Switch Manager<br/>\n Runs on your management computer on VLAN 99.<br/>\n SSH key lives on the management computer only.<br/>\n You never write CLI commands.<br/>\n <span style={{color:"var(--ac)"}}>README.md</span> has full documentation.\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// ANNOTATED CLI TABLE — the main inspect view\n// ══════════════════════════════════════════════════════════════════════════════\nfunction AnnotatedCLITable({ entries, pushState, currentIdx, pushMode, onConfirmOne }) {\n // Group entries by group field\n const groups = [];\n let lastGroup = null;\n entries.forEach((e, i) => {\n if (e.group !== lastGroup) {\n groups.push({ label: e.group, entries: [] });\n lastGroup = e.group;\n }\n groups[groups.length-1].entries.push({ ...e, globalIdx: i });\n });\n\n const getRowState = (globalIdx) => {\n if (!pushState) return "pending";\n if (globalIdx < currentIdx) return "success";\n if (globalIdx === currentIdx) return "running";\n return "waiting";\n };\n\n const getRowResult = (globalIdx) => {\n if (!pushState?.results) return null;\n return pushState.results.find(r => r.index === globalIdx);\n };\n\n return (\n <table className="cli-table">\n <thead>\n <tr>\n <th style={{width:24}}></th>\n <th>Command</th>\n <th>What it does</th>\n {pushMode === "step" && !pushState?.done && <th style={{width:80}}></th>}\n </tr>\n </thead>\n <tbody>\n {groups.map((grp, gi) => (\n <>\n <tr key={`g${gi}`} className="cli-group-header">\n <td colSpan={pushMode==="step" && !pushState?.done ? 4 : 3}>{grp.label}</td>\n </tr>\n {grp.entries.map((e, ei) => {\n const rowState = getRowState(e.globalIdx);\n const result = getRowResult(e.globalIdx);\n const isCurrentStep = pushMode === "step" && pushState && !pushState.done && e.globalIdx === currentIdx;\n return (\n <>\n <tr key={`e${gi}-${ei}`} className={`cli-row ${rowState}`}>\n <td className="cli-status">\n {rowState === "success" && "✓"}\n {rowState === "failed" && "✗"}\n {rowState === "running" && <span className="conn-spin" style={{display:"inline-block"}}/>}\n {(rowState === "pending" || rowState === "waiting") && "·"}\n </td>\n <td className="cli-cmd">{e.cmd}</td>\n <td className="cli-explain">{e.explain}</td>\n {pushMode === "step" && !pushState?.done && (\n <td>\n {isCurrentStep && (\n <button className="btn bs" style={{padding:"2px 10px",fontSize:10}}\n onClick={() => onConfirmOne(e.globalIdx)}>Send ↵</button>\n )}\n </td>\n )}\n </tr>\n {result && !result.success && result.output && (\n <tr key={`err${gi}-${ei}`}>\n <td colSpan={4} className="cli-err-out">\n Switch error: {result.output}\n </td>\n </tr>\n )}\n </>\n );\n })}\n </>\n ))}\n </tbody>\n </table>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// CLI & PUSH TAB\n// ══════════════════════════════════════════════════════════════════════════════\nfunction CliTab({ ports, vlans, acls, hostname, session, setSession, onNeedAuth, backendOk, settings }) {\n const annotated = generateAnnotatedCLI({ ports, vlans, acls, hostname });\n const pushCommands = annotated.map(e => e.cmd).filter(c => {\n const t = c.trim();\n return t && !t.startsWith("!");\n });\n\n const [stage, setStage] = useState("idle"); // idle|checking|danger|choosePushMode|pushing|done\n const [dangerResult, setDangerResult] = useState(null);\n const [pushMode, setPushMode] = useState(settings.defaultPushMode || "batch");\n const [pushState, setPushState] = useState(null);\n const [currentIdx, setCurrentIdx] = useState(0);\n const [rawCli, setRawCli] = useState("");\n\n // Reset when ports/vlans/acls change\n useEffect(() => { setStage("idle"); setPushState(null); setCurrentIdx(0); }, [ports, vlans, acls]);\n\n const handleReviewPush = async () => {\n setStage("checking");\n try {\n const check = await API("/check/danger", { method:"POST", body:{ commands: pushCommands } });\n setDangerResult(check);\n setStage("danger");\n } catch(e) {\n setDangerResult({ error: e.message }); setStage("danger");\n }\n };\n\n const proceedToChoose = () => setStage("choosePushMode");\n\n const startPush = async (mode, token) => {\n const t = token || session?.token;\n if (!t) { onNeedAuth(); return; }\n setPushMode(mode);\n setStage("pushing");\n setCurrentIdx(0);\n setPushState({ results: [], done: false });\n\n if (mode === "batch") {\n await executeBatch(t);\n }\n // step mode is driven by onConfirmOne\n };\n\n const executeBatch = async (token) => {\n try {\n const result = await API("/switch/push", {\n method:"POST",\n body: { token, commands: pushCommands }\n });\n setPushState({ ...result, done: true });\n setCurrentIdx(result.commands_sent || pushCommands.length);\n setStage("done");\n setSession(null); // lock session after push completes\n } catch(e) {\n setPushState({ success:false, error:e.message, results:[], commands_sent:0, done:true });\n setStage("done");\n setSession(null);\n }\n };\n\n const onConfirmOne = async (idx) => {\n if (!session?.token) { onNeedAuth(); return; }\n const cmd = pushCommands[idx];\n try {\n const result = await API("/switch/push", {\n method:"POST",\n body: { token: session.token, commands: [cmd] }\n });\n const r = result.results?.[0] || { index: idx, command: cmd, success: result.success, output: result.error || "", skipped: false };\n r.index = idx;\n setPushState(prev => {\n const results = [...(prev?.results||[]), r];\n if (!result.success) {\n setStage("done");\n setSession(null);\n return { ...prev, results, done: true, success: false, error: result.error };\n }\n const nextIdx = idx + 1;\n setCurrentIdx(nextIdx);\n if (nextIdx >= pushCommands.length) {\n // Save config\n API("/switch/push", { method:"POST", body:{ token: session.token, commands:["copy running-config nvram:config.cfg"] } })\n .catch(() => {});\n setStage("done");\n setSession(null);\n return { ...prev, results, done:true, success:true, saved:true };\n }\n return { ...prev, results };\n });\n } catch(e) {\n setStage("done"); setSession(null);\n setPushState(prev => ({ ...prev, done:true, success:false, error:e.message }));\n }\n };\n\n const handleRawPush = async () => {\n if (!session?.token) { onNeedAuth(); return; }\n const cmds = rawCli.split("\\n").filter(l => l.trim() && !l.trim().startsWith("!"));\n try {\n setStage("pushing");\n const result = await API("/switch/push", { method:"POST", body:{ token:session.token, commands:cmds } });\n setPushState({ ...result, done:true });\n setStage("done");\n setSession(null);\n } catch(e) {\n setPushState({ success:false, error:e.message, results:[], done:true });\n setStage("done"); setSession(null);\n }\n };\n\n const fullCLI = entriesToCLI(annotated, hostname);\n const cmdCount = pushCommands.length;\n\n // ── CLI Mode (raw text) ──\n if (settings.cliMode) {\n return (\n <div className="main" style={{flexDirection:"column"}}>\n <div className="panel">\n <div className="ph">◈ CLI Mode — Advanced\n <span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>Danger check and allowlist still apply</span>\n </div>\n <div className="pb">\n <div style={{marginBottom:8,fontSize:11,color:"var(--dm)"}}>\n Type or paste ERS CLI commands. Do not include enable, configure terminal, end, or copy — these are added automatically.\n </div>\n <textarea className="cli-raw" value={rawCli}\n onChange={e => setRawCli(e.target.value)}\n placeholder={"vlan create 100 name Finance type port\\nvlan members add 100 22\\nvlan pvid 22 100"}/>\n <div className="btn-row">\n <button className="btn bg" onClick={() => navigator.clipboard?.writeText(rawCli)}>Copy</button>\n <button className="btn bp" onClick={handleRawPush} disabled={!rawCli.trim()||!backendOk||stage==="pushing"}>\n {stage==="pushing" ? "Pushing..." : "Review & Push →"}\n </button>\n </div>\n {pushState?.done && (\n <div className={`push-summary ${pushState.success?"ok":"fail"}`} style={{marginTop:12}}>\n {pushState.success ? `✓ All commands succeeded — config saved` : `✗ Failed: ${pushState.error}`}\n </div>\n )}\n </div>\n </div>\n </div>\n );\n }\n\n // ── GUI Mode ──\n return (\n <div className="main" style={{flexDirection:"column"}}>\n <div className="panel">\n <div className="ph">\n ◈ Review & Push — {cmdCount} command{cmdCount!==1?"s":""}\n <span style={{marginLeft:"auto",display:"flex",gap:8}}>\n <button className="btn bg" onClick={() => navigator.clipboard?.writeText(fullCLI)}>Copy CLI</button>\n <button className="btn bg" onClick={() => {\n const a = document.createElement("a");\n a.href = URL.createObjectURL(new Blob([fullCLI],{type:"text/plain"}));\n a.download=`${hostname||"ers5952"}-config.txt`; a.click();\n }}>Download</button>\n {stage==="idle" && (\n <button className="btn bp" onClick={handleReviewPush} disabled={!backendOk||cmdCount===0}>\n {!backendOk ? "Backend Offline" : cmdCount===0 ? "No Changes" : "Review & Push →"}\n </button>\n )}\n </span>\n </div>\n <div className="pb">\n\n {/* ── Danger stage ── */}\n {stage==="danger" && dangerResult && (\n <div style={{marginBottom:14}}>\n {dangerResult.hard_blocked?.length > 0 && (\n <div className="hard-box">\n <h4>✗ Hard Blocked — Run These at the Switch Console</h4>\n {dangerResult.hard_blocked.map((d,i) => (\n <div key={i} className="danger-item">\n <div className="danger-cmd">{d.command}</div>\n <div className="danger-why">{d.reason}</div>\n </div>\n ))}\n <button className="btn bg" style={{marginTop:10}} onClick={()=>setStage("idle")}>Back</button>\n </div>\n )}\n {dangerResult.warnings?.length > 0 && (\n <div className="warn-box">\n <h4>⚠ Review These Commands</h4>\n {dangerResult.warnings.map((d,i) => (\n <div key={i} className="danger-item">\n <div className="danger-cmd">{d.command}</div>\n <div className="danger-why">{d.reason}</div>\n </div>\n ))}\n </div>\n )}\n {!dangerResult.has_hard_block && (\n <div className="btn-row">\n <button className="btn bg" onClick={()=>setStage("idle")}>Cancel</button>\n <button className="btn bs" onClick={proceedToChoose}>\n {dangerResult.has_warnings ? "Acknowledge & Continue →" : "Looks Good — Choose Push Mode →"}\n </button>\n </div>\n )}\n </div>\n )}\n\n {/* ── Push mode choice ── */}\n {stage==="choosePushMode" && (\n <div style={{marginBottom:14}}>\n <div className="sect">How do you want to push these {cmdCount} commands?</div>\n <div className="push-choice">\n <button className={`push-choice-btn ${pushMode==="batch"?"chosen":""}`}\n onClick={() => setPushMode("batch")}>\n <h4>⚡ Send All at Once</h4>\n <p>All commands sent in sequence. Results shown when complete. Faster — good when you\'ve reviewed and are confident.</p>\n </button>\n <button className={`push-choice-btn ${pushMode==="step"?"chosen":""}`}\n onClick={() => setPushMode("step")}>\n <h4>◈ Step by Step</h4>\n <p>Send one command at a time. Confirm each before the next is sent. Good for complex changes or when you want full control.</p>\n </button>\n </div>\n <div className="btn-row">\n <button className="btn bg" onClick={()=>setStage("danger")}>Back</button>\n {session\n ? <button className="btn bs" onClick={() => startPush(pushMode, session.token)}>\n Start Push →\n </button>\n : <button className="btn bw" onClick={onNeedAuth}>Authenticate First →</button>\n }\n </div>\n </div>\n )}\n\n {/* ── Push summary bar ── */}\n {(stage==="pushing" || stage==="done") && pushState && (\n <div className={`push-summary ${stage==="pushing"?"running":pushState.success?"ok":"fail"}`}>\n {stage==="pushing" && `Sending commands... (${currentIdx}/${cmdCount})`}\n {stage==="done" && pushState.success && `✓ All ${pushState.commands_sent} commands succeeded — config saved to NVRAM`}\n {stage==="done" && !pushState.success && `✗ Stopped at command ${(pushState.stopped_at||0)+1} — config NOT saved`}\n {stage==="done" && (\n <button className="btn bg" style={{marginLeft:"auto",padding:"2px 10px",fontSize:10}}\n onClick={() => { setStage("idle"); setPushState(null); setCurrentIdx(0); }}>\n Reset\n </button>\n )}\n </div>\n )}\n\n {/* ── Annotated command table ── */}\n {(stage==="idle" || stage==="checking" || stage==="pushing" || stage==="done") && (\n <AnnotatedCLITable\n entries={annotated}\n pushState={pushState}\n currentIdx={currentIdx}\n pushMode={pushMode}\n onConfirmOne={onConfirmOne}\n />\n )}\n\n {stage==="done" && !pushState?.success && pushState?.error && (\n <div style={{background:"rgba(255,23,68,.06)",border:"1px solid rgba(255,23,68,.2)",borderRadius:4,padding:"10px 14px",fontFamily:"var(--mono)",fontSize:11,color:"var(--err)"}}>\n {pushState.error}\n {pushState.hint && <div style={{color:"var(--dm)",marginTop:4}}>{pushState.hint}</div>}\n </div>\n )}\n\n {stage==="done" && (\n <div style={{marginTop:12,fontSize:11,color:"var(--dm)"}}>\n Session locked. To make more changes, configure them in the tabs above then click Review & Push and authenticate again.\n </div>\n )}\n </div>\n </div>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// PORT BUTTON + EDITOR (unchanged from previous version)\n// ══════════════════════════════════════════════════════════════════════════════\nfunction PortBtn({ port, vlans, selected, onClick }) {\n const vlan = vlans.find(v => v.id === port.accessVlan);\n let bg = "#1a1c22", color = "#3a4050";\n if (port.mode==="disabled") { bg="#13151a"; color="#2a2f3a"; }\n else if (port.mode==="trunk") { bg="#0a1a2e"; color="#2979ff"; }\n else if (vlan) { bg=vlan.color+"22"; color=vlan.color; }\n return (\n <button className={`port ${selected?"sel":""}`}\n style={{background:bg,color,borderColor:selected?"white":color+"66"}}\n onClick={() => onClick(port.id)}\n title={`Port ${port.id}${port.description?` — ${port.description}`:""}`}>\n {port.id}\n {port.poe && port.id<=48 && port.mode!=="disabled" && <span className="poe-dot"/>}\n </button>\n );\n}\n\nfunction PortEditor({ port, vlans, onChange }) {\n if (!port) return <div className="empty"><div style={{fontSize:28,marginBottom:8}}>◈</div>Select a port</div>;\n const isSFP = port.id > 48;\n const up = (k,v) => onChange({...port,[k]:v});\n const toggleTagged = vid => {\n const cur = port.taggedVlans||[];\n up("taggedVlans", cur.includes(vid) ? cur.filter(v=>v!==vid) : [...cur,vid]);\n };\n return (\n <div>\n <div className="sect">Port {port.id}{isSFP?" (SFP+)":""}</div>\n <div className="field">\n <label>Description</label>\n <input value={port.description} onChange={e=>up("description",e.target.value)} placeholder="e.g. AP-Corridor-1"/>\n </div>\n <div className="field">\n <label>Mode</label>\n <div className="rgrp">\n {["access","trunk","disabled"].map(m=>(\n <button key={m} className={`rbtn ${port.mode===m?m==="trunk"?"rt":m==="disabled"?"rd":"ra":""}`}\n onClick={()=>up("mode",m)}>{m}</button>\n ))}\n </div>\n </div>\n {port.mode==="access" && (\n <div className="field">\n <label>Access VLAN</label>\n <select value={port.accessVlan} onChange={e=>up("accessVlan",+e.target.value)}>\n {vlans.map(v=><option key={v.id} value={v.id}>{v.id}{v.name}</option>)}\n </select>\n </div>\n )}\n {port.mode==="trunk" && (<>\n <div className="field">\n <label>Native VLAN</label>\n <select value={port.nativeVlan} onChange={e=>up("nativeVlan",+e.target.value)}>\n {vlans.map(v=><option key={v.id} value={v.id}>{v.id}{v.name}</option>)}\n </select>\n </div>\n <div className="field">\n <label>Tagged VLANs</label>\n <div className="vtags">\n {vlans.map(v=>(\n <span key={v.id} className={`vtag ${port.taggedVlans?.includes(v.id)?"von":"voff"}`}\n style={{background:v.color+"33",color:v.color,border:`1px solid ${v.color}66`}}\n onClick={()=>toggleTagged(v.id)}>{v.id}</span>\n ))}\n </div>\n </div>\n </>)}\n {!isSFP && (<>\n <div className="trow">\n <span>PoE+</span>\n <button className={`tog ${port.poe?"on":""}`} onClick={()=>up("poe",!port.poe)}/>\n </div>\n {port.poe && (\n <div className="field" style={{marginTop:8}}>\n <label>PoE Limit (mW)</label>\n <input type="number" value={port.poeLimit} min={1000} max={30000} step={1000}\n onChange={e=>up("poeLimit",+e.target.value)}/>\n <div className="poe-bar-wrap"><div className="poe-bar" style={{width:`${port.poeLimit/300}%`}}/></div>\n </div>\n )}\n </>)}\n </div>\n );\n}\n\nfunction PortTab({ ports, vlans, selected, setSelected, updatePort, pollStatus }) {\n const port = ports.find(p=>p.id===selected);\n return (\n <div className="main">\n <div style={{flex:1}}>\n <div className="panel">\n <div className="ph">◈ ERS 5952 — Port Map\n {pollStatus==="stale" && <span style={{marginLeft:"auto",fontSize:10,color:"var(--warn)",fontFamily:"var(--mono)"}}>⚠ Data stale</span>}\n {pollStatus==="ok" && <span style={{marginLeft:"auto",fontSize:10,color:"var(--ok)",fontFamily:"var(--mono)"}}>● Live</span>}\n </div>\n <div className="pb">\n <div className="chassis">\n <div className="cl">48× GigE PoE+</div>\n <div className="pgrid">\n {ports.slice(0,48).map(p=><PortBtn key={p.id} port={p} vlans={vlans} selected={selected===p.id} onClick={setSelected}/>)}\n </div>\n <div className="cl" style={{marginTop:12}}>4× SFP+ Uplinks</div>\n <div className="pgrid-sfp">\n {ports.slice(48,52).map(p=><PortBtn key={p.id} port={p} vlans={vlans} selected={selected===p.id} onClick={setSelected}/>)}\n </div>\n </div>\n <div className="legend">\n {vlans.map(v=>(\n <div key={v.id} className="li"><span className="ld" style={{background:v.color}}/><span>VLAN {v.id}{v.name}</span></div>\n ))}\n <div className="li"><span className="ld" style={{background:"#0a1a2e",border:"1px solid #2979ff"}}/><span>Trunk</span></div>\n <div className="li"><span className="ld" style={{background:"#13151a"}}/><span>Disabled</span></div>\n </div>\n </div>\n </div>\n </div>\n <div className="panel" style={{width:280,flexShrink:0}}>\n <div className="ph">◈ Port Config</div>\n <div className="pb"><PortEditor port={port} vlans={vlans} onChange={updatePort}/></div>\n </div>\n </div>\n );\n}\n\nfunction VlanTab({ vlans, setVlans, ports }) {\n const [nid,setNid]=useState(""); const [nm,setNm]=useState("");\n const add = () => {\n const id=parseInt(nid);\n if(!id||id<1||id>4094||vlans.find(v=>v.id===id)) return;\n setVlans([...vlans,{id,name:nm||`VLAN-${id}`,color:VLAN_COLORS[vlans.length%VLAN_COLORS.length]}]);\n setNid(""); setNm("");\n };\n const cnt = vid => ports.filter(p=>p.mode==="access"?p.accessVlan===vid:p.taggedVlans?.includes(vid)).length;\n return (\n <div className="main">\n <div style={{flex:1}}>\n <div className="panel">\n <div className="ph">◈ VLAN Manager</div>\n <div className="pb">\n <table className="vtbl">\n <thead><tr><th>ID</th><th>Color</th><th>Name</th><th>Ports</th><th>Subnet</th><th></th></tr></thead>\n <tbody>\n {vlans.map(v=>(\n <tr key={v.id}>\n <td style={{color:"var(--ac)",fontWeight:700}}>{v.id}</td>\n <td><span className="sw" style={{background:v.color}}/></td>\n <td><input className="ii" value={v.name} onChange={e=>setVlans(vlans.map(x=>x.id===v.id?{...x,name:e.target.value}:x))}/></td>\n <td><span className="badge" style={{background:"var(--b2)"}}>{cnt(v.id)}</span></td>\n <td style={{color:"var(--dm)"}}>192.168.{v.id}.0/24</td>\n <td>{v.id!==1&&<button className="btn bd" style={{padding:"2px 8px",fontSize:10}} onClick={()=>setVlans(vlans.filter(x=>x.id!==v.id))}>✕</button>}</td>\n </tr>\n ))}\n </tbody>\n </table>\n <div style={{marginTop:16,paddingTop:12,borderTop:"1px solid var(--b1)"}}>\n <div className="sect">Add VLAN</div>\n <div style={{display:"flex",gap:8,alignItems:"flex-end"}}>\n <div className="field" style={{margin:0,width:90}}><label>ID</label><input value={nid} onChange={e=>setNid(e.target.value)} type="number" placeholder="100"/></div>\n <div className="field" style={{margin:0,flex:1}}><label>Name</label><input value={nm} onChange={e=>setNm(e.target.value)} placeholder="e.g. POS"/></div>\n <button className="btn bp" onClick={add}>Add</button>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n// ── ACL Templates ──────────────────────────────────────────────────────────\n// Each template is a factory function: takes { subnet, ctrldIp, nvrIp } and\n// returns { name, direction, rules[] } ready to paste into the ACL card list.\nconst ACL_TEMPLATES = [\n {\n id: "staff",\n label: "Staff VLAN — full internet, no management",\n description: "Allows everything except access to the management VLAN (99). Use on a staff or office VLAN.",\n params: ["subnet"],\n build: ({ subnet, vid }) => ({\n name: `STAFF-VLAN${vid}-POLICY`,\n direction: "in",\n rules: [\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"192.168.99.0", dstMask:"0.0.0.255", dstAny:false, port:"",\n _comment: "Block access to management VLAN 99" },\n { action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,\n dst:"", dstMask:"", dstAny:true, port:"",\n _comment: "Permit everything else" },\n ],\n }),\n },\n {\n id: "iot",\n label: "IoT / TV / Printer VLAN — internet only, strict isolation",\n description: "Blocks ALL private IP ranges (RFC1918). Devices get internet only — cannot reach any other VLAN, server, NAS, PBX, camera, or management network. Use for devices that need zero LAN access and have no local services to reach.",\n params: ["subnet"],\n build: ({ subnet, vid }) => ({\n name: `IOT-VLAN${vid}-POLICY`,\n direction: "in",\n rules: [\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"192.168.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",\n _comment: "Block all 192.168.x.x (other VLANs, management)" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"10.0.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",\n _comment: "Block 10.x.x.x" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"172.16.0.0",dstMask:"0.15.255.255", dstAny:false, port:"",\n _comment: "Block 172.16-31.x.x" },\n { action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,\n dst:"", dstMask:"", dstAny:true, port:"",\n _comment: "Permit internet" },\n ],\n }),\n },\n {\n id: "iot-local-services",\n label: "IoT VLAN — isolated + access local services via FQDN",\n description: [\n "For IoT devices that need to reach local servers (NAS, Home Assistant, etc.)",\n "by FQDN while remaining isolated from all other VLANs.",\n "",\n "What this ACL does:",\n " • Allows DNS queries to your resolver (so FQDNs resolve to LAN IPs)",\n " • Allows traffic to your servers VLAN subnet only",\n " • Blocks all other RFC1918 (user devices, cameras, management, etc.)",\n " • Allows internet",\n "",\n "How FQDN access works:",\n " Device queries \'nas.lan\' → DNS returns 192.168.20.X → ACL permits that IP",\n " The device never needs to know the IP. Works transparently.",\n "",\n "OFFLINE RESILIENCE — critical:",\n " If ctrld is running on the management PC, DNS fails when that PC is off",\n " or when internet is down (ctrld requires DoH3 connectivity to Control D).",\n " → Deploy ctrld on OPNsense (DNS tab → OPNsense mode) so it stays up",\n " independent of internet and independent of the management PC.",\n " → Enable split-horizon in DNS tab so *.lan queries go to local dnsmasq,",\n " which resolves from local-hostnames.json without any internet dependency.",\n " Result: \'nas.lan\' resolves correctly even with internet completely down.",\n "",\n "SERVERS VLAN security:",\n " Granting IoT access to the servers VLAN subnet means ALL servers on that",\n " subnet are reachable from IoT. Lock down the server VLAN inbound ACL to",\n " only permit the specific ports each service needs (e.g. TCP 8123 for Home",\n " Assistant, TCP 443 for internal HTTPS). Apply the ACL on the servers VLAN,",\n " not here — that way it applies regardless of which VLAN initiates.",\n ].join("\\n"),\n params: ["subnet", "ctrldIp", "serverSubnet"],\n build: ({ subnet, vid, ctrldIp, serverSubnet }) => {\n const srvNet = serverSubnet || "192.168.20.0";\n const dnsDst = ctrldIp || "RESOLVER_IP";\n return {\n name: `IOT-SVC-VLAN${vid}-POLICY`,\n direction: "in",\n rules: [\n { action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:dnsDst, dstMask:"", dstAny:false, port:"53",\n _comment: "DNS to resolver (UDP) — FQDNs resolve to LAN IPs" },\n { action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:dnsDst, dstMask:"", dstAny:false, port:"53",\n _comment: "DNS to resolver (TCP)" },\n { action:"permit", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:srvNet, dstMask:"0.0.0.255", dstAny:false, port:"",\n _comment: `Allow traffic to servers VLAN (${srvNet}/24)` },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"192.168.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",\n _comment: "Block all other 192.168.x.x (users, cameras, management)" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"10.0.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",\n _comment: "Block 10.x.x.x" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"172.16.0.0", dstMask:"0.15.255.255", dstAny:false, port:"",\n _comment: "Block 172.16-31.x.x" },\n { action:"permit", proto:"ip", src:"", srcMask:"", srcAny:true,\n dst:"", dstMask:"", dstAny:true, port:"",\n _comment: "Permit internet" },\n ],\n };\n },\n },\n {\n id: "guest",\n label: "Guest VLAN — internet only, DNS must work first",\n description: "Like IoT but DNS to ctrld is explicitly permitted first. Prevents guests from bypassing DNS filtering while still blocking all RFC1918 access.",\n params: ["subnet", "ctrldIp"],\n build: ({ subnet, vid, ctrldIp }) => ({\n name: `GUEST-VLAN${vid}-POLICY`,\n direction: "in",\n rules: [\n { action:"permit",proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:ctrldIp||"CTRLD_IP", dstMask:"", dstAny:false, port:"53",\n _comment: "Permit DNS to ctrld (DHCP-assigned resolver)" },\n { action:"permit",proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:ctrldIp||"CTRLD_IP", dstMask:"", dstAny:false, port:"53",\n _comment: "Permit DNS/TCP to ctrld" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"192.168.0.0",dstMask:"0.255.255.255",dstAny:false,port:"",\n _comment: "Block 192.168.x.x" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"10.0.0.0", dstMask:"0.255.255.255",dstAny:false,port:"",\n _comment: "Block 10.x.x.x" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"172.16.0.0",dstMask:"0.15.255.255", dstAny:false,port:"",\n _comment: "Block 172.16-31.x.x" },\n { action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,\n dst:"", dstMask:"", dstAny:true, port:"",\n _comment: "Permit internet" },\n ],\n }),\n },\n {\n id: "camera",\n label: "Camera VLAN — NVR only",\n description: "Cameras can only talk to one NVR/DVR IP. All other traffic is dropped. Prevents cameras from phoning home or scanning the network.",\n params: ["subnet", "nvrIp"],\n build: ({ subnet, vid, nvrIp }) => ({\n name: `CAMERA-VLAN${vid}-POLICY`,\n direction: "in",\n rules: [\n { action:"permit",proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:nvrIp||"NVR_IP", dstMask:"", dstAny:false, port:"",\n _comment: "Permit traffic to NVR/DVR only" },\n { action:"deny", proto:"ip", src:"", srcMask:"", srcAny:true,\n dst:"", dstMask:"", dstAny:true, port:"",\n _comment: "Drop everything else" },\n ],\n }),\n },\n {\n id: "voip",\n label: "SIP Phone VLAN — Asterisk / FreePBX (local desk phones)",\n description: [\n "USE THIS TEMPLATE FOR: dedicated desk phones or ATAs that are always on a fixed VLAN",\n "and register with Asterisk over the local network.",\n "",\n "Allows SIP signaling (UDP/TCP 5060), SIP/TLS (TCP 5061), and RTP audio",\n "(UDP 1000020000) to the PBX IP. Blocks management VLAN 99. Permits internet.",\n "",\n "The ERS 5952 performs L3 routing between VLANs — this switch ACL IS the",\n "enforcement point. No OPNsense inter-VLAN firewall rules needed for local SIP.",\n "Note: the ACL is subnet-based, so phones get any DHCP IP on the VLAN and",\n "it still works. For return RTP (PBX → phone), apply a matching permit on the",\n "server VLAN ACL if you have one.",\n "",\n "FOR MOBILE SOFTPHONES: skip this template entirely.",\n "Configure softphones with your public FQDN (e.g. pbx.yourdomain.com) and",\n "connect via OPNsense port forwarding — same path whether on home WiFi or",\n "cellular. This avoids VLAN roaming and MAC randomization issues completely.",\n "",\n " OPNsense setup for remote/FQDN softphone access:",\n " • NAT port forward: WAN:5061 → Asterisk IP:5061 (SIP/TLS)",\n " • NAT port forward: WAN:10000-20000 → Asterisk IP:10000-20000 (RTP)",\n " • Enable NAT reflection so home WiFi phones work via the same FQDN",\n " • Caddy reverse proxy for WebRTC: pbx.yourdomain.com → Asterisk:8089 (WSS)",\n " • Asterisk fail2ban essential — SIP port 5061 will be scanned",\n "",\n "IoT/TV/Printer VLANs: use the IoT template on those VLANs — it blocks all",\n "RFC1918 addresses including Asterisk. No additional config needed.",\n ].join("\\n"),\n params: ["subnet", "pbxIp"],\n build: ({ subnet, vid, pbxIp }) => ({\n name: `VOIP-VLAN${vid}-POLICY`,\n direction: "in",\n rules: [\n { action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5060", portEnd:"",\n _comment: "SIP signaling to PBX (UDP)" },\n { action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5060", portEnd:"",\n _comment: "SIP signaling to PBX (TCP)" },\n { action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"5061", portEnd:"",\n _comment: "SIP/TLS to PBX (TCP)" },\n { action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:pbxIp||"PBX_IP", dstMask:"", dstAny:false, port:"10000", portEnd:"20000",\n _comment: "RTP audio to PBX (UDP 10000-20000)" },\n { action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,\n dst:"192.168.99.0", dstMask:"0.0.0.255", dstAny:false, port:"",\n _comment: "Block management VLAN 99" },\n { action:"permit", proto:"ip", src:"", srcMask:"", srcAny:true,\n dst:"", dstMask:"", dstAny:true, port:"",\n _comment: "Permit internet and all other traffic" },\n ],\n }),\n },\n];\n\nfunction AclTemplateModal({ vlans, onApply, onClose }) {\n const [tpl, setTpl] = useState(ACL_TEMPLATES[0].id);\n const [vid, setVid] = useState(vlans[0]?.id || 1);\n const [ctrldIp, setCtrldIp] = useState("");\n const [nvrIp, setNvrIp] = useState("");\n const [pbxIp, setPbxIp] = useState("");\n const [serverSubnet, setServerSubnet] = useState("");\n\n const tmpl = ACL_TEMPLATES.find(t => t.id === tpl);\n const vlan = vlans.find(v => v.id === vid);\n const subnet = `192.168.${vid}.0`;\n\n // Auto-suggest servers subnet from vlans list\n const serversVlan = vlans.find(v => v.name?.toLowerCase().includes("server") && v.id !== 99);\n const serverSubnetPlaceholder = serversVlan ? `192.168.${serversVlan.id}.0` : "e.g. 192.168.20.0";\n\n const apply = () => {\n const srvSubnet = serverSubnet || serverSubnetPlaceholder;\n const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp, pbxIp, serverSubnet: srvSubnet });\n // Strip _comment keys — they are just for display here\n acl.rules = acl.rules.map(({ _comment, ...r }) => r);\n acl.applyVlan = vid;\n onApply(acl);\n onClose();\n };\n\n return (\n <div style={{\n position:"fixed",inset:0,background:"rgba(0,0,0,.65)",\n display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,\n }} onClick={onClose}>\n <div style={{\n background:"var(--bg2)",border:"1px solid var(--b2)",borderRadius:8,\n padding:24,maxWidth:560,width:"90%",\n }} onClick={e=>e.stopPropagation()}>\n <div style={{fontWeight:700,fontSize:14,marginBottom:14}}>\n ACL Template\n <button className="btn bg" style={{float:"right",fontSize:10,padding:"2px 8px"}}\n onClick={onClose}>✕</button>\n </div>\n\n {/* Template selector */}\n <div className="field" style={{margin:"0 0 12px"}}>\n <label>Template</label>\n <select value={tpl} onChange={e=>setTpl(e.target.value)}>\n {ACL_TEMPLATES.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}\n </select>\n </div>\n {tmpl && (\n <div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7,marginBottom:12,\n padding:"8px 12px",background:"var(--bg)",borderRadius:4,\n border:"1px solid var(--b1)",whiteSpace:"pre-wrap",maxHeight:140,overflowY:"auto"}}>\n {tmpl.description}\n </div>\n )}\n\n {/* VLAN selector */}\n <div className="field" style={{margin:"0 0 10px"}}>\n <label>Apply to VLAN</label>\n <select value={vid} onChange={e=>setVid(+e.target.value)}>\n {vlans.filter(v=>v.id!==99).map(v=>\n <option key={v.id} value={v.id}>{v.id}{v.name}</option>)}\n </select>\n </div>\n\n {/* Extra params */}\n {tmpl?.params.includes("ctrldIp") && (\n <div className="field" style={{margin:"0 0 10px"}}>\n <label>ctrld IP address</label>\n <input value={ctrldIp} onChange={e=>setCtrldIp(e.target.value)}\n placeholder="e.g. 192.168.99.50"\n style={{fontFamily:"var(--mono)",maxWidth:200}}/>\n <div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>\n IP of the machine running ctrld — shown in DNS tab after install\n </div>\n </div>\n )}\n {tmpl?.params.includes("nvrIp") && (\n <div className="field" style={{margin:"0 0 10px"}}>\n <label>NVR / DVR IP address</label>\n <input value={nvrIp} onChange={e=>setNvrIp(e.target.value)}\n placeholder="e.g. 192.168.30.10"\n style={{fontFamily:"var(--mono)",maxWidth:200}}/>\n </div>\n )}\n\n {tmpl?.params.includes("serverSubnet") && (\n <div className="field" style={{margin:"0 0 10px"}}>\n <label>Servers VLAN subnet</label>\n <input value={serverSubnet} onChange={e=>setServerSubnet(e.target.value)}\n placeholder={serverSubnetPlaceholder}\n style={{fontFamily:"var(--mono)",maxWidth:200}}/>\n <div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>\n Network address of the VLAN your servers live on (e.g. NAS, Home Assistant).\n Leave blank to use <span style={{fontFamily:"var(--mono)"}}>{serverSubnetPlaceholder}</span>.\n </div>\n </div>\n )}\n\n {/* Preview */}\n {tmpl?.params.includes("pbxIp") && (\n <div className="field" style={{margin:"0 0 10px"}}>\n <label>Asterisk / PBX IP address</label>\n <input value={pbxIp} onChange={e=>setPbxIp(e.target.value)}\n placeholder="e.g. 192.168.20.10"\n style={{fontFamily:"var(--mono)",maxWidth:200}}/>\n <div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>\n IP of your Asterisk server. SIP and RTP will only be permitted to this exact IP.\n </div>\n </div>\n )}\n\n {/* Preview */}\n {tmpl && (\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 12px",\n fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",\n lineHeight:1.7,marginBottom:14,maxHeight:180,overflowY:"auto",\n }}>\n {tmpl.build({subnet,vid,ctrldIp,nvrIp,pbxIp,serverSubnet:serverSubnet||serverSubnetPlaceholder}).rules.map((r,i)=>(\n <div key={i}>\n <span style={{color:"#566"}}>{` ${i+1} `}</span>\n <span style={{color:r.action==="permit"?"#0e7":"#f55"}}>{r.action}</span>\n {` ${r.proto} `}\n <span style={{color:"#a0c0d0"}}>\n {r.srcAny?"any":`${r.src||"?"} ${r.srcMask||""}`}\n </span>\n {" → "}\n <span style={{color:"#a0c0d0"}}>\n {r.dstAny?"any":`${r.dst||"?"} ${r.dstMask||""}`}\n </span>\n {r.portEnd\n ? <span style={{color:"#fa0"}}>{` range ${r.port} ${r.portEnd}`}</span>\n : r.port\n ? <span style={{color:"#fa0"}}>{` eq ${r.port}`}</span>\n : null}\n {r._comment && <span style={{color:"#445"}}>{` # ${r._comment}`}</span>}\n </div>\n ))}\n </div>\n )}\n\n <div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>\n <button className="btn bg" onClick={onClose}>Cancel</button>\n <button className="btn bp" onClick={apply}>Apply Template</button>\n </div>\n </div>\n </div>\n );\n}\n\nfunction AclTab({ acls, setAcls, vlans }) {\n const [nn,setNn]=useState(""); const [nv,setNv]=useState(vlans[0]?.id||1); const [nd,setNd]=useState("in");\n const [showTplModal,setShowTplModal]=useState(false);\n const addAcl=()=>{if(!nn)return;setAcls([...acls,{name:nn,applyVlan:nv,direction:nd,rules:[]}]);setNn("");};\n const addRule=name=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:[...a.rules,{action:"deny",proto:"ip",src:"",srcMask:"0.0.0.255",srcAny:true,dst:"",dstMask:"0.0.0.255",dstAny:true,port:""}]}));\n const upRule=(name,idx,k,v)=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:a.rules.map((r,i)=>i===idx?{...r,[k]:v}:r)}));\n const delRule=(name,idx)=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:a.rules.filter((_,i)=>i!==idx)}));\n const applyTemplate=acl=>setAcls(prev=>{\n // Replace if same name already exists, otherwise append\n const idx=prev.findIndex(a=>a.name===acl.name);\n return idx>=0?prev.map((a,i)=>i===idx?acl:a):[...prev,acl];\n });\n return (\n <div className="main">\n <div style={{flex:1}}>\n {showTplModal && (\n <AclTemplateModal vlans={vlans} onApply={applyTemplate} onClose={()=>setShowTplModal(false)}/>\n )}\n <div className="panel">\n <div className="ph">◈ ACL Builder\n <button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"3px 10px"}}\n onClick={()=>setShowTplModal(true)}>\n Use Template\n </button>\n </div>\n <div className="pb">\n {/* Templates hint when empty */}\n {acls.length===0&&(\n <div style={{marginBottom:12}}>\n <div style={{\n padding:"10px 14px",background:"rgba(0,229,255,.04)",\n border:"1px solid rgba(0,229,255,.12)",borderRadius:4,\n fontSize:11,color:"var(--dm)",lineHeight:1.7,\n }}>\n <span style={{color:"var(--ac)",fontWeight:700}}>Templates available: </span>\n Click <strong>Use Template</strong> to pre-fill rules for common\n patterns: Staff (full internet, no management), IoT (internet only),\n Guest (internet only + DNS enforcement), or Camera (NVR only).\n All rules are editable before pushing.\n </div>\n </div>\n )}\n {acls.length===0&&<div className="empty">No ACLs defined.</div>}\n {acls.map(acl=>(\n <div key={acl.name} className="acl-card">\n <div className="acl-hd">\n <span className="acl-nm">{acl.name}</span>\n <span style={{fontSize:11,color:"var(--dm)"}}>VLAN {acl.applyVlan} / {acl.direction.toUpperCase()}</span>\n <button className="btn bd" style={{padding:"2px 8px",fontSize:10}} onClick={()=>setAcls(acls.filter(a=>a.name!==acl.name))}>✕</button>\n </div>\n <div className="acl-rules">\n {acl.rules.map((r,i)=>(\n <div key={i} className="acl-rule">\n <select value={r.action} onChange={e=>upRule(acl.name,i,"action",e.target.value)}>{["permit","deny"].map(a=><option key={a}>{a}</option>)}</select>\n <select value={r.proto} onChange={e=>upRule(acl.name,i,"proto",e.target.value)}>{["ip","tcp","udp","icmp"].map(p=><option key={p}>{p}</option>)}</select>\n <div style={{display:"flex",gap:4,alignItems:"center"}}>\n <input placeholder={r.srcAny?"any":"10.0.0.0"} value={r.src} disabled={r.srcAny} onChange={e=>upRule(acl.name,i,"src",e.target.value)} style={{flex:1}}/>\n <label style={{fontSize:10,color:"var(--dm)",display:"flex",gap:3,alignItems:"center",whiteSpace:"nowrap"}}><input type="checkbox" checked={r.srcAny} onChange={e=>upRule(acl.name,i,"srcAny",e.target.checked)}/>any</label>\n </div>\n <div style={{display:"flex",gap:4,alignItems:"center"}}>\n <input placeholder={r.dstAny?"any":"10.0.0.0"} value={r.dst} disabled={r.dstAny} onChange={e=>upRule(acl.name,i,"dst",e.target.value)} style={{flex:1}}/>\n <label style={{fontSize:10,color:"var(--dm)",display:"flex",gap:3,alignItems:"center",whiteSpace:"nowrap"}}><input type="checkbox" checked={r.dstAny} onChange={e=>upRule(acl.name,i,"dstAny",e.target.checked)}/>any</label>\n </div>\n <input placeholder="port" value={r.port} onChange={e=>upRule(acl.name,i,"port",e.target.value)}/>\n <button className="btn bd" style={{padding:"2px 6px"}} onClick={()=>delRule(acl.name,i)}>✕</button>\n </div>\n ))}\n <button className="btn bg" style={{marginTop:6,fontSize:10}} onClick={()=>addRule(acl.name)}>+ Add Rule</button>\n </div>\n </div>\n ))}\n <div style={{marginTop:16,paddingTop:12,borderTop:"1px solid var(--b1)"}}>\n <div className="sect">New ACL</div>\n <div style={{display:"flex",gap:8,alignItems:"flex-end"}}>\n <div className="field" style={{margin:0,flex:1}}><label>Name</label><input value={nn} onChange={e=>setNn(e.target.value)} placeholder="BLOCK-IOT"/></div>\n <div className="field" style={{margin:0,width:130}}><label>VLAN</label><select value={nv} onChange={e=>setNv(+e.target.value)}>{vlans.map(v=><option key={v.id} value={v.id}>{v.id}{v.name}</option>)}</select></div>\n <div className="field" style={{margin:0,width:75}}><label>Direction</label><select value={nd} onChange={e=>setNd(e.target.value)}><option value="in">in</option><option value="out">out</option></select></div>\n <button className="btn bp" onClick={addAcl}>Create</button>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// APP\n// ══════════════════════════════════════════════════════════════════════════════\nexport default function App() {\n const [tab, setTab] = useState("dashboard");\n const [ports, setPorts] = useState(DEFAULT_PORTS);\n const [vlans, setVlans] = useState(DEFAULT_VLANS);\n const [acls, setAcls] = useState([]);\n const [selected, setSelected] = useState(null);\n const [hostname, setHostname] = useState("ERS-5952");\n const [switchIP, setSwitchIP] = useState("192.168.99.1");\n const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch" });\n const [showSettings, setShowSettings] = useState(false);\n\n const [connState, setConnState] = useState("connecting");\n const [connInfo, setConnInfo] = useState("");\n const connTimer = useRef(null);\n\n const [pollStatus, setPollStatus] = useState("idle");\n const [session, setSession] = useState(null);\n const [showTotp, setShowTotp] = useState(false);\n\n const updatePort = useCallback(p => setPorts(prev => prev.map(x => x.id===p.id?p:x)), []);\n\n // Heartbeat\n useEffect(() => {\n let firstPoll = true;\n const beat = async () => {\n const mode = document.hidden ? "background" : "active";\n try {\n const data = await API("/heartbeat", { method:"POST", body:{ visitor_id: VISITOR_ID, mode } });\n if (firstPoll) {\n firstPoll = false;\n setConnState("connected");\n setConnInfo(`Connected — ${switchIP}`);\n if (connTimer.current) clearTimeout(connTimer.current);\n connTimer.current = setTimeout(() => setConnState("hidden"), 3000);\n } else if (connState === "error") {\n setConnState("connected");\n setConnInfo(`Reconnected`);\n connTimer.current = setTimeout(() => setConnState("hidden"), 3000);\n }\n setPollStatus(data.poll_error ? "warn" : data.last_poll ? "ok" : "idle");\n } catch {\n setConnState("error");\n setPollStatus("err");\n firstPoll = true;\n }\n };\n beat();\n const t = setInterval(beat, 30000);\n const onVis = () => beat();\n document.addEventListener("visibilitychange", onVis);\n return () => { clearInterval(t); document.removeEventListener("visibilitychange", onVis); };\n }, [switchIP]);\n\n const handleTotpSuccess = (token) => {\n setShowTotp(false);\n setSession({ token });\n };\n\n const TABS = [\n { id:"dashboard",label:"Dashboard" },\n { id:"ports", label:"Port Map" },\n { id:"vlans", label:"VLANs" },\n { id:"acls", label:"ACL Builder" },\n { id:"cli", label:"Review & Push" },\n { id:"devices", label:"Device Access" },\n { id:"dhcp", label:"DHCP" },\n { id:"dns", label:"DNS Filtering" },\n { id:"vpn", label:"VPN" },\n ];\n\n return (\n <>\n <style>{css}</style>\n <div className="app">\n <ConnBanner state={connState} info={connInfo}/>\n <div className="topbar">\n <div>\n <div className="logo">ERS-5952 MANAGER</div>\n <div className="logo-sub">Extreme Networks / Avaya ERS 5900 Series</div>\n </div>\n <div className="sp"/>\n <span className="tl">Hostname</span>\n <input className="ti" value={hostname} onChange={e=>setHostname(e.target.value)} placeholder="ERS-5952"/>\n <span className="tl">Switch IP</span>\n <input className="ti" value={switchIP} onChange={e=>setSwitchIP(e.target.value)} placeholder="192.168.99.1"/>\n <div className="poll-pill">\n <span className={`dot ${pollStatus}`}/>\n {pollStatus==="ok"?"Live":pollStatus==="warn"?"Poll error":pollStatus==="err"?"Offline":"Idle"}\n </div>\n <SessionBtn session={session} onUnlock={() => setShowTotp(true)}/>\n <button className="settings-btn" onClick={() => setShowSettings(true)} title="Settings">⚙</button>\n </div>\n\n <div className="tabs">\n {TABS.map(t=>(\n <button key={t.id} className={`tab ${tab===t.id?"active":""}`} onClick={()=>setTab(t.id)}>{t.label}</button>\n ))}\n </div>\n\n {tab==="dashboard" && <DashboardTab\n vlans={vlans} acls={acls} ports={ports}\n backendOk={pollStatus!=="err"}\n switchOk={pollStatus==="ok"}\n onNavigate={setTab}\n />}\n {tab==="ports" && <PortTab ports={ports} vlans={vlans} selected={selected} setSelected={setSelected} updatePort={updatePort} pollStatus={pollStatus==="ok"?"ok":"stale"}/>}\n {tab==="vlans" && <VlanTab vlans={vlans} setVlans={setVlans} ports={ports}/>}\n {tab==="acls" && <AclTab acls={acls} setAcls={setAcls} vlans={vlans}/>}\n {tab==="cli" && <CliTab\n ports={ports} vlans={vlans} acls={acls} hostname={hostname}\n session={session} setSession={setSession}\n onNeedAuth={() => setShowTotp(true)}\n backendOk={pollStatus!=="err"}\n settings={settings}\n />}\n {tab==="devices" && <DeviceAccessTab\n session={session}\n onNeedAuth={() => setShowTotp(true)}\n backendOk={pollStatus!=="err"}\n />}\n {tab==="dhcp" && <DHCPTab\n session={session}\n onNeedAuth={() => setShowTotp(true)}\n backendOk={pollStatus!=="err"}\n />}\n {tab==="dns" && <DNSTab\n vlans={vlans}\n session={session}\n onNeedAuth={() => setShowTotp(true)}\n backendOk={pollStatus!=="err"}\n acls={acls}\n setAcls={setAcls}\n />}\n {tab==="vpn" && <WireGuardTab\n session={session}\n onNeedAuth={() => setShowTotp(true)}\n backendOk={pollStatus!=="err"}\n vlans={vlans}\n />}\n\n {showTotp && <TotpModal\n onSuccess={handleTotpSuccess}\n onCancel={() => setShowTotp(false)}\n commandCount={generateAnnotatedCLI({ports,vlans,acls,hostname}).length}\n />}\n\n {showSettings && <SettingsPanel\n settings={settings} setSettings={setSettings}\n onClose={() => setShowSettings(false)}\n />}\n </div>\n </>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// DEVICE ACCESS TAB\n// ══════════════════════════════════════════════════════════════════════════════\n\nconst MAC_TIPS = {\n ios: "Settings → Wi-Fi → your network → Private Wi-Fi Address → OFF\\nThen reconnect. Find real MAC: Settings → General → About → Wi-Fi Address",\n android: "Settings → Network → Wi-Fi → your network → Privacy → Use device MAC\\nThen reconnect.",\n macos: "System Settings → Network → your connection → Details → Hardware → Manually set MAC",\n windows: "Settings → Network → your adapter → Hardware properties → Random hardware addresses → OFF",\n};\n\nfunction DeviceAccessTab({ session, onNeedAuth, backendOk }) {\n const [devices, setDevices] = useState({ saved: [], live: [], mgmt_ip: "" });\n const [loading, setLoading] = useState(false);\n const [showForm, setShowForm] = useState(false);\n const [editDevice, setEditDevice] = useState(null);\n const [showMacTip, setShowMacTip] = useState(null);\n const [pushResult, setPushResult] = useState(null);\n const [form, setForm] = useState({ name:"", mac:"", ip:"", vlan:10, management_access:false, static_ip:false, notes:"" });\n\n const load = async () => {\n setLoading(true);\n try { setDevices(await API("/devices")); } catch(e) { console.error(e); }\n setLoading(false);\n };\n\n useEffect(() => { if (backendOk) load(); }, [backendOk]);\n\n const openAdd = () => { setForm({ name:"", mac:"", ip:"", vlan:10, management_access:false, static_ip:false, notes:"" }); setEditDevice(null); setShowForm(true); };\n const openEdit = (d) => { setForm({...d}); setEditDevice(d); setShowForm(true); };\n\n const save = async () => {\n if (!session) { onNeedAuth(); return; }\n try {\n const r = await API("/devices/save", { method:"POST", body:{ token: session.token, device: form } });\n setDevices(d => ({ ...d, saved: r.devices }));\n setShowForm(false);\n } catch(e) { alert(e.message); }\n };\n\n const del = async (mac) => {\n if (!session) { onNeedAuth(); return; }\n if (!confirm("Remove this device?")) return;\n try {\n const r = await API("/devices/delete", { method:"POST", body:{ token: session.token, mac } });\n setDevices(d => ({ ...d, saved: r.devices }));\n } catch(e) { alert(e.message); }\n };\n\n const pushReservation = async (device) => {\n if (!session) { onNeedAuth(); return; }\n try {\n const r = await API("/devices/push-reservation", { method:"POST", body:{ token: session.token, device } });\n setPushResult(r);\n } catch(e) { setPushResult({ success:false, error:e.message }); }\n };\n\n const pushPinhole = async (mac, allow) => {\n if (!session) { onNeedAuth(); return; }\n try {\n const r = await API("/devices/push-pinhole", { method:"POST", body:{ token: session.token, mac, allow } });\n setPushResult(r);\n } catch(e) { setPushResult({ success:false, error:e.message }); }\n };\n\n const adoptLive = (live) => {\n setForm({ name: live.hostname||"", mac: live.mac, ip: live.ip, vlan:10, management_access:false, static_ip:false, notes:"" });\n setEditDevice(null); setShowForm(true);\n };\n\n const savedMacs = new Set(devices.saved.map(d => d.mac));\n const unregistered = (devices.live||[]).filter(l => !savedMacs.has(l.mac));\n\n return (\n <div className="main" style={{flexDirection:"column",gap:12}}>\n\n {/* MAC randomization warning */}\n <div className="panel">\n <div className="ph">◈ Device Access — Management Network Pinhole</div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.7}}>\n Devices listed here can reach the switch manager UI directly from their normal VLAN\n without needing to be on VLAN 99 or connected to VPN. The switch enforces access\n via an ACL pinhole. TOTP still gates any changes.\n </div>\n <div style={{background:"rgba(255,234,0,.06)",border:"1px solid rgba(255,234,0,.2)",borderRadius:4,padding:"10px 14px",marginBottom:12}}>\n <div style={{color:"var(--warn)",fontSize:11,fontWeight:700,letterSpacing:1,textTransform:"uppercase",marginBottom:6}}>\n ⚠ MAC Address Randomization\n </div>\n <div style={{fontSize:11,color:"var(--tx)",marginBottom:8,lineHeight:1.6}}>\n Modern phones and laptops use random MAC addresses per network by default.\n This breaks DHCP reservations and makes stable IP assignment impossible.\n Disable it on each device before adding it here.\n </div>\n <div style={{display:"flex",gap:6,flexWrap:"wrap"}}>\n {Object.entries({iOS:"ios",Android:"android",macOS:"macos",Windows:"windows"}).map(([label,key])=>(\n <button key={key} className="btn bg" style={{padding:"3px 10px",fontSize:10}}\n onClick={()=>setShowMacTip(showMacTip===key?null:key)}>\n {label}\n </button>\n ))}\n </div>\n {showMacTip && (\n <div style={{marginTop:8,background:"var(--bg)",borderRadius:3,padding:"8px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--tx)",whiteSpace:"pre-line"}}>\n {MAC_TIPS[showMacTip]}\n </div>\n )}\n </div>\n <button className="btn bp" onClick={openAdd}>+ Add Device</button>\n <button className="btn bg" style={{marginLeft:8}} onClick={load}>↻ Refresh</button>\n </div>\n </div>\n\n {/* Saved devices */}\n <div className="panel">\n <div className="ph">◈ Registered Devices ({devices.saved.length})</div>\n <div className="pb">\n {devices.saved.length === 0 && <div className="empty">No devices registered yet.</div>}\n {devices.saved.map(d => (\n <div key={d.mac} style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px",marginBottom:8}}>\n <div style={{display:"flex",alignItems:"center",gap:10,flexWrap:"wrap"}}>\n <div style={{flex:1}}>\n <div style={{fontWeight:700,marginBottom:2}}>{d.name}</div>\n <div style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>\n {d.mac} · {d.ip} · VLAN {d.vlan}\n </div>\n {d.notes && <div style={{fontSize:11,color:"var(--dm)",marginTop:2}}>{d.notes}</div>}\n </div>\n <div style={{display:"flex",gap:6,flexWrap:"wrap"}}>\n {d.static_ip && (\n <button className="btn bg" style={{fontSize:10,padding:"3px 8px"}}\n onClick={() => pushReservation(d)}>\n Push DHCP Reservation\n </button>\n )}\n <button\n className={`btn ${d.management_access?"bd":"bs"}`}\n style={{fontSize:10,padding:"3px 8px"}}\n onClick={() => pushPinhole(d.mac, !d.management_access)}>\n {d.management_access ? "Revoke Access" : "Grant Access"}\n </button>\n <button className="btn bg" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>openEdit(d)}>Edit</button>\n <button className="btn bd" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>del(d.mac)}>✕</button>\n </div>\n </div>\n <div style={{display:"flex",gap:8,marginTop:6}}>\n <span className={`badge ${d.management_access?"":"" }`}\n style={{background:d.management_access?"rgba(0,230,118,.15)":"rgba(90,96,112,.15)",\n color:d.management_access?"var(--ok)":"var(--dm)"}}>\n {d.management_access ? "✓ Management access" : "✗ No management access"}\n </span>\n <span className="badge" style={{background:d.static_ip?"rgba(0,229,255,.12)":"rgba(90,96,112,.12)",\n color:d.static_ip?"var(--ac)":"var(--dm)"}}>\n {d.static_ip ? "Static IP reserved" : "Dynamic IP"}\n </span>\n </div>\n </div>\n ))}\n </div>\n </div>\n\n {/* Live DHCP leases */}\n {unregistered.length > 0 && (\n <div className="panel">\n <div className="ph">◈ Live DHCP Leases — Unregistered ({unregistered.length})</div>\n <div className="pb">\n <div style={{fontSize:11,color:"var(--dm)",marginBottom:10}}>\n These devices are on the network but not registered. Click to add them.\n </div>\n {unregistered.map(l => (\n <div key={l.mac} style={{display:"flex",alignItems:"center",gap:10,padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>\n <div style={{flex:1,fontFamily:"var(--mono)",fontSize:11}}>\n <span style={{color:"var(--tx)"}}>{l.ip}</span>\n <span style={{color:"var(--dm)",margin:"0 8px"}}>·</span>\n <span style={{color:"var(--dm)"}}>{l.mac}</span>\n {l.hostname && l.hostname !== "unknown" && (\n <span style={{color:"var(--ac)",marginLeft:8}}>{l.hostname}</span>\n )}\n </div>\n <button className="btn bg" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>adoptLive(l)}>\n + Register\n </button>\n </div>\n ))}\n </div>\n </div>\n )}\n\n {/* Push result */}\n {pushResult && (\n <div className="panel">\n <div className={`ph ${pushResult.success?"":"" }`}\n style={{color:pushResult.success?"var(--ok)":"var(--err)"}}>\n {pushResult.success ? "✓ Push successful — config saved" : `✗ Push failed: ${pushResult.error}`}\n <button className="btn bg" style={{marginLeft:"auto",padding:"2px 8px",fontSize:10}}\n onClick={()=>setPushResult(null)}>✕</button>\n </div>\n </div>\n )}\n\n {/* Add/Edit form modal */}\n {showForm && (\n <div className="modal-bg" onClick={()=>setShowForm(false)}>\n <div className="modal" style={{width:440,textAlign:"left"}} onClick={e=>e.stopPropagation()}>\n <h2 style={{marginBottom:16}}>{editDevice?"EDIT DEVICE":"ADD DEVICE"}</h2>\n <div className="field"><label>Device Name</label>\n <input value={form.name} onChange={e=>setForm(f=>({...f,name:e.target.value}))} placeholder="e.g. Dev Laptop, iPhone"/></div>\n <div className="field"><label>MAC Address</label>\n <input value={form.mac} onChange={e=>setForm(f=>({...f,mac:e.target.value}))}\n placeholder="aa:bb:cc:dd:ee:ff" style={{fontFamily:"var(--mono)"}}/></div>\n <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8}}>\n <div className="field"><label>IP Address</label>\n <input value={form.ip} onChange={e=>setForm(f=>({...f,ip:e.target.value}))}\n placeholder="192.168.10.50" style={{fontFamily:"var(--mono)"}}/></div>\n <div className="field"><label>VLAN</label>\n <input type="number" value={form.vlan} onChange={e=>setForm(f=>({...f,vlan:+e.target.value}))}/></div>\n </div>\n <div className="field"><label>Notes (optional)</label>\n <input value={form.notes} onChange={e=>setForm(f=>({...f,notes:e.target.value}))} placeholder="e.g. Main dev laptop"/></div>\n <div style={{display:"flex",gap:16,marginBottom:14}}>\n <label style={{display:"flex",alignItems:"center",gap:6,fontSize:12,cursor:"pointer"}}>\n <input type="checkbox" checked={form.static_ip}\n onChange={e=>setForm(f=>({...f,static_ip:e.target.checked}))}/>\n Reserve static IP (DHCP binding)\n </label>\n <label style={{display:"flex",alignItems:"center",gap:6,fontSize:12,cursor:"pointer"}}>\n <input type="checkbox" checked={form.management_access}\n onChange={e=>setForm(f=>({...f,management_access:e.target.checked}))}/>\n Grant management access\n </label>\n </div>\n <div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>\n <button className="btn bg" onClick={()=>setShowForm(false)}>Cancel</button>\n <button className="btn bp" onClick={save}>Save Device</button>\n </div>\n </div>\n </div>\n )}\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// WIREGUARD TAB\n// ══════════════════════════════════════════════════════════════════════════════\n\nfunction QRModal({ config, name, onClose }) {\n // Render QR using a simple API since we can\'t use native qrencode in browser\n const [qrUrl, setQrUrl] = useState(\'\');\n useEffect(() => {\n // Use Google Charts QR API (works offline-ish, data URI approach)\n const encoded = encodeURIComponent(config);\n setQrUrl(`https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encoded}`);\n }, [config]);\n\n return (\n <div className="modal-bg" onClick={onClose}>\n <div className="modal" onClick={e=>e.stopPropagation()}>\n <h2>◈ {name.toUpperCase()}</h2>\n <p style={{marginBottom:12}}>Scan with WireGuard app<br/>\n <span style={{fontSize:10,color:"var(--dm)"}}>iOS App Store / Android Play Store: search "WireGuard"<br/>\n Desktop: wireguard.com/install</span>\n </p>\n {qrUrl && <img src={qrUrl} alt="WireGuard QR" style={{width:220,height:220,margin:"0 auto 12px",display:"block",borderRadius:4}}/>}\n <div style={{background:"var(--bg)",borderRadius:3,padding:8,fontFamily:"var(--mono)",fontSize:10,\n color:"var(--dm)",textAlign:"left",whiteSpace:"pre",overflowX:"auto",\n maxHeight:120,overflowY:"auto",marginBottom:12}}>\n {config}\n </div>\n <div style={{display:"flex",gap:8,justifyContent:"center"}}>\n <button className="btn bg" onClick={()=>{\n const a=document.createElement(\'a\');\n a.href=URL.createObjectURL(new Blob([config],{type:\'text/plain\'}));\n a.download=`${name}.conf`; a.click();\n }}>Download .conf</button>\n <button className="btn bg" onClick={()=>navigator.clipboard?.writeText(config)}>Copy</button>\n <button className="btn bp" onClick={onClose}>Done</button>\n </div>\n </div>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// DASHBOARD TAB\n// ══════════════════════════════════════════════════════════════════════════════\n\nfunction DashboardTab({ vlans, acls, ports, backendOk, switchOk, onNavigate }) {\n const [data, setData] = useState(null);\n const [loading, setLoading] = useState(true);\n\n const load = async () => {\n if (!backendOk) return;\n setLoading(true);\n try {\n const [status, ctrld, wg, opnWg, relay, dhcp] = await Promise.allSettled([\n API("/status"),\n API("/ctrld/status"),\n API("/wireguard/status"),\n API("/opnsense/wireguard/status"),\n API("/dhcp/relay/status"),\n API("/dhcp/overview"),\n ]);\n setData({\n status: status.value || null,\n ctrld: ctrld.value || null,\n wg: wg.value || null,\n opnWg: opnWg.value || null,\n relay: relay.value || null,\n dhcp: dhcp.value || null,\n });\n } catch(e) { /* partial data still rendered */ }\n setLoading(false);\n };\n\n useEffect(() => { load(); }, [backendOk]);\n\n // ── Derived health values ───────────────────────────────────────────\n const opnConnected = data?.opnWg?.opnsense_configured;\n const ctrldRunning = data?.ctrld?.running;\n const ctrldMode = data?.ctrld?.mode;\n const ctrldProfiles = data?.ctrld?.vlan_profiles || [];\n const relayVlans = data?.relay?.vlans || {}; // {10: "192.168.99.1", ...}\n const allLeases = data?.dhcp?.leases || [];\n\n // Device count per VLAN (infer from IP third octet)\n const devicesByVlan = {};\n allLeases.forEach(l => {\n const vid = l.ip ? parseInt(l.ip.split(\'.\')[2], 10) : null;\n if (vid) devicesByVlan[vid] = (devicesByVlan[vid] || 0) + 1;\n });\n\n // WireGuard: prefer OPNsense if configured + plugin installed\n const opnWgReady = data?.opnWg?.plugin_installed && data?.opnWg?.server;\n const localWgReady = data?.wg?.running;\n const wgReady = opnWgReady || localWgReady;\n const wgLabel = opnWgReady ? `OPNsense · ${(data.opnWg.peers||[]).length} peer(s)`\n : localWgReady ? `Local · ${(data.wg.peers||[]).length} peer(s)`\n : "not configured";\n\n // Setup checklist items\n const assignedPorts = ports.filter(p => p.mode !== "disabled" && (p.accessVlan !== 1 || p.mode === "trunk")).length;\n const checklist = [\n { label:"Switch connected", done: switchOk, tab:"ports", detail: switchOk ? "online" : "check SSH key / IP" },\n { label:"VLANs defined", done: vlans.length > 1, tab:"vlans", detail: `${vlans.length} VLAN(s)` },\n { label:"Ports assigned", done: assignedPorts > 0, tab:"ports", detail: `${assignedPorts} port(s) configured` },\n { label:"ACL policies", done: acls.length > 0, tab:"acls", detail: acls.length > 0 ? `${acls.length} ACL(s)` : "none applied yet" },\n { label:"OPNsense connected", done: !!opnConnected, tab:"dhcp", detail: opnConnected ? "connected" : "add API key in DHCP tab" },\n { label:"DHCP relay", done: Object.keys(relayVlans).length > 0, tab:"dhcp", detail: Object.keys(relayVlans).length > 0 ? `${Object.keys(relayVlans).length} VLAN(s) relaying` : "not configured" },\n { label:"DNS filtering (ctrld)", done: !!ctrldRunning, tab:"dns", detail: ctrldRunning ? `${ctrldMode} mode` : "not configured" },\n { label:"WireGuard VPN", done: wgReady, tab:"vpn", detail: wgLabel },\n ];\n\n const done = checklist.filter(c => c.done).length;\n const total = checklist.length;\n const pct = Math.round((done / total) * 100);\n\n // Per-VLAN health row\n const vlanHealth = vlans.filter(v => v.id !== 99).map(v => {\n const hasDns = ctrldProfiles.some(p => p.vlan_id === v.id);\n const hasRelay = !!relayVlans[v.id];\n const hasAcl = acls.some(a => a.applyVlan === v.id);\n const devices = devicesByVlan[v.id] || 0;\n const score = (hasDns?1:0) + (hasRelay?1:0) + (hasAcl?1:0);\n return { ...v, hasDns, hasRelay, hasAcl, devices, score };\n });\n\n const Dot = ({ on, warn }) => (\n <span style={{\n display:"inline-block",width:7,height:7,borderRadius:"50%",marginRight:4,\n background: on ? "var(--ok)" : warn ? "var(--warn)" : "var(--b2)",\n verticalAlign:"middle",\n }}/>\n );\n\n const ServiceCard = ({ label, ok, detail, tab, icon }) => (\n <div onClick={() => onNavigate(tab)} style={{\n flex:"1 1 160px",background:"var(--bg2)",border:`1px solid ${ok?"var(--ok)":"var(--b2)"}`,\n borderRadius:6,padding:"12px 14px",cursor:"pointer",transition:"border-color .15s",\n minWidth:140,\n }}>\n <div style={{display:"flex",alignItems:"center",gap:6,marginBottom:6}}>\n <span style={{fontSize:14}}>{icon}</span>\n <span style={{fontSize:11,fontWeight:700,color: ok ? "var(--ok)" : "var(--dm)"}}>{label}</span>\n <span style={{marginLeft:"auto",width:8,height:8,borderRadius:"50%",\n background: ok ? "var(--ok)" : "var(--b2)"}}/>\n </div>\n <div style={{fontSize:10,color:"var(--dm)",fontFamily:"var(--mono)"}}>{detail || "—"}</div>\n </div>\n );\n\n if (!backendOk) return (\n <div className="main" style={{flexDirection:"column",gap:12}}>\n <div className="panel"><div className="pb" style={{color:"var(--err)",fontSize:12}}>\n Backend offline — check that the switch manager service is running.\n </div></div>\n </div>\n );\n\n return (\n <div className="main" style={{flexDirection:"column",gap:14}}>\n\n {/* ── Service status row ──────────────────────────────────── */}\n <div style={{display:"flex",flexWrap:"wrap",gap:10}}>\n <ServiceCard icon="⬡" label="Switch" ok={switchOk} detail={switchOk?"connected · polling":"offline"} tab="ports"/>\n <ServiceCard icon="◈" label="OPNsense" ok={!!opnConnected} detail={opnConnected?"connected":"not connected"} tab="dhcp"/>\n <ServiceCard icon="◈" label="DNS / ctrld" ok={!!ctrldRunning} detail={ctrldRunning?`running · ${ctrldMode}`:"not running"} tab="dns"/>\n <ServiceCard icon="◈" label="WireGuard" ok={wgReady} detail={wgLabel} tab="vpn"/>\n </div>\n\n {/* ── Setup checklist ─────────────────────────────────────── */}\n <div className="panel">\n <div className="ph" style={{display:"flex",alignItems:"center",gap:10}}>\n ◈ Setup Progress\n <div style={{flex:1,height:4,background:"var(--b1)",borderRadius:2,marginLeft:8,overflow:"hidden"}}>\n <div style={{width:`${pct}%`,height:"100%",background:pct===100?"var(--ok)":"var(--ac)",transition:"width .4s"}}/>\n </div>\n <span style={{fontSize:11,fontFamily:"var(--mono)",color:"var(--dm)",whiteSpace:"nowrap"}}>\n {done}/{total}\n </span>\n </div>\n <div className="pb" style={{display:"flex",flexDirection:"column",gap:6}}>\n {checklist.map((c,i) => (\n <div key={i} style={{\n display:"flex",alignItems:"center",gap:10,padding:"7px 10px",\n borderRadius:5,cursor:c.done?"default":"pointer",\n background: c.done ? "transparent" : "rgba(255,234,0,.04)",\n border:`1px solid ${c.done?"transparent":"rgba(255,234,0,.12)"}`,\n }} onClick={() => !c.done && onNavigate(c.tab)}>\n <span style={{\n width:18,height:18,borderRadius:"50%",display:"flex",alignItems:"center",\n justifyContent:"center",fontSize:11,flexShrink:0,\n background: c.done ? "var(--ok)" : "var(--b2)",\n color: c.done ? "#000" : "var(--dm)",fontWeight:700,\n }}>{c.done ? "✓" : (i+1)}</span>\n <span style={{flex:1,fontSize:12,color:c.done?"var(--dm)":"var(--tx)",fontWeight:c.done?400:600}}>\n {c.label}\n </span>\n <span style={{fontSize:11,color:"var(--dm)",fontFamily:"var(--mono)"}}>{c.detail}</span>\n {!c.done && (\n <span style={{fontSize:10,color:"var(--ac)",fontFamily:"var(--mono)",marginLeft:4}}>\n{c.tab}\n </span>\n )}\n </div>\n ))}\n </div>\n </div>\n\n {/* ── Per-VLAN health grid ─────────────────────────────────── */}\n <div className="panel">\n <div className="ph">◈ VLAN Health</div>\n <div className="pb" style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(260px,1fr))",gap:10}}>\n {loading && <div style={{color:"var(--dm)",fontSize:12,gridColumn:"1/-1"}}>Loading…</div>}\n {vlanHealth.map(v => (\n <div key={v.id} style={{\n border:`1px solid ${v.color}44`,borderRadius:6,\n padding:"10px 12px",background:`${v.color}08`,\n }}>\n <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}>\n <div style={{width:10,height:10,borderRadius:"50%",background:v.color,flexShrink:0}}/>\n <span style={{fontWeight:700,fontSize:12,color:v.color}}>VLAN {v.id}</span>\n <span style={{fontSize:11,color:"var(--dm)"}}>· {v.name}</span>\n <span style={{\n marginLeft:"auto",fontFamily:"var(--mono)",fontSize:10,\n color: v.devices>0 ? "var(--ok)" : "var(--dm)",\n }}>{v.devices} device{v.devices!==1?"s":""}</span>\n </div>\n <div style={{display:"flex",gap:12,marginBottom:10,fontSize:11}}>\n <span><Dot on={v.hasRelay}/> Relay</span>\n <span><Dot on={v.hasDns}/> DNS</span>\n <span><Dot on={v.hasAcl}/> ACL</span>\n </div>\n {/* Missing items nudge */}\n {v.score < 3 && (\n <div style={{fontSize:10,color:"var(--dm)",borderTop:"1px solid var(--b1)",paddingTop:6,lineHeight:1.8}}>\n {!v.hasRelay && <span onClick={()=>onNavigate("dhcp")}\n style={{cursor:"pointer",color:"var(--warn)",marginRight:8}}>+ relay</span>}\n {!v.hasDns && <span onClick={()=>onNavigate("dns")}\n style={{cursor:"pointer",color:"var(--warn)",marginRight:8}}>+ DNS profile</span>}\n {!v.hasAcl && <span onClick={()=>onNavigate("acls")}\n style={{cursor:"pointer",color:"var(--warn)"}}>+ ACL policy</span>}\n </div>\n )}\n </div>\n ))}\n </div>\n </div>\n\n {/* ── Quick links ──────────────────────────────────────────── */}\n <div className="panel">\n <div className="ph">◈ Quick Access</div>\n <div className="pb" style={{display:"flex",flexWrap:"wrap",gap:8}}>\n {[\n {label:"Configure Ports", tab:"ports"},\n {label:"Manage VLANs", tab:"vlans"},\n {label:"Apply ACL Templates", tab:"acls"},\n {label:"Review & Push", tab:"cli"},\n {label:"DHCP / OPNsense", tab:"dhcp"},\n {label:"DNS Filtering", tab:"dns"},\n {label:"WireGuard VPN", tab:"vpn"},\n {label:"Device Access", tab:"devices"},\n ].map(l=>(\n <button key={l.tab} className="btn bg" style={{fontSize:11,padding:"5px 12px"}}\n onClick={()=>onNavigate(l.tab)}>{l.label}</button>\n ))}\n <button className="btn bg" style={{fontSize:11,padding:"5px 12px",marginLeft:"auto"}}\n onClick={load}>↻ Refresh</button>\n </div>\n </div>\n\n </div>\n );\n}\n\nfunction WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {\n const [status, setStatus] = useState(null);\n const [clients, setClients] = useState([]);\n const [newName, setNewName] = useState(\'\');\n const [adding, setAdding] = useState(false);\n const [qrModal, setQrModal] = useState(null); // { config, name }\n const [error, setError] = useState(\'\');\n\n // ── OPNsense WireGuard state ────────────────────────────────────\n const [opnWg, setOpnWg] = useState(null); // status response\n const [opnLoading, setOpnLoading] = useState(false);\n const [opnError, setOpnError] = useState(\'\');\n const [opnSetup, setOpnSetup] = useState({\n server_name: \'switch-mgmt-vpn\', listen_port: 51820,\n tunnel_subnet: \'10.99.2.0/24\', public_endpoint: \'\',\n });\n const [opnPeerName, setOpnPeerName] = useState(\'\');\n const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs\n const [opnAdding, setOpnAdding] = useState(false);\n const [opnQr, setOpnQr] = useState(null); // { config, name }\n\n const load = async () => {\n try {\n const [s, c] = await Promise.all([API("/wireguard/status"), API("/wireguard/clients")]);\n setStatus(s); setClients(c.clients||[]);\n } catch(e) { setError(e.message); }\n };\n\n useEffect(() => { if (backendOk) load(); }, [backendOk]);\n\n const addClient = async () => {\n if (!newName.trim()) return;\n if (!session) { onNeedAuth(); return; }\n setAdding(true); setError(\'\');\n try {\n const r = await API("/wireguard/add-client", {\n method:"POST", body:{ token: session.token, name: newName.trim() }\n });\n setQrModal({ config: r.config, name: r.name });\n setNewName(\'\');\n await load();\n } catch(e) { setError(e.message); }\n setAdding(false);\n };\n\n const showQR = async (name) => {\n try {\n const r = await API(`/wireguard/client-qr/${name}`);\n setQrModal({ config: r.config, name: r.name });\n } catch(e) { setError(e.message); }\n };\n\n const revoke = async (name) => {\n if (!session) { onNeedAuth(); return; }\n if (!confirm(`Revoke access for "${name}"? They will be disconnected immediately.`)) return;\n try {\n await API("/wireguard/revoke-client", { method:"POST", body:{ token: session.token, name } });\n await load();\n } catch(e) { setError(e.message); }\n };\n\n const isRunning = status?.running;\n\n // ── OPNsense WireGuard handlers ──────────────────────────────────\n const loadOpnWg = async () => {\n setOpnLoading(true); setOpnError(\'\');\n try { setOpnWg(await API("/opnsense/wireguard/status")); }\n catch(e) { setOpnError(e.message); }\n setOpnLoading(false);\n };\n\n useEffect(() => { if (backendOk) loadOpnWg(); }, [backendOk]);\n\n const opnSetupServer = async () => {\n if (!session) { onNeedAuth(); return; }\n setOpnLoading(true); setOpnError(\'\');\n try {\n await API("/opnsense/wireguard/setup-server", {\n method:"POST", body:{ token: session.token, ...opnSetup }\n });\n await loadOpnWg();\n } catch(e) { setOpnError(e.message); }\n setOpnLoading(false);\n };\n\n const opnDeleteServer = async () => {\n if (!session) { onNeedAuth(); return; }\n if (!confirm("Remove the WireGuard server from OPNsense? All peers will be disconnected.")) return;\n setOpnLoading(true); setOpnError(\'\');\n try {\n await API(`/opnsense/wireguard/server?token=${session.token}`, { method:"DELETE" });\n await loadOpnWg();\n } catch(e) { setOpnError(e.message); }\n setOpnLoading(false);\n };\n\n const opnToggleVlan = (vid) => {\n setOpnVlans(prev => prev.includes(vid) ? prev.filter(v=>v!==vid) : [...prev, vid]);\n };\n\n const opnAddPeer = async () => {\n if (!opnPeerName.trim()) return;\n if (opnVlans.length === 0) { setOpnError("Select at least one VLAN for this peer."); return; }\n if (!session) { onNeedAuth(); return; }\n setOpnAdding(true); setOpnError(\'\');\n // Build vlan_subnets map from the vlans prop\n const vlan_subnets = {};\n vlans.forEach(v => { vlan_subnets[v.id] = `192.168.${v.id}.0/24`; });\n try {\n const r = await API("/opnsense/wireguard/add-peer", {\n method:"POST",\n body:{ token: session.token, name: opnPeerName.trim(),\n allowed_vlans: opnVlans, vlan_subnets }\n });\n setOpnQr({ config: r.config, name: r.name });\n setOpnPeerName(\'\'); setOpnVlans([]);\n await loadOpnWg();\n } catch(e) { setOpnError(e.message); }\n setOpnAdding(false);\n };\n\n const opnRevokePeer = async (uuid, name) => {\n if (!session) { onNeedAuth(); return; }\n if (!confirm(`Revoke VPN access for "${name}"?`)) return;\n setOpnError(\'\');\n try {\n await API(`/opnsense/wireguard/peer/${uuid}?token=${session.token}`, { method:"DELETE" });\n await loadOpnWg();\n } catch(e) { setOpnError(e.message); }\n };\n\n const opnShowConf = async (name) => {\n try {\n const r = await API(`/opnsense/wireguard/peer-config/${name}`);\n setOpnQr({ config: r.config, name: r.name });\n } catch(e) { setOpnError(e.message); }\n };\n\n // Pre-select servers VLAN when OPNsense WG server becomes available\n useEffect(() => {\n if (opnWg?.server && opnVlans.length === 0) {\n const serversVlan = vlans.find(v =>\n v.name?.toLowerCase().includes("server") && v.id !== 99\n );\n if (serversVlan) setOpnVlans([serversVlan.id]);\n }\n }, [opnWg?.server]);\n\n const opnAvailable = opnWg?.opnsense_configured && opnWg?.plugin_installed;\n\n return (\n <div className="main" style={{flexDirection:"column",gap:12}}>\n\n {/* ── Recommendation banner when OPNsense WG is available ──── */}\n {opnAvailable && (\n <div style={{\n background:"rgba(0,229,255,.06)",border:"1px solid rgba(0,229,255,.25)",\n borderRadius:6,padding:"12px 16px",display:"flex",gap:12,alignItems:"flex-start",\n }}>\n <div style={{fontSize:18,lineHeight:1,color:"var(--ac)"}}>◈</div>\n <div style={{flex:1}}>\n <div style={{fontWeight:700,color:"var(--ac)",marginBottom:4}}>\n OPNsense WireGuard available — recommended\n </div>\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7}}>\n Your OPNsense has the WireGuard plugin. Running VPN on the router is\n better than here: it stays on when this PC is off, your firewall and VPN\n live in the same place, and every VLAN can be reached remotely.\n The local option below still works as a backup.\n </div>\n </div>\n </div>\n )}\n\n {/* ── OPNsense WG first when available ─────────────────────── */}\n {opnAvailable && (\n <OPNsenseWGSection\n opnWg={opnWg} opnLoading={opnLoading} opnError={opnError}\n opnSetup={opnSetup} setOpnSetup={setOpnSetup}\n opnPeerName={opnPeerName} setOpnPeerName={setOpnPeerName}\n opnVlans={opnVlans} opnAdding={opnAdding}\n session={session} onNeedAuth={onNeedAuth} vlans={vlans}\n onToggleVlan={opnToggleVlan} onSetupServer={opnSetupServer}\n onDeleteServer={opnDeleteServer} onAddPeer={opnAddPeer}\n onRevokePeer={opnRevokePeer} onShowConf={opnShowConf} onRefresh={loadOpnWg}\n />\n )}\n\n {/* ── Local WireGuard ──────────────────────────────────────── */}\n <div className="panel">\n <div className="ph">\n {opnAvailable ? "◈ Local WireGuard — backup / this machine only" : "◈ WireGuard VPN"}\n <span style={{marginLeft:"auto",fontFamily:"var(--mono)",fontSize:10,\n color:isRunning?"var(--ok)":"var(--err)"}}>\n {isRunning ? "● Running" : "○ Not running"}\n </span>\n </div>\n <div className="pb">\n {opnAvailable ? (\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>\n Run WireGuard on this management PC instead of OPNsense.\n Use this as a backup when OPNsense is being reconfigured, or if you\n prefer not to touch the router. Clients can only reach VLAN 99\n (management network) from here — they cannot reach other VLANs\n unless this PC also routes that traffic.\n </div>\n ) : (\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>\n WireGuard lets you connect from anywhere — coffee shop, hotel, cellular —\n and reach this switch manager as if you were on VLAN 99.\n Each device gets its own key; revoking a key disconnects it immediately.\n <br/><br/>\n <span style={{color:"var(--warn)"}}>\n Tip: Connect OPNsense in the DHCP tab to unlock router-level WireGuard —\n always on, handles all VLANs, no dependency on this machine.\n </span>\n </div>\n )}\n {!isRunning && (\n <div style={{background:"rgba(255,23,68,.07)",border:"1px solid rgba(255,23,68,.2)",\n borderRadius:4,padding:"10px 14px",marginBottom:12,fontSize:11,color:"var(--err)"}}>\n WireGuard is not running on this machine. Run setup again and choose the WireGuard option,\n or run: <span style={{fontFamily:"var(--mono)"}}>sudo systemctl start wg-quick@wg0</span>\n </div>\n )}\n {error && <div style={{color:"var(--err)",fontSize:11,marginBottom:10}}>{error}</div>}\n </div>\n </div>\n\n {/* Connected peers */}\n {isRunning && status?.peers?.length > 0 && (\n <div className="panel">\n <div className="ph">◈ Local WG — Connected Peers ({status.peers.length})</div>\n <div className="pb">\n {status.peers.map((p,i) => (\n <div key={i} style={{padding:"8px 0",borderBottom:"1px solid var(--b1)",\n display:"flex",alignItems:"center",gap:12}}>\n <div style={{flex:1}}>\n <div style={{fontWeight:700,marginBottom:2}}>{p.name}</div>\n <div style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--dm)"}}>\n {p.allowed_ips} · {p.endpoint||"not connected"}\n </div>\n {p.last_handshake && (\n <div style={{fontSize:10,color:"var(--dm)"}}>Last seen: {p.last_handshake}</div>\n )}\n {p.transfer && (\n <div style={{fontSize:10,color:"var(--dm)"}}>Transfer: {p.transfer}</div>\n )}\n </div>\n <div style={{width:8,height:8,borderRadius:"50%",\n background:p.last_handshake&&!p.last_handshake.includes("never")?"var(--ok)":"var(--dm)"}}/>\n </div>\n ))}\n </div>\n </div>\n )}\n\n {/* Client management */}\n <div className="panel">\n <div className="ph">◈ Local WG — Clients</div>\n <div className="pb">\n {clients.length === 0 && <div className="empty" style={{padding:"16px 0"}}>No clients configured yet.</div>}\n {clients.map(c => (\n <div key={c.name} style={{display:"flex",alignItems:"center",gap:8,\n padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>\n <span style={{flex:1,fontWeight:600}}>{c.name}</span>\n <button className="btn bg" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>showQR(c.name)}>\n QR / .conf\n </button>\n <button className="btn bd" style={{fontSize:10,padding:"3px 8px"}} onClick={()=>revoke(c.name)}>\n Revoke\n </button>\n </div>\n ))}\n\n <div style={{marginTop:14,paddingTop:12,borderTop:"1px solid var(--b1)",display:"flex",gap:8,alignItems:"flex-end"}}>\n <div className="field" style={{margin:0,flex:1}}>\n <label>Add New Client</label>\n <input value={newName} onChange={e=>setNewName(e.target.value)}\n onKeyDown={e=>e.key==="Enter"&&addClient()}\n placeholder="e.g. laptop, phone, tablet"/>\n </div>\n <button className="btn bp" onClick={addClient} disabled={!newName.trim()||adding||!isRunning}>\n {adding?"Adding...":"Add Client"}\n </button>\n </div>\n {!session && (\n <div style={{fontSize:11,color:"var(--dm)",marginTop:8}}>\n <button className="btn bg" style={{fontSize:10,padding:"3px 8px",marginRight:6}}\n onClick={onNeedAuth}>Authenticate</button>\n to add or revoke clients\n </div>\n )}\n </div>\n </div>\n\n {/* SSH tunnel fallback */}\n <div className="panel">\n <div className="ph">◈ SSH Tunnel — Emergency Fallback</div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:8}}>\n If WireGuard is down or misconfigured, SSH port forwarding reaches the\n switch manager in one command — no daemon, no keys to manage.\n </div>\n <div style={{background:"#060809",borderRadius:4,padding:"10px 14px",\n fontFamily:"var(--mono)",fontSize:12,color:"#a0b0c0",marginBottom:8}}>\n ssh -L 8765:localhost:8765 user@your-management-computer-ip\n </div>\n <div style={{fontSize:11,color:"var(--dm)"}}>\n Then open <span style={{color:"var(--ac)",fontFamily:"var(--mono)"}}>http://localhost:8765</span>.\n Requires SSH accessible from outside (key auth only — consider fail2ban).\n </div>\n </div>\n </div>\n\n {qrModal && <QRModal config={qrModal.config} name={qrModal.name} onClose={()=>setQrModal(null)}/>}\n\n {/* OPNsense WG shown below local when not yet available (nudge to set up) */}\n {!opnAvailable && (\n <OPNsenseWGSection\n opnWg={opnWg}\n opnLoading={opnLoading}\n opnError={opnError}\n opnSetup={opnSetup}\n setOpnSetup={setOpnSetup}\n opnPeerName={opnPeerName}\n setOpnPeerName={setOpnPeerName}\n opnVlans={opnVlans}\n opnAdding={opnAdding}\n session={session}\n onNeedAuth={onNeedAuth}\n vlans={vlans}\n onToggleVlan={opnToggleVlan}\n onSetupServer={opnSetupServer}\n onDeleteServer={opnDeleteServer}\n onAddPeer={opnAddPeer}\n onRevokePeer={opnRevokePeer}\n onShowConf={opnShowConf}\n onRefresh={loadOpnWg}\n />\n )}\n\n {opnQr && <QRModal config={opnQr.config} name={opnQr.name} onClose={()=>setOpnQr(null)}/>}\n </div>\n );\n}\n\n// ── OPNsense WireGuard section (separate component for readability) ───────────\nfunction OPNsenseWGSection({\n opnWg, opnLoading, opnError, opnSetup, setOpnSetup,\n opnPeerName, setOpnPeerName, opnVlans, opnAdding,\n session, onNeedAuth, vlans,\n onToggleVlan, onSetupServer, onDeleteServer,\n onAddPeer, onRevokePeer, onShowConf, onRefresh,\n}) {\n const panelHeader = (\n <div className="ph" style={{display:"flex",alignItems:"center",gap:8}}>\n ◈ OPNsense WireGuard — Router-Level VPN\n <span style={{marginLeft:"auto",fontSize:10,fontFamily:"var(--mono)",\n color:"var(--dm)",cursor:"pointer"}} onClick={onRefresh}>\n {opnLoading ? "loading…" : "↻ refresh"}\n </span>\n </div>\n );\n\n // ── OPNsense not connected ──────────────────────────────────────\n if (!opnWg || !opnWg.opnsense_configured) {\n return (\n <div className="panel">\n {panelHeader}\n <div className="pb" style={{color:"var(--dm)",fontSize:12,lineHeight:1.8}}>\n <div style={{marginBottom:8}}>\n Move WireGuard off your management computer and onto OPNsense.\n Each peer can be restricted to specific VLANs — e.g. a phone\n gets access to VLAN 10 only, while a laptop gets VLAN 10 + 20.\n </div>\n <div style={{background:"rgba(255,234,0,.07)",border:"1px solid rgba(255,234,0,.2)",\n borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--warn)"}}>\n OPNsense is not connected. Go to the <strong>DHCP tab</strong> and\n add your OPNsense API credentials first.\n </div>\n </div>\n </div>\n );\n }\n\n // ── Plugin not installed ────────────────────────────────────────\n if (!opnWg.plugin_installed) {\n return (\n <div className="panel">\n {panelHeader}\n <div className="pb">\n <div style={{background:"rgba(255,23,68,.07)",border:"1px solid rgba(255,23,68,.2)",\n borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--err)",marginBottom:12}}>\n <strong>WireGuard plugin not installed on OPNsense.</strong>\n {opnWg.error && <div style={{marginTop:4,opacity:.7}}>{opnWg.error}</div>}\n </div>\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.9}}>\n Install it in OPNsense:\n <ol style={{margin:"6px 0 0 16px",padding:0}}>\n <li>System → Firmware → Plugins</li>\n <li>Search <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>wireguard</span></li>\n <li>Install <strong>os-wireguard</strong></li>\n <li>Reload this page or click refresh above</li>\n </ol>\n </div>\n </div>\n </div>\n );\n }\n\n const server = opnWg.server;\n const peers = opnWg.peers || [];\n\n // ── Server not yet created ──────────────────────────────────────\n if (!server) {\n return (\n <div className="panel">\n {panelHeader}\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>\n Create a WireGuard server on OPNsense. It runs as <code>wg1</code> so it\n does not conflict with the local <code>wg0</code> on this machine.\n Peer private keys are generated here and stored only on this management PC —\n OPNsense only ever receives the public key.\n </div>\n\n {opnError && (\n <div style={{color:"var(--err)",fontSize:11,marginBottom:10}}>{opnError}</div>\n )}\n\n <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,marginBottom:14}}>\n <div className="field" style={{margin:0}}>\n <label>Server Name</label>\n <input value={opnSetup.server_name}\n onChange={e=>setOpnSetup(s=>({...s,server_name:e.target.value}))}\n placeholder="switch-mgmt-vpn"/>\n </div>\n <div className="field" style={{margin:0}}>\n <label>Listen Port</label>\n <input type="number" value={opnSetup.listen_port}\n onChange={e=>setOpnSetup(s=>({...s,listen_port:parseInt(e.target.value)||51820}))}/>\n </div>\n <div className="field" style={{margin:0}}>\n <label>Tunnel Subnet</label>\n <input value={opnSetup.tunnel_subnet}\n onChange={e=>setOpnSetup(s=>({...s,tunnel_subnet:e.target.value}))}\n placeholder="10.99.2.0/24"/>\n </div>\n <div className="field" style={{margin:0}}>\n <label>Your Public IP / DDNS</label>\n <input value={opnSetup.public_endpoint}\n onChange={e=>setOpnSetup(s=>({...s,public_endpoint:e.target.value}))}\n placeholder="home.example.com or 1.2.3.4"/>\n </div>\n </div>\n\n <div style={{fontSize:11,color:"var(--dm)",marginBottom:14,lineHeight:1.7}}>\n The tunnel subnet (default <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>10.99.2.0/24</span>)\n is separate from your LAN VLANs. VPN clients get IPs from this range.\n Your public IP / DDNS is what clients connect to from the internet.\n </div>\n\n <div style={{display:"flex",gap:8,alignItems:"center"}}>\n {!session && (\n <button className="btn bg" style={{fontSize:11,padding:"4px 10px"}} onClick={onNeedAuth}>\n Authenticate\n </button>\n )}\n <button className="btn bp" onClick={onSetupServer}\n disabled={opnLoading || !session || !opnSetup.server_name}>\n {opnLoading ? "Creating…" : "Create Server on OPNsense"}\n </button>\n </div>\n </div>\n </div>\n );\n }\n\n // ── Server active — show peers and add-peer form ────────────────\n const tunnelNet = opnSetup.tunnel_subnet || "10.99.2.0/24";\n\n // Determine which VLANs have firewall rules to show\n const allUsedVlans = [...new Set(peers.flatMap(p => p.allowed_vlans || []))];\n\n return (\n <div style={{display:"flex",flexDirection:"column",gap:12}}>\n {/* Server status card */}\n <div className="panel">\n {panelHeader}\n <div className="pb">\n <div style={{display:"flex",alignItems:"flex-start",gap:16,flexWrap:"wrap"}}>\n <div style={{flex:1,minWidth:200}}>\n <div style={{fontWeight:700,marginBottom:6,color:"var(--ok)"}}>\n{server.name}\n </div>\n <div style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)",lineHeight:2}}>\n <span style={{color:"var(--tx)"}}>Tunnel:</span> {server.tunnel_ip}<br/>\n <span style={{color:"var(--tx)"}}>Port:</span> {server.listen_port}<br/>\n <span style={{color:"var(--tx)"}}>Endpoint:</span> {server.public_endpoint||<em>not set</em>}\n </div>\n </div>\n <div style={{flex:2,minWidth:240}}>\n <div style={{fontSize:10,color:"var(--dm)",marginBottom:4}}>Server Public Key</div>\n <div style={{fontFamily:"var(--mono)",fontSize:10,background:"var(--bg)",\n borderRadius:4,padding:"6px 10px",wordBreak:"break-all",\n border:"1px solid var(--b1)",color:"var(--ac)"}}>\n {server.pubkey || "— generating —"}\n </div>\n </div>\n <button className="btn bd" style={{fontSize:10,padding:"3px 10px",alignSelf:"flex-start"}}\n onClick={onDeleteServer}>\n Remove Server\n </button>\n </div>\n {opnError && (\n <div style={{color:"var(--err)",fontSize:11,marginTop:10}}>{opnError}</div>\n )}\n </div>\n </div>\n\n {/* Peer list */}\n <div className="panel">\n <div className="ph">◈ VPN Peers ({peers.length})</div>\n <div className="pb">\n {peers.length === 0 && (\n <div className="empty" style={{padding:"12px 0"}}>No peers yet. Add one below.</div>\n )}\n {peers.map(p => (\n <div key={p.uuid} style={{display:"flex",alignItems:"center",gap:10,\n padding:"8px 0",borderBottom:"1px solid var(--b1)"}}>\n <div style={{width:8,height:8,borderRadius:"50%",flexShrink:0,\n background:p.enabled?"var(--ok)":"var(--dm)"}}/>\n <div style={{flex:1}}>\n <div style={{fontWeight:700,marginBottom:3}}>{p.name}</div>\n <div style={{fontFamily:"var(--mono)",fontSize:10,color:"var(--dm)"}}>\n {p.tunnel_ip}\n </div>\n <div style={{display:"flex",flexWrap:"wrap",gap:4,marginTop:4}}>\n {(p.allowed_vlans||[]).map(vid => {\n const v = vlans.find(x=>x.id===vid)||{name:`VLAN ${vid}`,color:"var(--dm)"};\n return (\n <span key={vid} style={{\n background:`${v.color}22`,color:v.color,\n borderRadius:10,padding:"1px 7px",fontSize:10,\n fontFamily:"var(--mono)",fontWeight:700,\n }}>VLAN {vid} · {v.name}</span>\n );\n })}\n </div>\n </div>\n <button className="btn bg" style={{fontSize:10,padding:"3px 8px"}}\n onClick={()=>onShowConf(p.name)}>\n .conf / QR\n </button>\n <button className="btn bd" style={{fontSize:10,padding:"3px 8px"}}\n onClick={()=>onRevokePeer(p.uuid, p.name)}>\n Revoke\n </button>\n </div>\n ))}\n\n {/* Add peer form */}\n <div style={{marginTop:16,paddingTop:14,borderTop:"1px solid var(--b1)"}}>\n <div style={{fontWeight:700,marginBottom:10,fontSize:12}}>Add Peer</div>\n <div style={{display:"flex",gap:10,alignItems:"flex-start",flexWrap:"wrap"}}>\n <div className="field" style={{margin:0,flex:"0 0 180px"}}>\n <label>Peer Name</label>\n <input value={opnPeerName}\n onChange={e=>setOpnPeerName(e.target.value)}\n onKeyDown={e=>e.key==="Enter"&&onAddPeer()}\n placeholder="e.g. phone, laptop"/>\n </div>\n <div style={{flex:1,minWidth:220}}>\n <label style={{display:"block",fontSize:11,color:"var(--dm)",\n marginBottom:6,fontFamily:"var(--mono)"}}>VLAN Access</label>\n <div style={{display:"flex",flexWrap:"wrap",gap:6}}>\n {vlans.filter(v=>v.id!==99).map(v => {\n const on = opnVlans.includes(v.id);\n return (\n <div key={v.id} onClick={()=>onToggleVlan(v.id)}\n style={{\n cursor:"pointer",userSelect:"none",borderRadius:5,\n padding:"5px 10px",fontSize:11,fontFamily:"var(--mono)",\n border:`1px solid ${on?v.color:"var(--b2)"}`,\n background:on?`${v.color}22`:"transparent",\n color:on?v.color:"var(--dm)",fontWeight:on?700:400,\n transition:"all .15s",\n }}>\n {on?"✓ ":""}{v.id} · {v.name}\n </div>\n );\n })}\n </div>\n </div>\n </div>\n\n <div style={{marginTop:12,display:"flex",gap:8,alignItems:"center"}}>\n {!session && (\n <button className="btn bg" style={{fontSize:11,padding:"4px 10px"}}\n onClick={onNeedAuth}>Authenticate</button>\n )}\n <button className="btn bp" onClick={onAddPeer}\n disabled={opnAdding||!opnPeerName.trim()||opnVlans.length===0||!session}>\n {opnAdding?"Adding…":"Add Peer"}\n </button>\n <span style={{fontSize:11,color:"var(--dm)"}}>\n Generates keypair here · sends only pubkey to OPNsense\n </span>\n </div>\n </div>\n </div>\n </div>\n\n {/* Firewall rules guidance */}\n {peers.length > 0 && (\n <div className="panel">\n <div className="ph">◈ Required OPNsense Firewall Rules</div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:10}}>\n WireGuard handles the tunnel, but OPNsense still enforces firewall rules\n between the tunnel and your VLANs. Add these rules under\n <span style={{fontFamily:"var(--mono)",color:"var(--ac)",margin:"0 4px"}}>\n Firewall → Rules → WireGuard\n </span>\n (the interface is <span style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>wg1</span>\n if this is instance 1):\n </div>\n <div style={{background:"#060809",borderRadius:4,padding:"10px 14px",\n fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",lineHeight:2}}>\n {/* One rule per unique VLAN across all peers */}\n {[...new Set(peers.flatMap(p=>p.allowed_vlans||[]))].sort((a,b)=>a-b).map(vid => {\n const v = vlans.find(x=>x.id===vid)||{name:`VLAN ${vid}`};\n return (\n <div key={vid}>\n <span style={{color:"#4fc"}}># Allow WireGuard → VLAN {vid} ({v.name})</span><br/>\n <span style={{color:"#ffa"}}>pass </span>\n <span>in interface wg1</span><br/>\n <span style={{paddingLeft:16}}>src: {tunnelNet}</span><br/>\n <span style={{paddingLeft:16}}>dst: 192.168.{vid}.0/24</span><br/>\n <br/>\n </div>\n );\n })}\n <span style={{color:"#4fc"}}># Block everything else from the tunnel</span><br/>\n <span style={{color:"#f88"}}>block </span>\n <span>in interface wg1 src: {tunnelNet} dst: any</span>\n </div>\n <div style={{fontSize:11,color:"var(--dm)",marginTop:8,lineHeight:1.7}}>\n Also open UDP {server?.listen_port||51820} inbound on your WAN interface\n so peers can reach OPNsense from the internet.\n </div>\n </div>\n </div>\n )}\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// DHCP MANAGEMENT TAB\n// ══════════════════════════════════════════════════════════════════════════════\n\n// VLAN definitions — matches the network plan\nconst VLAN_MAP = {\n 99: { name:"Management", color:"var(--dm)", note:"Switch recovery — always local" },\n 10: { name:"House", color:"var(--ok)", note:"Devices get hostname.lan via OPNsense DNS" },\n 20: { name:"Servers", color:"var(--ac)", note:"Server names resolve via OPNsense Unbound" },\n 30: { name:"IoT", color:"var(--warn)",note:"Isolated devices, predictable IPs for firewall rules" },\n 40: { name:"Guest", color:"#b388ff", note:"Internet only — printer access via firewall rule" },\n 50: { name:"Cameras", color:"#ff80ab", note:"NVR-only access, no internet" },\n};\n\n// Infer VLAN from IP address third octet\nfunction vlanFromIp(ip) {\n if (!ip) return null;\n const third = parseInt(ip.split(\'.\')[2], 10);\n return isNaN(third) ? null : third;\n}\n\nfunction VlanBadge({ vlan }) {\n const info = VLAN_MAP[vlan];\n const color = info?.color || "var(--dm)";\n const label = info ? `VLAN ${vlan} · ${info.name}` : (vlan ? `VLAN ${vlan}` : "—");\n return (\n <span style={{\n background: `${color}18`, color, borderRadius:10,\n padding:"1px 7px", fontSize:10, fontFamily:"var(--mono)", fontWeight:700,\n whiteSpace:"nowrap",\n }}>{label}</span>\n );\n}\n\n// Relay panel — shows per-VLAN relay status and lets you push config\nfunction RelayPanel({ overview, opnsenseHost, session, onNeedAuth, onRefresh }) {\n const [pushing, setPushing] = useState(false);\n const [result, setResult] = useState(null);\n const [opIp, setOpIp] = useState(opnsenseHost || "");\n\n useEffect(() => { if (opnsenseHost && !opIp) setOpIp(opnsenseHost); }, [opnsenseHost]);\n\n const relay = overview?.relay || {};\n const relayMap = relay.vlans || {}; // { "10": "192.168.99.1", ... }\n\n const relayVlans = [10, 20, 30, 40, 50];\n\n const push = async () => {\n if (!session) { onNeedAuth(); return; }\n if (!opIp) return;\n setPushing(true); setResult(null);\n try {\n const r = await API("/dhcp/relay/configure", {\n method:"POST", body:{ token:session.token, opnsense_ip:opIp, vlans:relayVlans }\n });\n setResult(r);\n onRefresh();\n } catch(e) { setResult({ success:false, error:e.message }); }\n setPushing(false);\n };\n\n const allConfigured = relayVlans.every(v => relayMap[v] === opIp && opIp);\n\n return (\n <div className="panel">\n <div className="ph">◈ DHCP Relay — Switch → OPNsense</div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>\n The switch relays DHCP requests from each VLAN to OPNsense, which assigns\n IPs, records leases, and registers hostnames in Unbound DNS automatically.\n VLAN 99 always stays local — it\'s the recovery path if OPNsense is unreachable.\n </div>\n\n {/* Per-VLAN status grid */}\n <div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(200px,1fr))",gap:8,marginBottom:14}}>\n\n {/* VLAN 99 — always local */}\n <div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>\n <div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>\n <VlanBadge vlan={99}/>\n <span style={{fontSize:10,marginLeft:"auto",color:"var(--dm)"}}>🔒 local</span>\n </div>\n <div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[99].note}</div>\n </div>\n\n {relayVlans.map(vid => {\n const configured = relayMap[vid];\n const isOk = configured && configured === opIp;\n const stale = configured && configured !== opIp;\n return (\n <div key={vid} style={{background:"var(--bg)",border:`1px solid ${isOk?"rgba(0,230,118,.3)":stale?"rgba(255,234,0,.3)":"var(--b2)"}`,borderRadius:4,padding:"10px 12px"}}>\n <div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>\n <VlanBadge vlan={vid}/>\n <span style={{fontSize:10,marginLeft:"auto",\n color: isOk?"var(--ok)":stale?"var(--warn)":"var(--dm)"}}>\n {isOk ? `→ ${configured}` : stale ? `→ ${configured} ⚠` : "not set"}\n </span>\n </div>\n <div style={{fontSize:10,color:"var(--dm)",lineHeight:1.5}}>{VLAN_MAP[vid]?.note}</div>\n </div>\n );\n })}\n </div>\n\n {/* Push controls */}\n <div style={{display:"flex",gap:8,alignItems:"flex-end",flexWrap:"wrap"}}>\n <div className="field" style={{margin:0}}>\n <label>OPNsense IP</label>\n <input value={opIp} onChange={e=>setOpIp(e.target.value)}\n placeholder="192.168.99.1"\n style={{fontFamily:"var(--mono)",maxWidth:160}}/>\n </div>\n <button className="btn bp" onClick={push}\n disabled={pushing||!opIp||!session}\n style={{fontSize:11}}>\n {pushing ? "Pushing…" : allConfigured ? "✓ Re-apply Relay Config" : "Push Relay Config to Switch"}\n </button>\n </div>\n {result && (\n <div style={{marginTop:8,fontSize:11,\n color:result.success?"var(--ok)":"var(--err)"}}>\n {result.success ? "✓ Relay config pushed — save running-config to make permanent" : `✗ ${result.error||"Push failed"}`}\n </div>\n )}\n </div>\n </div>\n );\n}\n\nfunction ConflictBadge({ conflict }) {\n return (\n <div style={{\n background: conflict.ip_conflict\n ? "rgba(255,23,68,.1)" : "rgba(255,234,0,.08)",\n border: `1px solid ${conflict.ip_conflict ? "rgba(255,23,68,.3)" : "rgba(255,234,0,.25)"}`,\n borderRadius: 4, padding: "8px 12px", marginBottom: 8,\n }}>\n <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4}}>\n <span style={{\n color: conflict.ip_conflict ? "var(--err)" : "var(--warn)",\n fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: 1\n }}>\n {conflict.ip_conflict ? "✗ IP Conflict" : "⚠ Duplicate Entry"}\n </span>\n <span style={{fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>{conflict.mac}</span>\n {conflict.hostname && <span style={{fontSize:11,color:"var(--tx)"}}>{conflict.hostname}</span>}\n </div>\n <div style={{fontSize:11,color:"var(--dm)",fontFamily:"var(--mono)",marginBottom:8}}>\n Switch: <span style={{color:"var(--ac)"}}>{conflict.switch_ip}</span>\n <span style={{margin:"0 8px"}}>·</span>\n OPNsense: <span style={{color:"var(--ac)"}}>{conflict.opnsense_ip}</span>\n </div>\n {conflict.ip_conflict\n ? <div style={{fontSize:11,color:"var(--err)",marginBottom:8}}>\n Same device has different IPs on switch and OPNsense. One will win — decide which is correct.\n </div>\n : <div style={{fontSize:11,color:"var(--warn)",marginBottom:8}}>\n Same reservation exists in both places. Not harmful but messy — consider removing one.\n </div>\n }\n </div>\n );\n}\n\nfunction OPNsenseSetup({ onConfigured }) {\n const [detecting, setDetecting] = useState(false);\n const [detected, setDetected] = useState(null);\n const [host, setHost] = useState(\'\');\n const [key, setKey] = useState(\'\');\n const [secret, setSecret] = useState(\'\');\n const [testing, setTesting] = useState(false);\n const [error, setError] = useState(\'\');\n\n const detect = async () => {\n setDetecting(true); setError(\'\');\n try {\n const r = await API("/dhcp/detect-opnsense");\n setDetected(r);\n if (r.detected) setHost(r.host);\n } catch(e) { setError(e.message); }\n setDetecting(false);\n };\n\n const connect = async () => {\n if (!host || !key || !secret) return;\n setTesting(true); setError(\'\');\n try {\n const r = await API("/dhcp/configure-opnsense", {\n method: "POST", body: { host, key, secret }\n });\n onConfigured(r);\n } catch(e) { setError(e.message); }\n setTesting(false);\n };\n\n return (\n <div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:5,padding:14}}>\n <div style={{fontSize:12,fontWeight:700,marginBottom:8,color:"var(--ac)"}}>Connect OPNsense</div>\n <div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7,marginBottom:12}}>\n Optional — provides a unified view of all DHCP reservations across your network.\n The switch manager will show reservations from both the switch and OPNsense side-by-side,\n flag conflicts, and let you sync between them.<br/><br/>\n In OPNsense: System → Access → Users → your user → API keys → Create key\n </div>\n <div style={{display:"flex",gap:8,marginBottom:10}}>\n <button className="btn bg" onClick={detect} disabled={detecting} style={{fontSize:11}}>\n {detecting ? "Detecting..." : "Auto-detect OPNsense"}\n </button>\n {detected && !detected.detected && (\n <span style={{fontSize:11,color:"var(--dm)",alignSelf:"center"}}>\n Not found at {detected.gateway}\n </span>\n )}\n {detected?.detected && (\n <span style={{fontSize:11,color:"var(--ok)",alignSelf:"center"}}>\n ✓ Found at {detected.host}\n </span>\n )}\n </div>\n <div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:8,marginBottom:10}}>\n <div className="field" style={{margin:0}}>\n <label>OPNsense IP</label>\n <input value={host} onChange={e=>setHost(e.target.value)} placeholder="192.168.99.1"\n style={{fontFamily:"var(--mono)"}}/>\n </div>\n <div className="field" style={{margin:0}}>\n <label>API Key</label>\n <input value={key} onChange={e=>setKey(e.target.value)} placeholder="key"\n style={{fontFamily:"var(--mono)"}}/>\n </div>\n <div className="field" style={{margin:0}}>\n <label>API Secret</label>\n <input type="password" value={secret} onChange={e=>setSecret(e.target.value)}\n placeholder="secret"/>\n </div>\n </div>\n {error && <div style={{color:"var(--err)",fontSize:11,marginBottom:8}}>{error}</div>}\n <button className="btn bp" onClick={connect} disabled={!host||!key||!secret||testing}>\n {testing ? "Testing..." : "Connect & Save"}\n </button>\n </div>\n );\n}\n\nfunction DHCPRow({ res, source, onSync, session, onNeedAuth }) {\n const [syncing, setSyncing] = useState(false);\n\n const doSync = async (direction) => {\n if (!session) { onNeedAuth(); return; }\n setSyncing(true);\n try {\n await onSync(res.mac, direction);\n } finally { setSyncing(false); }\n };\n\n const sourceColor = source === "switch" ? "var(--ac)" : "var(--warn)";\n const sourceName = source === "switch" ? "Switch" : "OPNsense";\n\n // Derive VLAN: prefer explicit vlan field, fall back to IP third octet\n const vid = res.vlan || vlanFromIp(res.ip);\n const purpose = res.descr || res.notes || VLAN_MAP[vid]?.note || "";\n\n return (\n <tr style={{borderBottom:"1px solid var(--b1)"}}>\n <td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>\n {res.mac}\n </td>\n <td style={{padding:"7px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--ac)"}}>\n {res.ip}\n </td>\n <td style={{padding:"7px 10px",fontSize:12}}>\n {res.hostname || res.name || <span style={{color:"var(--dm)"}}>—</span>}\n {purpose && <div style={{fontSize:10,color:"var(--dm)",marginTop:2}}>{purpose}</div>}\n </td>\n <td style={{padding:"7px 10px"}}>\n {vid ? <VlanBadge vlan={vid}/> : <span style={{color:"var(--dm)",fontSize:10}}>—</span>}\n </td>\n <td style={{padding:"7px 10px"}}>\n <span style={{\n background: `${sourceColor}18`, color: sourceColor,\n borderRadius: 10, padding: "1px 7px", fontSize: 10,\n fontFamily: "var(--mono)", fontWeight: 700\n }}>{sourceName}</span>\n </td>\n <td style={{padding:"7px 10px"}}>\n <div style={{display:"flex",gap:4}}>\n {source === "switch" && (\n <button className="btn bg" style={{fontSize:10,padding:"2px 7px"}} disabled={syncing}\n onClick={() => doSync("to_opnsense")}>→ OPNsense</button>\n )}\n {source === "opnsense" && (\n <button className="btn bg" style={{fontSize:10,padding:"2px 7px"}} disabled={syncing}\n onClick={() => doSync("to_switch")}>→ Switch</button>\n )}\n <button className="btn bd" style={{fontSize:10,padding:"2px 7px"}} disabled={syncing}\n onClick={() => doSync(source === "switch" ? "remove_switch" : "remove_opnsense")}>\n Remove\n </button>\n </div>\n </td>\n </tr>\n );\n}\n\nfunction DHCPTab({ session, onNeedAuth, backendOk }) {\n const [overview, setOverview] = useState(null);\n const [loading, setLoading] = useState(false);\n const [showSetup, setShowSetup] = useState(false);\n const [syncResult, setSyncResult] = useState(null);\n const [showConflictSync, setShowConflictSync] = useState(null); // conflict obj\n\n const load = async () => {\n setLoading(true);\n try { setOverview(await API("/dhcp/overview")); }\n catch(e) { console.error(e); }\n setLoading(false);\n };\n\n useEffect(() => { if (backendOk) load(); }, [backendOk]);\n\n const handleSync = async (mac, direction) => {\n if (!session) { onNeedAuth(); return; }\n try {\n const r = await API("/dhcp/sync", { method:"POST", body:{ token:session.token, mac, direction } });\n setSyncResult({ success: true, mac, direction });\n await load();\n } catch(e) {\n setSyncResult({ success: false, error: e.message });\n }\n };\n\n const disconnectOPNsense = async () => {\n if (!confirm("Remove OPNsense connection?")) return;\n await API("/dhcp/configure-opnsense", { method:"DELETE" });\n await load();\n };\n\n const sw = overview?.switch;\n const ops = overview?.opnsense;\n const conflicts = overview?.conflicts || [];\n\n // Merge all reservations for the unified table\n const allRes = [\n ...(sw?.reservations||[]).map(r=>({...r,source:"switch"})),\n ...(ops?.reservations||[]).map(r=>({...r,source:"opnsense"})),\n ];\n\n // Find MACs that appear in both (for conflict highlighting)\n const conflictMacs = new Set(conflicts.map(c=>c.mac));\n\n return (\n <div className="main" style={{flexDirection:"column",gap:12}}>\n\n {/* Header + status */}\n <div className="panel">\n <div className="ph">◈ DHCP Management\n <button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"3px 8px"}}\n onClick={load}>↻ Refresh</button>\n </div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.7,marginBottom:14}}>\n DHCP reservations tie a MAC address to a permanent IP so device access rules\n stay stable. Setting them in two places causes confusion — this panel shows\n everything in one view and helps you keep it consistent.\n </div>\n\n {/* DHCP server status */}\n <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10,marginBottom:14}}>\n <div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>\n <div style={{fontSize:10,fontWeight:700,letterSpacing:2,textTransform:"uppercase",\n color:"var(--dm)",marginBottom:6}}>Switch DHCP</div>\n <div style={{display:"flex",alignItems:"center",gap:6}}>\n <span style={{width:8,height:8,borderRadius:"50%",\n background:sw?.status?.running?"var(--ok)":"var(--dm)"}}/>\n <span style={{fontSize:12}}>\n {sw?.status?.running\n ? `Active — VLAN${sw.status.vlans?.length>1?"s":""} ${sw.status.vlans?.join(", ")||"?"}`\n : "Not running"}\n </span>\n </div>\n {sw?.status?.running && (\n <div style={{fontSize:10,color:"var(--dm)",marginTop:4}}>\n Use for VLAN 99 management — devices get IPs before OPNsense is reachable\n </div>\n )}\n </div>\n <div style={{background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:4,padding:"10px 12px"}}>\n <div style={{fontSize:10,fontWeight:700,letterSpacing:2,textTransform:"uppercase",\n color:"var(--dm)",marginBottom:6}}>OPNsense DHCP</div>\n <div style={{display:"flex",alignItems:"center",gap:6}}>\n <span style={{width:8,height:8,borderRadius:"50%",\n background:ops?.configured?"var(--ok)":"var(--dm)"}}/>\n <span style={{fontSize:12}}>\n {ops?.configured ? `Connected — ${ops.host}` : "Not connected"}\n </span>\n </div>\n <div style={{marginTop:6,display:"flex",gap:6}}>\n {!ops?.configured && (\n <button className="btn bg" style={{fontSize:10,padding:"2px 8px"}}\n onClick={()=>setShowSetup(s=>!s)}>\n {showSetup?"Hide":"Connect OPNsense"}\n </button>\n )}\n {ops?.configured && (\n <button className="btn bg" style={{fontSize:10,padding:"2px 8px"}}\n onClick={disconnectOPNsense}>Disconnect</button>\n )}\n </div>\n </div>\n </div>\n\n {showSetup && (\n <OPNsenseSetup onConfigured={() => { setShowSetup(false); load(); }}/>\n )}\n\n {/* Recommendation */}\n <div style={{background:"rgba(0,229,255,.05)",border:"1px solid rgba(0,229,255,.15)",\n borderRadius:4,padding:"10px 14px",fontSize:11,color:"var(--dm)",lineHeight:1.7}}>\n <span style={{color:"var(--ac)",fontWeight:700}}>Recommendation: </span>\n Use switch DHCP for VLAN 99 (management) only.\n Let OPNsense handle DHCP for all other VLANs — it integrates with DNS,\n firewall rules, and shows everything in one place.\n Never run both for the same VLAN.\n </div>\n </div>\n </div>\n\n {/* Relay configuration */}\n <RelayPanel\n overview={overview}\n opnsenseHost={ops?.host||""}\n session={session}\n onNeedAuth={onNeedAuth}\n onRefresh={load}\n />\n\n {/* Conflicts */}\n {conflicts.length > 0 && (\n <div className="panel">\n <div className="ph" style={{color:"var(--err)"}}>\n ✗ Conflicts Detected ({conflicts.length})\n </div>\n <div className="pb">\n <div style={{fontSize:11,color:"var(--dm)",marginBottom:12}}>\n The same MAC address has reservations in both the switch and OPNsense.\n Decide which one is correct and remove the other.\n </div>\n {conflicts.map(c => (\n <div key={c.mac}>\n <ConflictBadge conflict={c}/>\n <div style={{display:"flex",gap:6,marginBottom:12,flexWrap:"wrap"}}>\n <button className="btn bw" style={{fontSize:11}}\n onClick={()=>setShowConflictSync(showConflictSync?.mac===c.mac?null:c)}>\n {showConflictSync?.mac===c.mac ? "Hide options" : "Resolve →"}\n </button>\n </div>\n {showConflictSync?.mac === c.mac && (\n <div style={{background:"var(--bg)",border:"1px solid var(--b2)",\n borderRadius:4,padding:"10px 14px",marginBottom:12}}>\n <div style={{fontSize:11,fontWeight:700,marginBottom:8}}>Choose which IP wins:</div>\n <div style={{display:"flex",gap:8,flexWrap:"wrap"}}>\n <button className="btn bs" style={{fontSize:11}}\n onClick={()=>{handleSync(c.mac,"to_opnsense");setShowConflictSync(null);}}>\n Switch wins ({c.switch_ip}) — update OPNsense\n </button>\n <button className="btn bs" style={{fontSize:11}}\n onClick={()=>{handleSync(c.mac,"to_switch");setShowConflictSync(null);}}>\n OPNsense wins ({c.opnsense_ip}) — update switch\n </button>\n <button className="btn bd" style={{fontSize:11}}\n onClick={()=>{handleSync(c.mac,"remove_switch");setShowConflictSync(null);}}>\n Remove from switch only\n </button>\n <button className="btn bd" style={{fontSize:11}}\n onClick={()=>{handleSync(c.mac,"remove_opnsense");setShowConflictSync(null);}}>\n Remove from OPNsense only\n </button>\n </div>\n </div>\n )}\n </div>\n ))}\n </div>\n </div>\n )}\n\n {/* Sync result */}\n {syncResult && (\n <div className="panel">\n <div className="ph" style={{color:syncResult.success?"var(--ok)":"var(--err)"}}>\n {syncResult.success ? "✓ Sync complete" : `✗ Sync failed: ${syncResult.error}`}\n <button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"2px 8px"}}\n onClick={()=>setSyncResult(null)}>✕</button>\n </div>\n </div>\n )}\n\n {/* Unified reservations table */}\n <div className="panel">\n <div className="ph">◈ All Reservations ({allRes.length})</div>\n <div className="pb" style={{padding:0}}>\n {allRes.length === 0 && (\n <div className="empty">No reservations found. Add devices in the Device Access tab.</div>\n )}\n {allRes.length > 0 && (\n <table style={{width:"100%",borderCollapse:"collapse"}}>\n <thead>\n <tr style={{borderBottom:"1px solid var(--b1)"}}>\n {["MAC","IP","Name / Description","VLAN","Source","Actions"].map(h=>(\n <th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,\n letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {allRes.map((r,i) => (\n <tr key={`${r.mac}-${r.source}`}\n style={{\n background: conflictMacs.has(r.mac) ? "rgba(255,234,0,.03)" : "transparent",\n borderLeft: conflictMacs.has(r.mac) ? "2px solid var(--warn)" : "2px solid transparent",\n }}>\n <DHCPRow\n res={r} source={r.source}\n onSync={handleSync}\n session={session} onNeedAuth={onNeedAuth}\n />\n </tr>\n ))}\n </tbody>\n </table>\n )}\n </div>\n </div>\n\n {/* Active leases */}\n {((sw?.leases||[]).length > 0 || (ops?.leases||[]).length > 0) && (\n <div className="panel">\n <div className="ph">◈ Active Leases\n <span style={{marginLeft:6,fontSize:10,color:"var(--dm)"}}>\n ({(sw?.leases||[]).length + (ops?.leases||[]).length} total)\n </span>\n </div>\n <div className="pb" style={{padding:0}}>\n <table style={{width:"100%",borderCollapse:"collapse"}}>\n <thead>\n <tr style={{borderBottom:"1px solid var(--b1)"}}>\n {["MAC","IP","Hostname","Source"].map(h=>(\n <th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,\n letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {[...(sw?.leases||[]).map(l=>({...l,source:"switch"})),\n ...(ops?.leases||[]).map(l=>({...l,source:"opnsense"}))\n ].map((l,i)=>(\n <tr key={i} style={{borderBottom:"1px solid var(--b1)"}}>\n <td style={{padding:"6px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>{l.mac}</td>\n <td style={{padding:"6px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--ac)"}}>{l.ip}</td>\n <td style={{padding:"6px 10px",fontSize:11}}>{l.hostname||<span style={{color:"var(--dm)"}}>—</span>}</td>\n <td style={{padding:"6px 10px"}}>\n <span style={{\n background:l.source==="switch"?"rgba(0,229,255,.12)":"rgba(255,234,0,.12)",\n color:l.source==="switch"?"var(--ac)":"var(--warn)",\n borderRadius:10,padding:"1px 7px",fontSize:10,fontFamily:"var(--mono)",fontWeight:700\n }}>{l.source==="switch"?"Switch":"OPNsense"}</span>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n )}\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// LOCAL HOSTNAME PANEL — dnsmasq .lan resolution\n// ══════════════════════════════════════════════════════════════════════════════\n\nfunction LocalHostnamesPanel({ session, onNeedAuth }) {\n const [entries, setEntries] = useState([]); // [{ name, ip }]\n const [loaded, setLoaded] = useState(false);\n const [saving, setSaving] = useState(false);\n const [result, setResult] = useState(null);\n const [showConf, setShowConf] = useState(false);\n const [localDomain, setLocalDomain] = useState("lan");\n\n const load = async () => {\n try {\n const r = await API("/dns/local-hostnames");\n setEntries(r.entries || []);\n setLoaded(true);\n } catch(e) { setLoaded(true); }\n };\n useEffect(() => { load(); }, []);\n\n const addEntry = () => setEntries(prev => [...prev, { name:"", ip:"" }]);\n const upEntry = (i, k, v) => setEntries(prev => prev.map((e,idx) => idx===i?{...e,[k]:v}:e));\n const delEntry = i => setEntries(prev => prev.filter((_,idx)=>idx!==i));\n\n const save = async () => {\n if (!session) { onNeedAuth(); return; }\n setSaving(true); setResult(null);\n try {\n const r = await API("/dns/local-hostnames", {\n method:"POST",\n body: { token:session.token, entries, local_domain:localDomain }\n });\n setResult(r);\n } catch(e) {\n setResult({ success:false, message:e.message });\n }\n setSaving(false);\n };\n\n return (\n <div className="panel">\n <div className="ph">◈ Local Hostnames (.lan resolution)\n <span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>\n Optional — needs dnsmasq Docker service\n </span>\n </div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:12}}>\n Add a <strong>dnsmasq</strong> container to resolve <code>.lan</code> hostnames\n for all devices. ctrld forwards <code>*.lan</code> queries to dnsmasq on port 5353;\n all other queries go through Control D as normal.\n <br/>\n <code style={{fontFamily:"var(--mono)",color:"var(--ac)",fontSize:11}}>\n switch.mgmt.lan\n </code>{" "}and{" "}\n <code style={{fontFamily:"var(--mono)",color:"var(--ac)",fontSize:11}}>\n management.lan\n </code>{" "}\n always resolve to the management computer\'s IP.\n </div>\n\n <div style={{display:"flex",gap:10,marginBottom:12,alignItems:"flex-end"}}>\n <div className="field" style={{margin:0}}>\n <label>Local domain suffix</label>\n <input value={localDomain} onChange={e=>setLocalDomain(e.target.value)}\n placeholder="lan"\n style={{fontFamily:"var(--mono)",maxWidth:120}}/>\n </div>\n <div style={{fontSize:10,color:"var(--dm)",paddingBottom:4}}>\n Queries for <code>*.{localDomain}</code> and <code>*.local</code> are\n forwarded to dnsmasq (port 5353).\n </div>\n </div>\n\n {/* Hostname table */}\n {entries.length > 0 && (\n <table style={{width:"100%",borderCollapse:"collapse",marginBottom:10}}>\n <thead>\n <tr style={{borderBottom:"1px solid var(--b1)"}}>\n {["Hostname","IP Address",""].map(h=>(\n <th key={h} style={{textAlign:"left",padding:"4px 8px",fontSize:10,\n letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {entries.map((e,i)=>(\n <tr key={i} style={{borderBottom:"1px solid var(--b1)"}}>\n <td style={{padding:"4px 8px"}}>\n <input value={e.name} onChange={ev=>upEntry(i,"name",ev.target.value)}\n placeholder={`printer.${localDomain}`}\n style={{\n width:"100%",background:"var(--bg)",border:"1px solid var(--b2)",\n color:"var(--tx)",padding:"3px 6px",fontFamily:"var(--mono)",\n fontSize:11,borderRadius:3,\n }}/>\n </td>\n <td style={{padding:"4px 8px"}}>\n <input value={e.ip} onChange={ev=>upEntry(i,"ip",ev.target.value)}\n placeholder="192.168.10.50"\n style={{\n width:"100%",background:"var(--bg)",border:"1px solid var(--b2)",\n color:"var(--tx)",padding:"3px 6px",fontFamily:"var(--mono)",\n fontSize:11,borderRadius:3,\n }}/>\n </td>\n <td style={{padding:"4px 8px"}}>\n <button className="btn bd" style={{padding:"2px 6px",fontSize:10}}\n onClick={()=>delEntry(i)}>✕</button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n )}\n\n <div style={{display:"flex",gap:8,flexWrap:"wrap",marginBottom: result?12:0}}>\n <button className="btn bg" style={{fontSize:10}} onClick={addEntry}>+ Add Hostname</button>\n <button className="btn bp" style={{fontSize:10}}\n onClick={save} disabled={saving||!session}>\n {saving?"Saving...":"Save & Generate dnsmasq.conf"}\n </button>\n {!session && (\n <button className="btn bg" style={{fontSize:10}} onClick={onNeedAuth}>Authenticate</button>\n )}\n </div>\n\n {/* Result */}\n {result && result.success && (\n <div style={{marginTop:10}}>\n <div style={{\n padding:"8px 12px",borderRadius:4,marginBottom:10,fontSize:11,\n background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",\n color:"var(--dm)",lineHeight:1.7,\n }}>\n <span style={{color:"var(--ok)",fontWeight:700}}>✓ </span>\n {result.message}\n </div>\n\n {/* dnsmasq.conf */}\n <div style={{marginBottom:10}}>\n <div style={{display:"flex",gap:6,marginBottom:6,alignItems:"center"}}>\n <span style={{fontSize:11,color:"var(--dm)"}}>\n dnsmasq.conf written to{" "}\n <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.conf_path}</code>\n </span>\n <button className="btn bg" style={{fontSize:10,padding:"1px 7px"}}\n onClick={()=>setShowConf(s=>!s)}>{showConf?"Hide":"Show"} config</button>\n <button className="btn bg" style={{fontSize:10,padding:"1px 7px"}}\n onClick={()=>navigator.clipboard?.writeText(result.dnsmasq_conf)}>Copy</button>\n </div>\n {showConf && (\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 12px",\n fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",\n whiteSpace:"pre",overflowX:"auto",maxHeight:180,overflowY:"auto",\n }}>{result.dnsmasq_conf}</div>\n )}\n </div>\n\n {/* docker-compose snippet */}\n <div style={{marginBottom:10}}>\n <div style={{fontSize:11,color:"var(--dm)",marginBottom:4}}>\n Add to docker-compose.yml then run:{" "}\n <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>docker compose up -d dnsmasq</code>\n </div>\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 12px",\n fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",\n whiteSpace:"pre",overflowX:"auto",\n }}>{result.docker_compose_snippet}</div>\n <button className="btn bg" style={{marginTop:6,fontSize:10}}\n onClick={()=>navigator.clipboard?.writeText(result.docker_compose_snippet)}>\n Copy compose snippet\n </button>\n </div>\n\n {/* ctrld.toml split-horizon block */}\n <div>\n <div style={{fontSize:11,color:"var(--dm)",marginBottom:4}}>\n Append to ctrld.toml (before the fallback upstream):\n </div>\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 12px",\n fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",\n whiteSpace:"pre",overflowX:"auto",\n }}>{result.split_horizon}</div>\n <button className="btn bg" style={{marginTop:6,fontSize:10}}\n onClick={()=>navigator.clipboard?.writeText(result.split_horizon)}>\n Copy toml block\n </button>\n </div>\n </div>\n )}\n {result && !result.success && (\n <div style={{\n marginTop:10,padding:"8px 12px",borderRadius:4,fontSize:11,\n background:"rgba(255,100,100,.06)",border:"1px solid rgba(255,100,100,.2)",\n color:"var(--err)",\n }}>✗ {result.message}</div>\n )}\n </div>\n </div>\n );\n}\n\n// ══════════════════════════════════════════════════════════════════════════════\n// DNS FILTERING TAB — Control D / ctrld\n// ══════════════════════════════════════════════════════════════════════════════\n\nconst CTRLD_MODES = {\n local: {\n label: "Option A — ctrld on this management computer",\n color: "var(--ok)",\n badge: "Fully automated",\n description: `Installs the ctrld daemon directly on this machine alongside the\nswitch manager. One command downloads and installs it, writes a\nper-VLAN config, and starts it as a system service.\n\nHow it works:\n • ctrld listens on port 53 on this machine\'s IP\n • The switch DHCP hands out this machine\'s IP as DNS for each VLAN\n • ctrld sees the source VLAN subnet and routes to the right profile\n • Each VLAN gets filtered by its own Control D profile via DoH3\n\nPort 53 note: On Ubuntu/Debian, systemd-resolved holds port 53.\nThe installer automatically disables its stub listener (DNSStubListener=no)\nbefore starting ctrld. The systemd-resolved service itself stays running.\n\nBest for: most setups. Self-contained, no OPNsense required.\nRequires: a Resolver ID per VLAN from your Control D dashboard.`,\n docsUrl: "https://docs.controld.com/docs/ctrld",\n },\n opnsense: {\n label: "Option B — ctrld on OPNsense",\n color: "var(--warn)",\n badge: "Semi-automated",\n description: `Installs ctrld on your OPNsense router instead of this machine.\nThe setup generates a single SSH command you paste into OPNsense shell.\nOPNsense then becomes the DNS resolver for your network.\n\nHow it works:\n • You run one command in OPNsense shell (via SSH or console)\n • ctrld installs as a service on OPNsense\n • OPNsense\'s IP becomes the DNS server for each VLAN\n • Per-VLAN routing uses source IP matching in ctrld config\n\nUnbound conflict: OPNsense runs Unbound on port 53. The correct fix is:\n 1. Move Unbound to listen on 127.0.0.1:5353 (keep it for .lan names)\n 2. Run ctrld on port 53\n 3. Tell ctrld to forward *.lan / *.local to 127.0.0.1:5353\nInstructions are shown in the result panel after generating the command.\n\nBest for: setups where OPNsense is already the DNS server, or where\nyou want DNS handled at the router rather than the switch manager machine.\nRequires: SSH access to OPNsense and a Resolver ID per VLAN.`,\n docsUrl: "https://docs.controld.com/docs/routers-platform",\n },\n manual: {\n label: "Option C — manual / existing setup",\n color: "var(--dm)",\n badge: "Config generated",\n description: `Generates the ctrld.toml config file and install command for you\nto apply manually wherever you choose to run ctrld.\n\nThis option is for:\n • Running ctrld on a separate dedicated machine\n • Integrating with an existing DNS setup\n • Using dnscrypt-proxy or another DoH3 proxy instead of ctrld\n • Advanced users who want full control\n\nThe site generates the correct ctrld.toml and DHCP option 6 values.\nYou install and configure ctrld yourself.\n\nInstall command (any Linux/Mac/OPNsense):\n sh -c \'sh -c "$(curl -sL https://api.controld.com/dl)" -s RESOLVER_ID forced\'`,\n docsUrl: "https://docs.controld.com/docs/ctrld",\n },\n};\n\nfunction CtrldVlanRow({ vlan, profile, onChange }) {\n return (\n <tr style={{borderBottom:"1px solid var(--b1)"}}>\n <td style={{padding:"8px 10px"}}>\n <div style={{display:"flex",alignItems:"center",gap:8}}>\n <span style={{width:10,height:10,borderRadius:2,background:vlan.color,flexShrink:0}}/>\n <span style={{fontWeight:600}}>VLAN {vlan.id}</span>\n <span style={{color:"var(--dm)",fontSize:11}}>{vlan.name}</span>\n </div>\n </td>\n <td style={{padding:"8px 10px",fontFamily:"var(--mono)",fontSize:11,color:"var(--dm)"}}>\n 192.168.{vlan.id}.0/24\n </td>\n <td style={{padding:"8px 10px"}}>\n <input\n value={profile?.resolver_id || ""}\n onChange={e => onChange(vlan.id, "resolver_id", e.target.value)}\n placeholder="Resolver ID from Control D dashboard"\n style={{\n width:"100%", background:"var(--bg)", border:"1px solid var(--b2)",\n color:"var(--tx)", padding:"4px 8px", fontFamily:"var(--mono)",\n fontSize:11, borderRadius:3,\n }}\n />\n </td>\n <td style={{padding:"8px 10px"}}>\n {profile?.resolver_id\n ? <span style={{color:"var(--ok)",fontSize:11}}>✓ configured</span>\n : <span style={{color:"var(--dm)",fontSize:11}}>not set — will use fallback</span>\n }\n </td>\n </tr>\n );\n}\n\nfunction DNSTab({ vlans, session, onNeedAuth, backendOk, acls, setAcls }) {\n const [status, setStatus] = useState(null);\n const [mode, setMode] = useState(null);\n const [profiles, setProfiles] = useState({});\n const [opnsenseHost, setOpnsenseHost] = useState("");\n const [loading, setLoading] = useState(false);\n const [saving, setSaving] = useState(false);\n const [result, setResult] = useState(null);\n const [showToml, setShowToml] = useState(false);\n\n // DNS enforcement state\n const [enforceIp, setEnforceIp] = useState("");\n const [enforceLoading, setEnforceLoading] = useState(false);\n const [enforceResult, setEnforceResult] = useState(null);\n\n // Local domain split-horizon state\n const [localDomain, setLocalDomain] = useState("lan");\n const [showLocalDomain, setShowLocalDomain] = useState(false);\n\n const load = async () => {\n setLoading(true);\n try {\n const s = await API("/ctrld/status");\n setStatus(s);\n if (s.mode) setMode(s.mode);\n if (s.vlan_profiles?.length) {\n const p = {};\n s.vlan_profiles.forEach(vp => { p[vp.vlan_id] = vp; });\n setProfiles(p);\n }\n } catch(e) { console.error(e); }\n setLoading(false);\n };\n\n useEffect(() => { if (backendOk) load(); }, [backendOk]);\n\n // Initialise profiles from VLANs\n useEffect(() => {\n if (vlans.length && Object.keys(profiles).length === 0) {\n const p = {};\n vlans.forEach(v => {\n p[v.id] = {\n vlan_id: v.id,\n name: v.name,\n subnet: `192.168.${v.id}.0/24`,\n resolver_id: "",\n };\n });\n setProfiles(p);\n }\n }, [vlans]);\n\n const updateProfile = (vlanId, field, value) => {\n setProfiles(prev => ({\n ...prev,\n [vlanId]: {\n ...prev[vlanId],\n vlan_id: vlanId,\n name: vlans.find(v=>v.id===vlanId)?.name || `VLAN ${vlanId}`,\n subnet: `192.168.${vlanId}.0/24`,\n [field]: value,\n }\n }));\n };\n\n const save = async () => {\n if (!session) { onNeedAuth(); return; }\n if (!mode) { alert("Choose an option first"); return; }\n setSaving(true); setResult(null);\n try {\n const vlan_profiles = Object.values(profiles).filter(p => p.resolver_id);\n const r = await API("/ctrld/save-config", {\n method: "POST",\n body: {\n token: session.token,\n config: { mode, vlan_profiles, opnsense_host: opnsenseHost },\n }\n });\n setResult(r);\n await load();\n } catch(e) {\n setResult({ success: false, message: e.message });\n }\n setSaving(false);\n };\n\n const updateProfiles = async () => {\n if (!session) { onNeedAuth(); return; }\n setSaving(true);\n try {\n const vlan_profiles = Object.values(profiles).filter(p => p.resolver_id);\n const r = await API("/ctrld/update-profiles", {\n method: "POST",\n body: { token: session.token, vlan_profiles }\n });\n setResult(r);\n } catch(e) {\n setResult({ success: false, message: e.message });\n }\n setSaving(false);\n };\n\n // Generate DNS enforcement ACLs for all non-management VLANs\n const generateEnforceAcls = async () => {\n if (!session) { onNeedAuth(); return; }\n const ip = enforceIp || status?.dns_ip;\n if (!ip) { alert("Enter the ctrld IP address first"); return; }\n setEnforceLoading(true); setEnforceResult(null);\n try {\n const vlan_ids = vlans.filter(v => v.id !== 99).map(v => v.id);\n const r = await API("/ctrld/dns-enforce-acls", {\n method: "POST",\n body: { token: session.token, ctrld_ip: ip, vlan_ids }\n });\n setEnforceResult(r);\n // Pre-load commands into Review & Push by storing as a special ACL marker\n // (user will copy them to the Review & Push tab)\n } catch(e) {\n setEnforceResult({ success: false, message: e.message });\n }\n setEnforceLoading(false);\n };\n\n const configuredProfiles = Object.values(profiles).filter(p => p.resolver_id);\n const isInstalled = status?.installed;\n const isRunning = status?.running;\n\n return (\n <div className="main" style={{flexDirection:"column",gap:12}}>\n\n {/* Status header */}\n <div className="panel">\n <div className="ph">◈ DNS Filtering — Control D\n <span style={{marginLeft:"auto",display:"flex",alignItems:"center",gap:6,\n fontFamily:"var(--mono)",fontSize:10}}>\n {isRunning\n ? <><span style={{width:7,height:7,borderRadius:"50%",background:"var(--ok)"}}/> ctrld running</>\n : isInstalled\n ? <><span style={{width:7,height:7,borderRadius:"50%",background:"var(--warn)"}}/> ctrld installed, not running</>\n : <><span style={{width:7,height:7,borderRadius:"50%",background:"var(--dm)"}}/> not configured</>\n }\n </span>\n </div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:14}}>\n Control D filters DNS queries per VLAN — ads, malware, adult content,\n social media, and more. Each VLAN gets its own profile with different rules.\n The <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>ctrld</code> daemon\n acts as a local DNS proxy, accepting plain DNS from devices and forwarding\n upstream via DoH3 to Control D. Your switch DHCP points each VLAN to it.\n <br/><br/>\n <span style={{color:"var(--ac)"}}>\n You need a Control D account and a Resolver ID per VLAN.\n </span>\n {" "}Get them at{" "}\n <a href="https://controld.com" target="_blank"\n style={{color:"var(--ac)"}}>controld.com</a>\n {" "}→ Add Device → Router → copy the Resolver ID shown.\n </div>\n\n {/* Mode selection */}\n {!isInstalled && (\n <div style={{marginBottom:14}}>\n <div className="sect">Choose how to run ctrld</div>\n <div style={{display:"flex",flexDirection:"column",gap:8}}>\n {Object.entries(CTRLD_MODES).map(([key, m]) => (\n <div key={key}\n onClick={() => setMode(key)}\n style={{\n background: mode===key ? "rgba(0,229,255,.05)" : "var(--bg)",\n border: `1px solid ${mode===key ? "var(--ac)" : "var(--b2)"}`,\n borderRadius: 5, padding:"12px 14px", cursor:"pointer",\n transition:"all .15s",\n }}>\n <div style={{display:"flex",alignItems:"center",gap:10,marginBottom:6}}>\n <div style={{width:16,height:16,borderRadius:"50%",border:`2px solid ${m.color}`,\n background:mode===key?m.color:"transparent",flexShrink:0}}/>\n <span style={{fontWeight:700,fontSize:12}}>{m.label}</span>\n <span style={{\n background:`${m.color}20`,color:m.color,\n fontSize:10,fontFamily:"var(--mono)",fontWeight:700,\n padding:"1px 7px",borderRadius:10,marginLeft:"auto"\n }}>{m.badge}</span>\n </div>\n <pre style={{\n fontFamily:"var(--sans)",fontSize:11,color:"var(--dm)",\n lineHeight:1.7,margin:0,whiteSpace:"pre-wrap",\n paddingLeft:26,\n }}>{m.description}</pre>\n <div style={{paddingLeft:26,marginTop:6}}>\n <a href={m.docsUrl} target="_blank"\n style={{fontSize:10,color:"var(--ac)"}}\n onClick={e=>e.stopPropagation()}>\n Documentation →\n </a>\n </div>\n </div>\n ))}\n </div>\n\n {mode === "opnsense" && (\n <div className="field" style={{marginTop:12}}>\n <label>OPNsense IP address</label>\n <input value={opnsenseHost}\n onChange={e=>setOpnsenseHost(e.target.value)}\n placeholder="192.168.99.1"\n style={{fontFamily:"var(--mono)",maxWidth:220}}/>\n </div>\n )}\n </div>\n )}\n\n {/* Already installed — show current mode */}\n {isInstalled && (\n <div style={{\n background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",\n borderRadius:4,padding:"10px 14px",marginBottom:14,\n display:"flex",alignItems:"center",gap:10,\n }}>\n <span style={{color:"var(--ok)",fontSize:13}}>✓</span>\n <div style={{fontSize:12}}>\n ctrld is installed — <strong>{CTRLD_MODES[status?.mode||"local"]?.label || status?.mode}</strong>\n <div style={{fontSize:10,color:"var(--dm)",fontFamily:"var(--mono)",marginTop:2}}>\n {status?.output}\n </div>\n </div>\n </div>\n )}\n </div>\n </div>\n\n {/* Per-VLAN Resolver IDs */}\n <div className="panel">\n <div className="ph">◈ Control D Resolver IDs — per VLAN\n <span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>\n From controld.com → Add Device → Router → Resolver ID\n </span>\n </div>\n <div className="pb" style={{padding:0}}>\n <table style={{width:"100%",borderCollapse:"collapse"}}>\n <thead>\n <tr style={{borderBottom:"1px solid var(--b1)"}}>\n {["VLAN","Subnet","Resolver ID","Status"].map(h=>(\n <th key={h} style={{textAlign:"left",padding:"6px 10px",fontSize:10,\n letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {vlans.map(v => (\n <CtrldVlanRow\n key={v.id}\n vlan={v}\n profile={profiles[v.id]}\n onChange={updateProfile}\n />\n ))}\n </tbody>\n </table>\n\n <div style={{padding:"12px 14px",borderTop:"1px solid var(--b1)",\n display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>\n <div style={{fontSize:11,color:"var(--dm)",flex:1}}>\n {configuredProfiles.length} of {vlans.length} VLANs have a Resolver ID.\n {configuredProfiles.length < vlans.length &&\n " VLANs without a Resolver ID will use the first configured profile as fallback."}\n </div>\n {isInstalled\n ? <button className="btn bp" onClick={updateProfiles} disabled={saving||!session}>\n {saving ? "Saving..." : "Update Profiles"}\n </button>\n : <button className="btn bp" onClick={save}\n disabled={saving||!session||!mode||configuredProfiles.length===0}>\n {saving ? "Installing..." : mode==="local" ? "Install & Start ctrld" :\n mode==="opnsense" ? "Generate OPNsense Command" : "Save & Generate Config"}\n </button>\n }\n {!session && (\n <button className="btn bg" style={{fontSize:10}} onClick={onNeedAuth}>\n Authenticate\n </button>\n )}\n </div>\n </div>\n </div>\n\n {/* Result panel */}\n {result && (\n <div className="panel">\n <div className="ph" style={{color:result.success?"var(--ok)":"var(--err)"}}>\n {result.success ? "✓ " : "✗ "}\n {result.message}\n <button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"2px 8px"}}\n onClick={()=>setResult(null)}>✕</button>\n </div>\n {result.success && (\n <div className="pb">\n {/* Option A result */}\n {result.mode === "local" && result.dns_ip && (\n <div style={{fontSize:12,lineHeight:1.8}}>\n <div style={{marginBottom:8}}>\n <span style={{color:"var(--ac)",fontFamily:"var(--mono)"}}>\n DNS IP for DHCP option 6: {result.dns_ip}\n </span>\n </div>\n <div style={{color:"var(--dm)",marginBottom:12}}>{result.dhcp_action}</div>\n <div style={{fontSize:11,color:"var(--dm)"}}>\n Go to the <strong>DHCP tab</strong> → select each VLAN pool →\n set DNS server to <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.dns_ip}</code>\n </div>\n </div>\n )}\n\n {/* Option B result */}\n {result.mode === "opnsense" && (\n <div style={{fontSize:12,lineHeight:1.9}}>\n <div className="sect">Run this in OPNsense shell</div>\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 14px",\n fontFamily:"var(--mono)",fontSize:12,color:"#a0b0c0",marginBottom:12,\n overflowX:"auto",whiteSpace:"pre",\n }}>{result.ssh_cmd || result.install_cmd}</div>\n <div style={{color:"var(--dm)",fontSize:11,lineHeight:1.8}}>\n <div>After install:</div>\n <div>1. {result.step2}</div>\n <div>2. {result.step3}</div>\n <div>3. {result.step4}</div>\n </div>\n <button className="btn bg" style={{marginTop:10,fontSize:10}}\n onClick={()=>navigator.clipboard?.writeText(result.ssh_cmd||result.install_cmd)}>\n Copy Command\n </button>\n\n {/* OPNsense Unbound conflict resolution */}\n <div style={{\n marginTop:14,padding:"12px 14px",\n background:"rgba(255,193,7,.06)",border:"1px solid rgba(255,193,7,.2)",\n borderRadius:4,fontSize:11,lineHeight:1.8,\n }}>\n <div style={{fontWeight:700,color:"var(--warn)",marginBottom:6}}>\n OPNsense Unbound conflict — port 53\n </div>\n <div style={{color:"var(--dm)"}}>\n OPNsense runs Unbound DNS on port 53. ctrld needs port 53.\n The right fix is to keep Unbound running (it resolves <code>.lan</code> hostnames)\n but move it to <code>127.0.0.1:5353</code>, then run ctrld on <code>:53</code>.\n </div>\n <div style={{marginTop:8,fontWeight:600}}>Steps in OPNsense UI:</div>\n <ol style={{margin:"4px 0 0 18px",color:"var(--dm)"}}>\n <li>Services → Unbound DNS → General → change "Listen Port" to <code>5353</code>\n and "Listen Interface" to <code>Loopback (lo0)</code>. Save + Apply.</li>\n <li>Add a forwarding rule in ctrld.toml (shown in the TOML preview below)\n to send <code>*.{localDomain}</code> and <code>*.local</code> to\n <code>127.0.0.1:5353</code>.</li>\n <li>Run <code>ctrld restart</code> on OPNsense after placing the new config.</li>\n </ol>\n <div style={{marginTop:8,display:"flex",alignItems:"center",gap:8}}>\n <span style={{color:"var(--dm)"}}>Local domain:</span>\n <input value={localDomain} onChange={e=>setLocalDomain(e.target.value)}\n style={{\n fontFamily:"var(--mono)",fontSize:11,maxWidth:120,\n background:"var(--bg)",border:"1px solid var(--b2)",\n color:"var(--tx)",padding:"2px 6px",borderRadius:3,\n }}/>\n <span style={{color:"var(--dm)",fontSize:10}}>\n (default: lan — queries for *.{localDomain} forwarded to Unbound)\n </span>\n </div>\n </div>\n </div>\n )}\n\n {/* Option C result */}\n {result.mode === "manual" && (\n <div style={{fontSize:12,lineHeight:1.8}}>\n <div style={{color:"var(--dm)",marginBottom:10}}>\n Install command (run on whichever machine will run ctrld):\n </div>\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 14px",\n fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",marginBottom:12,\n }}>{result.install_cmd}</div>\n <div style={{color:"var(--dm)",marginBottom:6}}>\n Then place the config below at: <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.config_path}</code>\n </div>\n </div>\n )}\n\n {/* TOML config — shown for B and C, toggleable for A */}\n {result.toml && (\n <div>\n <button className="btn bg" style={{fontSize:10,marginBottom:8}}\n onClick={()=>setShowToml(s=>!s)}>\n {showToml ? "Hide" : "Show"} ctrld.toml config\n </button>\n <button className="btn bg" style={{fontSize:10,marginBottom:8,marginLeft:6}}\n onClick={()=>navigator.clipboard?.writeText(result.toml)}>\n Copy toml\n </button>\n {showToml && (\n <div style={{\n background:"#060809",borderRadius:4,padding:"12px 14px",\n fontFamily:"var(--mono)",fontSize:11,color:"#a0b0c0",\n whiteSpace:"pre",overflowX:"auto",maxHeight:300,overflowY:"auto",\n }}>{result.toml}</div>\n )}\n </div>\n )}\n\n {/* Port 53 fix notification (Option A) */}\n {result.mode === "local" && result.port53?.needed && (\n <div style={{\n marginTop:12,padding:"10px 14px",borderRadius:4,fontSize:11,lineHeight:1.7,\n background: result.port53.fixed\n ? "rgba(0,230,118,.06)" : "rgba(255,100,100,.06)",\n border: result.port53.fixed\n ? "1px solid rgba(0,230,118,.2)" : "1px solid rgba(255,100,100,.2)",\n }}>\n <span style={{fontWeight:700,color:result.port53.fixed?"var(--ok)":"var(--err)"}}>\n {result.port53.fixed ? "✓ " : "✗ "}Port 53 conflict:{" "}\n </span>\n <span style={{color:"var(--dm)"}}>{result.port53.message}</span>\n {!result.port53.fixed && (\n <div style={{marginTop:6,color:"var(--dm)"}}>\n Fix manually:{" "}\n <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>\n echo "[Resolve]" | sudo tee -a /etc/systemd/resolved.conf\n &amp;&amp; echo "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf\n &amp;&amp; sudo systemctl restart systemd-resolved\n </code>\n </div>\n )}\n </div>\n )}\n\n {/* DHCP reminder */}\n {result.success && (\n <div style={{\n marginTop:12,background:"rgba(0,229,255,.05)",\n border:"1px solid rgba(0,229,255,.15)",borderRadius:4,\n padding:"10px 14px",fontSize:11,color:"var(--dm)",lineHeight:1.7,\n }}>\n <span style={{color:"var(--ac)",fontWeight:700}}>Next step: </span>\n In the <strong>DHCP tab</strong>, configure each VLAN pool to use\n the ctrld machine\'s IP as DNS (option 6). The switch will then hand\n out the correct DNS server to every device on each VLAN automatically.\n </div>\n )}\n </div>\n )}\n </div>\n )}\n\n {/* Local Hostname Resolution */}\n <LocalHostnamesPanel session={session} onNeedAuth={onNeedAuth} />\n\n {/* DNS Enforcement ACLs */}\n <div className="panel">\n <div className="ph">◈ Enforce DNS on Switch</div>\n <div className="pb">\n <div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:12}}>\n Without enforcement, a device can ignore DHCP-assigned DNS and use\n <code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}> 8.8.8.8</code> directly,\n bypassing all ctrld filtering. These ACLs block that:\n <ul style={{margin:"6px 0 0 18px",lineHeight:1.9}}>\n <li>Permit UDP/TCP port 53 <em>to ctrld only</em></li>\n <li>Deny UDP/TCP port 53 to everywhere else</li>\n <li>Deny TCP port 853 (DNS-over-TLS bypass)</li>\n <li>Permit everything else (internet still works)</li>\n </ul>\n Generated ACLs are shown for review — push them via the\n <strong> Review &amp; Push</strong> tab as usual.\n </div>\n <div style={{display:"flex",gap:10,alignItems:"flex-end",flexWrap:"wrap"}}>\n <div className="field" style={{margin:0,flex:"0 0 auto"}}>\n <label>ctrld IP address</label>\n <input\n value={enforceIp || (status?.dns_ip||"")}\n onChange={e=>setEnforceIp(e.target.value)}\n placeholder={status?.dns_ip || "e.g. 192.168.99.50"}\n style={{fontFamily:"var(--mono)",maxWidth:200}}\n />\n </div>\n <button className="btn bp" style={{alignSelf:"flex-end"}}\n onClick={generateEnforceAcls} disabled={enforceLoading||!session}>\n {enforceLoading ? "Generating..." : "Generate Enforcement ACLs"}\n </button>\n {!session && (\n <button className="btn bg" style={{alignSelf:"flex-end",fontSize:10}}\n onClick={onNeedAuth}>Authenticate</button>\n )}\n </div>\n\n {/* Enforcement result */}\n {enforceResult && (\n <div style={{marginTop:12}}>\n {enforceResult.success ? (\n <div>\n <div style={{\n padding:"8px 12px",borderRadius:4,marginBottom:10,fontSize:11,\n background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",\n color:"var(--dm)",\n }}>\n <span style={{color:"var(--ok)",fontWeight:700}}>✓ </span>\n Generated {enforceResult.count} commands for VLANs{" "}\n {enforceResult.vlans?.join(", ")}.{" "}\n Copy the commands below into the{" "}\n <strong>Review &amp; Push</strong> tab → Raw CLI mode.\n </div>\n <div style={{\n background:"#060809",borderRadius:4,padding:"10px 12px",\n fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",\n whiteSpace:"pre",overflowX:"auto",maxHeight:260,overflowY:"auto",\n }}>{enforceResult.commands?.join("\\n")}</div>\n <button className="btn bg" style={{marginTop:8,fontSize:10}}\n onClick={()=>navigator.clipboard?.writeText(enforceResult.commands?.join("\\n"))}>\n Copy Commands\n </button>\n </div>\n ) : (\n <div style={{\n padding:"8px 12px",borderRadius:4,fontSize:11,\n background:"rgba(255,100,100,.06)",border:"1px solid rgba(255,100,100,.2)",\n color:"var(--err)",\n }}>\n{enforceResult.message}\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n\n {/* Quick reference */}\n <div className="panel">\n <div className="ph">◈ How It Works</div>\n <div className="pb">\n <div style={{\n display:"grid",gridTemplateColumns:"repeat(4,1fr)",gap:10,\n fontSize:11,textAlign:"center",\n }}>\n {[\n {icon:"📱", label:"Device", sub:"sends DNS query\\nto switch DHCP\\nassigned DNS IP"},\n {icon:"⚡", label:"ctrld", sub:"receives query\\nidentifies VLAN\\nby source subnet"},\n {icon:"🔒", label:"DoH3", sub:"forwards via\\nencrypted HTTPS/3\\nto Control D"},\n {icon:"🛡️", label:"Control D", sub:"applies your\\nVLAN profile\\nreturns answer"},\n ].map((s,i)=>(\n <div key={i} style={{\n background:"var(--bg)",border:"1px solid var(--b2)",borderRadius:5,\n padding:"12px 8px",\n }}>\n <div style={{fontSize:22,marginBottom:6}}>{s.icon}</div>\n <div style={{fontWeight:700,marginBottom:4,color:"var(--tx)"}}>{s.label}</div>\n <div style={{color:"var(--dm)",whiteSpace:"pre-line",lineHeight:1.6}}>{s.sub}</div>\n </div>\n ))}\n </div>\n <div style={{marginTop:12,fontSize:11,color:"var(--dm)",lineHeight:1.8}}>\n <span style={{color:"var(--ac)",fontWeight:700}}>Per-VLAN enforcement: </span>\n ctrld identifies which VLAN a DNS query came from by the source IP subnet.\n Each VLAN subnet maps to a different Control D profile. Staff get permissive\n filtering. IoT devices get strict filtering. Guests get aggressive ad/malware blocking.\n All encrypted via DoH3 — your ISP sees HTTPS traffic, not DNS queries.\n </div>\n </div>\n </div>\n </div>\n );\n}\n'
README_SRC = '# Avaya / Extreme ERS Switch Manager\n\nA browser-based management interface for Avaya / Extreme Networks ERS switches. You click buttons. The software figures out the CLI. You never type a switch command.\n\nCompatible with: ERS 5928, ERS 5948, ERS 5952, ERS 5952-PWR+, ERS 59100GTS-PWR+\n\n---\n\n## Requirements\n\n**Management computer OS: Linux** (Ubuntu / Debian recommended — Raspberry Pi OS works perfectly)\n\nThe setup script uses `apt`, `systemd`, `picocom`, and Docker. It will not run on Mac or Windows. The management computer does not need a monitor — a headless Raspberry Pi or thin client is ideal. Once running, the web UI is accessible from any browser on any device on your network.\n\nThe switch UI itself (the browser interface) works from any OS — phone, tablet, Mac, Windows, Linux.\n\n**Switch OS: BOSS (Baystack Operating System Software) v7.9.6**\n\nBOSS is the firmware that runs on Avaya / Extreme ERS switches. This project is written and tested against BOSS v7.9.6 on the ERS 59100GTS-PWR+. It is not Cisco IOS or any other vendor\'s CLI — commands are different. BOSS firmware is pre-installed on the switch and does not need to be downloaded or installed.\n\n---\n\n## Physical Setup — First Time Out of the Box\n\nEverything here happens before you run any software. You need a console cable and a laptop.\n\n---\n\n### Step 1 — Find the Service Port\n\nThe **console (service) port** is a physical RJ-45 serial port on the switch. It is **not** a regular ethernet port.\n\n**ERS 59100GTS-PWR+:**\nThe console port is on the **front panel**, left side. It is labelled **"Console"** and looks like a standard ethernet jack but is wired as RS-232 serial. It is located next to the USB port and the out-of-band management port.\n\n**ERS 5952 / 5952-PWR+:**\nThe console port is on the **front panel**, far left, labelled **"Console"**.\n\n---\n\n### Step 2 — Get a Console Cable\n\nSearch **"RJ45 to USB console cable Cisco compatible"** — around $8. This is the same cable used for Cisco, Juniper, and most enterprise switches. It has an RJ-45 plug on one end and USB-A on the other.\n\n**Do not** use a standard ethernet cable — it will not work.\n\n---\n\n### Step 3 — Connect and Open a Terminal\n\n**Important:** Plug the console cable in — both the RJ-45 end into the switch and the USB end into your laptop — **before** powering on the switch. If you plug in after power-on, `screen` and `picocom` may not detect the device correctly.\n\n**Settings:** `9600 baud · 8 data bits · No parity · 1 stop bit · No flow control`\n\nOpen a terminal session:\n\n| OS | Command |\n|---|---|\n| Linux | `sudo picocom -b 9600 /dev/ttyUSB0` |\n| Mac | `screen /dev/tty.usbserial-* 9600` |\n| Windows | PuTTY → Serial → COM port → 9600 baud |\n\nTo find your COM/device name if unsure:\n- Linux: `ls /dev/ttyUSB*` before and after plugging in\n- Mac: `ls /dev/tty.usb*` before and after plugging in\n- Windows: Device Manager → Ports (COM & LPT)\n\n> **Console vs SSH:** The console cable gives you direct CLI access to type commands on the switch. It is only needed for initial setup. Once SSH is configured, the management script connects over Ethernet (port 22) and you never need the console cable again — unless you lock yourself out.\n\n---\n\n### Step 4 — Power On and Read the Boot Screen\n\nPower on the switch. You will see a banner like this:\n\n```\nEnter Ctrl-Y to begin.\n\n*************************************************************\n*** Ethernet Routing Switch 59100GTS-PWR+ ***\n*** Copyright (C) 1996-2024 Extreme Networks. ***\n*** HW:22 FW:7.5.0.4 SW:v7.9.6.015 ***\n*************************************************************\n```\n\n**Press Ctrl-Y.** The switch is waiting for this keypress to continue booting — it will not proceed without it. After pressing Ctrl-Y the switch runs POST and boots normally in about 6090 seconds.\n\nWhen it is ready you will see:\n\n```\nLogin:\n```\n\n---\n\n### Step 5 — Log In with Default Credentials\n\n```\nLogin: admin\nPassword: (press Enter — no password by default)\n```\n\nIf that fails, try `admin` / `admin`. If the switch was previously configured you may need to do a factory reset (see Troubleshooting below).\n\nYou will land at the `5952#` or `59100GTS-PWR+#` prompt in privileged exec mode.\n\n---\n\n### Step 6 — Bootstrap VLAN 99 via Console\n\nThis is the only time you need the console cable for normal operation. Enter these commands one at a time, waiting for the prompt before each:\n\n```\nenable\nconfigure terminal\nvlan create 99 name "Management" type port\ninterface vlan 99\nip address 192.168.99.1 255.255.255.0\nno shutdown\nexit\nvlan members add 99 1\nvlan pvid 1 99\nip ssh\nusername admin password YourPassword\nend\nsave config\n```\n\nReplace `1` with the port number your management computer will plug into. Replace `YourPassword` with a strong password (832 characters; allowed special characters: `! @ # $ % ^ & * - _ = + [ ] ; : , . /` — no spaces, no quotes).\n\n**Port numbering:** On both the ERS 5952 and ERS 59100, use bare port numbers (`1`, `2`, `48`…). There is no slot prefix.\n\n> **What about the switch\'s existing IP?**\n> Out of the box the switch has `192.168.1.1` on VLAN 1. If your router is also `192.168.1.1`, they will conflict — two devices with the same IP on the same network causes ARP fights and makes both unreliable.\n>\n> This setup deliberately avoids that by putting management traffic on a **separate VLAN (99) with its own subnet (192.168.99.x)**. Your management computer gets a static IP on that subnet and talks to the switch there. The switch\'s original VLAN 1 address is irrelevant once VLAN 99 is up.\n>\n> If you ever need to change the switch\'s VLAN 1 IP (e.g. to remove the conflict before VLAN 99 is configured):\n> ```\n> config terminal\n> interface vlan 1\n> ip address 192.168.1.2 255.255.255.0\n> exit\n> ip default-gateway 192.168.1.1\n> exit\n> save config\n> ```\n\nAfter the last command the switch confirms with `CP1 [07/04/15 12:00:00.000:INFO]: Operation Success` or similar.\n\n---\n\n### Step 7 — Give the Management Computer a Static IP\n\nOn the machine that will run the switch manager software:\n\n```bash\n# Find your interface name first\nip link show\n\n# Set a static IP on the management network interface (adjust eth0 to your interface)\nsudo ip addr add 192.168.99.50/24 dev eth0\nsudo ip link set eth0 up\n```\n\nVerify: `ping 192.168.99.1` — you should get replies from the switch.\n\n---\n\n### Step 8 — Run the Setup Script\n\n```bash\npython Avaya_5952_setup.py\n```\n\nThe script handles everything from here. When it pauses and asks you to load the SSH public key onto the switch, it will show you exactly what to paste into the console.\n\n---\n\n## Installation\n\nCopy `Avaya_5952_setup.py` to your always-on management computer (Raspberry Pi, HP T620 thin client, old computer or laptop) and run:\n\n```\npython Avaya_5952_setup.py\n```\n\nThat is the entire installation process. The script handles everything automatically. The only step requiring human intervention is loading the SSH public key onto the switch via console cable — the script pauses, shows you exactly what to type, and waits for you to confirm before continuing.\n\n---\n\n## What the Setup Script Does\n\nThe script runs 21 steps automatically:\n\n1. Writes all project files (backend, frontend source, this README)\n2. Installs system packages (picocom, qrencode, wireguard-tools)\n3. Adds your user to the dialout group for console cable access\n4. Installs Python dependencies\n5. Creates the config directory `/etc/switch-manager/`\n6. Sets a static IP on your management network interface\n7. Asks whether to deploy with Docker (recommended) or native Python\n8. Guides you through console cable setup and software links\n9. Generates an ed25519 SSH keypair\n10. Shows you exactly what to paste on the switch console\n11. Tests the SSH connection and retries if it fails\n12. Pins the switch host key (MITM protection)\n13. Generates your TOTP authenticator secret with QR code\n14. Verifies your authenticator app is working before continuing\n15. Patches the backend config with your switch IP and credentials\n16. Builds the React frontend (requires Node.js)\n17. Installs and enables a systemd service (or Docker container)\n18. Starts the service\n19. Optionally sets up Docker with Caddy HTTPS\n20. Optionally configures WireGuard VPN for remote access\n21. Optionally installs Control D DNS filtering via ctrld\n\nRe-running is safe — completed steps are skipped.\n\n---\n\n## Deployment Options\n\nThe script explains both options before asking:\n\n**Docker + Caddy (recommended default)**\nRuns the switch manager in a container with all dependencies baked in. Caddy provides automatic HTTPS at a hostname you choose (default: `switch.mgmt.lan`). Caddy generates its own internal CA — the setup script installs it into your system trust store automatically so browsers show a clean padlock. Updates with one command: `docker compose pull && docker compose up -d`.\n\n**Native Python (bare metal)**\nRuns directly as a Python process managed by systemd. Simpler, lower memory, no Docker required. Access via `http://IP:8765` directly. Right choice for very low memory machines or users who prefer managing services directly.\n\n---\n\n## Accessing the Interface\n\n```\nhttp://[management computer IP]:8765\n```\n\nOr with Docker + Caddy:\n```\nhttps://switch.mgmt.lan\n```\n\nYou must be on VLAN 99 (management VLAN) or connected via WireGuard VPN to reach this address. This is intentional — the switch enforces isolation at the network layer.\n\n### Getting onto VLAN 99 from your laptop\n\n**Linux:**\n```bash\nsudo ip link add link eth0 name eth0.99 type vlan id 99\nsudo ip addr add 192.168.99.50/24 dev eth0.99\nsudo ip link set eth0.99 up\n```\n\n**Mac:** System Settings → Network → Add VLAN interface → VLAN ID 99\n\n**Dedicated port:** Ask the switch to put one port on VLAN 99 as an access port. Plug in when managing.\n\n**VPN:** Connect via WireGuard (configured during setup or via the VPN tab).\n\n---\n\n## The Interface — Eight Tabs\n\n### Port Map\n\nVisual 48+4 port chassis, colour-coded by VLAN. Each port shows its VLAN assignment, mode (access/trunk/disabled), and a green dot when PoE is active. Click any port to configure it in the right panel.\n\n**Port configuration panel:**\n- Description (e.g. "AP-Corridor-1", "Camera-NE")\n- Mode: Access, Trunk, or Disabled\n- VLAN assignment (access) or tagged/native VLANs (trunk)\n- PoE on/off and wattage limit (copper ports, 1W30W)\n\nLive status pulls from the switch every 15 seconds while the tab is active, 60 seconds when backgrounded, and pauses when nobody has the page open.\n\n### VLANs\n\nCreate, rename, and delete VLANs. Shows port count and subnet per VLAN. VLAN 1 cannot be deleted.\n\n### ACL Builder\n\nBuild Access Control Lists visually. Each rule specifies action (permit/deny), protocol (ip/tcp/udp/icmp), source, destination, and optional port. The ACL is assigned to a VLAN interface with a direction. The tool generates all CLI syntax — you never write it yourself.\n\n**Templates:** Click "Use Template" to pre-fill rules for common patterns: Staff (full internet, no management), IoT (internet only, no RFC1918), Guest (internet + ctrld DNS enforcement), Camera (NVR only). All rules are editable after applying the template.\n\n### Review & Push\n\nEvery change across all tabs is translated into the exact CLI commands the switch understands. This tab shows those commands before anything is sent.\n\n**You never write CLI commands.** The tool generates them. The review step exists so you can inspect what will be sent.\n\n**Push mode choice:**\n- **Batch** — all commands sent in sequence, results shown when complete\n- **Step by step** — one command at a time, confirm each before the next is sent\n\nEach command is displayed with a plain-English explanation of what it does and what it affects. You can read exactly what is about to happen.\n\n### Device Access\n\nManages which devices can reach the management interface from their normal VLAN without needing VPN. The switch enforces access via an ACL pinhole. TOTP still gates any changes.\n\n**MAC address randomization warning:** Modern phones and laptops randomize MAC addresses per network. This breaks DHCP reservations. The tab shows per-platform instructions to disable it (iOS, Android, macOS, Windows) before adding a device.\n\n**Live DHCP leases** pulled from the switch appear as unregistered devices — click Register to add them.\n\n**Per device:**\n- Name, MAC, IP, VLAN\n- Reserve static IP (DHCP binding pushed to switch)\n- Grant/revoke management access (ACL pinhole pushed to switch)\n\n### DHCP\n\nUnified view of all DHCP reservations across switch and OPNsense (if configured).\n\n**DHCP server recommendation:**\n- Use switch DHCP for VLAN 99 (management) — devices get IPs before OPNsense is reachable\n- Use OPNsense for all other VLANs — integrates with DNS, firewall rules, lease history\n- Never run both for the same VLAN\n\n**OPNsense integration (optional):**\nAuto-detects OPNsense at your gateway IP. If found, prompts for API key. Once connected, shows reservations from both switch and OPNsense in one table, colour-coded by source.\n\n**Conflict detection:** If the same MAC has reservations in both places, a badge appears — red for IP conflicts (same MAC, different IP), yellow for duplicates (same MAC, same IP). Each conflict has a Resolve button with four options: Switch wins, OPNsense wins, Remove from switch, Remove from OPNsense.\n\n### DNS Filtering\n\nConfigures Control D DNS filtering per VLAN via the `ctrld` daemon.\n\n**How it works:** `ctrld` runs as a local DNS proxy. Devices send normal DNS queries to it. `ctrld` identifies the source VLAN subnet and routes each query to the correct Control D profile via DoH3. Each VLAN gets different filtering rules. Your ISP sees encrypted HTTPS traffic, not DNS queries.\n\n**Three deployment options presented with full explanations:**\n\n*Option A — ctrld on the management computer (fully automated)*\nInstalls ctrld alongside the switch manager. One command downloads and installs it, writes the per-VLAN config, starts it as a system service. The switch DHCP points each VLAN to this machine\'s IP for DNS.\n\n*Option B — ctrld on OPNsense (semi-automated)*\nGenerates a single SSH command to paste into OPNsense shell. ctrld installs as a service on OPNsense. OPNsense\'s IP becomes the DNS server for the network.\n\n*Option C — manual / existing setup*\nGenerates the `ctrld.toml` config and install command. You install wherever you choose.\n\n**Per-VLAN Resolver IDs:** Each VLAN gets its own Control D profile. Enter the Resolver ID from the Control D dashboard (controld.com → Add Device → Router → Resolver ID). VLANs without a Resolver ID use the first configured profile as fallback.\n\n**DNS Enforcement:** After installing ctrld, use the "Enforce DNS on Switch" button to generate ACLs that block devices from bypassing ctrld by using 8.8.8.8 directly. See [DNS Enforcement ACLs](#dns-enforcement-acls) below.\n\n**Local Hostnames:** Optionally run a dnsmasq container so `.lan` names resolve for all devices. See [Local Hostname Resolution](#local-hostname-resolution-dnsmasq) below.\n\n**References:**\n- Control D documentation: https://docs.controld.com/docs/ctrld\n- Router setup guide: https://docs.controld.com/docs/routers-platform\n\n### VPN\n\nManages WireGuard VPN for remote access to the management interface from outside the management VLAN.\n\n**Add clients:** Enter a name (laptop, phone, tablet) and the tool generates a keypair, adds the peer to the server config, reloads WireGuard live, and displays a QR code to scan with the WireGuard app. Also saves a `.conf` file for desktop import.\n\n**Revoke clients:** Disconnects the peer immediately and removes it from the server config.\n\n**Connected peers:** Shows last handshake time and transfer stats for each peer.\n\n**SSH tunnel alternative:** For power users — one command gives secure access without WireGuard installed:\n```bash\nssh -L 8765:localhost:8765 user@management-computer-ip\n```\n\n---\n\n## Making Changes — Step by Step\n\n1. **Configure** across any tabs — nothing happens on the switch yet\n2. **Go to Review & Push** — read the generated commands with explanations\n3. **Check for dangers** — automatic pre-flight runs before authentication\n4. **Authenticate** — enter 6-digit TOTP code to unlock a push session\n5. **Choose push mode** — batch (faster) or step-by-step (full control)\n6. **Push** — commands go one at a time, checked after each\n7. **Review results** — each command shows success or failure with the switch\'s error output\n8. **Auto-lock** — session closes when push completes. New changes need a new TOTP code\n\n---\n\n## Authentication and Sessions\n\n**Read-only** — no authentication required. Anyone on VLAN 99 can view the dashboard.\n\n**Push mode** — requires TOTP. One code unlocks exactly one push session. The session closes automatically when the push completes or you cancel. The next set of changes requires a new TOTP code.\n\n**Why TOTP and not a password:** VLAN 99 isolation is the primary barrier. TOTP adds a second factor confirming it is you making a change. A code is useless after 30 seconds and requires physical access to your authenticator app.\n\n---\n\n## How Commands Reach the Switch\n\nExact sequence on every push:\n\n1. Browser sends the command list to the management computer backend\n2. Backend runs a danger check against known lethal patterns\n3. Backend validates every command against an allowlist\n4. Backend opens a fresh SSH connection using the ed25519 key stored on the management computer\n5. Commands execute one at a time via interactive shell\n6. After each command the switch\'s response is checked for error patterns\n7. On error: push stops immediately, no further commands sent, config not saved\n8. On full success: `end` then `copy running-config nvram:config.cfg` — config saved to NVRAM\n\n---\n\n## Blocked Commands\n\n**Hard-blocked — refused entirely, must run at the switch console:**\n\n| Pattern | Reason |\n|---|---|\n| `no vlan 99` | Deletes management VLAN |\n| `vlan members remove ... 99` | Removes VLAN 99 from a port — kills management trunk |\n| `no vlan tagging ... 99` | Removes VLAN 99 tagging — kills management trunk |\n| `no ip ssh` | Disables SSH — permanent lockout |\n| `no ip address` | Removes IP — management computer loses connectivity |\n| `interface vlan 99` | Modifies management VLAN interface |\n| `boot config flags factory` | Factory reset |\n\n**Warning-level — shown for review, push proceeds with confirmation:**\n\n| Pattern | Reason |\n|---|---|\n| `shutdown` | Shuts down an interface — confirm not your uplink |\n| `default interface` | Resets interface to defaults |\n| `no vlan [id]` | Deletes a VLAN — confirm no active ports depend on it |\n| `spanning-tree ... disable` | Disables spanning tree — loop risk |\n\n---\n\n## SSH Key Security\n\nThe ed25519 private key lives at `/etc/switch-manager/ers5952_key` on the management computer. It never leaves that machine. Your laptop, phone, or tablet never touches it.\n\nThe switch host key is pinned after the first connection. If the switch\'s host key ever changes the backend refuses to connect and reports the mismatch — MITM protection even on the management VLAN.\n\n---\n\n## Console Cable\n\nSee [Physical Setup — First Time Out of the Box](#physical-setup--first-time-out-of-the-box) above for the complete walkthrough including where the console port is, what cable to buy, and what to do at the boot screen.\n\nThe short version: one-time only, RJ-45 to USB console cable (~$8), 9600 baud, no parity.\n\n**Switch password rules:** 832 characters. Special characters allowed: `! @ # $ % ^ & * - _ = + [ ] ; : , . /`. No spaces. No quotes.\n\n---\n\n## Live Polling\n\nThe backend polls the switch using a connection pool:\n- Pool lifetime: 25 seconds (shorter than switch idle timeout)\n- Tab active/visible: polls every 15 seconds\n- Tab backgrounded: polls every 60 seconds\n- No visitors for 5 minutes: polling pauses completely\n- New visitor opens page: immediate poll, resumes normal interval\n\nThe switch is never being polled when nobody is looking at the dashboard.\n\n---\n\n## Files Created\n\n| File | Location | Purpose |\n|---|---|---|\n| `switch_backend.py` | Project folder | Python API server (34 endpoints) |\n| `ers5952-manager.jsx` | Project folder | React app source (8 tabs) |\n| `README.md` | Project folder | This file |\n| `Dockerfile` | Project folder | Docker image definition |\n| `docker-compose.yml` | Project folder | Caddy + switch manager services |\n| `Caddyfile` | Project folder | HTTPS reverse proxy config |\n| `ctrld.toml` | Project folder | Control D per-VLAN DNS config (if configured) |\n| `frontend/dist/` | Project folder | Built React app |\n| `ers5952_key` | `/etc/switch-manager/` | SSH private key (chmod 600) |\n| `ers5952_key.pub` | `/etc/switch-manager/` | SSH public key |\n| `known_hosts` | `/etc/switch-manager/` | Pinned switch host key |\n| `totp_secret` | `/etc/switch-manager/` | TOTP seed (chmod 600) — back this up |\n| `devices.json` | `/etc/switch-manager/` | Registered device list |\n| `opnsense.json` | `/etc/switch-manager/` | OPNsense API credentials (chmod 600) |\n| `ctrld.json` | `/etc/switch-manager/` | Control D config (chmod 600) |\n| `wg_server_private` | `/etc/switch-manager/` | WireGuard server private key (chmod 600) |\n| `wg_server_public` | `/etc/switch-manager/` | WireGuard server public key |\n| `clients/` | `/etc/switch-manager/` | WireGuard client .conf files |\n| `switch-manager.service` | `/etc/systemd/system/` | Systemd service (native mode) |\n| `local-hostnames.json` | `/etc/switch-manager/` | User-defined hostname→IP mappings (optional) |\n| `dnsmasq.conf` | `/etc/switch-manager/` | Generated dnsmasq config (optional) |\n\n**Back up `/etc/switch-manager/totp_secret`** — if the management computer fails and you have not backed this up you will need to regenerate the TOTP secret and re-scan it into your authenticator app.\n\n---\n\n## Firmware Upgrade\n\nThe switch runs two software components that must both be upgraded:\n- **Diagnostic image** (diag) — upgraded first\n- **Agent image** (BOSS firmware) — upgraded second\n\n**Important rules:**\n- Upgrade one version at a time — cannot skip releases\n- Always upgrade the diagnostic image before the agent image\n- USB files must not be marked read-only or the transfer fails\n- Firmware downloads require an active support contract at the [Extreme Networks portal](https://extreme-networks.my.site.com)\n- Check the [ERS Announcements page](https://community.extremenetworks.com/t5/ers-announcements/bg-p/ERS_Announcements) to find the latest version\n\nYour switch currently runs: **BOSS v7.9.6.015 / Diagnostics 7.5.0.4**\n\n---\n\n### Method 1 — USB (no network needed, easiest)\n\n1. Download the diagnostic `.bin` and agent `.img` files from the Extreme portal\n2. Copy both files to a USB stick — ensure they are **not read-only**\n3. Insert the USB stick into the front panel USB port on the switch\n4. Via console or SSH (in enable mode):\n\n```\ndownload usb diag ers5900diag_7x_x_x_x.bin\n```\nWait for it to complete and confirm, then:\n```\ndownload usb image ers5900_7x_x_x_x.img\n```\nThe switch will reboot automatically after the agent upgrade.\n\nAlternatively, from the **boot menu** (option 4 — "Download Agent/Diag") you can trigger a USB download without logging in first.\n\n---\n\n### Method 2 — SFTP over SSH (requires network)\n\nFrom enable mode on the switch, with an SFTP server running on your management computer:\n\n```\ncopy sftp address 192.168.99.50 filename ers5900diag_7x_x_x_x.bin\n```\nWait for completion, then:\n```\ncopy sftp address 192.168.99.50 filename ers5900_7x_x_x_x.img\n```\nThe switch reboots after the agent upgrade.\n\n---\n\n### Method 3 — TFTP from a laptop (no dedicated server needed)\n\nYour laptop can act as a temporary TFTP server. The switch has a default IP of **`192.168.1.1/24`** on VLAN 1 out of the box (or after a factory reset) — so on a fresh switch you can skip console IP configuration entirely. Just plug in Ethernet and go.\n\nPlug both the console cable **and** an Ethernet cable from the laptop into the switch at the same time.\n\nOn the laptop:\n```bash\nsudo apt install tftpd-hpa\nsudo cp ers5900diag_*.bin ers5900_*.img /srv/tftp/\nsudo ip addr add 192.168.1.50/24 dev eth0 # must be on same /24 as switch default (192.168.1.x)\nsudo systemctl start tftpd-hpa\n```\n\nThen on the switch via console (or via SSH to `192.168.1.1` if SSH is already enabled):\n```\nenable\ncopy tftp address 192.168.1.50 filename ers5900diag_7x_x_x_x.bin\n```\nThen:\n```\ncopy tftp address 192.168.1.50 filename ers5900_7x_x_x_x.img\n```\n\nStop the server when done:\n```bash\nsudo systemctl stop tftpd-hpa\n```\n\n> **No console needed on a factory switch:** The switch answers at `192.168.1.1` immediately after boot. As long as your laptop is on `192.168.1.x/24`, the TFTP transfer works without touching the console at all — useful if you only have an Ethernet cable and no console cable handy.\n\n---\n\n### Method 4 — XMODEM over console cable (last resort, no network required)\n\nIf the USB port is broken and you have no Ethernet available at all, boot menu option 4 ("Download Agent/Diag") supports XMODEM file transfer directly over the serial console cable. No network required.\n\n**Warning:** At 9600 baud, a 10 MB firmware file takes approximately 3 hours. Only use this if nothing else is possible.\n\nIn `screen`, after selecting option 4 from the boot menu, send the file with:\n\n```\nCtrl-A then :exec !! sx -b /path/to/ers5900_7x_x_x_x.img\n```\n\n(`sx` is part of the `lrzsz` package: `sudo apt install lrzsz`)\n\n---\n\n## Troubleshooting\n\n**Connection banner stuck on "Connecting..."**\nBackend not running or device not on VLAN 99. Check: `sudo systemctl status switch-manager` or `docker compose ps`\n\n**"Switch unreachable" in status pill**\nBackend running but cannot reach switch. Check: `ping 192.168.99.1` from the management computer.\n\n**TOTP code rejected**\nEnsure time is synchronised on both the management computer and your phone. Backend allows one 30-second window of clock drift. Sync: `sudo timedatectl set-ntp true`\n\n**Command failed — switch error shown**\nRead the error text — the switch says exactly what was wrong. Fix the configuration and push again. Already-succeeded commands do not need to be resent.\n\n**Host key rejection after switch reset**\nRe-pin: `ssh-keyscan -H 192.168.99.1 > /etc/switch-manager/known_hosts`\n\n**Factory reset — wiping a previously configured switch**\n\n> When to wipe: if the switch has unknown previous configuration, unknown passwords, or you want a guaranteed clean slate. If the switch already responds to `admin` with no password and has no unexpected VLANs or ACLs, a wipe is not required — you can configure over the existing state.\n\n**Method 1 — Boot menu (easiest, works even if you don\'t know the password)**\n\nThe console cable must be plugged in before power-on. During boot a diagnostic menu appears briefly:\n\n```\nDIAGNOSTIC BREAK MENU\n59100 GTS-PWR+ Diagnostics 7.5.0.4\n 1 - Launch Primary Agent-1 Vers: 7.9.6.015\n 2 - Launch Secondary Agent-2 Vers: 7.6.2.019\n 3 - Toggle Primary Agent Selection\n 4 - Download Agent/ Diag\n 5 - Reinitialize Agent Configuration Files\n 6 - Display Error Log\n 7 - Display System Information\n 8 - Continue Boot Sequence\n 9 - Reset\n A - Power-Cycle\n B - Toggle Do-POST Selection [ ENABLED ]\n C - Run POST tests\nSelect:\nBooting Agent in 60 seconds...\n```\n\nPress **5** — "Reinitialize Agent Configuration Files". The switch wipes its config and reboots into factory defaults. You have 60 seconds before it boots automatically.\n\nOther useful options: **1/2** switch between primary (v7.9.6) and secondary (v7.6.2) firmware. **6** shows the error log. **7** shows system info. **8** continues normal boot if you entered the menu by accident. **9** resets (reboots). **A** power-cycles. **B** toggles POST (hardware self-test) on/off. **C** runs POST tests manually.\n\n**Method 2 — CLI (requires working login)**\n\n```\nenable\nboot config flags factory\nsave config\nboot\n```\n\nAfter either method the switch comes up with blank config, default credentials (`admin` / no password), and management IP `192.168.1.1` on VLAN 1. Continue from Step 5.\n\n**ctrld not filtering DNS**\nCheck DHCP option 6 is set to the ctrld machine\'s IP on each VLAN pool. Check ctrld is running: `ctrld status`. Check the switch is handing out the right DNS: from a device, run `nslookup example.com` and verify the server IP matches.\n\n**WireGuard not connecting**\nCheck port 51820 UDP is reachable from outside your network (router port forwarding may be needed for external access). Check: `sudo systemctl status wg-quick@wg0`\n\n**Service logs**\n```bash\n# Native\njournalctl -u switch-manager -f\n\n# Docker\ndocker compose logs -f\ndocker compose ps\n```\n\n---\n\n## Command Reference — Tested Status\n\nCommands the script sends to the switch, with known test status. Use this as a guide when debugging unexpected behaviour — untested commands may behave differently on your firmware version.\n\n### Read commands\n\n| Command | Status | Notes |\n|---|---|---|\n| `show interfaces` | confirmed | |\n| `show poe-main-status` | confirmed | |\n| `show vlan` | confirmed | |\n| `show sys-info` | confirmed | |\n| `show arp` | confirmed | |\n| `show config` | confirmed | Returns table output |\n| `show ip helper-address` | not tested | |\n| `show ip route default` | not tested | |\n| `show dhcp-server leases` | invalid on this firmware | Script handles gracefully — returns empty |\n| `show dhcp-server static-binding` | invalid on this firmware | Script handles gracefully — returns empty |\n| `show dhcp-server` | invalid on this firmware | Script handles gracefully — returns empty |\n\n### Config / push commands\n\n| Command | Status | Notes |\n|---|---|---|\n| `enable` | confirmed | |\n| `configure terminal` | confirmed | |\n| `terminal length 0` | not tested | Disables pagination — sent before reads |\n| `interface FastEthernet {port}` | confirmed | |\n| `vlan members add {vid} {port}` | confirmed | |\n| `vlan pvid {port} {vid}` | not tested | Sets native/untagged VLAN on a port |\n| `vlan tagging {tagged-set} {port}` | not tested | Adds trunk tagging |\n| `vlan create {vid} name "x" type port` | not tested | |\n| `no vlan {vid}` | not tested | |\n| `name "{description}"` (under interface) | not tested | Sets port description |\n| `shutdown` (under interface) | not tested | Disables a port |\n| `poe enable` / `no poe enable` | not tested | |\n| `poe poe-limit {milliwatts}` | not tested | |\n| `interface vlan {vid}` (in config mode) | not tested | |\n| `ip access-list extended {name}` | not tested | |\n| `ip access-group {name} in/out` | not tested | |\n| `end` | not tested | Returns to enable mode |\n| `save config` | confirmed | **Caused a reboot on first run** — watch the first time you push this |\n\n> **`save config` reboot note:** On at least one switch, issuing `save config` triggered a reboot. This may be firmware-version-specific or a one-time behaviour after certain config states. Subsequent saves have not reproduced it. Be aware when pushing config changes in a live environment.\n\n---\n\n## Scrollback in `screen` (console cable sessions)\n\nBy default `screen` does not let you scroll up through output. Enable it with copy mode:\n\n| Action | Keys |\n|---|---|\n| Enter scrollback mode | `Ctrl-A` then `[` |\n| Scroll up / down | Arrow keys or `PgUp` / `PgDn` |\n| Exit scrollback mode | `Esc` or `q` |\n\nTo increase the scrollback buffer for a session (default is only 100 lines):\n```\nCtrl-A then :scrollback 10000\n```\n\nTo set it permanently, add this to `~/.screenrc`:\n```\ndefscrollback 10000\n```\n\nUseful when reviewing long `show config` or `show interfaces` output during console sessions.\n\n---\n\n## What This Tool Does Not Do\n\n- Does not manage OPNsense, pfSense, or any other device directly (OPNsense integration is read/sync only)\n- Does not provide a terminal or shell — there is no way to type arbitrary commands through the main interface (CLI mode in settings is available for advanced users but still runs through the safety pipeline)\n- Does not support multiple switches simultaneously\n- Does not provide traffic analytics or bandwidth graphs\n- Does not automatically discover or adopt new network devices\n\n---\n\n## On the ERS Switch Family\n\nThese switches have no REST API. Everything this tool does is via SSH sessions that parse text output and send CLI commands — the same thing a human would do at a terminal, automated and wrapped in a browser interface.\n\nThere is an inherent limit to how reliably the tool can detect every possible error condition. The danger blocking system catches the known lethal patterns but cannot anticipate every possible misconfiguration. Use the CLI review step. Read what is about to be sent.\n\nThe console cable is always your fallback. Keep it accessible.\n\n---\n\n## DNS Filtering — Port 53 Conflict Resolution\n\nWhen installing ctrld (Option A — local install), ctrld needs to bind port 53. On Ubuntu and Debian, `systemd-resolved` holds port 53 via its stub listener.\n\n**The tool fixes this automatically** during installation. It adds `DNSStubListener=no` to `/etc/systemd/resolved.conf` and restarts `systemd-resolved`. The service itself keeps running — it still manages `/etc/resolv.conf` and local hostname caching. Only the stub listener is disabled.\n\nIf the automatic fix fails (permission issue, non-standard config), fix it manually:\n\n```bash\necho "[Resolve]" | sudo tee -a /etc/systemd/resolved.conf\necho "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf\nsudo systemctl restart systemd-resolved\n```\n\n**On OPNsense (Option B):** OPNsense runs Unbound DNS on port 53. The correct approach is not to uninstall Unbound — it handles `.lan` hostnames and local DNS. Instead:\n\n1. In OPNsense UI: Services → Unbound DNS → General → set Listen Port to `5353`, Listen Interface to `Loopback (lo0)`. Save + Apply.\n2. Configure ctrld to forward `*.lan` and `*.local` to `127.0.0.1:5353` (the split-horizon block shown in the DNS tab result panel).\n3. ctrld handles all other queries via DoH3 to Control D.\n\nThis keeps local names working while all external DNS is filtered per-VLAN through Control D.\n\n---\n\n## DNS Enforcement ACLs\n\nWithout enforcement, a device can ignore DHCP-assigned DNS and use `8.8.8.8` directly, bypassing all ctrld filtering.\n\nThe DNS tab has an **"Enforce DNS on Switch"** button that generates ACLs blocking this. For each VLAN:\n\n```\nip access-list extended DNS-ENFORCE-VLAN10\n 1 permit udp 192.168.10.0 0.0.0.255 host [ctrld-ip] eq 53\n 2 permit tcp 192.168.10.0 0.0.0.255 host [ctrld-ip] eq 53\n 3 deny udp 192.168.10.0 0.0.0.255 any eq 53\n 4 deny tcp 192.168.10.0 0.0.0.255 any eq 53\n 5 deny tcp 192.168.10.0 0.0.0.255 any eq 853\n 6 permit ip any any\ninterface vlan 10\n ip access-group DNS-ENFORCE-VLAN10 in\n```\n\nRules 12 permit DNS only to ctrld. Rules 34 block DNS anywhere else (8.8.8.8, Cloudflare, etc.). Rule 5 blocks DNS-over-TLS (port 853) as another bypass path. Rule 6 permits all other traffic so internet still works.\n\nVLAN 99 (management) is automatically excluded — a broken ACL on the management VLAN would lock you out.\n\nThe generated commands are shown for review and pushed through the normal TOTP-gated push mechanism. Nothing is sent to the switch automatically.\n\n---\n\n## Inter-VLAN Routing ACL Templates\n\nThe **ACL Builder** tab has a **"Use Template"** button that pre-fills common policies:\n\n**Staff VLAN — full internet, no management access**\nPermits everything except access to VLAN 99 (192.168.99.0/24). Use on a staff or office VLAN where users need full internet but must not reach the management interface.\n\n**IoT VLAN — internet only, no RFC1918**\nBlocks all RFC1918 private address ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x). IoT devices get internet but cannot reach any other VLAN, internal servers, or management. Permits internet.\n\n**Guest VLAN — internet only, DNS must work first**\nLike IoT but explicitly permits DNS to ctrld first (before the deny rules), ensuring DNS filtering continues to work even after RFC1918 is blocked.\n\n**Camera VLAN — NVR only**\nCameras may only send traffic to one NVR/DVR IP. All other traffic is dropped. Prevents cameras from phoning home, scanning the network, or accessing the internet directly.\n\nAll templates are fully editable after applying. The template fills the rule table — you adjust IPs, add rules, or delete rules before pushing.\n\n---\n\n## Local Hostname Resolution (dnsmasq)\n\nThe DNS tab has a **"Local Hostnames"** section. It manages an optional `dnsmasq` container that resolves `.lan` hostnames for all devices on the network.\n\n**How it works:**\n1. dnsmasq runs in Docker, listening on port 5353 on the management computer.\n2. ctrld is configured to forward `*.lan` and `*.local` queries to `127.0.0.1:5353` (split-horizon rule).\n3. All other queries go through Control D as normal.\n4. `switch.mgmt.lan` and `management.lan` always resolve to the management computer\'s IP.\n\n**Setup:**\n\nAdd hostname→IP mappings in the DNS tab → Local Hostnames section. Click **Save & Generate dnsmasq.conf**. The tab shows:\n- The generated `dnsmasq.conf` content and path\n- A docker-compose snippet to add the dnsmasq service\n- A ctrld.toml block to enable split-horizon forwarding\n\nAdd the docker-compose snippet to `docker-compose.yml`, add the toml block to `ctrld.toml`, then:\n```bash\ndocker compose up -d dnsmasq\nctrld restart\n```\n\nThe `dnsmasq.conf` is written to `/etc/switch-manager/dnsmasq.conf` and mounted read-only into the container.\n\n**Files added:**\n\n| File | Location | Purpose |\n|---|---|---|\n| `local-hostnames.json` | `/etc/switch-manager/` | User-defined hostname→IP mappings |\n| `dnsmasq.conf` | `/etc/switch-manager/` | Generated dnsmasq config |\n'
# ═══════════════════════════════════════════════════════════════════════════
# SETUP STEPS
# ═══════════════════════════════════════════════════════════════════════════
import ipaddress
def detect_os():
import platform
s = platform.system().lower()
if s == 'linux': return 'linux'
if s == 'darwin': return 'mac'
return 'windows'
OS = detect_os()
# ── Dockerfile and compose templates ───────────────────────────────────────
DOCKERFILE = """\
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn paramiko pyotp
COPY switch_backend.py .
COPY frontend/dist ./frontend/dist
EXPOSE 8765
CMD ["uvicorn", "switch_backend:app", "--host", "0.0.0.0", "--port", "8765"]
"""
COMPOSE_YML = """services:
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
- switch-manager
switch-manager:
build: .
expose:
- "8765"
volumes:
- /etc/switch-manager:/etc/switch-manager:ro
- ./frontend/dist:/app/frontend/dist:ro
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
volumes:
caddy_data:
caddy_config:
"""
CADDYFILE_TEMPLATE = """{fqdn} {{
reverse_proxy switch-manager:8765
tls internal
}}
:80 {{
redir https://{{host}}{{uri}} permanent
}}
"""
# ── WireGuard helpers ──────────────────────────────────────────────────────
WG_CONF_DIR = Path("/etc/wireguard")
WG_SERVER_KEY = CONF_DIR / "wg_server_private"
WG_CLIENT_DIR = CONF_DIR / "clients"
WG_PORT = 51820
WG_SUBNET = "10.99.0" # VPN: server=10.99.0.1, clients=10.99.0.x
def wg_genkey():
priv = run("wg genkey").stdout.strip()
pub = subprocess.run(["wg","pubkey"], input=priv,
capture_output=True, text=True).stdout.strip()
return priv, pub
def detect_opnsense(gateway_ip):
try:
import urllib.request, ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = f"https://{gateway_ip}/api/core/firmware/status"
urllib.request.urlopen(
urllib.request.Request(url, headers={"User-Agent":"switch-manager/1"}),
timeout=3, context=ctx)
return True
except Exception:
return False
# ═══════════════════════════════════════════════════════════════════════════
# STEP FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════
def write_files():
step(1, "Write project files")
for fname, content in [
("switch_backend.py", BACKEND_SRC),
("ers5952-manager.jsx", JSX_SRC),
("README.md", README_SRC),
]:
target = HERE / fname
if not target.exists() or ask_yn(f"Overwrite {fname}?", False):
target.write_text(content)
ok(f"{fname} written")
else:
ok(f"{fname} already exists — skipped")
def install_system_packages():
step(2, "System packages")
if OS != 'linux':
info(f"On {OS} — skipping system package install")
return
pkgmgr = ("apt" if shutil.which("apt") else
"dnf" if shutil.which("dnf") else
"pacman" if shutil.which("pacman") else None)
if not pkgmgr:
warn("Cannot detect package manager — install picocom and qrencode manually")
return
needed = []
for pkg, binary in [("picocom","picocom"),("qrencode","qrencode"),
("wireguard-tools","wg"),("curl","curl")]:
if shutil.which(binary):
ok(f"{pkg} already installed")
else:
needed.append(pkg)
if needed:
info(f"Installing: {', '.join(needed)}")
if pkgmgr == "apt":
sudo("apt-get update -qq")
sudo(f"apt-get install -y {' '.join(needed)}")
elif pkgmgr == "dnf":
sudo(f"dnf install -y {' '.join(needed)}")
elif pkgmgr == "pacman":
sudo(f"pacman -S --noconfirm {' '.join(needed)}")
for pkg in needed:
ok(f"{pkg} installed") if shutil.which(pkg.split('-')[0]) else warn(f"{pkg} may not have installed")
def ensure_dialout_group():
if OS != 'linux': return
step(3, "Serial port access")
import grp
user = os.getenv("USER","")
try:
members = grp.getgrnam("dialout").gr_mem
except KeyError:
warn("dialout group not found"); return
if user in members:
ok(f"{user} already in dialout group"); return
sudo(f"usermod -aG dialout {user}")
ok(f"{user} added to dialout group — active for this session")
def install_deps():
step(4, "Python dependencies")
import importlib.util
missing = [p for p in PACKAGES
if not importlib.util.find_spec(p.replace("-","_"))]
for p in PACKAGES:
if p not in missing: ok(f"{p} present")
if missing:
info(f"Installing: {', '.join(missing)}")
run(f"{sys.executable} -m pip install {' '.join(missing)} --break-system-packages",
capture=False)
try:
import qrcode; ok("qrcode library present")
except ImportError:
run(f"{sys.executable} -m pip install qrcode --break-system-packages", capture=False)
def create_conf_dir():
step(5, "Config directory")
if CONF_DIR.exists():
ok(f"{CONF_DIR} exists"); return
sudo(f"mkdir -p {CONF_DIR}")
sudo(f"chown {os.getenv('USER','root')} {CONF_DIR}")
sudo(f"chmod 750 {CONF_DIR}")
ok(f"Created {CONF_DIR}")
def set_static_ip():
step(6, "Network — management IP")
sep()
info("This machine needs a static IP on the switch management subnet.")
info("Default: switch = 192.168.99.1, this machine = 192.168.99.50")
sep()
if not ask_yn("Set a static IP now?", True):
info("Skipping — configure manually before SSH test")
return "192.168.99.50", "192.168.99.1"
import re
result = run("ip link show") if OS == 'linux' else run("ifconfig -l")
ifaces = []
if OS == 'linux' and result:
for line in result.stdout.splitlines():
m = re.match(r'^\d+: ([\w]+):', line)
if m:
name = m.group(1)
skip = ('lo','vir','docker','br-','veth','wl','tun','wg')
if not any(name.startswith(s) for s in skip):
ifaces.append(name)
elif OS == 'mac' and result:
ifaces = [n for n in result.stdout.split() if n.startswith("en")]
if not ifaces:
iface = ask("Interface name (e.g. eth0, enp2s0, en0)", "eth0")
ifaces = [iface]
sep()
print(f"\n {bold('Available interfaces:')}")
for i, iface in enumerate(ifaces):
r = run(f"ip addr show {iface}" if OS=='linux' else f"ifconfig {iface}")
cur = "no IP"
for line in r.stdout.splitlines():
m = re.search(r'inet (\S+)', line)
if m and not m.group(1).startswith("127"):
cur = m.group(1); break
print(f" {cyan(str(i+1))}. {bold(iface)} {dim(f'({cur})')}")
print()
if len(ifaces) == 1:
chosen = ifaces[0]
ok(f"Using {chosen}")
else:
try:
idx = int(ask(f"Interface (1-{len(ifaces)})", "1")) - 1
chosen = ifaces[max(0, min(idx, len(ifaces)-1))]
except ValueError:
chosen = ifaces[0]
ip = ask("IP for this machine", "192.168.99.50")
mask = ask("Prefix length", "24")
gateway = ask("Switch IP (gateway)", "192.168.99.1")
sep()
if OS == 'linux':
run(f"sudo ip addr flush dev {chosen}", check=False)
run(f"sudo ip addr add {ip}/{mask} dev {chosen}")
run(f"sudo ip link set {chosen} up")
run(f"sudo ip route add default via {gateway}", check=False)
elif OS == 'mac':
sudo(f"ifconfig {chosen} {ip} netmask 255.255.255.0")
sudo(f"route add default {gateway}", check=False)
time.sleep(1)
r = run(f"ping -c 2 -W 2 {gateway}" if OS=='linux' else f"ping -c 2 -t 2 {gateway}",
check=False)
if r.returncode == 0:
ok(f"Switch at {gateway} responds to ping")
else:
warn(f"Cannot reach {gateway} — check cable and which port on the switch")
if OS == 'linux' and ask_yn("Make permanent (survives reboot)?", True):
_make_permanent_linux(chosen, ip, mask, gateway)
elif OS == 'mac':
info(f"Mac: System Settings → Network → {chosen} → TCP/IP → Manual")
info(f"IP: {ip} Mask: 255.255.255.0 Router: {gateway}")
return ip, gateway
def _make_permanent_linux(iface, ip, mask, gateway):
if shutil.which("netplan"):
cfg = Path("/etc/netplan/99-switch-manager.yaml")
tmp = Path("/tmp/99-switch-manager.yaml")
tmp.write_text(
f"network:\n version: 2\n ethernets:\n {iface}:\n"
f" addresses:\n - {ip}/{mask}\n"
f" routes:\n - to: default\n via: {gateway}\n"
)
sudo(f"mv {tmp} {cfg}")
sudo("netplan apply")
ok(f"Permanent via netplan — {cfg}")
else:
ifaces_file = Path("/etc/network/interfaces")
cur = ifaces_file.read_text() if ifaces_file.exists() else ""
if iface not in cur:
tmp = Path("/tmp/ifaces_append")
tmp.write_text(
cur + f"\nauto {iface}\niface {iface} inet static\n"
f" address {ip}/{mask}\n gateway {gateway}\n"
)
sudo(f"mv {tmp} {ifaces_file}")
sudo("systemctl restart networking")
ok("Permanent via /etc/network/interfaces")
else:
warn(f"{iface} already in /etc/network/interfaces — edit manually if needed")
def choose_deployment():
step(7, "Deployment method")
sep()
print(f"""
{bold('About the deployment options:')}
{bold('Docker')} {cyan('(recommended default)')}
Runs the switch manager in a container with all dependencies
baked in. The host machine only needs Docker itself.
Why Docker is recommended:
- Self-contained: Python version and all libraries are locked inside
the container. Nothing installed on your system, no version conflicts.
- Easy updates: one command updates everything:
{dim('docker compose pull && docker compose up -d')}
- Includes Caddy: automatic HTTPS with a proper hostname
({dim('https://switch.mgmt.lan')}) instead of a raw IP and port.
- Reliable restarts: Docker's restart policy is simpler than systemd
for users who are not system administrators.
- Caddy generates its own certificate authority. The setup script
installs it into your system trust store automatically so browsers
show a clean padlock with no warnings.
{bold('Native Python')} {cyan('(bare metal — for specific situations)')}
Runs directly as a Python process on this machine.
Why you might prefer bare metal:
- Very low memory machine (Docker adds ~100MB overhead)
- You already know systemd and prefer managing services directly
- Corporate or personal policy against running Docker
- Simpler mental model — one Python process, easy to inspect and kill
- No Caddy: access via http://IP:8765 directly
""")
sep()
choice = ask("Deployment method (1=Docker recommended, 2=Native Python)", "1")
use_docker = choice.strip() != "2"
ok("Docker + Caddy selected") if use_docker else ok("Native Python selected")
sep()
return use_docker
def setup_docker():
step(8, "Docker setup")
if OS != 'linux':
warn("Docker auto-install supported on Linux only")
info("Install Docker Desktop: https://docs.docker.com/desktop/")
info(f"Then run in {HERE}: docker compose up -d")
return False
# Install Docker via official convenience script
if shutil.which("docker"):
ok("Docker already installed")
else:
info("Downloading Docker install script...")
r = run("curl -fsSL https://get.docker.com -o /tmp/get-docker.sh", check=False)
if r.returncode != 0:
err("Could not download Docker script — check internet connection")
return False
info("Installing Docker (this may take a minute)...")
run("sudo sh /tmp/get-docker.sh", capture=False)
if not shutil.which("docker"):
err("Docker install failed"); return False
ok("Docker installed")
# Add user to docker group
user = os.getenv("USER","")
import grp
try:
docker_members = grp.getgrnam("docker").gr_mem
except KeyError:
docker_members = []
if user not in docker_members:
sudo(f"usermod -aG docker {user}")
ok(f"{user} added to docker group")
else:
ok(f"{user} already in docker group")
# Ensure compose is available
compose_ok = run("docker compose version", check=False).returncode == 0
if not compose_ok:
info("Installing docker compose plugin...")
if shutil.which("apt"):
sudo("apt-get install -y docker-compose-plugin")
compose_ok = run("docker compose version", check=False).returncode == 0
if not compose_ok:
warn("docker compose not found — trying pip install")
run(f"{sys.executable} -m pip install docker-compose --break-system-packages", capture=False)
# Write Dockerfile and compose
(HERE / "Dockerfile").write_text(DOCKERFILE)
ok("Dockerfile written")
(HERE / "docker-compose.yml").write_text(COMPOSE_YML)
ok("docker-compose.yml written")
# Ask for FQDN
sep()
fqdn = ask("Hostname for the switch manager UI", "switch.mgmt.lan")
FQDN_FILE = CONF_DIR / "fqdn"
FQDN_FILE.write_text(fqdn)
# Write Caddyfile
caddyfile = HERE / "Caddyfile"
caddyfile.write_text(CADDYFILE_TEMPLATE.format(fqdn=fqdn))
ok(f"Caddyfile written — will serve https://{fqdn}")
# Update backend ALLOWED_ORIGINS with the FQDN
if BACKEND.exists():
content = BACKEND.read_text()
content = content.replace(
'ALLOWED_ORIGINS = ["*"]',
f'ALLOWED_ORIGINS = ["https://{fqdn}", "http://{fqdn}", "*"]'
)
BACKEND.write_text(content)
ok("Backend CORS updated")
# Build image
info("Building container image (first build downloads Python base image)...")
r = run(f"cd {HERE} && docker compose build", capture=False, check=False)
if r.returncode != 0:
err("Docker build failed — check output above"); return False
ok("Container image built")
# Start temporarily to extract Caddy CA cert
info("Starting containers to generate Caddy CA...")
run(f"cd {HERE} && docker compose up -d", capture=False, check=False)
time.sleep(5)
# Install Caddy CA into system trust store
_install_caddy_ca()
ok(f"Access at: https://{fqdn}")
info(f"Add DNS: {fqdn}{local_ip()} in your router/OPNsense split DNS")
return True
def console_cable_guidance():
step(9, "Console cable — one-time switch bootstrap")
sep()
print(f"""
{bold('The console cable is only needed once')} — to set up VLAN 99
and load the SSH public key. After that, the ethernet cable
from this machine to the switch handles everything permanently.
{bold('What to buy:')}
Search Amazon/eBay: {cyan(CONSOLE_CABLE_SEARCH)}
Get the RJ-45 to USB version (~$8). Works on any laptop.
No serial port on your laptop needed.
{bold('Software — all free:')}""")
for name, url in CONSOLE_SOFTWARE.items():
print(f" {cyan(name)}")
print(f" {dim(url)}")
if OS == 'linux':
print(f"""
{bold('On Linux — no extra software needed:')}
Find device: {cyan('ls /dev/ttyUSB*')} (appears when cable is plugged in)
Connect: {cyan('sudo picocom -b 9600 /dev/ttyUSB0')}
Or: {cyan('sudo screen /dev/ttyUSB0 9600')}""")
elif OS == 'mac':
print(f"""
{bold('On Mac — Terminal is built in:')}
{cyan('screen /dev/tty.usbserial-* 9600')}
(Tab-complete after /dev/tty.usb to find the right name)""")
elif OS == 'windows':
print(f"""
{bold('On Windows:')}
1. Install driver for your cable (links above)
2. Device Manager → Ports (COM & LPT) → note COM number
3. PuTTY → Connection: Serial → Speed: 9600 → Open""")
print(f"""
{bold('Settings (same everywhere):')}
9600 baud · 8 data bits · No parity · 1 stop bit · No flow control
{bold('Send one command at a time.')}
Wait for the {cyan('5952(config)#')} prompt before sending the next line.
""")
sep()
pause("Press Enter when ready (or to skip if VLAN 99 already configured)...")
def gen_ssh_key():
step(10, "SSH keypair")
if KEY_PATH.exists():
ok(f"Key exists at {KEY_PATH}"); return
run(f'ssh-keygen -t ed25519 -f {KEY_PATH} -C "switch-manager" -N ""')
KEY_PATH.chmod(0o600)
ok("Keypair created")
def get_switch_details():
step(11, "Switch details")
sep()
host = ask("Switch management IP", "192.168.99.1")
port = ask("SSH port", "22")
user = ask("Switch username", "admin")
sep()
return host, int(port), user
def load_key_on_switch(host, user):
step(12, "Load public key on switch")
pub = Path(str(KEY_PATH) + ".pub").read_text().strip()
sep()
print(f"""
{yellow("Console step")} — enter these one at a time on the switch:
{cyan("enable")}
{cyan("configure terminal")}
{cyan(f'username {user} ssh-key "{pub}"')}
{cyan("end")}
{cyan("copy running-config nvram:config.cfg")}
{bold("Switch password rules:")}
{dim("8 to 32 characters. Special chars OK: ! @ # $ % ^ & * - _ = + [ ] ; : , . /")}
{dim("No spaces. No quotes (' or " + chr(34) + "). Spaces end the command.")}
{dim("Public key also saved at: " + str(KEY_PATH) + ".pub")}
""")
pause("Press Enter once the key is loaded...")
def test_ssh(host, port, user):
step(13, "Test SSH connection")
info(f"Connecting to {user}@{host}:{port}...")
try:
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=host, port=port, username=user,
key_filename=str(KEY_PATH), look_for_keys=False,
allow_agent=False, timeout=10, banner_timeout=10)
_, stdout, _ = client.exec_command("show sys-info")
out = stdout.read().decode("utf-8", errors="replace")
client.close()
ok("SSH connection successful")
for line in out.splitlines():
if any(x in line for x in ["SysName","Hostname","Name"]):
info(f"Switch: {line.strip()}"); break
return True
except Exception as e:
err(f"SSH failed: {e}")
print(f"""
{yellow("Troubleshooting:")}
ping {host} ← can this machine reach the switch?
show ip ssh ← on switch console: confirms SSH is enabled
show ssh ← on switch console: confirms key is loaded
ls /dev/ttyUSB* ← confirm console cable is still connected
""")
return ask_yn("Retry?", True) and test_ssh(host, port, user)
def pin_host_key(host):
step(14, "Pin switch host key")
if KNOWN_HOSTS.exists():
ok("Host key already pinned"); return
result = run(f"ssh-keyscan -H {host} 2>/dev/null")
if result.returncode == 0 and result.stdout.strip():
KNOWN_HOSTS.write_text(result.stdout)
KNOWN_HOSTS.chmod(0o644)
ok("Host key pinned — MITM protection active")
else:
warn("Could not pin host key — trust-on-first-use applies")
def setup_totp():
step(15, "TOTP authenticator")
import pyotp
if TOTP_FILE.exists():
ok("TOTP secret exists")
if not ask_yn("Show QR / secret again?", False): return
else:
secret = pyotp.random_base32()
TOTP_FILE.write_text(secret)
TOTP_FILE.chmod(0o600)
ok("TOTP secret created")
_display_totp()
def _display_totp():
import pyotp
secret = TOTP_FILE.read_text().strip()
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name="ERS5952", issuer_name="SwitchManager")
sep()
print(f"\n {bold('Secret (for manual entry):')}\n {cyan(secret)}\n")
shown = False
if shutil.which("qrencode"):
r = run(f'qrencode -t ANSIUTF8 "{uri}"', capture=True)
if r.returncode == 0:
print(r.stdout)
ok("QR code above — scan with Google Authenticator, Authy, etc.")
shown = True
if not shown:
try:
import qrcode
qr = qrcode.QRCode()
qr.add_data(uri)
qr.make()
print(" Scan this QR code:\n")
qr.print_ascii(invert=True)
ok("QR code above — scan with your authenticator app")
shown = True
except ImportError:
pass
if not shown:
info("Paste this URI into Google Authenticator or Authy:")
print(f"\n {dim(uri)}\n")
sep()
pause("Press Enter once scanned...")
while True:
code = ask("Enter the 6-digit code to verify").strip()
if totp.verify(code, valid_window=1):
ok("TOTP verified — authenticator working"); break
else:
err("Incorrect — codes rotate every 30 seconds, try again")
def patch_backend(host, port, user):
step(16, "Patch backend config")
if not BACKEND.exists():
warn("switch_backend.py not found — skipping"); return
content = BACKEND.read_text()
for old, new in [
('SWITCH_HOST = "192.168.99.1"', f'SWITCH_HOST = "{host}"'),
('SWITCH_PORT = 22', f'SWITCH_PORT = {port}'),
('SWITCH_USER = "admin"', f'SWITCH_USER = "{user}"'),
]:
content = content.replace(old, new)
BACKEND.write_text(content)
ok(f"Config patched — {host}:{port} user={user}")
def build_frontend():
step(17, "React frontend build")
if not shutil.which("node"):
warn("Node.js not found — skipping")
info("Install from https://nodejs.org then re-run this script")
return False
if DIST.exists() and not ask_yn("Already built — rebuild?", False):
ok("Using existing build"); return True
if not APP_JSX.exists():
warn("ers5952-manager.jsx not found"); return False
info("Creating Vite project...")
run(f"npm create vite@latest {FRONTEND} -- --template react --yes", capture=False)
shutil.copy(APP_JSX, FRONTEND / "src" / "App.jsx")
main_jsx = FRONTEND / "src" / "main.jsx"
main_jsx.write_text(
main_jsx.read_text()
.replace("import './index.css'\n","")
.replace("import './index.css'","")
)
info("Installing packages...")
run(f"cd {FRONTEND} && npm install", capture=False)
info("Building...")
run(f"cd {FRONTEND} && npm run build", capture=False)
if DIST.exists():
ok("Frontend built"); return True
err("Build failed — check output above"); return False
def _install_caddy_ca():
"""Extract Caddy internal CA cert and install into system trust store."""
import base64
info("Installing Caddy CA certificate into system trust store...")
# Extract cert from running Caddy container
result = run(
"docker exec $(docker compose ps -q caddy) "
"cat /data/caddy/pki/authorities/local/root.crt 2>/dev/null",
check=False
)
if result.returncode != 0 or not result.stdout.strip():
# Wait a bit more and retry
time.sleep(5)
result = run(
"docker exec $(docker compose ps -q caddy) "
"cat /data/caddy/pki/authorities/local/root.crt 2>/dev/null",
check=False
)
if not result.stdout.strip():
warn("Could not extract Caddy CA cert — browser will show cert warning")
info("After startup, run: docker exec switch-manager-caddy-1 cat /data/caddy/pki/authorities/local/root.crt | sudo tee /usr/local/share/ca-certificates/caddy-local.crt && sudo update-ca-certificates")
return
cert = result.stdout.strip()
cert_path = Path("/usr/local/share/ca-certificates/caddy-switch-manager.crt")
tmp = Path("/tmp/caddy-switch-manager.crt")
tmp.write_text(cert)
sudo(f"mv {tmp} {cert_path}")
sudo("update-ca-certificates")
ok("Caddy CA installed — browsers on this machine will trust the cert")
info("For other devices on VLAN 99, install the CA cert:")
info(f"Download from: https://{(CONF_DIR / 'fqdn').read_text().strip() if (CONF_DIR / 'fqdn').exists() else 'switch.mgmt.lan'}/caddy-ca.crt")
info("iOS: AirDrop or email the .crt file, tap it, go to Settings → Profile Downloaded")
info("Android: Settings → Security → Install certificate")
info("Mac: double-click the .crt, add to Keychain, set to Always Trust")
info("Windows: double-click the .crt, Install Certificate, Local Machine, Trusted Root")
def install_service():
step(18, "Systemd service")
if not shutil.which("systemctl"):
info("No systemd — run manually: python switch_backend.py"); return False
if SERVICE.exists() and not ask_yn("Service exists — reinstall?", False):
ok("Service unchanged"); return True
user = os.getenv("USER","root")
svc = textwrap.dedent(f"""
[Unit]
Description=Avaya ERS 5952 Switch Manager
After=network.target
[Service]
Type=simple
User={user}
WorkingDirectory={HERE}
ExecStart={sys.executable} {BACKEND}
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
""").strip()
tmp = Path("/tmp/switch-manager.service")
tmp.write_text(svc)
sudo(f"mv {tmp} {SERVICE}")
sudo("systemctl daemon-reload")
sudo("systemctl enable switch-manager")
ok("Service installed and enabled"); return True
def start_service(use_docker=False):
step(19, "Start service")
if use_docker:
run(f"cd {HERE} && docker compose up -d", capture=False, check=False)
time.sleep(3)
r = run("docker compose ps", check=False)
ok("Container running") if ("running" in r.stdout.lower() or "Up" in r.stdout) else warn("Check: docker compose ps")
return
if shutil.which("systemctl") and SERVICE.exists():
sudo("systemctl restart switch-manager")
time.sleep(2)
r = run("systemctl is-active switch-manager")
ok("Service running") if r.stdout.strip()=="active" else warn("Check: journalctl -u switch-manager -n 30")
else:
p = __import__('subprocess').Popen(
[sys.executable, str(BACKEND)],
stdout=__import__('subprocess').DEVNULL,
stderr=__import__('subprocess').DEVNULL)
time.sleep(2)
ok(f"Backend running (PID {p.pid})") if p.poll() is None else err("Failed — run: python switch_backend.py")
# ═══════════════════════════════════════════════════════════════════════════
# WIREGUARD VPN
# ═══════════════════════════════════════════════════════════════════════════
def setup_wireguard(mgmt_ip, gateway_ip):
step(20, "WireGuard VPN — remote access")
sep()
print(f"""
{bold('WireGuard lets your laptop or phone connect over WiFi')}
and reach the switch manager as if on the management network.
Your device → WireGuard VPN → management computer → VLAN 99 → switch
{cyan('1. WireGuard on this management computer')} {dim('(fully automated)')}
Works without OPNsense. Server runs here.
{cyan('2. WireGuard on OPNsense')} {dim('(semi-automated)')}
More robust for multi-device setups.
Only available if OPNsense is detected.
""")
sep()
opnsense_found = False
if gateway_ip:
info(f"Checking for OPNsense at {gateway_ip}...")
opnsense_found = detect_opnsense(gateway_ip)
ok(f"OPNsense detected at {gateway_ip}") if opnsense_found else info("OPNsense not detected")
choice = "1"
if opnsense_found:
choice = ask("WireGuard server (1=this computer, 2=OPNsense)", "1")
if choice.strip() == "2" and opnsense_found:
_wg_opnsense(gateway_ip, mgmt_ip)
else:
_wg_local(mgmt_ip)
def setup_ctrld(vlans_config: list):
"""
Optional step — set up Control D DNS filtering via ctrld.
Presents all three options with explanations, user chooses.
vlans_config: list of { id, name } from VLAN setup
"""
step(21, "DNS Filtering — Control D (optional)")
sep()
print(f"""
{bold('What is DNS filtering?')}
Every time a device opens a website or app, it first asks a DNS server
"what is the address of google.com?" — before any data is sent.
DNS filtering intercepts that question and can block ads, malware, adult
content, social media, or anything else — invisibly, for every device
on your network, without installing anything on the devices themselves.
{bold('Control D')} is a DNS filtering service. Each of your VLANs gets
its own filtering profile — so your Staff VLAN can browse freely while
your Guest VLAN blocks everything except basic browsing.
{cyan('ctrld')} is a small program that runs on your router or this computer.
It sits between your devices and the internet for DNS only:
Your device → ctrld (port 53) → Control D cloud → answer back
Nothing else about your traffic is touched.
{bold('Before you start — you need a free Control D account:')}
{cyan('https://controld.com')}
{dim('Once logged in: go to Endpoints → Add Device → type: Router')}
{dim('Create one Device per VLAN. Each gets a short Resolver ID like "p-abc123".')}
{dim('That ID is what this script asks for below.')}
{bold('Three ways to run ctrld:')}
{cyan('A — On this management computer')} {dim('(fully automated)')}
ctrld runs on the same computer as the switch manager.
Good if you do not have OPNsense or just want to get started.
Downside: if this computer sleeps or shuts down, filtering stops
(your devices can still get on the internet, just unfiltered).
{cyan('B — On OPNsense')} {dim('(recommended — one SSH command + two OPNsense clicks)')}
ctrld runs on your router, which is always on.
Filtering survives this computer being off or rebooted.
Requires: SSH access to OPNsense (one command to install).
Also requires moving OPNsense's built-in DNS off port 53 first
— this script explains exactly how to do that.
{cyan('C — Generate config only')} {dim('(copy-paste the file yourself)')}
Produces a ready-to-use ctrld.toml you can nano/copy anywhere.
Good if you want to review the file before anything is changed.
""")
sep()
if not ask_yn("Set up Control D DNS filtering now?", False):
info("Skipping — you can configure this in the DNS Filtering tab later")
return
choice = ask("Option (A/B/C)", "B").strip().upper()
if choice not in ("A", "B", "C"):
warn("Invalid choice — skipping"); return
# ── Local domain name ────────────────────────────────────────────────────
sep()
print(f"""
{bold('Local domain name')}
OPNsense gives every device on your network a name ending in your local
domain — for example "johns-laptop.lan" or "printer.lan".
The default in OPNsense is {cyan('lan')} (so devices get names like device.lan).
{dim('You can check yours in OPNsense → System → General → Domain.')}
{dim('If you have not changed it, it is "lan". Just press Enter.')}
""")
local_domain = ask(" Your OPNsense local domain", "lan").strip().strip(".")
# ── Local resolver (Unbound on OPNsense or skip) ─────────────────────────
sep()
print(f"""
{bold('Local device name resolution (e.g. printer.lan, devlaptop.lan)')}
ctrld will handle ALL DNS queries on port 53 — but it does not know
about your local devices. OPNsense's built-in DNS (called Unbound)
tracks those: it watches DHCP leases and knows "johns-laptop = 192.168.10.5".
{bold('The problem:')} both Unbound and ctrld want to listen on port 53.
They cannot share it. One must move.
{bold('The solution:')} move Unbound to port 5353 (internal only), ctrld takes 53.
ctrld then forwards any query ending in .{cyan(local_domain)} or .local back to
Unbound at 127.0.0.1:5353. Everything works exactly as before, just routed.
{dim('Why .local too? Apple devices (iPhones, Macs, iPads) and many printers')}
{dim('use .local automatically via a protocol called mDNS/Bonjour.')}
{dim('Without this rule, your Mac would not find your printer by name.')}
{bold('If you are NOT using OPNsense')} or want to skip local name forwarding,
press Enter to leave this blank. Device names like printer.lan will not
resolve — you would use IP addresses instead.
""")
if choice == "B":
local_resolver = ask(
f" Unbound address after port move (OPNsense → 127.0.0.1:5353)",
"127.0.0.1:5353"
).strip()
else:
local_resolver = ask(
" Local DNS address for .lan/.local names (blank to skip)",
""
).strip()
# ── Collect per-VLAN subnet + Resolver ID ────────────────────────────────
sep()
print(f"""
{bold('VLAN subnets and Control D Resolver IDs')}
For each VLAN you want filtered, you need two things:
{cyan('Subnet')} — the IP address range for that VLAN.
Example: if your Staff VLAN is 192.168.10.x, the subnet is 192.168.10.0/24.
The "/24" means "all addresses from .0 to .255 in that group".
ctrld uses this to know which VLAN a query is coming from.
{cyan('Resolver ID')} — the short code from Control D for that VLAN's profile.
Find it at: {dim('controld.com → Endpoints → your device → copy the Resolver ID')}
It looks like: {dim('p-abc123')} or {dim('abcd1234')}
{dim('Press Enter on Resolver ID to skip a VLAN (no filtering for that VLAN).')}
""")
profiles = []
for vlan in (vlans_config or [{"id": 10, "name": "Staff"},
{"id": 20, "name": "Servers"},
{"id": 30, "name": "IoT"},
{"id": 40, "name": "Guest"},
{"id": 50, "name": "Cameras"}]):
vid = vlan["id"]
name = vlan["name"]
print(f"\n {bold(f'VLAN {vid}{name}')}")
suggested_subnet = f"192.168.{vid}.0/24"
subnet = ask(
f" Subnet (the IP range for this VLAN)",
suggested_subnet
).strip()
rid = ask(
f" Control D Resolver ID (press Enter to skip filtering for this VLAN)",
""
).strip()
if rid:
profiles.append({
"vlan_id": vid,
"name": name,
"subnet": subnet,
"resolver_id": rid,
})
ok(f"VLAN {vid} ({name}) — {subnet} → ControlD profile {rid}")
else:
info(f"VLAN {vid} ({name}) — skipped (no filtering)")
if not profiles:
warn("No Resolver IDs entered — skipping DNS setup")
return
sep()
if choice == "A":
_ctrld_install_local_setup(profiles, local_domain, local_resolver)
elif choice == "B":
_ctrld_opnsense_setup(profiles, local_domain, local_resolver)
else:
_ctrld_manual_setup(profiles, local_domain, local_resolver)
def _build_toml(profiles: list, local_domain: str = "lan",
local_resolver: str = "") -> str:
"""
Generate a ctrld.toml in the correct format (table notation, not TOML arrays).
Correct ctrld format uses [listener.0], [network.N], [upstream.N] — NOT [[arrays]].
Networks (source VLAN subnets) are defined in [network.N] sections and referenced
by index in [listener.0.policy].networks. Upstreams are indexed the same way.
If local_resolver is set (e.g. '127.0.0.1:5353'), a 'local' upstream is added and
*.lan / *.local queries are forwarded there via the policy rules section.
Control D bootstrap IP 76.76.2.0 is the anycast resolver for initial contact
before DoH is established — required for correct cold-start behaviour.
"""
BOOTSTRAP = "76.76.2.0" # Control D anycast bootstrap IP
active = [p for p in profiles if p.get("resolver_id", "").strip()]
lines = [
"# ctrld configuration — generated by Avaya 5952 Switch Manager",
"# Documentation: https://docs.controld.com/docs/ctrld",
"",
"[service]",
" log_level = 'info'",
" log_path = '/tmp/ctrld.log'",
"",
]
# ── Listener with per-VLAN policy ────────────────────────────────────────
lines += [
"[listener]",
" [listener.0]",
" ip = '0.0.0.0'",
" port = 53",
" [listener.0.policy]",
]
# networks array — maps each VLAN subnet to its upstream by index
if active:
net_lines = []
for i, p in enumerate(active):
net_lines.append(f" {{ 'network.{i}' = ['upstream.{i}'] }},")
# Add local upstream as last network entry if local resolver configured
if local_resolver:
# No specific network for local — it's a rules-based match
pass
lines += [" networks = ["] + net_lines + [" ]"]
else:
lines += [" networks = []"]
# rules array — domain-specific routing (e.g. *.lan → local resolver)
if local_resolver:
domain_suffix = local_domain.strip(".")
lines += [
" rules = [",
f" {{ '*.{domain_suffix}' = ['upstream.local'] }},",
" { '*.local' = ['upstream.local'] },",
" ]",
]
else:
lines += [" rules = []"]
lines += [""]
# ── Network sections (one per VLAN) ──────────────────────────────────────
if active:
lines += ["[network]"]
for i, p in enumerate(active):
vid = p["vlan_id"]
name = p.get("name", f"VLAN{vid}")
subnet = p.get("subnet", f"192.168.{vid}.0/24")
lines += [
f" # VLAN {vid}{name}",
f" [network.{i}]",
f" name = '{name}'",
f" cidrs = ['{subnet}']",
"",
]
# ── Upstream sections (one per VLAN + optional local) ─────────────────────
lines += ["[upstream]"]
for i, p in enumerate(active):
vid = p["vlan_id"]
rid = p["resolver_id"].strip()
lines += [
f" # VLAN {vid}{p.get('name', '')}",
f" [upstream.{i}]",
f" type = 'doh'",
f" endpoint = 'https://dns.controld.com/{rid}'",
f" bootstrap_ip = '{BOOTSTRAP}'",
f" timeout = 5000",
"",
]
# Optional local resolver (dnsmasq on port 5353 for .lan names)
if local_resolver:
lines += [
f" # Local resolver — handles *.{local_domain} and *.local hostnames",
f" [upstream.local]",
f" type = 'legacy'",
f" endpoint = '{local_resolver}'",
f" timeout = 2000",
"",
]
return "\n".join(lines)
def _ctrld_install_local_setup(profiles: list,
local_domain: str = "lan",
local_resolver: str = ""):
info("Installing ctrld on this machine...")
first_rid = profiles[0]["resolver_id"]
# Download and install via official installer
install_cmd = f'sh -c \'sh -c "$(curl -sL https://api.controld.com/dl)" -s {first_rid} forced\''
result = run(install_cmd, capture=False, check=False)
ctrld_bin = shutil.which("ctrld") or "/usr/local/bin/ctrld"
if not Path(ctrld_bin).exists():
err("ctrld install may have failed — check output above")
info("Manual install: https://docs.controld.com/docs/ctrld")
return
ok("ctrld installed")
# Write multi-VLAN config
cfg_path = Path("/etc/controld/ctrld.toml")
cfg_path.parent.mkdir(parents=True, exist_ok=True)
toml = _build_toml(profiles, local_domain=local_domain, local_resolver=local_resolver)
cfg_path.write_text(toml)
ok(f"Config written to {cfg_path}")
# Restart with new config
run(f"{ctrld_bin} stop", check=False)
run(f"{ctrld_bin} start", check=False)
time.sleep(2)
r = run(f"{ctrld_bin} status", check=False)
if r.returncode == 0:
ok("ctrld running")
else:
warn("ctrld may not be running — check: ctrld status")
mgmt_ip = local_ip()
sep()
print(f"""
{bold('ctrld is running on this machine at:')} {cyan(mgmt_ip)}
{bold('Next — tell your router to use this machine for DNS:')}
In OPNsense: Services → DHCPv4 → [each VLAN interface]
Set {bold('DNS Server')} to {cyan(mgmt_ip)} and save.
Or in the switch manager DHCP tab, set DNS (option 6)
to {cyan(mgmt_ip)} for each VLAN pool.
Once done, every device that renews its DHCP lease will
start using ctrld automatically — no changes on the devices.
""")
def _ctrld_opnsense_setup(profiles: list,
local_domain: str = "lan",
local_resolver: str = "127.0.0.1:5353"):
opnsense_ip = ask("OPNsense IP address", "192.168.99.1")
first_rid = profiles[0]["resolver_id"]
install_cmd = f'sh -c \'sh -c "$(curl -sL https://api.controld.com/dl)" -s {first_rid} forced\''
ssh_cmd = f"ssh root@{opnsense_ip} '{install_cmd}'"
toml = _build_toml(profiles, local_domain=local_domain, local_resolver=local_resolver)
cfg_path = "/usr/local/etc/controld/ctrld.toml"
sep()
print(f"""
{bold('━━━ Before you run ctrld — move Unbound off port 53 ━━━')}
OPNsense has a built-in DNS server called Unbound (currently on port 53).
ctrld also needs port 53. They cannot share it, so Unbound must move first.
{bold('In OPNsense web interface:')}
1. Go to {cyan('Services → Unbound DNS → General')}
2. Change {bold('Listen Port')} from {cyan('53')} to {cyan('5353')}
3. Change {bold('Network Interfaces')} to {cyan('Loopback')} only
{dim('(this means Unbound only answers from within OPNsense itself,')}
{dim(' not directly from your network devices — ctrld handles those)')}
4. Click {cyan('Apply')}
{dim('Why loopback only? After this change, ctrld is the one answering your')}
{dim('devices on port 53. Unbound only needs to answer ctrld, which runs on')}
{dim('the same machine. Loopback = same machine only.')}
{bold('Do this now, then press Enter to continue.')}
""")
input(" Press Enter when Unbound has been moved to port 5353... ")
sep()
print(f"""
{bold('━━━ Step 1 — Install ctrld on OPNsense (SSH command) ━━━')}
Open a terminal and run this command:
{cyan(ssh_cmd)}
{dim('This connects to OPNsense over SSH and runs the official ctrld installer.')}
{dim('If you get a "permission denied" error, check that root SSH is enabled')}
{dim('in OPNsense: System → Settings → Administration → Secure Shell.')}
{bold('Do this now, then press Enter to continue.')}
""")
input(" Press Enter when ctrld is installed on OPNsense... ")
# Write the toml locally so we can scp it
toml_file = HERE / "ctrld.toml"
toml_file.write_text(toml)
ok(f"ctrld.toml written to: {toml_file}")
scp_cmd = f"scp {toml_file} root@{opnsense_ip}:{cfg_path}"
restart_cmd = f"ssh root@{opnsense_ip} 'ctrld restart'"
sep()
print(f"""
{bold('━━━ Step 2 — Copy the config to OPNsense ━━━')}
Run these two commands in order:
{cyan(scp_cmd)}
{cyan(restart_cmd)}
{dim('scp copies the generated ctrld.toml to OPNsense.')}
{dim('ctrld restart applies the new per-VLAN configuration.')}
{bold('Do this now, then press Enter to continue.')}
""")
input(" Press Enter when ctrld has been restarted on OPNsense... ")
sep()
print(f"""
{bold('━━━ Step 3 — Tell your network to use ctrld for DNS ━━━')}
In OPNsense: Services → DHCPv4 → [each VLAN interface]
Set {bold('DNS Server')} to {cyan(opnsense_ip)} and save.
(OPNsense's own IP — ctrld is now running there on port 53.)
{dim('When a device renews its DHCP lease (or you reconnect it),')}
{dim('it will receive OPNsense as its DNS server and queries will')}
{dim('flow through ctrld automatically.')}
{bold('━━━ How it all works now ━━━')}
Device asks DNS question
ctrld on OPNsense (port 53)
├── *.{local_domain} or *.local → Unbound (port 5353, local names)
└── everything else (by VLAN subnet) → Control D cloud (filtered)
{bold('Verify it is working:')}
{cyan(f'ssh root@{opnsense_ip} "ctrld status"')}
""")
if ask_yn("Copy the SSH install command to clipboard?", False):
try:
import subprocess
subprocess.run(["xclip", "-selection", "clipboard"],
input=ssh_cmd.encode(), check=False)
ok("Copied to clipboard")
except Exception:
info("xclip not available — copy the command manually from above")
def _ctrld_manual_setup(profiles: list,
local_domain: str = "lan",
local_resolver: str = ""):
toml = _build_toml(profiles, local_domain=local_domain, local_resolver=local_resolver)
toml_file = HERE / "ctrld.toml"
toml_file.write_text(toml)
first_rid = profiles[0]["resolver_id"]
sep()
_install_cmd = "sh -c 'sh -c \"$(curl -sL https://api.controld.com/dl)\" -s " + first_rid + " forced'"
unbound_note = ""
if local_resolver:
unbound_note = f"""
{bold('IMPORTANT — before installing on OPNsense:')}
Move Unbound off port 53 first, or ctrld will fail to start.
In OPNsense: Services → Unbound DNS → General
Change Listen Port to {cyan('5353')}, Network Interfaces to {cyan('Loopback')}, Apply.
"""
print(f"""
{bold('ctrld.toml written to:')} {cyan(str(toml_file))}
Open it with: {cyan(f'cat {toml_file}')}
Or nano it: {cyan(f'nano {toml_file}')}
{unbound_note}
{bold('To install ctrld on any Linux / Mac / OPNsense machine:')}
{cyan(_install_cmd)}
{bold('Copy the config file to the machine where ctrld is installed:')}
{dim('OPNsense path: /usr/local/etc/controld/ctrld.toml')}
{dim('Linux path: /etc/controld/ctrld.toml')}
{bold('Then apply it:')} {cyan('ctrld restart')}
{bold('Finally — set DNS for each VLAN:')}
In OPNsense: Services → DHCPv4 → [each VLAN] → DNS Server = IP of ctrld machine.
Once devices renew their DHCP lease, they will use ctrld automatically.
Full docs: {dim('https://docs.controld.com/docs/ctrld')}
""")
def _wg_local(mgmt_ip):
info("Setting up WireGuard server on this machine...")
if OS != 'linux':
warn("WireGuard server auto-setup is Linux only")
info("Install from https://www.wireguard.com/install/")
return
if not shutil.which("wg"):
err("WireGuard not installed — run system package step first"); return
# Server keys
if not WG_SERVER_KEY.exists():
priv, pub = wg_genkey()
WG_SERVER_KEY.write_text(priv); WG_SERVER_KEY.chmod(0o600)
(CONF_DIR / "wg_server_public").write_text(pub)
ok("Server keypair generated")
else:
priv = WG_SERVER_KEY.read_text().strip()
pub = (CONF_DIR / "wg_server_public").read_text().strip()
ok("Using existing server keypair")
# IP forwarding
fwd = Path("/etc/sysctl.d/99-wireguard.conf")
if not fwd.exists():
tmp = Path("/tmp/99-wg.conf")
tmp.write_text("net.ipv4.ip_forward=1\n")
sudo(f"mv {tmp} {fwd}")
sudo("sysctl -p /etc/sysctl.d/99-wireguard.conf")
ok("IP forwarding enabled")
# Detect outbound interface for NAT
nat_iface = run("ip route get 1.1.1.1 2>/dev/null | awk '{print $5; exit}'").stdout.strip() or "eth0"
WG_CONF_DIR.mkdir(exist_ok=True)
wg_conf = WG_CONF_DIR / "wg0.conf"
# Preserve existing peers
existing_peers = ""
if wg_conf.exists():
txt = wg_conf.read_text()
peer_idx = txt.find("\n[Peer]")
if peer_idx >= 0:
existing_peers = txt[peer_idx:]
server_conf = (
f"[Interface]\n"
f"Address = {WG_SUBNET}.1/24\n"
f"ListenPort = {WG_PORT}\n"
f"PrivateKey = {priv}\n\n"
f"# NAT — clients reach the management network\n"
f"PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; "
f"iptables -t nat -A POSTROUTING -o {nat_iface} -j MASQUERADE\n"
f"PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; "
f"iptables -t nat -D POSTROUTING -o {nat_iface} -j MASQUERADE\n"
)
tmp = Path("/tmp/wg0.conf")
tmp.write_text(server_conf + existing_peers)
sudo(f"mv {tmp} {wg_conf}")
sudo(f"chmod 600 {wg_conf}")
ok("WireGuard server config written")
WG_CLIENT_DIR.mkdir(exist_ok=True)
_wg_add_client(pub, mgmt_ip, wg_conf)
sudo("systemctl enable wg-quick@wg0")
sudo("systemctl restart wg-quick@wg0")
time.sleep(2)
r = run("systemctl is-active wg-quick@wg0")
ok("WireGuard running") if r.stdout.strip()=="active" else warn("Check: journalctl -u wg-quick@wg0 -n 20")
def _wg_add_client(server_pub, mgmt_ip, wg_conf):
import re
# Find next available IP
used = set()
if wg_conf.exists():
for m in re.finditer(r'AllowedIPs\s*=\s*(\S+)', wg_conf.read_text()):
used.add(m.group(1).split('/')[0])
num = 2
while f"{WG_SUBNET}.{num}" in used and num < 254:
num += 1
client_ip = f"{WG_SUBNET}.{num}"
name = ask("Client name (e.g. laptop, phone, tablet)", f"client{num-1}")
c_priv, c_pub = wg_genkey()
# Append peer to server config
peer = f"\n[Peer]\n# {name}\nPublicKey = {c_pub}\nAllowedIPs = {client_ip}/32\n"
with open(wg_conf, 'a') as f:
f.write(peer)
ok(f"Peer '{name}' added")
# Reload live config if WireGuard is running
run("wg syncconf wg0 <(wg-quick strip wg0) 2>/dev/null", check=False)
# Get server public IP for endpoint
pub_ip = run("curl -s --max-time 5 https://api.ipify.org 2>/dev/null",
check=False).stdout.strip() or mgmt_ip
# Split tunnel — only management subnets go through VPN
mgmt_subnet = '.'.join(mgmt_ip.split('.')[:3]) + '.0/24'
vpn_subnet = f"{WG_SUBNET}.0/24"
client_conf = (
f"[Interface]\n"
f"PrivateKey = {c_priv}\n"
f"Address = {client_ip}/24\n"
f"DNS = {WG_SUBNET}.1\n\n"
f"[Peer]\n"
f"PublicKey = {server_pub}\n"
f"Endpoint = {pub_ip}:{WG_PORT}\n"
f"AllowedIPs = {mgmt_subnet}, {vpn_subnet}\n"
f"PersistentKeepalive = 25\n"
)
client_file = WG_CLIENT_DIR / f"{name}.conf"
client_file.write_text(client_conf)
client_file.chmod(0o600)
ok(f"Client config: {client_file}")
# Output QR
sep()
print(f"\n {bold('Scan this QR code with the WireGuard app:')}")
print(f" {dim('iOS / Android: search WireGuard in App Store / Play Store')}")
print(f" {dim('Desktop: https://www.wireguard.com/install/')}\n")
shown = False
if shutil.which("qrencode"):
r = run(f"qrencode -t ANSIUTF8 -r {client_file}", capture=True)
if r.returncode == 0:
print(r.stdout); shown = True
if not shown:
try:
import qrcode
qr = qrcode.QRCode()
qr.add_data(client_conf)
qr.make()
qr.print_ascii(invert=True)
shown = True
except ImportError:
pass
if not shown:
info(f"Config at {client_file} — import manually into WireGuard app")
sep()
print(f"""
{bold('Config file:')} {cyan(str(client_file))}
{bold('Desktop import:')} WireGuard → Import tunnel → {client_file.name}
{bold('Once connected, open:')}
{cyan(f'http://{mgmt_ip}:8765')} — switch manager UI
{cyan(mgmt_subnet)} — full management subnet accessible
""")
if ask_yn("Add another client (another device)?", False):
_wg_add_client(server_pub, mgmt_ip, wg_conf)
def _wg_opnsense(gateway_ip, mgmt_ip):
sep()
print(f"""
{bold('OPNsense WireGuard — semi-automated')}
Keys and peer config generated here.
Add the peer in OPNsense UI: VPN → WireGuard → Peers
""")
c_priv, c_pub = wg_genkey()
server_pub = ask("OPNsense WireGuard server public key")
client_ip = ask("VPN IP for this client", f"{WG_SUBNET}.2")
mgmt_subnet = '.'.join(mgmt_ip.split('.')[:3]) + '.0/24'
client_conf = (
f"[Interface]\n"
f"PrivateKey = {c_priv}\n"
f"Address = {client_ip}/24\n\n"
f"[Peer]\n"
f"PublicKey = {server_pub}\n"
f"Endpoint = {gateway_ip}:{WG_PORT}\n"
f"AllowedIPs = {mgmt_subnet}, {WG_SUBNET}.0/24\n"
f"PersistentKeepalive = 25\n"
)
WG_CLIENT_DIR.mkdir(exist_ok=True)
client_file = WG_CLIENT_DIR / "opnsense-client.conf"
client_file.write_text(client_conf)
client_file.chmod(0o600)
print(f"""
{bold('Add this peer in OPNsense → VPN → WireGuard → Peers:')}
Public Key: {cyan(c_pub)}
Allowed IPs: {cyan(f'{client_ip}/32')}
""")
sep()
shown = False
if shutil.which("qrencode"):
r = run(f"qrencode -t ANSIUTF8 -r {client_file}", capture=True)
if r.returncode == 0:
print(r.stdout); shown = True
if not shown:
try:
import qrcode
qr = qrcode.QRCode()
qr.add_data(client_conf)
qr.make()
qr.print_ascii(invert=True)
except ImportError:
info(f"Import {client_file} into WireGuard app manually")
ok(f"Client config: {client_file}")
def summary(host, use_docker, mgmt_ip):
ip = mgmt_ip or local_ip()
fqdn_file = CONF_DIR / "fqdn"
fqdn = fqdn_file.read_text().strip() if fqdn_file.exists() else None
url = f"https://{fqdn}" if (use_docker and fqdn) else f"http://{ip}:8765"
url_note = f"also http://{ip}:8765" if use_docker and fqdn else ""
method = "Docker + Caddy (HTTPS)" if use_docker else "Native Python / systemd"
logs = "docker compose logs -f" if use_docker else "journalctl -u switch-manager -f"
status = "docker compose ps" if use_docker else "systemctl status switch-manager"
print(f"""
{bold(cyan("══════════════════════════════════════════════════"))}
{bold(cyan(" Avaya ERS 5952 Switch Manager — Ready"))}
{bold(cyan("══════════════════════════════════════════════════"))}
{bold("URL (VLAN 99 or WireGuard connected):")}
{green(url)}
{dim(url_note) if url_note else ""}
{bold("Switch:")} {cyan(host)}
{bold("Running as:")} {method}
{(bold("DNS needed: ") + dim(f"Add {fqdn}{ip} in your router split DNS")) if use_docker and fqdn else ""}
{bold("TOTP secret:")} {dim(str(TOTP_FILE))} ← back this up
{bold("VPN configs:")} {dim(str(WG_CLIENT_DIR))}
{bold("Read-only:")} open the URL — no auth needed
{bold("To push:")} Authenticate → TOTP → push → auto-locks
Next set of changes needs a new TOTP code.
{bold("Console cable:")} only if you lock yourself out
{bold("Ethernet:")} handles all normal management
{bold("Logs:")} {logs}
{bold("Status:")} {status}
{bold(cyan("══════════════════════════════════════════════════"))}
""")
if ask_yn(f"Open {url} in browser?", True):
import webbrowser; time.sleep(1); webbrowser.open(url)
# ═══════════════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════════════
def main():
print(f"""
{bold(cyan("══════════════════════════════════════════════════"))}
{bold(cyan(" Avaya ERS 5952 Switch Manager — Setup"))}
{bold(cyan("══════════════════════════════════════════════════"))}
Browser-based management for your Avaya / Extreme
ERS 5952 switch. No CLI knowledge needed.
Copy this single file to your always-on management
computer (Raspberry Pi, HP T620 thin client, old
computer or laptop) and run:
python Avaya_5952_setup.py
{yellow("One manual step:")} loading the SSH key onto the
switch via console cable. Everything else is automated.
{dim("Compatible: ERS 5928, 5948, 5952, 5952-PWR+")}
{bold(cyan("══════════════════════════════════════════════════"))}
""")
if not ask_yn("Begin setup?", True): sys.exit(0)
write_files()
install_system_packages()
ensure_dialout_group()
install_deps()
create_conf_dir()
mgmt_ip, gateway_ip = "192.168.99.50", "192.168.99.1"
result = set_static_ip()
if result:
mgmt_ip, gateway_ip = result
use_docker = choose_deployment()
console_cable_guidance()
gen_ssh_key()
host, port, user = get_switch_details()
load_key_on_switch(host, user)
ssh_ok = test_ssh(host, port, user)
if not ssh_ok:
warn("SSH test failed — continuing, fix before using push feature")
pin_host_key(host)
setup_totp()
patch_backend(host, port, user)
build_frontend()
if use_docker:
setup_docker()
else:
install_service()
start_service(use_docker)
if ask_yn("Set up Control D DNS filtering?", False):
setup_ctrld([
{"id":10,"name":"Staff"},
{"id":20,"name":"Servers"},
{"id":30,"name":"IoT"},
{"id":40,"name":"Guest"},
{"id":50,"name":"Cameras"},
])
if ask_yn("Set up WireGuard VPN for remote access from laptop/phone?", True):
setup_wireguard(mgmt_ip, gateway_ip)
summary(host, use_docker, mgmt_ip)
if __name__ == "__main__":
main()