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>
);
}
+207 -49
View File
@@ -4492,7 +4492,13 @@ def _save_services(services: list):
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"]
for svc in services:
fqdn = svc.get("fqdn", "")
@@ -4506,17 +4512,53 @@ def _generate_caddyfile_services(services: list) -> str:
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"]
def _generate_unbound_overrides(services: list, opnsense_ip: str) -> str:
"""Generate Unbound local-data lines for service FQDN → OPNsense IP.
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:
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:
lines.append(f'local-data: "{fqdn}. IN A {target_ip}"')
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")
def get_services():
"""List configured service proxies."""
@@ -4552,8 +4594,19 @@ def delete_service(body: dict):
@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.
Deploy service proxies via OPNsense — preserves full VLAN isolation.
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", ""))
services = _load_services()
@@ -4562,17 +4615,14 @@ def deploy_services(body: dict):
steps_done = []
errors = []
pending_steps = []
# 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'
cfg = _load_opnsense_cfg()
opnsense_ip = cfg.get("host", SWITCH_HOST.rsplit('.', 1)[0] + '.1')
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)
try:
CADDYFILE_EXTRA.write_text(caddy_content)
@@ -4581,12 +4631,13 @@ def deploy_services(body: dict):
errors.append(f"Caddyfile write: {e}")
# 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"):
dns_content = _generate_unbound_overrides(services, mgmt_ip)
dns_content = _generate_unbound_overrides(services, opnsense_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}")
steps_done.append(
f"Wrote Unbound overrides: {len(services)} service FQDNs → {opnsense_ip} (gateway)")
except Exception as 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. "
"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
# 3. Deploy reverse proxy on OPNsense
# Option A: Write Caddy config to OPNsense via SFTP and reload
# Option B: Configure HAProxy plugin via OPNsense API
# We try Caddy first (simpler), fall back to instructions
if cfg.get("ssh_key_path"):
# Check if Caddy is available on OPNsense
out, _, code = _opnsense_ssh_run(cfg, "which caddy 2>/dev/null")
if code == 0 and out.strip():
# Caddy is installed on OPNsense — write config and reload
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:
_opnsense_sftp_write(cfg, "/usr/local/etc/caddy/Caddyfile.services", caddy_content)
_opnsense_ssh_run(cfg, "caddy reload --config /usr/local/etc/caddy/Caddyfile 2>&1")
steps_done.append("Caddy on OPNsense: config written and reloaded")
except Exception as e:
errors.append(f"Caddy on OPNsense: {e}")
else:
# No Caddy on OPNsense — check HAProxy plugin
try:
_opnsense_request(cfg, "firewall/filter/apply", "POST")
except ValueError as e:
errors.append(f"Firewall apply: {e}")
_opnsense_request(cfg, "haproxy/settings/searchServers")
# HAProxy plugin is available — add backends
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 {
"success": len(errors) == 0,
"steps_done": steps_done,
"pending_steps": pending_steps,
"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)",
"opnsense_ip": opnsense_ip,
"architecture": (
"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','')}",
priority="urgent", tags="rotating_light")
elif action == "vlan_enable":
# Re-enable a VLAN's internet access on OPNsense by adding allow-out rule
vlan_id = schedule.get("vlan_id")
vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
cfg = _load_opnsense_cfg()
vmap = _load_vlan_if_map()
iface = vmap.get(str(vlan_id), "")
if cfg.get("key") and iface:
try:
r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", {
"rule": {
"enabled": "1", "action": "pass",
"interface": iface, "direction": "in",
"ipprotocol": "inet", "protocol": "any",
"source": {"network": f"{iface}net"},
"destination": {"any": "1"},
"descr": f"Scheduled: allow {vlan_name} outbound",
}
})
_opnsense_request(cfg, "firewall/filter/apply", "POST")
# Track the rule UUID for later disable
_vlan_schedule_rules = _load_vlan_schedule_rules()
_vlan_schedule_rules[str(vlan_id)] = r.get("uuid", "")
_save_vlan_schedule_rules(_vlan_schedule_rules)
log.info(f"Scheduled VLAN enable: {vlan_name} ({vlan_id})")
_ntfy_send(f"VLAN Enabled: {vlan_name}",
f"Internet access restored for {vlan_name} (scheduled)",
tags="white_check_mark,globe_with_meridians")
except Exception as e:
log.warning(f"VLAN enable failed: {e}")
_ntfy_send(f"VLAN Enable Failed: {vlan_name}", str(e),
priority="high", tags="x")
elif action == "vlan_disable":
# Disable a VLAN's internet access by removing allow-out rule
vlan_id = schedule.get("vlan_id")
vlan_name = schedule.get("vlan_name", f"VLAN {vlan_id}")
cfg = _load_opnsense_cfg()
if cfg.get("key"):
_vlan_schedule_rules = _load_vlan_schedule_rules()
uuid = _vlan_schedule_rules.get(str(vlan_id), "")
if uuid:
try:
_opnsense_request(cfg, f"firewall/filter/delRule/{uuid}", "POST")
_opnsense_request(cfg, "firewall/filter/apply", "POST")
_vlan_schedule_rules.pop(str(vlan_id), None)
_save_vlan_schedule_rules(_vlan_schedule_rules)
log.info(f"Scheduled VLAN disable: {vlan_name} ({vlan_id})")
_ntfy_send(f"VLAN Disabled: {vlan_name}",
f"Internet access blocked for {vlan_name} (scheduled)",
tags="no_entry,moon")
except Exception as e:
log.warning(f"VLAN disable failed: {e}")
_ntfy_send(f"VLAN Disable Failed: {vlan_name}", str(e),
priority="high", tags="x")
else:
# No tracked rule — try to find and disable by description
log.warning(f"No tracked rule UUID for VLAN {vlan_id}"
f"block rule must be added manually or via firewall policy")
except Exception as e:
log.warning(f"Scheduled task '{name}' failed: {e}")
_ntfy_send(f"Scheduled Task Failed: {name}", str(e),
priority="high", tags="x")
# VLAN schedule rule tracking (which firewall rules we created for enable/disable)
VLAN_SCHED_RULES_FILE = _Path("/etc/switch-manager/vlan-schedule-rules.json")
def _load_vlan_schedule_rules() -> dict:
if VLAN_SCHED_RULES_FILE.exists():
try: return _json.loads(VLAN_SCHED_RULES_FILE.read_text())
except: pass
return {}
def _save_vlan_schedule_rules(rules: dict):
VLAN_SCHED_RULES_FILE.write_text(_json.dumps(rules, indent=2))
VLAN_SCHED_RULES_FILE.chmod(0o600)
def _scheduler_loop():
"""Background thread: check schedules every 60 seconds."""
log.info("Scheduler thread started")