Add firewall policy matrix, service proxy, ntfy alerts, and scheduler
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
This commit is contained in:
@@ -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
|
||||
|
||||
+698
-10
@@ -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" && <FirewallTab
|
||||
vlans={vlans}
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="services" && <ServicesTab
|
||||
vlans={vlans}
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="alerts" && <AlertsTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="vpn" && <WireGuardTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
@@ -4748,3 +4768,671 @@ function BackupTab({ session, onNeedAuth, backendOk }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Inter-VLAN Policy Matrix</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||
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).
|
||||
</div>
|
||||
|
||||
{nonMgmt.length > 1 ? (
|
||||
<div style={{overflowX:"auto"}}>
|
||||
<table style={{borderCollapse:"collapse",fontSize:12,width:"100%"}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{padding:8,textAlign:"left",borderBottom:"1px solid var(--b2)",color:"var(--dm)"}}>
|
||||
From \ To
|
||||
</th>
|
||||
{nonMgmt.map(v => (
|
||||
<th key={v.id} style={{padding:8,textAlign:"center",borderBottom:"1px solid var(--b2)",
|
||||
color:v.color,fontWeight:700,minWidth:80}}>
|
||||
{v.name}<br/><span style={{fontSize:10,color:"var(--dm)"}}>V{v.id}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nonMgmt.map(src => (
|
||||
<tr key={src.id}>
|
||||
<td style={{padding:8,fontWeight:700,color:src.color,borderBottom:"1px solid var(--b1)"}}>
|
||||
{src.name} <span style={{fontSize:10,color:"var(--dm)"}}>V{src.id}</span>
|
||||
</td>
|
||||
{nonMgmt.map(dst => {
|
||||
if (src.id === dst.id) return (
|
||||
<td key={dst.id} style={{padding:8,textAlign:"center",background:"var(--b1)",
|
||||
borderBottom:"1px solid var(--b1)",color:"var(--dm)",fontSize:10}}>—</td>
|
||||
);
|
||||
const p = getPolicy(src.id, dst.id);
|
||||
return (
|
||||
<td key={dst.id} style={{
|
||||
padding:4,textAlign:"center",borderBottom:"1px solid var(--b1)",
|
||||
cursor:"pointer",
|
||||
}} onClick={() => {
|
||||
setForm(f => ({...f, src_vlan: String(src.id), dst_vlan: String(dst.id)}));
|
||||
setPreview(null); setResult(null);
|
||||
}}>
|
||||
<div style={{
|
||||
padding:"6px 4px",borderRadius:4,fontSize:10,fontWeight:600,
|
||||
background: p ? policyColor(p.type) + "20" : "var(--bg)",
|
||||
border: `1px solid ${p ? policyColor(p.type) + "40" : "var(--b2)"}`,
|
||||
color: p ? policyColor(p.type) : "var(--dm)",
|
||||
}}>
|
||||
{p ? (presets[p.type]?.label || p.type) : "No policy"}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{color:"var(--dm)",fontSize:12,padding:16,textAlign:"center"}}>
|
||||
Create at least 2 non-management VLANs to use the policy matrix.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{display:"flex",gap:12,marginTop:12,flexWrap:"wrap"}}>
|
||||
{Object.entries(presets).map(([k,v]) => (
|
||||
<div key={k} style={{display:"flex",alignItems:"center",gap:4,fontSize:11}}>
|
||||
<span style={{width:10,height:10,borderRadius:2,background:policyColor(k),display:"inline-block"}}/>
|
||||
{v.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy Editor */}
|
||||
<div className="panel">
|
||||
<div className="ph">Set Policy</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 1fr",gap:12,alignItems:"flex-end"}}>
|
||||
<div className="field"><label>Source VLAN</label>
|
||||
<select value={form.src_vlan} onChange={e => { setForm(f => ({...f, src_vlan: e.target.value})); setPreview(null); }}>
|
||||
<option value="">Select...</option>
|
||||
{vlans.filter(v=>v.id!==99).map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Destination VLAN</label>
|
||||
<select value={form.dst_vlan} onChange={e => { setForm(f => ({...f, dst_vlan: e.target.value})); setPreview(null); }}>
|
||||
<option value="">Select...</option>
|
||||
{vlans.filter(v=>v.id!==99).map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Policy Type</label>
|
||||
<select value={form.type} onChange={e => setForm(f => ({...f, type: e.target.value}))}>
|
||||
{Object.entries(presets).map(([k,v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(form.type === "services" || form.type === "printer") && (
|
||||
<div className="field"><label>Ports (comma-separated)</label>
|
||||
<input value={form.ports} onChange={e => setForm(f => ({...f, ports: e.target.value}))}
|
||||
placeholder={form.type === "printer" ? "9100,631,443,515" : "80,443,8080"}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.type && presets[form.type] && (
|
||||
<div style={{marginTop:8,fontSize:12,color:"var(--dm)",fontStyle:"italic"}}>
|
||||
{presets[form.type].description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{display:"flex",gap:8,marginTop:16}}>
|
||||
<button className="btn bd" onClick={doPreview}
|
||||
disabled={!form.src_vlan || !form.dst_vlan}>
|
||||
Preview Commands
|
||||
</button>
|
||||
<button className="btn bp" onClick={pushPolicy}
|
||||
disabled={pushing || !form.src_vlan || !form.dst_vlan}>
|
||||
{pushing ? "Pushing..." : "Push Policy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && !preview.error && (
|
||||
<div style={{marginTop:12,padding:12,background:"var(--bg)",borderRadius:6,border:"1px solid var(--b2)"}}>
|
||||
<div style={{fontWeight:700,fontSize:12,marginBottom:8,color:"var(--ac)"}}>
|
||||
{preview.description}
|
||||
</div>
|
||||
<div style={{fontSize:11,fontWeight:600,color:"var(--tx)",marginBottom:4}}>Switch ACL Commands:</div>
|
||||
<pre style={{fontSize:11,color:"var(--dm)",margin:0,whiteSpace:"pre-wrap"}}>
|
||||
{preview.switch_cmds?.join("\n")}
|
||||
</pre>
|
||||
{preview.opnsense_rules?.length > 0 && <>
|
||||
<div style={{fontSize:11,fontWeight:600,color:"var(--tx)",marginTop:8,marginBottom:4}}>
|
||||
OPNsense Firewall Rules:
|
||||
</div>
|
||||
{preview.opnsense_rules.map((r,i) => (
|
||||
<div key={i} style={{fontSize:11,color:"var(--dm)"}}>
|
||||
{r.rule.action.toUpperCase()} {r.rule.descr}
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div style={{
|
||||
marginTop:12,padding:12,borderRadius:6,
|
||||
background: result.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
|
||||
border: `1px solid ${result.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:result.success?"#00e676":"#ff1744",marginBottom:4}}>
|
||||
{result.success ? "Policy Pushed" : "Push Failed"}
|
||||
</div>
|
||||
{result.steps_done?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--tx)"}}><span style={{color:"#00e676"}}>done </span>{s}</div>
|
||||
))}
|
||||
{result.errors?.map((e,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Policies List */}
|
||||
{policies.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="ph">Active Policies ({policies.length})</div>
|
||||
<div className="pb">
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>Source</th><th>Destination</th><th>Type</th><th>Pushed</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{policies.map((p,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:700}}>{vlans.find(v=>v.id===p.src_vlan)?.name || `V${p.src_vlan}`}</td>
|
||||
<td style={{fontWeight:700}}>{vlans.find(v=>v.id===p.dst_vlan)?.name || `V${p.dst_vlan}`}</td>
|
||||
<td><span style={{color:policyColor(p.type),fontWeight:600}}>{presets[p.type]?.label || p.type}</span></td>
|
||||
<td style={{fontSize:11,color:"var(--dm)"}}>{p.pushed_at || "not pushed"}</td>
|
||||
<td>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}} onClick={async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
await API("/firewall/policies", { method:"DELETE", body:{ token:session.token, src_vlan:p.src_vlan, dst_vlan:p.dst_vlan }});
|
||||
load();
|
||||
}}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Service Proxy</div>
|
||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
||||
Expose services running on your LAN to other VLANs without opening inter-VLAN access.
|
||||
Each service gets an FQDN (e.g. <span style={{color:"var(--ac)"}}>plex.home.lan</span>) 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.
|
||||
<div style={{marginTop:8,padding:8,background:"var(--bg)",borderRadius:4,fontFamily:"monospace",fontSize:11}}>
|
||||
Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Service Form */}
|
||||
<div className="panel">
|
||||
<div className="ph">Add Service</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
||||
<div className="field"><label>Service FQDN</label>
|
||||
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
|
||||
placeholder="plex.home.lan"/>
|
||||
</div>
|
||||
<div className="field"><label>Backend URL (actual server)</label>
|
||||
<input value={form.backend_url} onChange={e => setForm(f => ({...f, backend_url: e.target.value}))}
|
||||
placeholder="http://192.168.1.100:32400"/>
|
||||
</div>
|
||||
<div className="field"><label>Description</label>
|
||||
<input value={form.description} onChange={e => setForm(f => ({...f, description: e.target.value}))}
|
||||
placeholder="Plex Media Server"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:12}}>
|
||||
<div className="sect">Allowed VLANs (which VLANs can reach this service)</div>
|
||||
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||
{vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => (
|
||||
<label key={v.id} style={{
|
||||
display:"flex",alignItems:"center",gap:4,cursor:"pointer",
|
||||
padding:"4px 10px",borderRadius:4,fontSize:12,
|
||||
background: form.allowed_vlans.includes(v.id) ? v.color + "20" : "var(--bg)",
|
||||
border: `1px solid ${form.allowed_vlans.includes(v.id) ? v.color : "var(--b2)"}`,
|
||||
}}>
|
||||
<input type="checkbox" checked={form.allowed_vlans.includes(v.id)}
|
||||
onChange={() => toggleVlan(v.id)}/>
|
||||
<span style={{color: v.color, fontWeight:600}}>{v.name}</span>
|
||||
<span style={{color:"var(--dm)",fontSize:10}}>V{v.id}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
|
||||
style={{marginTop:12}}>
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service List */}
|
||||
{services.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="ph">Configured Services ({services.length})</div>
|
||||
<div className="pb">
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>FQDN</th><th>Backend</th><th>Description</th><th>VLANs</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{services.map((s,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
|
||||
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
||||
<td>{s.description || "—"}</td>
|
||||
<td style={{fontSize:11}}>
|
||||
{(s.allowed_vlans || []).map(vid => {
|
||||
const v = vlans.find(x => x.id === vid);
|
||||
return <span key={vid} style={{color:v?.color||"var(--dm)",marginRight:4}}>{v?.name||`V${vid}`}</span>;
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => removeService(s.fqdn)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
|
||||
<button className="btn bp" onClick={deploy} disabled={deploying} style={{padding:"10px 24px"}}>
|
||||
{deploying ? "Deploying..." : "Deploy All Services"}
|
||||
</button>
|
||||
<span style={{fontSize:11,color:"var(--dm)"}}>
|
||||
Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{deployResult && (
|
||||
<div style={{
|
||||
marginTop:12,padding:12,borderRadius:6,
|
||||
background: deployResult.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
|
||||
border: `1px solid ${deployResult.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:deployResult.success?"#00e676":"#ff1744",marginBottom:4}}>
|
||||
{deployResult.success ? "Deploy Complete" : "Deploy Had Errors"}
|
||||
</div>
|
||||
{deployResult.steps_done?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--tx)"}}><span style={{color:"#00e676"}}>done </span>{s}</div>
|
||||
))}
|
||||
{deployResult.errors?.map((e,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||
))}
|
||||
{deployResult.note && (
|
||||
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
|
||||
{deployResult.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
{/* ntfy Configuration */}
|
||||
<div className="panel">
|
||||
<div className="ph">Push Notifications (ntfy)</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||
Get push notifications on your phone/desktop when network events occur.
|
||||
Works with <span style={{color:"var(--ac)"}}>ntfy.sh</span> (free, no account needed)
|
||||
or a self-hosted ntfy server.
|
||||
</div>
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
||||
<div className="field"><label>ntfy Server URL</label>
|
||||
<input value={ntfyCfg.url} onChange={e => setNtfyCfg(c => ({...c, url: e.target.value}))}
|
||||
placeholder="https://ntfy.sh"/>
|
||||
</div>
|
||||
<div className="field"><label>Topic</label>
|
||||
<input value={ntfyCfg.topic} onChange={e => setNtfyCfg(c => ({...c, topic: e.target.value}))}
|
||||
placeholder="my-network-alerts"/>
|
||||
</div>
|
||||
<div className="field"><label>Access Token (optional)</label>
|
||||
<input value={ntfyToken} onChange={e => setNtfyToken(e.target.value)}
|
||||
type="password" placeholder={ntfyCfg.has_token ? "••••••• (saved)" : "for private topics"}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:12}}>
|
||||
<div className="sect">Alert Events</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:6}}>
|
||||
{Object.entries(eventLabels).map(([k,label]) => (
|
||||
<label key={k} style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:12}}>
|
||||
<input type="checkbox" checked={ntfyCfg.events?.[k] || false}
|
||||
onChange={() => toggleEvent(k)}/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:12}}>
|
||||
<label style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:13,fontWeight:600}}>
|
||||
<input type="checkbox" checked={ntfyCfg.enabled}
|
||||
onChange={e => setNtfyCfg(c => ({...c, enabled: e.target.checked}))}/>
|
||||
Enable notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{display:"flex",gap:8,marginTop:16}}>
|
||||
<button className="btn bp" onClick={saveNtfy} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Configuration"}
|
||||
</button>
|
||||
<button className="btn bd" onClick={testNtfy} disabled={testing || !ntfyCfg.topic}>
|
||||
{testing ? "Sending..." : "Send Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Operations */}
|
||||
<div className="panel">
|
||||
<div className="ph">Scheduled Operations</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||
Schedule recurring tasks like automatic backups or connectivity checks.
|
||||
Tasks run in the background and send ntfy alerts on failure (if configured).
|
||||
</div>
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 80px 80px 1fr",gap:12,alignItems:"flex-end"}}>
|
||||
<div className="field"><label>Name</label>
|
||||
<input value={schedForm.name} onChange={e => setSchedForm(f => ({...f, name: e.target.value}))}
|
||||
placeholder="nightly-backup"/>
|
||||
</div>
|
||||
<div className="field"><label>Action</label>
|
||||
<select value={schedForm.action} onChange={e => setSchedForm(f => ({...f, action: e.target.value}))}>
|
||||
<option value="backup">Backup</option>
|
||||
<option value="connectivity_check">Connectivity Check</option>
|
||||
</select>
|
||||
</div>
|
||||
{schedForm.action === "backup" && (
|
||||
<div className="field"><label>Device</label>
|
||||
<select value={schedForm.device} onChange={e => setSchedForm(f => ({...f, device: e.target.value}))}>
|
||||
<option value="both">Both</option>
|
||||
<option value="switch">Switch only</option>
|
||||
<option value="opnsense">OPNsense only</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field"><label>Hour</label>
|
||||
<input value={schedForm.hour} onChange={e => setSchedForm(f => ({...f, hour: e.target.value}))}
|
||||
placeholder="3" style={{textAlign:"center"}}/>
|
||||
</div>
|
||||
<div className="field"><label>Minute</label>
|
||||
<input value={schedForm.minute} onChange={e => setSchedForm(f => ({...f, minute: e.target.value}))}
|
||||
placeholder="0" style={{textAlign:"center"}}/>
|
||||
</div>
|
||||
<div className="field"><label>Days (* = every day)</label>
|
||||
<input value={schedForm.days} onChange={e => setSchedForm(f => ({...f, days: e.target.value}))}
|
||||
placeholder="mon,wed,fri or *"/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn bp" onClick={addSchedule} disabled={!schedForm.name}
|
||||
style={{marginTop:12}}>Add Schedule</button>
|
||||
|
||||
{schedules.length > 0 && (
|
||||
<div style={{marginTop:16}}>
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>Name</th><th>Action</th><th>Time</th><th>Days</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{schedules.map((s,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:700}}>{s.name}</td>
|
||||
<td>{s.action}{s.device ? ` (${s.device})` : ""}</td>
|
||||
<td style={{fontFamily:"monospace"}}>{s.hour || "*"}:{(s.minute || "0").padStart(2,"0")}</td>
|
||||
<td style={{fontSize:11}}>{s.days || "*"}</td>
|
||||
<td><span style={{color:s.enabled!==false?"#00e676":"var(--dm)",fontSize:11}}>
|
||||
{s.enabled!==false?"active":"disabled"}</span></td>
|
||||
<td style={{display:"flex",gap:4}}>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => runNow(s.name)}>Run Now</button>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => deleteSchedule(s.name)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user