Add IoT+local services ACL template; fix offline DNS resilience guidance

New ACL template: "IoT VLAN — isolated + access local services via FQDN"
- Permits DNS to the configured resolver (so FQDNs resolve to LAN IPs)
- Permits traffic to the servers VLAN subnet only (NAS, Home Assistant, etc.)
- Blocks all other RFC1918 — users, cameras, management stay isolated
- Permits internet
- serverSubnet param auto-suggested from the vlans list if a VLAN named
  "Servers" exists; otherwise user enters it

Template description explains the full picture:
- How FQDN access works through the ACL (DNS → LAN IP → ACL permits it)
- Offline resilience: ctrld must be on OPNsense (not management PC) to
  survive internet outages; split-horizon in DNS tab makes *.lan resolve
  from local dnsmasq without any internet dependency
- Servers VLAN security: lock down servers VLAN inbound ACL by port
  so IoT can only reach specific service ports, not all server traffic

Strict IoT template description tightened — now clearly says "zero LAN
access" so users pick the right template for their use case.

Modal: serverSubnet field added alongside existing ctrldIp/nvrIp/pbxIp
params; preview correctly passes serverSubnet to the build function.

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-23 17:00:43 +00:00
parent 5c8536effd
commit 020162c1c8
2 changed files with 95 additions and 10 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+94 -9
View File
@@ -1029,8 +1029,8 @@ const ACL_TEMPLATES = [
}, },
{ {
id: "iot", id: "iot",
label: "IoT / TV / Printer VLAN — internet only, no RFC1918", label: "IoT / TV / Printer VLAN — internet only, strict isolation",
description: "Blocks all private IP ranges (RFC1918). Devices get internet but cannot reach any other VLAN — servers, NAS, PBX, cameras, management, or any LAN service. Use for IoT, smart TVs, printers, and any device that should never initiate connections to internal resources.", description: "Blocks ALL private IP ranges (RFC1918). Devices get internet only — cannot reach any other VLAN, server, NAS, PBX, camera, or management network. Use for devices that need zero LAN access and have no local services to reach.",
params: ["subnet"], params: ["subnet"],
build: ({ subnet, vid }) => ({ build: ({ subnet, vid }) => ({
name: `IOT-VLAN${vid}-POLICY`, name: `IOT-VLAN${vid}-POLICY`,
@@ -1051,6 +1051,72 @@ const ACL_TEMPLATES = [
], ],
}), }),
}, },
{
id: "iot-local-services",
label: "IoT VLAN — isolated + access local services via FQDN",
description: [
"For IoT devices that need to reach local servers (NAS, Home Assistant, etc.)",
"by FQDN while remaining isolated from all other VLANs.",
"",
"What this ACL does:",
" • Allows DNS queries to your resolver (so FQDNs resolve to LAN IPs)",
" • Allows traffic to your servers VLAN subnet only",
" • Blocks all other RFC1918 (user devices, cameras, management, etc.)",
" • Allows internet",
"",
"How FQDN access works:",
" Device queries 'nas.lan' → DNS returns 192.168.20.X → ACL permits that IP",
" The device never needs to know the IP. Works transparently.",
"",
"OFFLINE RESILIENCE — critical:",
" If ctrld is running on the management PC, DNS fails when that PC is off",
" or when internet is down (ctrld requires DoH3 connectivity to Control D).",
" → Deploy ctrld on OPNsense (DNS tab → OPNsense mode) so it stays up",
" independent of internet and independent of the management PC.",
" → Enable split-horizon in DNS tab so *.lan queries go to local dnsmasq,",
" which resolves from local-hostnames.json without any internet dependency.",
" Result: 'nas.lan' resolves correctly even with internet completely down.",
"",
"SERVERS VLAN security:",
" Granting IoT access to the servers VLAN subnet means ALL servers on that",
" subnet are reachable from IoT. Lock down the server VLAN inbound ACL to",
" only permit the specific ports each service needs (e.g. TCP 8123 for Home",
" Assistant, TCP 443 for internal HTTPS). Apply the ACL on the servers VLAN,",
" not here — that way it applies regardless of which VLAN initiates.",
].join("\n"),
params: ["subnet", "ctrldIp", "serverSubnet"],
build: ({ subnet, vid, ctrldIp, serverSubnet }) => {
const srvNet = serverSubnet || "192.168.20.0";
const dnsDst = ctrldIp || "RESOLVER_IP";
return {
name: `IOT-SVC-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit", proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:dnsDst, dstMask:"", dstAny:false, port:"53",
_comment: "DNS to resolver (UDP) — FQDNs resolve to LAN IPs" },
{ action:"permit", proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:dnsDst, dstMask:"", dstAny:false, port:"53",
_comment: "DNS to resolver (TCP)" },
{ action:"permit", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:srvNet, dstMask:"0.0.0.255", dstAny:false, port:"",
_comment: `Allow traffic to servers VLAN (${srvNet}/24)` },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",
_comment: "Block all other 192.168.x.x (users, cameras, management)" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"10.0.0.0", dstMask:"0.255.255.255", dstAny:false, port:"",
_comment: "Block 10.x.x.x" },
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"172.16.0.0", dstMask:"0.15.255.255", dstAny:false, port:"",
_comment: "Block 172.16-31.x.x" },
{ action:"permit", proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit internet" },
],
};
},
},
{ {
id: "guest", id: "guest",
label: "Guest VLAN — internet only, DNS must work first", label: "Guest VLAN — internet only, DNS must work first",
@@ -1159,18 +1225,24 @@ const ACL_TEMPLATES = [
]; ];
function AclTemplateModal({ vlans, onApply, onClose }) { function AclTemplateModal({ vlans, onApply, onClose }) {
const [tpl, setTpl] = useState(ACL_TEMPLATES[0].id); const [tpl, setTpl] = useState(ACL_TEMPLATES[0].id);
const [vid, setVid] = useState(vlans[0]?.id || 1); const [vid, setVid] = useState(vlans[0]?.id || 1);
const [ctrldIp, setCtrldIp] = useState(""); const [ctrldIp, setCtrldIp] = useState("");
const [nvrIp, setNvrIp] = useState(""); const [nvrIp, setNvrIp] = useState("");
const [pbxIp, setPbxIp] = useState(""); const [pbxIp, setPbxIp] = useState("");
const [serverSubnet, setServerSubnet] = useState("");
const tmpl = ACL_TEMPLATES.find(t => t.id === tpl); const tmpl = ACL_TEMPLATES.find(t => t.id === tpl);
const vlan = vlans.find(v => v.id === vid); const vlan = vlans.find(v => v.id === vid);
const subnet = `192.168.${vid}.0`; const subnet = `192.168.${vid}.0`;
// Auto-suggest servers subnet from vlans list
const serversVlan = vlans.find(v => v.name?.toLowerCase().includes("server") && v.id !== 99);
const serverSubnetPlaceholder = serversVlan ? `192.168.${serversVlan.id}.0` : "e.g. 192.168.20.0";
const apply = () => { const apply = () => {
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp, pbxIp }); const srvSubnet = serverSubnet || serverSubnetPlaceholder;
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp, pbxIp, serverSubnet: srvSubnet });
// Strip _comment keys — they are just for display here // Strip _comment keys — they are just for display here
acl.rules = acl.rules.map(({ _comment, ...r }) => r); acl.rules = acl.rules.map(({ _comment, ...r }) => r);
acl.applyVlan = vid; acl.applyVlan = vid;
@@ -1238,6 +1310,19 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
</div> </div>
)} )}
{tmpl?.params.includes("serverSubnet") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>Servers VLAN subnet</label>
<input value={serverSubnet} onChange={e=>setServerSubnet(e.target.value)}
placeholder={serverSubnetPlaceholder}
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
<div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>
Network address of the VLAN your servers live on (e.g. NAS, Home Assistant).
Leave blank to use <span style={{fontFamily:"var(--mono)"}}>{serverSubnetPlaceholder}</span>.
</div>
</div>
)}
{/* Preview */} {/* Preview */}
{tmpl?.params.includes("pbxIp") && ( {tmpl?.params.includes("pbxIp") && (
<div className="field" style={{margin:"0 0 10px"}}> <div className="field" style={{margin:"0 0 10px"}}>
@@ -1258,7 +1343,7 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
fontFamily:"var(--mono)",fontSize:10,color:"#7090a0", fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",
lineHeight:1.7,marginBottom:14,maxHeight:180,overflowY:"auto", lineHeight:1.7,marginBottom:14,maxHeight:180,overflowY:"auto",
}}> }}>
{tmpl.build({subnet,vid,ctrldIp,nvrIp,pbxIp}).rules.map((r,i)=>( {tmpl.build({subnet,vid,ctrldIp,nvrIp,pbxIp,serverSubnet:serverSubnet||serverSubnetPlaceholder}).rules.map((r,i)=>(
<div key={i}> <div key={i}>
<span style={{color:"#566"}}>{` ${i+1} `}</span> <span style={{color:"#566"}}>{` ${i+1} `}</span>
<span style={{color:r.action==="permit"?"#0e7":"#f55"}}>{r.action}</span> <span style={{color:r.action==="permit"?"#0e7":"#f55"}}>{r.action}</span>