diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 7638ff2..3fcf7d0 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -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 (
-
Service Proxy
+
Service Proxy — FQDN Access Without Breaking VLAN Isolation
- Expose services running on your LAN to other VLANs without opening inter-VLAN access. - Each service gets an FQDN (e.g. plex.home.lan) that - resolves to the management box. Caddy reverse-proxies the request to the actual server. - Only port 443 is opened — no direct VLAN-to-VLAN access needed. -
- Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400 +
+ Make LAN services reachable by FQDN from any VLAN without + any inter-VLAN access. Devices never touch the service's VLAN directly. +
+
+
+ How it works: +
+
1. IoT device (VLAN 30) asks DNS for plex.home.lan
+
2. Unbound returns 192.168.30.1 (OPNsense gateway — device can already reach this)
+
3. OPNsense reverse proxy (Caddy/HAProxy) forwards to actual server 192.168.1.100:32400
+
4. Response returns the same path. IoT device never sees or touches LAN.
+
+
+ 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.
@@ -5112,7 +5116,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { setForm(f => ({...f, fqdn: e.target.value}))} placeholder="plex.home.lan"/>
-
+
setForm(f => ({...f, backend_url: e.target.value}))} placeholder="http://192.168.1.100:32400"/>
@@ -5122,29 +5126,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
-
-
Allowed VLANs (which VLANs can reach this service)
-
- {vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => ( - - ))} -
-
- +
+ All VLANs can reach this service automatically (via their gateway). No per-VLAN selection needed. +
@@ -5154,19 +5142,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
Configured Services ({services.length})
- + {services.map((s,i) => ( -
FQDNBackendDescriptionVLANs
FQDNBackendDescription
{s.fqdn} {s.backend_url} {s.description || "—"} - {(s.allowed_vlans || []).map(vid => { - const v = vlans.find(x => x.id === vid); - return {v?.name||`V${vid}`}; - })} - @@ -5181,7 +5163,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {deploying ? "Deploying..." : "Deploy All Services"} - Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules + Pushes DNS overrides to Unbound + configures reverse proxy on OPNsense @@ -5200,9 +5182,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {deployResult.errors?.map((e,i) => (
error {e}
))} - {deployResult.note && ( + {deployResult.pending_steps?.map((s,i) => ( +
manual {s}
+ ))} + {deployResult.architecture && (
- {deployResult.note} + {deployResult.architecture}
)} @@ -5360,13 +5345,28 @@ function AlertsTab({ session, onNeedAuth, backendOk }) { - {/* Scheduled Operations */} + {/* VLAN Schedules */}
-
Scheduled Operations
+
VLAN Schedules — Time-Based Access Control
+
+
+ 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. +
+ + +
+
+ + {/* General Scheduled Operations */} +
+
General Scheduled Operations
Schedule recurring tasks like automatic backups or connectivity checks. - Tasks run in the background and send ntfy alerts on failure (if configured).
@@ -5436,3 +5436,113 @@ function AlertsTab({ session, onNeedAuth, backendOk }) {
); } + + +// ── 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 ( +
+
+
Quick Presets
+
+ {presets.map((p,i) => ( + + ))} +
+
+
+
+ +
+
+
+ setOffHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="0"/> + : + setOffMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/> +
+
+
+
+ setOnHour(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="6"/> + : + setOnMin(e.target.value)} style={{width:40,textAlign:"center"}} placeholder="00"/> +
+
+
+ setDays(e.target.value)} placeholder="* or mon,tue,wed"/> +
+
+ +
+
+ {vlanId && ( +
+ {vlanName || `VLAN ${vlanId}`}: + Internet disabled at {offHour}:{(offMin||"0").padStart(2,"0")}, + re-enabled at {onHour}:{(onMin||"0").padStart(2,"0")} + {days === "*" ? " every day" : ` on ${days}`}. + Switch ports stay up — devices just lose internet access. +
+ )} +
+ ); +} diff --git a/switch_backend.py b/switch_backend.py index bb27b86..eb95a17 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -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")