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
|
redir https://{{host}}{{uri}} permanent
|
||||||
}}
|
}}
|
||||||
|
|
||||||
# Service proxies — auto-generated by switch-manager
|
# Service reverse proxy entries — auto-managed by switch-manager Services tab
|
||||||
# To include service proxy entries, add this line (uncommented) after deployment:
|
import /etc/caddy/Caddyfile.services
|
||||||
# import /etc/switch-manager/Caddyfile.services
|
|
||||||
|
|||||||
+2
-1
@@ -6,6 +6,7 @@ services:
|
|||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- /etc/switch-manager/Caddyfile.services:/etc/caddy/Caddyfile.services:ro
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -17,7 +18,7 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "8765"
|
- "8765"
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/switch-manager:/etc/switch-manager:ro
|
- /etc/switch-manager:/etc/switch-manager
|
||||||
- ./frontend/dist:/app/frontend/dist:ro
|
- ./frontend/dist:/app/frontend/dist:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+90
-32
@@ -5041,13 +5041,16 @@ function FirewallTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||||
const [services, setServices] = useState([]);
|
const [services, setServices] = useState([]);
|
||||||
const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" });
|
const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "" });
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
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 load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const d = await API("/services");
|
const [svc, st] = await Promise.all([API("/services"), API("/services/status")]);
|
||||||
setServices(d.services || []);
|
setServices(svc.services || []);
|
||||||
|
setStatus(st);
|
||||||
} catch(e) { console.error(e); }
|
} catch(e) { console.error(e); }
|
||||||
};
|
};
|
||||||
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
||||||
@@ -5068,24 +5071,63 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
await load();
|
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 () => {
|
const deploy = async () => {
|
||||||
if (!session) { onNeedAuth(); return; }
|
if (!session) { onNeedAuth(); return; }
|
||||||
setDeploying(true); setDeployResult(null);
|
setDeploying(true); setDeployResult(null);
|
||||||
try {
|
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);
|
setDeployResult(r);
|
||||||
|
await load();
|
||||||
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
|
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
|
||||||
setDeploying(false);
|
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 (
|
return (
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
|
{/* Architecture explanation */}
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="ph">Services — Caddy Reverse Proxy + NAT Reflection</div>
|
<div className="ph">Services — Caddy Reverse Proxy + NAT Reflection</div>
|
||||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
||||||
<div style={{marginBottom: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.
|
Only port 443 is forwarded from WAN. Service ports are never exposed externally.
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -5093,22 +5135,44 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
fontFamily:"monospace",fontSize:11,lineHeight:2,
|
fontFamily:"monospace",fontSize:11,lineHeight:2,
|
||||||
}}>
|
}}>
|
||||||
<div style={{color:"var(--ac)",fontWeight:700,marginBottom:4,fontFamily:"inherit",fontSize:12}}>
|
<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>
|
||||||
<div>1. IoT TV (VLAN 30) asks DNS for <span style={{color:"var(--ac)"}}>plex.mydomain.com</span></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>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>3. OPNsense: "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>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>5. <span style={{color:"#00e676"}}>Traffic never leaves your network. Full VLAN isolation.</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{marginTop:8,color:"var(--tx)",fontWeight:600}}>
|
<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>
|
||||||
<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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Add Service Form */}
|
{/* Add Service Form */}
|
||||||
@@ -5116,9 +5180,9 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
<div className="ph">Add Service</div>
|
<div className="ph">Add Service</div>
|
||||||
<div className="pb">
|
<div className="pb">
|
||||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
<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}))}
|
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
|
||||||
placeholder="plex.home.lan"/>
|
placeholder="plex.mydomain.com"/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field"><label>Backend (LAN server IP:port)</label>
|
<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}))}
|
<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"/>
|
placeholder="Plex Media Server"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
|
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
|
||||||
style={{marginTop:12}}>
|
style={{marginTop:12}}>Add Service</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Service List */}
|
{/* Service List + Deploy */}
|
||||||
{services.length > 0 && (
|
{services.length > 0 && (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="ph">Configured Services ({services.length})</div>
|
<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={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
|
||||||
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
||||||
<td>{s.description || "—"}</td>
|
<td>{s.description || "—"}</td>
|
||||||
<td>
|
<td><button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
onClick={() => removeService(s.fqdn)}>Remove</button></td>
|
||||||
onClick={() => removeService(s.fqdn)}>Remove</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -5164,10 +5220,10 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
|
|
||||||
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
|
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
|
||||||
<button className="btn bp" onClick={deploy} disabled={deploying} style={{padding:"10px 24px"}}>
|
<button className="btn bp" onClick={deploy} disabled={deploying} style={{padding:"10px 24px"}}>
|
||||||
{deploying ? "Deploying..." : "Deploy All Services"}
|
{deploying ? "Deploying..." : "Deploy"}
|
||||||
</button>
|
</button>
|
||||||
<span style={{fontSize:11,color:"var(--dm)"}}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -5187,12 +5243,14 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
|||||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||||
))}
|
))}
|
||||||
{deployResult.pending_steps?.map((s,i) => (
|
{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 && (
|
{deployResult.caddy_content && (
|
||||||
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
|
<details style={{marginTop:8}}>
|
||||||
{deployResult.architecture}
|
<summary style={{cursor:"pointer",color:"var(--ac)",fontSize:11}}>View Caddyfile.services</summary>
|
||||||
</div>
|
<pre style={{fontSize:10,maxHeight:200,overflow:"auto",marginTop:4,
|
||||||
|
background:"var(--bg)",padding:8,borderRadius:4}}>{deployResult.caddy_content}</pre>
|
||||||
|
</details>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+262
-48
@@ -4558,37 +4558,215 @@ def delete_service(body: dict):
|
|||||||
return {"success": True, "services": services}
|
return {"success": True, "services": services}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/services/nat-reflection")
|
SERVICES_RULE_FILE = _Path("/etc/switch-manager/service-nat-rules.json")
|
||||||
def check_nat_reflection():
|
|
||||||
"""Check if NAT reflection is enabled on OPNsense."""
|
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()
|
cfg = _load_opnsense_cfg()
|
||||||
if not cfg.get("ssh_key_path"):
|
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:
|
try:
|
||||||
out, _, code = _opnsense_ssh_run(
|
# Try the OPNsense API approach first (Firewall > Settings)
|
||||||
cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
# The setting is under system > disablenatreflection (absent = enabled)
|
||||||
return {"configured": True, "likely_enabled": "1" in out.strip() or int(out.strip()) > 0}
|
# 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:
|
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")
|
@app.post("/api/services/deploy")
|
||||||
def deploy_services(body: dict):
|
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:
|
Steps:
|
||||||
- Caddy runs on the LAN management computer (reverse proxy for all services)
|
1. Pre-change backup
|
||||||
- WAN: port 443 forwarded to Caddy — only port exposed externally
|
2. Write Caddyfile.services with reverse proxy entries
|
||||||
- LAN devices: reach services directly via Caddy
|
3. Reload Caddy (docker compose exec or systemctl)
|
||||||
- Isolated VLANs (IoT, Guest): use public FQDNs — OPNsense NAT
|
4. Check/enable NAT reflection on OPNsense
|
||||||
reflection routes internally without traffic leaving the network
|
5. Check/create WAN port forward 443 → Caddy
|
||||||
- Full VLAN isolation preserved — IoT treated same as external users
|
6. Return status of each step
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
require_session(body.get("token", ""))
|
require_session(body.get("token", ""))
|
||||||
services = _load_services()
|
services = _load_services()
|
||||||
@@ -4599,48 +4777,89 @@ def deploy_services(body: dict):
|
|||||||
errors = []
|
errors = []
|
||||||
pending_steps = []
|
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)
|
caddy_content = _generate_caddyfile_services(services)
|
||||||
try:
|
try:
|
||||||
CADDYFILE_EXTRA.write_text(caddy_content)
|
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:
|
except Exception as e:
|
||||||
errors.append(f"Caddyfile write: {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()
|
cfg = _load_opnsense_cfg()
|
||||||
nat_status = None
|
nat_status = None
|
||||||
if cfg.get("ssh_key_path"):
|
if cfg.get("ssh_key_path"):
|
||||||
try:
|
try:
|
||||||
out, _, code = _opnsense_ssh_run(
|
out, _, _ = _opnsense_ssh_run(cfg,
|
||||||
cfg, "grep -c 'enablenatreflectionhelper' /conf/config.xml 2>/dev/null || echo 0")
|
"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_enabled = "1" in out.strip() or (out.strip().isdigit() and int(out.strip()) > 0)
|
||||||
nat_status = nat_enabled
|
nat_status = nat_enabled
|
||||||
if nat_enabled:
|
if nat_enabled:
|
||||||
steps_done.append("NAT reflection: enabled on OPNsense")
|
steps_done.append("NAT reflection: already enabled")
|
||||||
else:
|
else:
|
||||||
pending_steps.append(
|
pending_steps.append(
|
||||||
"Enable NAT reflection: OPNsense > Firewall > Settings > Advanced > "
|
"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:
|
except Exception as e:
|
||||||
errors.append(f"NAT reflection check: {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
|
# ── Step 4: WAN port forward ─────────────────────────────────────
|
||||||
if cfg.get("key"):
|
tracked = _load_service_rules()
|
||||||
try:
|
if tracked.get("wan_443_uuid"):
|
||||||
rules = _opnsense_request(cfg, "firewall/filter/searchRule")
|
steps_done.append(f"WAN port forward 443 → {tracked.get('mgmt_ip', mgmt_ip)}:443 (tracked)")
|
||||||
# This is a best-effort check — port forwards are in NAT, not filter
|
else:
|
||||||
pending_steps.append(
|
pending_steps.append(
|
||||||
"Verify WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
|
f"Create WAN port forward: OPNsense > Firewall > NAT > Port Forward — "
|
||||||
"WAN TCP 443 → management computer IP:443 (Caddy)")
|
f"WAN TCP 443 → {mgmt_ip}:443 (Caddy). "
|
||||||
except Exception:
|
f"Or use the 'Create Port Forward' button above.")
|
||||||
pass
|
|
||||||
|
|
||||||
pending_steps.append(
|
|
||||||
"Reload Caddy on management computer: "
|
|
||||||
"docker compose restart caddy (or: caddy reload)")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": len(errors) == 0,
|
"success": len(errors) == 0,
|
||||||
@@ -4649,13 +4868,8 @@ 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,
|
||||||
"nat_reflection_enabled": nat_status,
|
"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