Add unified network management with backup/restore and safety checks

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
This commit is contained in:
Claude
2026-03-28 00:32:45 +00:00
parent 4eee783af0
commit 7f6fa75aff
2 changed files with 1011 additions and 0 deletions
+483
View File
@@ -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" && <NetworkTab
vlans={vlans} setVlans={setVlans}
ports={ports} updatePort={updatePort}
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{tab==="vpn" && <WireGuardTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
vlans={vlans}
/>}
{tab==="backups" && <BackupTab
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
/>}
{showTotp && <TotpModal
onSuccess={handleTotpSuccess}
@@ -4265,3 +4279,472 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk, acls, setAcls }) {
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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) => (
<div style={{ display:"flex", flexWrap:"wrap", gap:4, marginBottom:8 }}>
{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 (
<div key={p.id} onClick={() => {
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}
</div>
);
})}
</div>
);
return (
<div className="main">
<div style={{flex:1}}>
{/* Connectivity Status */}
<div className="panel">
<div className="ph">Connectivity Status</div>
<div className="pb" style={{display:"flex",gap:16,alignItems:"center"}}>
<div style={{display:"flex",alignItems:"center",gap:6}}>
<span className={`dot ${connStatus?.switch?.ok ? "ok" : "err"}`}/>
<span style={{fontSize:12}}>Switch {connStatus?.switch?.ok ? "Online" : "Offline"}</span>
</div>
<div style={{display:"flex",alignItems:"center",gap:6}}>
<span className={`dot ${connStatus?.opnsense?.ok ? "ok" : connStatus?.opnsense?.configured ? "warn" : "idle"}`}/>
<span style={{fontSize:12}}>
OPNsense {connStatus?.opnsense?.ok ? "Online" : connStatus?.opnsense?.configured ? "Unreachable" : "Not configured"}
</span>
</div>
<button className="btn bd" onClick={checkConn} disabled={connLoading}
style={{marginLeft:"auto",fontSize:11,padding:"4px 12px"}}>
{connLoading ? "Checking..." : "Refresh"}
</button>
</div>
</div>
{/* Existing VLANs overview */}
<div className="panel">
<div className="ph">Active VLANs</div>
<div className="pb">
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
{vlans.map(v => {
const cnt = ports.filter(p =>
p.mode === "access" ? p.accessVlan === v.id : p.taggedVlans?.includes(v.id)
).length;
return (
<div key={v.id} style={{
background: v.color + "20", border:`1px solid ${v.color}50`,
borderRadius:6, padding:"8px 14px", minWidth:140,
}}>
<div style={{fontWeight:700,color:v.color,fontSize:14}}>VLAN {v.id}</div>
<div style={{fontSize:12,color:"var(--tx)"}}>{v.name}</div>
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
{cnt} port{cnt !== 1 ? "s" : ""} | 192.168.{v.id}.0/24
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Unified VLAN Provisioning Form */}
<div className="panel">
<div className="ph">Provision New VLAN (Switch + OPNsense)</div>
<div className="pb">
<div style={{
background:"var(--bg)",border:"1px solid var(--ac)30",borderRadius:6,
padding:12,marginBottom:16,fontSize:12,color:"var(--dm)",lineHeight:1.6,
}}>
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.
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
<div className="field"><label>VLAN ID</label>
<input value={form.vlan_id} onChange={e => {
setForm(f => ({...f, vlan_id: e.target.value}));
autoFillSubnet(e.target.value);
}} type="number" placeholder="60"/>
</div>
<div className="field"><label>Name</label>
<input value={form.name} onChange={e => setForm(f => ({...f, name: e.target.value}))} placeholder="e.g. Cameras"/>
</div>
<div className="field"><label>Subnet</label>
<input value={form.subnet} onChange={e => setForm(f => ({...f, subnet: e.target.value}))} placeholder="192.168.60.0/24"/>
</div>
<div className="field"><label>Gateway (OPNsense IP)</label>
<input value={form.gateway} onChange={e => setForm(f => ({...f, gateway: e.target.value}))} placeholder="192.168.60.1"/>
</div>
<div className="field"><label>DHCP Start</label>
<input value={form.dhcp_start} onChange={e => setForm(f => ({...f, dhcp_start: e.target.value}))} placeholder="192.168.60.100"/>
</div>
<div className="field"><label>DHCP End</label>
<input value={form.dhcp_end} onChange={e => setForm(f => ({...f, dhcp_end: e.target.value}))} placeholder="192.168.60.200"/>
</div>
<div className="field"><label>OPNsense Parent Interface</label>
<input value={form.parent_if} onChange={e => setForm(f => ({...f, parent_if: e.target.value}))} placeholder="igb0"/>
</div>
<div className="field"><label>OPNsense Interface (if assigned)</label>
<input value={form.opnsense_if} onChange={e => setForm(f => ({...f, opnsense_if: e.target.value}))} placeholder="e.g. opt3 (leave blank if new)"/>
</div>
<div className="field" style={{display:"flex",alignItems:"flex-end"}}>
<label style={{display:"flex",alignItems:"center",gap:6,cursor:"pointer"}}>
<input type="checkbox" checked={form.allow_internet}
onChange={e => setForm(f => ({...f, allow_internet: e.target.checked}))}/>
Allow internet access
</label>
</div>
</div>
{/* Port Selection */}
<div style={{marginTop:20}}>
<div className="sect">Access Ports (will be assigned to this VLAN)</div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:8}}>
Click ports to select. Dimmed ports are already assigned to another VLAN.
</div>
{portGrid(1, 24, false)}
{portGrid(25, 48, false)}
{selectedPorts.length > 0 && (
<div style={{marginTop:12}}>
<div className="sect">PoE Settings for Selected Ports</div>
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
{selectedPorts.map(sp => (
<div key={sp.id} style={{
display:"flex",alignItems:"center",gap:6,
background:"var(--bg)",border:"1px solid var(--b2)",
borderRadius:4,padding:"4px 10px",fontSize:12,
}}>
<span style={{fontWeight:700,color:"var(--ac)"}}>Port {sp.id}</span>
<label style={{display:"flex",alignItems:"center",gap:4,cursor:"pointer"}}>
<input type="checkbox" checked={sp.poe}
onChange={e => setSelectedPorts(prev =>
prev.map(p => p.id === sp.id ? {...p, poe: e.target.checked} : p)
)}/>
<span style={{fontSize:11}}>PoE</span>
</label>
</div>
))}
</div>
</div>
)}
<div style={{marginTop:16}}>
<div className="sect">Trunk Uplinks (tag this VLAN on existing trunks)</div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:8}}>
Select uplink ports that should carry this VLAN (typically SFP+ ports 49-52).
</div>
{portGrid(49, 52, true)}
</div>
</div>
{/* Summary */}
{(form.vlan_id && form.name) && (
<div style={{
marginTop:16,padding:12,background:"var(--b1)",borderRadius:6,
fontSize:12,lineHeight:1.8,
}}>
<div style={{fontWeight:700,color:"var(--ac)",marginBottom:4}}>Provision Summary</div>
<div>VLAN {form.vlan_id} "{form.name}" | Subnet: {form.subnet || "—"}</div>
<div>Access ports: {selectedPorts.length > 0
? selectedPorts.map(p => `${p.id}${p.poe ? " (PoE)" : ""}`).join(", ")
: "none selected"}</div>
<div>Trunk ports: {trunkPorts.length > 0 ? trunkPorts.join(", ") : "none"}</div>
<div>OPNsense: {form.opnsense_if
? `DHCP ${form.dhcp_start}${form.dhcp_end} on ${form.opnsense_if}`
: "VLAN tag only (assign interface in OPNsense UI)"}</div>
{form.allow_internet && <div>Firewall: allow outbound</div>}
</div>
)}
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
<button className="btn bp" onClick={provision}
disabled={provisioning || !form.vlan_id || !form.name}
style={{padding:"10px 24px"}}>
{provisioning ? "Provisioning..." : "Provision VLAN"}
</button>
<span style={{fontSize:11,color:"var(--dm)"}}>
Auto-backup runs before changes. TOTP required.
</span>
</div>
{/* Result */}
{result && (
<div style={{
marginTop:16,padding:12,borderRadius:6,
background: result.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
border: `1px solid ${result.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
}}>
<div style={{fontWeight:700,marginBottom:8,color:result.success?"#00e676":"#ff1744"}}>
{result.success ? "Provisioning Complete" : "Provisioning Failed"}
</div>
{result.steps_done?.map((s,i) => (
<div key={i} style={{fontSize:12,color:"var(--tx)",lineHeight:1.8}}>
<span style={{color:"#00e676",marginRight:6}}>done</span>{s}
</div>
))}
{result.errors?.map((e,i) => (
<div key={i} style={{fontSize:12,color:"#ff1744",lineHeight:1.8}}>
<span style={{marginRight:6}}>error</span>{e}
</div>
))}
{result.pending_steps?.map((s,i) => (
<div key={i} style={{fontSize:12,color:"var(--warn, #ffea00)",lineHeight:1.8}}>
<span style={{marginRight:6}}>manual</span>{s}
</div>
))}
{result.backup && (
<div style={{marginTop:8,fontSize:11,color:"var(--dm)"}}>
Backup: switch={result.backup.switch?.file || "N/A"}
{result.backup.opnsense ? `, opnsense=${result.backup.opnsense.file || "N/A"}` : ""}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// 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 }) => (
<div className="panel">
<div className="ph">{device === "switch" ? "Switch" : "OPNsense"} Backups</div>
<div className="pb">
<div style={{display:"flex",gap:8,marginBottom:12,alignItems:"flex-end"}}>
<div className="field" style={{margin:0,flex:1}}>
<label>Reason (optional)</label>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="e.g. before VLAN change"/>
</div>
<button className="btn bp" onClick={() => createBackup(device)} disabled={creating}
style={{padding:"8px 16px"}}>
{creating ? "Creating..." : `Backup ${device === "switch" ? "Switch" : "OPNsense"}`}
</button>
</div>
{items.length === 0 ? (
<div style={{color:"var(--dm)",fontSize:12,padding:16,textAlign:"center"}}>
No backups yet. Create one before making changes.
</div>
) : (
<table className="vtbl">
<thead><tr><th>Time</th><th>Reason</th><th>Size</th><th>Actions</th></tr></thead>
<tbody>
{items.map((b,i) => (
<tr key={i}>
<td style={{fontSize:12,fontFamily:"monospace"}}>{b.timestamp}</td>
<td style={{fontSize:12}}>{b.reason || "—"}</td>
<td style={{fontSize:12}}>{b.size ? `${(b.size/1024).toFixed(1)} KB` : "—"}</td>
<td style={{display:"flex",gap:6}}>
<a href={`/api/backup/download/${device}/${b.file}`}
style={{fontSize:11,color:"var(--ac)",textDecoration:"none",cursor:"pointer"}}>
Download
</a>
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
onClick={() => restore(device, b.file)}
disabled={restoring === b.file}>
{restoring === b.file ? "Restoring..." : "Restore"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
return (
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Backup & Restore</div>
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
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.
<div style={{marginTop:8,color:"var(--ac)",fontWeight:600}}>
Restore: OPNsense configs can be restored via API. Switch configs must be reviewed
and applied via the Review & Push tab (to prevent accidental lockout).
</div>
</div>
</div>
<div style={{display:"flex",gap:8,marginBottom:16}}>
<button className="btn bp" onClick={() => createBackup("both")} disabled={creating}
style={{padding:"10px 20px"}}>
{creating ? "Creating..." : "Backup Both Devices"}
</button>
</div>
{restoreResult && (
<div style={{
marginBottom:16,padding:12,borderRadius:6,
background: restoreResult.result?.ok ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
border: `1px solid ${restoreResult.result?.ok ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
fontSize:12,
}}>
{restoreResult.result?.ok
? <div style={{color:"#00e676"}}>Restore successful. Safety backup was created first.</div>
: <div style={{color:"#ff1744"}}>Restore failed: {restoreResult.result?.error || "Unknown error"}</div>
}
{restoreResult.config_preview && (
<details style={{marginTop:8}}>
<summary style={{cursor:"pointer",color:"var(--ac)"}}>Config preview</summary>
<pre style={{fontSize:10,maxHeight:300,overflow:"auto",marginTop:4,
background:"var(--bg)",padding:8,borderRadius:4}}>
{restoreResult.config_preview}
</pre>
</details>
)}
</div>
)}
<BackupTable device="switch" items={backups.switch} />
<BackupTable device="opnsense" items={backups.opnsense} />
</div>
</div>
);
}
+528
View File
@@ -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"<opnsense>" not in content and b"<OPNsense>" not in content:
return {"ok": False, "error": "File does not look like an OPNsense config"}
host = cfg.get("host", "")
key = cfg.get("key", "")
secret = cfg.get("secret", "")
if not host or not key:
return {"ok": False, "error": "OPNsense not configured"}
try:
creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()
ctx = _ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
# OPNsense restore API expects multipart form upload
import mimetypes
boundary = f"----BackupRestore{_ts()}"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="conf"; filename="{filename}"\r\n'
f"Content-Type: application/xml\r\n\r\n"
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
url = f"https://{host}/api/core/backup/restore"
req = _urlreq.Request(url, data=body, headers={
"Authorization": f"Basic {creds}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}, method="POST")
with _urlreq.urlopen(req, timeout=60, context=ctx) as r:
result = _json.loads(r.read().decode())
log.info(f"OPNsense restore from {filename}: {result}")
return {"ok": True, "result": result, "file": filename}
except Exception as e:
log.warning(f"OPNsense restore failed: {e}")
return {"ok": False, "error": str(e)}
# ── Switch backup (running-config capture) ───────────────────────────
def _switch_backup(reason: str = "") -> dict:
"""Capture switch running-config via SSH and save locally."""
bdir = BACKUP_DIR / "switch"
bdir.mkdir(parents=True, exist_ok=True)
ts = _ts()
fname = f"switch-{ts}.cfg"
fpath = bdir / fname
try:
raw = read_cmd("show running-config")
if not raw or len(raw) < 50:
return {"ok": False, "error": "Empty or too-short running-config output"}
fpath.write_text(raw)
fpath.chmod(0o600)
meta = {"timestamp": ts, "reason": reason, "file": fname, "size": len(raw)}
(bdir / f"switch-{ts}.meta.json").write_text(_json.dumps(meta, indent=2))
_prune_backups(bdir)
log.info(f"Switch backup saved: {fname} ({len(raw)} bytes) reason={reason}")
return {"ok": True, "file": fname, "size": len(raw), "timestamp": ts}
except Exception as e:
log.warning(f"Switch backup failed: {e}")
return {"ok": False, "error": str(e)}
# ── Pre-change backup (called automatically before any push) ─────────
def _pre_change_backup(reason: str) -> dict:
"""Backup both devices before making changes. Returns status for each."""
results = {"switch": None, "opnsense": None}
# Always backup switch
results["switch"] = _switch_backup(reason=reason)
# Backup OPNsense if configured
cfg = _load_opnsense_cfg()
if cfg.get("key"):
results["opnsense"] = _opnsense_backup(cfg, reason=reason)
return results
# ── Connectivity safety check ────────────────────────────────────────
def _check_connectivity() -> dict:
"""Verify SSH reachability to switch and OPNsense. Non-destructive probe."""
result = {"switch": {"ok": False, "error": ""}, "opnsense": {"ok": False, "error": "", "configured": False}}
# Check switch
try:
conn = _pool.get()
transport = conn.get_transport()
if transport and transport.is_active():
transport.send_ignore()
result["switch"]["ok"] = True
else:
result["switch"]["error"] = "SSH transport not active"
except Exception as e:
result["switch"]["error"] = str(e)
# Check OPNsense (if configured)
cfg = _load_opnsense_cfg()
if cfg.get("key"):
result["opnsense"]["configured"] = True
try:
_opnsense_request(cfg, "core/firmware/status")
result["opnsense"]["ok"] = True
except Exception as e:
result["opnsense"]["error"] = str(e)
if cfg.get("ssh_key_path"):
try:
test = _opnsense_ssh_test(cfg)
result["opnsense"]["ssh_ok"] = test.get("ok", False)
except Exception:
result["opnsense"]["ssh_ok"] = False
return result
# ── API endpoints ────────────────────────────────────────────────────
@app.get("/api/backup/list")
def backup_list():
"""List all available backups for both devices."""
backups = {"switch": [], "opnsense": []}
for device in ["switch", "opnsense"]:
bdir = BACKUP_DIR / device
if not bdir.exists():
continue
for meta_file in sorted(bdir.glob("*.meta.json"), reverse=True):
try:
meta = _json.loads(meta_file.read_text())
meta["exists"] = (bdir / meta["file"]).exists()
backups[device].append(meta)
except Exception:
continue
return backups
@app.post("/api/backup/create")
def backup_create(body: dict):
"""Manually trigger a backup of one or both devices."""
require_session(body.get("token", ""))
device = body.get("device", "both") # "switch", "opnsense", or "both"
reason = body.get("reason", "manual backup")
results = {}
if device in ("switch", "both"):
results["switch"] = _switch_backup(reason=reason)
if device in ("opnsense", "both"):
cfg = _load_opnsense_cfg()
if cfg.get("key"):
results["opnsense"] = _opnsense_backup(cfg, reason=reason)
else:
results["opnsense"] = {"ok": False, "error": "OPNsense not configured"}
return results
@app.post("/api/backup/restore")
def backup_restore(body: dict):
"""Restore a backup to a device. Creates a new backup first as safety net."""
require_session(body.get("token", ""))
device = body.get("device", "")
filename = body.get("filename", "")
if not device or not filename:
raise HTTPException(400, "device and filename required")
if device not in ("switch", "opnsense"):
raise HTTPException(400, "device must be 'switch' or 'opnsense'")
# Safety: backup current state first
safety = _pre_change_backup(reason=f"pre-restore safety backup before restoring {filename}")
if device == "opnsense":
cfg = _load_opnsense_cfg()
result = _opnsense_restore(cfg, filename)
return {"result": result, "safety_backup": safety}
elif device == "switch":
# Switch restore = parse config and push commands
bdir = BACKUP_DIR / "switch"
fpath = bdir / filename
if not fpath.exists():
raise HTTPException(404, f"Backup file not found: {filename}")
return {
"result": {"ok": True, "note": "Switch config restore requires manual review. "
"Download the backup file and apply commands via Review & Push tab."},
"safety_backup": safety,
"config_preview": fpath.read_text()[:5000],
}
@app.get("/api/backup/download/{device}/{filename}")
def backup_download(device: str, filename: str):
"""Download a backup file."""
if device not in ("switch", "opnsense"):
raise HTTPException(400, "device must be 'switch' or 'opnsense'")
# Prevent path traversal
if "/" in filename or ".." in filename:
raise HTTPException(400, "Invalid filename")
fpath = BACKUP_DIR / device / filename
if not fpath.exists():
raise HTTPException(404, "Backup not found")
from fastapi.responses import FileResponse
return FileResponse(fpath, filename=filename)
@app.get("/api/connectivity/check")
def connectivity_check():
"""Check SSH/API reachability to both switch and OPNsense."""
return _check_connectivity()
# ══════════════════════════════════════════════════════════════════════
# UNIFIED NETWORK PROVISIONING — one operation for both devices
# ══════════════════════════════════════════════════════════════════════
class UnifiedVlanProvision(BaseModel):
token: str
vlan_id: int
name: str
subnet: str # e.g. "192.168.60.0/24"
gateway: str # e.g. "192.168.60.1"
dhcp_start: str # e.g. "192.168.60.100"
dhcp_end: str # e.g. "192.168.60.200"
parent_if: str # OPNsense physical parent, e.g. "igb0"
opnsense_if: Optional[str] = ""
allow_internet: bool = True
# Port assignments
ports: list[dict] = [] # [{"port": 1, "poe": true}, {"port": 5, "poe": false}]
# Trunk uplink ports (these get the new VLAN tagged)
trunk_ports: list[int] = []
@field_validator("vlan_id")
@classmethod
def cv(cls, v):
vid = san_vid(v)
if vid in (1, 99):
raise ValueError("VLAN 1 and 99 are reserved")
return vid
@field_validator("name")
@classmethod
def cn(cls, v): return _san(v, _RE_VNAME, "name")
@app.post("/api/network/provision")
def unified_provision(body: UnifiedVlanProvision):
"""
Unified VLAN + port + OPNsense provisioning in one operation.
1. Pre-change backup of both devices
2. Connectivity check
3. Create VLAN on switch + assign ports + set PoE
4. Create VLAN on OPNsense + DHCP scope + firewall rule
5. Post-change connectivity verify
"""
import ipaddress as _ipaddr
require_session(body.token)
steps_done: list[str] = []
errors: list[str] = []
pending_steps: list[str] = []
# Validate addresses
try:
net = _ipaddr.ip_network(body.subnet, strict=False)
_ipaddr.ip_address(body.gateway)
_ipaddr.ip_address(body.dhcp_start)
_ipaddr.ip_address(body.dhcp_end)
except ValueError as e:
raise HTTPException(400, f"Invalid address: {e}")
# ── Step 0: Pre-change connectivity check ────────────────────────
conn = _check_connectivity()
if not conn["switch"]["ok"]:
raise HTTPException(503, f"Switch unreachable — aborting: {conn['switch']['error']}")
steps_done.append("connectivity: switch reachable")
# ── Step 1: Pre-change backup ────────────────────────────────────
backup = _pre_change_backup(reason=f"pre-provision VLAN {body.vlan_id} '{body.name}'")
if backup["switch"] and backup["switch"].get("ok"):
steps_done.append(f"backup: switch config saved ({backup['switch']['file']})")
else:
errors.append(f"backup: switch backup failed — {backup['switch'].get('error', 'unknown')}")
# Non-fatal but warn
if backup.get("opnsense") and backup["opnsense"].get("ok"):
steps_done.append(f"backup: OPNsense config saved ({backup['opnsense']['file']})")
# ── Step 2: Create VLAN on switch ────────────────────────────────
switch_cmds = [f'vlan create {body.vlan_id} name "{body.name}" type port']
# Assign access ports
for pa in body.ports:
p = san_port(pa["port"])
vid = body.vlan_id
switch_cmds += [
f"vlan members add {vid} {p}",
f"vlan pvid {p} {vid}",
]
iface = f"FastEthernet {p}" if p <= 48 else f"GigabitEthernet {p}"
if p <= 96:
switch_cmds.append(f"interface {iface}")
if pa.get("poe", True):
switch_cmds += [" poe enable", f" poe poe-limit {pa.get('poe_limit', 30000)}"]
else:
switch_cmds += [" no poe enable"]
# Add VLAN to trunk uplinks
for tp in body.trunk_ports:
tp = san_port(tp)
switch_cmds += [
f"vlan members add {body.vlan_id} {tp}",
f"vlan tagging {body.vlan_id} {tp}",
]
# Danger check before push
danger = check_danger(switch_cmds)
if danger["has_hard_block"]:
raise HTTPException(400, {
"message": "Hard-blocked commands detected",
"blocked": danger["hard_blocked"],
})
result = push_one_by_one(switch_cmds)
if result.get("success"):
steps_done.append(f"switch: VLAN {body.vlan_id} created, {len(body.ports)} ports assigned, "
f"{len(body.trunk_ports)} trunk ports updated")
else:
errors.append(f"switch: push failed at command {result.get('stopped_at', '?')}: "
f"{result.get('error', 'unknown')}")
# Return early — don't configure OPNsense for a VLAN the switch doesn't have
return {
"success": False, "steps_done": steps_done, "errors": errors,
"pending_steps": [], "backup": backup, "switch_result": result,
}
# ── Step 3: OPNsense VLAN + DHCP + firewall ─────────────────────
cfg = _load_opnsense_cfg()
if not cfg.get("key"):
pending_steps += [
f"OPNsense: create VLAN tag {body.vlan_id} on {body.parent_if}",
f"OPNsense: assign interface, set IP {body.gateway}/{net.prefixlen}",
f"OPNsense: create DHCP scope {body.dhcp_start}{body.dhcp_end}",
]
if body.allow_internet:
pending_steps.append("OPNsense: add allow-outbound firewall rule")
else:
# Create VLAN tag
try:
vlan_r = _opnsense_request(cfg, "interfaces/vlan_settings/addItem", "POST", {
"vlan": {"if": body.parent_if, "tag": str(body.vlan_id),
"pcp": "0", "descr": body.name}
})
_opnsense_request(cfg, "interfaces/vlan_settings/reconfigure", "POST")
steps_done.append(f"OPNsense: VLAN tag {body.vlan_id} created on {body.parent_if}")
except ValueError as e:
errors.append(f"OPNsense VLAN tag: {e}")
if body.opnsense_if:
# DHCP scope
try:
_opnsense_request(cfg, "dhcpv4/settings/addSubnet", "POST", {
"subnet": {
"interface": body.opnsense_if,
"subnet": str(net),
"gateway": body.gateway,
"dns_servers": body.gateway,
"range": {"from": body.dhcp_start, "to": body.dhcp_end},
}
})
_opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
steps_done.append(f"OPNsense: DHCP scope {body.dhcp_start}{body.dhcp_end}")
except ValueError as e:
errors.append(f"OPNsense DHCP: {e}")
# Firewall rule
if body.allow_internet:
try:
_opnsense_request(cfg, "firewall/filter/addRule", "POST", {
"rule": {
"enabled": "1", "action": "pass",
"interface": body.opnsense_if, "direction": "in",
"ipprotocol": "inet", "protocol": "any",
"source": {"network": f"{body.opnsense_if}net"},
"destination": {"any": "1"},
"descr": f"Allow VLAN {body.vlan_id} {body.name} outbound",
}
})
_opnsense_request(cfg, "firewall/filter/apply", "POST")
steps_done.append(f"OPNsense: allow-outbound firewall rule added")
except ValueError as e:
errors.append(f"OPNsense firewall: {e}")
# Persist mapping
vmap = _load_vlan_if_map()
vmap[str(body.vlan_id)] = body.opnsense_if
_save_vlan_if_map(vmap)
else:
pending_steps += [
f"OPNsense UI: assign {body.parent_if}.{body.vlan_id} as interface, "
f"set IP {body.gateway}/{net.prefixlen}",
f"Then re-run with opnsense_if set to create DHCP + firewall",
]
# ── Step 4: Post-change connectivity verify ──────────────────────
post_conn = _check_connectivity()
if not post_conn["switch"]["ok"]:
errors.append("POST-CHANGE WARNING: switch SSH connectivity lost! "
f"Backup available: {backup['switch'].get('file', 'N/A')}")
return {
"success": len(errors) == 0,
"steps_done": steps_done,
"pending_steps": pending_steps,
"errors": errors,
"backup": backup,
"switch_result": result,
"post_connectivity": post_conn,
}
# ── Wrap existing push to auto-backup ────────────────────────────────
_original_push = push_one_by_one
def push_one_by_one_with_backup(commands: list[str]) -> dict:
"""Wraps push_one_by_one to create a backup before pushing."""
backup = _pre_change_backup(reason=f"pre-push ({len(commands)} commands)")
result = _original_push(commands)
result["backup"] = backup
return result
# Monkey-patch: the push endpoint calls push_one_by_one directly
# We leave push_one_by_one as-is (it's used internally) and
# add the backup in the API endpoint wrapper below.
@app.post("/api/switch/push-safe")
def push_safe(body: PushBatch):
"""Push with automatic pre-change backup and post-change connectivity check."""
require_session(body.token)
# Danger check
danger = check_danger(body.commands)
if danger["has_hard_block"]:
raise HTTPException(400, {
"message": "Hard-blocked commands — must be run at console",
"blocked": danger["hard_blocked"],
})
# Pre-check
conn = _check_connectivity()
if not conn["switch"]["ok"]:
raise HTTPException(503, "Switch unreachable — refusing to push")
# Backup
backup = _pre_change_backup(reason=f"pre-push ({len(body.commands)} commands)")
# Push
result = push_one_by_one(body.commands)
# Post-check
post_conn = _check_connectivity()
if not post_conn["switch"]["ok"]:
result["connectivity_warning"] = (
"Switch SSH lost after push! "
f"Backup: {backup['switch'].get('file', 'N/A')}"
)
result["backup"] = backup
result["post_connectivity"] = post_conn
return result