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