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:
Claude
2026-03-28 01:08:16 +00:00
parent 7928f6f769
commit 3cdad0dcb5
2 changed files with 368 additions and 100 deletions
+161 -51
View File
@@ -5040,7 +5040,7 @@ function FirewallTab({ vlans, session, onNeedAuth, backendOk }) {
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
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 [deployResult, setDeployResult] = useState(null);
@@ -5057,7 +5057,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
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: [] });
setForm({ fqdn: "", backend_url: "", description: "" });
await load();
} catch(e) { alert("Save failed: " + e.message); }
};
@@ -5078,27 +5078,31 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
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="ph">Service Proxy FQDN Access Without Breaking VLAN Isolation</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 style={{marginBottom:8}}>
Make LAN services reachable by FQDN from any VLAN <b style={{color:"var(--tx)"}}>without
any inter-VLAN access</b>. Devices never touch the service's VLAN directly.
</div>
<div style={{
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>
@@ -5112,7 +5116,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
<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>
<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}))}
placeholder="http://192.168.1.100:32400"/>
</div>
@@ -5122,29 +5126,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
</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 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>
@@ -5154,19 +5142,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
<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>
<thead><tr><th>FQDN</th><th>Backend</th><th>Description</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>
@@ -5181,7 +5163,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
{deploying ? "Deploying..." : "Deploy All Services"}
</button>
<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>
</div>
@@ -5200,9 +5182,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
{deployResult.errors?.map((e,i) => (
<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}}>
{deployResult.note}
{deployResult.architecture}
</div>
)}
</div>
@@ -5360,13 +5345,28 @@ function AlertsTab({ session, onNeedAuth, backendOk }) {
</div>
</div>
{/* Scheduled Operations */}
{/* VLAN Schedules */}
<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 midnight6am, 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 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"}}>
@@ -5436,3 +5436,113 @@ function AlertsTab({ session, onNeedAuth, backendOk }) {
</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>
);
}