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:
+91
-33
@@ -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 > 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.
|
||||
</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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user