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",
label: "IoT / TV / Printer VLAN — internet only, no RFC1918",
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.",
label: "IoT / TV / Printer VLAN — internet only, strict isolation",
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"],
build: ({ subnet, vid }) => ({
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",
label: "Guest VLAN — internet only, DNS must work first",
@@ -1159,18 +1225,24 @@ const ACL_TEMPLATES = [
];
function AclTemplateModal({ vlans, onApply, onClose }) {
const [tpl, setTpl] = useState(ACL_TEMPLATES[0].id);
const [vid, setVid] = useState(vlans[0]?.id || 1);
const [ctrldIp, setCtrldIp] = useState("");
const [nvrIp, setNvrIp] = useState("");
const [pbxIp, setPbxIp] = useState("");
const [tpl, setTpl] = useState(ACL_TEMPLATES[0].id);
const [vid, setVid] = useState(vlans[0]?.id || 1);
const [ctrldIp, setCtrldIp] = useState("");
const [nvrIp, setNvrIp] = useState("");
const [pbxIp, setPbxIp] = useState("");
const [serverSubnet, setServerSubnet] = useState("");
const tmpl = ACL_TEMPLATES.find(t => t.id === tpl);
const vlan = vlans.find(v => v.id === vid);
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 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
acl.rules = acl.rules.map(({ _comment, ...r }) => r);
acl.applyVlan = vid;
@@ -1238,6 +1310,19 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
</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 */}
{tmpl?.params.includes("pbxIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
@@ -1258,7 +1343,7 @@ function AclTemplateModal({ vlans, onApply, onClose }) {
fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",
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}>
<span style={{color:"#566"}}>{` ${i+1} `}</span>
<span style={{color:r.action==="permit"?"#0e7":"#f55"}}>{r.action}</span>