diff --git a/ers5952-manager.jsx b/ers5952-manager.jsx
index 6545996..e2790ff 100644
--- a/ers5952-manager.jsx
+++ b/ers5952-manager.jsx
@@ -5099,12 +5099,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
const [deploying, setDeploying] = useState(false);
const [deployResult, setDeployResult] = useState(null);
const [actionLoading, setActionLoading] = useState("");
+ const [caddyIpInput, setCaddyIpInput] = useState("");
const load = async () => {
try {
const [svc, st] = await Promise.all([API("/services"), API("/services/status")]);
setServices(svc.services || []);
setStatus(st);
+ if (st.caddy_ip && !caddyIpInput) setCaddyIpInput(st.caddy_ip);
} catch(e) { console.error(e); }
};
useEffect(() => { if (backendOk) load(); }, [backendOk]);
@@ -5125,6 +5127,17 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
await load();
};
+ const saveCaddyIp = async () => {
+ if (!session) { onNeedAuth(); return; }
+ if (!caddyIpInput.trim()) return;
+ try {
+ await API("/services/config", { method:"POST", body:{
+ token: session.token, config: { caddy_ip: caddyIpInput.trim() }
+ }});
+ await load();
+ } catch(e) { alert("Failed: " + e.message); }
+ };
+
const enableNatReflection = async () => {
if (!session) { onNeedAuth(); return; }
setActionLoading("nat");
@@ -5141,7 +5154,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
setActionLoading("pf");
try {
const r = await API("/services/create-port-forward", { method:"POST",
- body:{ token: session.token, mgmt_ip: status?.mgmt_ip } });
+ body:{ token: session.token, caddy_ip: status?.caddy_ip } });
if (r.note) alert(r.note);
await load();
} catch(e) { alert("Failed: " + e.message); }
@@ -5153,7 +5166,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
setDeploying(true); setDeployResult(null);
try {
const r = await API("/services/deploy", { method:"POST",
- body:{ token: session.token, mgmt_ip: status?.mgmt_ip } });
+ body:{ token: session.token, caddy_ip: status?.caddy_ip } });
setDeployResult(r);
await load();
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
@@ -5203,10 +5216,32 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
+ {/* Services Host Config */}
+
+
Services Host (Caddy Computer)
+
+
+ The computer running Caddy and your services (Plex, etc.) — on LAN, separate from the
+ VLAN 99 management computer.
+
+
+
+
+ setCaddyIpInput(e.target.value)}
+ placeholder="192.168.1.50"/>
+
+
+
+
+
+
{/* Status Checklist */}
Setup Checklist
+
0}
label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} />
- {status?.mgmt_ip && (
-
- Management computer IP: {status.mgmt_ip}
-
- )}
diff --git a/switch_backend.py b/switch_backend.py
index a96571c..db3dc21 100644
--- a/switch_backend.py
+++ b/switch_backend.py
@@ -4515,8 +4515,12 @@ def push_policy(body: dict):
# ══════════════════════════════════════════════════════════════════════
#
# 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
+# Caddy runs on a SEPARATE LAN services computer, NOT the VLAN 99
+# management computer. Management box only runs this tool + SSH keys.
+# Services box (LAN) runs Caddy, Plex, Docker, etc.
+#
+# WAN port forward 443 → services box LAN IP (Caddy).
+# Caddy routes
# by hostname (SNI) to the correct backend. Service ports (32400, 8123,
# etc.) are NEVER exposed on WAN.
#
@@ -4530,8 +4534,25 @@ def push_policy(body: dict):
# IoT = untrusted = same access as someone on the internet.
SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json")
+SERVICES_CONFIG_FILE = _Path("/etc/switch-manager/services-config.json")
CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services")
+def _load_services_config() -> dict:
+ """Load services/Caddy host config: caddy_ip, etc.
+ This is the LAN services box, NOT the VLAN 99 management computer."""
+ if SERVICES_CONFIG_FILE.exists():
+ try: return _json.loads(SERVICES_CONFIG_FILE.read_text())
+ except: pass
+ return {}
+
+def _save_services_config(cfg: dict):
+ SERVICES_CONFIG_FILE.write_text(_json.dumps(cfg, indent=2))
+ SERVICES_CONFIG_FILE.chmod(0o600)
+
+def _get_caddy_ip() -> str:
+ """Return the Caddy/services box LAN IP."""
+ return _load_services_config().get("caddy_ip", "")
+
def _load_services() -> list:
if SERVICES_FILE.exists():
try: return _json.loads(SERVICES_FILE.read_text())
@@ -4622,13 +4643,32 @@ def _get_mgmt_ip() -> str:
return ""
+@app.get("/api/services/config")
+def get_services_config():
+ """Return services host configuration."""
+ return _load_services_config()
+
+@app.post("/api/services/config")
+def save_services_config_endpoint(body: dict):
+ """Save services host configuration (Caddy box LAN IP)."""
+ require_session(body.get("token", ""))
+ cfg = body.get("config", {})
+ if not cfg.get("caddy_ip"):
+ raise HTTPException(400, "caddy_ip required — the LAN IP of your services/Caddy computer")
+ _save_services_config(cfg)
+ return {"success": True, "config": cfg}
+
@app.get("/api/services/status")
def services_status():
- """Full status check: Caddy import, NAT reflection, port forward, services."""
+ """Full status check: Caddy host, NAT reflection, port forward, services."""
services = _load_services()
+ svc_cfg = _load_services_config()
cfg = _load_opnsense_cfg()
+ caddy_ip = svc_cfg.get("caddy_ip", "")
result = {
"services": services,
+ "caddy_ip": caddy_ip,
+ "caddy_configured": bool(caddy_ip),
"mgmt_ip": _get_mgmt_ip(),
"caddy_file_exists": CADDYFILE_EXTRA.exists(),
"nat_reflection": None,
@@ -4731,19 +4771,19 @@ def create_wan_port_forward(body: dict):
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")
+ caddy_ip = body.get("caddy_ip", _get_caddy_ip())
+ if not caddy_ip:
+ raise HTTPException(400, "Caddy/services box IP not configured — set it in the Services tab")
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}
+ "caddy_ip": caddy_ip}
backup = _pre_change_backup(reason="pre-WAN-port-forward-443")
try:
- # Create NAT port forward rule: WAN TCP 443 → mgmt_ip:443
+ # Create NAT port forward rule: WAN TCP 443 → caddy_ip:443 (services box on LAN)
r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", {
"rule": {
"enabled": "1",
@@ -4751,8 +4791,8 @@ def create_wan_port_forward(body: dict):
"protocol": "tcp",
"source": {"any": "1"},
"destination": {"any": "1", "port": "443"},
- "target": {"address": mgmt_ip, "port": "443"},
- "descr": "switch-manager: WAN 443 → Caddy reverse proxy",
+ "target": {"address": caddy_ip, "port": "443"},
+ "descr": f"switch-manager: WAN 443 → Caddy ({caddy_ip})",
"nordr": "0",
}
})
@@ -4770,8 +4810,8 @@ def create_wan_port_forward(body: dict):
"ipprotocol": "inet",
"protocol": "tcp",
"source": {"any": "1"},
- "destination": {"address": mgmt_ip, "port": "443"},
- "descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)",
+ "destination": {"address": caddy_ip, "port": "443"},
+ "descr": f"switch-manager: allow WAN → Caddy ({caddy_ip}:443)",
}
})
uuid = r.get("uuid", "")
@@ -4780,10 +4820,10 @@ def create_wan_port_forward(body: dict):
_opnsense_request(cfg, "firewall/filter/apply", "POST")
tracked["wan_443_uuid"] = uuid
- tracked["mgmt_ip"] = mgmt_ip
+ tracked["caddy_ip"] = caddy_ip
_save_service_rules(tracked)
- return {"success": True, "uuid": uuid, "mgmt_ip": mgmt_ip, "backup": backup,
+ return {"success": True, "uuid": uuid, "caddy_ip": caddy_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."}
@@ -4816,7 +4856,7 @@ def deploy_services(body: dict):
backup = _pre_change_backup(reason="pre-service-deploy")
- mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip())
+ caddy_ip = body.get("caddy_ip", _get_caddy_ip()) or _get_mgmt_ip()
# ── Step 1: Write Caddyfile.services ─────────────────────────────
caddy_content = _generate_caddyfile_services(services)
@@ -4891,11 +4931,11 @@ def deploy_services(body: dict):
# ── 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)")
+ steps_done.append(f"WAN port forward 443 → {tracked.get('caddy_ip', caddy_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"WAN TCP 443 → {caddy_ip}:443 (services box). "
f"Or use the 'Create Port Forward' button above.")
return {
@@ -4905,7 +4945,7 @@ def deploy_services(body: dict):
"errors": errors,
"backup": backup,
"caddy_content": caddy_content,
- "mgmt_ip": mgmt_ip,
+ "caddy_ip": caddy_ip,
"nat_reflection_enabled": nat_status,
}