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 = [
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user