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} +
+ )}
); } @@ -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({ +
+
+ + 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 && ( +
+ + + {/* Existing VLANs overview */} +
+
Active VLANs
+
+
+ {vlans.map(v => { + const cnt = ports.filter(p => + p.mode === "access" ? p.accessVlan === v.id : p.taggedVlans?.includes(v.id) + ).length; + return ( +
+
VLAN {v.id}
+
{v.name}
+
+ {cnt} port{cnt !== 1 ? "s" : ""} | 192.168.{v.id}.0/24 +
+
+ ); + })} +
+
+
+ + {/* Unified VLAN Provisioning Form */} +
+
Provision New VLAN (Switch + OPNsense)
+
+
+ This creates everything in one step: VLAN on the switch, assigns ports with PoE settings, + creates the VLAN tag and DHCP scope on OPNsense, and adds a firewall rule. + A backup is taken automatically before any changes are made. +
+ +
+
+ { + setForm(f => ({...f, vlan_id: e.target.value})); + autoFillSubnet(e.target.value); + }} type="number" placeholder="60"/> +
+
+ setForm(f => ({...f, name: e.target.value}))} placeholder="e.g. Cameras"/> +
+
+ setForm(f => ({...f, subnet: e.target.value}))} placeholder="192.168.60.0/24"/> +
+
+ setForm(f => ({...f, gateway: e.target.value}))} placeholder="192.168.60.1"/> +
+
+ setForm(f => ({...f, dhcp_start: e.target.value}))} placeholder="192.168.60.100"/> +
+
+ setForm(f => ({...f, dhcp_end: e.target.value}))} placeholder="192.168.60.200"/> +
+
+ setForm(f => ({...f, parent_if: e.target.value}))} placeholder="igb0"/> +
+
+ setForm(f => ({...f, opnsense_if: e.target.value}))} placeholder="e.g. opt3 (leave blank if new)"/> +
+
+ +
+
+ + {/* Port Selection */} +
+
Access Ports (will be assigned to this VLAN)
+
+ Click ports to select. Dimmed ports are already assigned to another VLAN. +
+ {portGrid(1, 24, false)} + {portGrid(25, 48, false)} + + {selectedPorts.length > 0 && ( +
+
PoE Settings for Selected Ports
+
+ {selectedPorts.map(sp => ( +
+ Port {sp.id} + +
+ ))} +
+
+ )} + +
+
Trunk Uplinks (tag this VLAN on existing trunks)
+
+ Select uplink ports that should carry this VLAN (typically SFP+ ports 49-52). +
+ {portGrid(49, 52, true)} +
+
+ + {/* Summary */} + {(form.vlan_id && form.name) && ( +
+
Provision Summary
+
VLAN {form.vlan_id} "{form.name}" | Subnet: {form.subnet || "—"}
+
Access ports: {selectedPorts.length > 0 + ? selectedPorts.map(p => `${p.id}${p.poe ? " (PoE)" : ""}`).join(", ") + : "none selected"}
+
Trunk ports: {trunkPorts.length > 0 ? trunkPorts.join(", ") : "none"}
+
OPNsense: {form.opnsense_if + ? `DHCP ${form.dhcp_start}–${form.dhcp_end} on ${form.opnsense_if}` + : "VLAN tag only (assign interface in OPNsense UI)"}
+ {form.allow_internet &&
Firewall: allow outbound
} +
+ )} + +
+ + + Auto-backup runs before changes. TOTP required. + +
+ + {/* Result */} + {result && ( +
+
+ {result.success ? "Provisioning Complete" : "Provisioning Failed"} +
+ {result.steps_done?.map((s,i) => ( +
+ done{s} +
+ ))} + {result.errors?.map((e,i) => ( +
+ error{e} +
+ ))} + {result.pending_steps?.map((s,i) => ( +
+ manual{s} +
+ ))} + {result.backup && ( +
+ Backup: switch={result.backup.switch?.file || "N/A"} + {result.backup.opnsense ? `, opnsense=${result.backup.opnsense.file || "N/A"}` : ""} +
+ )} +
+ )} +
+
+ + + ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// BACKUP TAB — view, create, restore, download backups +// ══════════════════════════════════════════════════════════════════════════════ + +function BackupTab({ session, onNeedAuth, backendOk }) { + const [backups, setBackups] = useState({ switch: [], opnsense: [] }); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [restoring, setRestoring] = useState(null); + const [restoreResult, setRestoreResult] = useState(null); + const [reason, setReason] = useState(""); + + const load = async () => { + setLoading(true); + try { setBackups(await API("/backup/list")); } catch(e) { console.error(e); } + setLoading(false); + }; + + useEffect(() => { if (backendOk) load(); }, [backendOk]); + + const createBackup = async (device) => { + if (!session) { onNeedAuth(); return; } + setCreating(true); + try { + await API("/backup/create", { method: "POST", body: { + token: session.token, device, reason: reason || "manual backup", + }}); + setReason(""); + await load(); + } catch(e) { alert("Backup failed: " + e.message); } + setCreating(false); + }; + + const restore = async (device, filename) => { + if (!session) { onNeedAuth(); return; } + if (!confirm(`Restore ${device} from ${filename}?\n\nA safety backup will be created first.`)) return; + setRestoring(filename); setRestoreResult(null); + try { + const r = await API("/backup/restore", { method: "POST", body: { + token: session.token, device, filename, + }}); + setRestoreResult(r); + await load(); + } catch(e) { setRestoreResult({ result: { ok: false, error: e.message } }); } + setRestoring(null); + }; + + const BackupTable = ({ device, items }) => ( +
+
{device === "switch" ? "Switch" : "OPNsense"} Backups
+
+
+
+ + setReason(e.target.value)} placeholder="e.g. before VLAN change"/> +
+ +
+ {items.length === 0 ? ( +
+ No backups yet. Create one before making changes. +
+ ) : ( + + + + {items.map((b,i) => ( + + + + + + + ))} + +
TimeReasonSizeActions
{b.timestamp}{b.reason || "—"}{b.size ? `${(b.size/1024).toFixed(1)} KB` : "—"} + + Download + + +
+ )} +
+
+ ); + + return ( +
+
+
+
Backup & Restore
+
+ Backups are created automatically before every change (VLAN provisioning, push operations). + You can also create manual backups here. OPNsense backups are full XML config exports. + Switch backups capture the running configuration. +
+ Restore: OPNsense configs can be restored via API. Switch configs must be reviewed + and applied via the Review & Push tab (to prevent accidental lockout). +
+
+
+ +
+ +
+ + {restoreResult && ( +
+ {restoreResult.result?.ok + ?
Restore successful. Safety backup was created first.
+ :
Restore failed: {restoreResult.result?.error || "Unknown error"}
+ } + {restoreResult.config_preview && ( +
+ Config preview +
+                  {restoreResult.config_preview}
+                
+
+ )} +
+ )} + + + +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// 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 ? ( +
+ + + + + {nonMgmt.map(v => ( + + ))} + + + + {nonMgmt.map(src => ( + + + {nonMgmt.map(dst => { + if (src.id === dst.id) return ( + + ); + const p = getPolicy(src.id, dst.id); + return ( + + ); + })} + + ))} + +
+ From \ To + + {v.name}
V{v.id} +
+ {src.name} V{src.id} + { + 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
+
+
+
+ +
+
+ +
+
+ +
+ {(form.type === "services" || form.type === "printer") && ( +
+ 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 && !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})
+
+ + + + {policies.map((p,i) => ( + + + + + + + + ))} + +
SourceDestinationTypePushed
{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"} + +
+
+
+ )} +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// 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 && ( + + )} +
+ ); + + 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 */} +
+
Add Service
+
+
+
+ setForm(f => ({...f, fqdn: e.target.value}))} + placeholder="plex.mydomain.com"/> +
+
+ setForm(f => ({...f, backend_url: e.target.value}))} + placeholder="http://192.168.1.100:32400"/> +
+
+ setForm(f => ({...f, description: e.target.value}))} + placeholder="Plex Media Server"/> +
+
+ +
+
+ + {/* Service List + Deploy */} + {services.length > 0 && ( +
+
Configured Services ({services.length})
+
+ + + + {services.map((s,i) => ( + + + + + + + ))} + +
FQDNBackendDescription
{s.fqdn}{s.backend_url}{s.description || "—"}
+ +
+ + + 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. +
+ +
+
+ setNtfyCfg(c => ({...c, url: e.target.value}))} + placeholder="https://ntfy.sh"/> +
+
+ setNtfyCfg(c => ({...c, topic: e.target.value}))} + placeholder="my-network-alerts"/> +
+
+ setNtfyToken(e.target.value)} + type="password" placeholder={ntfyCfg.has_token ? "••••••• (saved)" : "for private topics"}/> +
+
+ +
+
Alert Events
+
+ {Object.entries(eventLabels).map(([k,label]) => ( + + ))} +
+
+ +
+ +
+ +
+ + +
+
+
+ + {/* 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. +
+ +
+
+ setSchedForm(f => ({...f, name: e.target.value}))} + placeholder="nightly-backup"/> +
+
+ +
+ {schedForm.action === "backup" && ( +
+ +
+ )} +
+ setSchedForm(f => ({...f, hour: e.target.value}))} + placeholder="3" style={{textAlign:"center"}}/> +
+
+ setSchedForm(f => ({...f, minute: e.target.value}))} + placeholder="0" style={{textAlign:"center"}}/> +
+
+ setSchedForm(f => ({...f, days: e.target.value}))} + placeholder="mon,wed,fri or *"/> +
+
+ + + {schedules.length > 0 && ( +
+ + + + {schedules.map((s,i) => ( + + + + + + + + + ))} + +
NameActionTimeDaysStatus
{s.name}{s.action}{s.device ? ` (${s.device})` : ""}{s.hour || "*"}:{(s.minute || "0").padStart(2,"0")}{s.days || "*"} + {s.enabled!==false?"active":"disabled"} + + +
+
+ )} +
+
+
+
+ ); +} + + +// ── 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) => ( + + ))} +
+
+
+
+ +
+
+
+ setOffHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="0"/> + : + setOffMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/> +
+
+
+
+ setOnHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="6"/> + : + setOnMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/> +
+
+
+ setDays(e.target.value)} placeholder="* or mon,tue,wed"/> +
+
+ +
+
+ {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.). +
+ +
+
+ +
+
+ setForm(f => ({...f, wan_port: e.target.value}))} + placeholder="25565" type="number"/> +
+
+ setForm(f => ({...f, target_ip: e.target.value}))} + placeholder="192.168.1.100"/> +
+
+ setForm(f => ({...f, target_port: e.target.value}))} + placeholder="same" type="number"/> +
+
+ setForm(f => ({...f, description: e.target.value}))} + placeholder="Minecraft server"/> +
+
+ +
+ + {forwards.length > 0 && ( +
+
Active Port Forwards ({forwards.length})
+
+ + + + {forwards.map((f,i) => ( + + + + + + + + + ))} + +
ProtoWAN PortTargetDescriptionCreated
{f.proto}{f.wan_port}{f.target_ip}:{f.target_port}{f.description || "—"}{f.created_at || "—"}
+
+
+ )} +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// 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}
+
+ ))} +
+ + )} + + +
+
+
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// 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 +
+
+ ); + })} +
+ + +
+
+
+
+ ); +} 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 # ══════════════════════════════════════════════════════════════════════