Wire services end-to-end: Caddy reload, NAT reflection, port forward
Fully wired service proxy deployment: Backend: - /api/services/status: full checklist (OPNsense API, SSH, NAT reflection, port forward 443, Caddyfile.services, service count) - /api/services/enable-nat-reflection: enables NAT reflection on OPNsense via SSH config.xml edit + filter reload - /api/services/create-port-forward: creates WAN TCP 443 → Caddy port forward via OPNsense NAT API, tracks rule UUID - /api/services/deploy: writes Caddyfile.services, reloads Caddy (tries docker compose exec, then restart, then systemctl), checks NAT reflection status, verifies port forward exists Infrastructure: - docker-compose.yml: mount Caddyfile.services into Caddy container, switch-manager volume writable (for writing Caddyfile.services) - Caddyfile.template: auto-imports /etc/caddy/Caddyfile.services Frontend: - Setup Checklist panel with green/red dots for each prerequisite - Enable NAT Reflection button (one-click) - Create Port Forward button (one-click) - Deploy button writes Caddyfile, reloads Caddy, verifies everything - Caddyfile preview in deploy results https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
+2
-3
@@ -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
|
||||
|
||||
+2
-1
@@ -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:
|
||||
|
||||
+90
-32
@@ -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}) => (
|
||||
<div style={{display:"flex",alignItems:"center",gap:8,padding:"6px 0"}}>
|
||||
<span className={`dot ${ok === true ? "ok" : ok === false ? "err" : "idle"}`}/>
|
||||
<span style={{fontSize:12,flex:1}}>{label}</span>
|
||||
{ok === false && action && (
|
||||
<button className="btn bp" style={{fontSize:11,padding:"4px 12px"}}
|
||||
onClick={action} disabled={loading}>
|
||||
{loading ? "..." : actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
{/* Architecture explanation */}
|
||||
<div className="panel">
|
||||
<div className="ph">Services — Caddy Reverse Proxy + NAT Reflection</div>
|
||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
||||
<div style={{marginBottom:8}}>
|
||||
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.
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -5093,22 +5135,44 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
fontFamily:"monospace",fontSize:11,lineHeight:2,
|
||||
}}>
|
||||
<div style={{color:"var(--ac)",fontWeight:700,marginBottom:4,fontFamily:"inherit",fontSize:12}}>
|
||||
How isolated VLANs reach services:
|
||||
How isolated VLANs reach services (NAT reflection):
|
||||
</div>
|
||||
<div>1. IoT TV (VLAN 30) asks DNS for <span style={{color:"var(--ac)"}}>plex.mydomain.com</span></div>
|
||||
<div>2. DNS returns your <span style={{color:"#00e676"}}>public IP</span></div>
|
||||
<div>3. OPNsense sees "that's my WAN IP" → <span style={{color:"#ff6d00"}}>NAT reflection</span> routes internally</div>
|
||||
<div>4. Port forward sends to Caddy → Caddy proxies to Plex</div>
|
||||
<div>3. OPNsense: "that's my WAN IP" → <span style={{color:"#ff6d00"}}>NAT reflection</span> → routes internally</div>
|
||||
<div>4. Port forward → Caddy (management computer) → reverse proxy to <span style={{color:"#ff6d00"}}>192.168.1.x:port</span></div>
|
||||
<div>5. <span style={{color:"#00e676"}}>Traffic never leaves your network. Full VLAN isolation.</span></div>
|
||||
</div>
|
||||
<div style={{marginTop:8,color:"var(--tx)",fontWeight:600}}>
|
||||
IoT = untrusted = treated exactly like an external user. No pinholes, no cross-VLAN access.
|
||||
IoT = untrusted = treated exactly like an external user. No pinholes. No cross-VLAN access.
|
||||
</div>
|
||||
<div style={{marginTop:6,fontSize:11,color:"var(--dm)"}}>
|
||||
Requires: OPNsense NAT reflection enabled (Firewall > Settings > Advanced > Reflection for port forwards)
|
||||
+ WAN port forward TCP 443 → management computer (Caddy).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Checklist */}
|
||||
<div className="panel">
|
||||
<div className="ph">Setup Checklist</div>
|
||||
<div className="pb">
|
||||
<Check ok={status?.opnsense_configured} label="OPNsense API connected" />
|
||||
<Check ok={status?.opnsense_ssh} label="OPNsense SSH connected" />
|
||||
<Check ok={status?.nat_reflection}
|
||||
label={`NAT reflection ${status?.nat_reflection ? "enabled" : "not enabled — required for isolated VLANs"}`}
|
||||
action={enableNatReflection} actionLabel="Enable NAT Reflection"
|
||||
loading={actionLoading === "nat"} />
|
||||
<Check ok={status?.port_forward_443}
|
||||
label={`WAN port forward 443 → ${status?.mgmt_ip || "?"}:443 (Caddy)`}
|
||||
action={createPortForward} actionLabel="Create Port Forward"
|
||||
loading={actionLoading === "pf"} />
|
||||
<Check ok={status?.caddy_file_exists}
|
||||
label="Caddyfile.services exists" />
|
||||
<Check ok={services.length > 0}
|
||||
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>
|
||||
|
||||
{/* Add Service Form */}
|
||||
@@ -5116,9 +5180,9 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
<div className="ph">Add Service</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
||||
<div className="field"><label>Service FQDN</label>
|
||||
<div className="field"><label>Public FQDN (what users type)</label>
|
||||
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
|
||||
placeholder="plex.home.lan"/>
|
||||
placeholder="plex.mydomain.com"/>
|
||||
</div>
|
||||
<div className="field"><label>Backend (LAN server IP:port)</label>
|
||||
<input value={form.backend_url} onChange={e => setForm(f => ({...f, backend_url: e.target.value}))}
|
||||
@@ -5129,18 +5193,12 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
placeholder="Plex Media Server"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
|
||||
style={{marginTop:12}}>
|
||||
Add Service
|
||||
</button>
|
||||
<div style={{marginTop:6,fontSize:11,color:"var(--dm)"}}>
|
||||
All VLANs can reach this service automatically (via their gateway). No per-VLAN selection needed.
|
||||
</div>
|
||||
style={{marginTop:12}}>Add Service</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service List */}
|
||||
{/* Service List + Deploy */}
|
||||
{services.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="ph">Configured Services ({services.length})</div>
|
||||
@@ -5153,10 +5211,8 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
<td style={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
|
||||
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
||||
<td>{s.description || "—"}</td>
|
||||
<td>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => removeService(s.fqdn)}>Remove</button>
|
||||
</td>
|
||||
<td><button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => removeService(s.fqdn)}>Remove</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -5164,10 +5220,10 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
|
||||
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
|
||||
<button className="btn bp" onClick={deploy} disabled={deploying} style={{padding:"10px 24px"}}>
|
||||
{deploying ? "Deploying..." : "Deploy All Services"}
|
||||
{deploying ? "Deploying..." : "Deploy"}
|
||||
</button>
|
||||
<span style={{fontSize:11,color:"var(--dm)"}}>
|
||||
Updates Caddyfile + checks NAT reflection on OPNsense
|
||||
Writes Caddyfile.services, reloads Caddy, verifies NAT reflection + port forward
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5187,12 +5243,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||
))}
|
||||
{deployResult.pending_steps?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--warn, #ffea00)"}}><span>manual </span>{s}</div>
|
||||
<div key={i} style={{fontSize:12,color:"#ffea00"}}><span>todo </span>{s}</div>
|
||||
))}
|
||||
{deployResult.architecture && (
|
||||
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
|
||||
{deployResult.architecture}
|
||||
</div>
|
||||
{deployResult.caddy_content && (
|
||||
<details style={{marginTop:8}}>
|
||||
<summary style={{cursor:"pointer",color:"var(--ac)",fontSize:11}}>View Caddyfile.services</summary>
|
||||
<pre style={{fontSize:10,maxHeight:200,overflow:"auto",marginTop:4,
|
||||
background:"var(--bg)",padding:8,borderRadius:4}}>{deployResult.caddy_content}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+262
-48
@@ -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 <system>
|
||||
# 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\\ <enablenatreflectionhelper>1<\\/enablenatreflectionhelper>' /conf/config.xml 2>/dev/null || "
|
||||
"sed -i '/<\\/system>/i\\ <enablenatreflectionhelper>1<\\/enablenatreflectionhelper>' /conf/config.xml",
|
||||
# Remove disablenatreflection if present
|
||||
"sed -i '' '/<disablenatreflection>/d' /conf/config.xml 2>/dev/null || "
|
||||
"sed -i '/<disablenatreflection>/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
|
||||
# ── 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(
|
||||
"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)")
|
||||
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."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user