Fix service proxy architecture and add VLAN time-based schedules
Service proxy fix: - DNS now resolves service FQDNs to OPNsense gateway IP (not mgmt box) - Devices reach services through their own gateway — never touch other VLANs. Full VLAN isolation preserved. - No new firewall rules needed — devices can already reach their gateway - Deploy tries Caddy on OPNsense first, then HAProxy plugin, then gives manual setup instructions - Removed "allowed VLANs" selector — all VLANs can reach services automatically through the gateway reverse proxy VLAN time-based schedules: - New vlan_enable/vlan_disable scheduler actions - Creates/removes OPNsense firewall allow-outbound rules on schedule - Switch ports stay up so devices reconnect when re-enabled - Tracked rule UUIDs for clean enable/disable cycles - VlanScheduleWizard UI component with paired off/on times - Quick presets: Guest WiFi midnight-6am, Business 6pm-8am weekdays, Kids 9pm-7am, IoT 11pm-5am - ntfy notifications on VLAN enable/disable events https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
+161
-51
@@ -5040,7 +5040,7 @@ function FirewallTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
|
|
||||||
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||||
const [services, setServices] = useState([]);
|
const [services, setServices] = useState([]);
|
||||||
const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] });
|
const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" });
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [deployResult, setDeployResult] = useState(null);
|
const [deployResult, setDeployResult] = useState(null);
|
||||||
|
|
||||||
@@ -5057,7 +5057,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
if (!form.fqdn || !form.backend_url) return;
|
if (!form.fqdn || !form.backend_url) return;
|
||||||
try {
|
try {
|
||||||
await API("/services", { method: "POST", body: { token: session.token, service: form } });
|
await API("/services", { method: "POST", body: { token: session.token, service: form } });
|
||||||
setForm({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] });
|
setForm({ fqdn: "", backend_url: "", description: "" });
|
||||||
await load();
|
await load();
|
||||||
} catch(e) { alert("Save failed: " + e.message); }
|
} catch(e) { alert("Save failed: " + e.message); }
|
||||||
};
|
};
|
||||||
@@ -5078,27 +5078,31 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
setDeploying(false);
|
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 (
|
return (
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="ph">Service Proxy</div>
|
<div className="ph">Service Proxy — FQDN Access Without Breaking VLAN Isolation</div>
|
||||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
<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.
|
<div style={{marginBottom:8}}>
|
||||||
Each service gets an FQDN (e.g. <span style={{color:"var(--ac)"}}>plex.home.lan</span>) that
|
Make LAN services reachable by FQDN from any VLAN <b style={{color:"var(--tx)"}}>without
|
||||||
resolves to the management box. Caddy reverse-proxies the request to the actual server.
|
any inter-VLAN access</b>. Devices never touch the service's VLAN directly.
|
||||||
Only port 443 is opened — no direct VLAN-to-VLAN access needed.
|
</div>
|
||||||
<div style={{marginTop:8,padding:8,background:"var(--bg)",borderRadius:4,fontFamily:"monospace",fontSize:11}}>
|
<div style={{
|
||||||
Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400
|
padding:12,background:"var(--bg)",borderRadius:6,border:"1px solid var(--b2)",
|
||||||
|
fontFamily:"monospace",fontSize:11,lineHeight:2,
|
||||||
|
}}>
|
||||||
|
<div style={{color:"var(--ac)",fontWeight:700,marginBottom:4,fontFamily:"inherit",fontSize:12}}>
|
||||||
|
How it works:
|
||||||
|
</div>
|
||||||
|
<div>1. IoT device (VLAN 30) asks DNS for <span style={{color:"var(--ac)"}}>plex.home.lan</span></div>
|
||||||
|
<div>2. Unbound returns <span style={{color:"#00e676"}}>192.168.30.1</span> (OPNsense gateway — device can already reach this)</div>
|
||||||
|
<div>3. OPNsense reverse proxy (Caddy/HAProxy) forwards to actual server <span style={{color:"#ff6d00"}}>192.168.1.100:32400</span></div>
|
||||||
|
<div>4. Response returns the same path. <span style={{color:"#00e676"}}>IoT device never sees or touches LAN.</span></div>
|
||||||
|
</div>
|
||||||
|
<div style={{marginTop:8,color:"var(--ac)",fontWeight:600}}>
|
||||||
|
No firewall rules needed. No VLAN-to-VLAN access opened. All VLANs can already reach
|
||||||
|
their own gateway — that's how they get internet. The gateway does the proxying.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5112,7 +5116,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
|
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
|
||||||
placeholder="plex.home.lan"/>
|
placeholder="plex.home.lan"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>Backend URL (actual server)</label>
|
<div className="field"><label>Backend URL (actual server on LAN)</label>
|
||||||
<input value={form.backend_url} onChange={e => setForm(f => ({...f, backend_url: e.target.value}))}
|
<input value={form.backend_url} onChange={e => setForm(f => ({...f, backend_url: e.target.value}))}
|
||||||
placeholder="http://192.168.1.100:32400"/>
|
placeholder="http://192.168.1.100:32400"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -5122,29 +5126,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
|
||||||
style={{marginTop:12}}>
|
style={{marginTop:12}}>
|
||||||
Add Service
|
Add Service
|
||||||
</button>
|
</button>
|
||||||
|
<div style={{marginTop:6,fontSize:11,color:"var(--dm)"}}>
|
||||||
|
All VLANs can reach this service automatically (via their gateway). No per-VLAN selection needed.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5154,19 +5142,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
<div className="ph">Configured Services ({services.length})</div>
|
<div className="ph">Configured Services ({services.length})</div>
|
||||||
<div className="pb">
|
<div className="pb">
|
||||||
<table className="vtbl">
|
<table className="vtbl">
|
||||||
<thead><tr><th>FQDN</th><th>Backend</th><th>Description</th><th>VLANs</th><th></th></tr></thead>
|
<thead><tr><th>FQDN</th><th>Backend</th><th>Description</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{services.map((s,i) => (
|
{services.map((s,i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td style={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
|
<td style={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
|
||||||
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
||||||
<td>{s.description || "—"}</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>
|
<td>
|
||||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||||
onClick={() => removeService(s.fqdn)}>Remove</button>
|
onClick={() => removeService(s.fqdn)}>Remove</button>
|
||||||
@@ -5181,7 +5163,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
{deploying ? "Deploying..." : "Deploy All Services"}
|
{deploying ? "Deploying..." : "Deploy All Services"}
|
||||||
</button>
|
</button>
|
||||||
<span style={{fontSize:11,color:"var(--dm)"}}>
|
<span style={{fontSize:11,color:"var(--dm)"}}>
|
||||||
Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules
|
Pushes DNS overrides to Unbound + configures reverse proxy on OPNsense
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5200,9 +5182,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
{deployResult.errors?.map((e,i) => (
|
{deployResult.errors?.map((e,i) => (
|
||||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||||
))}
|
))}
|
||||||
{deployResult.note && (
|
{deployResult.pending_steps?.map((s,i) => (
|
||||||
|
<div key={i} style={{fontSize:12,color:"var(--warn, #ffea00)"}}><span>manual </span>{s}</div>
|
||||||
|
))}
|
||||||
|
{deployResult.architecture && (
|
||||||
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
|
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
|
||||||
{deployResult.note}
|
{deployResult.architecture}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -5360,13 +5345,28 @@ function AlertsTab({ session, onNeedAuth, backendOk }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scheduled Operations */}
|
{/* VLAN Schedules */}
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="ph">Scheduled Operations</div>
|
<div className="ph">VLAN Schedules — Time-Based Access Control</div>
|
||||||
|
<div className="pb">
|
||||||
|
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||||
|
Schedule VLANs to enable/disable internet access at specific times.
|
||||||
|
Example: Guest WiFi off midnight–6am, Business VLAN off after hours.
|
||||||
|
This works by adding/removing OPNsense firewall allow-outbound rules on schedule.
|
||||||
|
Switch ports stay up — devices just lose internet, so they reconnect automatically when re-enabled.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VlanScheduleWizard vlans={vlans} session={session} onNeedAuth={onNeedAuth}
|
||||||
|
onSaved={loadSchedules} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* General Scheduled Operations */}
|
||||||
|
<div className="panel">
|
||||||
|
<div className="ph">General Scheduled Operations</div>
|
||||||
<div className="pb">
|
<div className="pb">
|
||||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||||
Schedule recurring tasks like automatic backups or connectivity checks.
|
Schedule recurring tasks like automatic backups or connectivity checks.
|
||||||
Tasks run in the background and send ntfy alerts on failure (if configured).
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 80px 80px 1fr",gap:12,alignItems:"flex-end"}}>
|
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 80px 80px 1fr",gap:12,alignItems:"flex-end"}}>
|
||||||
@@ -5436,3 +5436,113 @@ function AlertsTab({ session, onNeedAuth, backendOk }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── VLAN Schedule Wizard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VlanScheduleWizard({ vlans, session, onNeedAuth, onSaved }) {
|
||||||
|
const [vlanId, setVlanId] = useState("");
|
||||||
|
const [offHour, setOffHour] = useState("0");
|
||||||
|
const [offMin, setOffMin] = useState("0");
|
||||||
|
const [onHour, setOnHour] = useState("6");
|
||||||
|
const [onMin, setOnMin] = useState("0");
|
||||||
|
const [days, setDays] = useState("*");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1);
|
||||||
|
const vlanName = vlans.find(v => v.id === parseInt(vlanId))?.name || "";
|
||||||
|
|
||||||
|
const createPair = async () => {
|
||||||
|
if (!session) { onNeedAuth(); return; }
|
||||||
|
if (!vlanId) return;
|
||||||
|
setSaving(true);
|
||||||
|
const vid = parseInt(vlanId);
|
||||||
|
const vname = vlanName || `VLAN ${vid}`;
|
||||||
|
try {
|
||||||
|
await API("/schedules", { method: "POST", body: {
|
||||||
|
token: session.token,
|
||||||
|
schedule: {
|
||||||
|
name: `${vname}-off`, action: "vlan_disable",
|
||||||
|
vlan_id: vid, vlan_name: vname,
|
||||||
|
hour: offHour, minute: offMin, days, enabled: true,
|
||||||
|
}
|
||||||
|
}});
|
||||||
|
await API("/schedules", { method: "POST", body: {
|
||||||
|
token: session.token,
|
||||||
|
schedule: {
|
||||||
|
name: `${vname}-on`, action: "vlan_enable",
|
||||||
|
vlan_id: vid, vlan_name: vname,
|
||||||
|
hour: onHour, minute: onMin, days, enabled: true,
|
||||||
|
}
|
||||||
|
}});
|
||||||
|
if (onSaved) onSaved();
|
||||||
|
} catch(e) { alert("Failed: " + e.message); }
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ label: "Guest WiFi: off midnight-6am", off: "0:00", on: "6:00", days: "*" },
|
||||||
|
{ label: "Business: off 6pm-8am weekdays", off: "18:00", on: "8:00", days: "mon,tue,wed,thu,fri" },
|
||||||
|
{ label: "Kids: off 9pm-7am", off: "21:00", on: "7:00", days: "*" },
|
||||||
|
{ label: "IoT: off 11pm-5am", off: "23:00", on: "5:00", days: "*" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const applyPreset = (p) => {
|
||||||
|
const [oh, om] = p.off.split(":");
|
||||||
|
const [nh, nm] = p.on.split(":");
|
||||||
|
setOffHour(oh); setOffMin(om); setOnHour(nh); setOnMin(nm); setDays(p.days);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{marginBottom:12}}>
|
||||||
|
<div className="sect">Quick Presets</div>
|
||||||
|
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||||
|
{presets.map((p,i) => (
|
||||||
|
<button key={i} className="btn bd" style={{fontSize:11,padding:"4px 10px"}}
|
||||||
|
onClick={() => applyPreset(p)}>{p.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:"grid",gridTemplateColumns:"1fr 100px 100px 100px 100px 1fr",gap:12,alignItems:"flex-end"}}>
|
||||||
|
<div className="field"><label>VLAN</label>
|
||||||
|
<select value={vlanId} onChange={e => setVlanId(e.target.value)}>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{nonMgmt.map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>Off at</label>
|
||||||
|
<div style={{display:"flex",gap:2,alignItems:"center"}}>
|
||||||
|
<input value={offHour} onChange={e => setOffHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="0"/>
|
||||||
|
<span style={{color:"var(--dm)"}}>:</span>
|
||||||
|
<input value={offMin} onChange={e => setOffMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field"><label>On at</label>
|
||||||
|
<div style={{display:"flex",gap:2,alignItems:"center"}}>
|
||||||
|
<input value={onHour} onChange={e => setOnHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="6"/>
|
||||||
|
<span style={{color:"var(--dm)"}}>:</span>
|
||||||
|
<input value={onMin} onChange={e => setOnMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{gridColumn:"span 2"}}><label>Days</label>
|
||||||
|
<input value={days} onChange={e => setDays(e.target.value)} placeholder="* or mon,tue,wed"/>
|
||||||
|
</div>
|
||||||
|
<div style={{display:"flex",alignItems:"flex-end"}}>
|
||||||
|
<button className="btn bp" onClick={createPair} disabled={saving || !vlanId}>
|
||||||
|
{saving ? "Creating..." : "Create Schedule"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{vlanId && (
|
||||||
|
<div style={{marginTop:12,padding:10,background:"var(--b1)",borderRadius:6,fontSize:12,lineHeight:1.8}}>
|
||||||
|
<span style={{color:"var(--ac)",fontWeight:700}}>{vlanName || `VLAN ${vlanId}`}: </span>
|
||||||
|
Internet disabled at {offHour}:{(offMin||"0").padStart(2,"0")},
|
||||||
|
re-enabled at {onHour}:{(onMin||"0").padStart(2,"0")}
|
||||||
|
{days === "*" ? " every day" : ` on ${days}`}.
|
||||||
|
<span style={{color:"var(--dm)"}}> Switch ports stay up — devices just lose internet access.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+207
-49
@@ -4492,7 +4492,13 @@ def _save_services(services: list):
|
|||||||
|
|
||||||
|
|
||||||
def _generate_caddyfile_services(services: list) -> str:
|
def _generate_caddyfile_services(services: list) -> str:
|
||||||
"""Generate Caddyfile blocks for service reverse proxies."""
|
"""Generate Caddyfile blocks for service reverse proxies.
|
||||||
|
|
||||||
|
Caddy runs on OPNsense (or the management box). Each service FQDN
|
||||||
|
gets a reverse_proxy block pointing to the actual backend server.
|
||||||
|
Devices on isolated VLANs never touch the backend directly — they
|
||||||
|
hit their own gateway IP which Caddy proxies through.
|
||||||
|
"""
|
||||||
blocks = ["# Auto-generated by switch-manager — do not edit manually\n"]
|
blocks = ["# Auto-generated by switch-manager — do not edit manually\n"]
|
||||||
for svc in services:
|
for svc in services:
|
||||||
fqdn = svc.get("fqdn", "")
|
fqdn = svc.get("fqdn", "")
|
||||||
@@ -4506,17 +4512,53 @@ def _generate_caddyfile_services(services: list) -> str:
|
|||||||
return "\n".join(blocks)
|
return "\n".join(blocks)
|
||||||
|
|
||||||
|
|
||||||
def _generate_unbound_overrides(services: list, mgmt_ip: str) -> str:
|
def _generate_unbound_overrides(services: list, opnsense_ip: str) -> str:
|
||||||
"""Generate Unbound local-data lines for service FQDN → management box IP."""
|
"""Generate Unbound local-data lines for service FQDN → OPNsense IP.
|
||||||
lines = ["# Auto-generated by switch-manager\n"]
|
|
||||||
|
DNS resolves every service FQDN to the OPNsense router IP. Since
|
||||||
|
OPNsense is already the gateway for every VLAN, devices can reach
|
||||||
|
it without any new firewall rules. OPNsense runs the reverse proxy
|
||||||
|
(Caddy/HAProxy) which forwards to the actual backend server.
|
||||||
|
|
||||||
|
This means: VLAN isolation is fully preserved. An IoT device on
|
||||||
|
VLAN 30 hits plex.home.lan → DNS says 192.168.30.1 (its gateway)
|
||||||
|
→ OPNsense proxies to the actual Plex server on LAN. The IoT
|
||||||
|
device never sees or reaches the LAN subnet.
|
||||||
|
"""
|
||||||
|
lines = ["# Auto-generated by switch-manager — service proxy DNS\n",
|
||||||
|
"# Each FQDN resolves to OPNsense gateway IP.\n",
|
||||||
|
"# Devices reach services via their own gateway (reverse proxy),\n",
|
||||||
|
"# never touching other VLANs directly.\n"]
|
||||||
for svc in services:
|
for svc in services:
|
||||||
fqdn = svc.get("fqdn", "")
|
fqdn = svc.get("fqdn", "")
|
||||||
target_ip = svc.get("proxy_ip", mgmt_ip)
|
# Use the OPNsense IP — it's the gateway for every VLAN
|
||||||
|
target_ip = svc.get("proxy_ip", opnsense_ip)
|
||||||
if fqdn:
|
if fqdn:
|
||||||
lines.append(f'local-data: "{fqdn}. IN A {target_ip}"')
|
lines.append(f'local-data: "{fqdn}. IN A {target_ip}"')
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_haproxy_cfg(services: list) -> str:
|
||||||
|
"""Generate OPNsense HAProxy backend/server entries for service proxies.
|
||||||
|
|
||||||
|
If OPNsense has the os-haproxy plugin, we can configure it via API.
|
||||||
|
This is a fallback config for manual import if the API isn't available.
|
||||||
|
"""
|
||||||
|
lines = ["# HAProxy service proxy backends — import into OPNsense HAProxy plugin\n"]
|
||||||
|
for svc in services:
|
||||||
|
fqdn = svc.get("fqdn", "")
|
||||||
|
backend_url = svc.get("backend_url", "")
|
||||||
|
if not fqdn or not backend_url:
|
||||||
|
continue
|
||||||
|
# Parse backend URL
|
||||||
|
host_port = backend_url.replace("http://", "").replace("https://", "")
|
||||||
|
lines.append(f"# {svc.get('description', fqdn)}")
|
||||||
|
lines.append(f"# Frontend SNI match: {fqdn}")
|
||||||
|
lines.append(f"# Backend: {host_port}")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/services")
|
@app.get("/api/services")
|
||||||
def get_services():
|
def get_services():
|
||||||
"""List configured service proxies."""
|
"""List configured service proxies."""
|
||||||
@@ -4552,8 +4594,19 @@ def delete_service(body: dict):
|
|||||||
@app.post("/api/services/deploy")
|
@app.post("/api/services/deploy")
|
||||||
def deploy_services(body: dict):
|
def deploy_services(body: dict):
|
||||||
"""
|
"""
|
||||||
Deploy service proxies: write Caddyfile, push DNS overrides to Unbound,
|
Deploy service proxies via OPNsense — preserves full VLAN isolation.
|
||||||
add firewall rules to allow other VLANs to reach the proxy.
|
|
||||||
|
Architecture:
|
||||||
|
1. DNS (Unbound on OPNsense) resolves service FQDNs to the OPNsense
|
||||||
|
router IP. Since OPNsense is the gateway for every VLAN, devices
|
||||||
|
can already reach it — no new firewall rules needed.
|
||||||
|
2. Reverse proxy (Caddy or HAProxy on OPNsense) accepts the request
|
||||||
|
and proxies it to the actual backend server on whatever VLAN it
|
||||||
|
lives on. OPNsense can route between VLANs — it's the router.
|
||||||
|
3. The requesting device (e.g. IoT on VLAN 30) never sees or touches
|
||||||
|
the backend's VLAN. It only talks to its own gateway.
|
||||||
|
|
||||||
|
No inter-VLAN firewall rules are created. VLAN isolation stays intact.
|
||||||
"""
|
"""
|
||||||
require_session(body.get("token", ""))
|
require_session(body.get("token", ""))
|
||||||
services = _load_services()
|
services = _load_services()
|
||||||
@@ -4562,17 +4615,14 @@ def deploy_services(body: dict):
|
|||||||
|
|
||||||
steps_done = []
|
steps_done = []
|
||||||
errors = []
|
errors = []
|
||||||
|
pending_steps = []
|
||||||
|
|
||||||
# Determine management box IP
|
cfg = _load_opnsense_cfg()
|
||||||
import socket as _sock
|
opnsense_ip = cfg.get("host", SWITCH_HOST.rsplit('.', 1)[0] + '.1')
|
||||||
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")
|
backup = _pre_change_backup(reason="pre-service-proxy deploy")
|
||||||
|
|
||||||
# 1. Write Caddyfile.services
|
# 1. Write Caddyfile.services (local copy for reference / mgmt-box proxy)
|
||||||
caddy_content = _generate_caddyfile_services(services)
|
caddy_content = _generate_caddyfile_services(services)
|
||||||
try:
|
try:
|
||||||
CADDYFILE_EXTRA.write_text(caddy_content)
|
CADDYFILE_EXTRA.write_text(caddy_content)
|
||||||
@@ -4581,12 +4631,13 @@ def deploy_services(body: dict):
|
|||||||
errors.append(f"Caddyfile write: {e}")
|
errors.append(f"Caddyfile write: {e}")
|
||||||
|
|
||||||
# 2. Push DNS overrides to OPNsense Unbound
|
# 2. Push DNS overrides to OPNsense Unbound
|
||||||
cfg = _load_opnsense_cfg()
|
# FQDNs resolve to OPNsense IP — devices already can reach their gateway
|
||||||
if cfg.get("ssh_key_path"):
|
if cfg.get("ssh_key_path"):
|
||||||
dns_content = _generate_unbound_overrides(services, mgmt_ip)
|
dns_content = _generate_unbound_overrides(services, opnsense_ip)
|
||||||
try:
|
try:
|
||||||
_opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/service-proxies.conf", dns_content)
|
_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}")
|
steps_done.append(
|
||||||
|
f"Wrote Unbound overrides: {len(services)} service FQDNs → {opnsense_ip} (gateway)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Unbound DNS write: {e}")
|
errors.append(f"Unbound DNS write: {e}")
|
||||||
|
|
||||||
@@ -4601,47 +4652,80 @@ def deploy_services(body: dict):
|
|||||||
errors.append("OPNsense SSH not configured — DNS overrides not deployed. "
|
errors.append("OPNsense SSH not configured — DNS overrides not deployed. "
|
||||||
"Add service FQDNs to your DNS manually.")
|
"Add service FQDNs to your DNS manually.")
|
||||||
|
|
||||||
# 3. Add firewall rules: allow each VLAN to reach mgmt_ip on 443
|
# 3. Deploy reverse proxy on OPNsense
|
||||||
if cfg.get("key"):
|
# Option A: Write Caddy config to OPNsense via SFTP and reload
|
||||||
allowed_vlans = set()
|
# Option B: Configure HAProxy plugin via OPNsense API
|
||||||
for svc in services:
|
# We try Caddy first (simpler), fall back to instructions
|
||||||
for vid in svc.get("allowed_vlans", []):
|
if cfg.get("ssh_key_path"):
|
||||||
allowed_vlans.add(vid)
|
# Check if Caddy is available on OPNsense
|
||||||
vmap = _load_vlan_if_map()
|
out, _, code = _opnsense_ssh_run(cfg, "which caddy 2>/dev/null")
|
||||||
for vid in allowed_vlans:
|
if code == 0 and out.strip():
|
||||||
iface = vmap.get(str(vid), "")
|
# Caddy is installed on OPNsense — write config and reload
|
||||||
if not iface:
|
|
||||||
errors.append(f"VLAN {vid}: no OPNsense interface mapped — skip firewall rule")
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
_opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
_opnsense_sftp_write(cfg, "/usr/local/etc/caddy/Caddyfile.services", caddy_content)
|
||||||
"rule": {
|
_opnsense_ssh_run(cfg, "caddy reload --config /usr/local/etc/caddy/Caddyfile 2>&1")
|
||||||
"enabled": "1", "action": "pass",
|
steps_done.append("Caddy on OPNsense: config written and reloaded")
|
||||||
"interface": iface, "direction": "in",
|
except Exception as e:
|
||||||
"ipprotocol": "inet", "protocol": "tcp",
|
errors.append(f"Caddy on OPNsense: {e}")
|
||||||
"source": {"network": f"{iface}net"},
|
else:
|
||||||
"destination": {"address": mgmt_ip, "port": "443"},
|
# No Caddy on OPNsense — check HAProxy plugin
|
||||||
"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:
|
try:
|
||||||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
_opnsense_request(cfg, "haproxy/settings/searchServers")
|
||||||
except ValueError as e:
|
# HAProxy plugin is available — add backends
|
||||||
errors.append(f"Firewall apply: {e}")
|
for svc in services:
|
||||||
|
fqdn = svc.get("fqdn", "")
|
||||||
|
backend_url = svc.get("backend_url", "")
|
||||||
|
if not fqdn or not backend_url:
|
||||||
|
continue
|
||||||
|
host_port = backend_url.replace("http://", "").replace("https://", "")
|
||||||
|
parts = host_port.split(":")
|
||||||
|
backend_host = parts[0]
|
||||||
|
backend_port = parts[1] if len(parts) > 1 else "80"
|
||||||
|
try:
|
||||||
|
# Add HAProxy backend server
|
||||||
|
_opnsense_request(cfg, "haproxy/settings/addServer", "POST", {
|
||||||
|
"server": {
|
||||||
|
"name": fqdn.replace(".", "-"),
|
||||||
|
"address": backend_host,
|
||||||
|
"port": backend_port,
|
||||||
|
"mode": "active",
|
||||||
|
"ssl": "0",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
steps_done.append(f"HAProxy: backend {fqdn} → {host_port}")
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(f"HAProxy backend {fqdn}: {e}")
|
||||||
|
try:
|
||||||
|
_opnsense_request(cfg, "haproxy/service/reconfigure", "POST")
|
||||||
|
steps_done.append("HAProxy reconfigured")
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(f"HAProxy reconfigure: {e}")
|
||||||
|
except Exception:
|
||||||
|
# Neither Caddy nor HAProxy available
|
||||||
|
pending_steps += [
|
||||||
|
"Install Caddy or HAProxy plugin on OPNsense to enable reverse proxying.",
|
||||||
|
"OPNsense: System > Firmware > Plugins > os-haproxy (recommended)",
|
||||||
|
"Or: pkg install caddy (FreeBSD package)",
|
||||||
|
"DNS overrides are deployed — once a reverse proxy is running on OPNsense, "
|
||||||
|
"services will be reachable by FQDN from all VLANs without breaking isolation.",
|
||||||
|
]
|
||||||
|
|
||||||
|
# No firewall rules needed — devices already can reach their gateway
|
||||||
|
steps_done.append("No firewall changes needed — devices reach services via their own gateway")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": len(errors) == 0,
|
"success": len(errors) == 0,
|
||||||
"steps_done": steps_done,
|
"steps_done": steps_done,
|
||||||
|
"pending_steps": pending_steps,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"backup": backup,
|
"backup": backup,
|
||||||
"caddy_content": caddy_content,
|
"caddy_content": caddy_content,
|
||||||
"mgmt_ip": mgmt_ip,
|
"opnsense_ip": opnsense_ip,
|
||||||
"note": "Restart Caddy to pick up new Caddyfile.services: "
|
"architecture": (
|
||||||
"docker compose restart caddy (or systemctl restart caddy)",
|
"DNS resolves service FQDNs to OPNsense gateway IP. "
|
||||||
|
"Devices reach services through their own gateway (reverse proxy). "
|
||||||
|
"No inter-VLAN firewall rules created. Full VLAN isolation preserved."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4854,12 +4938,86 @@ def _run_scheduled_task(schedule: dict):
|
|||||||
f"Switch unreachable: {conn['switch'].get('error','')}",
|
f"Switch unreachable: {conn['switch'].get('error','')}",
|
||||||
priority="urgent", tags="rotating_light")
|
priority="urgent", tags="rotating_light")
|
||||||
|
|
||||||
|
elif action == "vlan_enable":
|
||||||
|
# Re-enable a VLAN's internet access on OPNsense by adding allow-out rule
|
||||||
|
vlan_id = schedule.get("vlan_id")
|
||||||
|
vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
|
||||||
|
cfg = _load_opnsense_cfg()
|
||||||
|
vmap = _load_vlan_if_map()
|
||||||
|
iface = vmap.get(str(vlan_id), "")
|
||||||
|
if cfg.get("key") and iface:
|
||||||
|
try:
|
||||||
|
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||||||
|
"rule": {
|
||||||
|
"enabled": "1", "action": "pass",
|
||||||
|
"interface": iface, "direction": "in",
|
||||||
|
"ipprotocol": "inet", "protocol": "any",
|
||||||
|
"source": {"network": f"{iface}net"},
|
||||||
|
"destination": {"any": "1"},
|
||||||
|
"descr": f"Scheduled: allow {vlan_name} outbound",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||||||
|
# Track the rule UUID for later disable
|
||||||
|
_vlan_schedule_rules = _load_vlan_schedule_rules()
|
||||||
|
_vlan_schedule_rules[str(vlan_id)] = r.get("uuid", "")
|
||||||
|
_save_vlan_schedule_rules(_vlan_schedule_rules)
|
||||||
|
log.info(f"Scheduled VLAN enable: {vlan_name} ({vlan_id})")
|
||||||
|
_ntfy_send(f"VLAN Enabled: {vlan_name}",
|
||||||
|
f"Internet access restored for {vlan_name} (scheduled)",
|
||||||
|
tags="white_check_mark,globe_with_meridians")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"VLAN enable failed: {e}")
|
||||||
|
_ntfy_send(f"VLAN Enable Failed: {vlan_name}", str(e),
|
||||||
|
priority="high", tags="x")
|
||||||
|
|
||||||
|
elif action == "vlan_disable":
|
||||||
|
# Disable a VLAN's internet access by removing allow-out rule
|
||||||
|
vlan_id = schedule.get("vlan_id")
|
||||||
|
vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
|
||||||
|
cfg = _load_opnsense_cfg()
|
||||||
|
if cfg.get("key"):
|
||||||
|
_vlan_schedule_rules = _load_vlan_schedule_rules()
|
||||||
|
uuid = _vlan_schedule_rules.get(str(vlan_id), "")
|
||||||
|
if uuid:
|
||||||
|
try:
|
||||||
|
_opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
|
||||||
|
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||||||
|
_vlan_schedule_rules.pop(str(vlan_id), None)
|
||||||
|
_save_vlan_schedule_rules(_vlan_schedule_rules)
|
||||||
|
log.info(f"Scheduled VLAN disable: {vlan_name} ({vlan_id})")
|
||||||
|
_ntfy_send(f"VLAN Disabled: {vlan_name}",
|
||||||
|
f"Internet access blocked for {vlan_name} (scheduled)",
|
||||||
|
tags="no_entry,moon")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"VLAN disable failed: {e}")
|
||||||
|
_ntfy_send(f"VLAN Disable Failed: {vlan_name}", str(e),
|
||||||
|
priority="high", tags="x")
|
||||||
|
else:
|
||||||
|
# No tracked rule — try to find and disable by description
|
||||||
|
log.warning(f"No tracked rule UUID for VLAN {vlan_id} — "
|
||||||
|
f"block rule must be added manually or via firewall policy")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Scheduled task '{name}' failed: {e}")
|
log.warning(f"Scheduled task '{name}' failed: {e}")
|
||||||
_ntfy_send(f"Scheduled Task Failed: {name}", str(e),
|
_ntfy_send(f"Scheduled Task Failed: {name}", str(e),
|
||||||
priority="high", tags="x")
|
priority="high", tags="x")
|
||||||
|
|
||||||
|
|
||||||
|
# VLAN schedule rule tracking (which firewall rules we created for enable/disable)
|
||||||
|
VLAN_SCHED_RULES_FILE = _Path("/etc/switch-manager/vlan-schedule-rules.json")
|
||||||
|
|
||||||
|
def _load_vlan_schedule_rules() -> dict:
|
||||||
|
if VLAN_SCHED_RULES_FILE.exists():
|
||||||
|
try: return _json.loads(VLAN_SCHED_RULES_FILE.read_text())
|
||||||
|
except: pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_vlan_schedule_rules(rules: dict):
|
||||||
|
VLAN_SCHED_RULES_FILE.write_text(_json.dumps(rules, indent=2))
|
||||||
|
VLAN_SCHED_RULES_FILE.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
def _scheduler_loop():
|
def _scheduler_loop():
|
||||||
"""Background thread: check schedules every 60 seconds."""
|
"""Background thread: check schedules every 60 seconds."""
|
||||||
log.info("Scheduler thread started")
|
log.info("Scheduler thread started")
|
||||||
|
|||||||
Reference in New Issue
Block a user