From df9914e1a1814f9861dd686134f62f60e9062000 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 01:28:30 +0000 Subject: [PATCH] Rewrite services to use Caddy on LAN + NAT reflection for isolated VLANs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach tried to put a reverse proxy on OPNsense or poke firewall holes — both wrong. Correct architecture: - Caddy stays on LAN management computer (where it already is) - WAN: port 443 forwarded to Caddy. Only port exposed externally. - LAN devices reach services directly via Caddy - Isolated VLANs (IoT, Guest) use public FQDNs (plex.mydomain.com) - OPNsense NAT reflection handles this internally — traffic never leaves the network, but IoT is treated exactly like an external user - Zero cross-VLAN access. No pinholes. Full isolation preserved. IoT = untrusted = same access as someone on the internet. This is the correct security model — no exceptions for "just one port." Deploy endpoint now: writes Caddyfile entries, checks NAT reflection status, provides setup checklist for port forward + reflection toggle. https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE --- ers5952-manager.jsx | 30 +++--- switch_backend.py | 234 ++++++++++++++++---------------------------- 2 files changed, 99 insertions(+), 165 deletions(-) diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 3fcf7d0..73c8dd5 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -5082,27 +5082,31 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
-
Service Proxy — FQDN Access Without Breaking VLAN Isolation
+
Services — Caddy Reverse Proxy + NAT Reflection
- Make LAN services reachable by FQDN from any VLAN without - any inter-VLAN access. Devices never touch the service's VLAN directly. + Caddy on the LAN management computer is your reverse proxy for all services. + Only port 443 is forwarded from WAN. Service ports are never exposed externally.
- How it works: + How isolated VLANs reach services:
-
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.
+
1. IoT TV (VLAN 30) asks DNS for plex.mydomain.com
+
2. DNS returns your public IP
+
3. OPNsense sees "that's my WAN IP" → NAT reflection routes internally
+
4. Port forward sends to Caddy → Caddy proxies to Plex
+
5. Traffic never leaves your network. Full VLAN isolation.
-
- 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. +
+ IoT = untrusted = treated exactly like an external user. No pinholes, no cross-VLAN access. +
+
+ Requires: OPNsense NAT reflection enabled (Firewall > Settings > Advanced > Reflection for port forwards) + + WAN port forward TCP 443 → management computer (Caddy).
@@ -5116,7 +5120,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"/>
@@ -5163,7 +5167,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {deploying ? "Deploying..." : "Deploy All Services"} - Pushes DNS overrides to Unbound + configures reverse proxy on OPNsense + Updates Caddyfile + checks NAT reflection on OPNsense
diff --git a/switch_backend.py b/switch_backend.py index eb95a17..36dc774 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -4474,8 +4474,23 @@ def push_policy(body: dict): # ══════════════════════════════════════════════════════════════════════ -# SERVICE PROXY — expose LAN services to other VLANs via Caddy + DNS +# SERVICE ACCESS — manage Caddy config + OPNsense port forwards + NAT reflection # ══════════════════════════════════════════════════════════════════════ +# +# Architecture: +# Caddy runs on the LAN management computer. It is the reverse proxy for +# all services — only port 443 is forwarded from WAN, and Caddy routes +# by hostname (SNI) to the correct backend. Service ports (32400, 8123, +# etc.) are NEVER exposed on WAN. +# +# For LAN devices: they reach services directly via Caddy on the LAN. +# For isolated VLANs (IoT, Guest, etc.): they use the public FQDN +# (e.g. plex.mydomain.com). OPNsense NAT reflection handles this +# internally — traffic never actually leaves the network. The isolated +# VLAN device is treated exactly like an external user. +# +# This preserves full VLAN isolation. No pinholes, no cross-VLAN access. +# IoT = untrusted = same access as someone on the internet. SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json") CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services") @@ -4492,14 +4507,14 @@ def _save_services(services: list): def _generate_caddyfile_services(services: list) -> str: - """Generate Caddyfile blocks for service reverse proxies. + """Generate Caddyfile blocks for the LAN management computer's Caddy. - 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. + Each service FQDN gets a reverse_proxy block. Caddy handles TLS + termination and routes by hostname. Only port 443 needs to be + forwarded from WAN to this machine. """ - blocks = ["# Auto-generated by switch-manager — do not edit manually\n"] + blocks = ["# Auto-generated by switch-manager — service reverse proxy entries\n", + "# Add to your Caddyfile or use: import /etc/switch-manager/Caddyfile.services\n"] for svc in services: fqdn = svc.get("fqdn", "") backend_url = svc.get("backend_url", "") @@ -4507,58 +4522,10 @@ def _generate_caddyfile_services(services: list) -> str: continue blocks.append(f"{fqdn} {{") blocks.append(f" reverse_proxy {backend_url}") - blocks.append(f" tls internal") blocks.append(f"}}\n") return "\n".join(blocks) -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", "") - # 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.""" @@ -4567,7 +4534,7 @@ def get_services(): @app.post("/api/services") def save_service(body: dict): - """Add or update a service proxy.""" + """Add or update a service proxy entry.""" require_session(body.get("token", "")) svc = body.get("service", {}) if not svc.get("fqdn") or not svc.get("backend_url"): @@ -4582,7 +4549,7 @@ def save_service(body: dict): @app.delete("/api/services") def delete_service(body: dict): - """Remove a service proxy.""" + """Remove a service proxy entry.""" require_session(body.get("token", "")) fqdn = body.get("fqdn", "") services = _load_services() @@ -4591,22 +4558,37 @@ def delete_service(body: dict): return {"success": True, "services": services} +@app.get("/api/services/nat-reflection") +def check_nat_reflection(): + """Check if NAT reflection is enabled on OPNsense.""" + cfg = _load_opnsense_cfg() + if not cfg.get("ssh_key_path"): + return {"configured": False, "error": "OPNsense SSH not configured"} + try: + out, _, code = _opnsense_ssh_run( + cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + return {"configured": True, "likely_enabled": "1" in out.strip() or int(out.strip()) > 0} + except Exception as e: + return {"configured": True, "likely_enabled": None, "error": str(e)} + + @app.post("/api/services/deploy") def deploy_services(body: dict): """ - Deploy service proxies via OPNsense — preserves full VLAN isolation. + Deploy service proxy configuration. 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. + - Caddy runs on the LAN management computer (reverse proxy for all services) + - WAN: port 443 forwarded to Caddy — only port exposed externally + - LAN devices: reach services directly via Caddy + - Isolated VLANs (IoT, Guest): use public FQDNs — OPNsense NAT + reflection routes internally without traffic leaving the network + - Full VLAN isolation preserved — IoT treated same as external users - No inter-VLAN firewall rules are created. VLAN isolation stays intact. + This endpoint: + 1. Writes/updates Caddyfile with service entries + 2. Checks NAT reflection status on OPNsense + 3. Provides setup instructions for anything not yet configured """ require_session(body.get("token", "")) services = _load_services() @@ -4617,12 +4599,9 @@ def deploy_services(body: dict): errors = [] pending_steps = [] - 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 (local copy for reference / mgmt-box proxy) + # 1. Write Caddyfile.services for Caddy on the management computer caddy_content = _generate_caddyfile_services(services) try: CADDYFILE_EXTRA.write_text(caddy_content) @@ -4630,88 +4609,38 @@ def deploy_services(body: dict): except Exception as e: errors.append(f"Caddyfile write: {e}") - # 2. Push DNS overrides to OPNsense Unbound - # FQDNs resolve to OPNsense IP — devices already can reach their gateway + # 2. Check NAT reflection + cfg = _load_opnsense_cfg() + nat_status = None if cfg.get("ssh_key_path"): - 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 → {opnsense_ip} (gateway)") + out, _, code = _opnsense_ssh_run( + cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + nat_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) + nat_status = nat_enabled + if nat_enabled: + steps_done.append("NAT reflection: enabled on OPNsense") + else: + pending_steps.append( + "Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > " + "Reflection for port forwards = Enable") except Exception as e: - errors.append(f"Unbound DNS write: {e}") + errors.append(f"NAT reflection check: {e}") - # Validate and reload Unbound - out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1") - if code != 0: - errors.append(f"unbound-checkconf failed: {err or out}") - else: - _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") - steps_done.append("Unbound reloaded with service DNS overrides") - else: - errors.append("OPNsense SSH not configured — DNS overrides not deployed. " - "Add service FQDNs to your DNS manually.") + # 3. Verify WAN port forward exists for 443 + if cfg.get("key"): + try: + rules = _opnsense_request(cfg, "firewall/filter/searchRule") + # This is a best-effort check — port forwards are in NAT, not filter + pending_steps.append( + "Verify WAN port forward: OPNsense > Firewall > NAT > Port Forward — " + "WAN TCP 443 → management computer IP:443 (Caddy)") + except Exception: + pass - # 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_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, "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") + pending_steps.append( + "Reload Caddy on management computer: " + "docker compose restart caddy (or: caddy reload)") return { "success": len(errors) == 0, @@ -4720,11 +4649,12 @@ def deploy_services(body: dict): "errors": errors, "backup": backup, "caddy_content": caddy_content, - "opnsense_ip": opnsense_ip, + "nat_reflection_enabled": nat_status, "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." + "Caddy on LAN management computer handles all reverse proxying. " + "Only port 443 forwarded from WAN. Isolated VLANs use public FQDNs — " + "OPNsense NAT reflection routes internally (no round trip to internet). " + "Full VLAN isolation preserved. IoT = untrusted = external user." ), }