Separate Caddy/services box from VLAN 99 management computer
The management computer (VLAN 99) only runs the switch manager tool and holds SSH keys/TOTP secrets. Caddy and services (Plex, etc.) run on a SEPARATE computer on LAN. Backend: - New /api/services/config endpoint to store services box LAN IP - services-config.json persists caddy_ip separately from mgmt_ip - Port forward creation targets caddy_ip (LAN services box), not mgmt_ip (VLAN 99 management computer) - Deploy endpoint uses caddy_ip for all Caddy/NAT references - _get_caddy_ip() helper reads from services config Frontend: - New "Services Host" panel: configure Caddy box LAN IP - Checklist shows caddy_ip status, not mgmt_ip - Port forward and deploy pass caddy_ip to backend - Clear labels: "Services box" vs "Management computer" Architecture: VLAN 99: management computer (this tool, SSH keys, TOTP) LAN: services computer (Caddy, Plex, Docker containers) WAN port forward 443 → services computer LAN IP https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
+38
-8
@@ -5099,12 +5099,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [deployResult, setDeployResult] = useState(null);
|
const [deployResult, setDeployResult] = useState(null);
|
||||||
const [actionLoading, setActionLoading] = useState("");
|
const [actionLoading, setActionLoading] = useState("");
|
||||||
|
const [caddyIpInput, setCaddyIpInput] = useState("");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [svc, st] = await Promise.all([API("/services"), API("/services/status")]);
|
const [svc, st] = await Promise.all([API("/services"), API("/services/status")]);
|
||||||
setServices(svc.services || []);
|
setServices(svc.services || []);
|
||||||
setStatus(st);
|
setStatus(st);
|
||||||
|
if (st.caddy_ip && !caddyIpInput) setCaddyIpInput(st.caddy_ip);
|
||||||
} catch(e) { console.error(e); }
|
} catch(e) { console.error(e); }
|
||||||
};
|
};
|
||||||
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
||||||
@@ -5125,6 +5127,17 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
await load();
|
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 () => {
|
const enableNatReflection = async () => {
|
||||||
if (!session) { onNeedAuth(); return; }
|
if (!session) { onNeedAuth(); return; }
|
||||||
setActionLoading("nat");
|
setActionLoading("nat");
|
||||||
@@ -5141,7 +5154,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
setActionLoading("pf");
|
setActionLoading("pf");
|
||||||
try {
|
try {
|
||||||
const r = await API("/services/create-port-forward", { method:"POST",
|
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);
|
if (r.note) alert(r.note);
|
||||||
await load();
|
await load();
|
||||||
} catch(e) { alert("Failed: " + e.message); }
|
} catch(e) { alert("Failed: " + e.message); }
|
||||||
@@ -5153,7 +5166,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
setDeploying(true); setDeployResult(null);
|
setDeploying(true); setDeployResult(null);
|
||||||
try {
|
try {
|
||||||
const r = await API("/services/deploy", { method:"POST",
|
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);
|
setDeployResult(r);
|
||||||
await load();
|
await load();
|
||||||
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
|
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
|
||||||
@@ -5203,10 +5216,32 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Services Host Config */}
|
||||||
|
<div className="panel">
|
||||||
|
<div className="ph">Services Host (Caddy Computer)</div>
|
||||||
|
<div className="pb">
|
||||||
|
<div style={{fontSize:12,color:"var(--dm)",marginBottom:10,lineHeight:1.6}}>
|
||||||
|
The computer running Caddy and your services (Plex, etc.) — on LAN, separate from the
|
||||||
|
VLAN 99 management computer.
|
||||||
|
</div>
|
||||||
|
<div style={{display:"flex",gap:8,alignItems:"flex-end"}}>
|
||||||
|
<div className="field" style={{margin:0,flex:"0 0 200px"}}>
|
||||||
|
<label>Caddy / Services Box LAN IP</label>
|
||||||
|
<input value={caddyIpInput} onChange={e => setCaddyIpInput(e.target.value)}
|
||||||
|
placeholder="192.168.1.50"/>
|
||||||
|
</div>
|
||||||
|
<button className="btn bp" onClick={saveCaddyIp} disabled={!caddyIpInput.trim()}
|
||||||
|
style={{padding:"8px 16px"}}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status Checklist */}
|
{/* Status Checklist */}
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="ph">Setup Checklist</div>
|
<div className="ph">Setup Checklist</div>
|
||||||
<div className="pb">
|
<div className="pb">
|
||||||
|
<Check ok={status?.caddy_configured}
|
||||||
|
label={status?.caddy_ip ? `Services box: ${status.caddy_ip}` : "Services box IP not set — configure above"} />
|
||||||
<Check ok={status?.opnsense_configured} label="OPNsense API connected" />
|
<Check ok={status?.opnsense_configured} label="OPNsense API connected" />
|
||||||
<Check ok={status?.opnsense_ssh} label="OPNsense SSH connected" />
|
<Check ok={status?.opnsense_ssh} label="OPNsense SSH connected" />
|
||||||
<Check ok={status?.nat_reflection}
|
<Check ok={status?.nat_reflection}
|
||||||
@@ -5214,18 +5249,13 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
action={enableNatReflection} actionLabel="Enable NAT Reflection"
|
action={enableNatReflection} actionLabel="Enable NAT Reflection"
|
||||||
loading={actionLoading === "nat"} />
|
loading={actionLoading === "nat"} />
|
||||||
<Check ok={status?.port_forward_443}
|
<Check ok={status?.port_forward_443}
|
||||||
label={`WAN port forward 443 → ${status?.mgmt_ip || "?"}:443 (Caddy)`}
|
label={`WAN port forward 443 → ${status?.caddy_ip || "?"}:443 (Caddy)`}
|
||||||
action={createPortForward} actionLabel="Create Port Forward"
|
action={createPortForward} actionLabel="Create Port Forward"
|
||||||
loading={actionLoading === "pf"} />
|
loading={actionLoading === "pf"} />
|
||||||
<Check ok={status?.caddy_file_exists}
|
<Check ok={status?.caddy_file_exists}
|
||||||
label="Caddyfile.services exists" />
|
label="Caddyfile.services exists" />
|
||||||
<Check ok={services.length > 0}
|
<Check ok={services.length > 0}
|
||||||
label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} />
|
label={`${services.length} service${services.length !== 1 ? "s" : ""} configured`} />
|
||||||
{status?.mgmt_ip && (
|
|
||||||
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
|
|
||||||
Management computer IP: <span style={{fontFamily:"monospace",color:"var(--ac)"}}>{status.mgmt_ip}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+58
-18
@@ -4515,8 +4515,12 @@ def push_policy(body: dict):
|
|||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
#
|
#
|
||||||
# Architecture:
|
# Architecture:
|
||||||
# Caddy runs on the LAN management computer. It is the reverse proxy for
|
# Caddy runs on a SEPARATE LAN services computer, NOT the VLAN 99
|
||||||
# all services — only port 443 is forwarded from WAN, and Caddy routes
|
# 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,
|
# by hostname (SNI) to the correct backend. Service ports (32400, 8123,
|
||||||
# etc.) are NEVER exposed on WAN.
|
# etc.) are NEVER exposed on WAN.
|
||||||
#
|
#
|
||||||
@@ -4530,8 +4534,25 @@ def push_policy(body: dict):
|
|||||||
# IoT = untrusted = same access as someone on the internet.
|
# IoT = untrusted = same access as someone on the internet.
|
||||||
|
|
||||||
SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json")
|
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")
|
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:
|
def _load_services() -> list:
|
||||||
if SERVICES_FILE.exists():
|
if SERVICES_FILE.exists():
|
||||||
try: return _json.loads(SERVICES_FILE.read_text())
|
try: return _json.loads(SERVICES_FILE.read_text())
|
||||||
@@ -4622,13 +4643,32 @@ def _get_mgmt_ip() -> str:
|
|||||||
return ""
|
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")
|
@app.get("/api/services/status")
|
||||||
def 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()
|
services = _load_services()
|
||||||
|
svc_cfg = _load_services_config()
|
||||||
cfg = _load_opnsense_cfg()
|
cfg = _load_opnsense_cfg()
|
||||||
|
caddy_ip = svc_cfg.get("caddy_ip", "")
|
||||||
result = {
|
result = {
|
||||||
"services": services,
|
"services": services,
|
||||||
|
"caddy_ip": caddy_ip,
|
||||||
|
"caddy_configured": bool(caddy_ip),
|
||||||
"mgmt_ip": _get_mgmt_ip(),
|
"mgmt_ip": _get_mgmt_ip(),
|
||||||
"caddy_file_exists": CADDYFILE_EXTRA.exists(),
|
"caddy_file_exists": CADDYFILE_EXTRA.exists(),
|
||||||
"nat_reflection": None,
|
"nat_reflection": None,
|
||||||
@@ -4731,19 +4771,19 @@ def create_wan_port_forward(body: dict):
|
|||||||
if not cfg.get("key"):
|
if not cfg.get("key"):
|
||||||
raise HTTPException(503, "OPNsense API not configured")
|
raise HTTPException(503, "OPNsense API not configured")
|
||||||
|
|
||||||
mgmt_ip = body.get("mgmt_ip", _get_mgmt_ip())
|
caddy_ip = body.get("caddy_ip", _get_caddy_ip())
|
||||||
if not mgmt_ip:
|
if not caddy_ip:
|
||||||
raise HTTPException(400, "Cannot determine management computer IP — provide mgmt_ip")
|
raise HTTPException(400, "Caddy/services box IP not configured — set it in the Services tab")
|
||||||
|
|
||||||
tracked = _load_service_rules()
|
tracked = _load_service_rules()
|
||||||
if tracked.get("wan_443_uuid"):
|
if tracked.get("wan_443_uuid"):
|
||||||
return {"success": True, "already_exists": True, "uuid": tracked["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")
|
backup = _pre_change_backup(reason="pre-WAN-port-forward-443")
|
||||||
|
|
||||||
try:
|
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", {
|
r = _opnsense_request(cfg, "firewall/source_nat/addRule", "POST", {
|
||||||
"rule": {
|
"rule": {
|
||||||
"enabled": "1",
|
"enabled": "1",
|
||||||
@@ -4751,8 +4791,8 @@ def create_wan_port_forward(body: dict):
|
|||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"source": {"any": "1"},
|
"source": {"any": "1"},
|
||||||
"destination": {"any": "1", "port": "443"},
|
"destination": {"any": "1", "port": "443"},
|
||||||
"target": {"address": mgmt_ip, "port": "443"},
|
"target": {"address": caddy_ip, "port": "443"},
|
||||||
"descr": "switch-manager: WAN 443 → Caddy reverse proxy",
|
"descr": f"switch-manager: WAN 443 → Caddy ({caddy_ip})",
|
||||||
"nordr": "0",
|
"nordr": "0",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -4770,8 +4810,8 @@ def create_wan_port_forward(body: dict):
|
|||||||
"ipprotocol": "inet",
|
"ipprotocol": "inet",
|
||||||
"protocol": "tcp",
|
"protocol": "tcp",
|
||||||
"source": {"any": "1"},
|
"source": {"any": "1"},
|
||||||
"destination": {"address": mgmt_ip, "port": "443"},
|
"destination": {"address": caddy_ip, "port": "443"},
|
||||||
"descr": "switch-manager: allow WAN → Caddy:443 (pair with NAT rule)",
|
"descr": f"switch-manager: allow WAN → Caddy ({caddy_ip}:443)",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
uuid = r.get("uuid", "")
|
uuid = r.get("uuid", "")
|
||||||
@@ -4780,10 +4820,10 @@ def create_wan_port_forward(body: dict):
|
|||||||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||||||
|
|
||||||
tracked["wan_443_uuid"] = uuid
|
tracked["wan_443_uuid"] = uuid
|
||||||
tracked["mgmt_ip"] = mgmt_ip
|
tracked["caddy_ip"] = caddy_ip
|
||||||
_save_service_rules(tracked)
|
_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: "
|
"note": "If this is the first time, also verify in OPNsense UI: "
|
||||||
"Firewall > NAT > Port Forward that the rule looks correct. "
|
"Firewall > NAT > Port Forward that the rule looks correct. "
|
||||||
"The OPNsense NAT API varies between versions."}
|
"The OPNsense NAT API varies between versions."}
|
||||||
@@ -4816,7 +4856,7 @@ def deploy_services(body: dict):
|
|||||||
|
|
||||||
backup = _pre_change_backup(reason="pre-service-deploy")
|
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 ─────────────────────────────
|
# ── Step 1: Write Caddyfile.services ─────────────────────────────
|
||||||
caddy_content = _generate_caddyfile_services(services)
|
caddy_content = _generate_caddyfile_services(services)
|
||||||
@@ -4891,11 +4931,11 @@ def deploy_services(body: dict):
|
|||||||
# ── Step 4: WAN port forward ─────────────────────────────────────
|
# ── Step 4: WAN port forward ─────────────────────────────────────
|
||||||
tracked = _load_service_rules()
|
tracked = _load_service_rules()
|
||||||
if tracked.get("wan_443_uuid"):
|
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:
|
else:
|
||||||
pending_steps.append(
|
pending_steps.append(
|
||||||
f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
|
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.")
|
f"Or use the 'Create Port Forward' button above.")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -4905,7 +4945,7 @@ def deploy_services(body: dict):
|
|||||||
"errors": errors,
|
"errors": errors,
|
||||||
"backup": backup,
|
"backup": backup,
|
||||||
"caddy_content": caddy_content,
|
"caddy_content": caddy_content,
|
||||||
"mgmt_ip": mgmt_ip,
|
"caddy_ip": caddy_ip,
|
||||||
"nat_reflection_enabled": nat_status,
|
"nat_reflection_enabled": nat_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user