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.
Add Service
-
-
- Add Service
-
-
- 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 || "—"}
-
- removeService(s.fqdn)}>Remove
-
+
removeService(s.fqdn)}>Remove
))}
@@ -5164,10 +5220,10 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
- {deploying ? "Deploying..." : "Deploy All Services"}
+ {deploying ? "Deploying..." : "Deploy"}
- 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."
- ),
}