Rewrite services to use Caddy on LAN + NAT reflection for isolated VLANs

Previous approach tried to put a reverse proxy on OPNsense or poke
firewall holes — both wrong. Correct architecture:

- Caddy stays on LAN management computer (where it already is)
- WAN: port 443 forwarded to Caddy. Only port exposed externally.
- LAN devices reach services directly via Caddy
- Isolated VLANs (IoT, Guest) use public FQDNs (plex.mydomain.com)
- OPNsense NAT reflection handles this internally — traffic never
  leaves the network, but IoT is treated exactly like an external user
- Zero cross-VLAN access. No pinholes. Full isolation preserved.

IoT = untrusted = same access as someone on the internet. This is the
correct security model — no exceptions for "just one port."

Deploy endpoint now: writes Caddyfile entries, checks NAT reflection
status, provides setup checklist for port forward + reflection toggle.

https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
Claude
2026-03-28 01:28:30 +00:00
parent 3cdad0dcb5
commit df9914e1a1
2 changed files with 99 additions and 165 deletions
+17 -13
View File
@@ -5082,27 +5082,31 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
<div className="main">
<div style={{flex:1}}>
<div className="panel">
<div className="ph">Service Proxy FQDN Access Without Breaking VLAN Isolation</div>
<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}}>
Make LAN services reachable by FQDN from any VLAN <b style={{color:"var(--tx)"}}>without
any inter-VLAN access</b>. Devices never touch the service's VLAN directly.
Caddy on the LAN management computer is your reverse proxy for all services.
Only port 443 is forwarded from WAN. Service ports are never exposed externally.
</div>
<div style={{
padding:12,background:"var(--bg)",borderRadius:6,border:"1px solid var(--b2)",
fontFamily:"monospace",fontSize:11,lineHeight:2,
}}>
<div style={{color:"var(--ac)",fontWeight:700,marginBottom:4,fontFamily:"inherit",fontSize:12}}>
How it works:
How isolated VLANs reach services:
</div>
<div>1. IoT device (VLAN 30) asks DNS for <span style={{color:"var(--ac)"}}>plex.home.lan</span></div>
<div>2. Unbound returns <span style={{color:"#00e676"}}>192.168.30.1</span> (OPNsense gateway device can already reach this)</div>
<div>3. OPNsense reverse proxy (Caddy/HAProxy) forwards to actual server <span style={{color:"#ff6d00"}}>192.168.1.100:32400</span></div>
<div>4. Response returns the same path. <span style={{color:"#00e676"}}>IoT device never sees or touches LAN.</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>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>5. <span style={{color:"#00e676"}}>Traffic never leaves your network. Full VLAN isolation.</span></div>
</div>
<div style={{marginTop:8,color:"var(--ac)",fontWeight:600}}>
No firewall rules needed. No VLAN-to-VLAN access opened. All VLANs can already reach
their own gateway that's how they get internet. The gateway does the proxying.
<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).
</div>
</div>
</div>
@@ -5116,7 +5120,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
placeholder="plex.home.lan"/>
</div>
<div className="field"><label>Backend URL (actual server on LAN)</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}))}
placeholder="http://192.168.1.100:32400"/>
</div>
@@ -5163,7 +5167,7 @@ function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
{deploying ? "Deploying..." : "Deploy All Services"}
</button>
<span style={{fontSize:11,color:"var(--dm)"}}>
Pushes DNS overrides to Unbound + configures reverse proxy on OPNsense
Updates Caddyfile + checks NAT reflection on OPNsense
</span>
</div>