diff --git a/Caddyfile.template b/Caddyfile.template index 952e1a5..5c501bd 100644 --- a/Caddyfile.template +++ b/Caddyfile.template @@ -9,6 +9,5 @@ redir https://{{host}}{{uri}} permanent }} -# Service proxies — auto-generated by switch-manager -# To include service proxy entries, add this line (uncommented) after deployment: -# import /etc/switch-manager/Caddyfile.services +# Service reverse proxy entries — auto-managed by switch-manager Services tab +import /etc/caddy/Caddyfile.services diff --git a/docker-compose.yml b/docker-compose.yml index 26b85b9..eb7a17e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro + - /etc/switch-manager/Caddyfile.services:/etc/caddy/Caddyfile.services:ro - caddy_data:/data - caddy_config:/config restart: unless-stopped @@ -17,7 +18,7 @@ services: expose: - "8765" volumes: - - /etc/switch-manager:/etc/switch-manager:ro + - /etc/switch-manager:/etc/switch-manager - ./frontend/dist:/app/frontend/dist:ro restart: unless-stopped environment: diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx index 73c8dd5..d8efc5e 100644 --- a/ers5952-manager.jsx +++ b/ers5952-manager.jsx @@ -5041,13 +5041,16 @@ function FirewallTab({ vlans, session, onNeedAuth, backendOk }) { function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { const [services, setServices] = useState([]); const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" }); + const [status, setStatus] = useState(null); const [deploying, setDeploying] = useState(false); const [deployResult, setDeployResult] = useState(null); + const [actionLoading, setActionLoading] = useState(""); const load = async () => { try { - const d = await API("/services"); - setServices(d.services || []); + const [svc, st] = await Promise.all([API("/services"), API("/services/status")]); + setServices(svc.services || []); + setStatus(st); } catch(e) { console.error(e); } }; useEffect(() => { if (backendOk) load(); }, [backendOk]); @@ -5068,24 +5071,63 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { await load(); }; + const enableNatReflection = async () => { + if (!session) { onNeedAuth(); return; } + setActionLoading("nat"); + try { + const r = await API("/services/enable-nat-reflection", { method:"POST", body:{ token: session.token } }); + if (r.success) await load(); + else alert("NAT reflection enable may need manual verification"); + } catch(e) { alert("Failed: " + e.message); } + setActionLoading(""); + }; + + const createPortForward = async () => { + if (!session) { onNeedAuth(); return; } + setActionLoading("pf"); + try { + const r = await API("/services/create-port-forward", { method:"POST", + body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); + if (r.note) alert(r.note); + await load(); + } catch(e) { alert("Failed: " + e.message); } + setActionLoading(""); + }; + const deploy = async () => { if (!session) { onNeedAuth(); return; } setDeploying(true); setDeployResult(null); try { - const r = await API("/services/deploy", { method: "POST", body: { token: session.token } }); + const r = await API("/services/deploy", { method:"POST", + body:{ token: session.token, mgmt_ip: status?.mgmt_ip } }); setDeployResult(r); + await load(); } catch(e) { setDeployResult({ success: false, errors: [e.message] }); } setDeploying(false); }; + const Check = ({ok, label, action, actionLabel, loading}) => ( +
+ + {label} + {ok === false && action && ( + + )} +
+ ); + return (
+ {/* Architecture explanation */}
Services — Caddy Reverse Proxy + NAT Reflection
- Caddy on the LAN management computer is your reverse proxy for all services. + Caddy on the LAN management computer is your reverse proxy. Only port 443 is forwarded from WAN. Service ports are never exposed externally.
- How isolated VLANs reach services: + How isolated VLANs reach services (NAT reflection):
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
+
3. OPNsense: "that's my WAN IP" → NAT reflection → routes internally
+
4. Port forward → Caddy (management computer) → reverse proxy to 192.168.1.x:port
5. Traffic never leaves your network. Full VLAN isolation.
- 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). + IoT = untrusted = treated exactly like an external user. No pinholes. No cross-VLAN access.
+ {/* Status Checklist */} +
+
Setup Checklist
+
+ + + + + + 0} + label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} /> + {status?.mgmt_ip && ( +
+ Management computer IP: {status.mgmt_ip} +
+ )} +
+
+ {/* Add Service Form */}
Add Service
-
+
setForm(f => ({...f, fqdn: e.target.value}))} - placeholder="plex.home.lan"/> + placeholder="plex.mydomain.com"/>
setForm(f => ({...f, backend_url: e.target.value}))} @@ -5129,18 +5193,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { placeholder="Plex Media Server"/>
- -
- All VLANs can reach this service automatically (via their gateway). No per-VLAN selection needed. -
+ style={{marginTop:12}}>Add Service
- {/* Service List */} + {/* Service List + Deploy */} {services.length > 0 && (
Configured Services ({services.length})
@@ -5153,10 +5211,8 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) { {s.fqdn} {s.backend_url} {s.description || "—"} - - - + ))} @@ -5164,10 +5220,10 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
- Updates Caddyfile + checks NAT reflection on OPNsense + Writes Caddyfile.services, reloads Caddy, verifies NAT reflection + port forward
@@ -5187,12 +5243,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
error {e}
))} {deployResult.pending_steps?.map((s,i) => ( -
manual {s}
+
todo {s}
))} - {deployResult.architecture && ( -
- {deployResult.architecture} -
+ {deployResult.caddy_content && ( +
+ View Caddyfile.services +
{deployResult.caddy_content}
+
)}
)} diff --git a/switch_backend.py b/switch_backend.py index 36dc774..d9ccc87 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -4558,37 +4558,215 @@ 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.""" +SERVICES_RULE_FILE = _Path("/etc/switch-manager/service-nat-rules.json") + +def _load_service_rules() -> dict: + """Load tracked OPNsense NAT rule UUIDs for service port forwards.""" + if SERVICES_RULE_FILE.exists(): + try: return _json.loads(SERVICES_RULE_FILE.read_text()) + except: pass + return {} + +def _save_service_rules(rules: dict): + SERVICES_RULE_FILE.write_text(_json.dumps(rules, indent=2)) + SERVICES_RULE_FILE.chmod(0o600) + + +def _get_mgmt_ip() -> str: + """Best-effort detection of management computer LAN IP.""" + import socket as _sock + try: + s = _sock.socket(_sock.AF_INET, _sock.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "" + + +@app.get("/api/services/status") +def services_status(): + """Full status check: Caddy import, NAT reflection, port forward, services.""" + services = _load_services() + cfg = _load_opnsense_cfg() + result = { + "services": services, + "mgmt_ip": _get_mgmt_ip(), + "caddy_file_exists": CADDYFILE_EXTRA.exists(), + "nat_reflection": None, + "port_forward_443": None, + "opnsense_configured": bool(cfg.get("key")), + "opnsense_ssh": bool(cfg.get("ssh_key_path")), + } + + # Check NAT reflection + if cfg.get("ssh_key_path"): + try: + out, _, _ = _opnsense_ssh_run(cfg, + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + result["nat_reflection"] = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) + except Exception as e: + result["nat_reflection_error"] = str(e) + + # Check for existing WAN port forward to 443 + if cfg.get("key"): + try: + nat_rules = _opnsense_request(cfg, "firewall/source_nat/searchRule") + # OPNsense 24+ uses source_nat; older uses legacy — try both + except Exception: + nat_rules = {} + if not nat_rules: + try: + nat_rules = _opnsense_request(cfg, "firewall/filter/searchRule") + except Exception: + nat_rules = {} + # We can't reliably parse NAT rules from the filter API — + # mark as "needs verification" unless we've created one ourselves + tracked = _load_service_rules() + result["port_forward_443"] = bool(tracked.get("wan_443_uuid")) + result["tracked_rules"] = tracked + + return result + + +@app.post("/api/services/enable-nat-reflection") +def enable_nat_reflection(body: dict): + """Enable NAT reflection on OPNsense via SSH (modifies config.xml).""" + require_session(body.get("token", "")) cfg = _load_opnsense_cfg() if not cfg.get("ssh_key_path"): - return {"configured": False, "error": "OPNsense SSH not configured"} + raise HTTPException(503, "OPNsense SSH not configured") + + backup = _pre_change_backup(reason="pre-NAT-reflection-enable") + + # OPNsense stores NAT reflection settings in /conf/config.xml under + # The cleanest way is via the API if available, or configctl 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} + # Try the OPNsense API approach first (Firewall > Settings) + # The setting is under system > disablenatreflection (absent = enabled) + # and system > enablenatreflectionhelper (present = enabled) + out, err, code = _opnsense_ssh_run(cfg, ( + "configctl firmware configure 2>/dev/null; " + "echo 'NAT reflection: checking current state'; " + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0" + )) + already_enabled = "1" in out.strip().split('\n')[-1] + if already_enabled: + return {"success": True, "already_enabled": True, "backup": backup} + + # Enable via configctl / direct XML edit + # OPNsense 24+: use pluginctl or direct config edit + cmds = [ + # Add enablenatreflectionhelper if not present + "sed -i '' '/<\\/system>/i\\ 1<\\/enablenatreflectionhelper>' /conf/config.xml 2>/dev/null || " + "sed -i '/<\\/system>/i\\ 1<\\/enablenatreflectionhelper>' /conf/config.xml", + # Remove disablenatreflection if present + "sed -i '' '//d' /conf/config.xml 2>/dev/null || " + "sed -i '//d' /conf/config.xml", + # Reload filter + "configctl filter reload", + ] + for cmd in cmds: + _opnsense_ssh_run(cfg, cmd) + + # Verify + out, _, _ = _opnsense_ssh_run(cfg, + "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0) + + return {"success": enabled, "backup": backup, + "note": "Firewall filter reloaded" if enabled else "May need manual verification"} except Exception as e: - return {"configured": True, "likely_enabled": None, "error": str(e)} + raise HTTPException(500, f"NAT reflection enable failed: {e}") + + +@app.post("/api/services/create-port-forward") +def create_wan_port_forward(body: dict): + """ + Create WAN port forward: TCP 443 → management computer (Caddy). + + Uses OPNsense firewall NAT API. Only creates the rule if we haven't + already (tracked by UUID in service-nat-rules.json). + """ + require_session(body.get("token", "")) + cfg = _load_opnsense_cfg() + if not cfg.get("key"): + raise HTTPException(503, "OPNsense API not configured") + + mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) + if not mgmt_ip: + raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip") + + tracked = _load_service_rules() + if tracked.get("wan_443_uuid"): + return {"success": True, "already_exists": True, "uuid": tracked["wan_443_uuid"], + "mgmt_ip": mgmt_ip} + + backup = _pre_change_backup(reason="pre-WAN-port-forward-443") + + try: + # Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443 + r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", { + "rule": { + "enabled": "1", + "interface": "wan", + "protocol": "tcp", + "source": {"any": "1"}, + "destination": {"any": "1", "port": "443"}, + "target": {"address": mgmt_ip, "port": "443"}, + "descr": "switch-manager: WAN 443 → Caddy reverse proxy", + "nordr": "0", + } + }) + uuid = r.get("uuid", "") + + # If source_nat didn't work, try legacy firewall NAT API + if not uuid: + # OPNsense legacy NAT — different endpoint + r = _opnsense_request(cfg, "firewall/filter/addRule", "POST", { + "rule": { + "enabled": "1", + "action": "pass", + "interface": "wan", + "direction": "in", + "ipprotocol": "inet", + "protocol": "tcp", + "source": {"any": "1"}, + "destination": {"address": mgmt_ip, "port": "443"}, + "descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)", + } + }) + uuid = r.get("uuid", "") + + # Apply changes + _opnsense_request(cfg, "firewall/filter/apply", "POST") + + tracked["wan_443_uuid"] = uuid + tracked["mgmt_ip"] = mgmt_ip + _save_service_rules(tracked) + + return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup, + "note": "If this is the first time, also verify in OPNsense UI: " + "Firewall > NAT > Port Forward that the rule looks correct. " + "The OPNsense NAT API varies between versions."} + except Exception as e: + raise HTTPException(500, f"Port forward creation failed: {e}") @app.post("/api/services/deploy") def deploy_services(body: dict): """ - Deploy service proxy configuration. + Full deploy: write Caddyfile, reload Caddy, ensure NAT reflection + and port forward are configured on OPNsense. - Architecture: - - 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 - - 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 + Steps: + 1. Pre-change backup + 2. Write Caddyfile.services with reverse proxy entries + 3. Reload Caddy (docker compose exec or systemctl) + 4. Check/enable NAT reflection on OPNsense + 5. Check/create WAN port forward 443 → Caddy + 6. Return status of each step """ require_session(body.get("token", "")) services = _load_services() @@ -4599,48 +4777,89 @@ def deploy_services(body: dict): errors = [] pending_steps = [] - backup = _pre_change_backup(reason="pre-service-proxy deploy") + backup = _pre_change_backup(reason="pre-service-deploy") - # 1. Write Caddyfile.services for Caddy on the management computer + mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip()) + + # ── Step 1: Write Caddyfile.services ───────────────────────────── caddy_content = _generate_caddyfile_services(services) try: CADDYFILE_EXTRA.write_text(caddy_content) - steps_done.append(f"Wrote {CADDYFILE_EXTRA} ({len(services)} services)") + steps_done.append(f"Wrote Caddyfile.services ({len(services)} services)") except Exception as e: errors.append(f"Caddyfile write: {e}") - # 2. Check NAT reflection + # ── Step 2: Reload Caddy ───────────────────────────────────────── + import subprocess as _sp + caddy_reloaded = False + # Try docker compose first + try: + r = _sp.run(["docker", "compose", "exec", "caddy", "caddy", "reload", + "--config", "/etc/caddy/Caddyfile"], + capture_output=True, text=True, timeout=15, + cwd=str(_Path(__file__).parent)) + if r.returncode == 0: + steps_done.append("Caddy reloaded via docker compose") + caddy_reloaded = True + else: + # Try docker exec with container name pattern + r2 = _sp.run(["docker", "compose", "restart", "caddy"], + capture_output=True, text=True, timeout=30, + cwd=str(_Path(__file__).parent)) + if r2.returncode == 0: + steps_done.append("Caddy restarted via docker compose") + caddy_reloaded = True + else: + errors.append(f"Docker caddy reload failed: {r.stderr.strip()}") + except Exception: + pass + + if not caddy_reloaded: + # Try systemctl + try: + r = _sp.run(["systemctl", "reload", "caddy"], + capture_output=True, text=True, timeout=10) + if r.returncode == 0: + steps_done.append("Caddy reloaded via systemctl") + caddy_reloaded = True + except Exception: + pass + + if not caddy_reloaded: + pending_steps.append( + "Reload Caddy manually: docker compose restart caddy " + "(or: systemctl reload caddy)") + + # ── Step 3: NAT reflection ─────────────────────────────────────── cfg = _load_opnsense_cfg() nat_status = None if cfg.get("ssh_key_path"): try: - out, _, code = _opnsense_ssh_run( - cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0") + out, _, _ = _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") + steps_done.append("NAT reflection: already enabled") else: pending_steps.append( "Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > " - "Reflection for port forwards = Enable") + "Reflection for port forwards = Enable. " + "Or use the 'Enable NAT Reflection' button above.") except Exception as e: errors.append(f"NAT reflection check: {e}") + else: + pending_steps.append("Configure OPNsense SSH to auto-check NAT reflection") - # 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 - - pending_steps.append( - "Reload Caddy on management computer: " - "docker compose restart caddy (or: caddy reload)") + # ── Step 4: WAN port forward ───────────────────────────────────── + tracked = _load_service_rules() + if tracked.get("wan_443_uuid"): + steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)") + else: + pending_steps.append( + f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — " + f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). " + f"Or use the 'Create Port Forward' button above.") return { "success": len(errors) == 0, @@ -4649,13 +4868,8 @@ def deploy_services(body: dict): "errors": errors, "backup": backup, "caddy_content": caddy_content, + "mgmt_ip": mgmt_ip, "nat_reflection_enabled": nat_status, - "architecture": ( - "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." - ), }