diff --git a/Caddyfile.template b/Caddyfile.template
index 8869cf9..5c501bd 100644
--- a/Caddyfile.template
+++ b/Caddyfile.template
@@ -8,3 +8,6 @@
:80 {{
redir https://{{host}}{{uri}} permanent
}}
+
+# Service reverse proxy entries — auto-managed by switch-manager Services tab
+import /etc/caddy/Caddyfile.services
diff --git a/docker-compose.yml b/docker-compose.yml
index 26b85b9..eb7a17e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,6 +6,7 @@ services:
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - /etc/switch-manager/Caddyfile.services:/etc/caddy/Caddyfile.services:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
@@ -17,7 +18,7 @@ services:
expose:
- "8765"
volumes:
- - /etc/switch-manager:/etc/switch-manager:ro
+ - /etc/switch-manager:/etc/switch-manager
- ./frontend/dist:/app/frontend/dist:ro
restart: unless-stopped
environment:
diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx
index b870960..3606627 100644
--- a/ers5952-manager.jsx
+++ b/ers5952-manager.jsx
@@ -417,7 +417,7 @@ function SessionBtn({ session, onUnlock }) {
// ══════════════════════════════════════════════════════════════════════════════
// SETTINGS PANEL
// ══════════════════════════════════════════════════════════════════════════════
-function SettingsPanel({ settings, setSettings, onClose }) {
+function SettingsPanel({ settings, setSettings, session, onClose }) {
return (
e.stopPropagation()}>
@@ -454,6 +454,29 @@ function SettingsPanel({ settings, setSettings, onClose }) {
+
+
Network
+
+
+
Services / Caddy box LAN IP
+
The computer running Caddy and services (Plex, etc.) — separate from this management computer
+
+
setSettings(s => ({...s, caddyIp: e.target.value}))}
+ placeholder="192.168.1.50"
+ onBlur={() => {
+ if (settings.caddyIp && session?.token) {
+ fetch("/api/services/config", {
+ method:"POST",
+ headers:{"Content-Type":"application/json"},
+ body: JSON.stringify({ token: session.token, config: { caddy_ip: settings.caddyIp } })
+ }).catch(() => {});
+ }
+ }}
+ style={{background:"var(--bg)",border:"1px solid var(--b2)",color:"var(--tx)",
+ padding:"4px 8px",fontFamily:"var(--mono)",fontSize:11,borderRadius:3,width:140}}/>
+
+
+
About
@@ -1473,7 +1496,7 @@ export default function App() {
const [selected, setSelected] = useState(null);
const [hostname, setHostname] = useState("ERS-5952");
const [switchIP, setSwitchIP] = useState("192.168.99.1");
- const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch" });
+ const [settings, setSettings] = useState({ cliMode: false, defaultPushMode: "batch", caddyIp: "" });
const [showSettings, setShowSettings] = useState(false);
const [connState, setConnState] = useState("connecting");
@@ -1487,6 +1510,13 @@ export default function App() {
const updatePort = useCallback(p => setPorts(prev => prev.map(x => x.id===p.id?p:x)), []);
// Heartbeat
+ // Load services config (caddy IP) once on mount
+ useEffect(() => {
+ API("/services/config").then(cfg => {
+ if (cfg.caddy_ip) setSettings(s => ({...s, caddyIp: cfg.caddy_ip}));
+ }).catch(() => {});
+ }, []);
+
useEffect(() => {
let firstPoll = true;
const beat = async () => {
@@ -1525,14 +1555,22 @@ export default function App() {
const TABS = [
{ id:"dashboard",label:"Dashboard" },
- { id:"ports", label:"Port Map" },
- { id:"vlans", label:"VLANs" },
- { id:"acls", label:"ACL Builder" },
- { id:"cli", label:"Review & Push" },
- { id:"devices", label:"Device Access" },
- { id:"dhcp", label:"DHCP" },
- { id:"dns", label:"DNS Filtering" },
- { id:"vpn", label:"VPN" },
+ { id:"network", label:"Network" },
+ { id:"firewall", label:"Firewall" },
+ { id:"services", label:"Services" },
+ { id:"portfwd", label:"Port Fwd" },
+ { id:"ports", label:"Port Map" },
+ { id:"vlans", label:"VLANs" },
+ { id:"acls", label:"ACL Builder" },
+ { id:"cli", label:"Review & Push" },
+ { id:"devices", label:"Device Access" },
+ { id:"dhcp", label:"DHCP" },
+ { id:"dns", label:"DNS Filtering" },
+ { id:"vpn", label:"VPN" },
+ { id:"poe", label:"PoE" },
+ { id:"topology", label:"Topology" },
+ { id:"backups", label:"Backups" },
+ { id:"alerts", label:"Alerts" },
];
return (
@@ -1598,12 +1636,51 @@ export default function App() {
acls={acls}
setAcls={setAcls}
/>}
+ {tab==="network" &&
setShowTotp(true)}
+ backendOk={pollStatus!=="err"}
+ />}
+ {tab==="firewall" && setShowTotp(true)}
+ backendOk={pollStatus!=="err"}
+ />}
+ {tab==="services" && setShowTotp(true)}
+ backendOk={pollStatus!=="err"}
+ />}
+ {tab==="alerts" && setShowTotp(true)}
+ backendOk={pollStatus!=="err"}
+ />}
{tab==="vpn" && setShowTotp(true)}
backendOk={pollStatus!=="err"}
vlans={vlans}
/>}
+ {tab==="backups" && setShowTotp(true)}
+ backendOk={pollStatus!=="err"}
+ />}
+ {tab==="portfwd" && setShowTotp(true)}
+ backendOk={pollStatus!=="err"}
+ />}
+ {tab==="poe" && }
+ {tab==="topology" && }
{showTotp && setShowSettings(false)}
/>}
@@ -1866,7 +1944,7 @@ function DeviceAccessTab({ session, onNeedAuth, backendOk }) {
// WIREGUARD TAB
// ══════════════════════════════════════════════════════════════════════════════
-function QRModal({ config, name, onClose }) {
+function QRModal({ config, name, ctrldNote, onClose }) {
// Render QR using a simple API since we can't use native qrencode in browser
const [qrUrl, setQrUrl] = useState('');
useEffect(() => {
@@ -1889,6 +1967,14 @@ function QRModal({ config, name, onClose }) {
maxHeight:120,overflowY:"auto",marginBottom:12}}>
{config}
+ {ctrldNote && (
+
+
ControlD DNS Setup
+ {ctrldNote}
+
+ )}
{
const a=document.createElement('a');
@@ -2153,6 +2239,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
});
const [opnPeerName, setOpnPeerName] = useState('');
const [opnVlans, setOpnVlans] = useState([]); // checked VLAN IDs
+ const [opnDnsProfile, setOpnDnsProfile] = useState(''); // ControlD profile for VPN clients — auto-set from VLAN
const [opnAdding, setOpnAdding] = useState(false);
const [opnQr, setOpnQr] = useState(null); // { config, name }
@@ -2232,7 +2319,16 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
};
const opnToggleVlan = (vid) => {
- setOpnVlans(prev => prev.includes(vid) ? prev.filter(v=>v!==vid) : [...prev, vid]);
+ const next = opnVlans.includes(vid) ? opnVlans.filter(v=>v!==vid) : [...opnVlans, vid];
+ setOpnVlans(next);
+ // Auto-set DNS profile from the VLAN (when single VLAN selected)
+ if (next.length === 1) {
+ const v = vlans.find(x => x.id === next[0]);
+ if (v) setOpnDnsProfile(v.name.toLowerCase());
+ } else if (next.length === 0) {
+ setOpnDnsProfile('');
+ }
+ // When multiple VLANs selected, keep whatever profile is set
};
const opnAddPeer = async () => {
@@ -2247,9 +2343,10 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
const r = await API("/opnsense/wireguard/add-peer", {
method:"POST",
body:{ token: session.token, name: opnPeerName.trim(),
- allowed_vlans: opnVlans, vlan_subnets }
+ allowed_vlans: opnVlans, vlan_subnets,
+ dns_profile: opnDnsProfile }
});
- setOpnQr({ config: r.config, name: r.name });
+ setOpnQr({ config: r.config, name: r.name, ctrld_note: r.ctrld_note });
setOpnPeerName(''); setOpnVlans([]);
await loadOpnWg();
} catch(e) { setOpnError(e.message); }
@@ -2316,6 +2413,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
opnSetup={opnSetup} setOpnSetup={setOpnSetup}
opnPeerName={opnPeerName} setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans} opnAdding={opnAdding}
+ opnDnsProfile={opnDnsProfile} setOpnDnsProfile={setOpnDnsProfile}
session={session} onNeedAuth={onNeedAuth} vlans={vlans}
onToggleVlan={opnToggleVlan} onSetupServer={opnSetupServer}
onDeleteServer={opnDeleteServer} onAddPeer={opnAddPeer}
@@ -2464,6 +2562,8 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
setOpnPeerName={setOpnPeerName}
opnVlans={opnVlans}
opnAdding={opnAdding}
+ opnDnsProfile={opnDnsProfile}
+ setOpnDnsProfile={setOpnDnsProfile}
session={session}
onNeedAuth={onNeedAuth}
vlans={vlans}
@@ -2477,7 +2577,8 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
/>
)}
- {opnQr && setOpnQr(null)}/>}
+ {opnQr && setOpnQr(null)}/>}
);
}
@@ -2486,6 +2587,7 @@ function WireGuardTab({ session, onNeedAuth, backendOk, vlans = [] }) {
function OPNsenseWGSection({
opnWg, opnLoading, opnError, opnSetup, setOpnSetup,
opnPeerName, setOpnPeerName, opnVlans, opnAdding,
+ opnDnsProfile, setOpnDnsProfile,
session, onNeedAuth, vlans,
onToggleVlan, onSetupServer, onDeleteServer,
onAddPeer, onRevokePeer, onShowConf, onRefresh,
@@ -2732,6 +2834,23 @@ function OPNsenseWGSection({
+
+
+ ControlD DNS Profile
+ setOpnDnsProfile(e.target.value)}
+ placeholder="auto-set from VLAN, or type a profile name"/>
+
+
+ {opnVlans.length === 1
+ ? `Auto-set to "${opnDnsProfile}" from selected VLAN. Change if needed.`
+ : opnVlans.length > 1
+ ? "Multiple VLANs selected — set the profile manually."
+ : "Select a VLAN above to auto-fill, or type a ControlD profile name."}
+ {" "}VPN clients use OPNsense DNS → Unbound → ctrld → ControlD.
+
+
+
{!session && (
);
}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// UNIFIED NETWORK TAB — manage VLANs + ports + OPNsense in one place
+// ══════════════════════════════════════════════════════════════════════════════
+
+function NetworkTab({ vlans, setVlans, ports, updatePort, session, onNeedAuth, backendOk }) {
+ const [form, setForm] = useState({
+ vlan_id: "", name: "", subnet: "", gateway: "",
+ dhcp_start: "", dhcp_end: "",
+ parent_if: "igb0", opnsense_if: "", allow_internet: true,
+ });
+ const [selectedPorts, setSelectedPorts] = useState([]);
+ const [trunkPorts, setTrunkPorts] = useState([]);
+ const [provisioning, setProvisioning] = useState(false);
+ const [result, setResult] = useState(null);
+ const [connStatus, setConnStatus] = useState(null);
+ const [connLoading, setConnLoading] = useState(false);
+
+ const checkConn = async () => {
+ setConnLoading(true);
+ try { setConnStatus(await API("/connectivity/check")); }
+ catch(e) { setConnStatus({ error: e.message }); }
+ setConnLoading(false);
+ };
+
+ useEffect(() => { if (backendOk) checkConn(); }, [backendOk]);
+
+ const autoFillSubnet = (vid) => {
+ if (!vid) return;
+ const v = parseInt(vid);
+ if (v > 0 && v < 255) {
+ setForm(f => ({
+ ...f,
+ subnet: `192.168.${v}.0/24`,
+ gateway: `192.168.${v}.1`,
+ dhcp_start: `192.168.${v}.100`,
+ dhcp_end: `192.168.${v}.200`,
+ }));
+ }
+ };
+
+ const togglePort = (portId, list, setList) => {
+ setList(prev => prev.includes(portId) ? prev.filter(p => p !== portId) : [...prev, portId]);
+ };
+
+ const provision = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setProvisioning(true); setResult(null);
+ try {
+ const body = {
+ token: session.token,
+ vlan_id: parseInt(form.vlan_id),
+ name: form.name,
+ subnet: form.subnet,
+ gateway: form.gateway,
+ dhcp_start: form.dhcp_start,
+ dhcp_end: form.dhcp_end,
+ parent_if: form.parent_if,
+ opnsense_if: form.opnsense_if || "",
+ allow_internet: form.allow_internet,
+ ports: selectedPorts.map(p => ({ port: p.id, poe: p.poe, poe_limit: p.poeLimit || 30000 })),
+ trunk_ports: trunkPorts,
+ };
+ const r = await API("/network/provision", { method: "POST", body });
+ setResult(r);
+ if (r.success) {
+ // Update local vlans state
+ const newVlan = { id: parseInt(form.vlan_id), name: form.name,
+ color: VLAN_COLORS[vlans.length % VLAN_COLORS.length] };
+ if (!vlans.find(v => v.id === newVlan.id)) setVlans([...vlans, newVlan]);
+ }
+ } catch(e) {
+ setResult({ success: false, errors: [e.message] });
+ }
+ setProvisioning(false);
+ };
+
+ const portGrid = (start, end, forTrunk) => (
+
+ {ports.filter(p => p.id >= start && p.id <= end).map(p => {
+ const inUse = p.mode === "access" && p.accessVlan !== 1;
+ const vlan = vlans.find(v => v.id === (p.accessVlan || 1));
+ const isSelected = forTrunk ? trunkPorts.includes(p.id) : selectedPorts.find(s => s.id === p.id);
+ return (
+
{
+ if (forTrunk) {
+ togglePort(p.id, trunkPorts, setTrunkPorts);
+ } else {
+ if (isSelected) {
+ setSelectedPorts(prev => prev.filter(s => s.id !== p.id));
+ } else {
+ setSelectedPorts(prev => [...prev, { id: p.id, poe: true, poeLimit: 30000 }]);
+ }
+ }
+ }} style={{
+ width:36, height:36, display:"flex", alignItems:"center", justifyContent:"center",
+ fontSize:11, fontWeight:700, borderRadius:4, cursor:"pointer",
+ border: isSelected ? "2px solid var(--ac)" : "1px solid var(--b2)",
+ background: isSelected ? "var(--ac)" : inUse ? (vlan?.color || "var(--b1)") + "30" : "var(--bg)",
+ color: isSelected ? "var(--bg)" : inUse ? "var(--dm)" : "var(--tx)",
+ opacity: inUse && !forTrunk ? 0.5 : 1,
+ }} title={`Port ${p.id}${inUse ? ` (${vlan?.name || "VLAN " + p.accessVlan})` : ""}${p.description ? " — " + p.description : ""}`}>
+ {p.id}
+
+ );
+ })}
+
+ );
+
+ return (
+
+
+ {/* Connectivity Status */}
+
+
Connectivity Status
+
+
+
+ Switch {connStatus?.switch?.ok ? "Online" : "Offline"}
+
+
+
+
+ OPNsense {connStatus?.opnsense?.ok ? "Online" : connStatus?.opnsense?.configured ? "Unreachable" : "Not configured"}
+
+
+
+ {connLoading ? "Checking..." : "Refresh"}
+
+
+
+
+ {/* Existing VLANs overview */}
+
+
Active VLANs
+
+
+ {vlans.map(v => {
+ const cnt = ports.filter(p =>
+ p.mode === "access" ? p.accessVlan === v.id : p.taggedVlans?.includes(v.id)
+ ).length;
+ return (
+
+
VLAN {v.id}
+
{v.name}
+
+ {cnt} port{cnt !== 1 ? "s" : ""} | 192.168.{v.id}.0/24
+
+
+ );
+ })}
+
+
+
+
+ {/* Unified VLAN Provisioning Form */}
+
+
Provision New VLAN (Switch + OPNsense)
+
+
+ This creates everything in one step: VLAN on the switch, assigns ports with PoE settings,
+ creates the VLAN tag and DHCP scope on OPNsense, and adds a firewall rule.
+ A backup is taken automatically before any changes are made.
+
+
+
+
+ {/* Port Selection */}
+
+
Access Ports (will be assigned to this VLAN)
+
+ Click ports to select. Dimmed ports are already assigned to another VLAN.
+
+ {portGrid(1, 24, false)}
+ {portGrid(25, 48, false)}
+
+ {selectedPorts.length > 0 && (
+
+
PoE Settings for Selected Ports
+
+ {selectedPorts.map(sp => (
+
+ Port {sp.id}
+
+ setSelectedPorts(prev =>
+ prev.map(p => p.id === sp.id ? {...p, poe: e.target.checked} : p)
+ )}/>
+ PoE
+
+
+ ))}
+
+
+ )}
+
+
+
Trunk Uplinks (tag this VLAN on existing trunks)
+
+ Select uplink ports that should carry this VLAN (typically SFP+ ports 49-52).
+
+ {portGrid(49, 52, true)}
+
+
+
+ {/* Summary */}
+ {(form.vlan_id && form.name) && (
+
+
Provision Summary
+
VLAN {form.vlan_id} "{form.name}" | Subnet: {form.subnet || "—"}
+
Access ports: {selectedPorts.length > 0
+ ? selectedPorts.map(p => `${p.id}${p.poe ? " (PoE)" : ""}`).join(", ")
+ : "none selected"}
+
Trunk ports: {trunkPorts.length > 0 ? trunkPorts.join(", ") : "none"}
+
OPNsense: {form.opnsense_if
+ ? `DHCP ${form.dhcp_start}–${form.dhcp_end} on ${form.opnsense_if}`
+ : "VLAN tag only (assign interface in OPNsense UI)"}
+ {form.allow_internet &&
Firewall: allow outbound
}
+
+ )}
+
+
+
+ {provisioning ? "Provisioning..." : "Provision VLAN"}
+
+
+ Auto-backup runs before changes. TOTP required.
+
+
+
+ {/* Result */}
+ {result && (
+
+
+ {result.success ? "Provisioning Complete" : "Provisioning Failed"}
+
+ {result.steps_done?.map((s,i) => (
+
+ done {s}
+
+ ))}
+ {result.errors?.map((e,i) => (
+
+ error {e}
+
+ ))}
+ {result.pending_steps?.map((s,i) => (
+
+ manual {s}
+
+ ))}
+ {result.backup && (
+
+ Backup: switch={result.backup.switch?.file || "N/A"}
+ {result.backup.opnsense ? `, opnsense=${result.backup.opnsense.file || "N/A"}` : ""}
+
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// BACKUP TAB — view, create, restore, download backups
+// ══════════════════════════════════════════════════════════════════════════════
+
+function BackupTab({ session, onNeedAuth, backendOk }) {
+ const [backups, setBackups] = useState({ switch: [], opnsense: [] });
+ const [loading, setLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [restoring, setRestoring] = useState(null);
+ const [restoreResult, setRestoreResult] = useState(null);
+ const [reason, setReason] = useState("");
+
+ const load = async () => {
+ setLoading(true);
+ try { setBackups(await API("/backup/list")); } catch(e) { console.error(e); }
+ setLoading(false);
+ };
+
+ useEffect(() => { if (backendOk) load(); }, [backendOk]);
+
+ const createBackup = async (device) => {
+ if (!session) { onNeedAuth(); return; }
+ setCreating(true);
+ try {
+ await API("/backup/create", { method: "POST", body: {
+ token: session.token, device, reason: reason || "manual backup",
+ }});
+ setReason("");
+ await load();
+ } catch(e) { alert("Backup failed: " + e.message); }
+ setCreating(false);
+ };
+
+ const restore = async (device, filename) => {
+ if (!session) { onNeedAuth(); return; }
+ if (!confirm(`Restore ${device} from ${filename}?\n\nA safety backup will be created first.`)) return;
+ setRestoring(filename); setRestoreResult(null);
+ try {
+ const r = await API("/backup/restore", { method: "POST", body: {
+ token: session.token, device, filename,
+ }});
+ setRestoreResult(r);
+ await load();
+ } catch(e) { setRestoreResult({ result: { ok: false, error: e.message } }); }
+ setRestoring(null);
+ };
+
+ const BackupTable = ({ device, items }) => (
+
+
{device === "switch" ? "Switch" : "OPNsense"} Backups
+
+
+
+ Reason (optional)
+ setReason(e.target.value)} placeholder="e.g. before VLAN change"/>
+
+
createBackup(device)} disabled={creating}
+ style={{padding:"8px 16px"}}>
+ {creating ? "Creating..." : `Backup ${device === "switch" ? "Switch" : "OPNsense"}`}
+
+
+ {items.length === 0 ? (
+
+ No backups yet. Create one before making changes.
+
+ ) : (
+
+ Time Reason Size Actions
+
+ {items.map((b,i) => (
+
+ {b.timestamp}
+ {b.reason || "—"}
+ {b.size ? `${(b.size/1024).toFixed(1)} KB` : "—"}
+
+
+ Download
+
+ restore(device, b.file)}
+ disabled={restoring === b.file}>
+ {restoring === b.file ? "Restoring..." : "Restore"}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+
+ return (
+
+
+
+
Backup & Restore
+
+ Backups are created automatically before every change (VLAN provisioning, push operations).
+ You can also create manual backups here. OPNsense backups are full XML config exports.
+ Switch backups capture the running configuration.
+
+ Restore: OPNsense configs can be restored via API. Switch configs must be reviewed
+ and applied via the Review & Push tab (to prevent accidental lockout).
+
+
+
+
+
+ createBackup("both")} disabled={creating}
+ style={{padding:"10px 20px"}}>
+ {creating ? "Creating..." : "Backup Both Devices"}
+
+
+
+ {restoreResult && (
+
+ {restoreResult.result?.ok
+ ?
Restore successful. Safety backup was created first.
+ :
Restore failed: {restoreResult.result?.error || "Unknown error"}
+ }
+ {restoreResult.config_preview && (
+
+ Config preview
+
+ {restoreResult.config_preview}
+
+
+ )}
+
+ )}
+
+
+
+
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// FIREWALL TAB — inter-VLAN policy matrix
+// ══════════════════════════════════════════════════════════════════════════════
+
+function FirewallTab({ vlans, session, onNeedAuth, backendOk }) {
+ const [policies, setPolicies] = useState([]);
+ const [presets, setPresets] = useState({});
+ const [form, setForm] = useState({ src_vlan: "", dst_vlan: "", type: "block", ports: "" });
+ const [preview, setPreview] = useState(null);
+ const [pushing, setPushing] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const load = async () => {
+ try {
+ const d = await API("/firewall/policies");
+ setPolicies(d.policies || []);
+ setPresets(d.presets || {});
+ } catch(e) { console.error(e); }
+ };
+ useEffect(() => { if (backendOk) load(); }, [backendOk]);
+
+ const doPreview = async () => {
+ if (!form.src_vlan || !form.dst_vlan || !form.type) return;
+ try {
+ const policy = {
+ src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan),
+ type: form.type,
+ ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [],
+ };
+ const p = await API("/firewall/preview", { method: "POST", body: { policy } });
+ setPreview(p);
+ } catch(e) { setPreview({ error: e.message }); }
+ };
+
+ const pushPolicy = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setPushing(true); setResult(null);
+ try {
+ const policy = {
+ src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan),
+ type: form.type,
+ ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [],
+ };
+ const r = await API("/firewall/push", { method: "POST", body: { token: session.token, policy } });
+ setResult(r);
+ await load();
+ } catch(e) { setResult({ success: false, errors: [e.message] }); }
+ setPushing(false);
+ };
+
+ // Build the VLAN matrix
+ const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1);
+ const getPolicy = (src, dst) => policies.find(p => p.src_vlan === src && p.dst_vlan === dst);
+
+ const policyColor = (type) => ({
+ block: "#ff1744", allow: "#00e676", "one-way": "#2979ff",
+ printer: "#ff6d00", services: "#d500f9",
+ }[type] || "var(--dm)");
+
+ return (
+
+
+
+
Inter-VLAN Policy Matrix
+
+
+ Click a cell to set the policy between two VLANs. Policies generate both switch ACLs
+ and OPNsense firewall rules. LAN (VLAN 1) has full access by default.
+ Management VLAN 99 is always isolated (enforced by hard-block).
+
+
+ {nonMgmt.length > 1 ? (
+
+
+
+
+
+ From \ To
+
+ {nonMgmt.map(v => (
+
+ {v.name}V{v.id}
+
+ ))}
+
+
+
+ {nonMgmt.map(src => (
+
+
+ {src.name} V{src.id}
+
+ {nonMgmt.map(dst => {
+ if (src.id === dst.id) return (
+ —
+ );
+ const p = getPolicy(src.id, dst.id);
+ return (
+ {
+ setForm(f => ({...f, src_vlan: String(src.id), dst_vlan: String(dst.id)}));
+ setPreview(null); setResult(null);
+ }}>
+
+ {p ? (presets[p.type]?.label || p.type) : "No policy"}
+
+
+ );
+ })}
+
+ ))}
+
+
+
+ ) : (
+
+ Create at least 2 non-management VLANs to use the policy matrix.
+
+ )}
+
+ {/* Legend */}
+
+ {Object.entries(presets).map(([k,v]) => (
+
+
+ {v.label}
+
+ ))}
+
+
+
+
+ {/* Policy Editor */}
+
+
Set Policy
+
+
+
Source VLAN
+ { setForm(f => ({...f, src_vlan: e.target.value})); setPreview(null); }}>
+ Select...
+ {vlans.filter(v=>v.id!==99).map(v => {v.name} (V{v.id}) )}
+
+
+
Destination VLAN
+ { setForm(f => ({...f, dst_vlan: e.target.value})); setPreview(null); }}>
+ Select...
+ {vlans.filter(v=>v.id!==99).map(v => {v.name} (V{v.id}) )}
+
+
+
Policy Type
+ setForm(f => ({...f, type: e.target.value}))}>
+ {Object.entries(presets).map(([k,v]) => {v.label} )}
+
+
+ {(form.type === "services" || form.type === "printer") && (
+
Ports (comma-separated)
+ setForm(f => ({...f, ports: e.target.value}))}
+ placeholder={form.type === "printer" ? "9100,631,443,515" : "80,443,8080"}/>
+
+ )}
+
+
+ {form.type && presets[form.type] && (
+
+ {presets[form.type].description}
+
+ )}
+
+
+
+ Preview Commands
+
+
+ {pushing ? "Pushing..." : "Push Policy"}
+
+
+
+ {preview && !preview.error && (
+
+
+ {preview.description}
+
+
Switch ACL Commands:
+
+ {preview.switch_cmds?.join("\n")}
+
+ {preview.opnsense_rules?.length > 0 && <>
+
+ OPNsense Firewall Rules:
+
+ {preview.opnsense_rules.map((r,i) => (
+
+ {r.rule.action.toUpperCase()} {r.rule.descr}
+
+ ))}
+ >}
+
+ )}
+
+ {result && (
+
+
+ {result.success ? "Policy Pushed" : "Push Failed"}
+
+ {result.steps_done?.map((s,i) => (
+
done {s}
+ ))}
+ {result.errors?.map((e,i) => (
+
error {e}
+ ))}
+
+ )}
+
+
+
+ {/* Active Policies List */}
+ {policies.length > 0 && (
+
+
Active Policies ({policies.length})
+
+
+ Source Destination Type Pushed
+
+ {policies.map((p,i) => (
+
+ {vlans.find(v=>v.id===p.src_vlan)?.name || `V${p.src_vlan}`}
+ {vlans.find(v=>v.id===p.dst_vlan)?.name || `V${p.dst_vlan}`}
+ {presets[p.type]?.label || p.type}
+ {p.pushed_at || "not pushed"}
+
+ {
+ if (!session) { onNeedAuth(); return; }
+ await API("/firewall/policies", { method:"DELETE", body:{ token:session.token, src_vlan:p.src_vlan, dst_vlan:p.dst_vlan }});
+ load();
+ }}>Remove
+
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// SERVICES TAB — expose LAN services to other VLANs via reverse proxy + DNS
+// ══════════════════════════════════════════════════════════════════════════════
+
+function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
+ const [services, setServices] = useState([]);
+ const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" });
+ const [status, setStatus] = useState(null);
+ const [deploying, setDeploying] = useState(false);
+ const [deployResult, setDeployResult] = useState(null);
+ const [actionLoading, setActionLoading] = useState("");
+
+ const load = async () => {
+ try {
+ const [svc, st] = await Promise.all([API("/services"), API("/services/status")]);
+ setServices(svc.services || []);
+ setStatus(st);
+ } catch(e) { console.error(e); }
+ };
+ useEffect(() => { if (backendOk) load(); }, [backendOk]);
+
+ const addService = async () => {
+ if (!session) { onNeedAuth(); return; }
+ if (!form.fqdn || !form.backend_url) return;
+ try {
+ await API("/services", { method: "POST", body: { token: session.token, service: form } });
+ setForm({ fqdn: "", backend_url: "", description: "" });
+ await load();
+ } catch(e) { alert("Save failed: " + e.message); }
+ };
+
+ const removeService = async (fqdn) => {
+ if (!session) { onNeedAuth(); return; }
+ await API("/services", { method: "DELETE", body: { token: session.token, fqdn } });
+ await load();
+ };
+
+ const enableNatReflection = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setActionLoading("nat");
+ try {
+ const r = await API("/services/enable-nat-reflection", { method:"POST", body:{ token: session.token } });
+ if (r.success) await load();
+ else alert("NAT reflection enable may need manual verification");
+ } catch(e) { alert("Failed: " + e.message); }
+ setActionLoading("");
+ };
+
+ const createPortForward = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setActionLoading("pf");
+ try {
+ const r = await API("/services/create-port-forward", { method:"POST",
+ body:{ token: session.token, caddy_ip: status?.caddy_ip } });
+ if (r.note) alert(r.note);
+ await load();
+ } catch(e) { alert("Failed: " + e.message); }
+ setActionLoading("");
+ };
+
+ const deploy = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setDeploying(true); setDeployResult(null);
+ try {
+ const r = await API("/services/deploy", { method:"POST",
+ body:{ token: session.token, caddy_ip: status?.caddy_ip } });
+ setDeployResult(r);
+ await load();
+ } catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
+ setDeploying(false);
+ };
+
+ const Check = ({ok, label, action, actionLabel, loading}) => (
+
+
+ {label}
+ {ok === false && action && (
+
+ {loading ? "..." : actionLabel}
+
+ )}
+
+ );
+
+ return (
+
+
+ {/* Architecture explanation */}
+
+
Services — Caddy Reverse Proxy + NAT Reflection
+
+
+ Caddy on the LAN management computer is your reverse proxy.
+ Only port 443 is forwarded from WAN. Service ports are never exposed externally.
+
+
+
+ How isolated VLANs reach services (NAT reflection):
+
+
1. IoT TV (VLAN 30) asks DNS for plex.mydomain.com
+
2. DNS returns your public IP
+
3. OPNsense: "that's my WAN IP" → NAT reflection → routes internally
+
4. Port forward → Caddy (management computer) → reverse proxy to 192.168.1.x:port
+
5. Traffic never leaves your network. Full VLAN isolation.
+
+
+ IoT = untrusted = treated exactly like an external user. No pinholes. No cross-VLAN access.
+
+
+
+
+ {/* Status Checklist */}
+
+
Setup Checklist
+
+
+
+
+
+
+
+ 0}
+ label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} />
+
+
+
+ {/* Add Service Form */}
+
+
+ {/* Service List + Deploy */}
+ {services.length > 0 && (
+
+
Configured Services ({services.length})
+
+
+ FQDN Backend Description
+
+ {services.map((s,i) => (
+
+ {s.fqdn}
+ {s.backend_url}
+ {s.description || "—"}
+ removeService(s.fqdn)}>Remove
+
+ ))}
+
+
+
+
+
+ {deploying ? "Deploying..." : "Deploy"}
+
+
+ Writes Caddyfile.services, reloads Caddy, verifies NAT reflection + port forward
+
+
+
+ {deployResult && (
+
+
+ {deployResult.success ? "Deploy Complete" : "Deploy Had Errors"}
+
+ {deployResult.steps_done?.map((s,i) => (
+
done {s}
+ ))}
+ {deployResult.errors?.map((e,i) => (
+
error {e}
+ ))}
+ {deployResult.pending_steps?.map((s,i) => (
+
todo {s}
+ ))}
+ {deployResult.caddy_content && (
+
+ View Caddyfile.services
+ {deployResult.caddy_content}
+
+ )}
+
+ )}
+
+
+ )}
+
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// ALERTS TAB — ntfy configuration + scheduled operations
+// ══════════════════════════════════════════════════════════════════════════════
+
+function AlertsTab({ session, onNeedAuth, backendOk }) {
+ const [ntfyCfg, setNtfyCfg] = useState({ url: "https://ntfy.sh", topic: "", enabled: false, events: {} });
+ const [ntfyToken, setNtfyToken] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [schedules, setSchedules] = useState([]);
+ const [schedForm, setSchedForm] = useState({
+ name: "", action: "backup", device: "both", hour: "3", minute: "0", days: "*", enabled: true,
+ });
+
+ const loadNtfy = async () => {
+ try { setNtfyCfg(await API("/alerts/config")); } catch(e) { console.error(e); }
+ };
+ const loadSchedules = async () => {
+ try {
+ const d = await API("/schedules");
+ setSchedules(d.schedules || []);
+ } catch(e) { console.error(e); }
+ };
+ useEffect(() => { if (backendOk) { loadNtfy(); loadSchedules(); } }, [backendOk]);
+
+ const saveNtfy = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setSaving(true);
+ try {
+ await API("/alerts/config", { method: "POST", body: {
+ token: session.token, url: ntfyCfg.url, topic: ntfyCfg.topic,
+ ntfy_token: ntfyToken, enabled: ntfyCfg.enabled, events: ntfyCfg.events,
+ }});
+ await loadNtfy();
+ } catch(e) { alert("Save failed: " + e.message); }
+ setSaving(false);
+ };
+
+ const testNtfy = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setTesting(true);
+ try {
+ await API("/alerts/test", { method: "POST", body: { token: session.token } });
+ alert("Test notification sent! Check your ntfy app/topic.");
+ } catch(e) { alert("Test failed: " + e.message); }
+ setTesting(false);
+ };
+
+ const addSchedule = async () => {
+ if (!session) { onNeedAuth(); return; }
+ if (!schedForm.name) return;
+ try {
+ await API("/schedules", { method: "POST", body: { token: session.token, schedule: schedForm } });
+ setSchedForm(f => ({...f, name: ""}));
+ await loadSchedules();
+ } catch(e) { alert("Save failed: " + e.message); }
+ };
+
+ const deleteSchedule = async (name) => {
+ if (!session) { onNeedAuth(); return; }
+ await API("/schedules", { method: "DELETE", body: { token: session.token, name } });
+ await loadSchedules();
+ };
+
+ const runNow = async (name) => {
+ if (!session) { onNeedAuth(); return; }
+ try {
+ await API("/schedules/run-now", { method: "POST", body: { token: session.token, name } });
+ alert(`Schedule "${name}" triggered.`);
+ } catch(e) { alert("Run failed: " + e.message); }
+ };
+
+ const toggleEvent = (key) => {
+ setNtfyCfg(c => ({ ...c, events: { ...c.events, [key]: !c.events[key] } }));
+ };
+
+ const eventLabels = {
+ connectivity_lost: "Switch goes offline / comes back",
+ backup_failed: "Scheduled backup fails",
+ push_failed: "Config push fails",
+ poe_budget_warning: "PoE budget exceeds 85%",
+ port_down: "Port goes down (high volume)",
+ };
+
+ return (
+
+
+ {/* ntfy Configuration */}
+
+
Push Notifications (ntfy)
+
+
+ Get push notifications on your phone/desktop when network events occur.
+ Works with ntfy.sh (free, no account needed)
+ or a self-hosted ntfy server.
+
+
+
+
+
+
Alert Events
+
+ {Object.entries(eventLabels).map(([k,label]) => (
+
+ toggleEvent(k)}/>
+ {label}
+
+ ))}
+
+
+
+
+
+ setNtfyCfg(c => ({...c, enabled: e.target.checked}))}/>
+ Enable notifications
+
+
+
+
+
+ {saving ? "Saving..." : "Save Configuration"}
+
+
+ {testing ? "Sending..." : "Send Test"}
+
+
+
+
+
+ {/* VLAN Schedules */}
+
+
VLAN Schedules — Time-Based Access Control
+
+
+ Schedule VLANs to enable/disable internet access at specific times.
+ Example: Guest WiFi off midnight–6am, Business VLAN off after hours.
+ This works by adding/removing OPNsense firewall allow-outbound rules on schedule.
+ Switch ports stay up — devices just lose internet, so they reconnect automatically when re-enabled.
+
+
+
+
+
+
+ {/* General Scheduled Operations */}
+
+
General Scheduled Operations
+
+
+ Schedule recurring tasks like automatic backups or connectivity checks.
+
+
+
+
Name
+ setSchedForm(f => ({...f, name: e.target.value}))}
+ placeholder="nightly-backup"/>
+
+
Action
+ setSchedForm(f => ({...f, action: e.target.value}))}>
+ Backup
+ Connectivity Check
+
+
+ {schedForm.action === "backup" && (
+
Device
+ setSchedForm(f => ({...f, device: e.target.value}))}>
+ Both
+ Switch only
+ OPNsense only
+
+
+ )}
+
Hour
+ setSchedForm(f => ({...f, hour: e.target.value}))}
+ placeholder="3" style={{textAlign:"center"}}/>
+
+
Minute
+ setSchedForm(f => ({...f, minute: e.target.value}))}
+ placeholder="0" style={{textAlign:"center"}}/>
+
+
Days (* = every day)
+ setSchedForm(f => ({...f, days: e.target.value}))}
+ placeholder="mon,wed,fri or *"/>
+
+
+
Add Schedule
+
+ {schedules.length > 0 && (
+
+
+ Name Action Time Days Status
+
+ {schedules.map((s,i) => (
+
+ {s.name}
+ {s.action}{s.device ? ` (${s.device})` : ""}
+ {s.hour || "*"}:{(s.minute || "0").padStart(2,"0")}
+ {s.days || "*"}
+
+ {s.enabled!==false?"active":"disabled"}
+
+ runNow(s.name)}>Run Now
+ deleteSchedule(s.name)}>Delete
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+
+// ── VLAN Schedule Wizard ────────────────────────────────────────────────────
+
+function VlanScheduleWizard({ vlans, session, onNeedAuth, onSaved }) {
+ const [vlanId, setVlanId] = useState("");
+ const [offHour, setOffHour] = useState("0");
+ const [offMin, setOffMin] = useState("0");
+ const [onHour, setOnHour] = useState("6");
+ const [onMin, setOnMin] = useState("0");
+ const [days, setDays] = useState("*");
+ const [saving, setSaving] = useState(false);
+
+ const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1);
+ const vlanName = vlans.find(v => v.id === parseInt(vlanId))?.name || "";
+
+ const createPair = async () => {
+ if (!session) { onNeedAuth(); return; }
+ if (!vlanId) return;
+ setSaving(true);
+ const vid = parseInt(vlanId);
+ const vname = vlanName || `VLAN ${vid}`;
+ try {
+ await API("/schedules", { method: "POST", body: {
+ token: session.token,
+ schedule: {
+ name: `${vname}-off`, action: "vlan_disable",
+ vlan_id: vid, vlan_name: vname,
+ hour: offHour, minute: offMin, days, enabled: true,
+ }
+ }});
+ await API("/schedules", { method: "POST", body: {
+ token: session.token,
+ schedule: {
+ name: `${vname}-on`, action: "vlan_enable",
+ vlan_id: vid, vlan_name: vname,
+ hour: onHour, minute: onMin, days, enabled: true,
+ }
+ }});
+ if (onSaved) onSaved();
+ } catch(e) { alert("Failed: " + e.message); }
+ setSaving(false);
+ };
+
+ const presets = [
+ { label: "Guest WiFi: off midnight-6am", off: "0:00", on: "6:00", days: "*" },
+ { label: "Business: off 6pm-8am weekdays", off: "18:00", on: "8:00", days: "mon,tue,wed,thu,fri" },
+ { label: "Kids: off 9pm-7am", off: "21:00", on: "7:00", days: "*" },
+ { label: "IoT: off 11pm-5am", off: "23:00", on: "5:00", days: "*" },
+ ];
+
+ const applyPreset = (p) => {
+ const [oh, om] = p.off.split(":");
+ const [nh, nm] = p.on.split(":");
+ setOffHour(oh); setOffMin(om); setOnHour(nh); setOnMin(nm); setDays(p.days);
+ };
+
+ return (
+
+
+
Quick Presets
+
+ {presets.map((p,i) => (
+ applyPreset(p)}>{p.label}
+ ))}
+
+
+
+
VLAN
+ setVlanId(e.target.value)}>
+ Select...
+ {nonMgmt.map(v => {v.name} (V{v.id}) )}
+
+
+
+
+
Days
+ setDays(e.target.value)} placeholder="* or mon,tue,wed"/>
+
+
+
+ {saving ? "Creating..." : "Create Schedule"}
+
+
+
+ {vlanId && (
+
+ {vlanName || `VLAN ${vlanId}`}:
+ Internet disabled at {offHour}:{(offMin||"0").padStart(2,"0")},
+ re-enabled at {onHour}:{(onMin||"0").padStart(2,"0")}
+ {days === "*" ? " every day" : ` on ${days}`}.
+ Switch ports stay up — devices just lose internet access.
+
+ )}
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// PORT FORWARD TAB
+// ══════════════════════════════════════════════════════════════════════════════
+
+function PortForwardTab({ session, onNeedAuth, backendOk }) {
+ const [forwards, setForwards] = useState([]);
+ const [form, setForm] = useState({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" });
+ const [creating, setCreating] = useState(false);
+
+ const load = async () => {
+ try { setForwards((await API("/port-forwards")).forwards || []); } catch(e) { console.error(e); }
+ };
+ useEffect(() => { if (backendOk) load(); }, [backendOk]);
+
+ const create = async () => {
+ if (!session) { onNeedAuth(); return; }
+ setCreating(true);
+ try {
+ const r = await API("/port-forwards", { method:"POST", body:{
+ token: session.token, forward: {...form, target_port: form.target_port || form.wan_port }
+ }});
+ if (r.note) alert(r.note);
+ setForm({ proto:"tcp", wan_port:"", target_ip:"", target_port:"", description:"" });
+ await load();
+ } catch(e) { alert("Failed: " + e.message); }
+ setCreating(false);
+ };
+
+ const remove = async (uuid) => {
+ if (!session) { onNeedAuth(); return; }
+ await API("/port-forwards", { method:"DELETE", body:{ token:session.token, uuid }});
+ await load();
+ };
+
+ return (
+
+
+
+
Port Forwarding — OPNsense NAT
+
+ Forward WAN ports to internal servers. For services behind Caddy (reverse proxy),
+ you only need port 443 forwarded — Caddy handles routing by hostname.
+ Use this for non-HTTP services (game servers, SSH, mail, etc.).
+
+
+
+
+ {creating ? "Creating..." : "Create Port Forward"}
+
+
+
+ {forwards.length > 0 && (
+
+
Active Port Forwards ({forwards.length})
+
+
+ Proto WAN Port Target Description Created
+
+ {forwards.map((f,i) => (
+
+ {f.proto}
+ {f.wan_port}
+ {f.target_ip}:{f.target_port}
+ {f.description || "—"}
+ {f.created_at || "—"}
+ remove(f.uuid)}>Remove
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// POE BUDGET TAB
+// ══════════════════════════════════════════════════════════════════════════════
+
+function PoETab({ backendOk }) {
+ const [poe, setPoe] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const load = async () => {
+ setLoading(true);
+ try { setPoe(await API("/poe/budget")); } catch(e) { console.error(e); }
+ setLoading(false);
+ };
+ useEffect(() => { if (backendOk) load(); }, [backendOk]);
+
+ if (!poe || !poe.available) return (
+
+
PoE Budget
+
+ {loading ? "Loading..." : "No PoE data available — switch may be offline"}
+
+
+
+ );
+
+ const pct = poe.percent_used || 0;
+ const barColor = pct > 90 ? "#ff1744" : pct > 75 ? "#ff6d00" : "#00e676";
+
+ return (
+
+
+
+
PoE Power Budget
+
+ {/* Budget bar */}
+
+
+ Used: {poe.used_watts || "?"}W
+ Available: {poe.total_watts || "?"}W
+ Remaining: {poe.remaining_watts || "?"}W
+
+
+
+
+ {pct.toFixed(1)}%
+
+
+ {pct > 85 && (
+
+ Warning: PoE budget above 85%. New PoE devices may not power up.
+
+ )}
+
+
+ {/* Per-port table */}
+ {poe.ports?.length > 0 && (
+ <>
+
Per-Port Power Draw
+
+ {poe.ports.map(p => (
+
0 ? barColor + "15" : "var(--bg)",
+ border: `1px solid ${p.watts > 0 ? barColor + "30" : "var(--b2)"}`,
+ }}>
+
Port {p.port}
+
0 ? barColor : "var(--dm)"}}>{p.watts}W
+
{p.status}
+
+ ))}
+
+ >
+ )}
+
+
+ {loading ? "Loading..." : "Refresh"}
+
+
+
+
+
+ );
+}
+
+
+// ══════════════════════════════════════════════════════════════════════════════
+// TOPOLOGY TAB — network diagram
+// ══════════════════════════════════════════════════════════════════════════════
+
+function TopologyTab({ vlans, ports, backendOk }) {
+ const [topo, setTopo] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const load = async () => {
+ setLoading(true);
+ try { setTopo(await API("/topology")); } catch(e) { console.error(e); }
+ setLoading(false);
+ };
+ useEffect(() => { if (backendOk) load(); }, [backendOk]);
+
+ const upPorts = (topo?.ports || []).filter(p => p.link === "up");
+ const downPorts = (topo?.ports || []).filter(p => p.link === "down");
+
+ return (
+
+
+
+
Network Topology
+
+ {/* Router */}
+
+
+
OPNsense
+
{topo?.router?.ip || "not configured"}
+ {topo?.router?.version &&
v{topo.router.version}
}
+
+ {topo?.router?.connected ? "Online" : "Offline"}
+
+
+
+
+ {/* Trunk link */}
+
+
+
Trunk (all VLANs tagged)
+
+
+
+ {/* Switch */}
+
+
+
{topo?.switch?.hostname || "ERS-5952"}
+
{topo?.switch?.ip}
+
+ {topo?.switch?.connected ? "Online" : "Offline"}
+
+
+ {upPorts.length} ports up, {downPorts.length} down
+
+
+
+
+ {/* VLANs fan out */}
+
+ {vlans.map(v => {
+ const vlanPorts = ports.filter(p =>
+ (p.mode === "access" && p.accessVlan === v.id) ||
+ (p.mode === "trunk" && p.taggedVlans?.includes(v.id))
+ );
+ const upCount = vlanPorts.filter(p => {
+ const tp = (topo?.ports || []).find(tp => tp.id === p.id);
+ return tp?.link === "up";
+ }).length;
+ const devices = (topo?.devices || []).filter(d => d.vlan === v.id);
+ return (
+
+
VLAN {v.id}
+
{v.name}
+
+ {vlanPorts.length} ports ({upCount} up)
+
+
+ {devices.length} registered device{devices.length !== 1 ? "s" : ""}
+
+
+ 192.168.{v.id}.0/24
+
+
+ );
+ })}
+
+
+
+ {loading ? "Loading..." : "Refresh"}
+
+
+
+
+
+ );
+}
diff --git a/switch_backend.py b/switch_backend.py
index d1e9506..e708eee 100644
--- a/switch_backend.py
+++ b/switch_backend.py
@@ -510,6 +510,12 @@ def _poll_loop():
_cache["poll_error"] = str(e)
log.warning(f"Poll error: {e}")
+ # Check alert conditions after each poll
+ try:
+ _check_and_alert()
+ except Exception:
+ pass # alerts are best-effort, never crash the poller
+
interval = POLL_ACTIVE_S if mode == "active" else POLL_BG_S
time.sleep(interval)
@@ -3028,6 +3034,8 @@ class OPNWGAddPeer(BaseModel):
name: str
allowed_vlans: list # list of VLAN IDs: [10, 20, 30]
vlan_subnets: dict # {10: "192.168.10.0/24", 20: "192.168.20.0/24", ...}
+ dns_profile: Optional[str] = "" # ControlD profile name to apply (matches a ctrld upstream)
+ dns_server: Optional[str] = "" # Override DNS server IP in client config (default: OPNsense VLAN 99 IP)
@app.get("/api/opnsense/wireguard/status")
@@ -3296,17 +3304,31 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
except Exception:
pass
+ # ── Determine DNS server for client config ─────────────────────
+ # Use OPNsense's VLAN 99 gateway IP so DNS goes through:
+ # Unbound (:53) → ctrld (127.0.0.1:5354) → ControlD
+ # This applies the correct ControlD profile based on source IP.
+ if body.dns_server:
+ dns_ip = body.dns_server
+ else:
+ # Use OPNsense's host IP (typically its VLAN 99 gateway)
+ dns_ip = opn_cfg.get("host", "")
+ if not dns_ip:
+ # Fallback to tunnel gateway
+ dns_ip = wg.get("server_tunnel_ip", "").split("/")[0]
+
# ── Build client .conf ────────────────────────────────────────────
server_pubkey = wg.get("server_pubkey", "")
endpoint_host = wg.get("public_endpoint", "") or ""
endpoint_port = wg.get("listen_port", 51820)
- tunnel_gw = wg.get("server_tunnel_ip", "").split("/")[0]
client_conf = (
f"[Interface]\n"
f"PrivateKey = {c_priv}\n"
f"Address = {peer_ip}\n"
- f"DNS = {tunnel_gw}\n\n"
+ f"DNS = {dns_ip}\n"
+ f"# DNS goes to OPNsense → Unbound → ctrld → ControlD\n"
+ f"# ControlD profile applied by source IP (WireGuard tunnel subnet)\n\n"
f"[Peer]\n"
f"PublicKey = {server_pubkey or ''}\n"
f"Endpoint = {endpoint_host}:{endpoint_port}\n"
@@ -3314,6 +3336,22 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
f"PersistentKeepalive = 25\n"
)
+ # ── Add ctrld network rule for WireGuard tunnel subnet ───────────
+ # So ctrld can route DNS queries from VPN clients to the right
+ # ControlD profile (e.g. "house" profile for VLAN 99 users)
+ ctrld_note = ""
+ if body.dns_profile:
+ tunnel_subnet = wg.get("tunnel_subnet", "10.99.2.0/24")
+ ctrld_note = (
+ f"Add this to your ctrld.toml (proxy mode) or configure via DNS tab:\n"
+ f" [network.wg]\n"
+ f" name = 'WireGuard VPN'\n"
+ f" cidrs = ['{tunnel_subnet}']\n\n"
+ f" Then map network.wg to the '{body.dns_profile}' upstream in "
+ f"listener.0.policy.networks.\n"
+ f" This applies the '{body.dns_profile}' ControlD profile to all VPN clients."
+ )
+
# ── Persist peer metadata locally ────────────────────────────────
peer_meta = {
"uuid": peer_uuid,
@@ -3323,6 +3361,8 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
"allowed_ips": allowed_ips,
+ "dns_server": dns_ip,
+ "dns_profile": body.dns_profile,
"config": client_conf,
}
peers = [p for p in wg.get("peers", []) if p.get("name") != body.name]
@@ -3330,14 +3370,17 @@ def opnsense_wg_add_peer(body: OPNWGAddPeer):
wg["peers"] = peers
_save_opnsense_wg(wg)
- log.info(f"OPNsense WG peer added: {body.name} → {peer_ip} VLANs={body.allowed_vlans}")
+ log.info(f"OPNsense WG peer added: {body.name} → {peer_ip} VLANs={body.allowed_vlans} DNS={dns_ip}")
return {
"success": True,
"uuid": peer_uuid,
"name": body.name,
"tunnel_ip": peer_ip,
"allowed_vlans": body.allowed_vlans,
+ "dns_server": dns_ip,
+ "dns_profile": body.dns_profile,
"config": client_conf,
+ "ctrld_note": ctrld_note,
}
@@ -3615,6 +3658,1875 @@ def opnsense_unbound_write_forward_ctrld(body: dict):
return {"success": True, "content": content, "enabled": enabled, "port": port}
+# ══════════════════════════════════════════════════════════════════════
+# BACKUP / RESTORE — both OPNsense and switch
+# ══════════════════════════════════════════════════════════════════════
+
+import datetime as _dt
+import shutil as _shutil
+
+BACKUP_DIR = _Path("/etc/switch-manager/backups")
+BACKUP_DIR.mkdir(parents=True, exist_ok=True)
+MAX_BACKUPS = 50 # keep last N backups per device
+
+def _ts() -> str:
+ return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
+
+def _prune_backups(subdir: _Path):
+ """Keep only the last MAX_BACKUPS files in a backup subdirectory."""
+ files = sorted(subdir.glob("*"), key=lambda f: f.stat().st_mtime)
+ while len(files) > MAX_BACKUPS:
+ files.pop(0).unlink()
+
+# ── OPNsense backup (XML config export) ─────────────────────────────
+
+def _opnsense_backup(cfg: dict, reason: str = "") -> dict:
+ """Download OPNsense config.xml via API and save locally."""
+ host = cfg.get("host", "")
+ key = cfg.get("key", "")
+ secret = cfg.get("secret", "")
+ if not host or not key:
+ return {"ok": False, "error": "OPNsense not configured"}
+ bdir = BACKUP_DIR / "opnsense"
+ bdir.mkdir(parents=True, exist_ok=True)
+ ts = _ts()
+ fname = f"opnsense-{ts}.xml"
+ fpath = bdir / fname
+ try:
+ creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()
+ ctx = _ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = _ssl.CERT_NONE
+ url = f"https://{host}/api/core/backup/download/this"
+ req = _urlreq.Request(url, headers={
+ "Authorization": f"Basic {creds}",
+ }, method="POST")
+ with _urlreq.urlopen(req, timeout=30, context=ctx) as r:
+ xml_data = r.read()
+ fpath.write_bytes(xml_data)
+ fpath.chmod(0o600)
+ # Write metadata
+ meta = {"timestamp": ts, "reason": reason, "file": fname,
+ "size": len(xml_data), "host": host}
+ (bdir / f"opnsense-{ts}.meta.json").write_text(_json.dumps(meta, indent=2))
+ _prune_backups(bdir)
+ log.info(f"OPNsense backup saved: {fname} ({len(xml_data)} bytes) reason={reason}")
+ return {"ok": True, "file": fname, "size": len(xml_data), "timestamp": ts}
+ except Exception as e:
+ log.warning(f"OPNsense backup failed: {e}")
+ return {"ok": False, "error": str(e)}
+
+
+def _opnsense_restore(cfg: dict, filename: str) -> dict:
+ """Upload a config.xml backup to OPNsense."""
+ bdir = BACKUP_DIR / "opnsense"
+ fpath = bdir / filename
+ if not fpath.exists():
+ return {"ok": False, "error": f"Backup file not found: {filename}"}
+ # Sanity: must be XML
+ content = fpath.read_bytes()
+ if b"" not in content and b"" not in content:
+ return {"ok": False, "error": "File does not look like an OPNsense config"}
+ host = cfg.get("host", "")
+ key = cfg.get("key", "")
+ secret = cfg.get("secret", "")
+ if not host or not key:
+ return {"ok": False, "error": "OPNsense not configured"}
+ try:
+ creds = _b64.b64encode(f"{key}:{secret}".encode()).decode()
+ ctx = _ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = _ssl.CERT_NONE
+ # OPNsense restore API expects multipart form upload
+ import mimetypes
+ boundary = f"----BackupRestore{_ts()}"
+ body = (
+ f"--{boundary}\r\n"
+ f'Content-Disposition: form-data; name="conf"; filename="{filename}"\r\n'
+ f"Content-Type: application/xml\r\n\r\n"
+ ).encode() + content + f"\r\n--{boundary}--\r\n".encode()
+ url = f"https://{host}/api/core/backup/restore"
+ req = _urlreq.Request(url, data=body, headers={
+ "Authorization": f"Basic {creds}",
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
+ }, method="POST")
+ with _urlreq.urlopen(req, timeout=60, context=ctx) as r:
+ result = _json.loads(r.read().decode())
+ log.info(f"OPNsense restore from {filename}: {result}")
+ return {"ok": True, "result": result, "file": filename}
+ except Exception as e:
+ log.warning(f"OPNsense restore failed: {e}")
+ return {"ok": False, "error": str(e)}
+
+
+# ── Switch backup (running-config capture) ───────────────────────────
+
+def _switch_backup(reason: str = "") -> dict:
+ """Capture switch running-config via SSH and save locally."""
+ bdir = BACKUP_DIR / "switch"
+ bdir.mkdir(parents=True, exist_ok=True)
+ ts = _ts()
+ fname = f"switch-{ts}.cfg"
+ fpath = bdir / fname
+ try:
+ raw = read_cmd("show running-config")
+ if not raw or len(raw) < 50:
+ return {"ok": False, "error": "Empty or too-short running-config output"}
+ fpath.write_text(raw)
+ fpath.chmod(0o600)
+ meta = {"timestamp": ts, "reason": reason, "file": fname, "size": len(raw)}
+ (bdir / f"switch-{ts}.meta.json").write_text(_json.dumps(meta, indent=2))
+ _prune_backups(bdir)
+ log.info(f"Switch backup saved: {fname} ({len(raw)} bytes) reason={reason}")
+ return {"ok": True, "file": fname, "size": len(raw), "timestamp": ts}
+ except Exception as e:
+ log.warning(f"Switch backup failed: {e}")
+ return {"ok": False, "error": str(e)}
+
+
+# ── Pre-change backup (called automatically before any push) ─────────
+
+def _pre_change_backup(reason: str) -> dict:
+ """Backup both devices before making changes. Returns status for each."""
+ results = {"switch": None, "opnsense": None}
+ # Always backup switch
+ results["switch"] = _switch_backup(reason=reason)
+ # Backup OPNsense if configured
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key"):
+ results["opnsense"] = _opnsense_backup(cfg, reason=reason)
+ return results
+
+
+# ── Connectivity safety check ────────────────────────────────────────
+
+def _check_connectivity() -> dict:
+ """Verify SSH reachability to switch and OPNsense. Non-destructive probe."""
+ result = {"switch": {"ok": False, "error": ""}, "opnsense": {"ok": False, "error": "", "configured": False}}
+
+ # Check switch
+ try:
+ conn = _pool.get()
+ transport = conn.get_transport()
+ if transport and transport.is_active():
+ transport.send_ignore()
+ result["switch"]["ok"] = True
+ else:
+ result["switch"]["error"] = "SSH transport not active"
+ except Exception as e:
+ result["switch"]["error"] = str(e)
+
+ # Check OPNsense (if configured)
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key"):
+ result["opnsense"]["configured"] = True
+ try:
+ _opnsense_request(cfg, "core/firmware/status")
+ result["opnsense"]["ok"] = True
+ except Exception as e:
+ result["opnsense"]["error"] = str(e)
+ if cfg.get("ssh_key_path"):
+ try:
+ test = _opnsense_ssh_test(cfg)
+ result["opnsense"]["ssh_ok"] = test.get("ok", False)
+ except Exception:
+ result["opnsense"]["ssh_ok"] = False
+
+ return result
+
+
+# ── API endpoints ────────────────────────────────────────────────────
+
+@app.get("/api/backup/list")
+def backup_list():
+ """List all available backups for both devices."""
+ backups = {"switch": [], "opnsense": []}
+ for device in ["switch", "opnsense"]:
+ bdir = BACKUP_DIR / device
+ if not bdir.exists():
+ continue
+ for meta_file in sorted(bdir.glob("*.meta.json"), reverse=True):
+ try:
+ meta = _json.loads(meta_file.read_text())
+ meta["exists"] = (bdir / meta["file"]).exists()
+ backups[device].append(meta)
+ except Exception:
+ continue
+ return backups
+
+
+@app.post("/api/backup/create")
+def backup_create(body: dict):
+ """Manually trigger a backup of one or both devices."""
+ require_session(body.get("token", ""))
+ device = body.get("device", "both") # "switch", "opnsense", or "both"
+ reason = body.get("reason", "manual backup")
+ results = {}
+ if device in ("switch", "both"):
+ results["switch"] = _switch_backup(reason=reason)
+ if device in ("opnsense", "both"):
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key"):
+ results["opnsense"] = _opnsense_backup(cfg, reason=reason)
+ else:
+ results["opnsense"] = {"ok": False, "error": "OPNsense not configured"}
+ return results
+
+
+@app.post("/api/backup/restore")
+def backup_restore(body: dict):
+ """Restore a backup to a device. Creates a new backup first as safety net."""
+ require_session(body.get("token", ""))
+ device = body.get("device", "")
+ filename = body.get("filename", "")
+ if not device or not filename:
+ raise HTTPException(400, "device and filename required")
+ if device not in ("switch", "opnsense"):
+ raise HTTPException(400, "device must be 'switch' or 'opnsense'")
+
+ # Safety: backup current state first
+ safety = _pre_change_backup(reason=f"pre-restore safety backup before restoring {filename}")
+
+ if device == "opnsense":
+ cfg = _load_opnsense_cfg()
+ result = _opnsense_restore(cfg, filename)
+ return {"result": result, "safety_backup": safety}
+ elif device == "switch":
+ # Switch restore = parse config and push commands
+ bdir = BACKUP_DIR / "switch"
+ fpath = bdir / filename
+ if not fpath.exists():
+ raise HTTPException(404, f"Backup file not found: {filename}")
+ return {
+ "result": {"ok": True, "note": "Switch config restore requires manual review. "
+ "Download the backup file and apply commands via Review & Push tab."},
+ "safety_backup": safety,
+ "config_preview": fpath.read_text()[:5000],
+ }
+
+
+@app.get("/api/backup/download/{device}/{filename}")
+def backup_download(device: str, filename: str):
+ """Download a backup file."""
+ if device not in ("switch", "opnsense"):
+ raise HTTPException(400, "device must be 'switch' or 'opnsense'")
+ # Prevent path traversal
+ if "/" in filename or ".." in filename:
+ raise HTTPException(400, "Invalid filename")
+ fpath = BACKUP_DIR / device / filename
+ if not fpath.exists():
+ raise HTTPException(404, "Backup not found")
+ from fastapi.responses import FileResponse
+ return FileResponse(fpath, filename=filename)
+
+
+@app.get("/api/connectivity/check")
+def connectivity_check():
+ """Check SSH/API reachability to both switch and OPNsense."""
+ return _check_connectivity()
+
+
+# ══════════════════════════════════════════════════════════════════════
+# UNIFIED NETWORK PROVISIONING — one operation for both devices
+# ══════════════════════════════════════════════════════════════════════
+
+class UnifiedVlanProvision(BaseModel):
+ token: str
+ vlan_id: int
+ name: str
+ subnet: str # e.g. "192.168.60.0/24"
+ gateway: str # e.g. "192.168.60.1"
+ dhcp_start: str # e.g. "192.168.60.100"
+ dhcp_end: str # e.g. "192.168.60.200"
+ parent_if: str # OPNsense physical parent, e.g. "igb0"
+ opnsense_if: Optional[str] = ""
+ allow_internet: bool = True
+ # Port assignments
+ ports: list[dict] = [] # [{"port": 1, "poe": true}, {"port": 5, "poe": false}]
+ # Trunk uplink ports (these get the new VLAN tagged)
+ trunk_ports: list[int] = []
+
+ @field_validator("vlan_id")
+ @classmethod
+ def cv(cls, v):
+ vid = san_vid(v)
+ if vid in (1, 99):
+ raise ValueError("VLAN 1 and 99 are reserved")
+ return vid
+ @field_validator("name")
+ @classmethod
+ def cn(cls, v): return _san(v, _RE_VNAME, "name")
+
+
+@app.post("/api/network/provision")
+def unified_provision(body: UnifiedVlanProvision):
+ """
+ Unified VLAN + port + OPNsense provisioning in one operation.
+
+ 1. Pre-change backup of both devices
+ 2. Connectivity check
+ 3. Create VLAN on switch + assign ports + set PoE
+ 4. Create VLAN on OPNsense + DHCP scope + firewall rule
+ 5. Post-change connectivity verify
+ """
+ import ipaddress as _ipaddr
+ require_session(body.token)
+
+ steps_done: list[str] = []
+ errors: list[str] = []
+ pending_steps: list[str] = []
+
+ # Validate addresses
+ try:
+ net = _ipaddr.ip_network(body.subnet, strict=False)
+ _ipaddr.ip_address(body.gateway)
+ _ipaddr.ip_address(body.dhcp_start)
+ _ipaddr.ip_address(body.dhcp_end)
+ except ValueError as e:
+ raise HTTPException(400, f"Invalid address: {e}")
+
+ # ── Step 0: Pre-change connectivity check ────────────────────────
+ conn = _check_connectivity()
+ if not conn["switch"]["ok"]:
+ raise HTTPException(503, f"Switch unreachable — aborting: {conn['switch']['error']}")
+ steps_done.append("connectivity: switch reachable")
+
+ # ── Step 1: Pre-change backup ────────────────────────────────────
+ backup = _pre_change_backup(reason=f"pre-provision VLAN {body.vlan_id} '{body.name}'")
+ if backup["switch"] and backup["switch"].get("ok"):
+ steps_done.append(f"backup: switch config saved ({backup['switch']['file']})")
+ else:
+ errors.append(f"backup: switch backup failed — {backup['switch'].get('error', 'unknown')}")
+ # Non-fatal but warn
+ if backup.get("opnsense") and backup["opnsense"].get("ok"):
+ steps_done.append(f"backup: OPNsense config saved ({backup['opnsense']['file']})")
+
+ # ── Step 2: Create VLAN on switch ────────────────────────────────
+ switch_cmds = [f'vlan create {body.vlan_id} name "{body.name}" type port']
+
+ # Assign access ports
+ for pa in body.ports:
+ p = san_port(pa["port"])
+ vid = body.vlan_id
+ switch_cmds += [
+ f"vlan members add {vid} {p}",
+ f"vlan pvid {p} {vid}",
+ ]
+ iface = f"FastEthernet {p}" if p <= 48 else f"GigabitEthernet {p}"
+ if p <= 96:
+ switch_cmds.append(f"interface {iface}")
+ if pa.get("poe", True):
+ switch_cmds += [" poe enable", f" poe poe-limit {pa.get('poe_limit', 30000)}"]
+ else:
+ switch_cmds += [" no poe enable"]
+
+ # Add VLAN to trunk uplinks
+ for tp in body.trunk_ports:
+ tp = san_port(tp)
+ switch_cmds += [
+ f"vlan members add {body.vlan_id} {tp}",
+ f"vlan tagging {body.vlan_id} {tp}",
+ ]
+
+ # Danger check before push
+ danger = check_danger(switch_cmds)
+ if danger["has_hard_block"]:
+ raise HTTPException(400, {
+ "message": "Hard-blocked commands detected",
+ "blocked": danger["hard_blocked"],
+ })
+
+ result = push_one_by_one(switch_cmds)
+ if result.get("success"):
+ steps_done.append(f"switch: VLAN {body.vlan_id} created, {len(body.ports)} ports assigned, "
+ f"{len(body.trunk_ports)} trunk ports updated")
+ else:
+ errors.append(f"switch: push failed at command {result.get('stopped_at', '?')}: "
+ f"{result.get('error', 'unknown')}")
+ # Return early — don't configure OPNsense for a VLAN the switch doesn't have
+ return {
+ "success": False, "steps_done": steps_done, "errors": errors,
+ "pending_steps": [], "backup": backup, "switch_result": result,
+ }
+
+ # ── Step 3: OPNsense VLAN + DHCP + firewall ─────────────────────
+ cfg = _load_opnsense_cfg()
+ if not cfg.get("key"):
+ pending_steps += [
+ f"OPNsense: create VLAN tag {body.vlan_id} on {body.parent_if}",
+ f"OPNsense: assign interface, set IP {body.gateway}/{net.prefixlen}",
+ f"OPNsense: create DHCP scope {body.dhcp_start}–{body.dhcp_end}",
+ ]
+ if body.allow_internet:
+ pending_steps.append("OPNsense: add allow-outbound firewall rule")
+ else:
+ # Create VLAN tag
+ try:
+ vlan_r = _opnsense_request(cfg, "interfaces/vlan_settings/addItem", "POST", {
+ "vlan": {"if": body.parent_if, "tag": str(body.vlan_id),
+ "pcp": "0", "descr": body.name}
+ })
+ _opnsense_request(cfg, "interfaces/vlan_settings/reconfigure", "POST")
+ steps_done.append(f"OPNsense: VLAN tag {body.vlan_id} created on {body.parent_if}")
+ except ValueError as e:
+ errors.append(f"OPNsense VLAN tag: {e}")
+
+ if body.opnsense_if:
+ # DHCP scope
+ try:
+ _opnsense_request(cfg, "dhcpv4/settings/addSubnet", "POST", {
+ "subnet": {
+ "interface": body.opnsense_if,
+ "subnet": str(net),
+ "gateway": body.gateway,
+ "dns_servers": body.gateway,
+ "range": {"from": body.dhcp_start, "to": body.dhcp_end},
+ }
+ })
+ _opnsense_request(cfg, "dhcpv4/service/reconfigure", "POST")
+ steps_done.append(f"OPNsense: DHCP scope {body.dhcp_start}–{body.dhcp_end}")
+ except ValueError as e:
+ errors.append(f"OPNsense DHCP: {e}")
+
+ # Firewall rule
+ if body.allow_internet:
+ try:
+ _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "interface": body.opnsense_if, "direction": "in",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{body.opnsense_if}net"},
+ "destination": {"any": "1"},
+ "descr": f"Allow VLAN {body.vlan_id} {body.name} outbound",
+ }
+ })
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+ steps_done.append(f"OPNsense: allow-outbound firewall rule added")
+ except ValueError as e:
+ errors.append(f"OPNsense firewall: {e}")
+
+ # Persist mapping
+ vmap = _load_vlan_if_map()
+ vmap[str(body.vlan_id)] = body.opnsense_if
+ _save_vlan_if_map(vmap)
+ else:
+ pending_steps += [
+ f"OPNsense UI: assign {body.parent_if}.{body.vlan_id} as interface, "
+ f"set IP {body.gateway}/{net.prefixlen}",
+ f"Then re-run with opnsense_if set to create DHCP + firewall",
+ ]
+
+ # ── Step 4: Post-change connectivity verify ──────────────────────
+ post_conn = _check_connectivity()
+ if not post_conn["switch"]["ok"]:
+ errors.append("POST-CHANGE WARNING: switch SSH connectivity lost! "
+ f"Backup available: {backup['switch'].get('file', 'N/A')}")
+
+ return {
+ "success": len(errors) == 0,
+ "steps_done": steps_done,
+ "pending_steps": pending_steps,
+ "errors": errors,
+ "backup": backup,
+ "switch_result": result,
+ "post_connectivity": post_conn,
+ }
+
+
+# ── Wrap existing push to auto-backup ────────────────────────────────
+
+_original_push = push_one_by_one
+
+def push_one_by_one_with_backup(commands: list[str]) -> dict:
+ """Wraps push_one_by_one to create a backup before pushing."""
+ backup = _pre_change_backup(reason=f"pre-push ({len(commands)} commands)")
+ result = _original_push(commands)
+ result["backup"] = backup
+ return result
+
+# Monkey-patch: the push endpoint calls push_one_by_one directly
+# We leave push_one_by_one as-is (it's used internally) and
+# add the backup in the API endpoint wrapper below.
+
+@app.post("/api/switch/push-safe")
+def push_safe(body: PushBatch):
+ """Push with automatic pre-change backup and post-change connectivity check."""
+ require_session(body.token)
+
+ # Danger check
+ danger = check_danger(body.commands)
+ if danger["has_hard_block"]:
+ raise HTTPException(400, {
+ "message": "Hard-blocked commands — must be run at console",
+ "blocked": danger["hard_blocked"],
+ })
+
+ # Pre-check
+ conn = _check_connectivity()
+ if not conn["switch"]["ok"]:
+ raise HTTPException(503, "Switch unreachable — refusing to push")
+
+ # Backup
+ backup = _pre_change_backup(reason=f"pre-push ({len(body.commands)} commands)")
+
+ # Push
+ result = push_one_by_one(body.commands)
+
+ # Post-check
+ post_conn = _check_connectivity()
+ if not post_conn["switch"]["ok"]:
+ result["connectivity_warning"] = (
+ "Switch SSH lost after push! "
+ f"Backup: {backup['switch'].get('file', 'N/A')}"
+ )
+
+ result["backup"] = backup
+ result["post_connectivity"] = post_conn
+ return result
+
+
+# ══════════════════════════════════════════════════════════════════════
+# FIREWALL POLICY MATRIX — inter-VLAN access control
+# ══════════════════════════════════════════════════════════════════════
+
+POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json")
+
+# Policy types:
+# "block" — deny all traffic between VLANs
+# "allow" — permit all traffic between VLANs
+# "one-way" — src VLAN can reach dst VLAN, but not reverse
+# "services" — src can reach dst on specific ports only
+# "printer" — other VLANs can print (reach ports 9100,631,443), printer can't initiate
+
+POLICY_PRESETS = {
+ "block": {
+ "label": "Blocked",
+ "description": "No traffic allowed between these VLANs",
+ },
+ "allow": {
+ "label": "Full Access",
+ "description": "All traffic permitted between these VLANs",
+ },
+ "one-way": {
+ "label": "One-Way Access",
+ "description": "Source VLAN can reach destination, but not reverse",
+ },
+ "printer": {
+ "label": "Printer Access",
+ "description": "Other VLANs can reach printers (ports 9100/631/443/515), printers cannot initiate connections back",
+ "ports": [9100, 631, 443, 515],
+ },
+ "services": {
+ "label": "Service Ports Only",
+ "description": "Access limited to specified TCP/UDP ports",
+ },
+}
+
+
+def _load_policies() -> list:
+ if POLICIES_FILE.exists():
+ try: return _json.loads(POLICIES_FILE.read_text())
+ except: pass
+ return []
+
+
+def _save_policies(policies: list):
+ POLICIES_FILE.write_text(_json.dumps(policies, indent=2))
+ POLICIES_FILE.chmod(0o600)
+
+
+def _build_policy_acls(policy: dict) -> dict:
+ """
+ Generate switch ACL commands AND OPNsense firewall rule payloads for a policy.
+
+ Returns {switch_cmds: [...], opnsense_rules: [...], description: str}
+ """
+ ptype = policy.get("type", "block")
+ src_vid = policy.get("src_vlan")
+ dst_vid = policy.get("dst_vlan")
+ ports = policy.get("ports", [])
+ src_sub = f"192.168.{src_vid}.0"
+ dst_sub = f"192.168.{dst_vid}.0"
+ mask = "0.0.0.255"
+ acl_name = f"POLICY-V{src_vid}-V{dst_vid}"
+
+ switch_cmds = []
+ opnsense_rules = []
+
+ if ptype == "block":
+ switch_cmds = [
+ f"ip access-list extended {acl_name}",
+ f" 1 deny ip {src_sub} {mask} {dst_sub} {mask}",
+ f" 2 permit ip any any",
+ f"interface vlan {src_vid}",
+ f" ip access-group {acl_name} in",
+ ]
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "block",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{src_sub}/{24}"},
+ "destination": {"network": f"{dst_sub}/24"},
+ "descr": f"Block VLAN {src_vid} → VLAN {dst_vid}",
+ }
+ })
+
+ elif ptype == "allow":
+ switch_cmds = [
+ f"ip access-list extended {acl_name}",
+ f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}",
+ f" 2 permit ip any any",
+ f"interface vlan {src_vid}",
+ f" ip access-group {acl_name} in",
+ ]
+ # OPNsense: explicit allow (usually default, but good to be explicit)
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{src_sub}/24"},
+ "destination": {"network": f"{dst_sub}/24"},
+ "descr": f"Allow VLAN {src_vid} → VLAN {dst_vid}",
+ }
+ })
+
+ elif ptype == "one-way":
+ # Allow src→dst, block dst→src (reverse ACL on dst VLAN)
+ switch_cmds = [
+ f"ip access-list extended {acl_name}",
+ f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}",
+ f" 2 permit ip any any",
+ f"interface vlan {src_vid}",
+ f" ip access-group {acl_name} in",
+ f"ip access-list extended {acl_name}-REV",
+ f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}",
+ f" 2 permit ip any any",
+ f"interface vlan {dst_vid}",
+ f" ip access-group {acl_name}-REV in",
+ ]
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{src_sub}/24"},
+ "destination": {"network": f"{dst_sub}/24"},
+ "descr": f"Allow VLAN {src_vid} → VLAN {dst_vid} (one-way)",
+ }
+ })
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "block",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{dst_sub}/24"},
+ "destination": {"network": f"{src_sub}/24"},
+ "descr": f"Block VLAN {dst_vid} → VLAN {src_vid} (one-way reverse)",
+ }
+ })
+
+ elif ptype == "printer":
+ # Other VLANs can reach printer VLAN on print ports; printers can't initiate
+ printer_ports = ports or [9100, 631, 443, 515]
+ rule_num = 1
+ switch_cmds = [f"ip access-list extended {acl_name}"]
+ for port in printer_ports:
+ switch_cmds.append(
+ f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}")
+ rule_num += 1
+ switch_cmds += [
+ f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}",
+ f" {rule_num+1} permit ip any any",
+ f"interface vlan {src_vid}",
+ f" ip access-group {acl_name} in",
+ ]
+ # Reverse: block printers from initiating to src VLAN
+ switch_cmds += [
+ f"ip access-list extended {acl_name}-REV",
+ f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}",
+ f" 2 permit ip any any",
+ f"interface vlan {dst_vid}",
+ f" ip access-group {acl_name}-REV in",
+ ]
+ # OPNsense rules
+ for port in printer_ports:
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "ipprotocol": "inet", "protocol": "tcp",
+ "source": {"network": f"{src_sub}/24"},
+ "destination": {"network": f"{dst_sub}/24", "port": str(port)},
+ "descr": f"VLAN {src_vid} → printer VLAN {dst_vid} port {port}",
+ }
+ })
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "block",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{dst_sub}/24"},
+ "destination": {"network": f"{src_sub}/24"},
+ "descr": f"Block printer VLAN {dst_vid} → VLAN {src_vid}",
+ }
+ })
+
+ elif ptype == "services":
+ rule_num = 1
+ switch_cmds = [f"ip access-list extended {acl_name}"]
+ for port in ports:
+ switch_cmds.append(
+ f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}")
+ rule_num += 1
+ switch_cmds += [
+ f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}",
+ f" {rule_num+1} permit ip any any",
+ f"interface vlan {src_vid}",
+ f" ip access-group {acl_name} in",
+ ]
+ for port in ports:
+ opnsense_rules.append({
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "ipprotocol": "inet", "protocol": "tcp",
+ "source": {"network": f"{src_sub}/24"},
+ "destination": {"network": f"{dst_sub}/24", "port": str(port)},
+ "descr": f"VLAN {src_vid} → VLAN {dst_vid} port {port}",
+ }
+ })
+
+ return {
+ "switch_cmds": switch_cmds,
+ "opnsense_rules": opnsense_rules,
+ "acl_name": acl_name,
+ "description": f"{POLICY_PRESETS.get(ptype,{}).get('label','Custom')} — "
+ f"VLAN {src_vid} → VLAN {dst_vid}",
+ }
+
+
+@app.get("/api/firewall/policies")
+def get_policies():
+ """Return saved inter-VLAN policies and available presets."""
+ return {"policies": _load_policies(), "presets": POLICY_PRESETS}
+
+
+@app.post("/api/firewall/policies")
+def save_policy(body: dict):
+ """Save or update an inter-VLAN policy."""
+ require_session(body.get("token", ""))
+ policy = body.get("policy", {})
+ if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
+ raise HTTPException(400, "src_vlan, dst_vlan, and type required")
+
+ policies = _load_policies()
+ # Replace existing policy for this VLAN pair
+ policies = [p for p in policies
+ if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])]
+ policies.append(policy)
+ _save_policies(policies)
+ return {"success": True, "policies": policies}
+
+
+@app.delete("/api/firewall/policies")
+def delete_policy(body: dict):
+ """Remove an inter-VLAN policy."""
+ require_session(body.get("token", ""))
+ src = body.get("src_vlan")
+ dst = body.get("dst_vlan")
+ policies = _load_policies()
+ policies = [p for p in policies if not (p["src_vlan"] == src and p["dst_vlan"] == dst)]
+ _save_policies(policies)
+ return {"success": True, "policies": policies}
+
+
+@app.post("/api/firewall/preview")
+def preview_policy(body: dict):
+ """Preview generated ACLs/rules for a policy without pushing."""
+ policy = body.get("policy", {})
+ if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
+ raise HTTPException(400, "src_vlan, dst_vlan, and type required")
+ return _build_policy_acls(policy)
+
+
+@app.post("/api/firewall/push")
+def push_policy(body: dict):
+ """Push a firewall policy to both switch and OPNsense."""
+ require_session(body.get("token", ""))
+ policy = body.get("policy", {})
+ if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
+ raise HTTPException(400, "src_vlan, dst_vlan, and type required")
+
+ generated = _build_policy_acls(policy)
+ steps_done = []
+ errors = []
+
+ # Pre-backup
+ backup = _pre_change_backup(
+ reason=f"pre-policy VLAN {policy['src_vlan']}→{policy['dst_vlan']} ({policy['type']})")
+
+ # Push switch ACLs
+ if generated["switch_cmds"]:
+ danger = check_danger(generated["switch_cmds"])
+ if danger["has_hard_block"]:
+ raise HTTPException(400, {"message": "Hard-blocked", "blocked": danger["hard_blocked"]})
+ result = push_one_by_one(generated["switch_cmds"])
+ if result.get("success"):
+ steps_done.append(f"switch: ACL {generated['acl_name']} applied")
+ else:
+ errors.append(f"switch: {result.get('error', 'push failed')}")
+
+ # Push OPNsense rules
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key") and generated["opnsense_rules"]:
+ vmap = _load_vlan_if_map()
+ src_if = vmap.get(str(policy["src_vlan"]), "")
+ for rule_data in generated["opnsense_rules"]:
+ if src_if:
+ rule_data["rule"]["interface"] = src_if
+ rule_data["rule"]["direction"] = "in"
+ try:
+ _opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_data)
+ steps_done.append(f"OPNsense: {rule_data['rule']['descr']}")
+ except ValueError as e:
+ errors.append(f"OPNsense: {e}")
+ try:
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+ steps_done.append("OPNsense: firewall rules applied")
+ except ValueError as e:
+ errors.append(f"OPNsense apply: {e}")
+
+ # Save policy to local state
+ policies = _load_policies()
+ policies = [p for p in policies
+ if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])]
+ policy["pushed"] = True
+ policy["pushed_at"] = _ts()
+ policies.append(policy)
+ _save_policies(policies)
+
+ return {
+ "success": len(errors) == 0,
+ "steps_done": steps_done,
+ "errors": errors,
+ "backup": backup,
+ "generated": generated,
+ }
+
+
+# ══════════════════════════════════════════════════════════════════════
+# SERVICE ACCESS — manage Caddy config + OPNsense port forwards + NAT reflection
+# ══════════════════════════════════════════════════════════════════════
+#
+# Architecture:
+# Caddy runs on a SEPARATE LAN services computer, NOT the VLAN 99
+# management computer. Management box only runs this tool + SSH keys.
+# Services box (LAN) runs Caddy, Plex, Docker, etc.
+#
+# WAN port forward 443 → services box LAN IP (Caddy).
+# Caddy routes
+# by hostname (SNI) to the correct backend. Service ports (32400, 8123,
+# etc.) are NEVER exposed on WAN.
+#
+# For LAN devices: they reach services directly via Caddy on the LAN.
+# For isolated VLANs (IoT, Guest, etc.): they use the public FQDN
+# (e.g. plex.mydomain.com). OPNsense NAT reflection handles this
+# internally — traffic never actually leaves the network. The isolated
+# VLAN device is treated exactly like an external user.
+#
+# This preserves full VLAN isolation. No pinholes, no cross-VLAN access.
+# IoT = untrusted = same access as someone on the internet.
+
+SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json")
+SERVICES_CONFIG_FILE = _Path("/etc/switch-manager/services-config.json")
+CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services")
+
+def _load_services_config() -> dict:
+ """Load services/Caddy host config: caddy_ip, etc.
+ This is the LAN services box, NOT the VLAN 99 management computer."""
+ if SERVICES_CONFIG_FILE.exists():
+ try: return _json.loads(SERVICES_CONFIG_FILE.read_text())
+ except: pass
+ return {}
+
+def _save_services_config(cfg: dict):
+ SERVICES_CONFIG_FILE.write_text(_json.dumps(cfg, indent=2))
+ SERVICES_CONFIG_FILE.chmod(0o600)
+
+def _get_caddy_ip() -> str:
+ """Return the Caddy/services box LAN IP."""
+ return _load_services_config().get("caddy_ip", "")
+
+def _load_services() -> list:
+ if SERVICES_FILE.exists():
+ try: return _json.loads(SERVICES_FILE.read_text())
+ except: pass
+ return []
+
+def _save_services(services: list):
+ SERVICES_FILE.write_text(_json.dumps(services, indent=2))
+ SERVICES_FILE.chmod(0o600)
+
+
+def _generate_caddyfile_services(services: list) -> str:
+ """Generate Caddyfile blocks for the LAN management computer's Caddy.
+
+ Each service FQDN gets a reverse_proxy block. Caddy handles TLS
+ termination and routes by hostname. Only port 443 needs to be
+ forwarded from WAN to this machine.
+ """
+ blocks = ["# Auto-generated by switch-manager — service reverse proxy entries\n",
+ "# Add to your Caddyfile or use: import /etc/switch-manager/Caddyfile.services\n"]
+ for svc in services:
+ fqdn = svc.get("fqdn", "")
+ backend_url = svc.get("backend_url", "")
+ if not fqdn or not backend_url:
+ continue
+ blocks.append(f"{fqdn} {{")
+ blocks.append(f" reverse_proxy {backend_url}")
+ blocks.append(f"}}\n")
+ return "\n".join(blocks)
+
+
+@app.get("/api/services")
+def get_services():
+ """List configured service proxies."""
+ return {"services": _load_services()}
+
+
+@app.post("/api/services")
+def save_service(body: dict):
+ """Add or update a service proxy entry."""
+ require_session(body.get("token", ""))
+ svc = body.get("service", {})
+ if not svc.get("fqdn") or not svc.get("backend_url"):
+ raise HTTPException(400, "fqdn and backend_url required")
+
+ services = _load_services()
+ services = [s for s in services if s["fqdn"] != svc["fqdn"]]
+ services.append(svc)
+ _save_services(services)
+ return {"success": True, "services": services}
+
+
+@app.delete("/api/services")
+def delete_service(body: dict):
+ """Remove a service proxy entry."""
+ require_session(body.get("token", ""))
+ fqdn = body.get("fqdn", "")
+ services = _load_services()
+ services = [s for s in services if s["fqdn"] != fqdn]
+ _save_services(services)
+ return {"success": True, "services": services}
+
+
+SERVICES_RULE_FILE = _Path("/etc/switch-manager/service-nat-rules.json")
+
+def _load_service_rules() -> dict:
+ """Load tracked OPNsense NAT rule UUIDs for service port forwards."""
+ if SERVICES_RULE_FILE.exists():
+ try: return _json.loads(SERVICES_RULE_FILE.read_text())
+ except: pass
+ return {}
+
+def _save_service_rules(rules: dict):
+ SERVICES_RULE_FILE.write_text(_json.dumps(rules, indent=2))
+ SERVICES_RULE_FILE.chmod(0o600)
+
+
+def _get_mgmt_ip() -> str:
+ """Best-effort detection of management computer LAN IP."""
+ import socket as _sock
+ try:
+ s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except Exception:
+ return ""
+
+
+@app.get("/api/services/config")
+def get_services_config():
+ """Return services host configuration."""
+ return _load_services_config()
+
+@app.post("/api/services/config")
+def save_services_config_endpoint(body: dict):
+ """Save services host configuration (Caddy box LAN IP)."""
+ require_session(body.get("token", ""))
+ cfg = body.get("config", {})
+ if not cfg.get("caddy_ip"):
+ raise HTTPException(400, "caddy_ip required — the LAN IP of your services/Caddy computer")
+ _save_services_config(cfg)
+ return {"success": True, "config": cfg}
+
+@app.get("/api/services/status")
+def services_status():
+ """Full status check: Caddy host, NAT reflection, port forward, services."""
+ services = _load_services()
+ svc_cfg = _load_services_config()
+ cfg = _load_opnsense_cfg()
+ caddy_ip = svc_cfg.get("caddy_ip", "")
+ result = {
+ "services": services,
+ "caddy_ip": caddy_ip,
+ "caddy_configured": bool(caddy_ip),
+ "mgmt_ip": _get_mgmt_ip(),
+ "caddy_file_exists": CADDYFILE_EXTRA.exists(),
+ "nat_reflection": None,
+ "port_forward_443": None,
+ "opnsense_configured": bool(cfg.get("key")),
+ "opnsense_ssh": bool(cfg.get("ssh_key_path")),
+ }
+
+ # Check NAT reflection
+ if cfg.get("ssh_key_path"):
+ try:
+ out, _, _ = _opnsense_ssh_run(cfg,
+ "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
+ result["nat_reflection"] = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
+ except Exception as e:
+ result["nat_reflection_error"] = str(e)
+
+ # Check for existing WAN port forward to 443
+ if cfg.get("key"):
+ try:
+ nat_rules = _opnsense_request(cfg, "firewall/source_nat/searchRule")
+ # OPNsense 24+ uses source_nat; older uses legacy — try both
+ except Exception:
+ nat_rules = {}
+ if not nat_rules:
+ try:
+ nat_rules = _opnsense_request(cfg, "firewall/filter/searchRule")
+ except Exception:
+ nat_rules = {}
+ # We can't reliably parse NAT rules from the filter API —
+ # mark as "needs verification" unless we've created one ourselves
+ tracked = _load_service_rules()
+ result["port_forward_443"] = bool(tracked.get("wan_443_uuid"))
+ result["tracked_rules"] = tracked
+
+ return result
+
+
+@app.post("/api/services/enable-nat-reflection")
+def enable_nat_reflection(body: dict):
+ """Enable NAT reflection on OPNsense via SSH (modifies config.xml)."""
+ require_session(body.get("token", ""))
+ cfg = _load_opnsense_cfg()
+ if not cfg.get("ssh_key_path"):
+ raise HTTPException(503, "OPNsense SSH not configured")
+
+ backup = _pre_change_backup(reason="pre-NAT-reflection-enable")
+
+ # OPNsense stores NAT reflection settings in /conf/config.xml under
+ # The cleanest way is via the API if available, or configctl
+ try:
+ # Try the OPNsense API approach first (Firewall > Settings)
+ # The setting is under system > disablenatreflection (absent = enabled)
+ # and system > enablenatreflectionhelper (present = enabled)
+ out, err, code = _opnsense_ssh_run(cfg, (
+ "configctl firmware configure 2>/dev/null; "
+ "echo 'NAT reflection: checking current state'; "
+ "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0"
+ ))
+ already_enabled = "1" in out.strip().split('\n')[-1]
+ if already_enabled:
+ return {"success": True, "already_enabled": True, "backup": backup}
+
+ # Enable via configctl / direct XML edit
+ # OPNsense 24+: use pluginctl or direct config edit
+ cmds = [
+ # Add enablenatreflectionhelper if not present
+ "sed -i '' '/<\\/system>/i\\ 1<\\/enablenatreflectionhelper>' /conf/config.xml 2>/dev/null || "
+ "sed -i '/<\\/system>/i\\ 1<\\/enablenatreflectionhelper>' /conf/config.xml",
+ # Remove disablenatreflection if present
+ "sed -i '' '//d' /conf/config.xml 2>/dev/null || "
+ "sed -i '//d' /conf/config.xml",
+ # Reload filter
+ "configctl filter reload",
+ ]
+ for cmd in cmds:
+ _opnsense_ssh_run(cfg, cmd)
+
+ # Verify
+ out, _, _ = _opnsense_ssh_run(cfg,
+ "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
+ enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
+
+ return {"success": enabled, "backup": backup,
+ "note": "Firewall filter reloaded" if enabled else "May need manual verification"}
+ except Exception as e:
+ raise HTTPException(500, f"NAT reflection enable failed: {e}")
+
+
+@app.post("/api/services/create-port-forward")
+def create_wan_port_forward(body: dict):
+ """
+ Create WAN port forward: TCP 443 → management computer (Caddy).
+
+ Uses OPNsense firewall NAT API. Only creates the rule if we haven't
+ already (tracked by UUID in service-nat-rules.json).
+ """
+ require_session(body.get("token", ""))
+ cfg = _load_opnsense_cfg()
+ if not cfg.get("key"):
+ raise HTTPException(503, "OPNsense API not configured")
+
+ caddy_ip = body.get("caddy_ip", _get_caddy_ip())
+ if not caddy_ip:
+ raise HTTPException(400, "Caddy/services box IP not configured — set it in the Services tab")
+
+ tracked = _load_service_rules()
+ if tracked.get("wan_443_uuid"):
+ return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"],
+ "caddy_ip": caddy_ip}
+
+ backup = _pre_change_backup(reason="pre-WAN-port-forward-443")
+
+ try:
+ # Create NAT port forward rule: WAN TCP 443 → caddy_ip:443 (services box on LAN)
+ r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", {
+ "rule": {
+ "enabled": "1",
+ "interface": "wan",
+ "protocol": "tcp",
+ "source": {"any": "1"},
+ "destination": {"any": "1", "port": "443"},
+ "target": {"address": caddy_ip, "port": "443"},
+ "descr": f"switch-manager: WAN 443 → Caddy ({caddy_ip})",
+ "nordr": "0",
+ }
+ })
+ uuid = r.get("uuid", "")
+
+ # If source_nat didn't work, try legacy firewall NAT API
+ if not uuid:
+ # OPNsense legacy NAT — different endpoint
+ r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
+ "rule": {
+ "enabled": "1",
+ "action": "pass",
+ "interface": "wan",
+ "direction": "in",
+ "ipprotocol": "inet",
+ "protocol": "tcp",
+ "source": {"any": "1"},
+ "destination": {"address": caddy_ip, "port": "443"},
+ "descr": f"switch-manager: allow WAN → Caddy ({caddy_ip}:443)",
+ }
+ })
+ uuid = r.get("uuid", "")
+
+ # Apply changes
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+
+ tracked["wan_443_uuid"] = uuid
+ tracked["caddy_ip"] = caddy_ip
+ _save_service_rules(tracked)
+
+ return {"success": True, "uuid": uuid, "caddy_ip": caddy_ip, "backup": backup,
+ "note": "If this is the first time, also verify in OPNsense UI: "
+ "Firewall > NAT > Port Forward that the rule looks correct. "
+ "The OPNsense NAT API varies between versions."}
+ except Exception as e:
+ raise HTTPException(500, f"Port forward creation failed: {e}")
+
+
+@app.post("/api/services/deploy")
+def deploy_services(body: dict):
+ """
+ Full deploy: write Caddyfile, reload Caddy, ensure NAT reflection
+ and port forward are configured on OPNsense.
+
+ Steps:
+ 1. Pre-change backup
+ 2. Write Caddyfile.services with reverse proxy entries
+ 3. Reload Caddy (docker compose exec or systemctl)
+ 4. Check/enable NAT reflection on OPNsense
+ 5. Check/create WAN port forward 443 → Caddy
+ 6. Return status of each step
+ """
+ require_session(body.get("token", ""))
+ services = _load_services()
+ if not services:
+ raise HTTPException(400, "No services configured")
+
+ steps_done = []
+ errors = []
+ pending_steps = []
+
+ backup = _pre_change_backup(reason="pre-service-deploy")
+
+ caddy_ip = body.get("caddy_ip", _get_caddy_ip()) or _get_mgmt_ip()
+
+ # ── Step 1: Write Caddyfile.services ─────────────────────────────
+ caddy_content = _generate_caddyfile_services(services)
+ try:
+ CADDYFILE_EXTRA.write_text(caddy_content)
+ steps_done.append(f"Wrote Caddyfile.services ({len(services)} services)")
+ except Exception as e:
+ errors.append(f"Caddyfile write: {e}")
+
+ # ── Step 2: Reload Caddy ─────────────────────────────────────────
+ import subprocess as _sp
+ caddy_reloaded = False
+ # Try docker compose first
+ try:
+ r = _sp.run(["docker", "compose", "exec", "caddy", "caddy", "reload",
+ "--config", "/etc/caddy/Caddyfile"],
+ capture_output=True, text=True, timeout=15,
+ cwd=str(_Path(__file__).parent))
+ if r.returncode == 0:
+ steps_done.append("Caddy reloaded via docker compose")
+ caddy_reloaded = True
+ else:
+ # Try docker exec with container name pattern
+ r2 = _sp.run(["docker", "compose", "restart", "caddy"],
+ capture_output=True, text=True, timeout=30,
+ cwd=str(_Path(__file__).parent))
+ if r2.returncode == 0:
+ steps_done.append("Caddy restarted via docker compose")
+ caddy_reloaded = True
+ else:
+ errors.append(f"Docker caddy reload failed: {r.stderr.strip()}")
+ except Exception:
+ pass
+
+ if not caddy_reloaded:
+ # Try systemctl
+ try:
+ r = _sp.run(["systemctl", "reload", "caddy"],
+ capture_output=True, text=True, timeout=10)
+ if r.returncode == 0:
+ steps_done.append("Caddy reloaded via systemctl")
+ caddy_reloaded = True
+ except Exception:
+ pass
+
+ if not caddy_reloaded:
+ pending_steps.append(
+ "Reload Caddy manually: docker compose restart caddy "
+ "(or: systemctl reload caddy)")
+
+ # ── Step 3: NAT reflection ───────────────────────────────────────
+ cfg = _load_opnsense_cfg()
+ nat_status = None
+ if cfg.get("ssh_key_path"):
+ try:
+ out, _, _ = _opnsense_ssh_run(cfg,
+ "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
+ nat_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
+ nat_status = nat_enabled
+ if nat_enabled:
+ steps_done.append("NAT reflection: already enabled")
+ else:
+ pending_steps.append(
+ "Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > "
+ "Reflection for port forwards = Enable. "
+ "Or use the 'Enable NAT Reflection' button above.")
+ except Exception as e:
+ errors.append(f"NAT reflection check: {e}")
+ else:
+ pending_steps.append("Configure OPNsense SSH to auto-check NAT reflection")
+
+ # ── Step 4: WAN port forward ─────────────────────────────────────
+ tracked = _load_service_rules()
+ if tracked.get("wan_443_uuid"):
+ steps_done.append(f"WAN port forward 443 → {tracked.get('caddy_ip', caddy_ip)}:443 (tracked)")
+ else:
+ pending_steps.append(
+ f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
+ f"WAN TCP 443 → {caddy_ip}:443 (services box). "
+ f"Or use the 'Create Port Forward' button above.")
+
+ return {
+ "success": len(errors) == 0,
+ "steps_done": steps_done,
+ "pending_steps": pending_steps,
+ "errors": errors,
+ "backup": backup,
+ "caddy_content": caddy_content,
+ "caddy_ip": caddy_ip,
+ "nat_reflection_enabled": nat_status,
+ }
+
+
+# ══════════════════════════════════════════════════════════════════════
+# NTFY ALERTS — push notifications for network events
+# ══════════════════════════════════════════════════════════════════════
+
+NTFY_FILE = _Path("/etc/switch-manager/ntfy.json")
+
+def _load_ntfy_cfg() -> dict:
+ if NTFY_FILE.exists():
+ try: return _json.loads(NTFY_FILE.read_text())
+ except: pass
+ return {}
+
+def _save_ntfy_cfg(cfg: dict):
+ NTFY_FILE.write_text(_json.dumps(cfg, indent=2))
+ NTFY_FILE.chmod(0o600)
+
+
+def _ntfy_send(title: str, message: str, priority: str = "default", tags: str = ""):
+ """Send a notification via ntfy. Non-blocking, fire-and-forget."""
+ cfg = _load_ntfy_cfg()
+ url = cfg.get("url", "")
+ topic = cfg.get("topic", "")
+ if not url or not topic:
+ return
+ try:
+ full_url = f"{url.rstrip('/')}/{topic}"
+ headers = {
+ "Title": title,
+ "Priority": priority,
+ }
+ if tags:
+ headers["Tags"] = tags
+ token = cfg.get("token", "")
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ data = message.encode("utf-8")
+ req = _urlreq.Request(full_url, data=data, headers=headers, method="POST")
+ ctx = _ssl.create_default_context()
+ ctx.check_hostname = False
+ ctx.verify_mode = _ssl.CERT_NONE
+ _urlreq.urlopen(req, timeout=5, context=ctx)
+ log.info(f"ntfy alert sent: {title}")
+ except Exception as e:
+ log.warning(f"ntfy send failed: {e}")
+
+
+@app.get("/api/alerts/config")
+def get_ntfy_config():
+ """Return ntfy configuration (without token)."""
+ cfg = _load_ntfy_cfg()
+ return {
+ "url": cfg.get("url", ""),
+ "topic": cfg.get("topic", ""),
+ "has_token": bool(cfg.get("token", "")),
+ "enabled": cfg.get("enabled", False),
+ "events": cfg.get("events", {
+ "connectivity_lost": True,
+ "backup_failed": True,
+ "push_failed": True,
+ "poe_budget_warning": True,
+ "port_down": False,
+ }),
+ }
+
+
+@app.post("/api/alerts/config")
+def save_ntfy_config(body: dict):
+ """Save ntfy configuration."""
+ require_session(body.get("token_session", body.get("token", "")))
+ cfg = {
+ "url": body.get("url", "https://ntfy.sh"),
+ "topic": body.get("topic", ""),
+ "token": body.get("ntfy_token", ""),
+ "enabled": body.get("enabled", False),
+ "events": body.get("events", {}),
+ }
+ _save_ntfy_cfg(cfg)
+ return {"success": True}
+
+
+@app.post("/api/alerts/test")
+def test_ntfy(body: dict):
+ """Send a test notification."""
+ require_session(body.get("token", ""))
+ _ntfy_send(
+ title="Switch Manager Test",
+ message="If you see this, ntfy alerts are working!",
+ priority="low",
+ tags="white_check_mark,test_tube",
+ )
+ return {"success": True}
+
+
+# ── Alert integration into polling ───────────────────────────────────
+
+_last_alert_state: dict = {}
+
+def _check_and_alert():
+ """Called from the poll loop to detect alertable conditions."""
+ cfg = _load_ntfy_cfg()
+ if not cfg.get("enabled"):
+ return
+ events = cfg.get("events", {})
+ global _last_alert_state
+
+ with _cache_lock:
+ poll_err = _cache.get("poll_error")
+ port_status = _cache.get("port_status", "")
+ poe_status = _cache.get("poe_status", "")
+
+ # Connectivity lost
+ if events.get("connectivity_lost") and poll_err:
+ if not _last_alert_state.get("conn_lost"):
+ _ntfy_send("Switch Offline", f"Cannot reach switch: {poll_err}",
+ priority="urgent", tags="rotating_light,warning")
+ _last_alert_state["conn_lost"] = True
+ else:
+ if _last_alert_state.get("conn_lost"):
+ _ntfy_send("Switch Back Online", "Connectivity restored",
+ priority="default", tags="white_check_mark")
+ _last_alert_state["conn_lost"] = False
+
+ # PoE budget warning (parse from poe_status if available)
+ if events.get("poe_budget_warning") and poe_status:
+ import re as _re_alert
+ watts_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed)', poe_status)
+ budget_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum)', poe_status)
+ if watts_match and budget_match:
+ try:
+ used = int(watts_match[0][0])
+ budget = int(budget_match[0][0])
+ pct = (used / budget * 100) if budget > 0 else 0
+ if pct > 85 and not _last_alert_state.get("poe_warn"):
+ _ntfy_send("PoE Budget Warning",
+ f"PoE usage at {pct:.0f}% ({used}W / {budget}W)",
+ priority="high", tags="zap,warning")
+ _last_alert_state["poe_warn"] = True
+ elif pct <= 80:
+ _last_alert_state["poe_warn"] = False
+ except (ValueError, IndexError):
+ pass
+
+
+# ══════════════════════════════════════════════════════════════════════
+# SCHEDULED OPERATIONS — cron-like scheduler for backups and VLAN ops
+# ══════════════════════════════════════════════════════════════════════
+
+SCHEDULES_FILE = _Path("/etc/switch-manager/schedules.json")
+_scheduler_thread = None
+
+def _load_schedules() -> list:
+ if SCHEDULES_FILE.exists():
+ try: return _json.loads(SCHEDULES_FILE.read_text())
+ except: pass
+ return []
+
+def _save_schedules(schedules: list):
+ SCHEDULES_FILE.write_text(_json.dumps(schedules, indent=2))
+ SCHEDULES_FILE.chmod(0o600)
+
+
+def _should_run_now(schedule: dict) -> bool:
+ """Check if a schedule should run based on current time and its cron-like fields."""
+ now = _dt.datetime.now()
+ hour = schedule.get("hour", "*")
+ minute = schedule.get("minute", "0")
+ days = schedule.get("days", "*") # "mon,tue,wed" or "*"
+
+ if hour != "*" and now.hour != int(hour):
+ return False
+ if minute != "*" and now.minute != int(minute):
+ return False
+ if days != "*":
+ day_names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
+ today = day_names[now.weekday()]
+ if today not in days.lower().split(","):
+ return False
+ return True
+
+
+def _run_scheduled_task(schedule: dict):
+ """Execute a scheduled task."""
+ action = schedule.get("action", "")
+ name = schedule.get("name", "unnamed")
+ log.info(f"Scheduler: running '{name}' (action={action})")
+
+ try:
+ if action == "backup":
+ device = schedule.get("device", "both")
+ result = {}
+ if device in ("switch", "both"):
+ result["switch"] = _switch_backup(reason=f"scheduled: {name}")
+ if device in ("opnsense", "both"):
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key"):
+ result["opnsense"] = _opnsense_backup(cfg, reason=f"scheduled: {name}")
+ log.info(f"Scheduled backup '{name}': {result}")
+ _ntfy_send(f"Scheduled Backup: {name}",
+ f"Switch: {'OK' if result.get('switch',{}).get('ok') else 'FAIL'}, "
+ f"OPNsense: {'OK' if result.get('opnsense',{}).get('ok') else 'N/A'}",
+ tags="floppy_disk")
+
+ elif action == "connectivity_check":
+ conn = _check_connectivity()
+ if not conn["switch"]["ok"]:
+ _ntfy_send("Scheduled Check: Switch Offline",
+ f"Switch unreachable: {conn['switch'].get('error','')}",
+ priority="urgent", tags="rotating_light")
+
+ elif action == "vlan_enable":
+ # Re-enable a VLAN's internet access on OPNsense by adding allow-out rule
+ vlan_id = schedule.get("vlan_id")
+ vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
+ cfg = _load_opnsense_cfg()
+ vmap = _load_vlan_if_map()
+ iface = vmap.get(str(vlan_id), "")
+ if cfg.get("key") and iface:
+ try:
+ r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "interface": iface, "direction": "in",
+ "ipprotocol": "inet", "protocol": "any",
+ "source": {"network": f"{iface}net"},
+ "destination": {"any": "1"},
+ "descr": f"Scheduled: allow {vlan_name} outbound",
+ }
+ })
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+ # Track the rule UUID for later disable
+ _vlan_schedule_rules = _load_vlan_schedule_rules()
+ _vlan_schedule_rules[str(vlan_id)] = r.get("uuid", "")
+ _save_vlan_schedule_rules(_vlan_schedule_rules)
+ log.info(f"Scheduled VLAN enable: {vlan_name} ({vlan_id})")
+ _ntfy_send(f"VLAN Enabled: {vlan_name}",
+ f"Internet access restored for {vlan_name} (scheduled)",
+ tags="white_check_mark,globe_with_meridians")
+ except Exception as e:
+ log.warning(f"VLAN enable failed: {e}")
+ _ntfy_send(f"VLAN Enable Failed: {vlan_name}", str(e),
+ priority="high", tags="x")
+
+ elif action == "vlan_disable":
+ # Disable a VLAN's internet access by removing allow-out rule
+ vlan_id = schedule.get("vlan_id")
+ vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key"):
+ _vlan_schedule_rules = _load_vlan_schedule_rules()
+ uuid = _vlan_schedule_rules.get(str(vlan_id), "")
+ if uuid:
+ try:
+ _opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+ _vlan_schedule_rules.pop(str(vlan_id), None)
+ _save_vlan_schedule_rules(_vlan_schedule_rules)
+ log.info(f"Scheduled VLAN disable: {vlan_name} ({vlan_id})")
+ _ntfy_send(f"VLAN Disabled: {vlan_name}",
+ f"Internet access blocked for {vlan_name} (scheduled)",
+ tags="no_entry,moon")
+ except Exception as e:
+ log.warning(f"VLAN disable failed: {e}")
+ _ntfy_send(f"VLAN Disable Failed: {vlan_name}", str(e),
+ priority="high", tags="x")
+ else:
+ # No tracked rule — try to find and disable by description
+ log.warning(f"No tracked rule UUID for VLAN {vlan_id} — "
+ f"block rule must be added manually or via firewall policy")
+
+ except Exception as e:
+ log.warning(f"Scheduled task '{name}' failed: {e}")
+ _ntfy_send(f"Scheduled Task Failed: {name}", str(e),
+ priority="high", tags="x")
+
+
+# VLAN schedule rule tracking (which firewall rules we created for enable/disable)
+VLAN_SCHED_RULES_FILE = _Path("/etc/switch-manager/vlan-schedule-rules.json")
+
+def _load_vlan_schedule_rules() -> dict:
+ if VLAN_SCHED_RULES_FILE.exists():
+ try: return _json.loads(VLAN_SCHED_RULES_FILE.read_text())
+ except: pass
+ return {}
+
+def _save_vlan_schedule_rules(rules: dict):
+ VLAN_SCHED_RULES_FILE.write_text(_json.dumps(rules, indent=2))
+ VLAN_SCHED_RULES_FILE.chmod(0o600)
+
+
+def _scheduler_loop():
+ """Background thread: check schedules every 60 seconds."""
+ log.info("Scheduler thread started")
+ last_runs: dict[str, str] = {} # {schedule_name: "YYYYMMDD-HHMM"}
+ while True:
+ time.sleep(60)
+ schedules = _load_schedules()
+ now_key = _dt.datetime.now().strftime("%Y%m%d-%H%M")
+ for sched in schedules:
+ if not sched.get("enabled", True):
+ continue
+ name = sched.get("name", "")
+ # Don't run the same schedule twice in the same minute
+ if last_runs.get(name) == now_key:
+ continue
+ if _should_run_now(sched):
+ last_runs[name] = now_key
+ try:
+ _run_scheduled_task(sched)
+ except Exception as e:
+ log.warning(f"Scheduler error for '{name}': {e}")
+
+
+def start_scheduler():
+ global _scheduler_thread
+ if _scheduler_thread is None or not _scheduler_thread.is_alive():
+ _scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler")
+ _scheduler_thread.start()
+
+
+# Start scheduler on import (alongside poller)
+start_scheduler()
+
+
+@app.get("/api/schedules")
+def get_schedules():
+ return {"schedules": _load_schedules()}
+
+
+@app.post("/api/schedules")
+def save_schedule(body: dict):
+ require_session(body.get("token", ""))
+ sched = body.get("schedule", {})
+ if not sched.get("name") or not sched.get("action"):
+ raise HTTPException(400, "name and action required")
+
+ schedules = _load_schedules()
+ schedules = [s for s in schedules if s["name"] != sched["name"]]
+ schedules.append(sched)
+ _save_schedules(schedules)
+ return {"success": True, "schedules": schedules}
+
+
+@app.delete("/api/schedules")
+def delete_schedule(body: dict):
+ require_session(body.get("token", ""))
+ name = body.get("name", "")
+ schedules = _load_schedules()
+ schedules = [s for s in schedules if s["name"] != name]
+ _save_schedules(schedules)
+ return {"success": True, "schedules": schedules}
+
+
+@app.post("/api/schedules/run-now")
+def run_schedule_now(body: dict):
+ """Manually trigger a scheduled task immediately."""
+ require_session(body.get("token", ""))
+ name = body.get("name", "")
+ schedules = _load_schedules()
+ sched = next((s for s in schedules if s["name"] == name), None)
+ if not sched:
+ raise HTTPException(404, f"Schedule '{name}' not found")
+ _run_scheduled_task(sched)
+ return {"success": True, "ran": name}
+
+
+# ══════════════════════════════════════════════════════════════════════
+# PORT FORWARDING — manage OPNsense NAT port forwards
+# ══════════════════════════════════════════════════════════════════════
+
+PORT_FWD_FILE = _Path("/etc/switch-manager/port-forwards.json")
+
+def _load_port_forwards() -> list:
+ if PORT_FWD_FILE.exists():
+ try: return _json.loads(PORT_FWD_FILE.read_text())
+ except: pass
+ return []
+
+def _save_port_forwards(fwds: list):
+ PORT_FWD_FILE.write_text(_json.dumps(fwds, indent=2))
+ PORT_FWD_FILE.chmod(0o600)
+
+
+@app.get("/api/port-forwards")
+def get_port_forwards():
+ return {"forwards": _load_port_forwards()}
+
+
+@app.post("/api/port-forwards")
+def create_port_forward(body: dict):
+ """Create a NAT port forward on OPNsense + companion firewall rule."""
+ require_session(body.get("token", ""))
+ fwd = body.get("forward", {})
+ proto = fwd.get("proto", "tcp")
+ wan_port = fwd.get("wan_port", "")
+ target_ip = fwd.get("target_ip", "")
+ target_port = fwd.get("target_port", wan_port)
+ description = fwd.get("description", "")
+
+ if not wan_port or not target_ip:
+ raise HTTPException(400, "wan_port and target_ip required")
+
+ cfg = _load_opnsense_cfg()
+ if not cfg.get("key"):
+ raise HTTPException(503, "OPNsense API not configured")
+
+ backup = _pre_change_backup(reason=f"pre-port-forward {proto}/{wan_port}→{target_ip}:{target_port}")
+
+ try:
+ # Create firewall pass rule for the forwarded traffic
+ r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
+ "rule": {
+ "enabled": "1", "action": "pass",
+ "interface": "wan", "direction": "in",
+ "ipprotocol": "inet", "protocol": proto,
+ "source": {"any": "1"},
+ "destination": {"address": target_ip, "port": str(target_port)},
+ "descr": f"Port forward: WAN {proto}/{wan_port} → {target_ip}:{target_port}"
+ f"{' — ' + description if description else ''}",
+ }
+ })
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+ uuid = r.get("uuid", "")
+
+ fwd_entry = {
+ "proto": proto, "wan_port": wan_port,
+ "target_ip": target_ip, "target_port": target_port,
+ "description": description, "uuid": uuid,
+ "created_at": _ts(),
+ }
+ fwds = _load_port_forwards()
+ fwds.append(fwd_entry)
+ _save_port_forwards(fwds)
+
+ return {"success": True, "forward": fwd_entry, "backup": backup,
+ "note": "Firewall rule created. Also verify NAT port forward exists: "
+ f"OPNsense > Firewall > NAT > Port Forward — WAN {proto} {wan_port} → {target_ip}:{target_port}"}
+ except Exception as e:
+ raise HTTPException(500, f"Port forward creation failed: {e}")
+
+
+@app.delete("/api/port-forwards")
+def delete_port_forward(body: dict):
+ """Remove a port forward and its firewall rule."""
+ require_session(body.get("token", ""))
+ uuid = body.get("uuid", "")
+ if not uuid:
+ raise HTTPException(400, "uuid required")
+
+ cfg = _load_opnsense_cfg()
+ if cfg.get("key") and uuid:
+ try:
+ _opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
+ _opnsense_request(cfg, "firewall/filter/apply", "POST")
+ except Exception as e:
+ log.warning(f"Port forward rule delete failed: {e}")
+
+ fwds = _load_port_forwards()
+ fwds = [f for f in fwds if f.get("uuid") != uuid]
+ _save_port_forwards(fwds)
+ return {"success": True}
+
+
+# ══════════════════════════════════════════════════════════════════════
+# NETWORK TOPOLOGY — auto-generated from live data
+# ══════════════════════════════════════════════════════════════════════
+
+@app.get("/api/topology")
+def get_topology():
+ """Build network topology from live switch + OPNsense data."""
+ topology = {
+ "router": {"ip": "", "hostname": "OPNsense", "connected": False},
+ "switch": {"ip": SWITCH_HOST, "hostname": "ERS-5952", "connected": False},
+ "vlans": [],
+ "ports": [],
+ "devices": [],
+ }
+
+ # Switch connectivity
+ try:
+ conn = _pool.get()
+ transport = conn.get_transport()
+ if transport and transport.is_active():
+ topology["switch"]["connected"] = True
+ except Exception:
+ pass
+
+ # OPNsense
+ cfg = _load_opnsense_cfg()
+ if cfg.get("host"):
+ topology["router"]["ip"] = cfg["host"]
+ try:
+ fw = _opnsense_request(cfg, "core/firmware/status")
+ topology["router"]["connected"] = True
+ topology["router"]["version"] = fw.get("product_version", "")
+ except Exception:
+ pass
+
+ # VLANs from cache
+ with _cache_lock:
+ vlan_raw = _cache.get("vlan_members", "")
+ port_raw = _cache.get("port_status", "")
+
+ # Parse port status for link state
+ if port_raw:
+ for line in port_raw.splitlines():
+ import re as _re_topo
+ m = _re_topo.match(r'\s*(\d+)\s+(\S+)\s+(\S+)\s+(\S+)', line)
+ if m:
+ port_id = int(m.group(1))
+ link = m.group(3).lower()
+ topology["ports"].append({
+ "id": port_id,
+ "link": "up" if "up" in link else "down",
+ })
+
+ # Devices from saved list
+ try:
+ topology["devices"] = _load_devices()[:50] # Cap at 50
+ except Exception:
+ pass
+
+ # VLAN info
+ vmap = _load_vlan_if_map()
+ topology["vlan_interface_map"] = vmap
+
+ return topology
+
+
+# ══════════════════════════════════════════════════════════════════════
+# POE BUDGET DASHBOARD — power consumption overview
+# ══════════════════════════════════════════════════════════════════════
+
+@app.get("/api/poe/budget")
+def poe_budget():
+ """Parse PoE status from cached switch data."""
+ with _cache_lock:
+ poe_raw = _cache.get("poe_status", "")
+
+ if not poe_raw:
+ return {"available": False, "error": "No PoE data cached — switch may be offline"}
+
+ import re as _re_poe
+ result = {
+ "available": True,
+ "raw": poe_raw[:2000],
+ "total_watts": None,
+ "used_watts": None,
+ "remaining_watts": None,
+ "percent_used": None,
+ "ports": [],
+ }
+
+ # Parse total/used from various ERS output formats
+ budget_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum|total)', poe_raw, _re_poe.I)
+ used_m = _re_poe.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed|delivering)', poe_raw, _re_poe.I)
+ if budget_m:
+ result["total_watts"] = int(budget_m[0][0])
+ if used_m:
+ result["used_watts"] = int(used_m[0][0])
+ if result["total_watts"] and result["used_watts"]:
+ result["remaining_watts"] = result["total_watts"] - result["used_watts"]
+ result["percent_used"] = round(result["used_watts"] / result["total_watts"] * 100, 1)
+
+ # Parse per-port PoE
+ for line in poe_raw.splitlines():
+ pm = _re_poe.match(
+ r'\s*(\d+)\s+\S+\s+(\S+)\s+\S+\s+(\d+(?:\.\d+)?)\s*[Ww]', line)
+ if pm:
+ result["ports"].append({
+ "port": int(pm.group(1)),
+ "status": pm.group(2),
+ "watts": float(pm.group(3)),
+ })
+
+ return result
+
+
# ══════════════════════════════════════════════════════════════════════
# OPNSENSE NAT — WEBRTC / MATTERMOST CALLS FIX
# ══════════════════════════════════════════════════════════════════════