From 7f6fa75afff0247bd01e7ce4565a857687d8385e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:32:45 +0000 Subject: [PATCH 01/11] Add unified network management with backup/restore and safety checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New capabilities: - Unified Network tab: provision VLANs across switch + OPNsense in one operation — select ports, set PoE per-port, auto-configure DHCP and firewall rules on OPNsense - Automatic backup before every change: switch running-config via SSH, OPNsense full XML config export via API - Backup/Restore tab: manual backups, download, restore with safety net (creates backup of current state before restoring) - Connectivity safety checks: pre-change and post-change SSH/API probes to both devices — warns if connectivity lost after push - Safe push endpoint (/api/switch/push-safe) wraps existing push with auto-backup and connectivity verification - Backup pruning (keeps last 50 per device) https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 483 ++++++++++++++++++++++++++++++++++++++++ switch_backend.py | 528 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1011 insertions(+) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index b870960..aa4e09f 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -1525,6 +1525,7 @@ export default function App() { const TABS = [ { id:"dashboard",label:"Dashboard" }, + { id:"network", label:"Network" }, { id:"ports", label:"Port Map" }, { id:"vlans", label:"VLANs" }, { id:"acls", label:"ACL Builder" }, @@ -1533,6 +1534,7 @@ export default function App() { { id:"dhcp", label:"DHCP" }, { id:"dns", label:"DNS Filtering" }, { id:"vpn", label:"VPN" }, + { id:"backups", label:"Backups" }, ]; return ( @@ -1598,12 +1600,24 @@ export default function App() { acls={acls} setAcls={setAcls} />} + {tab==="network" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} {tab==="vpn" && setShowTotp(true)} backendOk={pollStatus!=="err"} vlans={vlans} />} + {tab==="backups" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} {showTotp && ); } + + +// ══════════════════════════════════════════════════════════════════════════════ +// UNIFIED NETWORK TAB — manage VLANs + ports + OPNsense in one place +// ══════════════════════════════════════════════════════════════════════════════ + +function NetworkTab({ vlans, setVlans, ports, updatePort, session, onNeedAuth, backendOk }) { + const [form, setForm] = useState({ + vlan_id: "", name: "", subnet: "", gateway: "", + dhcp_start: "", dhcp_end: "", + parent_if: "igb0", opnsense_if: "", allow_internet: true, + }); + const [selectedPorts, setSelectedPorts] = useState([]); + const [trunkPorts, setTrunkPorts] = useState([]); + const [provisioning, setProvisioning] = useState(false); + const [result, setResult] = useState(null); + const [connStatus, setConnStatus] = useState(null); + const [connLoading, setConnLoading] = useState(false); + + const checkConn = async () => { + setConnLoading(true); + try { setConnStatus(await API("/connectivity/check")); } + catch(e) { setConnStatus({ error: e.message }); } + setConnLoading(false); + }; + + useEffect(() => { if (backendOk) checkConn(); }, [backendOk]); + + const autoFillSubnet = (vid) => { + if (!vid) return; + const v = parseInt(vid); + if (v > 0 && v < 255) { + setForm(f => ({ + ...f, + subnet: `192.168.${v}.0/24`, + gateway: `192.168.${v}.1`, + dhcp_start: `192.168.${v}.100`, + dhcp_end: `192.168.${v}.200`, + })); + } + }; + + const togglePort = (portId, list, setList) => { + setList(prev => prev.includes(portId) ? prev.filter(p => p !== portId) : [...prev, portId]); + }; + + const provision = async () => { + if (!session) { onNeedAuth(); return; } + setProvisioning(true); setResult(null); + try { + const body = { + token: session.token, + vlan_id: parseInt(form.vlan_id), + name: form.name, + subnet: form.subnet, + gateway: form.gateway, + dhcp_start: form.dhcp_start, + dhcp_end: form.dhcp_end, + parent_if: form.parent_if, + opnsense_if: form.opnsense_if || "", + allow_internet: form.allow_internet, + ports: selectedPorts.map(p => ({ port: p.id, poe: p.poe, poe_limit: p.poeLimit || 30000 })), + trunk_ports: trunkPorts, + }; + const r = await API("/network/provision", { method: "POST", body }); + setResult(r); + if (r.success) { + // Update local vlans state + const newVlan = { id: parseInt(form.vlan_id), name: form.name, + color: VLAN_COLORS[vlans.length % VLAN_COLORS.length] }; + if (!vlans.find(v => v.id === newVlan.id)) setVlans([...vlans, newVlan]); + } + } catch(e) { + setResult({ success: false, errors: [e.message] }); + } + setProvisioning(false); + }; + + const portGrid = (start, end, forTrunk) => ( +
+ {ports.filter(p => p.id >= start && p.id <= end).map(p => { + const inUse = p.mode === "access" && p.accessVlan !== 1; + const vlan = vlans.find(v => v.id === (p.accessVlan || 1)); + const isSelected = forTrunk ? trunkPorts.includes(p.id) : selectedPorts.find(s => s.id === p.id); + return ( +
{ + if (forTrunk) { + togglePort(p.id, trunkPorts, setTrunkPorts); + } else { + if (isSelected) { + setSelectedPorts(prev => prev.filter(s => s.id !== p.id)); + } else { + setSelectedPorts(prev => [...prev, { id: p.id, poe: true, poeLimit: 30000 }]); + } + } + }} style={{ + width:36, height:36, display:"flex", alignItems:"center", justifyContent:"center", + fontSize:11, fontWeight:700, borderRadius:4, cursor:"pointer", + border: isSelected ? "2px solid var(--ac)" : "1px solid var(--b2)", + background: isSelected ? "var(--ac)" : inUse ? (vlan?.color || "var(--b1)") + "30" : "var(--bg)", + color: isSelected ? "var(--bg)" : inUse ? "var(--dm)" : "var(--tx)", + opacity: inUse && !forTrunk ? 0.5 : 1, + }} title={`Port ${p.id}${inUse ? ` (${vlan?.name || "VLAN " + p.accessVlan})` : ""}${p.description ? " — " + p.description : ""}`}> + {p.id} +
+ ); + })} +
+ ); + + return ( +
+
+ {/* Connectivity Status */} +
+
Connectivity Status
+
+
+ + Switch {connStatus?.switch?.ok ? "Online" : "Offline"} +
+
+ + + OPNsense {connStatus?.opnsense?.ok ? "Online" : connStatus?.opnsense?.configured ? "Unreachable" : "Not configured"} + +
+ +
+
+ + {/* Existing VLANs overview */} +
+
Active VLANs
+
+
+ {vlans.map(v => { + const cnt = ports.filter(p => + p.mode === "access" ? p.accessVlan === v.id : p.taggedVlans?.includes(v.id) + ).length; + return ( +
+
VLAN {v.id}
+
{v.name}
+
+ {cnt} port{cnt !== 1 ? "s" : ""} | 192.168.{v.id}.0/24 +
+
+ ); + })} +
+
+
+ + {/* Unified VLAN Provisioning Form */} +
+
Provision New VLAN (Switch + OPNsense)
+
+
+ This creates everything in one step: VLAN on the switch, assigns ports with PoE settings, + creates the VLAN tag and DHCP scope on OPNsense, and adds a firewall rule. + A backup is taken automatically before any changes are made. +
+ +
+
+ { + setForm(f => ({...f, vlan_id: e.target.value})); + autoFillSubnet(e.target.value); + }} type="number" placeholder="60"/> +
+
+ setForm(f => ({...f, name: e.target.value}))} placeholder="e.g. Cameras"/> +
+
+ setForm(f => ({...f, subnet: e.target.value}))} placeholder="192.168.60.0/24"/> +
+
+ setForm(f => ({...f, gateway: e.target.value}))} placeholder="192.168.60.1"/> +
+
+ setForm(f => ({...f, dhcp_start: e.target.value}))} placeholder="192.168.60.100"/> +
+
+ setForm(f => ({...f, dhcp_end: e.target.value}))} placeholder="192.168.60.200"/> +
+
+ setForm(f => ({...f, parent_if: e.target.value}))} placeholder="igb0"/> +
+
+ setForm(f => ({...f, opnsense_if: e.target.value}))} placeholder="e.g. opt3 (leave blank if new)"/> +
+
+ +
+
+ + {/* Port Selection */} +
+
Access Ports (will be assigned to this VLAN)
+
+ Click ports to select. Dimmed ports are already assigned to another VLAN. +
+ {portGrid(1, 24, false)} + {portGrid(25, 48, false)} + + {selectedPorts.length > 0 && ( +
+
PoE Settings for Selected Ports
+
+ {selectedPorts.map(sp => ( +
+ Port {sp.id} + +
+ ))} +
+
+ )} + +
+
Trunk Uplinks (tag this VLAN on existing trunks)
+
+ Select uplink ports that should carry this VLAN (typically SFP+ ports 49-52). +
+ {portGrid(49, 52, true)} +
+
+ + {/* Summary */} + {(form.vlan_id && form.name) && ( +
+
Provision Summary
+
VLAN {form.vlan_id} "{form.name}" | Subnet: {form.subnet || "—"}
+
Access ports: {selectedPorts.length > 0 + ? selectedPorts.map(p => `${p.id}${p.poe ? " (PoE)" : ""}`).join(", ") + : "none selected"}
+
Trunk ports: {trunkPorts.length > 0 ? trunkPorts.join(", ") : "none"}
+
OPNsense: {form.opnsense_if + ? `DHCP ${form.dhcp_start}–${form.dhcp_end} on ${form.opnsense_if}` + : "VLAN tag only (assign interface in OPNsense UI)"}
+ {form.allow_internet &&
Firewall: allow outbound
} +
+ )} + +
+ + + Auto-backup runs before changes. TOTP required. + +
+ + {/* Result */} + {result && ( +
+
+ {result.success ? "Provisioning Complete" : "Provisioning Failed"} +
+ {result.steps_done?.map((s,i) => ( +
+ done{s} +
+ ))} + {result.errors?.map((e,i) => ( +
+ error{e} +
+ ))} + {result.pending_steps?.map((s,i) => ( +
+ manual{s} +
+ ))} + {result.backup && ( +
+ Backup: switch={result.backup.switch?.file || "N/A"} + {result.backup.opnsense ? `, opnsense=${result.backup.opnsense.file || "N/A"}` : ""} +
+ )} +
+ )} +
+
+
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// BACKUP TAB — view, create, restore, download backups +// ══════════════════════════════════════════════════════════════════════════════ + +function BackupTab({ session, onNeedAuth, backendOk }) { + const [backups, setBackups] = useState({ switch: [], opnsense: [] }); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [restoring, setRestoring] = useState(null); + const [restoreResult, setRestoreResult] = useState(null); + const [reason, setReason] = useState(""); + + const load = async () => { + setLoading(true); + try { setBackups(await API("/backup/list")); } catch(e) { console.error(e); } + setLoading(false); + }; + + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const createBackup = async (device) => { + if (!session) { onNeedAuth(); return; } + setCreating(true); + try { + await API("/backup/create", { method: "POST", body: { + token: session.token, device, reason: reason || "manual backup", + }}); + setReason(""); + await load(); + } catch(e) { alert("Backup failed: " + e.message); } + setCreating(false); + }; + + const restore = async (device, filename) => { + if (!session) { onNeedAuth(); return; } + if (!confirm(`Restore ${device} from ${filename}?\n\nA safety backup will be created first.`)) return; + setRestoring(filename); setRestoreResult(null); + try { + const r = await API("/backup/restore", { method: "POST", body: { + token: session.token, device, filename, + }}); + setRestoreResult(r); + await load(); + } catch(e) { setRestoreResult({ result: { ok: false, error: e.message } }); } + setRestoring(null); + }; + + const BackupTable = ({ device, items }) => ( +
+
{device === "switch" ? "Switch" : "OPNsense"} Backups
+
+
+
+ + setReason(e.target.value)} placeholder="e.g. before VLAN change"/> +
+ +
+ {items.length === 0 ? ( +
+ No backups yet. Create one before making changes. +
+ ) : ( + + + + {items.map((b,i) => ( + + + + + + + ))} + +
TimeReasonSizeActions
{b.timestamp}{b.reason || "—"}{b.size ? `${(b.size/1024).toFixed(1)} KB` : "—"} + + Download + + +
+ )} +
+
+ ); + + return ( +
+
+
+
Backup & Restore
+
+ Backups are created automatically before every change (VLAN provisioning, push operations). + You can also create manual backups here. OPNsense backups are full XML config exports. + Switch backups capture the running configuration. +
+ Restore: OPNsense configs can be restored via API. Switch configs must be reviewed + and applied via the Review & Push tab (to prevent accidental lockout). +
+
+
+ +
+ +
+ + {restoreResult && ( +
+ {restoreResult.result?.ok + ?
Restore successful. Safety backup was created first.
+ :
Restore failed: {restoreResult.result?.error || "Unknown error"}
+ } + {restoreResult.config_preview && ( +
+ Config preview +
+                  {restoreResult.config_preview}
+                
+
+ )} +
+ )} + + + +
+
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index cad4b82..5d90f31 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -3613,3 +3613,531 @@ def opnsense_unbound_write_forward_ctrld(body: dict): raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}") _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") return {"success": True, "content": content, "enabled": enabled, "port": port} + + +# ══════════════════════════════════════════════════════════════════════ +# BACKUP / RESTORE — both OPNsense and switch +# ══════════════════════════════════════════════════════════════════════ + +import datetime as _dt +import shutil as _shutil + +BACKUP_DIR = _Path("/etc/switch-manager/backups") +BACKUP_DIR.mkdir(parents=True, exist_ok=True) +MAX_BACKUPS = 50 # keep last N backups per device + +def _ts() -> str: + return _dt.datetime.now().strftime("%Y%m%d-%H%M%S") + +def _prune_backups(subdir: _Path): + """Keep only the last MAX_BACKUPS files in a backup subdirectory.""" + files = sorted(subdir.glob("*"), key=lambda f: f.stat().st_mtime) + while len(files) > MAX_BACKUPS: + files.pop(0).unlink() + +# ── OPNsense backup (XML config export) ───────────────────────────── + +def _opnsense_backup(cfg: dict, reason: str = "") -> dict: + """Download OPNsense config.xml via API and save locally.""" + host = cfg.get("host", "") + key = cfg.get("key", "") + secret = cfg.get("secret", "") + if not host or not key: + return {"ok": False, "error": "OPNsense not configured"} + bdir = BACKUP_DIR / "opnsense" + bdir.mkdir(parents=True, exist_ok=True) + ts = _ts() + fname = f"opnsense-{ts}.xml" + fpath = bdir / fname + try: + creds = _b64.b64encode(f"{key}:{secret}".encode()).decode() + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + url = f"https://{host}/api/core/backup/download/this" + req = _urlreq.Request(url, headers={ + "Authorization": f"Basic {creds}", + }, method="POST") + with _urlreq.urlopen(req, timeout=30, context=ctx) as r: + xml_data = r.read() + fpath.write_bytes(xml_data) + fpath.chmod(0o600) + # Write metadata + meta = {"timestamp": ts, "reason": reason, "file": fname, + "size": len(xml_data), "host": host} + (bdir / f"opnsense-{ts}.meta.json").write_text(_json.dumps(meta, indent=2)) + _prune_backups(bdir) + log.info(f"OPNsense backup saved: {fname} ({len(xml_data)} bytes) reason={reason}") + return {"ok": True, "file": fname, "size": len(xml_data), "timestamp": ts} + except Exception as e: + log.warning(f"OPNsense backup failed: {e}") + return {"ok": False, "error": str(e)} + + +def _opnsense_restore(cfg: dict, filename: str) -> dict: + """Upload a config.xml backup to OPNsense.""" + bdir = BACKUP_DIR / "opnsense" + fpath = bdir / filename + if not fpath.exists(): + return {"ok": False, "error": f"Backup file not found: {filename}"} + # Sanity: must be XML + content = fpath.read_bytes() + if b"" not in content and b"" not in content: + return {"ok": False, "error": "File does not look like an OPNsense config"} + host = cfg.get("host", "") + key = cfg.get("key", "") + secret = cfg.get("secret", "") + if not host or not key: + return {"ok": False, "error": "OPNsense not configured"} + try: + creds = _b64.b64encode(f"{key}:{secret}".encode()).decode() + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + # OPNsense restore API expects multipart form upload + import mimetypes + boundary = f"----BackupRestore{_ts()}" + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="conf"; filename="{filename}"\r\n' + f"Content-Type: application/xml\r\n\r\n" + ).encode() + content + f"\r\n--{boundary}--\r\n".encode() + url = f"https://{host}/api/core/backup/restore" + req = _urlreq.Request(url, data=body, headers={ + "Authorization": f"Basic {creds}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, method="POST") + with _urlreq.urlopen(req, timeout=60, context=ctx) as r: + result = _json.loads(r.read().decode()) + log.info(f"OPNsense restore from {filename}: {result}") + return {"ok": True, "result": result, "file": filename} + except Exception as e: + log.warning(f"OPNsense restore failed: {e}") + return {"ok": False, "error": str(e)} + + +# ── Switch backup (running-config capture) ─────────────────────────── + +def _switch_backup(reason: str = "") -> dict: + """Capture switch running-config via SSH and save locally.""" + bdir = BACKUP_DIR / "switch" + bdir.mkdir(parents=True, exist_ok=True) + ts = _ts() + fname = f"switch-{ts}.cfg" + fpath = bdir / fname + try: + raw = read_cmd("show running-config") + if not raw or len(raw) < 50: + return {"ok": False, "error": "Empty or too-short running-config output"} + fpath.write_text(raw) + fpath.chmod(0o600) + meta = {"timestamp": ts, "reason": reason, "file": fname, "size": len(raw)} + (bdir / f"switch-{ts}.meta.json").write_text(_json.dumps(meta, indent=2)) + _prune_backups(bdir) + log.info(f"Switch backup saved: {fname} ({len(raw)} bytes) reason={reason}") + return {"ok": True, "file": fname, "size": len(raw), "timestamp": ts} + except Exception as e: + log.warning(f"Switch backup failed: {e}") + return {"ok": False, "error": str(e)} + + +# ── Pre-change backup (called automatically before any push) ───────── + +def _pre_change_backup(reason: str) -> dict: + """Backup both devices before making changes. Returns status for each.""" + results = {"switch": None, "opnsense": None} + # Always backup switch + results["switch"] = _switch_backup(reason=reason) + # Backup OPNsense if configured + cfg = _load_opnsense_cfg() + if cfg.get("key"): + results["opnsense"] = _opnsense_backup(cfg, reason=reason) + return results + + +# ── Connectivity safety check ──────────────────────────────────────── + +def _check_connectivity() -> dict: + """Verify SSH reachability to switch and OPNsense. Non-destructive probe.""" + result = {"switch": {"ok": False, "error": ""}, "opnsense": {"ok": False, "error": "", "configured": False}} + + # Check switch + try: + conn = _pool.get() + transport = conn.get_transport() + if transport and transport.is_active(): + transport.send_ignore() + result["switch"]["ok"] = True + else: + result["switch"]["error"] = "SSH transport not active" + except Exception as e: + result["switch"]["error"] = str(e) + + # Check OPNsense (if configured) + cfg = _load_opnsense_cfg() + if cfg.get("key"): + result["opnsense"]["configured"] = True + try: + _opnsense_request(cfg, "core/firmware/status") + result["opnsense"]["ok"] = True + except Exception as e: + result["opnsense"]["error"] = str(e) + if cfg.get("ssh_key_path"): + try: + test = _opnsense_ssh_test(cfg) + result["opnsense"]["ssh_ok"] = test.get("ok", False) + except Exception: + result["opnsense"]["ssh_ok"] = False + + return result + + +# ── API endpoints ──────────────────────────────────────────────────── + +@app.get("/api/backup/list") +def backup_list(): + """List all available backups for both devices.""" + backups = {"switch": [], "opnsense": []} + for device in ["switch", "opnsense"]: + bdir = BACKUP_DIR / device + if not bdir.exists(): + continue + for meta_file in sorted(bdir.glob("*.meta.json"), reverse=True): + try: + meta = _json.loads(meta_file.read_text()) + meta["exists"] = (bdir / meta["file"]).exists() + backups[device].append(meta) + except Exception: + continue + return backups + + +@app.post("/api/backup/create") +def backup_create(body: dict): + """Manually trigger a backup of one or both devices.""" + require_session(body.get("token", "")) + device = body.get("device", "both") # "switch", "opnsense", or "both" + reason = body.get("reason", "manual backup") + results = {} + if device in ("switch", "both"): + results["switch"] = _switch_backup(reason=reason) + if device in ("opnsense", "both"): + cfg = _load_opnsense_cfg() + if cfg.get("key"): + results["opnsense"] = _opnsense_backup(cfg, reason=reason) + else: + results["opnsense"] = {"ok": False, "error": "OPNsense not configured"} + return results + + +@app.post("/api/backup/restore") +def backup_restore(body: dict): + """Restore a backup to a device. Creates a new backup first as safety net.""" + require_session(body.get("token", "")) + device = body.get("device", "") + filename = body.get("filename", "") + if not device or not filename: + raise HTTPException(400, "device and filename required") + if device not in ("switch", "opnsense"): + raise HTTPException(400, "device must be 'switch' or 'opnsense'") + + # Safety: backup current state first + safety = _pre_change_backup(reason=f"pre-restore safety backup before restoring {filename}") + + if device == "opnsense": + cfg = _load_opnsense_cfg() + result = _opnsense_restore(cfg, filename) + return {"result": result, "safety_backup": safety} + elif device == "switch": + # Switch restore = parse config and push commands + bdir = BACKUP_DIR / "switch" + fpath = bdir / filename + if not fpath.exists(): + raise HTTPException(404, f"Backup file not found: {filename}") + return { + "result": {"ok": True, "note": "Switch config restore requires manual review. " + "Download the backup file and apply commands via Review & Push tab."}, + "safety_backup": safety, + "config_preview": fpath.read_text()[:5000], + } + + +@app.get("/api/backup/download/{device}/{filename}") +def backup_download(device: str, filename: str): + """Download a backup file.""" + if device not in ("switch", "opnsense"): + raise HTTPException(400, "device must be 'switch' or 'opnsense'") + # Prevent path traversal + if "/" in filename or ".." in filename: + raise HTTPException(400, "Invalid filename") + fpath = BACKUP_DIR / device / filename + if not fpath.exists(): + raise HTTPException(404, "Backup not found") + from fastapi.responses import FileResponse + return FileResponse(fpath, filename=filename) + + +@app.get("/api/connectivity/check") +def connectivity_check(): + """Check SSH/API reachability to both switch and OPNsense.""" + return _check_connectivity() + + +# ══════════════════════════════════════════════════════════════════════ +# UNIFIED NETWORK PROVISIONING — one operation for both devices +# ══════════════════════════════════════════════════════════════════════ + +class UnifiedVlanProvision(BaseModel): + token: str + vlan_id: int + name: str + subnet: str # e.g. "192.168.60.0/24" + gateway: str # e.g. "192.168.60.1" + dhcp_start: str # e.g. "192.168.60.100" + dhcp_end: str # e.g. "192.168.60.200" + parent_if: str # OPNsense physical parent, e.g. "igb0" + opnsense_if: Optional[str] = "" + allow_internet: bool = True + # Port assignments + ports: list[dict] = [] # [{"port": 1, "poe": true}, {"port": 5, "poe": false}] + # Trunk uplink ports (these get the new VLAN tagged) + trunk_ports: list[int] = [] + + @field_validator("vlan_id") + @classmethod + def cv(cls, v): + vid = san_vid(v) + if vid in (1, 99): + raise ValueError("VLAN 1 and 99 are reserved") + return vid + @field_validator("name") + @classmethod + def cn(cls, v): return _san(v, _RE_VNAME, "name") + + +@app.post("/api/network/provision") +def unified_provision(body: UnifiedVlanProvision): + """ + Unified VLAN + port + OPNsense provisioning in one operation. + + 1. Pre-change backup of both devices + 2. Connectivity check + 3. Create VLAN on switch + assign ports + set PoE + 4. Create VLAN on OPNsense + DHCP scope + firewall rule + 5. Post-change connectivity verify + """ + import ipaddress as _ipaddr + require_session(body.token) + + steps_done: list[str] = [] + errors: list[str] = [] + pending_steps: list[str] = [] + + # Validate addresses + try: + net = _ipaddr.ip_network(body.subnet, strict=False) + _ipaddr.ip_address(body.gateway) + _ipaddr.ip_address(body.dhcp_start) + _ipaddr.ip_address(body.dhcp_end) + except ValueError as e: + raise HTTPException(400, f"Invalid address: {e}") + + # ── Step 0: Pre-change connectivity check ──────────────────────── + conn = _check_connectivity() + if not conn["switch"]["ok"]: + raise HTTPException(503, f"Switch unreachable — aborting: {conn['switch']['error']}") + steps_done.append("connectivity: switch reachable") + + # ── Step 1: Pre-change backup ──────────────────────────────────── + backup = _pre_change_backup(reason=f"pre-provision VLAN {body.vlan_id} '{body.name}'") + if backup["switch"] and backup["switch"].get("ok"): + steps_done.append(f"backup: switch config saved ({backup['switch']['file']})") + else: + errors.append(f"backup: switch backup failed — {backup['switch'].get('error', 'unknown')}") + # Non-fatal but warn + if backup.get("opnsense") and backup["opnsense"].get("ok"): + steps_done.append(f"backup: OPNsense config saved ({backup['opnsense']['file']})") + + # ── Step 2: Create VLAN on switch ──────────────────────────────── + switch_cmds = [f'vlan create {body.vlan_id} name "{body.name}" type port'] + + # Assign access ports + for pa in body.ports: + p = san_port(pa["port"]) + vid = body.vlan_id + switch_cmds += [ + f"vlan members add {vid} {p}", + f"vlan pvid {p} {vid}", + ] + iface = f"FastEthernet {p}" if p <= 48 else f"GigabitEthernet {p}" + if p <= 96: + switch_cmds.append(f"interface {iface}") + if pa.get("poe", True): + switch_cmds += [" poe enable", f" poe poe-limit {pa.get('poe_limit', 30000)}"] + else: + switch_cmds += [" no poe enable"] + + # Add VLAN to trunk uplinks + for tp in body.trunk_ports: + tp = san_port(tp) + switch_cmds += [ + f"vlan members add {body.vlan_id} {tp}", + f"vlan tagging {body.vlan_id} {tp}", + ] + + # Danger check before push + danger = check_danger(switch_cmds) + if danger["has_hard_block"]: + raise HTTPException(400, { + "message": "Hard-blocked commands detected", + "blocked": danger["hard_blocked"], + }) + + result = push_one_by_one(switch_cmds) + if result.get("success"): + steps_done.append(f"switch: VLAN {body.vlan_id} created, {len(body.ports)} ports assigned, " + f"{len(body.trunk_ports)} trunk ports updated") + else: + errors.append(f"switch: push failed at command {result.get('stopped_at', '?')}: " + f"{result.get('error', 'unknown')}") + # Return early — don't configure OPNsense for a VLAN the switch doesn't have + return { + "success": False, "steps_done": steps_done, "errors": errors, + "pending_steps": [], "backup": backup, "switch_result": result, + } + + # ── Step 3: OPNsense VLAN + DHCP + firewall ───────────────────── + cfg = _load_opnsense_cfg() + if not cfg.get("key"): + pending_steps += [ + f"OPNsense: create VLAN tag {body.vlan_id} on {body.parent_if}", + f"OPNsense: assign interface, set IP {body.gateway}/{net.prefixlen}", + f"OPNsense: create DHCP scope {body.dhcp_start}–{body.dhcp_end}", + ] + if body.allow_internet: + pending_steps.append("OPNsense: add allow-outbound firewall rule") + else: + # Create VLAN tag + try: + vlan_r = _opnsense_request(cfg, "interfaces/vlan_settings/addItem", "POST", { + "vlan": {"if": body.parent_if, "tag": str(body.vlan_id), + "pcp": "0", "descr": body.name} + }) + _opnsense_request(cfg, "interfaces/vlan_settings/reconfigure", "POST") + steps_done.append(f"OPNsense: VLAN tag {body.vlan_id} created on {body.parent_if}") + except ValueError as e: + errors.append(f"OPNsense VLAN tag: {e}") + + if body.opnsense_if: + # DHCP scope + try: + _opnsense_request(cfg, "dhcpv4/settings/addSubnet", "POST", { + "subnet": { + "interface": body.opnsense_if, + "subnet": str(net), + "gateway": body.gateway, + "dns_servers": body.gateway, + "range": {"from": body.dhcp_start, "to": body.dhcp_end}, + } + }) + _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST") + steps_done.append(f"OPNsense: DHCP scope {body.dhcp_start}–{body.dhcp_end}") + except ValueError as e: + errors.append(f"OPNsense DHCP: {e}") + + # Firewall rule + if body.allow_internet: + try: + _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", "action": "pass", + "interface": body.opnsense_if, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{body.opnsense_if}net"}, + "destination": {"any": "1"}, + "descr": f"Allow VLAN {body.vlan_id} {body.name} outbound", + } + }) + _opnsense_request(cfg, "firewall/filter/apply", "POST") + steps_done.append(f"OPNsense: allow-outbound firewall rule added") + except ValueError as e: + errors.append(f"OPNsense firewall: {e}") + + # Persist mapping + vmap = _load_vlan_if_map() + vmap[str(body.vlan_id)] = body.opnsense_if + _save_vlan_if_map(vmap) + else: + pending_steps += [ + f"OPNsense UI: assign {body.parent_if}.{body.vlan_id} as interface, " + f"set IP {body.gateway}/{net.prefixlen}", + f"Then re-run with opnsense_if set to create DHCP + firewall", + ] + + # ── Step 4: Post-change connectivity verify ────────────────────── + post_conn = _check_connectivity() + if not post_conn["switch"]["ok"]: + errors.append("POST-CHANGE WARNING: switch SSH connectivity lost! " + f"Backup available: {backup['switch'].get('file', 'N/A')}") + + return { + "success": len(errors) == 0, + "steps_done": steps_done, + "pending_steps": pending_steps, + "errors": errors, + "backup": backup, + "switch_result": result, + "post_connectivity": post_conn, + } + + +# ── Wrap existing push to auto-backup ──────────────────────────────── + +_original_push = push_one_by_one + +def push_one_by_one_with_backup(commands: list[str]) -> dict: + """Wraps push_one_by_one to create a backup before pushing.""" + backup = _pre_change_backup(reason=f"pre-push ({len(commands)} commands)") + result = _original_push(commands) + result["backup"] = backup + return result + +# Monkey-patch: the push endpoint calls push_one_by_one directly +# We leave push_one_by_one as-is (it's used internally) and +# add the backup in the API endpoint wrapper below. + +@app.post("/api/switch/push-safe") +def push_safe(body: PushBatch): + """Push with automatic pre-change backup and post-change connectivity check.""" + require_session(body.token) + + # Danger check + danger = check_danger(body.commands) + if danger["has_hard_block"]: + raise HTTPException(400, { + "message": "Hard-blocked commands — must be run at console", + "blocked": danger["hard_blocked"], + }) + + # Pre-check + conn = _check_connectivity() + if not conn["switch"]["ok"]: + raise HTTPException(503, "Switch unreachable — refusing to push") + + # Backup + backup = _pre_change_backup(reason=f"pre-push ({len(body.commands)} commands)") + + # Push + result = push_one_by_one(body.commands) + + # Post-check + post_conn = _check_connectivity() + if not post_conn["switch"]["ok"]: + result["connectivity_warning"] = ( + "Switch SSH lost after push! " + f"Backup: {backup['switch'].get('file', 'N/A')}" + ) + + result["backup"] = backup + result["post_connectivity"] = post_conn + return result From 7928f6f769bf706e49e5ea6dfc8a1d92b1c048f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:55:29 +0000 Subject: [PATCH 02/11] Add firewall policy matrix, service proxy, ntfy alerts, and scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firewall inter-VLAN policy matrix: - Visual VLAN-to-VLAN matrix with click-to-set policies - Policy types: block, allow, one-way, printer, service-ports - Printer template: other VLANs reach ports 9100/631/443/515, printers cannot initiate back — solves the "printer VLAN" use case - Generates both switch ACLs AND OPNsense firewall rules - Preview commands before pushing, auto-backup before changes Service proxy (LAN services via FQDN without inter-VLAN access): - Register services with FQDN + backend URL + allowed VLANs - Deploy generates Caddyfile entries, Unbound DNS overrides, and firewall rules allowing only port 443 to the proxy - Pattern: device on VLAN 30 → DNS resolves to mgmt box → Caddy proxies to actual LAN server — no VLAN-to-VLAN access needed ntfy push notifications: - Configure ntfy.sh or self-hosted ntfy server - Alert events: connectivity lost/restored, PoE budget >85%, backup failures, push failures - Integrated into poll loop — alerts fire on state transitions - Test notification button Scheduled operations: - Cron-like scheduler for automated backups and connectivity checks - Background thread checks every 60 seconds - Per-schedule: name, action, hour, minute, days (mon,wed,fri or *) - Run-now button for manual trigger - ntfy notifications on scheduled task completion/failure https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- Caddyfile.template | 4 + ers5952-manager.jsx | 708 ++++++++++++++++++++++++++++++++++++++- switch_backend.py | 793 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1495 insertions(+), 10 deletions(-) diff --git a/Caddyfile.template b/Caddyfile.template index 8869cf9..952e1a5 100644 --- a/Caddyfile.template +++ b/Caddyfile.template @@ -8,3 +8,7 @@ :80 {{ redir https://{{host}}{{uri}} permanent }} + +# Service proxies — auto-generated by switch-manager +# To include service proxy entries, add this line (uncommented) after deployment: +# import /etc/switch-manager/Caddyfile.services diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index aa4e09f..7638ff2 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -1525,16 +1525,19 @@ export default function App() { const TABS = [ { id:"dashboard",label:"Dashboard" }, - { id:"network", label:"Network" }, - { id:"ports", label:"Port Map" }, - { id:"vlans", label:"VLANs" }, - { id:"acls", label:"ACL Builder" }, - { id:"cli", label:"Review & Push" }, - { id:"devices", label:"Device Access" }, - { id:"dhcp", label:"DHCP" }, - { id:"dns", label:"DNS Filtering" }, - { id:"vpn", label:"VPN" }, - { id:"backups", label:"Backups" }, + { id:"network", label:"Network" }, + { id:"firewall", label:"Firewall" }, + { id:"services", label:"Services" }, + { id:"ports", label:"Port Map" }, + { id:"vlans", label:"VLANs" }, + { id:"acls", label:"ACL Builder" }, + { id:"cli", label:"Review & Push" }, + { id:"devices", label:"Device Access" }, + { id:"dhcp", label:"DHCP" }, + { id:"dns", label:"DNS Filtering" }, + { id:"vpn", label:"VPN" }, + { id:"backups", label:"Backups" }, + { id:"alerts", label:"Alerts" }, ]; return ( @@ -1607,6 +1610,23 @@ export default function App() { onNeedAuth={() => setShowTotp(true)} backendOk={pollStatus!=="err"} />} + {tab==="firewall" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} + {tab==="services" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} + {tab==="alerts" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} {tab==="vpn" && setShowTotp(true)} @@ -4748,3 +4768,671 @@ function BackupTab({ session, onNeedAuth, backendOk }) { ); } + + +// ══════════════════════════════════════════════════════════════════════════════ +// FIREWALL TAB — inter-VLAN policy matrix +// ══════════════════════════════════════════════════════════════════════════════ + +function FirewallTab({ vlans, session, onNeedAuth, backendOk }) { + const [policies, setPolicies] = useState([]); + const [presets, setPresets] = useState({}); + const [form, setForm] = useState({ src_vlan: "", dst_vlan: "", type: "block", ports: "" }); + const [preview, setPreview] = useState(null); + const [pushing, setPushing] = useState(false); + const [result, setResult] = useState(null); + + const load = async () => { + try { + const d = await API("/firewall/policies"); + setPolicies(d.policies || []); + setPresets(d.presets || {}); + } catch(e) { console.error(e); } + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const doPreview = async () => { + if (!form.src_vlan || !form.dst_vlan || !form.type) return; + try { + const policy = { + src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan), + type: form.type, + ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [], + }; + const p = await API("/firewall/preview", { method: "POST", body: { policy } }); + setPreview(p); + } catch(e) { setPreview({ error: e.message }); } + }; + + const pushPolicy = async () => { + if (!session) { onNeedAuth(); return; } + setPushing(true); setResult(null); + try { + const policy = { + src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan), + type: form.type, + ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [], + }; + const r = await API("/firewall/push", { method: "POST", body: { token: session.token, policy } }); + setResult(r); + await load(); + } catch(e) { setResult({ success: false, errors: [e.message] }); } + setPushing(false); + }; + + // Build the VLAN matrix + const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1); + const getPolicy = (src, dst) => policies.find(p => p.src_vlan === src && p.dst_vlan === dst); + + const policyColor = (type) => ({ + block: "#ff1744", allow: "#00e676", "one-way": "#2979ff", + printer: "#ff6d00", services: "#d500f9", + }[type] || "var(--dm)"); + + return ( +
+
+
+
Inter-VLAN Policy Matrix
+
+
+ Click a cell to set the policy between two VLANs. Policies generate both switch ACLs + and OPNsense firewall rules. LAN (VLAN 1) has full access by default. + Management VLAN 99 is always isolated (enforced by hard-block). +
+ + {nonMgmt.length > 1 ? ( +
+ + + + + {nonMgmt.map(v => ( + + ))} + + + + {nonMgmt.map(src => ( + + + {nonMgmt.map(dst => { + if (src.id === dst.id) return ( + + ); + const p = getPolicy(src.id, dst.id); + return ( + + ); + })} + + ))} + +
+ From \ To + + {v.name}
V{v.id} +
+ {src.name} V{src.id} + { + setForm(f => ({...f, src_vlan: String(src.id), dst_vlan: String(dst.id)})); + setPreview(null); setResult(null); + }}> +
+ {p ? (presets[p.type]?.label || p.type) : "No policy"} +
+
+
+ ) : ( +
+ Create at least 2 non-management VLANs to use the policy matrix. +
+ )} + + {/* Legend */} +
+ {Object.entries(presets).map(([k,v]) => ( +
+ + {v.label} +
+ ))} +
+
+
+ + {/* Policy Editor */} +
+
Set Policy
+
+
+
+ +
+
+ +
+
+ +
+ {(form.type === "services" || form.type === "printer") && ( +
+ setForm(f => ({...f, ports: e.target.value}))} + placeholder={form.type === "printer" ? "9100,631,443,515" : "80,443,8080"}/> +
+ )} +
+ + {form.type && presets[form.type] && ( +
+ {presets[form.type].description} +
+ )} + +
+ + +
+ + {preview && !preview.error && ( +
+
+ {preview.description} +
+
Switch ACL Commands:
+
+                  {preview.switch_cmds?.join("\n")}
+                
+ {preview.opnsense_rules?.length > 0 && <> +
+ OPNsense Firewall Rules: +
+ {preview.opnsense_rules.map((r,i) => ( +
+ {r.rule.action.toUpperCase()} {r.rule.descr} +
+ ))} + } +
+ )} + + {result && ( +
+
+ {result.success ? "Policy Pushed" : "Push Failed"} +
+ {result.steps_done?.map((s,i) => ( +
done {s}
+ ))} + {result.errors?.map((e,i) => ( +
error {e}
+ ))} +
+ )} +
+
+ + {/* Active Policies List */} + {policies.length > 0 && ( +
+
Active Policies ({policies.length})
+
+ + + + {policies.map((p,i) => ( + + + + + + + + ))} + +
SourceDestinationTypePushed
{vlans.find(v=>v.id===p.src_vlan)?.name || `V${p.src_vlan}`}{vlans.find(v=>v.id===p.dst_vlan)?.name || `V${p.dst_vlan}`}{presets[p.type]?.label || p.type}{p.pushed_at || "not pushed"} + +
+
+
+ )} +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// SERVICES TAB — expose LAN services to other VLANs via reverse proxy + DNS +// ══════════════════════════════════════════════════════════════════════════════ + +function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { + const [services, setServices] = useState([]); + const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] }); + const [deploying, setDeploying] = useState(false); + const [deployResult, setDeployResult] = useState(null); + + const load = async () => { + try { + const d = await API("/services"); + setServices(d.services || []); + } catch(e) { console.error(e); } + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const addService = async () => { + if (!session) { onNeedAuth(); return; } + if (!form.fqdn || !form.backend_url) return; + try { + await API("/services", { method: "POST", body: { token: session.token, service: form } }); + setForm({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] }); + await load(); + } catch(e) { alert("Save failed: " + e.message); } + }; + + const removeService = async (fqdn) => { + if (!session) { onNeedAuth(); return; } + await API("/services", { method: "DELETE", body: { token: session.token, fqdn } }); + await load(); + }; + + const deploy = async () => { + if (!session) { onNeedAuth(); return; } + setDeploying(true); setDeployResult(null); + try { + const r = await API("/services/deploy", { method: "POST", body: { token: session.token } }); + setDeployResult(r); + } catch(e) { setDeployResult({ success: false, errors: [e.message] }); } + setDeploying(false); + }; + + const toggleVlan = (vid) => { + setForm(f => ({ + ...f, + allowed_vlans: f.allowed_vlans.includes(vid) + ? f.allowed_vlans.filter(v => v !== vid) + : [...f.allowed_vlans, vid], + })); + }; + + return ( +
+
+
+
Service Proxy
+
+ Expose services running on your LAN to other VLANs without opening inter-VLAN access. + Each service gets an FQDN (e.g. plex.home.lan) that + resolves to the management box. Caddy reverse-proxies the request to the actual server. + Only port 443 is opened — no direct VLAN-to-VLAN access needed. +
+ Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400 +
+
+
+ + {/* Add Service Form */} +
+
Add Service
+
+
+
+ setForm(f => ({...f, fqdn: e.target.value}))} + placeholder="plex.home.lan"/> +
+
+ setForm(f => ({...f, backend_url: e.target.value}))} + placeholder="http://192.168.1.100:32400"/> +
+
+ setForm(f => ({...f, description: e.target.value}))} + placeholder="Plex Media Server"/> +
+
+ +
+
Allowed VLANs (which VLANs can reach this service)
+
+ {vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => ( + + ))} +
+
+ + +
+
+ + {/* Service List */} + {services.length > 0 && ( +
+
Configured Services ({services.length})
+
+ + + + {services.map((s,i) => ( + + + + + + + + ))} + +
FQDNBackendDescriptionVLANs
{s.fqdn}{s.backend_url}{s.description || "—"} + {(s.allowed_vlans || []).map(vid => { + const v = vlans.find(x => x.id === vid); + return {v?.name||`V${vid}`}; + })} + + +
+ +
+ + + Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules + +
+ + {deployResult && ( +
+
+ {deployResult.success ? "Deploy Complete" : "Deploy Had Errors"} +
+ {deployResult.steps_done?.map((s,i) => ( +
done {s}
+ ))} + {deployResult.errors?.map((e,i) => ( +
error {e}
+ ))} + {deployResult.note && ( +
+ {deployResult.note} +
+ )} +
+ )} +
+
+ )} +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// ALERTS TAB — ntfy configuration + scheduled operations +// ══════════════════════════════════════════════════════════════════════════════ + +function AlertsTab({ session, onNeedAuth, backendOk }) { + const [ntfyCfg, setNtfyCfg] = useState({ url: "https://ntfy.sh", topic: "", enabled: false, events: {} }); + const [ntfyToken, setNtfyToken] = useState(""); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [schedules, setSchedules] = useState([]); + const [schedForm, setSchedForm] = useState({ + name: "", action: "backup", device: "both", hour: "3", minute: "0", days: "*", enabled: true, + }); + + const loadNtfy = async () => { + try { setNtfyCfg(await API("/alerts/config")); } catch(e) { console.error(e); } + }; + const loadSchedules = async () => { + try { + const d = await API("/schedules"); + setSchedules(d.schedules || []); + } catch(e) { console.error(e); } + }; + useEffect(() => { if (backendOk) { loadNtfy(); loadSchedules(); } }, [backendOk]); + + const saveNtfy = async () => { + if (!session) { onNeedAuth(); return; } + setSaving(true); + try { + await API("/alerts/config", { method: "POST", body: { + token: session.token, url: ntfyCfg.url, topic: ntfyCfg.topic, + ntfy_token: ntfyToken, enabled: ntfyCfg.enabled, events: ntfyCfg.events, + }}); + await loadNtfy(); + } catch(e) { alert("Save failed: " + e.message); } + setSaving(false); + }; + + const testNtfy = async () => { + if (!session) { onNeedAuth(); return; } + setTesting(true); + try { + await API("/alerts/test", { method: "POST", body: { token: session.token } }); + alert("Test notification sent! Check your ntfy app/topic."); + } catch(e) { alert("Test failed: " + e.message); } + setTesting(false); + }; + + const addSchedule = async () => { + if (!session) { onNeedAuth(); return; } + if (!schedForm.name) return; + try { + await API("/schedules", { method: "POST", body: { token: session.token, schedule: schedForm } }); + setSchedForm(f => ({...f, name: ""})); + await loadSchedules(); + } catch(e) { alert("Save failed: " + e.message); } + }; + + const deleteSchedule = async (name) => { + if (!session) { onNeedAuth(); return; } + await API("/schedules", { method: "DELETE", body: { token: session.token, name } }); + await loadSchedules(); + }; + + const runNow = async (name) => { + if (!session) { onNeedAuth(); return; } + try { + await API("/schedules/run-now", { method: "POST", body: { token: session.token, name } }); + alert(`Schedule "${name}" triggered.`); + } catch(e) { alert("Run failed: " + e.message); } + }; + + const toggleEvent = (key) => { + setNtfyCfg(c => ({ ...c, events: { ...c.events, [key]: !c.events[key] } })); + }; + + const eventLabels = { + connectivity_lost: "Switch goes offline / comes back", + backup_failed: "Scheduled backup fails", + push_failed: "Config push fails", + poe_budget_warning: "PoE budget exceeds 85%", + port_down: "Port goes down (high volume)", + }; + + return ( +
+
+ {/* ntfy Configuration */} +
+
Push Notifications (ntfy)
+
+
+ Get push notifications on your phone/desktop when network events occur. + Works with ntfy.sh (free, no account needed) + or a self-hosted ntfy server. +
+ +
+
+ setNtfyCfg(c => ({...c, url: e.target.value}))} + placeholder="https://ntfy.sh"/> +
+
+ setNtfyCfg(c => ({...c, topic: e.target.value}))} + placeholder="my-network-alerts"/> +
+
+ setNtfyToken(e.target.value)} + type="password" placeholder={ntfyCfg.has_token ? "••••••• (saved)" : "for private topics"}/> +
+
+ +
+
Alert Events
+
+ {Object.entries(eventLabels).map(([k,label]) => ( + + ))} +
+
+ +
+ +
+ +
+ + +
+
+
+ + {/* Scheduled Operations */} +
+
Scheduled Operations
+
+
+ Schedule recurring tasks like automatic backups or connectivity checks. + Tasks run in the background and send ntfy alerts on failure (if configured). +
+ +
+
+ setSchedForm(f => ({...f, name: e.target.value}))} + placeholder="nightly-backup"/> +
+
+ +
+ {schedForm.action === "backup" && ( +
+ +
+ )} +
+ setSchedForm(f => ({...f, hour: e.target.value}))} + placeholder="3" style={{textAlign:"center"}}/> +
+
+ setSchedForm(f => ({...f, minute: e.target.value}))} + placeholder="0" style={{textAlign:"center"}}/> +
+
+ setSchedForm(f => ({...f, days: e.target.value}))} + placeholder="mon,wed,fri or *"/> +
+
+ + + {schedules.length > 0 && ( +
+ + + + {schedules.map((s,i) => ( + + + + + + + + + ))} + +
NameActionTimeDaysStatus
{s.name}{s.action}{s.device ? ` (${s.device})` : ""}{s.hour || "*"}:{(s.minute || "0").padStart(2,"0")}{s.days || "*"} + {s.enabled!==false?"active":"disabled"} + + +
+
+ )} +
+
+
+
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index 5d90f31..bb27b86 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -510,6 +510,12 @@ def _poll_loop(): _cache["poll_error"] = str(e) log.warning(f"Poll error: {e}") + # Check alert conditions after each poll + try: + _check_and_alert() + except Exception: + pass # alerts are best-effort, never crash the poller + interval = POLL_ACTIVE_S if mode == "active" else POLL_BG_S time.sleep(interval) @@ -4141,3 +4147,790 @@ def push_safe(body: PushBatch): result["backup"] = backup result["post_connectivity"] = post_conn return result + + +# ══════════════════════════════════════════════════════════════════════ +# FIREWALL POLICY MATRIX — inter-VLAN access control +# ══════════════════════════════════════════════════════════════════════ + +POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json") + +# Policy types: +# "block" — deny all traffic between VLANs +# "allow" — permit all traffic between VLANs +# "one-way" — src VLAN can reach dst VLAN, but not reverse +# "services" — src can reach dst on specific ports only +# "printer" — other VLANs can print (reach ports 9100,631,443), printer can't initiate + +POLICY_PRESETS = { + "block": { + "label": "Blocked", + "description": "No traffic allowed between these VLANs", + }, + "allow": { + "label": "Full Access", + "description": "All traffic permitted between these VLANs", + }, + "one-way": { + "label": "One-Way Access", + "description": "Source VLAN can reach destination, but not reverse", + }, + "printer": { + "label": "Printer Access", + "description": "Other VLANs can reach printers (ports 9100/631/443/515), printers cannot initiate connections back", + "ports": [9100, 631, 443, 515], + }, + "services": { + "label": "Service Ports Only", + "description": "Access limited to specified TCP/UDP ports", + }, +} + + +def _load_policies() -> list: + if POLICIES_FILE.exists(): + try: return _json.loads(POLICIES_FILE.read_text()) + except: pass + return [] + + +def _save_policies(policies: list): + POLICIES_FILE.write_text(_json.dumps(policies, indent=2)) + POLICIES_FILE.chmod(0o600) + + +def _build_policy_acls(policy: dict) -> dict: + """ + Generate switch ACL commands AND OPNsense firewall rule payloads for a policy. + + Returns {switch_cmds: [...], opnsense_rules: [...], description: str} + """ + ptype = policy.get("type", "block") + src_vid = policy.get("src_vlan") + dst_vid = policy.get("dst_vlan") + ports = policy.get("ports", []) + src_sub = f"192.168.{src_vid}.0" + dst_sub = f"192.168.{dst_vid}.0" + mask = "0.0.0.255" + acl_name = f"POLICY-V{src_vid}-V{dst_vid}" + + switch_cmds = [] + opnsense_rules = [] + + if ptype == "block": + switch_cmds = [ + f"ip access-list extended {acl_name}", + f" 1 deny ip {src_sub} {mask} {dst_sub} {mask}", + f" 2 permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "block", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_sub}/{24}"}, + "destination": {"network": f"{dst_sub}/24"}, + "descr": f"Block VLAN {src_vid} → VLAN {dst_vid}", + } + }) + + elif ptype == "allow": + switch_cmds = [ + f"ip access-list extended {acl_name}", + f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}", + f" 2 permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + # OPNsense: explicit allow (usually default, but good to be explicit) + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "pass", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_sub}/24"}, + "destination": {"network": f"{dst_sub}/24"}, + "descr": f"Allow VLAN {src_vid} → VLAN {dst_vid}", + } + }) + + elif ptype == "one-way": + # Allow src→dst, block dst→src (reverse ACL on dst VLAN) + switch_cmds = [ + f"ip access-list extended {acl_name}", + f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}", + f" 2 permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + f"ip access-list extended {acl_name}-REV", + f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}", + f" 2 permit ip any any", + f"interface vlan {dst_vid}", + f" ip access-group {acl_name}-REV in", + ] + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "pass", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_sub}/24"}, + "destination": {"network": f"{dst_sub}/24"}, + "descr": f"Allow VLAN {src_vid} → VLAN {dst_vid} (one-way)", + } + }) + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "block", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{dst_sub}/24"}, + "destination": {"network": f"{src_sub}/24"}, + "descr": f"Block VLAN {dst_vid} → VLAN {src_vid} (one-way reverse)", + } + }) + + elif ptype == "printer": + # Other VLANs can reach printer VLAN on print ports; printers can't initiate + printer_ports = ports or [9100, 631, 443, 515] + rule_num = 1 + switch_cmds = [f"ip access-list extended {acl_name}"] + for port in printer_ports: + switch_cmds.append( + f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}") + rule_num += 1 + switch_cmds += [ + f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}", + f" {rule_num+1} permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + # Reverse: block printers from initiating to src VLAN + switch_cmds += [ + f"ip access-list extended {acl_name}-REV", + f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}", + f" 2 permit ip any any", + f"interface vlan {dst_vid}", + f" ip access-group {acl_name}-REV in", + ] + # OPNsense rules + for port in printer_ports: + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "pass", + "ipprotocol": "inet", "protocol": "tcp", + "source": {"network": f"{src_sub}/24"}, + "destination": {"network": f"{dst_sub}/24", "port": str(port)}, + "descr": f"VLAN {src_vid} → printer VLAN {dst_vid} port {port}", + } + }) + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "block", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{dst_sub}/24"}, + "destination": {"network": f"{src_sub}/24"}, + "descr": f"Block printer VLAN {dst_vid} → VLAN {src_vid}", + } + }) + + elif ptype == "services": + rule_num = 1 + switch_cmds = [f"ip access-list extended {acl_name}"] + for port in ports: + switch_cmds.append( + f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}") + rule_num += 1 + switch_cmds += [ + f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}", + f" {rule_num+1} permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + for port in ports: + opnsense_rules.append({ + "rule": { + "enabled": "1", "action": "pass", + "ipprotocol": "inet", "protocol": "tcp", + "source": {"network": f"{src_sub}/24"}, + "destination": {"network": f"{dst_sub}/24", "port": str(port)}, + "descr": f"VLAN {src_vid} → VLAN {dst_vid} port {port}", + } + }) + + return { + "switch_cmds": switch_cmds, + "opnsense_rules": opnsense_rules, + "acl_name": acl_name, + "description": f"{POLICY_PRESETS.get(ptype,{}).get('label','Custom')} — " + f"VLAN {src_vid} → VLAN {dst_vid}", + } + + +@app.get("/api/firewall/policies") +def get_policies(): + """Return saved inter-VLAN policies and available presets.""" + return {"policies": _load_policies(), "presets": POLICY_PRESETS} + + +@app.post("/api/firewall/policies") +def save_policy(body: dict): + """Save or update an inter-VLAN policy.""" + require_session(body.get("token", "")) + policy = body.get("policy", {}) + if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"): + raise HTTPException(400, "src_vlan, dst_vlan, and type required") + + policies = _load_policies() + # Replace existing policy for this VLAN pair + policies = [p for p in policies + if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])] + policies.append(policy) + _save_policies(policies) + return {"success": True, "policies": policies} + + +@app.delete("/api/firewall/policies") +def delete_policy(body: dict): + """Remove an inter-VLAN policy.""" + require_session(body.get("token", "")) + src = body.get("src_vlan") + dst = body.get("dst_vlan") + policies = _load_policies() + policies = [p for p in policies if not (p["src_vlan"] == src and p["dst_vlan"] == dst)] + _save_policies(policies) + return {"success": True, "policies": policies} + + +@app.post("/api/firewall/preview") +def preview_policy(body: dict): + """Preview generated ACLs/rules for a policy without pushing.""" + policy = body.get("policy", {}) + if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"): + raise HTTPException(400, "src_vlan, dst_vlan, and type required") + return _build_policy_acls(policy) + + +@app.post("/api/firewall/push") +def push_policy(body: dict): + """Push a firewall policy to both switch and OPNsense.""" + require_session(body.get("token", "")) + policy = body.get("policy", {}) + if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"): + raise HTTPException(400, "src_vlan, dst_vlan, and type required") + + generated = _build_policy_acls(policy) + steps_done = [] + errors = [] + + # Pre-backup + backup = _pre_change_backup( + reason=f"pre-policy VLAN {policy['src_vlan']}→{policy['dst_vlan']} ({policy['type']})") + + # Push switch ACLs + if generated["switch_cmds"]: + danger = check_danger(generated["switch_cmds"]) + if danger["has_hard_block"]: + raise HTTPException(400, {"message": "Hard-blocked", "blocked": danger["hard_blocked"]}) + result = push_one_by_one(generated["switch_cmds"]) + if result.get("success"): + steps_done.append(f"switch: ACL {generated['acl_name']} applied") + else: + errors.append(f"switch: {result.get('error', 'push failed')}") + + # Push OPNsense rules + cfg = _load_opnsense_cfg() + if cfg.get("key") and generated["opnsense_rules"]: + vmap = _load_vlan_if_map() + src_if = vmap.get(str(policy["src_vlan"]), "") + for rule_data in generated["opnsense_rules"]: + if src_if: + rule_data["rule"]["interface"] = src_if + rule_data["rule"]["direction"] = "in" + try: + _opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_data) + steps_done.append(f"OPNsense: {rule_data['rule']['descr']}") + except ValueError as e: + errors.append(f"OPNsense: {e}") + try: + _opnsense_request(cfg, "firewall/filter/apply", "POST") + steps_done.append("OPNsense: firewall rules applied") + except ValueError as e: + errors.append(f"OPNsense apply: {e}") + + # Save policy to local state + policies = _load_policies() + policies = [p for p in policies + if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])] + policy["pushed"] = True + policy["pushed_at"] = _ts() + policies.append(policy) + _save_policies(policies) + + return { + "success": len(errors) == 0, + "steps_done": steps_done, + "errors": errors, + "backup": backup, + "generated": generated, + } + + +# ══════════════════════════════════════════════════════════════════════ +# SERVICE PROXY — expose LAN services to other VLANs via Caddy + DNS +# ══════════════════════════════════════════════════════════════════════ + +SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json") +CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services") + +def _load_services() -> list: + if SERVICES_FILE.exists(): + try: return _json.loads(SERVICES_FILE.read_text()) + except: pass + return [] + +def _save_services(services: list): + SERVICES_FILE.write_text(_json.dumps(services, indent=2)) + SERVICES_FILE.chmod(0o600) + + +def _generate_caddyfile_services(services: list) -> str: + """Generate Caddyfile blocks for service reverse proxies.""" + blocks = ["# Auto-generated by switch-manager — do not edit manually\n"] + for svc in services: + fqdn = svc.get("fqdn", "") + backend_url = svc.get("backend_url", "") + if not fqdn or not backend_url: + continue + blocks.append(f"{fqdn} {{") + blocks.append(f" reverse_proxy {backend_url}") + blocks.append(f" tls internal") + blocks.append(f"}}\n") + return "\n".join(blocks) + + +def _generate_unbound_overrides(services: list, mgmt_ip: str) -> str: + """Generate Unbound local-data lines for service FQDN → management box IP.""" + lines = ["# Auto-generated by switch-manager\n"] + for svc in services: + fqdn = svc.get("fqdn", "") + target_ip = svc.get("proxy_ip", mgmt_ip) + if fqdn: + lines.append(f'local-data: "{fqdn}. IN A {target_ip}"') + return "\n".join(lines) + + +@app.get("/api/services") +def get_services(): + """List configured service proxies.""" + return {"services": _load_services()} + + +@app.post("/api/services") +def save_service(body: dict): + """Add or update a service proxy.""" + require_session(body.get("token", "")) + svc = body.get("service", {}) + if not svc.get("fqdn") or not svc.get("backend_url"): + raise HTTPException(400, "fqdn and backend_url required") + + services = _load_services() + services = [s for s in services if s["fqdn"] != svc["fqdn"]] + services.append(svc) + _save_services(services) + return {"success": True, "services": services} + + +@app.delete("/api/services") +def delete_service(body: dict): + """Remove a service proxy.""" + require_session(body.get("token", "")) + fqdn = body.get("fqdn", "") + services = _load_services() + services = [s for s in services if s["fqdn"] != fqdn] + _save_services(services) + return {"success": True, "services": services} + + +@app.post("/api/services/deploy") +def deploy_services(body: dict): + """ + Deploy service proxies: write Caddyfile, push DNS overrides to Unbound, + add firewall rules to allow other VLANs to reach the proxy. + """ + require_session(body.get("token", "")) + services = _load_services() + if not services: + raise HTTPException(400, "No services configured") + + steps_done = [] + errors = [] + + # Determine management box IP + import socket as _sock + try: + mgmt_ip = _sock.gethostbyname(_sock.gethostname()) + except Exception: + mgmt_ip = SWITCH_HOST.rsplit('.', 1)[0] + '.50' + + backup = _pre_change_backup(reason="pre-service-proxy deploy") + + # 1. Write Caddyfile.services + caddy_content = _generate_caddyfile_services(services) + try: + CADDYFILE_EXTRA.write_text(caddy_content) + steps_done.append(f"Wrote {CADDYFILE_EXTRA} ({len(services)} services)") + except Exception as e: + errors.append(f"Caddyfile write: {e}") + + # 2. Push DNS overrides to OPNsense Unbound + cfg = _load_opnsense_cfg() + if cfg.get("ssh_key_path"): + dns_content = _generate_unbound_overrides(services, mgmt_ip) + try: + _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/service-proxies.conf", dns_content) + steps_done.append(f"Wrote Unbound overrides: {len(services)} service FQDNs → {mgmt_ip}") + except Exception as e: + errors.append(f"Unbound DNS write: {e}") + + # Validate and reload Unbound + out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1") + if code != 0: + errors.append(f"unbound-checkconf failed: {err or out}") + else: + _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") + steps_done.append("Unbound reloaded with service DNS overrides") + else: + errors.append("OPNsense SSH not configured — DNS overrides not deployed. " + "Add service FQDNs to your DNS manually.") + + # 3. Add firewall rules: allow each VLAN to reach mgmt_ip on 443 + if cfg.get("key"): + allowed_vlans = set() + for svc in services: + for vid in svc.get("allowed_vlans", []): + allowed_vlans.add(vid) + vmap = _load_vlan_if_map() + for vid in allowed_vlans: + iface = vmap.get(str(vid), "") + if not iface: + errors.append(f"VLAN {vid}: no OPNsense interface mapped — skip firewall rule") + continue + try: + _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", "action": "pass", + "interface": iface, "direction": "in", + "ipprotocol": "inet", "protocol": "tcp", + "source": {"network": f"{iface}net"}, + "destination": {"address": mgmt_ip, "port": "443"}, + "descr": f"VLAN {vid} → service proxy ({mgmt_ip}:443)", + } + }) + steps_done.append(f"Firewall: VLAN {vid} → {mgmt_ip}:443 allowed") + except ValueError as e: + errors.append(f"Firewall VLAN {vid}: {e}") + if allowed_vlans: + try: + _opnsense_request(cfg, "firewall/filter/apply", "POST") + except ValueError as e: + errors.append(f"Firewall apply: {e}") + + return { + "success": len(errors) == 0, + "steps_done": steps_done, + "errors": errors, + "backup": backup, + "caddy_content": caddy_content, + "mgmt_ip": mgmt_ip, + "note": "Restart Caddy to pick up new Caddyfile.services: " + "docker compose restart caddy (or systemctl restart caddy)", + } + + +# ══════════════════════════════════════════════════════════════════════ +# NTFY ALERTS — push notifications for network events +# ══════════════════════════════════════════════════════════════════════ + +NTFY_FILE = _Path("/etc/switch-manager/ntfy.json") + +def _load_ntfy_cfg() -> dict: + if NTFY_FILE.exists(): + try: return _json.loads(NTFY_FILE.read_text()) + except: pass + return {} + +def _save_ntfy_cfg(cfg: dict): + NTFY_FILE.write_text(_json.dumps(cfg, indent=2)) + NTFY_FILE.chmod(0o600) + + +def _ntfy_send(title: str, message: str, priority: str = "default", tags: str = ""): + """Send a notification via ntfy. Non-blocking, fire-and-forget.""" + cfg = _load_ntfy_cfg() + url = cfg.get("url", "") + topic = cfg.get("topic", "") + if not url or not topic: + return + try: + full_url = f"{url.rstrip('/')}/{topic}" + headers = { + "Title": title, + "Priority": priority, + } + if tags: + headers["Tags"] = tags + token = cfg.get("token", "") + if token: + headers["Authorization"] = f"Bearer {token}" + data = message.encode("utf-8") + req = _urlreq.Request(full_url, data=data, headers=headers, method="POST") + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + _urlreq.urlopen(req, timeout=5, context=ctx) + log.info(f"ntfy alert sent: {title}") + except Exception as e: + log.warning(f"ntfy send failed: {e}") + + +@app.get("/api/alerts/config") +def get_ntfy_config(): + """Return ntfy configuration (without token).""" + cfg = _load_ntfy_cfg() + return { + "url": cfg.get("url", ""), + "topic": cfg.get("topic", ""), + "has_token": bool(cfg.get("token", "")), + "enabled": cfg.get("enabled", False), + "events": cfg.get("events", { + "connectivity_lost": True, + "backup_failed": True, + "push_failed": True, + "poe_budget_warning": True, + "port_down": False, + }), + } + + +@app.post("/api/alerts/config") +def save_ntfy_config(body: dict): + """Save ntfy configuration.""" + require_session(body.get("token_session", body.get("token", ""))) + cfg = { + "url": body.get("url", "https://ntfy.sh"), + "topic": body.get("topic", ""), + "token": body.get("ntfy_token", ""), + "enabled": body.get("enabled", False), + "events": body.get("events", {}), + } + _save_ntfy_cfg(cfg) + return {"success": True} + + +@app.post("/api/alerts/test") +def test_ntfy(body: dict): + """Send a test notification.""" + require_session(body.get("token", "")) + _ntfy_send( + title="Switch Manager Test", + message="If you see this, ntfy alerts are working!", + priority="low", + tags="white_check_mark,test_tube", + ) + return {"success": True} + + +# ── Alert integration into polling ─────────────────────────────────── + +_last_alert_state: dict = {} + +def _check_and_alert(): + """Called from the poll loop to detect alertable conditions.""" + cfg = _load_ntfy_cfg() + if not cfg.get("enabled"): + return + events = cfg.get("events", {}) + global _last_alert_state + + with _cache_lock: + poll_err = _cache.get("poll_error") + port_status = _cache.get("port_status", "") + poe_status = _cache.get("poe_status", "") + + # Connectivity lost + if events.get("connectivity_lost") and poll_err: + if not _last_alert_state.get("conn_lost"): + _ntfy_send("Switch Offline", f"Cannot reach switch: {poll_err}", + priority="urgent", tags="rotating_light,warning") + _last_alert_state["conn_lost"] = True + else: + if _last_alert_state.get("conn_lost"): + _ntfy_send("Switch Back Online", "Connectivity restored", + priority="default", tags="white_check_mark") + _last_alert_state["conn_lost"] = False + + # PoE budget warning (parse from poe_status if available) + if events.get("poe_budget_warning") and poe_status: + import re as _re_alert + watts_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed)', poe_status) + budget_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum)', poe_status) + if watts_match and budget_match: + try: + used = int(watts_match[0][0]) + budget = int(budget_match[0][0]) + pct = (used / budget * 100) if budget > 0 else 0 + if pct > 85 and not _last_alert_state.get("poe_warn"): + _ntfy_send("PoE Budget Warning", + f"PoE usage at {pct:.0f}% ({used}W / {budget}W)", + priority="high", tags="zap,warning") + _last_alert_state["poe_warn"] = True + elif pct <= 80: + _last_alert_state["poe_warn"] = False + except (ValueError, IndexError): + pass + + +# ══════════════════════════════════════════════════════════════════════ +# SCHEDULED OPERATIONS — cron-like scheduler for backups and VLAN ops +# ══════════════════════════════════════════════════════════════════════ + +SCHEDULES_FILE = _Path("/etc/switch-manager/schedules.json") +_scheduler_thread = None + +def _load_schedules() -> list: + if SCHEDULES_FILE.exists(): + try: return _json.loads(SCHEDULES_FILE.read_text()) + except: pass + return [] + +def _save_schedules(schedules: list): + SCHEDULES_FILE.write_text(_json.dumps(schedules, indent=2)) + SCHEDULES_FILE.chmod(0o600) + + +def _should_run_now(schedule: dict) -> bool: + """Check if a schedule should run based on current time and its cron-like fields.""" + now = _dt.datetime.now() + hour = schedule.get("hour", "*") + minute = schedule.get("minute", "0") + days = schedule.get("days", "*") # "mon,tue,wed" or "*" + + if hour != "*" and now.hour != int(hour): + return False + if minute != "*" and now.minute != int(minute): + return False + if days != "*": + day_names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] + today = day_names[now.weekday()] + if today not in days.lower().split(","): + return False + return True + + +def _run_scheduled_task(schedule: dict): + """Execute a scheduled task.""" + action = schedule.get("action", "") + name = schedule.get("name", "unnamed") + log.info(f"Scheduler: running '{name}' (action={action})") + + try: + if action == "backup": + device = schedule.get("device", "both") + result = {} + if device in ("switch", "both"): + result["switch"] = _switch_backup(reason=f"scheduled: {name}") + if device in ("opnsense", "both"): + cfg = _load_opnsense_cfg() + if cfg.get("key"): + result["opnsense"] = _opnsense_backup(cfg, reason=f"scheduled: {name}") + log.info(f"Scheduled backup '{name}': {result}") + _ntfy_send(f"Scheduled Backup: {name}", + f"Switch: {'OK' if result.get('switch',{}).get('ok') else 'FAIL'}, " + f"OPNsense: {'OK' if result.get('opnsense',{}).get('ok') else 'N/A'}", + tags="floppy_disk") + + elif action == "connectivity_check": + conn = _check_connectivity() + if not conn["switch"]["ok"]: + _ntfy_send("Scheduled Check: Switch Offline", + f"Switch unreachable: {conn['switch'].get('error','')}", + priority="urgent", tags="rotating_light") + + except Exception as e: + log.warning(f"Scheduled task '{name}' failed: {e}") + _ntfy_send(f"Scheduled Task Failed: {name}", str(e), + priority="high", tags="x") + + +def _scheduler_loop(): + """Background thread: check schedules every 60 seconds.""" + log.info("Scheduler thread started") + last_runs: dict[str, str] = {} # {schedule_name: "YYYYMMDD-HHMM"} + while True: + time.sleep(60) + schedules = _load_schedules() + now_key = _dt.datetime.now().strftime("%Y%m%d-%H%M") + for sched in schedules: + if not sched.get("enabled", True): + continue + name = sched.get("name", "") + # Don't run the same schedule twice in the same minute + if last_runs.get(name) == now_key: + continue + if _should_run_now(sched): + last_runs[name] = now_key + try: + _run_scheduled_task(sched) + except Exception as e: + log.warning(f"Scheduler error for '{name}': {e}") + + +def start_scheduler(): + global _scheduler_thread + if _scheduler_thread is None or not _scheduler_thread.is_alive(): + _scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler") + _scheduler_thread.start() + + +# Start scheduler on import (alongside poller) +start_scheduler() + + +@app.get("/api/schedules") +def get_schedules(): + return {"schedules": _load_schedules()} + + +@app.post("/api/schedules") +def save_schedule(body: dict): + require_session(body.get("token", "")) + sched = body.get("schedule", {}) + if not sched.get("name") or not sched.get("action"): + raise HTTPException(400, "name and action required") + + schedules = _load_schedules() + schedules = [s for s in schedules if s["name"] != sched["name"]] + schedules.append(sched) + _save_schedules(schedules) + return {"success": True, "schedules": schedules} + + +@app.delete("/api/schedules") +def delete_schedule(body: dict): + require_session(body.get("token", "")) + name = body.get("name", "") + schedules = _load_schedules() + schedules = [s for s in schedules if s["name"] != name] + _save_schedules(schedules) + return {"success": True, "schedules": schedules} + + +@app.post("/api/schedules/run-now") +def run_schedule_now(body: dict): + """Manually trigger a scheduled task immediately.""" + require_session(body.get("token", "")) + name = body.get("name", "") + schedules = _load_schedules() + sched = next((s for s in schedules if s["name"] == name), None) + if not sched: + raise HTTPException(404, f"Schedule '{name}' not found") + _run_scheduled_task(sched) + return {"success": True, "ran": name} From 3cdad0dcb545eb3e60d5aeb75e4f795187285113 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 01:08:16 +0000 Subject: [PATCH 03/11] Fix service proxy architecture and add VLAN time-based schedules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service proxy fix: - DNS now resolves service FQDNs to OPNsense gateway IP (not mgmt box) - Devices reach services through their own gateway — never touch other VLANs. Full VLAN isolation preserved. - No new firewall rules needed — devices can already reach their gateway - Deploy tries Caddy on OPNsense first, then HAProxy plugin, then gives manual setup instructions - Removed "allowed VLANs" selector — all VLANs can reach services automatically through the gateway reverse proxy VLAN time-based schedules: - New vlan_enable/vlan_disable scheduler actions - Creates/removes OPNsense firewall allow-outbound rules on schedule - Switch ports stay up so devices reconnect when re-enabled - Tracked rule UUIDs for clean enable/disable cycles - VlanScheduleWizard UI component with paired off/on times - Quick presets: Guest WiFi midnight-6am, Business 6pm-8am weekdays, Kids 9pm-7am, IoT 11pm-5am - ntfy notifications on VLAN enable/disable events https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 212 +++++++++++++++++++++++++++--------- switch_backend.py | 256 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 368 insertions(+), 100 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 7638ff2..3fcf7d0 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -5040,7 +5040,7 @@ function FirewallTab({ vlans, session, onNeedAuth, backendOk }) { function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { const [services, setServices] = useState([]); - const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] }); + const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" }); const [deploying, setDeploying] = useState(false); const [deployResult, setDeployResult] = useState(null); @@ -5057,7 +5057,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { if (!form.fqdn || !form.backend_url) return; try { await API("/services", { method: "POST", body: { token: session.token, service: form } }); - setForm({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] }); + setForm({ fqdn: "", backend_url: "", description: "" }); await load(); } catch(e) { alert("Save failed: " + e.message); } }; @@ -5078,27 +5078,31 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { setDeploying(false); }; - const toggleVlan = (vid) => { - setForm(f => ({ - ...f, - allowed_vlans: f.allowed_vlans.includes(vid) - ? f.allowed_vlans.filter(v => v !== vid) - : [...f.allowed_vlans, vid], - })); - }; - return (
-
Service Proxy
+
Service Proxy — FQDN Access Without Breaking VLAN Isolation
- Expose services running on your LAN to other VLANs without opening inter-VLAN access. - Each service gets an FQDN (e.g. plex.home.lan) that - resolves to the management box. Caddy reverse-proxies the request to the actual server. - Only port 443 is opened — no direct VLAN-to-VLAN access needed. -
- Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400 +
+ Make LAN services reachable by FQDN from any VLAN without + any inter-VLAN access. Devices never touch the service's VLAN directly. +
+
+
+ How it works: +
+
1. IoT device (VLAN 30) asks DNS for plex.home.lan
+
2. Unbound returns 192.168.30.1 (OPNsense gateway — device can already reach this)
+
3. OPNsense reverse proxy (Caddy/HAProxy) forwards to actual server 192.168.1.100:32400
+
4. Response returns the same path. IoT device never sees or touches LAN.
+
+
+ No firewall rules needed. No VLAN-to-VLAN access opened. All VLANs can already reach + their own gateway — that's how they get internet. The gateway does the proxying.
@@ -5112,7 +5116,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { setForm(f => ({...f, fqdn: e.target.value}))} placeholder="plex.home.lan"/>
-
+
setForm(f => ({...f, backend_url: e.target.value}))} placeholder="http://192.168.1.100:32400"/>
@@ -5122,29 +5126,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
-
-
Allowed VLANs (which VLANs can reach this service)
-
- {vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => ( - - ))} -
-
- +
+ All VLANs can reach this service automatically (via their gateway). No per-VLAN selection needed. +
@@ -5154,19 +5142,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
Configured Services ({services.length})
- + {services.map((s,i) => ( - - + ))} @@ -5164,10 +5220,10 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
- Updates Caddyfile + checks NAT reflection on OPNsense + Writes Caddyfile.services, reloads Caddy, verifies NAT reflection + port forward
@@ -5187,12 +5243,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
error {e}
))} {deployResult.pending_steps?.map((s,i) => ( -
manual {s}
+
todo {s}
))} - {deployResult.architecture && ( -
- {deployResult.architecture} -
+ {deployResult.caddy_content && ( +
+ View Caddyfile.services +
{deployResult.caddy_content}
+
)} )} diff --git a/switch_backend.py b/switch_backend.py index 36dc774..d9ccc87 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -4558,37 +4558,215 @@ def delete_service(body: dict): return {"success": True, "services": services} -@app.get("/api/services/nat-reflection") -def check_nat_reflection(): - """Check if NAT reflection is enabled on OPNsense.""" +SERVICES_RULE_FILE = _Path("/etc/switch-manager/service-nat-rules.json") + +def _load_service_rules() -> dict: + """Load tracked OPNsense NAT rule UUIDs for service port forwards.""" + if SERVICES_RULE_FILE.exists(): + try: return _json.loads(SERVICES_RULE_FILE.read_text()) + except: pass + return {} + +def _save_service_rules(rules: dict): + SERVICES_RULE_FILE.write_text(_json.dumps(rules, indent=2)) + SERVICES_RULE_FILE.chmod(0o600) + + +def _get_mgmt_ip() -> str: + """Best-effort detection of management computer LAN IP.""" + import socket as _sock + try: + s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "" + + +@app.get("/api/services/status") +def services_status(): + """Full status check: Caddy import, NAT reflection, port forward, services.""" + services = _load_services() + cfg = _load_opnsense_cfg() + result = { + "services": services, + "mgmt_ip": _get_mgmt_ip(), + "caddy_file_exists": CADDYFILE_EXTRA.exists(), + "nat_reflection": None, + "port_forward_443": None, + "opnsense_configured": bool(cfg.get("key")), + "opnsense_ssh": bool(cfg.get("ssh_key_path")), + } + + # Check NAT reflection + if cfg.get("ssh_key_path"): + try: + out, _, _ = _opnsense_ssh_run(cfg, + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + result["nat_reflection"] = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) + except Exception as e: + result["nat_reflection_error"] = str(e) + + # Check for existing WAN port forward to 443 + if cfg.get("key"): + try: + nat_rules = _opnsense_request(cfg, "firewall/source_nat/searchRule") + # OPNsense 24+ uses source_nat; older uses legacy — try both + except Exception: + nat_rules = {} + if not nat_rules: + try: + nat_rules = _opnsense_request(cfg, "firewall/filter/searchRule") + except Exception: + nat_rules = {} + # We can't reliably parse NAT rules from the filter API — + # mark as "needs verification" unless we've created one ourselves + tracked = _load_service_rules() + result["port_forward_443"] = bool(tracked.get("wan_443_uuid")) + result["tracked_rules"] = tracked + + return result + + +@app.post("/api/services/enable-nat-reflection") +def enable_nat_reflection(body: dict): + """Enable NAT reflection on OPNsense via SSH (modifies config.xml).""" + require_session(body.get("token", "")) cfg = _load_opnsense_cfg() if not cfg.get("ssh_key_path"): - return {"configured": False, "error": "OPNsense SSH not configured"} + raise HTTPException(503, "OPNsense SSH not configured") + + backup = _pre_change_backup(reason="pre-NAT-reflection-enable") + + # OPNsense stores NAT reflection settings in /conf/config.xml under + # The cleanest way is via the API if available, or configctl try: - out, _, code = _opnsense_ssh_run( - cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") - return {"configured": True, "likely_enabled": "1" in out.strip() or int(out.strip()) > 0} + # Try the OPNsense API approach first (Firewall > Settings) + # The setting is under system > disablenatreflection (absent = enabled) + # and system > enablenatreflectionhelper (present = enabled) + out, err, code = _opnsense_ssh_run(cfg, ( + "configctl firmware configure 2>/dev/null; " + "echo 'NAT reflection: checking current state'; " + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0" + )) + already_enabled = "1" in out.strip().split('\n')[-1] + if already_enabled: + return {"success": True, "already_enabled": True, "backup": backup} + + # Enable via configctl / direct XML edit + # OPNsense 24+: use pluginctl or direct config edit + cmds = [ + # Add enablenatreflectionhelper if not present + "sed -i '' '/<\\/system>/i\\ 1<\\/enablenatreflectionhelper>' /conf/config.xml 2>/dev/null || " + "sed -i '/<\\/system>/i\\ 1<\\/enablenatreflectionhelper>' /conf/config.xml", + # Remove disablenatreflection if present + "sed -i '' '//d' /conf/config.xml 2>/dev/null || " + "sed -i '//d' /conf/config.xml", + # Reload filter + "configctl filter reload", + ] + for cmd in cmds: + _opnsense_ssh_run(cfg, cmd) + + # Verify + out, _, _ = _opnsense_ssh_run(cfg, + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) + + return {"success": enabled, "backup": backup, + "note": "Firewall filter reloaded" if enabled else "May need manual verification"} except Exception as e: - return {"configured": True, "likely_enabled": None, "error": str(e)} + raise HTTPException(500, f"NAT reflection enable failed: {e}") + + +@app.post("/api/services/create-port-forward") +def create_wan_port_forward(body: dict): + """ + Create WAN port forward: TCP 443 → management computer (Caddy). + + Uses OPNsense firewall NAT API. Only creates the rule if we haven't + already (tracked by UUID in service-nat-rules.json). + """ + require_session(body.get("token", "")) + cfg = _load_opnsense_cfg() + if not cfg.get("key"): + raise HTTPException(503, "OPNsense API not configured") + + mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) + if not mgmt_ip: + raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip") + + tracked = _load_service_rules() + if tracked.get("wan_443_uuid"): + return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"], + "mgmt_ip": mgmt_ip} + + backup = _pre_change_backup(reason="pre-WAN-port-forward-443") + + try: + # Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443 + r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", { + "rule": { + "enabled": "1", + "interface": "wan", + "protocol": "tcp", + "source": {"any": "1"}, + "destination": {"any": "1", "port": "443"}, + "target": {"address": mgmt_ip, "port": "443"}, + "descr": "switch-manager: WAN 443 → Caddy reverse proxy", + "nordr": "0", + } + }) + uuid = r.get("uuid", "") + + # If source_nat didn't work, try legacy firewall NAT API + if not uuid: + # OPNsense legacy NAT — different endpoint + r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", + "action": "pass", + "interface": "wan", + "direction": "in", + "ipprotocol": "inet", + "protocol": "tcp", + "source": {"any": "1"}, + "destination": {"address": mgmt_ip, "port": "443"}, + "descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)", + } + }) + uuid = r.get("uuid", "") + + # Apply changes + _opnsense_request(cfg, "firewall/filter/apply", "POST") + + tracked["wan_443_uuid"] = uuid + tracked["mgmt_ip"] = mgmt_ip + _save_service_rules(tracked) + + return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup, + "note": "If this is the first time, also verify in OPNsense UI: " + "Firewall > NAT > Port Forward that the rule looks correct. " + "The OPNsense NAT API varies between versions."} + except Exception as e: + raise HTTPException(500, f"Port forward creation failed: {e}") @app.post("/api/services/deploy") def deploy_services(body: dict): """ - Deploy service proxy configuration. + Full deploy: write Caddyfile, reload Caddy, ensure NAT reflection + and port forward are configured on OPNsense. - Architecture: - - Caddy runs on the LAN management computer (reverse proxy for all services) - - WAN: port 443 forwarded to Caddy — only port exposed externally - - LAN devices: reach services directly via Caddy - - Isolated VLANs (IoT, Guest): use public FQDNs — OPNsense NAT - reflection routes internally without traffic leaving the network - - Full VLAN isolation preserved — IoT treated same as external users - - This endpoint: - 1. Writes/updates Caddyfile with service entries - 2. Checks NAT reflection status on OPNsense - 3. Provides setup instructions for anything not yet configured + Steps: + 1. Pre-change backup + 2. Write Caddyfile.services with reverse proxy entries + 3. Reload Caddy (docker compose exec or systemctl) + 4. Check/enable NAT reflection on OPNsense + 5. Check/create WAN port forward 443 → Caddy + 6. Return status of each step """ require_session(body.get("token", "")) services = _load_services() @@ -4599,48 +4777,89 @@ def deploy_services(body: dict): errors = [] pending_steps = [] - backup = _pre_change_backup(reason="pre-service-proxy deploy") + backup = _pre_change_backup(reason="pre-service-deploy") - # 1. Write Caddyfile.services for Caddy on the management computer + mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) + + # ── Step 1: Write Caddyfile.services ───────────────────────────── caddy_content = _generate_caddyfile_services(services) try: CADDYFILE_EXTRA.write_text(caddy_content) - steps_done.append(f"Wrote {CADDYFILE_EXTRA} ({len(services)} services)") + steps_done.append(f"Wrote Caddyfile.services ({len(services)} services)") except Exception as e: errors.append(f"Caddyfile write: {e}") - # 2. Check NAT reflection + # ── Step 2: Reload Caddy ───────────────────────────────────────── + import subprocess as _sp + caddy_reloaded = False + # Try docker compose first + try: + r = _sp.run(["docker", "compose", "exec", "caddy", "caddy", "reload", + "--config", "/etc/caddy/Caddyfile"], + capture_output=True, text=True, timeout=15, + cwd=str(_Path(__file__).parent)) + if r.returncode == 0: + steps_done.append("Caddy reloaded via docker compose") + caddy_reloaded = True + else: + # Try docker exec with container name pattern + r2 = _sp.run(["docker", "compose", "restart", "caddy"], + capture_output=True, text=True, timeout=30, + cwd=str(_Path(__file__).parent)) + if r2.returncode == 0: + steps_done.append("Caddy restarted via docker compose") + caddy_reloaded = True + else: + errors.append(f"Docker caddy reload failed: {r.stderr.strip()}") + except Exception: + pass + + if not caddy_reloaded: + # Try systemctl + try: + r = _sp.run(["systemctl", "reload", "caddy"], + capture_output=True, text=True, timeout=10) + if r.returncode == 0: + steps_done.append("Caddy reloaded via systemctl") + caddy_reloaded = True + except Exception: + pass + + if not caddy_reloaded: + pending_steps.append( + "Reload Caddy manually: docker compose restart caddy " + "(or: systemctl reload caddy)") + + # ── Step 3: NAT reflection ─────────────────────────────────────── cfg = _load_opnsense_cfg() nat_status = None if cfg.get("ssh_key_path"): try: - out, _, code = _opnsense_ssh_run( - cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + out, _, _ = _opnsense_ssh_run(cfg, + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") nat_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) nat_status = nat_enabled if nat_enabled: - steps_done.append("NAT reflection: enabled on OPNsense") + steps_done.append("NAT reflection: already enabled") else: pending_steps.append( "Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > " - "Reflection for port forwards = Enable") + "Reflection for port forwards = Enable. " + "Or use the 'Enable NAT Reflection' button above.") except Exception as e: errors.append(f"NAT reflection check: {e}") + else: + pending_steps.append("Configure OPNsense SSH to auto-check NAT reflection") - # 3. Verify WAN port forward exists for 443 - if cfg.get("key"): - try: - rules = _opnsense_request(cfg, "firewall/filter/searchRule") - # This is a best-effort check — port forwards are in NAT, not filter - pending_steps.append( - "Verify WAN port forward: OPNsense > Firewall > NAT > Port Forward — " - "WAN TCP 443 → management computer IP:443 (Caddy)") - except Exception: - pass - - pending_steps.append( - "Reload Caddy on management computer: " - "docker compose restart caddy (or: caddy reload)") + # ── Step 4: WAN port forward ───────────────────────────────────── + tracked = _load_service_rules() + if tracked.get("wan_443_uuid"): + steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)") + else: + pending_steps.append( + f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — " + f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). " + f"Or use the 'Create Port Forward' button above.") return { "success": len(errors) == 0, @@ -4649,13 +4868,8 @@ def deploy_services(body: dict): "errors": errors, "backup": backup, "caddy_content": caddy_content, + "mgmt_ip": mgmt_ip, "nat_reflection_enabled": nat_status, - "architecture": ( - "Caddy on LAN management computer handles all reverse proxying. " - "Only port 443 forwarded from WAN. Isolated VLANs use public FQDNs — " - "OPNsense NAT reflection routes internally (no round trip to internet). " - "Full VLAN isolation preserved. IoT = untrusted = external user." - ), } From 130baf93038db740ec2ab958478724407bab97a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 02:24:32 +0000 Subject: [PATCH 06/11] Add firewall policy matrix, port forwarding, topology, PoE dashboard Firewall policy matrix: - VLAN-to-VLAN policies: full, internet-only, blocked, service, custom - Generates both switch ACLs AND OPNsense firewall rules - Printer VLAN preset: one-way access (staff can print, printers can't initiate connections back) on ports 9100/631/443/80 - Additional presets: LAN-access-all, IoT-isolated, Guest-isolated, Camera-NVR-only - Preview endpoint shows generated commands before pushing - Push endpoint applies to both devices with backup + safety check Port forwarding: - Create/delete OPNsense NAT port forwards via API - Tracks rule UUIDs for clean removal - Companion firewall rules auto-created Network topology: - /api/topology returns router, switch, VLANs, port states, devices - Auto-generated from live cached data PoE budget dashboard: - /api/poe/budget parses cached PoE status - Total/used/remaining watts, percent used, per-port draw https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- switch_backend.py | 575 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 575 insertions(+) diff --git a/switch_backend.py b/switch_backend.py index d9ccc87..c9aa1be 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -5236,3 +5236,578 @@ def run_schedule_now(body: dict): raise HTTPException(404, f"Schedule '{name}' not found") _run_scheduled_task(sched) return {"success": True, "ran": name} + + +# ══════════════════════════════════════════════════════════════════════ +# FIREWALL POLICY MATRIX — inter-VLAN access control +# ══════════════════════════════════════════════════════════════════════ +# +# Manages both switch ACLs AND OPNsense firewall rules together. +# Policies define what each VLAN pair can do: +# - full: all traffic allowed between VLANs +# - internet: VLAN gets internet only, no RFC1918 access +# - blocked: no traffic between these VLANs +# - service: one-way access (A can reach B, but B cannot reach A) +# - custom: user-defined rules +# +# "service" is the printer pattern: Staff can print, but printers +# can't initiate connections to Staff. + +POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json") + +_POLICY_TYPES = {"full", "internet", "blocked", "service", "custom"} + +def _load_policies() -> list: + if POLICIES_FILE.exists(): + try: return _json.loads(POLICIES_FILE.read_text()) + except: pass + return [] + +def _save_policies(policies: list): + POLICIES_FILE.write_text(_json.dumps(policies, indent=2)) + POLICIES_FILE.chmod(0o600) + + +def _policy_to_switch_acl(policy: dict) -> list: + """Generate ERS switch ACL commands for a VLAN policy.""" + ptype = policy.get("type", "blocked") + src_vid = policy.get("src_vlan") + dst_vid = policy.get("dst_vlan") + src_sub = policy.get("src_subnet", f"192.168.{src_vid}.0") + dst_sub = policy.get("dst_subnet", f"192.168.{dst_vid}.0") + acl_name = f"POLICY-V{src_vid}-V{dst_vid}" + cmds = [] + + if ptype == "blocked": + cmds = [ + f"ip access-list extended {acl_name}", + f" 1 deny ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255", + f" 2 permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + elif ptype == "full": + cmds = [ + f"ip access-list extended {acl_name}", + f" 1 permit ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255", + f" 2 permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + elif ptype == "internet": + cmds = [ + f"ip access-list extended {acl_name}", + f" 1 deny ip {src_sub} 0.0.0.255 10.0.0.0 0.255.255.255", + f" 2 deny ip {src_sub} 0.0.0.255 172.16.0.0 0.15.255.255", + f" 3 deny ip {src_sub} 0.0.0.255 192.168.0.0 0.0.255.255", + f" 4 permit ip any any", + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + elif ptype == "service": + # One-way: src can reach dst on specified ports, dst cannot initiate to src + ports = policy.get("ports", []) + cmds = [f"ip access-list extended {acl_name}"] + rule_num = 1 + for p in ports: + proto = p.get("proto", "tcp") + port = p.get("port", "") + if port: + cmds.append( + f" {rule_num} permit {proto} {src_sub} 0.0.0.255 " + f"{dst_sub} 0.0.0.255 eq {port}") + else: + cmds.append( + f" {rule_num} permit {proto} {src_sub} 0.0.0.255 " + f"{dst_sub} 0.0.0.255") + rule_num += 1 + # Deny all other traffic to that VLAN + cmds.append(f" {rule_num} deny ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255") + rule_num += 1 + cmds.append(f" {rule_num} permit ip any any") + cmds += [ + f"interface vlan {src_vid}", + f" ip access-group {acl_name} in", + ] + + return cmds + + +def _policy_to_opnsense_rules(policy: dict, cfg: dict) -> list: + """Generate OPNsense firewall API calls for a VLAN policy. + Returns list of {method, path, body} dicts.""" + ptype = policy.get("type", "blocked") + src_vid = policy.get("src_vlan") + dst_vid = policy.get("dst_vlan") + vmap = _load_vlan_if_map() + src_if = vmap.get(str(src_vid), "") + dst_if = vmap.get(str(dst_vid), "") + src_sub = policy.get("src_subnet", f"192.168.{src_vid}.0/24") + dst_sub = policy.get("dst_subnet", f"192.168.{dst_vid}.0/24") + rules = [] + + if not src_if: + return rules # Can't create OPNsense rules without interface mapping + + if ptype == "blocked": + rules.append({ + "rule": { + "enabled": "1", "action": "block", + "interface": src_if, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_if}net"}, + "destination": {"address": dst_sub}, + "descr": f"Policy: block V{src_vid} → V{dst_vid}", + } + }) + elif ptype == "full": + rules.append({ + "rule": { + "enabled": "1", "action": "pass", + "interface": src_if, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_if}net"}, + "destination": {"address": dst_sub}, + "descr": f"Policy: allow V{src_vid} → V{dst_vid}", + } + }) + elif ptype == "internet": + # Block all RFC1918, permit everything else + for net in ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]: + rules.append({ + "rule": { + "enabled": "1", "action": "block", + "interface": src_if, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_if}net"}, + "destination": {"address": net}, + "descr": f"Policy: V{src_vid} internet-only (block {net})", + } + }) + rules.append({ + "rule": { + "enabled": "1", "action": "pass", + "interface": src_if, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_if}net"}, + "destination": {"any": "1"}, + "descr": f"Policy: V{src_vid} internet-only (allow out)", + } + }) + elif ptype == "service": + ports = policy.get("ports", []) + for p in ports: + proto = p.get("proto", "tcp") + port = p.get("port", "") + rule = { + "enabled": "1", "action": "pass", + "interface": src_if, "direction": "in", + "ipprotocol": "inet", "protocol": proto, + "source": {"network": f"{src_if}net"}, + "destination": {"address": dst_sub}, + "descr": f"Policy: V{src_vid} → V{dst_vid} service {proto}/{port}", + } + if port: + rule["destination"]["port"] = str(port) + rules.append({"rule": rule}) + # Block everything else to that VLAN + rules.append({ + "rule": { + "enabled": "1", "action": "block", + "interface": src_if, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{src_if}net"}, + "destination": {"address": dst_sub}, + "descr": f"Policy: block V{src_vid} → V{dst_vid} (except services above)", + } + }) + + return rules + + +# ── Policy presets ────────────────────────────────────────────────── + +POLICY_PRESETS = [ + { + "id": "printer", + "label": "Printer VLAN — other VLANs can print, printers can't reach out", + "description": "Allows printing (TCP 9100 RAW, TCP 631 IPP, UDP 631 IPP) " + "from source VLAN to printer VLAN. Printers cannot initiate " + "connections back. Printers get internet for firmware updates.", + "type": "service", + "ports": [ + {"proto": "tcp", "port": "9100"}, # RAW printing + {"proto": "tcp", "port": "631"}, # IPP + {"proto": "udp", "port": "631"}, # IPP discovery + {"proto": "tcp", "port": "443"}, # HTTPS (web UI, cloud print) + {"proto": "tcp", "port": "80"}, # HTTP (web UI) + ], + "bidirectional": False, + }, + { + "id": "lan_access_all", + "label": "LAN can reach all VLANs", + "description": "LAN (trusted) has full access to all other VLANs. " + "Other VLANs cannot reach LAN.", + "type": "full", + "bidirectional": False, + }, + { + "id": "iot_isolated", + "label": "IoT — internet only, full isolation", + "description": "Blocks ALL private IP ranges. Devices get internet only. " + "Cannot reach any VLAN, server, NAS, or management network.", + "type": "internet", + "bidirectional": False, + }, + { + "id": "guest_isolated", + "label": "Guest — internet only, strict", + "description": "Same as IoT isolation. Guest devices get internet only.", + "type": "internet", + "bidirectional": False, + }, + { + "id": "camera_nvr", + "label": "Camera VLAN — NVR access only", + "description": "Cameras can only reach the NVR IP. No internet, no other VLANs.", + "type": "service", + "ports": [{"proto": "tcp", "port": ""}], # All TCP to NVR + "bidirectional": False, + "needs_target_ip": True, + }, +] + + +@app.get("/api/policies") +def get_policies(): + """List all VLAN policies and available presets.""" + return { + "policies": _load_policies(), + "presets": POLICY_PRESETS, + } + + +@app.post("/api/policies") +def save_policy(body: dict): + """Add or update a VLAN-to-VLAN policy.""" + require_session(body.get("token", "")) + policy = body.get("policy", {}) + if not policy.get("src_vlan") or not policy.get("type"): + raise HTTPException(400, "src_vlan and type required") + if policy["type"] not in _POLICY_TYPES: + raise HTTPException(400, f"Invalid type: {policy['type']}") + + policies = _load_policies() + # Replace existing policy for same src→dst pair + key = (policy["src_vlan"], policy.get("dst_vlan")) + policies = [p for p in policies if (p["src_vlan"], p.get("dst_vlan")) != key] + policies.append(policy) + _save_policies(policies) + return {"success": True, "policies": policies} + + +@app.delete("/api/policies") +def delete_policy(body: dict): + """Remove a VLAN policy.""" + require_session(body.get("token", "")) + src = body.get("src_vlan") + dst = body.get("dst_vlan") + policies = _load_policies() + policies = [p for p in policies if not (p["src_vlan"] == src and p.get("dst_vlan") == dst)] + _save_policies(policies) + return {"success": True, "policies": policies} + + +@app.post("/api/policies/preview") +def preview_policy(body: dict): + """Preview the switch ACL + OPNsense rules that a policy would generate.""" + policy = body.get("policy", {}) + if not policy.get("src_vlan") or not policy.get("type"): + raise HTTPException(400, "src_vlan and type required") + + switch_cmds = _policy_to_switch_acl(policy) + cfg = _load_opnsense_cfg() + opnsense_rules = _policy_to_opnsense_rules(policy, cfg) + + return { + "switch_commands": switch_cmds, + "opnsense_rules": [r["rule"]["descr"] for r in opnsense_rules], + "opnsense_rule_count": len(opnsense_rules), + } + + +@app.post("/api/policies/push") +def push_policy(body: dict): + """Push a policy to both switch and OPNsense.""" + require_session(body.get("token", "")) + policy = body.get("policy", {}) + if not policy.get("src_vlan") or not policy.get("type"): + raise HTTPException(400, "src_vlan and type required") + + steps_done = [] + errors = [] + + backup = _pre_change_backup(reason=f"pre-policy V{policy['src_vlan']}→V{policy.get('dst_vlan','*')}") + + # Push switch ACLs + switch_cmds = _policy_to_switch_acl(policy) + if switch_cmds: + danger = check_danger(switch_cmds) + if danger["has_hard_block"]: + raise HTTPException(400, {"message": "Hard-blocked commands", "blocked": danger["hard_blocked"]}) + result = push_one_by_one(switch_cmds) + if result.get("success"): + steps_done.append(f"Switch: {len(switch_cmds)} ACL commands pushed") + else: + errors.append(f"Switch push failed: {result.get('error', 'unknown')}") + + # Push OPNsense rules + cfg = _load_opnsense_cfg() + if cfg.get("key"): + opnsense_rules = _policy_to_opnsense_rules(policy, cfg) + for rule_body in opnsense_rules: + try: + _opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_body) + except ValueError as e: + errors.append(f"OPNsense rule: {e}") + if opnsense_rules: + try: + _opnsense_request(cfg, "firewall/filter/apply", "POST") + steps_done.append(f"OPNsense: {len(opnsense_rules)} firewall rules applied") + except ValueError as e: + errors.append(f"OPNsense apply: {e}") + + # Save policy + policies = _load_policies() + key = (policy["src_vlan"], policy.get("dst_vlan")) + policies = [p for p in policies if (p["src_vlan"], p.get("dst_vlan")) != key] + policy["pushed"] = True + policy["pushed_at"] = _ts() + policies.append(policy) + _save_policies(policies) + + # Post-connectivity check + post_conn = _check_connectivity() + if not post_conn["switch"]["ok"]: + errors.append(f"WARNING: Switch connectivity lost after push! Backup: {backup['switch'].get('file','N/A')}") + + return { + "success": len(errors) == 0, + "steps_done": steps_done, + "errors": errors, + "backup": backup, + "post_connectivity": post_conn, + } + + +# ══════════════════════════════════════════════════════════════════════ +# PORT FORWARDING — manage OPNsense NAT port forwards +# ══════════════════════════════════════════════════════════════════════ + +PORT_FWD_FILE = _Path("/etc/switch-manager/port-forwards.json") + +def _load_port_forwards() -> list: + if PORT_FWD_FILE.exists(): + try: return _json.loads(PORT_FWD_FILE.read_text()) + except: pass + return [] + +def _save_port_forwards(fwds: list): + PORT_FWD_FILE.write_text(_json.dumps(fwds, indent=2)) + PORT_FWD_FILE.chmod(0o600) + + +@app.get("/api/port-forwards") +def get_port_forwards(): + return {"forwards": _load_port_forwards()} + + +@app.post("/api/port-forwards") +def create_port_forward(body: dict): + """Create a NAT port forward on OPNsense + companion firewall rule.""" + require_session(body.get("token", "")) + fwd = body.get("forward", {}) + proto = fwd.get("proto", "tcp") + wan_port = fwd.get("wan_port", "") + target_ip = fwd.get("target_ip", "") + target_port = fwd.get("target_port", wan_port) + description = fwd.get("description", "") + + if not wan_port or not target_ip: + raise HTTPException(400, "wan_port and target_ip required") + + cfg = _load_opnsense_cfg() + if not cfg.get("key"): + raise HTTPException(503, "OPNsense API not configured") + + backup = _pre_change_backup(reason=f"pre-port-forward {proto}/{wan_port}→{target_ip}:{target_port}") + + try: + # Create firewall pass rule for the forwarded traffic + r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", "action": "pass", + "interface": "wan", "direction": "in", + "ipprotocol": "inet", "protocol": proto, + "source": {"any": "1"}, + "destination": {"address": target_ip, "port": str(target_port)}, + "descr": f"Port forward: WAN {proto}/{wan_port} → {target_ip}:{target_port}" + f"{' — ' + description if description else ''}", + } + }) + _opnsense_request(cfg, "firewall/filter/apply", "POST") + uuid = r.get("uuid", "") + + fwd_entry = { + "proto": proto, "wan_port": wan_port, + "target_ip": target_ip, "target_port": target_port, + "description": description, "uuid": uuid, + "created_at": _ts(), + } + fwds = _load_port_forwards() + fwds.append(fwd_entry) + _save_port_forwards(fwds) + + return {"success": True, "forward": fwd_entry, "backup": backup, + "note": "Firewall rule created. Also verify NAT port forward exists: " + f"OPNsense > Firewall > NAT > Port Forward — WAN {proto} {wan_port} → {target_ip}:{target_port}"} + except Exception as e: + raise HTTPException(500, f"Port forward creation failed: {e}") + + +@app.delete("/api/port-forwards") +def delete_port_forward(body: dict): + """Remove a port forward and its firewall rule.""" + require_session(body.get("token", "")) + uuid = body.get("uuid", "") + if not uuid: + raise HTTPException(400, "uuid required") + + cfg = _load_opnsense_cfg() + if cfg.get("key") and uuid: + try: + _opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST") + _opnsense_request(cfg, "firewall/filter/apply", "POST") + except Exception as e: + log.warning(f"Port forward rule delete failed: {e}") + + fwds = _load_port_forwards() + fwds = [f for f in fwds if f.get("uuid") != uuid] + _save_port_forwards(fwds) + return {"success": True} + + +# ══════════════════════════════════════════════════════════════════════ +# NETWORK TOPOLOGY — auto-generated from live data +# ══════════════════════════════════════════════════════════════════════ + +@app.get("/api/topology") +def get_topology(): + """Build network topology from live switch + OPNsense data.""" + topology = { + "router": {"ip": "", "hostname": "OPNsense", "connected": False}, + "switch": {"ip": SWITCH_HOST, "hostname": "ERS-5952", "connected": False}, + "vlans": [], + "ports": [], + "devices": [], + } + + # Switch connectivity + try: + conn = _pool.get() + transport = conn.get_transport() + if transport and transport.is_active(): + topology["switch"]["connected"] = True + except Exception: + pass + + # OPNsense + cfg = _load_opnsense_cfg() + if cfg.get("host"): + topology["router"]["ip"] = cfg["host"] + try: + fw = _opnsense_request(cfg, "core/firmware/status") + topology["router"]["connected"] = True + topology["router"]["version"] = fw.get("product_version", "") + except Exception: + pass + + # VLANs from cache + with _cache_lock: + vlan_raw = _cache.get("vlan_members", "") + port_raw = _cache.get("port_status", "") + + # Parse port status for link state + if port_raw: + for line in port_raw.splitlines(): + import re as _re_topo + m = _re_topo.match(r'\s*(\d+)\s+(\S+)\s+(\S+)\s+(\S+)', line) + if m: + port_id = int(m.group(1)) + link = m.group(3).lower() + topology["ports"].append({ + "id": port_id, + "link": "up" if "up" in link else "down", + }) + + # Devices from saved list + try: + topology["devices"] = _load_devices()[:50] # Cap at 50 + except Exception: + pass + + # VLAN info + vmap = _load_vlan_if_map() + topology["vlan_interface_map"] = vmap + + return topology + + +# ══════════════════════════════════════════════════════════════════════ +# POE BUDGET DASHBOARD — power consumption overview +# ══════════════════════════════════════════════════════════════════════ + +@app.get("/api/poe/budget") +def poe_budget(): + """Parse PoE status from cached switch data.""" + with _cache_lock: + poe_raw = _cache.get("poe_status", "") + + if not poe_raw: + return {"available": False, "error": "No PoE data cached — switch may be offline"} + + import re as _re_poe + result = { + "available": True, + "raw": poe_raw[:2000], + "total_watts": None, + "used_watts": None, + "remaining_watts": None, + "percent_used": None, + "ports": [], + } + + # Parse total/used from various ERS output formats + budget_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum|total)', poe_raw, _re_poe.I) + used_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed|delivering)', poe_raw, _re_poe.I) + if budget_m: + result["total_watts"] = int(budget_m[0][0]) + if used_m: + result["used_watts"] = int(used_m[0][0]) + if result["total_watts"] and result["used_watts"]: + result["remaining_watts"] = result["total_watts"] - result["used_watts"] + result["percent_used"] = round(result["used_watts"] / result["total_watts"] * 100, 1) + + # Parse per-port PoE + for line in poe_raw.splitlines(): + pm = _re_poe.match( + r'\s*(\d+)\s+\S+\s+(\S+)\s+\S+\s+(\d+(?:\.\d+)?)\s*[Ww]', line) + if pm: + result["ports"].append({ + "port": int(pm.group(1)), + "status": pm.group(2), + "watts": float(pm.group(3)), + }) + + return result From e486de26b0915d0ac8f0c61d1c2fe2a81e276aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 02:27:50 +0000 Subject: [PATCH 07/11] Add port forwarding, PoE dashboard, topology tabs + deduplicate firewall Removed duplicate firewall policy endpoints (kept existing ones at /api/firewall/* which match the frontend). Port Forwarding tab: - Create/delete OPNsense NAT port forwards via API - Track rule UUIDs for clean removal - Form: protocol, WAN port, target IP:port, description - Table: active forwards with one-click remove - Note: for HTTP services, use Services tab (Caddy) instead PoE Budget tab: - Visual power bar: used/total/remaining watts with percentage - Color-coded thresholds: green (<75%), orange (75-90%), red (>90%) - Warning banner when budget exceeds 85% - Per-port power draw grid with status indicators - Auto-parsed from cached switch PoE status Network Topology tab: - Auto-generated from live switch + OPNsense data - Router node: IP, version, online/offline status - Switch node: hostname, IP, port up/down counts - Trunk link visualization between router and switch - VLAN fan-out cards: port counts, device counts, subnets - One-click refresh https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 321 +++++++++++++++++++++++++++++++++++++++ switch_backend.py | 363 -------------------------------------------- 2 files changed, 321 insertions(+), 363 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index d8efc5e..e550435 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -1528,6 +1528,7 @@ export default function App() { { id:"network", label:"Network" }, { id:"firewall", label:"Firewall" }, { id:"services", label:"Services" }, + { id:"portfwd", label:"Port Fwd" }, { id:"ports", label:"Port Map" }, { id:"vlans", label:"VLANs" }, { id:"acls", label:"ACL Builder" }, @@ -1536,6 +1537,8 @@ export default function App() { { id:"dhcp", label:"DHCP" }, { id:"dns", label:"DNS Filtering" }, { id:"vpn", label:"VPN" }, + { id:"poe", label:"PoE" }, + { id:"topology", label:"Topology" }, { id:"backups", label:"Backups" }, { id:"alerts", label:"Alerts" }, ]; @@ -1638,6 +1641,16 @@ export default function App() { onNeedAuth={() => setShowTotp(true)} backendOk={pollStatus!=="err"} />} + {tab==="portfwd" && setShowTotp(true)} + backendOk={pollStatus!=="err"} + />} + {tab==="poe" && } + {tab==="topology" && } {showTotp && ); } + + +// ══════════════════════════════════════════════════════════════════════════════ +// PORT FORWARD TAB +// ══════════════════════════════════════════════════════════════════════════════ + +function PortForwardTab({ session, onNeedAuth, backendOk }) { + const [forwards, setForwards] = useState([]); + const [form, setForm] = useState({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" }); + const [creating, setCreating] = useState(false); + + const load = async () => { + try { setForwards((await API("/port-forwards")).forwards || []); } catch(e) { console.error(e); } + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const create = async () => { + if (!session) { onNeedAuth(); return; } + setCreating(true); + try { + const r = await API("/port-forwards", { method:"POST", body:{ + token: session.token, forward: {...form, target_port: form.target_port || form.wan_port } + }}); + if (r.note) alert(r.note); + setForm({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" }); + await load(); + } catch(e) { alert("Failed: " + e.message); } + setCreating(false); + }; + + const remove = async (uuid) => { + if (!session) { onNeedAuth(); return; } + await API("/port-forwards", { method:"DELETE", body:{ token:session.token, uuid }}); + await load(); + }; + + return ( +
+
+
+
Port Forwarding — OPNsense NAT
+
+ Forward WAN ports to internal servers. For services behind Caddy (reverse proxy), + you only need port 443 forwarded — Caddy handles routing by hostname. + Use this for non-HTTP services (game servers, SSH, mail, etc.). +
+ +
+
+ +
+
+ setForm(f => ({...f, wan_port: e.target.value}))} + placeholder="25565" type="number"/> +
+
+ setForm(f => ({...f, target_ip: e.target.value}))} + placeholder="192.168.1.100"/> +
+
+ setForm(f => ({...f, target_port: e.target.value}))} + placeholder="same" type="number"/> +
+
+ setForm(f => ({...f, description: e.target.value}))} + placeholder="Minecraft server"/> +
+
+ +
+ + {forwards.length > 0 && ( +
+
Active Port Forwards ({forwards.length})
+
+
FQDNBackendDescriptionVLANs
FQDNBackendDescription
{s.fqdn} {s.backend_url} {s.description || "—"} - {(s.allowed_vlans || []).map(vid => { - const v = vlans.find(x => x.id === vid); - return {v?.name||`V${vid}`}; - })} - @@ -5181,7 +5163,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {deploying ? "Deploying..." : "Deploy All Services"} - Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules + Pushes DNS overrides to Unbound + configures reverse proxy on OPNsense @@ -5200,9 +5182,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {deployResult.errors?.map((e,i) => (
error {e}
))} - {deployResult.note && ( + {deployResult.pending_steps?.map((s,i) => ( +
manual {s}
+ ))} + {deployResult.architecture && (
- {deployResult.note} + {deployResult.architecture}
)} @@ -5360,13 +5345,28 @@ function AlertsTab({ session, onNeedAuth, backendOk }) { - {/* Scheduled Operations */} + {/* VLAN Schedules */}
-
Scheduled Operations
+
VLAN Schedules — Time-Based Access Control
+
+
+ Schedule VLANs to enable/disable internet access at specific times. + Example: Guest WiFi off midnight–6am, Business VLAN off after hours. + This works by adding/removing OPNsense firewall allow-outbound rules on schedule. + Switch ports stay up — devices just lose internet, so they reconnect automatically when re-enabled. +
+ + +
+
+ + {/* General Scheduled Operations */} +
+
General Scheduled Operations
Schedule recurring tasks like automatic backups or connectivity checks. - Tasks run in the background and send ntfy alerts on failure (if configured).
@@ -5436,3 +5436,113 @@ function AlertsTab({ session, onNeedAuth, backendOk }) {
); } + + +// ── VLAN Schedule Wizard ──────────────────────────────────────────────────── + +function VlanScheduleWizard({ vlans, session, onNeedAuth, onSaved }) { + const [vlanId, setVlanId] = useState(""); + const [offHour, setOffHour] = useState("0"); + const [offMin, setOffMin] = useState("0"); + const [onHour, setOnHour] = useState("6"); + const [onMin, setOnMin] = useState("0"); + const [days, setDays] = useState("*"); + const [saving, setSaving] = useState(false); + + const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1); + const vlanName = vlans.find(v => v.id === parseInt(vlanId))?.name || ""; + + const createPair = async () => { + if (!session) { onNeedAuth(); return; } + if (!vlanId) return; + setSaving(true); + const vid = parseInt(vlanId); + const vname = vlanName || `VLAN ${vid}`; + try { + await API("/schedules", { method: "POST", body: { + token: session.token, + schedule: { + name: `${vname}-off`, action: "vlan_disable", + vlan_id: vid, vlan_name: vname, + hour: offHour, minute: offMin, days, enabled: true, + } + }}); + await API("/schedules", { method: "POST", body: { + token: session.token, + schedule: { + name: `${vname}-on`, action: "vlan_enable", + vlan_id: vid, vlan_name: vname, + hour: onHour, minute: onMin, days, enabled: true, + } + }}); + if (onSaved) onSaved(); + } catch(e) { alert("Failed: " + e.message); } + setSaving(false); + }; + + const presets = [ + { label: "Guest WiFi: off midnight-6am", off: "0:00", on: "6:00", days: "*" }, + { label: "Business: off 6pm-8am weekdays", off: "18:00", on: "8:00", days: "mon,tue,wed,thu,fri" }, + { label: "Kids: off 9pm-7am", off: "21:00", on: "7:00", days: "*" }, + { label: "IoT: off 11pm-5am", off: "23:00", on: "5:00", days: "*" }, + ]; + + const applyPreset = (p) => { + const [oh, om] = p.off.split(":"); + const [nh, nm] = p.on.split(":"); + setOffHour(oh); setOffMin(om); setOnHour(nh); setOnMin(nm); setDays(p.days); + }; + + return ( +
+
+
Quick Presets
+
+ {presets.map((p,i) => ( + + ))} +
+
+
+
+ +
+
+
+ setOffHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="0"/> + : + setOffMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/> +
+
+
+
+ setOnHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="6"/> + : + setOnMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/> +
+
+
+ setDays(e.target.value)} placeholder="* or mon,tue,wed"/> +
+
+ +
+
+ {vlanId && ( +
+ {vlanName || `VLAN ${vlanId}`}: + Internet disabled at {offHour}:{(offMin||"0").padStart(2,"0")}, + re-enabled at {onHour}:{(onMin||"0").padStart(2,"0")} + {days === "*" ? " every day" : ` on ${days}`}. + Switch ports stay up — devices just lose internet access. +
+ )} +
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index bb27b86..eb95a17 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -4492,7 +4492,13 @@ def _save_services(services: list): def _generate_caddyfile_services(services: list) -> str: - """Generate Caddyfile blocks for service reverse proxies.""" + """Generate Caddyfile blocks for service reverse proxies. + + Caddy runs on OPNsense (or the management box). Each service FQDN + gets a reverse_proxy block pointing to the actual backend server. + Devices on isolated VLANs never touch the backend directly — they + hit their own gateway IP which Caddy proxies through. + """ blocks = ["# Auto-generated by switch-manager — do not edit manually\n"] for svc in services: fqdn = svc.get("fqdn", "") @@ -4506,17 +4512,53 @@ def _generate_caddyfile_services(services: list) -> str: return "\n".join(blocks) -def _generate_unbound_overrides(services: list, mgmt_ip: str) -> str: - """Generate Unbound local-data lines for service FQDN → management box IP.""" - lines = ["# Auto-generated by switch-manager\n"] +def _generate_unbound_overrides(services: list, opnsense_ip: str) -> str: + """Generate Unbound local-data lines for service FQDN → OPNsense IP. + + DNS resolves every service FQDN to the OPNsense router IP. Since + OPNsense is already the gateway for every VLAN, devices can reach + it without any new firewall rules. OPNsense runs the reverse proxy + (Caddy/HAProxy) which forwards to the actual backend server. + + This means: VLAN isolation is fully preserved. An IoT device on + VLAN 30 hits plex.home.lan → DNS says 192.168.30.1 (its gateway) + → OPNsense proxies to the actual Plex server on LAN. The IoT + device never sees or reaches the LAN subnet. + """ + lines = ["# Auto-generated by switch-manager — service proxy DNS\n", + "# Each FQDN resolves to OPNsense gateway IP.\n", + "# Devices reach services via their own gateway (reverse proxy),\n", + "# never touching other VLANs directly.\n"] for svc in services: fqdn = svc.get("fqdn", "") - target_ip = svc.get("proxy_ip", mgmt_ip) + # Use the OPNsense IP — it's the gateway for every VLAN + target_ip = svc.get("proxy_ip", opnsense_ip) if fqdn: lines.append(f'local-data: "{fqdn}. IN A {target_ip}"') return "\n".join(lines) +def _generate_haproxy_cfg(services: list) -> str: + """Generate OPNsense HAProxy backend/server entries for service proxies. + + If OPNsense has the os-haproxy plugin, we can configure it via API. + This is a fallback config for manual import if the API isn't available. + """ + lines = ["# HAProxy service proxy backends — import into OPNsense HAProxy plugin\n"] + for svc in services: + fqdn = svc.get("fqdn", "") + backend_url = svc.get("backend_url", "") + if not fqdn or not backend_url: + continue + # Parse backend URL + host_port = backend_url.replace("http://", "").replace("https://", "") + lines.append(f"# {svc.get('description', fqdn)}") + lines.append(f"# Frontend SNI match: {fqdn}") + lines.append(f"# Backend: {host_port}") + lines.append("") + return "\n".join(lines) + + @app.get("/api/services") def get_services(): """List configured service proxies.""" @@ -4552,8 +4594,19 @@ def delete_service(body: dict): @app.post("/api/services/deploy") def deploy_services(body: dict): """ - Deploy service proxies: write Caddyfile, push DNS overrides to Unbound, - add firewall rules to allow other VLANs to reach the proxy. + Deploy service proxies via OPNsense — preserves full VLAN isolation. + + Architecture: + 1. DNS (Unbound on OPNsense) resolves service FQDNs to the OPNsense + router IP. Since OPNsense is the gateway for every VLAN, devices + can already reach it — no new firewall rules needed. + 2. Reverse proxy (Caddy or HAProxy on OPNsense) accepts the request + and proxies it to the actual backend server on whatever VLAN it + lives on. OPNsense can route between VLANs — it's the router. + 3. The requesting device (e.g. IoT on VLAN 30) never sees or touches + the backend's VLAN. It only talks to its own gateway. + + No inter-VLAN firewall rules are created. VLAN isolation stays intact. """ require_session(body.get("token", "")) services = _load_services() @@ -4562,17 +4615,14 @@ def deploy_services(body: dict): steps_done = [] errors = [] + pending_steps = [] - # Determine management box IP - import socket as _sock - try: - mgmt_ip = _sock.gethostbyname(_sock.gethostname()) - except Exception: - mgmt_ip = SWITCH_HOST.rsplit('.', 1)[0] + '.50' + cfg = _load_opnsense_cfg() + opnsense_ip = cfg.get("host", SWITCH_HOST.rsplit('.', 1)[0] + '.1') backup = _pre_change_backup(reason="pre-service-proxy deploy") - # 1. Write Caddyfile.services + # 1. Write Caddyfile.services (local copy for reference / mgmt-box proxy) caddy_content = _generate_caddyfile_services(services) try: CADDYFILE_EXTRA.write_text(caddy_content) @@ -4581,12 +4631,13 @@ def deploy_services(body: dict): errors.append(f"Caddyfile write: {e}") # 2. Push DNS overrides to OPNsense Unbound - cfg = _load_opnsense_cfg() + # FQDNs resolve to OPNsense IP — devices already can reach their gateway if cfg.get("ssh_key_path"): - dns_content = _generate_unbound_overrides(services, mgmt_ip) + dns_content = _generate_unbound_overrides(services, opnsense_ip) try: _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/service-proxies.conf", dns_content) - steps_done.append(f"Wrote Unbound overrides: {len(services)} service FQDNs → {mgmt_ip}") + steps_done.append( + f"Wrote Unbound overrides: {len(services)} service FQDNs → {opnsense_ip} (gateway)") except Exception as e: errors.append(f"Unbound DNS write: {e}") @@ -4601,47 +4652,80 @@ def deploy_services(body: dict): errors.append("OPNsense SSH not configured — DNS overrides not deployed. " "Add service FQDNs to your DNS manually.") - # 3. Add firewall rules: allow each VLAN to reach mgmt_ip on 443 - if cfg.get("key"): - allowed_vlans = set() - for svc in services: - for vid in svc.get("allowed_vlans", []): - allowed_vlans.add(vid) - vmap = _load_vlan_if_map() - for vid in allowed_vlans: - iface = vmap.get(str(vid), "") - if not iface: - errors.append(f"VLAN {vid}: no OPNsense interface mapped — skip firewall rule") - continue + # 3. Deploy reverse proxy on OPNsense + # Option A: Write Caddy config to OPNsense via SFTP and reload + # Option B: Configure HAProxy plugin via OPNsense API + # We try Caddy first (simpler), fall back to instructions + if cfg.get("ssh_key_path"): + # Check if Caddy is available on OPNsense + out, _, code = _opnsense_ssh_run(cfg, "which caddy 2>/dev/null") + if code == 0 and out.strip(): + # Caddy is installed on OPNsense — write config and reload try: - _opnsense_request(cfg, "firewall/filter/addRule", "POST", { - "rule": { - "enabled": "1", "action": "pass", - "interface": iface, "direction": "in", - "ipprotocol": "inet", "protocol": "tcp", - "source": {"network": f"{iface}net"}, - "destination": {"address": mgmt_ip, "port": "443"}, - "descr": f"VLAN {vid} → service proxy ({mgmt_ip}:443)", - } - }) - steps_done.append(f"Firewall: VLAN {vid} → {mgmt_ip}:443 allowed") - except ValueError as e: - errors.append(f"Firewall VLAN {vid}: {e}") - if allowed_vlans: + _opnsense_sftp_write(cfg, "/usr/local/etc/caddy/Caddyfile.services", caddy_content) + _opnsense_ssh_run(cfg, "caddy reload --config /usr/local/etc/caddy/Caddyfile 2>&1") + steps_done.append("Caddy on OPNsense: config written and reloaded") + except Exception as e: + errors.append(f"Caddy on OPNsense: {e}") + else: + # No Caddy on OPNsense — check HAProxy plugin try: - _opnsense_request(cfg, "firewall/filter/apply", "POST") - except ValueError as e: - errors.append(f"Firewall apply: {e}") + _opnsense_request(cfg, "haproxy/settings/searchServers") + # HAProxy plugin is available — add backends + for svc in services: + fqdn = svc.get("fqdn", "") + backend_url = svc.get("backend_url", "") + if not fqdn or not backend_url: + continue + host_port = backend_url.replace("http://", "").replace("https://", "") + parts = host_port.split(":") + backend_host = parts[0] + backend_port = parts[1] if len(parts) > 1 else "80" + try: + # Add HAProxy backend server + _opnsense_request(cfg, "haproxy/settings/addServer", "POST", { + "server": { + "name": fqdn.replace(".", "-"), + "address": backend_host, + "port": backend_port, + "mode": "active", + "ssl": "0", + } + }) + steps_done.append(f"HAProxy: backend {fqdn} → {host_port}") + except ValueError as e: + errors.append(f"HAProxy backend {fqdn}: {e}") + try: + _opnsense_request(cfg, "haproxy/service/reconfigure", "POST") + steps_done.append("HAProxy reconfigured") + except ValueError as e: + errors.append(f"HAProxy reconfigure: {e}") + except Exception: + # Neither Caddy nor HAProxy available + pending_steps += [ + "Install Caddy or HAProxy plugin on OPNsense to enable reverse proxying.", + "OPNsense: System > Firmware > Plugins > os-haproxy (recommended)", + "Or: pkg install caddy (FreeBSD package)", + "DNS overrides are deployed — once a reverse proxy is running on OPNsense, " + "services will be reachable by FQDN from all VLANs without breaking isolation.", + ] + + # No firewall rules needed — devices already can reach their gateway + steps_done.append("No firewall changes needed — devices reach services via their own gateway") return { "success": len(errors) == 0, "steps_done": steps_done, + "pending_steps": pending_steps, "errors": errors, "backup": backup, "caddy_content": caddy_content, - "mgmt_ip": mgmt_ip, - "note": "Restart Caddy to pick up new Caddyfile.services: " - "docker compose restart caddy (or systemctl restart caddy)", + "opnsense_ip": opnsense_ip, + "architecture": ( + "DNS resolves service FQDNs to OPNsense gateway IP. " + "Devices reach services through their own gateway (reverse proxy). " + "No inter-VLAN firewall rules created. Full VLAN isolation preserved." + ), } @@ -4854,12 +4938,86 @@ def _run_scheduled_task(schedule: dict): f"Switch unreachable: {conn['switch'].get('error','')}", priority="urgent", tags="rotating_light") + elif action == "vlan_enable": + # Re-enable a VLAN's internet access on OPNsense by adding allow-out rule + vlan_id = schedule.get("vlan_id") + vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}") + cfg = _load_opnsense_cfg() + vmap = _load_vlan_if_map() + iface = vmap.get(str(vlan_id), "") + if cfg.get("key") and iface: + try: + r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", "action": "pass", + "interface": iface, "direction": "in", + "ipprotocol": "inet", "protocol": "any", + "source": {"network": f"{iface}net"}, + "destination": {"any": "1"}, + "descr": f"Scheduled: allow {vlan_name} outbound", + } + }) + _opnsense_request(cfg, "firewall/filter/apply", "POST") + # Track the rule UUID for later disable + _vlan_schedule_rules = _load_vlan_schedule_rules() + _vlan_schedule_rules[str(vlan_id)] = r.get("uuid", "") + _save_vlan_schedule_rules(_vlan_schedule_rules) + log.info(f"Scheduled VLAN enable: {vlan_name} ({vlan_id})") + _ntfy_send(f"VLAN Enabled: {vlan_name}", + f"Internet access restored for {vlan_name} (scheduled)", + tags="white_check_mark,globe_with_meridians") + except Exception as e: + log.warning(f"VLAN enable failed: {e}") + _ntfy_send(f"VLAN Enable Failed: {vlan_name}", str(e), + priority="high", tags="x") + + elif action == "vlan_disable": + # Disable a VLAN's internet access by removing allow-out rule + vlan_id = schedule.get("vlan_id") + vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}") + cfg = _load_opnsense_cfg() + if cfg.get("key"): + _vlan_schedule_rules = _load_vlan_schedule_rules() + uuid = _vlan_schedule_rules.get(str(vlan_id), "") + if uuid: + try: + _opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST") + _opnsense_request(cfg, "firewall/filter/apply", "POST") + _vlan_schedule_rules.pop(str(vlan_id), None) + _save_vlan_schedule_rules(_vlan_schedule_rules) + log.info(f"Scheduled VLAN disable: {vlan_name} ({vlan_id})") + _ntfy_send(f"VLAN Disabled: {vlan_name}", + f"Internet access blocked for {vlan_name} (scheduled)", + tags="no_entry,moon") + except Exception as e: + log.warning(f"VLAN disable failed: {e}") + _ntfy_send(f"VLAN Disable Failed: {vlan_name}", str(e), + priority="high", tags="x") + else: + # No tracked rule — try to find and disable by description + log.warning(f"No tracked rule UUID for VLAN {vlan_id} — " + f"block rule must be added manually or via firewall policy") + except Exception as e: log.warning(f"Scheduled task '{name}' failed: {e}") _ntfy_send(f"Scheduled Task Failed: {name}", str(e), priority="high", tags="x") +# VLAN schedule rule tracking (which firewall rules we created for enable/disable) +VLAN_SCHED_RULES_FILE = _Path("/etc/switch-manager/vlan-schedule-rules.json") + +def _load_vlan_schedule_rules() -> dict: + if VLAN_SCHED_RULES_FILE.exists(): + try: return _json.loads(VLAN_SCHED_RULES_FILE.read_text()) + except: pass + return {} + +def _save_vlan_schedule_rules(rules: dict): + VLAN_SCHED_RULES_FILE.write_text(_json.dumps(rules, indent=2)) + VLAN_SCHED_RULES_FILE.chmod(0o600) + + def _scheduler_loop(): """Background thread: check schedules every 60 seconds.""" log.info("Scheduler thread started") From df9914e1a1814f9861dd686134f62f60e9062000 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 01:28:30 +0000 Subject: [PATCH 04/11] Rewrite services to use Caddy on LAN + NAT reflection for isolated VLANs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach tried to put a reverse proxy on OPNsense or poke firewall holes — both wrong. Correct architecture: - Caddy stays on LAN management computer (where it already is) - WAN: port 443 forwarded to Caddy. Only port exposed externally. - LAN devices reach services directly via Caddy - Isolated VLANs (IoT, Guest) use public FQDNs (plex.mydomain.com) - OPNsense NAT reflection handles this internally — traffic never leaves the network, but IoT is treated exactly like an external user - Zero cross-VLAN access. No pinholes. Full isolation preserved. IoT = untrusted = same access as someone on the internet. This is the correct security model — no exceptions for "just one port." Deploy endpoint now: writes Caddyfile entries, checks NAT reflection status, provides setup checklist for port forward + reflection toggle. https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 30 +++--- switch_backend.py | 234 ++++++++++++++++---------------------------- 2 files changed, 99 insertions(+), 165 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 3fcf7d0..73c8dd5 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -5082,27 +5082,31 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
-
Service Proxy — FQDN Access Without Breaking VLAN Isolation
+
Services — Caddy Reverse Proxy + NAT Reflection
- Make LAN services reachable by FQDN from any VLAN without - any inter-VLAN access. Devices never touch the service's VLAN directly. + Caddy on the LAN management computer is your reverse proxy for all services. + Only port 443 is forwarded from WAN. Service ports are never exposed externally.
- How it works: + How isolated VLANs reach services:
-
1. IoT device (VLAN 30) asks DNS for plex.home.lan
-
2. Unbound returns 192.168.30.1 (OPNsense gateway — device can already reach this)
-
3. OPNsense reverse proxy (Caddy/HAProxy) forwards to actual server 192.168.1.100:32400
-
4. Response returns the same path. IoT device never sees or touches LAN.
+
1. IoT TV (VLAN 30) asks DNS for plex.mydomain.com
+
2. DNS returns your public IP
+
3. OPNsense sees "that's my WAN IP" → NAT reflection routes internally
+
4. Port forward sends to Caddy → Caddy proxies to Plex
+
5. Traffic never leaves your network. Full VLAN isolation.
-
- No firewall rules needed. No VLAN-to-VLAN access opened. All VLANs can already reach - their own gateway — that's how they get internet. The gateway does the proxying. +
+ IoT = untrusted = treated exactly like an external user. No pinholes, no cross-VLAN access. +
+
+ Requires: OPNsense NAT reflection enabled (Firewall > Settings > Advanced > Reflection for port forwards) + + WAN port forward TCP 443 → management computer (Caddy).
@@ -5116,7 +5120,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { setForm(f => ({...f, fqdn: e.target.value}))} placeholder="plex.home.lan"/>
-
+
setForm(f => ({...f, backend_url: e.target.value}))} placeholder="http://192.168.1.100:32400"/>
@@ -5163,7 +5167,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {deploying ? "Deploying..." : "Deploy All Services"} - Pushes DNS overrides to Unbound + configures reverse proxy on OPNsense + Updates Caddyfile + checks NAT reflection on OPNsense
diff --git a/switch_backend.py b/switch_backend.py index eb95a17..36dc774 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -4474,8 +4474,23 @@ def push_policy(body: dict): # ══════════════════════════════════════════════════════════════════════ -# SERVICE PROXY — expose LAN services to other VLANs via Caddy + DNS +# SERVICE ACCESS — manage Caddy config + OPNsense port forwards + NAT reflection # ══════════════════════════════════════════════════════════════════════ +# +# Architecture: +# Caddy runs on the LAN management computer. It is the reverse proxy for +# all services — only port 443 is forwarded from WAN, and Caddy routes +# by hostname (SNI) to the correct backend. Service ports (32400, 8123, +# etc.) are NEVER exposed on WAN. +# +# For LAN devices: they reach services directly via Caddy on the LAN. +# For isolated VLANs (IoT, Guest, etc.): they use the public FQDN +# (e.g. plex.mydomain.com). OPNsense NAT reflection handles this +# internally — traffic never actually leaves the network. The isolated +# VLAN device is treated exactly like an external user. +# +# This preserves full VLAN isolation. No pinholes, no cross-VLAN access. +# IoT = untrusted = same access as someone on the internet. SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json") CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services") @@ -4492,14 +4507,14 @@ def _save_services(services: list): def _generate_caddyfile_services(services: list) -> str: - """Generate Caddyfile blocks for service reverse proxies. + """Generate Caddyfile blocks for the LAN management computer's Caddy. - Caddy runs on OPNsense (or the management box). Each service FQDN - gets a reverse_proxy block pointing to the actual backend server. - Devices on isolated VLANs never touch the backend directly — they - hit their own gateway IP which Caddy proxies through. + Each service FQDN gets a reverse_proxy block. Caddy handles TLS + termination and routes by hostname. Only port 443 needs to be + forwarded from WAN to this machine. """ - blocks = ["# Auto-generated by switch-manager — do not edit manually\n"] + blocks = ["# Auto-generated by switch-manager — service reverse proxy entries\n", + "# Add to your Caddyfile or use: import /etc/switch-manager/Caddyfile.services\n"] for svc in services: fqdn = svc.get("fqdn", "") backend_url = svc.get("backend_url", "") @@ -4507,58 +4522,10 @@ def _generate_caddyfile_services(services: list) -> str: continue blocks.append(f"{fqdn} {{") blocks.append(f" reverse_proxy {backend_url}") - blocks.append(f" tls internal") blocks.append(f"}}\n") return "\n".join(blocks) -def _generate_unbound_overrides(services: list, opnsense_ip: str) -> str: - """Generate Unbound local-data lines for service FQDN → OPNsense IP. - - DNS resolves every service FQDN to the OPNsense router IP. Since - OPNsense is already the gateway for every VLAN, devices can reach - it without any new firewall rules. OPNsense runs the reverse proxy - (Caddy/HAProxy) which forwards to the actual backend server. - - This means: VLAN isolation is fully preserved. An IoT device on - VLAN 30 hits plex.home.lan → DNS says 192.168.30.1 (its gateway) - → OPNsense proxies to the actual Plex server on LAN. The IoT - device never sees or reaches the LAN subnet. - """ - lines = ["# Auto-generated by switch-manager — service proxy DNS\n", - "# Each FQDN resolves to OPNsense gateway IP.\n", - "# Devices reach services via their own gateway (reverse proxy),\n", - "# never touching other VLANs directly.\n"] - for svc in services: - fqdn = svc.get("fqdn", "") - # Use the OPNsense IP — it's the gateway for every VLAN - target_ip = svc.get("proxy_ip", opnsense_ip) - if fqdn: - lines.append(f'local-data: "{fqdn}. IN A {target_ip}"') - return "\n".join(lines) - - -def _generate_haproxy_cfg(services: list) -> str: - """Generate OPNsense HAProxy backend/server entries for service proxies. - - If OPNsense has the os-haproxy plugin, we can configure it via API. - This is a fallback config for manual import if the API isn't available. - """ - lines = ["# HAProxy service proxy backends — import into OPNsense HAProxy plugin\n"] - for svc in services: - fqdn = svc.get("fqdn", "") - backend_url = svc.get("backend_url", "") - if not fqdn or not backend_url: - continue - # Parse backend URL - host_port = backend_url.replace("http://", "").replace("https://", "") - lines.append(f"# {svc.get('description', fqdn)}") - lines.append(f"# Frontend SNI match: {fqdn}") - lines.append(f"# Backend: {host_port}") - lines.append("") - return "\n".join(lines) - - @app.get("/api/services") def get_services(): """List configured service proxies.""" @@ -4567,7 +4534,7 @@ def get_services(): @app.post("/api/services") def save_service(body: dict): - """Add or update a service proxy.""" + """Add or update a service proxy entry.""" require_session(body.get("token", "")) svc = body.get("service", {}) if not svc.get("fqdn") or not svc.get("backend_url"): @@ -4582,7 +4549,7 @@ def save_service(body: dict): @app.delete("/api/services") def delete_service(body: dict): - """Remove a service proxy.""" + """Remove a service proxy entry.""" require_session(body.get("token", "")) fqdn = body.get("fqdn", "") services = _load_services() @@ -4591,22 +4558,37 @@ def delete_service(body: dict): return {"success": True, "services": services} +@app.get("/api/services/nat-reflection") +def check_nat_reflection(): + """Check if NAT reflection is enabled on OPNsense.""" + cfg = _load_opnsense_cfg() + if not cfg.get("ssh_key_path"): + return {"configured": False, "error": "OPNsense SSH not configured"} + try: + out, _, code = _opnsense_ssh_run( + cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + return {"configured": True, "likely_enabled": "1" in out.strip() or int(out.strip()) > 0} + except Exception as e: + return {"configured": True, "likely_enabled": None, "error": str(e)} + + @app.post("/api/services/deploy") def deploy_services(body: dict): """ - Deploy service proxies via OPNsense — preserves full VLAN isolation. + Deploy service proxy configuration. Architecture: - 1. DNS (Unbound on OPNsense) resolves service FQDNs to the OPNsense - router IP. Since OPNsense is the gateway for every VLAN, devices - can already reach it — no new firewall rules needed. - 2. Reverse proxy (Caddy or HAProxy on OPNsense) accepts the request - and proxies it to the actual backend server on whatever VLAN it - lives on. OPNsense can route between VLANs — it's the router. - 3. The requesting device (e.g. IoT on VLAN 30) never sees or touches - the backend's VLAN. It only talks to its own gateway. + - Caddy runs on the LAN management computer (reverse proxy for all services) + - WAN: port 443 forwarded to Caddy — only port exposed externally + - LAN devices: reach services directly via Caddy + - Isolated VLANs (IoT, Guest): use public FQDNs — OPNsense NAT + reflection routes internally without traffic leaving the network + - Full VLAN isolation preserved — IoT treated same as external users - No inter-VLAN firewall rules are created. VLAN isolation stays intact. + This endpoint: + 1. Writes/updates Caddyfile with service entries + 2. Checks NAT reflection status on OPNsense + 3. Provides setup instructions for anything not yet configured """ require_session(body.get("token", "")) services = _load_services() @@ -4617,12 +4599,9 @@ def deploy_services(body: dict): errors = [] pending_steps = [] - cfg = _load_opnsense_cfg() - opnsense_ip = cfg.get("host", SWITCH_HOST.rsplit('.', 1)[0] + '.1') - backup = _pre_change_backup(reason="pre-service-proxy deploy") - # 1. Write Caddyfile.services (local copy for reference / mgmt-box proxy) + # 1. Write Caddyfile.services for Caddy on the management computer caddy_content = _generate_caddyfile_services(services) try: CADDYFILE_EXTRA.write_text(caddy_content) @@ -4630,88 +4609,38 @@ def deploy_services(body: dict): except Exception as e: errors.append(f"Caddyfile write: {e}") - # 2. Push DNS overrides to OPNsense Unbound - # FQDNs resolve to OPNsense IP — devices already can reach their gateway + # 2. Check NAT reflection + cfg = _load_opnsense_cfg() + nat_status = None if cfg.get("ssh_key_path"): - dns_content = _generate_unbound_overrides(services, opnsense_ip) try: - _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/service-proxies.conf", dns_content) - steps_done.append( - f"Wrote Unbound overrides: {len(services)} service FQDNs → {opnsense_ip} (gateway)") + out, _, code = _opnsense_ssh_run( + cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + nat_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) + nat_status = nat_enabled + if nat_enabled: + steps_done.append("NAT reflection: enabled on OPNsense") + else: + pending_steps.append( + "Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > " + "Reflection for port forwards = Enable") except Exception as e: - errors.append(f"Unbound DNS write: {e}") + errors.append(f"NAT reflection check: {e}") - # Validate and reload Unbound - out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1") - if code != 0: - errors.append(f"unbound-checkconf failed: {err or out}") - else: - _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") - steps_done.append("Unbound reloaded with service DNS overrides") - else: - errors.append("OPNsense SSH not configured — DNS overrides not deployed. " - "Add service FQDNs to your DNS manually.") + # 3. Verify WAN port forward exists for 443 + if cfg.get("key"): + try: + rules = _opnsense_request(cfg, "firewall/filter/searchRule") + # This is a best-effort check — port forwards are in NAT, not filter + pending_steps.append( + "Verify WAN port forward: OPNsense > Firewall > NAT > Port Forward — " + "WAN TCP 443 → management computer IP:443 (Caddy)") + except Exception: + pass - # 3. Deploy reverse proxy on OPNsense - # Option A: Write Caddy config to OPNsense via SFTP and reload - # Option B: Configure HAProxy plugin via OPNsense API - # We try Caddy first (simpler), fall back to instructions - if cfg.get("ssh_key_path"): - # Check if Caddy is available on OPNsense - out, _, code = _opnsense_ssh_run(cfg, "which caddy 2>/dev/null") - if code == 0 and out.strip(): - # Caddy is installed on OPNsense — write config and reload - try: - _opnsense_sftp_write(cfg, "/usr/local/etc/caddy/Caddyfile.services", caddy_content) - _opnsense_ssh_run(cfg, "caddy reload --config /usr/local/etc/caddy/Caddyfile 2>&1") - steps_done.append("Caddy on OPNsense: config written and reloaded") - except Exception as e: - errors.append(f"Caddy on OPNsense: {e}") - else: - # No Caddy on OPNsense — check HAProxy plugin - try: - _opnsense_request(cfg, "haproxy/settings/searchServers") - # HAProxy plugin is available — add backends - for svc in services: - fqdn = svc.get("fqdn", "") - backend_url = svc.get("backend_url", "") - if not fqdn or not backend_url: - continue - host_port = backend_url.replace("http://", "").replace("https://", "") - parts = host_port.split(":") - backend_host = parts[0] - backend_port = parts[1] if len(parts) > 1 else "80" - try: - # Add HAProxy backend server - _opnsense_request(cfg, "haproxy/settings/addServer", "POST", { - "server": { - "name": fqdn.replace(".", "-"), - "address": backend_host, - "port": backend_port, - "mode": "active", - "ssl": "0", - } - }) - steps_done.append(f"HAProxy: backend {fqdn} → {host_port}") - except ValueError as e: - errors.append(f"HAProxy backend {fqdn}: {e}") - try: - _opnsense_request(cfg, "haproxy/service/reconfigure", "POST") - steps_done.append("HAProxy reconfigured") - except ValueError as e: - errors.append(f"HAProxy reconfigure: {e}") - except Exception: - # Neither Caddy nor HAProxy available - pending_steps += [ - "Install Caddy or HAProxy plugin on OPNsense to enable reverse proxying.", - "OPNsense: System > Firmware > Plugins > os-haproxy (recommended)", - "Or: pkg install caddy (FreeBSD package)", - "DNS overrides are deployed — once a reverse proxy is running on OPNsense, " - "services will be reachable by FQDN from all VLANs without breaking isolation.", - ] - - # No firewall rules needed — devices already can reach their gateway - steps_done.append("No firewall changes needed — devices reach services via their own gateway") + pending_steps.append( + "Reload Caddy on management computer: " + "docker compose restart caddy (or: caddy reload)") return { "success": len(errors) == 0, @@ -4720,11 +4649,12 @@ def deploy_services(body: dict): "errors": errors, "backup": backup, "caddy_content": caddy_content, - "opnsense_ip": opnsense_ip, + "nat_reflection_enabled": nat_status, "architecture": ( - "DNS resolves service FQDNs to OPNsense gateway IP. " - "Devices reach services through their own gateway (reverse proxy). " - "No inter-VLAN firewall rules created. Full VLAN isolation preserved." + "Caddy on LAN management computer handles all reverse proxying. " + "Only port 443 forwarded from WAN. Isolated VLANs use public FQDNs — " + "OPNsense NAT reflection routes internally (no round trip to internet). " + "Full VLAN isolation preserved. IoT = untrusted = external user." ), } From 67806d0255f5681108e2be9d54be21201cabe5d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 01:36:28 +0000 Subject: [PATCH 05/11] Wire services end-to-end: Caddy reload, NAT reflection, port forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fully wired service proxy deployment: Backend: - /api/services/status: full checklist (OPNsense API, SSH, NAT reflection, port forward 443, Caddyfile.services, service count) - /api/services/enable-nat-reflection: enables NAT reflection on OPNsense via SSH config.xml edit + filter reload - /api/services/create-port-forward: creates WAN TCP 443 → Caddy port forward via OPNsense NAT API, tracks rule UUID - /api/services/deploy: writes Caddyfile.services, reloads Caddy (tries docker compose exec, then restart, then systemctl), checks NAT reflection status, verifies port forward exists Infrastructure: - docker-compose.yml: mount Caddyfile.services into Caddy container, switch-manager volume writable (for writing Caddyfile.services) - Caddyfile.template: auto-imports /etc/caddy/Caddyfile.services Frontend: - Setup Checklist panel with green/red dots for each prerequisite - Enable NAT Reflection button (one-click) - Create Port Forward button (one-click) - Deploy button writes Caddyfile, reloads Caddy, verifies everything - Caddyfile preview in deploy results https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- Caddyfile.template | 5 +- docker-compose.yml | 3 +- ers5952-manager.jsx | 124 +++++++++++++----- switch_backend.py | 312 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 358 insertions(+), 86 deletions(-) diff --git a/Caddyfile.template b/Caddyfile.template index 952e1a5..5c501bd 100644 --- a/Caddyfile.template +++ b/Caddyfile.template @@ -9,6 +9,5 @@ redir https://{{host}}{{uri}} permanent }} -# Service proxies — auto-generated by switch-manager -# To include service proxy entries, add this line (uncommented) after deployment: -# import /etc/switch-manager/Caddyfile.services +# Service reverse proxy entries — auto-managed by switch-manager Services tab +import /etc/caddy/Caddyfile.services diff --git a/docker-compose.yml b/docker-compose.yml index 26b85b9..eb7a17e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro + - /etc/switch-manager/Caddyfile.services:/etc/caddy/Caddyfile.services:ro - caddy_data:/data - caddy_config:/config restart: unless-stopped @@ -17,7 +18,7 @@ services: expose: - "8765" volumes: - - /etc/switch-manager:/etc/switch-manager:ro + - /etc/switch-manager:/etc/switch-manager - ./frontend/dist:/app/frontend/dist:ro restart: unless-stopped environment: diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 73c8dd5..d8efc5e 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -5041,13 +5041,16 @@ function FirewallTab({ vlans, session, onNeedAuth, backendOk }) { function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { const [services, setServices] = useState([]); const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" }); + const [status, setStatus] = useState(null); const [deploying, setDeploying] = useState(false); const [deployResult, setDeployResult] = useState(null); + const [actionLoading, setActionLoading] = useState(""); const load = async () => { try { - const d = await API("/services"); - setServices(d.services || []); + const [svc, st] = await Promise.all([API("/services"), API("/services/status")]); + setServices(svc.services || []); + setStatus(st); } catch(e) { console.error(e); } }; useEffect(() => { if (backendOk) load(); }, [backendOk]); @@ -5068,24 +5071,63 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { await load(); }; + const enableNatReflection = async () => { + if (!session) { onNeedAuth(); return; } + setActionLoading("nat"); + try { + const r = await API("/services/enable-nat-reflection", { method:"POST", body:{ token: session.token } }); + if (r.success) await load(); + else alert("NAT reflection enable may need manual verification"); + } catch(e) { alert("Failed: " + e.message); } + setActionLoading(""); + }; + + const createPortForward = async () => { + if (!session) { onNeedAuth(); return; } + setActionLoading("pf"); + try { + const r = await API("/services/create-port-forward", { method:"POST", + body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); + if (r.note) alert(r.note); + await load(); + } catch(e) { alert("Failed: " + e.message); } + setActionLoading(""); + }; + const deploy = async () => { if (!session) { onNeedAuth(); return; } setDeploying(true); setDeployResult(null); try { - const r = await API("/services/deploy", { method: "POST", body: { token: session.token } }); + const r = await API("/services/deploy", { method:"POST", + body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); setDeployResult(r); + await load(); } catch(e) { setDeployResult({ success: false, errors: [e.message] }); } setDeploying(false); }; + const Check = ({ok, label, action, actionLabel, loading}) => ( +
+ + {label} + {ok === false && action && ( + + )} +
+ ); + return (
+ {/* Architecture explanation */}
Services — Caddy Reverse Proxy + NAT Reflection
- Caddy on the LAN management computer is your reverse proxy for all services. + Caddy on the LAN management computer is your reverse proxy. Only port 443 is forwarded from WAN. Service ports are never exposed externally.
- How isolated VLANs reach services: + How isolated VLANs reach services (NAT reflection):
1. IoT TV (VLAN 30) asks DNS for plex.mydomain.com
2. DNS returns your public IP
-
3. OPNsense sees "that's my WAN IP" → NAT reflection routes internally
-
4. Port forward sends to Caddy → Caddy proxies to Plex
+
3. OPNsense: "that's my WAN IP" → NAT reflection → routes internally
+
4. Port forward → Caddy (management computer) → reverse proxy to 192.168.1.x:port
5. Traffic never leaves your network. Full VLAN isolation.
- IoT = untrusted = treated exactly like an external user. No pinholes, no cross-VLAN access. -
-
- Requires: OPNsense NAT reflection enabled (Firewall > Settings > Advanced > Reflection for port forwards) - + WAN port forward TCP 443 → management computer (Caddy). + IoT = untrusted = treated exactly like an external user. No pinholes. No cross-VLAN access.
+ {/* Status Checklist */} +
+
Setup Checklist
+
+ + + + + + 0} + label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} /> + {status?.mgmt_ip && ( +
+ Management computer IP: {status.mgmt_ip} +
+ )} +
+
+ {/* Add Service Form */}
Add Service
-
+
setForm(f => ({...f, fqdn: e.target.value}))} - placeholder="plex.home.lan"/> + placeholder="plex.mydomain.com"/>
setForm(f => ({...f, backend_url: e.target.value}))} @@ -5129,18 +5193,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { placeholder="Plex Media Server"/>
- -
- All VLANs can reach this service automatically (via their gateway). No per-VLAN selection needed. -
+ style={{marginTop:12}}>Add Service
- {/* Service List */} + {/* Service List + Deploy */} {services.length > 0 && (
Configured Services ({services.length})
@@ -5153,10 +5211,8 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
{s.fqdn} {s.backend_url} {s.description || "—"} - -
+ + + {forwards.map((f,i) => ( + + + + + + + + + ))} + +
ProtoWAN PortTargetDescriptionCreated
{f.proto}{f.wan_port}{f.target_ip}:{f.target_port}{f.description || "—"}{f.created_at || "—"}
+
+ + )} + + + ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// POE BUDGET TAB +// ══════════════════════════════════════════════════════════════════════════════ + +function PoETab({ backendOk }) { + const [poe, setPoe] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { setPoe(await API("/poe/budget")); } catch(e) { console.error(e); } + setLoading(false); + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + if (!poe || !poe.available) return ( +
+
PoE Budget
+
+ {loading ? "Loading..." : "No PoE data available — switch may be offline"} +
+
+
+ ); + + const pct = poe.percent_used || 0; + const barColor = pct > 90 ? "#ff1744" : pct > 75 ? "#ff6d00" : "#00e676"; + + return ( +
+
+
+
PoE Power Budget
+
+ {/* Budget bar */} +
+
+ Used: {poe.used_watts || "?"}W + Available: {poe.total_watts || "?"}W + Remaining: {poe.remaining_watts || "?"}W +
+
+
+
+ {pct.toFixed(1)}% +
+
+ {pct > 85 && ( +
+ Warning: PoE budget above 85%. New PoE devices may not power up. +
+ )} +
+ + {/* Per-port table */} + {poe.ports?.length > 0 && ( + <> +
Per-Port Power Draw
+
+ {poe.ports.map(p => ( +
0 ? barColor + "15" : "var(--bg)", + border: `1px solid ${p.watts > 0 ? barColor + "30" : "var(--b2)"}`, + }}> +
Port {p.port}
+
0 ? barColor : "var(--dm)"}}>{p.watts}W
+
{p.status}
+
+ ))} +
+ + )} + + +
+
+
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// TOPOLOGY TAB — network diagram +// ══════════════════════════════════════════════════════════════════════════════ + +function TopologyTab({ vlans, ports, backendOk }) { + const [topo, setTopo] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { setTopo(await API("/topology")); } catch(e) { console.error(e); } + setLoading(false); + }; + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const upPorts = (topo?.ports || []).filter(p => p.link === "up"); + const downPorts = (topo?.ports || []).filter(p => p.link === "down"); + + return ( +
+
+
+
Network Topology
+
+ {/* Router */} +
+
+
OPNsense
+
{topo?.router?.ip || "not configured"}
+ {topo?.router?.version &&
v{topo.router.version}
} +
+ {topo?.router?.connected ? "Online" : "Offline"} +
+
+
+ + {/* Trunk link */} +
+
+
Trunk (all VLANs tagged)
+
+
+ + {/* Switch */} +
+
+
{topo?.switch?.hostname || "ERS-5952"}
+
{topo?.switch?.ip}
+
+ {topo?.switch?.connected ? "Online" : "Offline"} +
+
+ {upPorts.length} ports up, {downPorts.length} down +
+
+
+ + {/* VLANs fan out */} +
+ {vlans.map(v => { + const vlanPorts = ports.filter(p => + (p.mode === "access" && p.accessVlan === v.id) || + (p.mode === "trunk" && p.taggedVlans?.includes(v.id)) + ); + const upCount = vlanPorts.filter(p => { + const tp = (topo?.ports || []).find(tp => tp.id === p.id); + return tp?.link === "up"; + }).length; + const devices = (topo?.devices || []).filter(d => d.vlan === v.id); + return ( +
+
VLAN {v.id}
+
{v.name}
+
+ {vlanPorts.length} ports ({upCount} up) +
+
+ {devices.length} registered device{devices.length !== 1 ? "s" : ""} +
+
+ 192.168.{v.id}.0/24 +
+
+ ); + })} +
+ + +
+
+
+
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index c9aa1be..0484c34 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -5238,369 +5238,6 @@ def run_schedule_now(body: dict): return {"success": True, "ran": name} -# ══════════════════════════════════════════════════════════════════════ -# FIREWALL POLICY MATRIX — inter-VLAN access control -# ══════════════════════════════════════════════════════════════════════ -# -# Manages both switch ACLs AND OPNsense firewall rules together. -# Policies define what each VLAN pair can do: -# - full: all traffic allowed between VLANs -# - internet: VLAN gets internet only, no RFC1918 access -# - blocked: no traffic between these VLANs -# - service: one-way access (A can reach B, but B cannot reach A) -# - custom: user-defined rules -# -# "service" is the printer pattern: Staff can print, but printers -# can't initiate connections to Staff. - -POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json") - -_POLICY_TYPES = {"full", "internet", "blocked", "service", "custom"} - -def _load_policies() -> list: - if POLICIES_FILE.exists(): - try: return _json.loads(POLICIES_FILE.read_text()) - except: pass - return [] - -def _save_policies(policies: list): - POLICIES_FILE.write_text(_json.dumps(policies, indent=2)) - POLICIES_FILE.chmod(0o600) - - -def _policy_to_switch_acl(policy: dict) -> list: - """Generate ERS switch ACL commands for a VLAN policy.""" - ptype = policy.get("type", "blocked") - src_vid = policy.get("src_vlan") - dst_vid = policy.get("dst_vlan") - src_sub = policy.get("src_subnet", f"192.168.{src_vid}.0") - dst_sub = policy.get("dst_subnet", f"192.168.{dst_vid}.0") - acl_name = f"POLICY-V{src_vid}-V{dst_vid}" - cmds = [] - - if ptype == "blocked": - cmds = [ - f"ip access-list extended {acl_name}", - f" 1 deny ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255", - f" 2 permit ip any any", - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - elif ptype == "full": - cmds = [ - f"ip access-list extended {acl_name}", - f" 1 permit ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255", - f" 2 permit ip any any", - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - elif ptype == "internet": - cmds = [ - f"ip access-list extended {acl_name}", - f" 1 deny ip {src_sub} 0.0.0.255 10.0.0.0 0.255.255.255", - f" 2 deny ip {src_sub} 0.0.0.255 172.16.0.0 0.15.255.255", - f" 3 deny ip {src_sub} 0.0.0.255 192.168.0.0 0.0.255.255", - f" 4 permit ip any any", - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - elif ptype == "service": - # One-way: src can reach dst on specified ports, dst cannot initiate to src - ports = policy.get("ports", []) - cmds = [f"ip access-list extended {acl_name}"] - rule_num = 1 - for p in ports: - proto = p.get("proto", "tcp") - port = p.get("port", "") - if port: - cmds.append( - f" {rule_num} permit {proto} {src_sub} 0.0.0.255 " - f"{dst_sub} 0.0.0.255 eq {port}") - else: - cmds.append( - f" {rule_num} permit {proto} {src_sub} 0.0.0.255 " - f"{dst_sub} 0.0.0.255") - rule_num += 1 - # Deny all other traffic to that VLAN - cmds.append(f" {rule_num} deny ip {src_sub} 0.0.0.255 {dst_sub} 0.0.0.255") - rule_num += 1 - cmds.append(f" {rule_num} permit ip any any") - cmds += [ - f"interface vlan {src_vid}", - f" ip access-group {acl_name} in", - ] - - return cmds - - -def _policy_to_opnsense_rules(policy: dict, cfg: dict) -> list: - """Generate OPNsense firewall API calls for a VLAN policy. - Returns list of {method, path, body} dicts.""" - ptype = policy.get("type", "blocked") - src_vid = policy.get("src_vlan") - dst_vid = policy.get("dst_vlan") - vmap = _load_vlan_if_map() - src_if = vmap.get(str(src_vid), "") - dst_if = vmap.get(str(dst_vid), "") - src_sub = policy.get("src_subnet", f"192.168.{src_vid}.0/24") - dst_sub = policy.get("dst_subnet", f"192.168.{dst_vid}.0/24") - rules = [] - - if not src_if: - return rules # Can't create OPNsense rules without interface mapping - - if ptype == "blocked": - rules.append({ - "rule": { - "enabled": "1", "action": "block", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: block V{src_vid} → V{dst_vid}", - } - }) - elif ptype == "full": - rules.append({ - "rule": { - "enabled": "1", "action": "pass", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: allow V{src_vid} → V{dst_vid}", - } - }) - elif ptype == "internet": - # Block all RFC1918, permit everything else - for net in ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]: - rules.append({ - "rule": { - "enabled": "1", "action": "block", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": net}, - "descr": f"Policy: V{src_vid} internet-only (block {net})", - } - }) - rules.append({ - "rule": { - "enabled": "1", "action": "pass", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"any": "1"}, - "descr": f"Policy: V{src_vid} internet-only (allow out)", - } - }) - elif ptype == "service": - ports = policy.get("ports", []) - for p in ports: - proto = p.get("proto", "tcp") - port = p.get("port", "") - rule = { - "enabled": "1", "action": "pass", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": proto, - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: V{src_vid} → V{dst_vid} service {proto}/{port}", - } - if port: - rule["destination"]["port"] = str(port) - rules.append({"rule": rule}) - # Block everything else to that VLAN - rules.append({ - "rule": { - "enabled": "1", "action": "block", - "interface": src_if, "direction": "in", - "ipprotocol": "inet", "protocol": "any", - "source": {"network": f"{src_if}net"}, - "destination": {"address": dst_sub}, - "descr": f"Policy: block V{src_vid} → V{dst_vid} (except services above)", - } - }) - - return rules - - -# ── Policy presets ────────────────────────────────────────────────── - -POLICY_PRESETS = [ - { - "id": "printer", - "label": "Printer VLAN — other VLANs can print, printers can't reach out", - "description": "Allows printing (TCP 9100 RAW, TCP 631 IPP, UDP 631 IPP) " - "from source VLAN to printer VLAN. Printers cannot initiate " - "connections back. Printers get internet for firmware updates.", - "type": "service", - "ports": [ - {"proto": "tcp", "port": "9100"}, # RAW printing - {"proto": "tcp", "port": "631"}, # IPP - {"proto": "udp", "port": "631"}, # IPP discovery - {"proto": "tcp", "port": "443"}, # HTTPS (web UI, cloud print) - {"proto": "tcp", "port": "80"}, # HTTP (web UI) - ], - "bidirectional": False, - }, - { - "id": "lan_access_all", - "label": "LAN can reach all VLANs", - "description": "LAN (trusted) has full access to all other VLANs. " - "Other VLANs cannot reach LAN.", - "type": "full", - "bidirectional": False, - }, - { - "id": "iot_isolated", - "label": "IoT — internet only, full isolation", - "description": "Blocks ALL private IP ranges. Devices get internet only. " - "Cannot reach any VLAN, server, NAS, or management network.", - "type": "internet", - "bidirectional": False, - }, - { - "id": "guest_isolated", - "label": "Guest — internet only, strict", - "description": "Same as IoT isolation. Guest devices get internet only.", - "type": "internet", - "bidirectional": False, - }, - { - "id": "camera_nvr", - "label": "Camera VLAN — NVR access only", - "description": "Cameras can only reach the NVR IP. No internet, no other VLANs.", - "type": "service", - "ports": [{"proto": "tcp", "port": ""}], # All TCP to NVR - "bidirectional": False, - "needs_target_ip": True, - }, -] - - -@app.get("/api/policies") -def get_policies(): - """List all VLAN policies and available presets.""" - return { - "policies": _load_policies(), - "presets": POLICY_PRESETS, - } - - -@app.post("/api/policies") -def save_policy(body: dict): - """Add or update a VLAN-to-VLAN policy.""" - require_session(body.get("token", "")) - policy = body.get("policy", {}) - if not policy.get("src_vlan") or not policy.get("type"): - raise HTTPException(400, "src_vlan and type required") - if policy["type"] not in _POLICY_TYPES: - raise HTTPException(400, f"Invalid type: {policy['type']}") - - policies = _load_policies() - # Replace existing policy for same src→dst pair - key = (policy["src_vlan"], policy.get("dst_vlan")) - policies = [p for p in policies if (p["src_vlan"], p.get("dst_vlan")) != key] - policies.append(policy) - _save_policies(policies) - return {"success": True, "policies": policies} - - -@app.delete("/api/policies") -def delete_policy(body: dict): - """Remove a VLAN policy.""" - require_session(body.get("token", "")) - src = body.get("src_vlan") - dst = body.get("dst_vlan") - policies = _load_policies() - policies = [p for p in policies if not (p["src_vlan"] == src and p.get("dst_vlan") == dst)] - _save_policies(policies) - return {"success": True, "policies": policies} - - -@app.post("/api/policies/preview") -def preview_policy(body: dict): - """Preview the switch ACL + OPNsense rules that a policy would generate.""" - policy = body.get("policy", {}) - if not policy.get("src_vlan") or not policy.get("type"): - raise HTTPException(400, "src_vlan and type required") - - switch_cmds = _policy_to_switch_acl(policy) - cfg = _load_opnsense_cfg() - opnsense_rules = _policy_to_opnsense_rules(policy, cfg) - - return { - "switch_commands": switch_cmds, - "opnsense_rules": [r["rule"]["descr"] for r in opnsense_rules], - "opnsense_rule_count": len(opnsense_rules), - } - - -@app.post("/api/policies/push") -def push_policy(body: dict): - """Push a policy to both switch and OPNsense.""" - require_session(body.get("token", "")) - policy = body.get("policy", {}) - if not policy.get("src_vlan") or not policy.get("type"): - raise HTTPException(400, "src_vlan and type required") - - steps_done = [] - errors = [] - - backup = _pre_change_backup(reason=f"pre-policy V{policy['src_vlan']}→V{policy.get('dst_vlan','*')}") - - # Push switch ACLs - switch_cmds = _policy_to_switch_acl(policy) - if switch_cmds: - danger = check_danger(switch_cmds) - if danger["has_hard_block"]: - raise HTTPException(400, {"message": "Hard-blocked commands", "blocked": danger["hard_blocked"]}) - result = push_one_by_one(switch_cmds) - if result.get("success"): - steps_done.append(f"Switch: {len(switch_cmds)} ACL commands pushed") - else: - errors.append(f"Switch push failed: {result.get('error', 'unknown')}") - - # Push OPNsense rules - cfg = _load_opnsense_cfg() - if cfg.get("key"): - opnsense_rules = _policy_to_opnsense_rules(policy, cfg) - for rule_body in opnsense_rules: - try: - _opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_body) - except ValueError as e: - errors.append(f"OPNsense rule: {e}") - if opnsense_rules: - try: - _opnsense_request(cfg, "firewall/filter/apply", "POST") - steps_done.append(f"OPNsense: {len(opnsense_rules)} firewall rules applied") - except ValueError as e: - errors.append(f"OPNsense apply: {e}") - - # Save policy - policies = _load_policies() - key = (policy["src_vlan"], policy.get("dst_vlan")) - policies = [p for p in policies if (p["src_vlan"], p.get("dst_vlan")) != key] - policy["pushed"] = True - policy["pushed_at"] = _ts() - policies.append(policy) - _save_policies(policies) - - # Post-connectivity check - post_conn = _check_connectivity() - if not post_conn["switch"]["ok"]: - errors.append(f"WARNING: Switch connectivity lost after push! Backup: {backup['switch'].get('file','N/A')}") - - return { - "success": len(errors) == 0, - "steps_done": steps_done, - "errors": errors, - "backup": backup, - "post_connectivity": post_conn, - } - - # ══════════════════════════════════════════════════════════════════════ # PORT FORWARDING — manage OPNsense NAT port forwards # ══════════════════════════════════════════════════════════════════════ From 31f2008153dfaf22537b6240d52f49a5b80b1cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 12:16:00 +0000 Subject: [PATCH 08/11] Wire WireGuard peer DNS to ControlD profiles via ctrld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When creating a WireGuard peer on OPNsense: - Client config DNS now points to OPNsense's IP (not tunnel gateway) so DNS flows: client → OPNsense → Unbound → ctrld → ControlD - New dns_profile field: select which ControlD profile applies to VPN clients (default: "house" for VLAN 99) - Generates ctrld.toml instructions for WireGuard tunnel subnet routing — tells user what to add so ctrld routes VPN DNS queries to the correct ControlD profile - QR modal now shows ControlD setup instructions alongside the WireGuard config This solves the Android Private DNS conflict: WireGuard's DNS setting overrides Android's Private DNS, pointing to OPNsense which runs Unbound → ctrld. No Private DNS toggle needed on the phone. Multi-VLAN access for VPN peers works because the peer is on the WireGuard interface (not on any VLAN). OPNsense routes between the tunnel and VLANs per firewall rules. VLAN isolation preserved. https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 36 ++++++++++++++++++++++++++++++++---- switch_backend.py | 43 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index e550435..977f648 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -1913,7 +1913,7 @@ function DeviceAccessTab({ session, onNeedAuth, backendOk }) { // WIREGUARD TAB // ══════════════════════════════════════════════════════════════════════════════ -function QRModal({ config, name, onClose }) { +function QRModal({ config, name, ctrldNote, onClose }) { // Render QR using a simple API since we can't use native qrencode in browser const [qrUrl, setQrUrl] = useState(''); useEffect(() => { @@ -1936,6 +1936,14 @@ function QRModal({ config, name, onClose }) { maxHeight:120,overflowY:"auto",marginBottom:12}}> {config}
+ {ctrldNote && ( +
+
ControlD DNS Setup
+ {ctrldNote} +
+ )}
); } @@ -2533,6 +2547,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) { function OPNsenseWGSection({ opnWg, opnLoading, opnError, opnSetup, setOpnSetup, opnPeerName, setOpnPeerName, opnVlans, opnAdding, + opnDnsProfile, setOpnDnsProfile, session, onNeedAuth, vlans, onToggleVlan, onSetupServer, onDeleteServer, onAddPeer, onRevokePeer, onShowConf, onRefresh, @@ -2779,6 +2794,19 @@ function OPNsenseWGSection({
+
+
+ + setOpnDnsProfile(e.target.value)} + placeholder="house"/> +
+
+ VPN clients use OPNsense DNS → Unbound → ctrld → ControlD. + This profile applies to the WireGuard tunnel subnet. +
+
+
{!session && (
-
- +
+ setOpnDnsProfile(e.target.value)} - placeholder="house"/> + placeholder="auto-set from VLAN, or type a profile name"/>
- VPN clients use OPNsense DNS → Unbound → ctrld → ControlD. - This profile applies to the WireGuard tunnel subnet. + {opnVlans.length === 1 + ? `Auto-set to "${opnDnsProfile}" from selected VLAN. Change if needed.` + : opnVlans.length > 1 + ? "Multiple VLANs selected — set the profile manually." + : "Select a VLAN above to auto-fill, or type a ControlD profile name."} + {" "}VPN clients use OPNsense DNS → Unbound → ctrld → ControlD.
From 8a9fa02e2578247d641f227b7a4731d0c075c4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 12:45:34 +0000 Subject: [PATCH 10/11] Separate Caddy/services box from VLAN 99 management computer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management computer (VLAN 99) only runs the switch manager tool and holds SSH keys/TOTP secrets. Caddy and services (Plex, etc.) run on a SEPARATE computer on LAN. Backend: - New /api/services/config endpoint to store services box LAN IP - services-config.json persists caddy_ip separately from mgmt_ip - Port forward creation targets caddy_ip (LAN services box), not mgmt_ip (VLAN 99 management computer) - Deploy endpoint uses caddy_ip for all Caddy/NAT references - _get_caddy_ip() helper reads from services config Frontend: - New "Services Host" panel: configure Caddy box LAN IP - Checklist shows caddy_ip status, not mgmt_ip - Port forward and deploy pass caddy_ip to backend - Clear labels: "Services box" vs "Management computer" Architecture: VLAN 99: management computer (this tool, SSH keys, TOTP) LAN: services computer (Caddy, Plex, Docker containers) WAN port forward 443 → services computer LAN IP https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 46 ++++++++++++++++++++++----- switch_backend.py | 76 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 6545996..e2790ff 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -5099,12 +5099,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { const [deploying, setDeploying] = useState(false); const [deployResult, setDeployResult] = useState(null); const [actionLoading, setActionLoading] = useState(""); + const [caddyIpInput, setCaddyIpInput] = useState(""); const load = async () => { try { const [svc, st] = await Promise.all([API("/services"), API("/services/status")]); setServices(svc.services || []); setStatus(st); + if (st.caddy_ip && !caddyIpInput) setCaddyIpInput(st.caddy_ip); } catch(e) { console.error(e); } }; useEffect(() => { if (backendOk) load(); }, [backendOk]); @@ -5125,6 +5127,17 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { await load(); }; + const saveCaddyIp = async () => { + if (!session) { onNeedAuth(); return; } + if (!caddyIpInput.trim()) return; + try { + await API("/services/config", { method:"POST", body:{ + token: session.token, config: { caddy_ip: caddyIpInput.trim() } + }}); + await load(); + } catch(e) { alert("Failed: " + e.message); } + }; + const enableNatReflection = async () => { if (!session) { onNeedAuth(); return; } setActionLoading("nat"); @@ -5141,7 +5154,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { setActionLoading("pf"); try { const r = await API("/services/create-port-forward", { method:"POST", - body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); + body:{ token: session.token, caddy_ip: status?.caddy_ip } }); if (r.note) alert(r.note); await load(); } catch(e) { alert("Failed: " + e.message); } @@ -5153,7 +5166,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { setDeploying(true); setDeployResult(null); try { const r = await API("/services/deploy", { method:"POST", - body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); + body:{ token: session.token, caddy_ip: status?.caddy_ip } }); setDeployResult(r); await load(); } catch(e) { setDeployResult({ success: false, errors: [e.message] }); } @@ -5203,10 +5216,32 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
+ {/* Services Host Config */} +
+
Services Host (Caddy Computer)
+
+
+ The computer running Caddy and your services (Plex, etc.) — on LAN, separate from the + VLAN 99 management computer. +
+
+
+ + setCaddyIpInput(e.target.value)} + placeholder="192.168.1.50"/> +
+ +
+
+
+ {/* Status Checklist */}
Setup Checklist
+ 0} label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} /> - {status?.mgmt_ip && ( -
- Management computer IP: {status.mgmt_ip} -
- )}
diff --git a/switch_backend.py b/switch_backend.py index a96571c..db3dc21 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -4515,8 +4515,12 @@ def push_policy(body: dict): # ══════════════════════════════════════════════════════════════════════ # # Architecture: -# Caddy runs on the LAN management computer. It is the reverse proxy for -# all services — only port 443 is forwarded from WAN, and Caddy routes +# Caddy runs on a SEPARATE LAN services computer, NOT the VLAN 99 +# management computer. Management box only runs this tool + SSH keys. +# Services box (LAN) runs Caddy, Plex, Docker, etc. +# +# WAN port forward 443 → services box LAN IP (Caddy). +# Caddy routes # by hostname (SNI) to the correct backend. Service ports (32400, 8123, # etc.) are NEVER exposed on WAN. # @@ -4530,8 +4534,25 @@ def push_policy(body: dict): # IoT = untrusted = same access as someone on the internet. SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json") +SERVICES_CONFIG_FILE = _Path("/etc/switch-manager/services-config.json") CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services") +def _load_services_config() -> dict: + """Load services/Caddy host config: caddy_ip, etc. + This is the LAN services box, NOT the VLAN 99 management computer.""" + if SERVICES_CONFIG_FILE.exists(): + try: return _json.loads(SERVICES_CONFIG_FILE.read_text()) + except: pass + return {} + +def _save_services_config(cfg: dict): + SERVICES_CONFIG_FILE.write_text(_json.dumps(cfg, indent=2)) + SERVICES_CONFIG_FILE.chmod(0o600) + +def _get_caddy_ip() -> str: + """Return the Caddy/services box LAN IP.""" + return _load_services_config().get("caddy_ip", "") + def _load_services() -> list: if SERVICES_FILE.exists(): try: return _json.loads(SERVICES_FILE.read_text()) @@ -4622,13 +4643,32 @@ def _get_mgmt_ip() -> str: return "" +@app.get("/api/services/config") +def get_services_config(): + """Return services host configuration.""" + return _load_services_config() + +@app.post("/api/services/config") +def save_services_config_endpoint(body: dict): + """Save services host configuration (Caddy box LAN IP).""" + require_session(body.get("token", "")) + cfg = body.get("config", {}) + if not cfg.get("caddy_ip"): + raise HTTPException(400, "caddy_ip required — the LAN IP of your services/Caddy computer") + _save_services_config(cfg) + return {"success": True, "config": cfg} + @app.get("/api/services/status") def services_status(): - """Full status check: Caddy import, NAT reflection, port forward, services.""" + """Full status check: Caddy host, NAT reflection, port forward, services.""" services = _load_services() + svc_cfg = _load_services_config() cfg = _load_opnsense_cfg() + caddy_ip = svc_cfg.get("caddy_ip", "") result = { "services": services, + "caddy_ip": caddy_ip, + "caddy_configured": bool(caddy_ip), "mgmt_ip": _get_mgmt_ip(), "caddy_file_exists": CADDYFILE_EXTRA.exists(), "nat_reflection": None, @@ -4731,19 +4771,19 @@ def create_wan_port_forward(body: dict): if not cfg.get("key"): raise HTTPException(503, "OPNsense API not configured") - mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) - if not mgmt_ip: - raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip") + caddy_ip = body.get("caddy_ip", _get_caddy_ip()) + if not caddy_ip: + raise HTTPException(400, "Caddy/services box IP not configured — set it in the Services tab") tracked = _load_service_rules() if tracked.get("wan_443_uuid"): return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"], - "mgmt_ip": mgmt_ip} + "caddy_ip": caddy_ip} backup = _pre_change_backup(reason="pre-WAN-port-forward-443") try: - # Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443 + # Create NAT port forward rule: WAN TCP 443 → caddy_ip:443 (services box on LAN) r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", { "rule": { "enabled": "1", @@ -4751,8 +4791,8 @@ def create_wan_port_forward(body: dict): "protocol": "tcp", "source": {"any": "1"}, "destination": {"any": "1", "port": "443"}, - "target": {"address": mgmt_ip, "port": "443"}, - "descr": "switch-manager: WAN 443 → Caddy reverse proxy", + "target": {"address": caddy_ip, "port": "443"}, + "descr": f"switch-manager: WAN 443 → Caddy ({caddy_ip})", "nordr": "0", } }) @@ -4770,8 +4810,8 @@ def create_wan_port_forward(body: dict): "ipprotocol": "inet", "protocol": "tcp", "source": {"any": "1"}, - "destination": {"address": mgmt_ip, "port": "443"}, - "descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)", + "destination": {"address": caddy_ip, "port": "443"}, + "descr": f"switch-manager: allow WAN → Caddy ({caddy_ip}:443)", } }) uuid = r.get("uuid", "") @@ -4780,10 +4820,10 @@ def create_wan_port_forward(body: dict): _opnsense_request(cfg, "firewall/filter/apply", "POST") tracked["wan_443_uuid"] = uuid - tracked["mgmt_ip"] = mgmt_ip + tracked["caddy_ip"] = caddy_ip _save_service_rules(tracked) - return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup, + return {"success": True, "uuid": uuid, "caddy_ip": caddy_ip, "backup": backup, "note": "If this is the first time, also verify in OPNsense UI: " "Firewall > NAT > Port Forward that the rule looks correct. " "The OPNsense NAT API varies between versions."} @@ -4816,7 +4856,7 @@ def deploy_services(body: dict): backup = _pre_change_backup(reason="pre-service-deploy") - mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) + caddy_ip = body.get("caddy_ip", _get_caddy_ip()) or _get_mgmt_ip() # ── Step 1: Write Caddyfile.services ───────────────────────────── caddy_content = _generate_caddyfile_services(services) @@ -4891,11 +4931,11 @@ def deploy_services(body: dict): # ── Step 4: WAN port forward ───────────────────────────────────── tracked = _load_service_rules() if tracked.get("wan_443_uuid"): - steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)") + steps_done.append(f"WAN port forward 443 → {tracked.get('caddy_ip', caddy_ip)}:443 (tracked)") else: pending_steps.append( f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — " - f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). " + f"WAN TCP 443 → {caddy_ip}:443 (services box). " f"Or use the 'Create Port Forward' button above.") return { @@ -4905,7 +4945,7 @@ def deploy_services(body: dict): "errors": errors, "backup": backup, "caddy_content": caddy_content, - "mgmt_ip": mgmt_ip, + "caddy_ip": caddy_ip, "nat_reflection_enabled": nat_status, } From 5781fdafd690c7ca1f58bf7543c18814a67142f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:03:09 +0000 Subject: [PATCH 11/11] Move Caddy/services box IP to Settings panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services box LAN IP is now a setting (gear icon → Network section), not a panel in the Services tab. Set it once, saved via API on blur when authenticated. Loaded on app startup from /api/services/config. Removed: Services Host panel, caddyIpInput state, saveCaddyIp function Added: Network section in SettingsPanel with caddy_ip field - Auto-saves to backend on blur (requires active TOTP session) - Loaded from /api/services/config on mount - Services tab checklist reads from status.caddy_ip https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 68 ++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index e2790ff..3606627 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -417,7 +417,7 @@ function SessionBtn({ session, onUnlock }) { // ══════════════════════════════════════════════════════════════════════════════ // SETTINGS PANEL // ══════════════════════════════════════════════════════════════════════════════ -function SettingsPanel({ settings, setSettings, onClose }) { +function SettingsPanel({ settings, setSettings, session, onClose }) { return (
e.stopPropagation()}> @@ -454,6 +454,29 @@ function SettingsPanel({ settings, setSettings, onClose }) {
+
+

Network

+
+
+
Services / Caddy box LAN IP
+
The computer running Caddy and services (Plex, etc.) — separate from this management computer
+
+ setSettings(s => ({...s, caddyIp: e.target.value}))} + placeholder="192.168.1.50" + onBlur={() => { + if (settings.caddyIp && session?.token) { + fetch("/api/services/config", { + method:"POST", + headers:{"Content-Type":"application/json"}, + body: JSON.stringify({ token: session.token, config: { caddy_ip: settings.caddyIp } }) + }).catch(() => {}); + } + }} + style={{background:"var(--bg)",border:"1px solid var(--b2)",color:"var(--tx)", + padding:"4px 8px",fontFamily:"var(--mono)",fontSize:11,borderRadius:3,width:140}}/> +
+
+

About

@@ -1473,7 +1496,7 @@ export default function App() { const [selected, setSelected] = useState(null); const [hostname, setHostname] = useState("ERS-5952"); const [switchIP, setSwitchIP] = useState("192.168.99.1"); - const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch" }); + const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch", caddyIp: "" }); const [showSettings, setShowSettings] = useState(false); const [connState, setConnState] = useState("connecting"); @@ -1487,6 +1510,13 @@ export default function App() { const updatePort = useCallback(p => setPorts(prev => prev.map(x => x.id===p.id?p:x)), []); // Heartbeat + // Load services config (caddy IP) once on mount + useEffect(() => { + API("/services/config").then(cfg => { + if (cfg.caddy_ip) setSettings(s => ({...s, caddyIp: cfg.caddy_ip})); + }).catch(() => {}); + }, []); + useEffect(() => { let firstPoll = true; const beat = async () => { @@ -1660,6 +1690,7 @@ export default function App() { {showSettings && setShowSettings(false)} />}
@@ -5099,14 +5130,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { const [deploying, setDeploying] = useState(false); const [deployResult, setDeployResult] = useState(null); const [actionLoading, setActionLoading] = useState(""); - const [caddyIpInput, setCaddyIpInput] = useState(""); const load = async () => { try { const [svc, st] = await Promise.all([API("/services"), API("/services/status")]); setServices(svc.services || []); setStatus(st); - if (st.caddy_ip && !caddyIpInput) setCaddyIpInput(st.caddy_ip); } catch(e) { console.error(e); } }; useEffect(() => { if (backendOk) load(); }, [backendOk]); @@ -5127,17 +5156,6 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { await load(); }; - const saveCaddyIp = async () => { - if (!session) { onNeedAuth(); return; } - if (!caddyIpInput.trim()) return; - try { - await API("/services/config", { method:"POST", body:{ - token: session.token, config: { caddy_ip: caddyIpInput.trim() } - }}); - await load(); - } catch(e) { alert("Failed: " + e.message); } - }; - const enableNatReflection = async () => { if (!session) { onNeedAuth(); return; } setActionLoading("nat"); @@ -5216,26 +5234,6 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
- {/* Services Host Config */} -
-
Services Host (Caddy Computer)
-
-
- The computer running Caddy and your services (Plex, etc.) — on LAN, separate from the - VLAN 99 management computer. -
-
-
- - setCaddyIpInput(e.target.value)} - placeholder="192.168.1.50"/> -
- -
-
-
- {/* Status Checklist */}
Setup Checklist