From 7928f6f769bf706e49e5ea6dfc8a1d92b1c048f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:55:29 +0000 Subject: [PATCH] Add firewall policy matrix, service proxy, ntfy alerts, and scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firewall inter-VLAN policy matrix: - Visual VLAN-to-VLAN matrix with click-to-set policies - Policy types: block, allow, one-way, printer, service-ports - Printer template: other VLANs reach ports 9100/631/443/515, printers cannot initiate back — solves the "printer VLAN" use case - Generates both switch ACLs AND OPNsense firewall rules - Preview commands before pushing, auto-backup before changes Service proxy (LAN services via FQDN without inter-VLAN access): - Register services with FQDN + backend URL + allowed VLANs - Deploy generates Caddyfile entries, Unbound DNS overrides, and firewall rules allowing only port 443 to the proxy - Pattern: device on VLAN 30 → DNS resolves to mgmt box → Caddy proxies to actual LAN server — no VLAN-to-VLAN access needed ntfy push notifications: - Configure ntfy.sh or self-hosted ntfy server - Alert events: connectivity lost/restored, PoE budget >85%, backup failures, push failures - Integrated into poll loop — alerts fire on state transitions - Test notification button Scheduled operations: - Cron-like scheduler for automated backups and connectivity checks - Background thread checks every 60 seconds - Per-schedule: name, action, hour, minute, days (mon,wed,fri or *) - Run-now button for manual trigger - ntfy notifications on scheduled task completion/failure https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- Caddyfile.template | 4 + ers5952-manager.jsx | 708 ++++++++++++++++++++++++++++++++++++++- switch_backend.py | 793 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1495 insertions(+), 10 deletions(-) diff --git a/Caddyfile.template b/Caddyfile.template index 8869cf9..952e1a5 100644 --- a/Caddyfile.template +++ b/Caddyfile.template @@ -8,3 +8,7 @@ :80 {{ redir https://{{host}}{{uri}} permanent }} + +# Service proxies — auto-generated by switch-manager +# To include service proxy entries, add this line (uncommented) after deployment: +# import /etc/switch-manager/Caddyfile.services diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index aa4e09f..7638ff2 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -1525,16 +1525,19 @@ export default function App() { const TABS = [ { id:"dashboard",label:"Dashboard" }, - { id:"network", label:"Network" }, - { id:"ports", label:"Port Map" }, - { id:"vlans", label:"VLANs" }, - { id:"acls", label:"ACL Builder" }, - { id:"cli", label:"Review & Push" }, - { id:"devices", label:"Device Access" }, - { id:"dhcp", label:"DHCP" }, - { id:"dns", label:"DNS Filtering" }, - { id:"vpn", label:"VPN" }, - { id:"backups", label:"Backups" }, + { id:"network", label:"Network" }, + { id:"firewall", label:"Firewall" }, + { id:"services", label:"Services" }, + { 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:"backups", label:"Backups" }, + { id:"alerts", label:"Alerts" }, ]; return ( @@ -1607,6 +1610,23 @@ export default function App() { onNeedAuth={() => 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)} @@ -4748,3 +4768,671 @@ function BackupTab({ session, onNeedAuth, backendOk }) { ); } + + +// ══════════════════════════════════════════════════════════════════════════════ +// 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: "", allowed_vlans: [] }); + const [deploying, setDeploying] = useState(false); + const [deployResult, setDeployResult] = useState(null); + + const load = async () => { + try { + const d = await API("/services"); + setServices(d.services || []); + } 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: "", allowed_vlans: [] }); + 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 deploy = async () => { + if (!session) { onNeedAuth(); return; } + setDeploying(true); setDeployResult(null); + try { + const r = await API("/services/deploy", { method: "POST", body: { token: session.token } }); + setDeployResult(r); + } catch(e) { setDeployResult({ success: false, errors: [e.message] }); } + setDeploying(false); + }; + + const toggleVlan = (vid) => { + setForm(f => ({ + ...f, + allowed_vlans: f.allowed_vlans.includes(vid) + ? f.allowed_vlans.filter(v => v !== vid) + : [...f.allowed_vlans, vid], + })); + }; + + return ( +
+
+
+
Service Proxy
+
+ Expose services running on your LAN to other VLANs without opening inter-VLAN access. + Each service gets an FQDN (e.g. plex.home.lan) that + resolves to the management box. Caddy reverse-proxies the request to the actual server. + Only port 443 is opened — no direct VLAN-to-VLAN access needed. +
+ Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400 +
+
+
+ + {/* Add Service Form */} +
+
Add Service
+
+
+
+ setForm(f => ({...f, fqdn: e.target.value}))} + placeholder="plex.home.lan"/> +
+
+ 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"/> +
+
+ +
+
Allowed VLANs (which VLANs can reach this service)
+
+ {vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => ( + + ))} +
+
+ + +
+
+ + {/* Service List */} + {services.length > 0 && ( +
+
Configured Services ({services.length})
+
+ + + + {services.map((s,i) => ( + + + + + + + + ))} + +
FQDNBackendDescriptionVLANs
{s.fqdn}{s.backend_url}{s.description || "—"} + {(s.allowed_vlans || []).map(vid => { + const v = vlans.find(x => x.id === vid); + return {v?.name||`V${vid}`}; + })} + + +
+ +
+ + + Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules + +
+ + {deployResult && ( +
+
+ {deployResult.success ? "Deploy Complete" : "Deploy Had Errors"} +
+ {deployResult.steps_done?.map((s,i) => ( +
done {s}
+ ))} + {deployResult.errors?.map((e,i) => ( +
error {e}
+ ))} + {deployResult.note && ( +
+ {deployResult.note} +
+ )} +
+ )} +
+
+ )} +
+
+ ); +} + + +// ══════════════════════════════════════════════════════════════════════════════ +// 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]) => ( + + ))} +
+
+ +
+ +
+ +
+ + +
+
+
+ + {/* Scheduled Operations */} +
+
Scheduled Operations
+
+
+ Schedule recurring tasks like automatic backups or connectivity checks. + Tasks run in the background and send ntfy alerts on failure (if configured). +
+ +
+
+ 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"} + + +
+
+ )} +
+
+
+
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index 5d90f31..bb27b86 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) @@ -4141,3 +4147,790 @@ def push_safe(body: PushBatch): 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 PROXY — expose LAN services to other VLANs via Caddy + DNS +# ══════════════════════════════════════════════════════════════════════ + +SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json") +CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services") + +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 service reverse proxies.""" + blocks = ["# Auto-generated by switch-manager — do not edit manually\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" tls internal") + blocks.append(f"}}\n") + return "\n".join(blocks) + + +def _generate_unbound_overrides(services: list, mgmt_ip: str) -> str: + """Generate Unbound local-data lines for service FQDN → management box IP.""" + lines = ["# Auto-generated by switch-manager\n"] + for svc in services: + fqdn = svc.get("fqdn", "") + target_ip = svc.get("proxy_ip", mgmt_ip) + if fqdn: + lines.append(f'local-data: "{fqdn}. IN A {target_ip}"') + return "\n".join(lines) + + +@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.""" + 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.""" + 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} + + +@app.post("/api/services/deploy") +def deploy_services(body: dict): + """ + Deploy service proxies: write Caddyfile, push DNS overrides to Unbound, + add firewall rules to allow other VLANs to reach the proxy. + """ + require_session(body.get("token", "")) + services = _load_services() + if not services: + raise HTTPException(400, "No services configured") + + steps_done = [] + errors = [] + + # Determine management box IP + import socket as _sock + try: + mgmt_ip = _sock.gethostbyname(_sock.gethostname()) + except Exception: + mgmt_ip = SWITCH_HOST.rsplit('.', 1)[0] + '.50' + + backup = _pre_change_backup(reason="pre-service-proxy deploy") + + # 1. Write Caddyfile.services + caddy_content = _generate_caddyfile_services(services) + try: + CADDYFILE_EXTRA.write_text(caddy_content) + steps_done.append(f"Wrote {CADDYFILE_EXTRA} ({len(services)} services)") + except Exception as e: + errors.append(f"Caddyfile write: {e}") + + # 2. Push DNS overrides to OPNsense Unbound + cfg = _load_opnsense_cfg() + if cfg.get("ssh_key_path"): + dns_content = _generate_unbound_overrides(services, mgmt_ip) + try: + _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/service-proxies.conf", dns_content) + steps_done.append(f"Wrote Unbound overrides: {len(services)} service FQDNs → {mgmt_ip}") + except Exception as e: + errors.append(f"Unbound DNS write: {e}") + + # Validate and reload Unbound + out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1") + if code != 0: + errors.append(f"unbound-checkconf failed: {err or out}") + else: + _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") + steps_done.append("Unbound reloaded with service DNS overrides") + else: + errors.append("OPNsense SSH not configured — DNS overrides not deployed. " + "Add service FQDNs to your DNS manually.") + + # 3. Add firewall rules: allow each VLAN to reach mgmt_ip on 443 + if cfg.get("key"): + allowed_vlans = set() + for svc in services: + for vid in svc.get("allowed_vlans", []): + allowed_vlans.add(vid) + vmap = _load_vlan_if_map() + for vid in allowed_vlans: + iface = vmap.get(str(vid), "") + if not iface: + errors.append(f"VLAN {vid}: no OPNsense interface mapped — skip firewall rule") + continue + try: + _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", "action": "pass", + "interface": iface, "direction": "in", + "ipprotocol": "inet", "protocol": "tcp", + "source": {"network": f"{iface}net"}, + "destination": {"address": mgmt_ip, "port": "443"}, + "descr": f"VLAN {vid} → service proxy ({mgmt_ip}:443)", + } + }) + steps_done.append(f"Firewall: VLAN {vid} → {mgmt_ip}:443 allowed") + except ValueError as e: + errors.append(f"Firewall VLAN {vid}: {e}") + if allowed_vlans: + try: + _opnsense_request(cfg, "firewall/filter/apply", "POST") + except ValueError as e: + errors.append(f"Firewall apply: {e}") + + return { + "success": len(errors) == 0, + "steps_done": steps_done, + "errors": errors, + "backup": backup, + "caddy_content": caddy_content, + "mgmt_ip": mgmt_ip, + "note": "Restart Caddy to pick up new Caddyfile.services: " + "docker compose restart caddy (or systemctl restart caddy)", + } + + +# ══════════════════════════════════════════════════════════════════════ +# 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") + + 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") + + +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}