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:
@@ -1525,6 +1525,7 @@ export default function App() {
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id:"dashboard",label:"Dashboard" },
|
{ id:"dashboard",label:"Dashboard" },
|
||||||
|
{ id:"network", label:"Network" },
|
||||||
{ id:"ports", label:"Port Map" },
|
{ id:"ports", label:"Port Map" },
|
||||||
{ id:"vlans", label:"VLANs" },
|
{ id:"vlans", label:"VLANs" },
|
||||||
{ id:"acls", label:"ACL Builder" },
|
{ id:"acls", label:"ACL Builder" },
|
||||||
@@ -1533,6 +1534,7 @@ export default function App() {
|
|||||||
{ id:"dhcp", label:"DHCP" },
|
{ id:"dhcp", label:"DHCP" },
|
||||||
{ id:"dns", label:"DNS Filtering" },
|
{ id:"dns", label:"DNS Filtering" },
|
||||||
{ id:"vpn", label:"VPN" },
|
{ id:"vpn", label:"VPN" },
|
||||||
|
{ id:"backups", label:"Backups" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1598,12 +1600,24 @@ export default function App() {
|
|||||||
acls={acls}
|
acls={acls}
|
||||||
setAcls={setAcls}
|
setAcls={setAcls}
|
||||||
/>}
|
/>}
|
||||||
|
{tab==="network" && <NetworkTab
|
||||||
|
vlans={vlans} setVlans={setVlans}
|
||||||
|
ports={ports} updatePort={updatePort}
|
||||||
|
session={session}
|
||||||
|
onNeedAuth={() => setShowTotp(true)}
|
||||||
|
backendOk={pollStatus!=="err"}
|
||||||
|
/>}
|
||||||
{tab==="vpn" && <WireGuardTab
|
{tab==="vpn" && <WireGuardTab
|
||||||
session={session}
|
session={session}
|
||||||
onNeedAuth={() => setShowTotp(true)}
|
onNeedAuth={() => setShowTotp(true)}
|
||||||
backendOk={pollStatus!=="err"}
|
backendOk={pollStatus!=="err"}
|
||||||
vlans={vlans}
|
vlans={vlans}
|
||||||
/>}
|
/>}
|
||||||
|
{tab==="backups" && <BackupTab
|
||||||
|
session={session}
|
||||||
|
onNeedAuth={() => setShowTotp(true)}
|
||||||
|
backendOk={pollStatus!=="err"}
|
||||||
|
/>}
|
||||||
|
|
||||||
{showTotp && <TotpModal
|
{showTotp && <TotpModal
|
||||||
onSuccess={handleTotpSuccess}
|
onSuccess={handleTotpSuccess}
|
||||||
@@ -4265,3 +4279,472 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk, acls, setAcls }) {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3613,3 +3613,531 @@ def opnsense_unbound_write_forward_ctrld(body: dict):
|
|||||||
raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}")
|
raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}")
|
||||||
_opnsense_ssh_run(cfg, "unbound-control reload 2>&1")
|
_opnsense_ssh_run(cfg, "unbound-control reload 2>&1")
|
||||||
return {"success": True, "content": content, "enabled": enabled, "port": port}
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user