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 (
| FQDN | Backend | Description | VLANs | |
|---|---|---|---|---|
| FQDN | Backend | Description | ||
| {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 */}
-
+
+ {/* General Scheduled Operations */}
+ 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
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 (
+
+
+ );
+}
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")
+
+ Quick Presets
+
+ {presets.map((p,i) => (
+
+ ))}
+
+
+
+ {vlanId && (
+
+
+
+
+
+
+ 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"/>
+
+
+
+
+
+ {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.
+
+ )}
+ |