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:
+636
-2
@@ -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
|
||||
&& echo "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf
|
||||
&& 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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user