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:
Claude
2026-03-28 01:36:28 +00:00
parent df9914e1a1
commit 67806d0255
4 changed files with 358 additions and 86 deletions
+91 -33
View File
@@ -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,32 +5135,54 @@ 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.
</div>
<div style={{marginTop:6,fontSize:11,color:"var(--dm)"}}>
Requires: OPNsense NAT reflection enabled (Firewall &gt; Settings &gt; Advanced &gt; 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.
</div>
</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 */}
<div className="panel">
<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>
)}