Add DNS enforcement, ACL templates, local hostnames, ctrld format fix

Features added:
- Port 53 conflict resolution: auto-detect/fix systemd-resolved stub listener
  on Linux; instructions for OPNsense Unbound (ctrld auto-terminates it)
- DNS enforcement ACLs: generate ERS 5952 ACL commands that permit DNS only
  to ctrld IP and block all other port 53/853 traffic per VLAN
- Inter-VLAN routing ACL templates: Staff, IoT, Guest, Camera profiles with
  live preview and parameter inputs (ctrld IP, NVR IP, subnet)
- Local hostname resolution: dnsmasq Docker service for .lan split-horizon DNS;
  manage hostname→IP mappings via UI; generates dnsmasq.conf and ctrld.toml
  upstream.local block
- Fix ctrld.toml format: correct [listener.0], [network.N], [upstream.N] table
  notation (was using wrong [[array]] notation); matches official docs format
- Backend docstrings: added docstrings to all previously undocumented functions
- README: new sections for port 53 conflict resolution, DNS enforcement ACLs,
  ACL templates, and local hostname resolution (dnsmasq)
- Fix Python 3.11 f-string syntax errors in Avaya_5952_setup.py (backslash
  in f-string expressions, same-type quote in dict access); embed now succeeds

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-23 13:16:59 +00:00
parent 81f6afd697
commit 360fbb5606
4 changed files with 1367 additions and 88 deletions
+636 -2
View File
@@ -1005,18 +1005,258 @@ function VlanTab({ vlans, setVlans, ports }) {
);
}
// ── ACL Templates ──────────────────────────────────────────────────────────
// Each template is a factory function: takes { subnet, ctrldIp, nvrIp } and
// returns { name, direction, rules[] } ready to paste into the ACL card list.
const ACL_TEMPLATES = [
{
id: "staff",
label: "Staff VLAN — full internet, no management",
description: "Allows everything except access to the management VLAN (99). Use on a staff or office VLAN.",
params: ["subnet"],
build: ({ subnet, vid }) => ({
name: `STAFF-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"deny", proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:"192.168.99.0", dstMask:"0.0.0.255", dstAny:false, port:"",
_comment: "Block access to management VLAN 99" },
{ action:"permit",proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Permit everything else" },
],
}),
},
{
id: "iot",
label: "IoT VLAN — internet only, no RFC1918",
description: "Blocks access to all private IP ranges (RFC1918). IoT devices get internet but cannot reach other VLANs, servers, or management.",
params: ["subnet"],
build: ({ subnet, vid }) => ({
name: `IOT-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ 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 192.168.x.x (other VLANs, 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",
description: "Like IoT but DNS to ctrld is explicitly permitted first. Prevents guests from bypassing DNS filtering while still blocking all RFC1918 access.",
params: ["subnet", "ctrldIp"],
build: ({ subnet, vid, ctrldIp }) => ({
name: `GUEST-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit",proto:"udp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:ctrldIp||"CTRLD_IP", dstMask:"", dstAny:false, port:"53",
_comment: "Permit DNS to ctrld (DHCP-assigned resolver)" },
{ action:"permit",proto:"tcp", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:ctrldIp||"CTRLD_IP", dstMask:"", dstAny:false, port:"53",
_comment: "Permit DNS/TCP to ctrld" },
{ 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 192.168.x.x" },
{ 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: "camera",
label: "Camera VLAN — NVR only",
description: "Cameras can only talk to one NVR/DVR IP. All other traffic is dropped. Prevents cameras from phoning home or scanning the network.",
params: ["subnet", "nvrIp"],
build: ({ subnet, vid, nvrIp }) => ({
name: `CAMERA-VLAN${vid}-POLICY`,
direction: "in",
rules: [
{ action:"permit",proto:"ip", src:subnet, srcMask:"0.0.0.255", srcAny:false,
dst:nvrIp||"NVR_IP", dstMask:"", dstAny:false, port:"",
_comment: "Permit traffic to NVR/DVR only" },
{ action:"deny", proto:"ip", src:"", srcMask:"", srcAny:true,
dst:"", dstMask:"", dstAny:true, port:"",
_comment: "Drop everything else" },
],
}),
},
];
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 tmpl = ACL_TEMPLATES.find(t => t.id === tpl);
const vlan = vlans.find(v => v.id === vid);
const subnet = `192.168.${vid}.0`;
const apply = () => {
const acl = tmpl.build({ subnet, vid, ctrldIp, nvrIp });
// Strip _comment keys — they are just for display here
acl.rules = acl.rules.map(({ _comment, ...r }) => r);
acl.applyVlan = vid;
onApply(acl);
onClose();
};
return (
<div style={{
position:"fixed",inset:0,background:"rgba(0,0,0,.65)",
display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,
}} onClick={onClose}>
<div style={{
background:"var(--bg2)",border:"1px solid var(--b2)",borderRadius:8,
padding:24,maxWidth:560,width:"90%",
}} onClick={e=>e.stopPropagation()}>
<div style={{fontWeight:700,fontSize:14,marginBottom:14}}>
ACL Template
<button className="btn bg" style={{float:"right",fontSize:10,padding:"2px 8px"}}
onClick={onClose}>✕</button>
</div>
{/* Template selector */}
<div className="field" style={{margin:"0 0 12px"}}>
<label>Template</label>
<select value={tpl} onChange={e=>setTpl(e.target.value)}>
{ACL_TEMPLATES.map(t=><option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
{tmpl && (
<div style={{fontSize:11,color:"var(--dm)",lineHeight:1.7,marginBottom:12,
padding:"8px 12px",background:"var(--bg)",borderRadius:4,
border:"1px solid var(--b1)"}}>
{tmpl.description}
</div>
)}
{/* VLAN selector */}
<div className="field" style={{margin:"0 0 10px"}}>
<label>Apply to VLAN</label>
<select value={vid} onChange={e=>setVid(+e.target.value)}>
{vlans.filter(v=>v.id!==99).map(v=>
<option key={v.id} value={v.id}>{v.id} — {v.name}</option>)}
</select>
</div>
{/* Extra params */}
{tmpl?.params.includes("ctrldIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>ctrld IP address</label>
<input value={ctrldIp} onChange={e=>setCtrldIp(e.target.value)}
placeholder="e.g. 192.168.99.50"
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
<div style={{fontSize:10,color:"var(--dm)",marginTop:3}}>
IP of the machine running ctrld — shown in DNS tab after install
</div>
</div>
)}
{tmpl?.params.includes("nvrIp") && (
<div className="field" style={{margin:"0 0 10px"}}>
<label>NVR / DVR IP address</label>
<input value={nvrIp} onChange={e=>setNvrIp(e.target.value)}
placeholder="e.g. 192.168.30.10"
style={{fontFamily:"var(--mono)",maxWidth:200}}/>
</div>
)}
{/* Preview */}
{tmpl && (
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#7090a0",
lineHeight:1.7,marginBottom:14,maxHeight:160,overflowY:"auto",
}}>
{tmpl.build({subnet,vid,ctrldIp,nvrIp}).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>
{` ${r.proto} `}
<span style={{color:"#a0c0d0"}}>
{r.srcAny?"any":`${r.src||"?"} ${r.srcMask||""}`}
</span>
{" → "}
<span style={{color:"#a0c0d0"}}>
{r.dstAny?"any":`${r.dst||"?"} ${r.dstMask||""}`}
</span>
{r.port?<span style={{color:"#fa0"}}>{` eq ${r.port}`}</span>:null}
{r._comment && <span style={{color:"#445"}}>{` # ${r._comment}`}</span>}
</div>
))}
</div>
)}
<div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>
<button className="btn bg" onClick={onClose}>Cancel</button>
<button className="btn bp" onClick={apply}>Apply Template</button>
</div>
</div>
</div>
);
}
function AclTab({ acls, setAcls, vlans }) {
const [nn,setNn]=useState(""); const [nv,setNv]=useState(vlans[0]?.id||1); const [nd,setNd]=useState("in");
const [showTplModal,setShowTplModal]=useState(false);
const addAcl=()=>{if(!nn)return;setAcls([...acls,{name:nn,applyVlan:nv,direction:nd,rules:[]}]);setNn("");};
const addRule=name=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:[...a.rules,{action:"deny",proto:"ip",src:"",srcMask:"0.0.0.255",srcAny:true,dst:"",dstMask:"0.0.0.255",dstAny:true,port:""}]}));
const upRule=(name,idx,k,v)=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:a.rules.map((r,i)=>i===idx?{...r,[k]:v}:r)}));
const delRule=(name,idx)=>setAcls(acls.map(a=>a.name!==name?a:{...a,rules:a.rules.filter((_,i)=>i!==idx)}));
const applyTemplate=acl=>setAcls(prev=>{
// Replace if same name already exists, otherwise append
const idx=prev.findIndex(a=>a.name===acl.name);
return idx>=0?prev.map((a,i)=>i===idx?acl:a):[...prev,acl];
});
return (
<div className="main">
<div style={{flex:1}}>
{showTplModal && (
<AclTemplateModal vlans={vlans} onApply={applyTemplate} onClose={()=>setShowTplModal(false)}/>
)}
<div className="panel">
<div className="ph">◈ ACL Builder</div>
<div className="ph">◈ ACL Builder
<button className="btn bg" style={{marginLeft:"auto",fontSize:10,padding:"3px 10px"}}
onClick={()=>setShowTplModal(true)}>
Use Template
</button>
</div>
<div className="pb">
{/* Templates hint when empty */}
{acls.length===0&&(
<div style={{marginBottom:12}}>
<div style={{
padding:"10px 14px",background:"rgba(0,229,255,.04)",
border:"1px solid rgba(0,229,255,.12)",borderRadius:4,
fontSize:11,color:"var(--dm)",lineHeight:1.7,
}}>
<span style={{color:"var(--ac)",fontWeight:700}}>Templates available: </span>
Click <strong>Use Template</strong> to pre-fill rules for common
patterns: Staff (full internet, no management), IoT (internet only),
Guest (internet only + DNS enforcement), or Camera (NVR only).
All rules are editable before pushing.
</div>
</div>
)}
{acls.length===0&&<div className="empty">No ACLs defined.</div>}
{acls.map(acl=>(
<div key={acl.name} className="acl-card">
@@ -1188,6 +1428,8 @@ export default function App() {
session={session}
onNeedAuth={() => setShowTotp(true)}
backendOk={pollStatus!=="err"}
acls={acls}
setAcls={setAcls}
/>}
{tab==="vpn" && <WireGuardTab
session={session}
@@ -2101,6 +2343,215 @@ function DHCPTab({ session, onNeedAuth, backendOk }) {
);
}
// ══════════════════════════════════════════════════════════════════════════════
// LOCAL HOSTNAME PANEL — dnsmasq .lan resolution
// ══════════════════════════════════════════════════════════════════════════════
function LocalHostnamesPanel({ session, onNeedAuth }) {
const [entries, setEntries] = useState([]); // [{ name, ip }]
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState(null);
const [showConf, setShowConf] = useState(false);
const [localDomain, setLocalDomain] = useState("lan");
const load = async () => {
try {
const r = await API("/dns/local-hostnames");
setEntries(r.entries || []);
setLoaded(true);
} catch(e) { setLoaded(true); }
};
useEffect(() => { load(); }, []);
const addEntry = () => setEntries(prev => [...prev, { name:"", ip:"" }]);
const upEntry = (i, k, v) => setEntries(prev => prev.map((e,idx) => idx===i?{...e,[k]:v}:e));
const delEntry = i => setEntries(prev => prev.filter((_,idx)=>idx!==i));
const save = async () => {
if (!session) { onNeedAuth(); return; }
setSaving(true); setResult(null);
try {
const r = await API("/dns/local-hostnames", {
method:"POST",
body: { token:session.token, entries, local_domain:localDomain }
});
setResult(r);
} catch(e) {
setResult({ success:false, message:e.message });
}
setSaving(false);
};
return (
<div className="panel">
<div className="ph"> Local Hostnames (.lan resolution)
<span style={{marginLeft:"auto",fontSize:10,color:"var(--dm)"}}>
Optional needs dnsmasq Docker service
</span>
</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:12}}>
Add a <strong>dnsmasq</strong> container to resolve <code>.lan</code> hostnames
for all devices. ctrld forwards <code>*.lan</code> queries to dnsmasq on port 5353;
all other queries go through Control D as normal.
<br/>
<code style={{fontFamily:"var(--mono)",color:"var(--ac)",fontSize:11}}>
switch.mgmt.lan
</code>{" "}and{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)",fontSize:11}}>
management.lan
</code>{" "}
always resolve to the management computer's IP.
</div>
<div style={{display:"flex",gap:10,marginBottom:12,alignItems:"flex-end"}}>
<div className="field" style={{margin:0}}>
<label>Local domain suffix</label>
<input value={localDomain} onChange={e=>setLocalDomain(e.target.value)}
placeholder="lan"
style={{fontFamily:"var(--mono)",maxWidth:120}}/>
</div>
<div style={{fontSize:10,color:"var(--dm)",paddingBottom:4}}>
Queries for <code>*.{localDomain}</code> and <code>*.local</code> are
forwarded to dnsmasq (port 5353).
</div>
</div>
{/* Hostname table */}
{entries.length > 0 && (
<table style={{width:"100%",borderCollapse:"collapse",marginBottom:10}}>
<thead>
<tr style={{borderBottom:"1px solid var(--b1)"}}>
{["Hostname","IP Address",""].map(h=>(
<th key={h} style={{textAlign:"left",padding:"4px 8px",fontSize:10,
letterSpacing:2,textTransform:"uppercase",color:"var(--dm)"}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e,i)=>(
<tr key={i} style={{borderBottom:"1px solid var(--b1)"}}>
<td style={{padding:"4px 8px"}}>
<input value={e.name} onChange={ev=>upEntry(i,"name",ev.target.value)}
placeholder={`printer.${localDomain}`}
style={{
width:"100%",background:"var(--bg)",border:"1px solid var(--b2)",
color:"var(--tx)",padding:"3px 6px",fontFamily:"var(--mono)",
fontSize:11,borderRadius:3,
}}/>
</td>
<td style={{padding:"4px 8px"}}>
<input value={e.ip} onChange={ev=>upEntry(i,"ip",ev.target.value)}
placeholder="192.168.10.50"
style={{
width:"100%",background:"var(--bg)",border:"1px solid var(--b2)",
color:"var(--tx)",padding:"3px 6px",fontFamily:"var(--mono)",
fontSize:11,borderRadius:3,
}}/>
</td>
<td style={{padding:"4px 8px"}}>
<button className="btn bd" style={{padding:"2px 6px",fontSize:10}}
onClick={()=>delEntry(i)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{display:"flex",gap:8,flexWrap:"wrap",marginBottom: result?12:0}}>
<button className="btn bg" style={{fontSize:10}} onClick={addEntry}>+ Add Hostname</button>
<button className="btn bp" style={{fontSize:10}}
onClick={save} disabled={saving||!session}>
{saving?"Saving...":"Save & Generate dnsmasq.conf"}
</button>
{!session && (
<button className="btn bg" style={{fontSize:10}} onClick={onNeedAuth}>Authenticate</button>
)}
</div>
{/* Result */}
{result && result.success && (
<div style={{marginTop:10}}>
<div style={{
padding:"8px 12px",borderRadius:4,marginBottom:10,fontSize:11,
background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",
color:"var(--dm)",lineHeight:1.7,
}}>
<span style={{color:"var(--ok)",fontWeight:700}}>✓ </span>
{result.message}
</div>
{/* dnsmasq.conf */}
<div style={{marginBottom:10}}>
<div style={{display:"flex",gap:6,marginBottom:6,alignItems:"center"}}>
<span style={{fontSize:11,color:"var(--dm)"}}>
dnsmasq.conf written to{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>{result.conf_path}</code>
</span>
<button className="btn bg" style={{fontSize:10,padding:"1px 7px"}}
onClick={()=>setShowConf(s=>!s)}>{showConf?"Hide":"Show"} config</button>
<button className="btn bg" style={{fontSize:10,padding:"1px 7px"}}
onClick={()=>navigator.clipboard?.writeText(result.dnsmasq_conf)}>Copy</button>
</div>
{showConf && (
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",maxHeight:180,overflowY:"auto",
}}>{result.dnsmasq_conf}</div>
)}
</div>
{/* docker-compose snippet */}
<div style={{marginBottom:10}}>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:4}}>
Add to docker-compose.yml then run:{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>docker compose up -d dnsmasq</code>
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",
}}>{result.docker_compose_snippet}</div>
<button className="btn bg" style={{marginTop:6,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(result.docker_compose_snippet)}>
Copy compose snippet
</button>
</div>
{/* ctrld.toml split-horizon block */}
<div>
<div style={{fontSize:11,color:"var(--dm)",marginBottom:4}}>
Append to ctrld.toml (before the fallback upstream):
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",
}}>{result.split_horizon}</div>
<button className="btn bg" style={{marginTop:6,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(result.split_horizon)}>
Copy toml block
</button>
</div>
</div>
)}
{result && !result.success && (
<div style={{
marginTop:10,padding:"8px 12px",borderRadius:4,fontSize:11,
background:"rgba(255,100,100,.06)",border:"1px solid rgba(255,100,100,.2)",
color:"var(--err)",
}}>✗ {result.message}</div>
)}
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════════════════════════
// DNS FILTERING TAB — Control D / ctrld
// ══════════════════════════════════════════════════════════════════════════════
@@ -2120,6 +2571,10 @@ How it works:
• ctrld sees the source VLAN subnet and routes to the right profile
• Each VLAN gets filtered by its own Control D profile via DoH3
Port 53 note: On Ubuntu/Debian, systemd-resolved holds port 53.
The installer automatically disables its stub listener (DNSStubListener=no)
before starting ctrld. The systemd-resolved service itself stays running.
Best for: most setups. Self-contained, no OPNsense required.
Requires: a Resolver ID per VLAN from your Control D dashboard.`,
docsUrl: "https://docs.controld.com/docs/ctrld",
@@ -2138,6 +2593,12 @@ How it works:
• OPNsense's IP becomes the DNS server for each VLAN
Per-VLAN routing uses source IP matching in ctrld config
Unbound conflict: OPNsense runs Unbound on port 53. The correct fix is:
1. Move Unbound to listen on 127.0.0.1:5353 (keep it for .lan names)
2. Run ctrld on port 53
3. Tell ctrld to forward *.lan / *.local to 127.0.0.1:5353
Instructions are shown in the result panel after generating the command.
Best for: setups where OPNsense is already the DNS server, or where
you want DNS handled at the router rather than the switch manager machine.
Requires: SSH access to OPNsense and a Resolver ID per VLAN.`,
@@ -2200,7 +2661,7 @@ function CtrldVlanRow({ vlan, profile, onChange }) {
);
}
function DNSTab({ vlans, session, onNeedAuth, backendOk }) {
function DNSTab({ vlans, session, onNeedAuth, backendOk, acls, setAcls }) {
const [status, setStatus] = useState(null);
const [mode, setMode] = useState(null);
const [profiles, setProfiles] = useState({});
@@ -2210,6 +2671,15 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk }) {
const [result, setResult] = useState(null);
const [showToml, setShowToml] = useState(false);
// DNS enforcement state
const [enforceIp, setEnforceIp] = useState("");
const [enforceLoading, setEnforceLoading] = useState(false);
const [enforceResult, setEnforceResult] = useState(null);
// Local domain split-horizon state
const [localDomain, setLocalDomain] = useState("lan");
const [showLocalDomain, setShowLocalDomain] = useState(false);
const load = async () => {
setLoading(true);
try {
@@ -2293,6 +2763,27 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk }) {
setSaving(false);
};
// Generate DNS enforcement ACLs for all non-management VLANs
const generateEnforceAcls = async () => {
if (!session) { onNeedAuth(); return; }
const ip = enforceIp || status?.dns_ip;
if (!ip) { alert("Enter the ctrld IP address first"); return; }
setEnforceLoading(true); setEnforceResult(null);
try {
const vlan_ids = vlans.filter(v => v.id !== 99).map(v => v.id);
const r = await API("/ctrld/dns-enforce-acls", {
method: "POST",
body: { token: session.token, ctrld_ip: ip, vlan_ids }
});
setEnforceResult(r);
// Pre-load commands into Review & Push by storing as a special ACL marker
// (user will copy them to the Review & Push tab)
} catch(e) {
setEnforceResult({ success: false, message: e.message });
}
setEnforceLoading(false);
};
const configuredProfiles = Object.values(profiles).filter(p => p.resolver_id);
const isInstalled = status?.installed;
const isRunning = status?.running;
@@ -2504,6 +2995,43 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk }) {
onClick={()=>navigator.clipboard?.writeText(result.ssh_cmd||result.install_cmd)}>
Copy Command
</button>
{/* OPNsense Unbound conflict resolution */}
<div style={{
marginTop:14,padding:"12px 14px",
background:"rgba(255,193,7,.06)",border:"1px solid rgba(255,193,7,.2)",
borderRadius:4,fontSize:11,lineHeight:1.8,
}}>
<div style={{fontWeight:700,color:"var(--warn)",marginBottom:6}}>
OPNsense Unbound conflict — port 53
</div>
<div style={{color:"var(--dm)"}}>
OPNsense runs Unbound DNS on port 53. ctrld needs port 53.
The right fix is to keep Unbound running (it resolves <code>.lan</code> hostnames)
but move it to <code>127.0.0.1:5353</code>, then run ctrld on <code>:53</code>.
</div>
<div style={{marginTop:8,fontWeight:600}}>Steps in OPNsense UI:</div>
<ol style={{margin:"4px 0 0 18px",color:"var(--dm)"}}>
<li>Services → Unbound DNS → General → change "Listen Port" to <code>5353</code>
and "Listen Interface" to <code>Loopback (lo0)</code>. Save + Apply.</li>
<li>Add a forwarding rule in ctrld.toml (shown in the TOML preview below)
to send <code>*.{localDomain}</code> and <code>*.local</code> to
<code>127.0.0.1:5353</code>.</li>
<li>Run <code>ctrld restart</code> on OPNsense after placing the new config.</li>
</ol>
<div style={{marginTop:8,display:"flex",alignItems:"center",gap:8}}>
<span style={{color:"var(--dm)"}}>Local domain:</span>
<input value={localDomain} onChange={e=>setLocalDomain(e.target.value)}
style={{
fontFamily:"var(--mono)",fontSize:11,maxWidth:120,
background:"var(--bg)",border:"1px solid var(--b2)",
color:"var(--tx)",padding:"2px 6px",borderRadius:3,
}}/>
<span style={{color:"var(--dm)",fontSize:10}}>
(default: lan — queries for *.{localDomain} forwarded to Unbound)
</span>
</div>
</div>
</div>
)}
@@ -2544,6 +3072,32 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk }) {
</div>
)}
{/* Port 53 fix notification (Option A) */}
{result.mode === "local" && result.port53?.needed && (
<div style={{
marginTop:12,padding:"10px 14px",borderRadius:4,fontSize:11,lineHeight:1.7,
background: result.port53.fixed
? "rgba(0,230,118,.06)" : "rgba(255,100,100,.06)",
border: result.port53.fixed
? "1px solid rgba(0,230,118,.2)" : "1px solid rgba(255,100,100,.2)",
}}>
<span style={{fontWeight:700,color:result.port53.fixed?"var(--ok)":"var(--err)"}}>
{result.port53.fixed ? "✓ " : "✗ "}Port 53 conflict:{" "}
</span>
<span style={{color:"var(--dm)"}}>{result.port53.message}</span>
{!result.port53.fixed && (
<div style={{marginTop:6,color:"var(--dm)"}}>
Fix manually:{" "}
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}>
echo "[Resolve]" | sudo tee -a /etc/systemd/resolved.conf
&amp;&amp; echo "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf
&amp;&amp; sudo systemctl restart systemd-resolved
</code>
</div>
)}
</div>
)}
{/* DHCP reminder */}
{result.success && (
<div style={{
@@ -2562,6 +3116,86 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk }) {
</div>
)}
{/* Local Hostname Resolution */}
<LocalHostnamesPanel session={session} onNeedAuth={onNeedAuth} />
{/* DNS Enforcement ACLs */}
<div className="panel">
<div className="ph">◈ Enforce DNS on Switch</div>
<div className="pb">
<div style={{fontSize:12,color:"var(--dm)",lineHeight:1.8,marginBottom:12}}>
Without enforcement, a device can ignore DHCP-assigned DNS and use
<code style={{fontFamily:"var(--mono)",color:"var(--ac)"}}> 8.8.8.8</code> directly,
bypassing all ctrld filtering. These ACLs block that:
<ul style={{margin:"6px 0 0 18px",lineHeight:1.9}}>
<li>Permit UDP/TCP port 53 <em>to ctrld only</em></li>
<li>Deny UDP/TCP port 53 to everywhere else</li>
<li>Deny TCP port 853 (DNS-over-TLS bypass)</li>
<li>Permit everything else (internet still works)</li>
</ul>
Generated ACLs are shown for review — push them via the
<strong> Review &amp; Push</strong> tab as usual.
</div>
<div style={{display:"flex",gap:10,alignItems:"flex-end",flexWrap:"wrap"}}>
<div className="field" style={{margin:0,flex:"0 0 auto"}}>
<label>ctrld IP address</label>
<input
value={enforceIp || (status?.dns_ip||"")}
onChange={e=>setEnforceIp(e.target.value)}
placeholder={status?.dns_ip || "e.g. 192.168.99.50"}
style={{fontFamily:"var(--mono)",maxWidth:200}}
/>
</div>
<button className="btn bp" style={{alignSelf:"flex-end"}}
onClick={generateEnforceAcls} disabled={enforceLoading||!session}>
{enforceLoading ? "Generating..." : "Generate Enforcement ACLs"}
</button>
{!session && (
<button className="btn bg" style={{alignSelf:"flex-end",fontSize:10}}
onClick={onNeedAuth}>Authenticate</button>
)}
</div>
{/* Enforcement result */}
{enforceResult && (
<div style={{marginTop:12}}>
{enforceResult.success ? (
<div>
<div style={{
padding:"8px 12px",borderRadius:4,marginBottom:10,fontSize:11,
background:"rgba(0,230,118,.06)",border:"1px solid rgba(0,230,118,.2)",
color:"var(--dm)",
}}>
<span style={{color:"var(--ok)",fontWeight:700}}>✓ </span>
Generated {enforceResult.count} commands for VLANs{" "}
{enforceResult.vlans?.join(", ")}.{" "}
Copy the commands below into the{" "}
<strong>Review &amp; Push</strong> tab → Raw CLI mode.
</div>
<div style={{
background:"#060809",borderRadius:4,padding:"10px 12px",
fontFamily:"var(--mono)",fontSize:10,color:"#a0b0c0",
whiteSpace:"pre",overflowX:"auto",maxHeight:260,overflowY:"auto",
}}>{enforceResult.commands?.join("\n")}</div>
<button className="btn bg" style={{marginTop:8,fontSize:10}}
onClick={()=>navigator.clipboard?.writeText(enforceResult.commands?.join("\n"))}>
Copy Commands
</button>
</div>
) : (
<div style={{
padding:"8px 12px",borderRadius:4,fontSize:11,
background:"rgba(255,100,100,.06)",border:"1px solid rgba(255,100,100,.2)",
color:"var(--err)",
}}>
✗ {enforceResult.message}
</div>
)}
</div>
)}
</div>
</div>
{/* Quick reference */}
<div className="panel">
<div className="ph">◈ How It Works</div>