Merge pull request #1 from outis1one/claude/avaya-switch-manager-DxZs5

Claude/avaya switch manager dx zs5
This commit is contained in:
Outis
2026-03-23 10:05:11 -04:00
committed by GitHub
5 changed files with 1368 additions and 88 deletions
+1
View File
@@ -0,0 +1 @@
Avaya_5952_setup.py.bak
+101 -37
View File
File diff suppressed because one or more lines are too long
+112
View File
@@ -112,6 +112,8 @@ Create, rename, and delete VLANs. Shows port count and subnet per VLAN. VLAN 1 c
Build Access Control Lists visually. Each rule specifies action (permit/deny), protocol (ip/tcp/udp/icmp), source, destination, and optional port. The ACL is assigned to a VLAN interface with a direction. The tool generates all CLI syntax — you never write it yourself.
**Templates:** Click "Use Template" to pre-fill rules for common patterns: Staff (full internet, no management), IoT (internet only, no RFC1918), Guest (internet + ctrld DNS enforcement), Camera (NVR only). All rules are editable after applying the template.
### Review & Push
Every change across all tabs is translated into the exact CLI commands the ERS 5952 understands. This tab shows those commands before anything is sent.
@@ -170,6 +172,10 @@ Generates the `ctrld.toml` config and install command. You install wherever you
**Per-VLAN Resolver IDs:** Each VLAN gets its own Control D profile. Enter the Resolver ID from the Control D dashboard (controld.com → Add Device → Router → Resolver ID). VLANs without a Resolver ID use the first configured profile as fallback.
**DNS Enforcement:** After installing ctrld, use the "Enforce DNS on Switch" button to generate ACLs that block devices from bypassing ctrld by using 8.8.8.8 directly. See [DNS Enforcement ACLs](#dns-enforcement-acls) below.
**Local Hostnames:** Optionally run a dnsmasq container so `.lan` names resolve for all devices. See [Local Hostname Resolution](#local-hostname-resolution-dnsmasq) below.
**References:**
- Control D documentation: https://docs.controld.com/docs/ctrld
- Router setup guide: https://docs.controld.com/docs/routers-platform
@@ -354,6 +360,8 @@ The switch is never being polled when nobody is looking at the dashboard.
| `wg_server_public` | `/etc/switch-manager/` | WireGuard server public key |
| `clients/` | `/etc/switch-manager/` | WireGuard client .conf files |
| `switch-manager.service` | `/etc/systemd/system/` | Systemd service (native mode) |
| `local-hostnames.json` | `/etc/switch-manager/` | User-defined hostname→IP mappings (optional) |
| `dnsmasq.conf` | `/etc/switch-manager/` | Generated dnsmasq config (optional) |
**Back up `/etc/switch-manager/totp_secret`** — if the management computer fails and you have not backed this up you will need to regenerate the TOTP secret and re-scan it into your authenticator app.
@@ -411,3 +419,107 @@ This switch has no REST API. Everything this tool does is via SSH sessions that
There is an inherent limit to how reliably the tool can detect every possible error condition. The danger blocking system catches the known lethal patterns but cannot anticipate every possible misconfiguration. Use the CLI review step. Read what is about to be sent.
The console cable is always your fallback. Keep it accessible.
---
## DNS Filtering — Port 53 Conflict Resolution
When installing ctrld (Option A — local install), ctrld needs to bind port 53. On Ubuntu and Debian, `systemd-resolved` holds port 53 via its stub listener.
**The tool fixes this automatically** during installation. It adds `DNSStubListener=no` to `/etc/systemd/resolved.conf` and restarts `systemd-resolved`. The service itself keeps running — it still manages `/etc/resolv.conf` and local hostname caching. Only the stub listener is disabled.
If the automatic fix fails (permission issue, non-standard config), fix it manually:
```bash
echo "[Resolve]" | sudo tee -a /etc/systemd/resolved.conf
echo "DNSStubListener=no" | sudo tee -a /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved
```
**On OPNsense (Option B):** OPNsense runs Unbound DNS on port 53. The correct approach is not to uninstall Unbound — it handles `.lan` hostnames and local DNS. Instead:
1. In OPNsense UI: Services → Unbound DNS → General → set Listen Port to `5353`, Listen Interface to `Loopback (lo0)`. Save + Apply.
2. Configure ctrld to forward `*.lan` and `*.local` to `127.0.0.1:5353` (the split-horizon block shown in the DNS tab result panel).
3. ctrld handles all other queries via DoH3 to Control D.
This keeps local names working while all external DNS is filtered per-VLAN through Control D.
---
## DNS Enforcement ACLs
Without enforcement, a device can ignore DHCP-assigned DNS and use `8.8.8.8` directly, bypassing all ctrld filtering.
The DNS tab has an **"Enforce DNS on Switch"** button that generates ACLs blocking this. For each VLAN:
```
ip access-list extended DNS-ENFORCE-VLAN10
1 permit udp 192.168.10.0 0.0.0.255 host [ctrld-ip] eq 53
2 permit tcp 192.168.10.0 0.0.0.255 host [ctrld-ip] eq 53
3 deny udp 192.168.10.0 0.0.0.255 any eq 53
4 deny tcp 192.168.10.0 0.0.0.255 any eq 53
5 deny tcp 192.168.10.0 0.0.0.255 any eq 853
6 permit ip any any
interface vlan 10
ip access-group DNS-ENFORCE-VLAN10 in
```
Rules 12 permit DNS only to ctrld. Rules 34 block DNS anywhere else (8.8.8.8, Cloudflare, etc.). Rule 5 blocks DNS-over-TLS (port 853) as another bypass path. Rule 6 permits all other traffic so internet still works.
VLAN 99 (management) is automatically excluded — a broken ACL on the management VLAN would lock you out.
The generated commands are shown for review and pushed through the normal TOTP-gated push mechanism. Nothing is sent to the switch automatically.
---
## Inter-VLAN Routing ACL Templates
The **ACL Builder** tab has a **"Use Template"** button that pre-fills common policies:
**Staff VLAN — full internet, no management access**
Permits everything except access to VLAN 99 (192.168.99.0/24). Use on a staff or office VLAN where users need full internet but must not reach the management interface.
**IoT VLAN — internet only, no RFC1918**
Blocks all RFC1918 private address ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x). IoT devices get internet but cannot reach any other VLAN, internal servers, or management. Permits internet.
**Guest VLAN — internet only, DNS must work first**
Like IoT but explicitly permits DNS to ctrld first (before the deny rules), ensuring DNS filtering continues to work even after RFC1918 is blocked.
**Camera VLAN — NVR only**
Cameras may only send traffic to one NVR/DVR IP. All other traffic is dropped. Prevents cameras from phoning home, scanning the network, or accessing the internet directly.
All templates are fully editable after applying. The template fills the rule table — you adjust IPs, add rules, or delete rules before pushing.
---
## Local Hostname Resolution (dnsmasq)
The DNS tab has a **"Local Hostnames"** section. It manages an optional `dnsmasq` container that resolves `.lan` hostnames for all devices on the network.
**How it works:**
1. dnsmasq runs in Docker, listening on port 5353 on the management computer.
2. ctrld is configured to forward `*.lan` and `*.local` queries to `127.0.0.1:5353` (split-horizon rule).
3. All other queries go through Control D as normal.
4. `switch.mgmt.lan` and `management.lan` always resolve to the management computer's IP.
**Setup:**
Add hostname→IP mappings in the DNS tab → Local Hostnames section. Click **Save & Generate dnsmasq.conf**. The tab shows:
- The generated `dnsmasq.conf` content and path
- A docker-compose snippet to add the dnsmasq service
- A ctrld.toml block to enable split-horizon forwarding
Add the docker-compose snippet to `docker-compose.yml`, add the toml block to `ctrld.toml`, then:
```bash
docker compose up -d dnsmasq
ctrld restart
```
The `dnsmasq.conf` is written to `/etc/switch-manager/dnsmasq.conf` and mounted read-only into the container.
**Files added:**
| File | Location | Purpose |
|---|---|---|
| `local-hostnames.json` | `/etc/switch-manager/` | User-defined hostname→IP mappings |
| `dnsmasq.conf` | `/etc/switch-manager/` | Generated dnsmasq config |
+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>
+518 -49
View File
@@ -155,6 +155,14 @@ WARN_PATTERNS = [
]
def check_danger(commands: list[str]) -> dict:
"""
Scan a list of CLI commands for dangerous patterns.
Returns a dict with:
hard_blocked — commands that are refused entirely (e.g. no vlan 99, no ip ssh)
warnings — commands that are allowed but flagged (e.g. shutdown)
has_hard_block, has_warnings — convenience booleans
"""
hard, warn = [], []
for cmd in commands:
for pat, reason in HARD_BLOCK_PATTERNS:
@@ -189,6 +197,7 @@ _RE_ACTION = re.compile(r'^(permit|deny)$')
_RE_DESC = re.compile(r'^[a-zA-Z0-9\-_ ]{0,64}$')
def _san(v: str, pat: re.Pattern, field: str) -> str:
"""Reject shell-injection characters and check value against an allow-list regex."""
if _BAD_CHARS.search(v):
raise ValueError(f"{field}: disallowed characters")
for p in _BAD_PATS:
@@ -199,6 +208,7 @@ def _san(v: str, pat: re.Pattern, field: str) -> str:
return v
def san_vid(v, field="vlan_id") -> int:
"""Validate and return a VLAN ID integer (14094)."""
_san(str(v), _RE_VID, field)
vid = int(v)
if not 1 <= vid <= 4094:
@@ -206,6 +216,7 @@ def san_vid(v, field="vlan_id") -> int:
return vid
def san_port(v) -> int:
"""Validate and return a port number (152 for the ERS 5952)."""
p = int(v)
if not 1 <= p <= 52:
raise ValueError("port: must be 152")
@@ -223,6 +234,7 @@ _ALLOWED_CMD_RE = [
]
def is_allowed(cmd: str) -> bool:
"""Return True if cmd matches the CLI allow-list (whitelist of safe command patterns)."""
return any(p.match(cmd) for p in _ALLOWED_CMD_RE)
# ══════════════════════════════════════════════════════════════════════
@@ -428,6 +440,7 @@ def heartbeat(visitor_id: str, mode: str = "active"):
_poll_mode = mode
def prune_visitors():
"""Remove visitors not seen for POLL_IDLE_AFTER seconds and set mode to idle if none remain."""
global _poll_mode
with _visitors_lock:
now = time.time()
@@ -438,6 +451,14 @@ def prune_visitors():
_poll_mode = "idle"
def _poll_loop():
"""
Background thread: polls the switch for live status at a visitor-adaptive interval.
When visitors are active: polls every POLL_ACTIVE_S seconds.
When visitors have the tab backgrounded: polls every POLL_BG_S seconds.
When no visitors for POLL_IDLE_AFTER seconds: sleeps without polling.
Results cached in _cache; poll_error set on SSH failure.
"""
log.info("Poller thread started")
while True:
prune_visitors()
@@ -471,6 +492,7 @@ def _poll_loop():
time.sleep(interval)
def start_poller():
"""Launch the background polling thread as a daemon (exits when main process exits)."""
t = threading.Thread(target=_poll_loop, daemon=True)
t.start()
log.info("Poller started")
@@ -575,6 +597,13 @@ class PortConfig(BaseModel):
# ══════════════════════════════════════════════════════════════════════
def build_port(cfg: PortConfig) -> list[str]:
"""
Generate ERS 5952 CLI commands for a port configuration change.
Port 148 are FastEthernet; ports 4952 are GigabitEthernet SFP uplinks.
PoE is only available on ports 148.
Returns a list of CLI command strings ready for push_one_by_one().
"""
p = cfg.port
iface = f"FastEthernet {p}" if p <= 48 else f"GigabitEthernet {p}"
cmds = []
@@ -600,6 +629,12 @@ def build_port(cfg: PortConfig) -> list[str]:
return cmds
def build_acl(acl: AclCreate) -> list[str]:
"""
Generate ERS 5952 CLI commands to create an extended IP ACL and apply it to a VLAN interface.
Rules are numbered sequentially starting from 1.
The ACL is applied to the VLAN's Layer 3 interface in the specified direction (in/out).
"""
cmds = [f"ip access-list extended {acl.name}"]
for i, r in enumerate(acl.rules):
src = "any" if r.src_any else f"{r.src} {r.src_mask}"
@@ -688,6 +723,7 @@ def revoke(body: SessionRevoke):
@app.get("/api/status")
def status():
"""Backend and switch connectivity summary (no auth required)."""
with _cache_lock:
return {
"backend": "online",
@@ -713,6 +749,7 @@ def live():
@app.get("/api/switch/config")
def running_config():
"""Fetch and return the full switch running config (read-only, no auth)."""
out = read_cmd("show running-config")
return {"config": out, "lines": len(out.splitlines())}
@@ -720,6 +757,12 @@ def running_config():
@app.post("/api/check/danger")
def danger_check(body: dict):
"""
Pre-flight danger check — call this before showing TOTP prompt.
Returns hard_blocked, warnings, and safe_to_push flag.
No auth required so the user sees danger info before authenticating.
"""
cmds = body.get("commands", [])
result = check_danger(cmds)
rejected = [c for c in cmds if not is_allowed(c)]
@@ -761,22 +804,26 @@ def push(body: PushBatch):
@app.post("/api/switch/vlan")
def create_vlan(body: VlanCreate):
"""Create a new VLAN on the switch (type port = standard Layer 2 VLAN)."""
require_session(body.token)
return push_one_by_one(
[f'vlan create {body.vlan_id} name "{body.name}" type port'])
@app.delete("/api/switch/vlan/{vlan_id}")
def delete_vlan(vlan_id: int, token: str):
"""Delete a VLAN by ID. VLAN 1 is blocked at model level; VLAN 99 is blocked by danger check."""
require_session(token)
return push_one_by_one([f"no vlan {san_vid(vlan_id)}"])
@app.post("/api/switch/port")
def configure_port(body: PortConfig):
"""Apply port configuration: mode (access/trunk/disabled), VLAN, PoE, description."""
require_session(body.token)
return push_one_by_one(build_port(body))
@app.post("/api/switch/acl")
def create_acl(body: AclCreate):
"""Create an extended IP ACL and apply it to a VLAN interface."""
require_session(body.token)
return push_one_by_one(build_acl(body))
@@ -803,12 +850,14 @@ from pathlib import Path as _Path
DEVICES_FILE = _Path("/etc/switch-manager/devices.json")
def _load_devices() -> list:
"""Load the saved device list from devices.json, returning [] on missing or corrupt file."""
if DEVICES_FILE.exists():
try: return _json.loads(DEVICES_FILE.read_text())
except: pass
return []
def _save_devices(devices: list):
"""Persist the device list to devices.json with 2-space indentation."""
DEVICES_FILE.write_text(_json.dumps(devices, indent=2))
def _parse_dhcp_leases(raw: str) -> list:
@@ -923,6 +972,7 @@ def get_devices():
@app.post("/api/devices/save")
def save_device(body: DeviceUpdate):
"""Save or update a device entry (upsert by MAC address)."""
require_session(body.token)
devices = _load_devices()
existing = next((i for i, d in enumerate(devices) if d["mac"] == body.device.mac), None)
@@ -937,6 +987,7 @@ def save_device(body: DeviceUpdate):
@app.post("/api/devices/delete")
def delete_device(body: DeviceDelete):
"""Remove a device from the saved list by MAC address."""
require_session(body.token)
devices = [d for d in _load_devices() if d["mac"] != body.mac]
_save_devices(devices)
@@ -986,12 +1037,14 @@ class WGRevokeRequest(BaseModel):
name: str
def _wg_genkey_api():
"""Generate a WireGuard private/public keypair using the system wg tool."""
import subprocess as _sp
priv = _sp.run(["wg","genkey"], capture_output=True, text=True).stdout.strip()
pub = _sp.run(["wg","pubkey"], input=priv, capture_output=True, text=True).stdout.strip()
return priv, pub
def _wg_status() -> dict:
"""Return parsed WireGuard interface status including connected peers."""
import subprocess as _sp
try:
raw = _sp.run(["wg","show"], capture_output=True, text=True).stdout
@@ -1165,12 +1218,14 @@ import base64 as _b64
OPNSENSE_FILE = _Path("/etc/switch-manager/opnsense.json")
def _load_opnsense_cfg() -> dict:
"""Load saved OPNsense API credentials from opnsense.json, returning {} if absent."""
if OPNSENSE_FILE.exists():
try: return _json.loads(OPNSENSE_FILE.read_text())
except: pass
return {}
def _save_opnsense_cfg(cfg: dict):
"""Persist OPNsense API credentials to opnsense.json (chmod 600 — contains secrets)."""
OPNSENSE_FILE.write_text(_json.dumps(cfg, indent=2))
OPNSENSE_FILE.chmod(0o600)
@@ -1518,15 +1573,21 @@ def sync_reservation(body: SyncRequest):
# ══════════════════════════════════════════════════════════════════════
CTRLD_FILE = _Path("/etc/switch-manager/ctrld.json")
# NOTE: /usr/local/bin/ctrld is the Linux default path.
# On OPNsense (FreeBSD) ctrld installs to /usr/local/sbin/ctrld.
# For local-mode installs this path is checked at runtime, so it's fine.
# For OPNsense mode the binary runs on the router, not here — the path is irrelevant.
CTRLD_BIN = _Path("/usr/local/bin/ctrld")
def _load_ctrld_cfg() -> dict:
"""Load saved ctrld configuration (mode, vlan_profiles) from ctrld.json."""
if CTRLD_FILE.exists():
try: return _json.loads(CTRLD_FILE.read_text())
except: pass
return {}
def _save_ctrld_cfg(cfg: dict):
"""Persist ctrld configuration to ctrld.json (chmod 600 — contains Resolver IDs)."""
CTRLD_FILE.write_text(_json.dumps(cfg, indent=2))
CTRLD_FILE.chmod(0o600)
@@ -1560,71 +1621,109 @@ def _ctrld_config_path() -> _Path:
if p.exists(): return p
return candidates[0] # default for new install
def _build_ctrld_toml(vlan_profiles: list) -> str:
def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
local_resolver: str = "") -> str:
"""
Build a ctrld.toml config for per-VLAN DNS filtering.
Each VLAN gets its own upstream pointing to its Control D Resolver ID.
Source IP routing directs traffic to the correct profile automatically.
Build a ctrld.toml in the correct format — table notation, not TOML arrays.
The correct ctrld format uses [listener.0], [network.N], [upstream.N] table
sections, NOT [[listener]] / [[upstream]] / [[rule]] array tables. Source
VLAN routing is done via [network.N] sections (CIDR-based) referenced in the
[listener.0.policy].networks array. Domain-specific overrides go in .rules.
vlan_profiles: list of { vlan_id, name, subnet, resolver_id }
local_domain: suffix for internal hostnames (default 'lan')
local_resolver: if set (e.g. '127.0.0.1:5353'), adds split-horizon upstream
and rules so *.lan / *.local go to the local resolver instead
of Control D — keeps .lan names working for all VLAN clients.
Control D bootstrap IP 76.76.2.0 is used for cold-start before DoH is up.
"""
BOOTSTRAP = "76.76.2.0" # Control D anycast — required for cold-start
active = [vp for vp in vlan_profiles if vp.get("resolver_id", "").strip()]
lines = [
"# ctrld configuration — generated by Avaya 5952 Switch Manager",
"# https://github.com/Control-D-Inc/ctrld",
"# Documentation: https://docs.controld.com/docs/ctrld",
"",
"[service]",
'name = "ctrld"',
"",
"# Single listener on port 53 — all VLANs send DNS here",
"[[listener]]",
'ip = "0.0.0.0"',
"port = 53",
'tag = "all-vlans"',
" log_level = 'info'",
" log_path = '/tmp/ctrld.log'",
"",
]
# One upstream per VLAN
for vp in vlan_profiles:
rid = vp.get("resolver_id","").strip()
if not rid:
continue
tag = f"vlan{vp['vlan_id']}"
# ── Listener with per-VLAN policy ────────────────────────────────────────
# The policy.networks array maps each [network.N] to one [upstream.N].
# This is how ctrld routes different VLAN subnets to different profiles.
lines += [
"[listener]",
" [listener.0]",
" ip = '0.0.0.0'",
" port = 53",
" [listener.0.policy]",
]
if active:
net_entries = [f" " + "{ " + f"'network.{i}' = ['upstream.{i}']" + " },"
for i in range(len(active))]
lines += [" networks = ["] + net_entries + [" ]"]
else:
lines += [" networks = []"]
# Domain-specific rules (split-horizon for .lan / .local → local resolver)
if local_resolver:
domain_suffix = local_domain.strip(".")
lines += [
f"# VLAN {vp['vlan_id']}{vp['name']}",
f"[[upstream]]",
f'id = "{tag}"',
f'type = "doh3"',
f'endpoint = "https://dns.controld.com/{rid}"',
f'tag = "{tag}"',
" rules = [",
f" " + "{ " + f"'*.{domain_suffix}' = ['upstream.local']" + " },",
" " + "{ " + "'*.local' = ['upstream.local']" + " },",
" ]",
]
else:
lines += [" rules = []"]
lines += [""]
# ── Network sections — one per VLAN ──────────────────────────────────────
if active:
lines += ["[network]"]
for i, vp in enumerate(active):
vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}")
subnet = vp.get("subnet", f"192.168.{vid}.0/24")
lines += [
f" # VLAN {vid}{name}",
f" [network.{i}]",
f" name = '{name}'",
f" cidrs = ['{subnet}']",
"",
]
# ── Upstream sections — one per VLAN plus optional local ──────────────────
lines += ["[upstream]"]
for i, vp in enumerate(active):
vid = vp["vlan_id"]
rid = vp["resolver_id"].strip()
lines += [
f" # VLAN {vid}{vp.get('name', '')}",
f" [upstream.{i}]",
f" type = 'doh'",
f" endpoint = 'https://dns.controld.com/{rid}'",
f" bootstrap_ip = '{BOOTSTRAP}'",
f" timeout = 5000",
"",
]
# Routing rules — match source subnet to upstream
lines += ["# Route each VLAN subnet to its profile"]
for vp in vlan_profiles:
rid = vp.get("resolver_id","").strip()
if not rid:
continue
subnet = vp.get("subnet", f"192.168.{vp['vlan_id']}.0/24")
tag = f"vlan{vp['vlan_id']}"
# Optional local resolver for split-horizon .lan resolution (dnsmasq/Unbound)
if local_resolver:
lines += [
f"[[rule]]",
f'listener = "all-vlans"',
f'source_ip = "{subnet}"',
f'upstream = "{tag}"',
"",
]
# Fallback upstream (first valid profile or safe default)
first_valid = next((vp for vp in vlan_profiles if vp.get("resolver_id")), None)
if first_valid:
lines += [
"# Fallback for unmatched source IPs",
"[[upstream]]",
f'id = "fallback"',
f'type = "doh3"',
f'endpoint = "https://dns.controld.com/{first_valid["resolver_id"]}"',
f'tag = "fallback"',
f" # Local resolver — handles *.{local_domain} and *.local",
f" # dnsmasq on port 5353 (Docker) or Unbound on 127.0.0.1:5353 (OPNsense)",
f" [upstream.local]",
f" type = 'legacy'",
f" endpoint = '{local_resolver}'",
f" timeout = 2000",
"",
]
@@ -1713,6 +1812,70 @@ def ctrld_save_config(body: CtrldInstallRequest):
"config_path": str(_ctrld_config_path()),
}
def _fix_port53_conflict() -> dict:
"""
Detect and fix systemd-resolved holding port 53 (common on Ubuntu/Debian).
systemd-resolved's stub listener binds 127.0.0.53:53 and sometimes 0.0.0.0:53,
which blocks ctrld from binding port 53. The right fix is to disable only the
stub listener — NOT the service itself (the service still handles /etc/resolv.conf
and local hostname resolution).
Returns a dict with keys: needed (bool), fixed (bool), message (str).
"""
import subprocess as _sp
# Check if systemd-resolved is running and holding port 53
try:
ss_out = _sp.run(
["ss", "-tlnp", "sport", "=", ":53"],
capture_output=True, text=True, timeout=5
).stdout
if "systemd-resolve" not in ss_out and "resolved" not in ss_out:
return {"needed": False, "fixed": False,
"message": "No port 53 conflict detected"}
except Exception:
return {"needed": False, "fixed": False,
"message": "Could not check port 53 status (ss not available)"}
log.info("systemd-resolved is holding port 53 — disabling stub listener")
resolved_conf = _Path("/etc/systemd/resolved.conf")
try:
current = resolved_conf.read_text() if resolved_conf.exists() else ""
except Exception as e:
return {"needed": True, "fixed": False,
"message": f"Cannot read {resolved_conf}: {e}"}
# Already fixed?
if "DNSStubListener=no" in current:
_sp.run(["systemctl", "restart", "systemd-resolved"], capture_output=True)
return {"needed": True, "fixed": True,
"message": "DNSStubListener=no already present — restarted systemd-resolved"}
# Add the setting under [Resolve], creating the section if needed
if "[Resolve]" in current:
new_conf = current.rstrip() + "\nDNSStubListener=no\n"
else:
new_conf = current.rstrip() + "\n[Resolve]\nDNSStubListener=no\n"
try:
resolved_conf.write_text(new_conf)
except PermissionError:
return {"needed": True, "fixed": False,
"message": "Permission denied writing /etc/systemd/resolved.conf — run backend as root or with sudo"}
restart = _sp.run(["systemctl", "restart", "systemd-resolved"],
capture_output=True, text=True)
if restart.returncode != 0:
return {"needed": True, "fixed": False,
"message": f"Added DNSStubListener=no but systemd-resolved restart failed: {restart.stderr}"}
log.info("Port 53 conflict resolved — systemd-resolved stub listener disabled")
return {"needed": True, "fixed": True,
"message": "Disabled systemd-resolved stub listener (DNSStubListener=no) and restarted service"}
def _ctrld_install_local(toml: str, profiles: list) -> dict:
"""Download and install ctrld on this machine, write config, start service."""
import subprocess as _sp, platform as _platform
@@ -1735,6 +1898,12 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
if not first_rid:
return {"success": False, "message": "No Resolver ID provided"}
# Fix port 53 conflict BEFORE installing ctrld — on Ubuntu/Debian, systemd-resolved
# holds port 53 and ctrld cannot bind. Disabling the stub listener is safe:
# systemd-resolved keeps running for /etc/resolv.conf management.
port53_fix = _fix_port53_conflict()
log.info(f"Port 53 check: {port53_fix['message']}")
# Download the binary directly (more reliable than the shell installer for service control)
log.info("Installing ctrld...")
@@ -1750,6 +1919,7 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
"mode": "local",
"message": f"Install failed: {install_result.stderr or install_result.stdout}",
"toml": toml,
"port53": port53_fix,
}
# Write our multi-VLAN config (overrides the default single-profile config)
@@ -1779,6 +1949,7 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
"toml": toml,
"config_path": str(cfg_path),
"docs": "https://docs.controld.com/docs/ctrld",
"port53": port53_fix,
}
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list) -> dict:
@@ -1837,6 +2008,7 @@ def ctrld_update_profiles(body: CtrldUpdateProfile):
@app.delete("/api/ctrld/uninstall")
def ctrld_uninstall(token: str):
"""Stop ctrld, remove its binary, and delete saved config."""
require_session(token)
import subprocess as _sp
if CTRLD_BIN.exists():
@@ -1844,3 +2016,300 @@ def ctrld_uninstall(token: str):
if CTRLD_FILE.exists():
CTRLD_FILE.unlink()
return {"success": True}
# ── DNS enforcement ACL generation ──────────────────────────────────────────
class DnsEnforceRequest(BaseModel):
"""Request body for generating DNS enforcement ACLs."""
token: str
ctrld_ip: str # IP of the machine running ctrld (becomes the only allowed DNS target)
vlan_ids: list[int] # VLANs to enforce (excludes VLAN 99 management)
def _build_dns_enforce_acls(ctrld_ip: str, vlans_info: list[dict]) -> list[str]:
"""
Generate CLI commands for DNS enforcement ACLs on each VLAN interface.
For each VLAN the ACL:
- Permits UDP/TCP port 53 to ctrld_ip (allows DHCP-assigned DNS)
- Denies UDP/TCP port 53 to anywhere (blocks direct DNS bypass e.g. 8.8.8.8)
- Denies TCP port 853 to anywhere (blocks DNS-over-TLS bypass)
- Permits everything else (internet still works)
Without these rules a device can ignore DHCP-assigned DNS and use 8.8.8.8
directly, bypassing all ctrld filtering entirely.
vlans_info: list of { vlan_id: int, subnet: str } e.g. { vlan_id: 10, subnet: "192.168.10.0/24" }
"""
# Validate ctrld IP — must be a bare IP address, no injection
import ipaddress as _ip
try:
ctrld_addr = str(_ip.ip_address(ctrld_ip))
except ValueError:
raise ValueError(f"ctrld_ip: invalid IP address {repr(ctrld_ip)}")
cmds = []
for vi in vlans_info:
vid = san_vid(vi["vlan_id"])
subnet = vi.get("subnet", f"192.168.{vid}.0/24")
# Parse subnet into network/wildcard for ERS ACL syntax
try:
net = _ip.ip_network(subnet, strict=False)
net_str = str(net.network_address)
wild = str(_ip.ip_address(int(net.hostmask)))
except ValueError:
net_str = f"192.168.{vid}.0"
wild = "0.0.0.255"
acl_name = f"DNS-ENFORCE-VLAN{vid}"
cmds += [
f"ip access-list extended {acl_name}",
# 1 & 2: permit DNS to ctrld only (DHCP-assigned resolver)
f" 1 permit udp {net_str} {wild} host {ctrld_addr} eq 53",
f" 2 permit tcp {net_str} {wild} host {ctrld_addr} eq 53",
# 3 & 4: deny DNS to anywhere else (block 8.8.8.8 and friends)
f" 3 deny udp {net_str} {wild} any eq 53",
f" 4 deny tcp {net_str} {wild} any eq 53",
# 5: deny DNS-over-TLS (port 853) so devices can't use DoT as bypass
f" 5 deny tcp {net_str} {wild} any eq 853",
# 6: permit everything else — internet still works
f" 6 permit ip any any",
# Apply inbound on the VLAN interface
f"interface vlan {vid}",
f" ip access-group {acl_name} in",
]
return cmds
@app.post("/api/ctrld/dns-enforce-acls")
def ctrld_dns_enforce_acls(body: DnsEnforceRequest):
"""
Generate DNS enforcement ACL commands for the requested VLANs.
Returns the raw CLI commands for review — the caller then pushes them
via the normal TOTP-gated push endpoint. This endpoint only generates;
it does NOT push anything to the switch itself.
"""
require_session(body.token)
# Refuse to touch VLAN 99 (management) — a broken ACL there = lockout
safe_vlans = [v for v in body.vlan_ids if v != 99]
if not safe_vlans:
raise HTTPException(400, "No safe VLANs to enforce — VLAN 99 is excluded automatically")
vlans_info = [{"vlan_id": v} for v in safe_vlans]
try:
cmds = _build_dns_enforce_acls(body.ctrld_ip, vlans_info)
except ValueError as e:
raise HTTPException(400, str(e))
return {
"success": True,
"commands": cmds,
"count": len(cmds),
"note": "Review these commands then push via the Review & Push tab",
"vlans": safe_vlans,
"ctrld_ip": body.ctrld_ip,
}
# ── Local hostname resolution (dnsmasq) ──────────────────────────────────────
LOCAL_HOSTNAMES_FILE = _Path("/etc/switch-manager/local-hostnames.json")
DNSMASQ_CONF_PATH = _Path("/etc/switch-manager/dnsmasq.conf")
def _load_local_hostnames() -> list:
"""Load user-defined hostname→IP mappings for .lan resolution."""
if LOCAL_HOSTNAMES_FILE.exists():
try: return _json.loads(LOCAL_HOSTNAMES_FILE.read_text())
except: pass
return []
def _save_local_hostnames(entries: list):
"""Persist hostname→IP mappings (used to generate dnsmasq.conf)."""
LOCAL_HOSTNAMES_FILE.write_text(_json.dumps(entries, indent=2))
def _generate_dnsmasq_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str:
"""
Build a dnsmasq.conf for local .lan hostname resolution.
dnsmasq runs on port 5353 inside Docker alongside the switch manager.
ctrld.toml forwards *.lan and *.local queries to 127.0.0.1:5353.
This keeps local names working even when all external DNS goes through ctrld.
entries: list of { name: str, ip: str }
mgmt_ip: IP of the management computer (switch.mgmt.lan and management.lan point here)
"""
import ipaddress as _ip
lines = [
"# dnsmasq — local .lan hostname resolution",
"# Generated by Avaya 5952 Switch Manager",
"# Listens on port 5353 (mapped from Docker container port 53)",
"# ctrld forwards *.lan and *.local here",
"",
"port=53", # dnsmasq internal port (Docker maps host:5353 → container:53)
"no-resolv", # don't use /etc/resolv.conf — this is a local-only resolver
"no-hosts", # don't use /etc/hosts
"domain-needed", # never forward bare names upstream
"bogus-priv", # don't forward RFC1918 PTR queries upstream
"",
"# Management computer — always present",
f"address=/switch.mgmt.lan/{mgmt_ip}",
f"address=/management.lan/{mgmt_ip}",
"",
]
if entries:
lines += ["# User-defined hostnames"]
for e in entries:
hostname = e.get("name","").strip()
ip_addr = e.get("ip","").strip()
if not hostname or not ip_addr:
continue
# Validate the IP — skip malformed entries
try:
_ip.ip_address(ip_addr)
except ValueError:
continue
# Strip leading/trailing dots, sanitise hostname
hostname = hostname.strip(".")
if not hostname:
continue
lines.append(f"address=/{hostname}/{ip_addr}")
return "\n".join(lines) + "\n"
def _generate_ctrld_split_horizon_block(local_domain: str = "lan",
dnsmasq_port: int = 5353) -> str:
"""
Generate an example ctrld.toml for split-horizon DNS with a local resolver.
In the correct ctrld format, split-horizon is done by:
1. Adding [upstream.local] with type='legacy' pointing to dnsmasq/Unbound
2. Adding rules in [listener.0.policy].rules that send *.lan → upstream.local
Because the format uses indexed table sections ([network.N], [upstream.N]),
you can't simply append a fragment — the full toml must be regenerated via
_build_ctrld_toml(vlan_profiles, local_resolver='127.0.0.1:5353').
This function returns a plain-English example for display only.
"""
port = dnsmasq_port
domain = local_domain.strip(".")
return "\n".join([
"# Add to your ctrld.toml — regenerate via DNS tab for correct indexing",
"",
"# In [listener.0.policy], add to the rules array:",
f"# {{ '*.{domain}' = ['upstream.local'] }},",
"# { '*.local' = ['upstream.local'] },",
"",
"# Add a new upstream section (increment index as needed):",
"[upstream.local]",
f" type = 'legacy'",
f" endpoint = '127.0.0.1:{port}'",
f" timeout = 2000",
"",
f"# Then restart ctrld: ctrld restart",
])
class LocalHostnameEntry(BaseModel):
name: str # e.g. "printer.lan"
ip: str # e.g. "192.168.10.50"
class LocalHostnamesUpdate(BaseModel):
token: str
entries: list[LocalHostnameEntry]
local_domain: Optional[str] = "lan"
@app.get("/api/dns/local-hostnames")
def get_local_hostnames():
"""Return saved local hostname mappings and the generated dnsmasq.conf."""
entries = _load_local_hostnames()
import socket as _sock
try:
mgmt_ip = _sock.gethostbyname(_sock.gethostname())
except Exception:
mgmt_ip = "192.168.99.50"
conf = _generate_dnsmasq_conf(entries, mgmt_ip)
return {
"entries": entries,
"dnsmasq_conf": conf,
"conf_path": str(DNSMASQ_CONF_PATH),
}
@app.post("/api/dns/local-hostnames")
def save_local_hostnames(body: LocalHostnamesUpdate):
"""
Save local hostname mappings, write dnsmasq.conf, and return the updated
ctrld.toml split-horizon block to append (user applies it via the DNS tab).
"""
require_session(body.token)
entries = [e.dict() for e in body.entries]
_save_local_hostnames(entries)
import socket as _sock
try:
mgmt_ip = _sock.gethostbyname(_sock.gethostname())
except Exception:
mgmt_ip = "192.168.99.50"
conf = _generate_dnsmasq_conf(entries, mgmt_ip)
DNSMASQ_CONF_PATH.parent.mkdir(parents=True, exist_ok=True)
DNSMASQ_CONF_PATH.write_text(conf)
# Regenerate ctrld.toml with split-horizon enabled (if ctrld is configured)
ctrld_cfg = _load_ctrld_cfg()
split_horizon_toml = None
if ctrld_cfg.get("vlan_profiles"):
split_horizon_toml = _build_ctrld_toml(
ctrld_cfg["vlan_profiles"],
local_domain=body.local_domain or "lan",
local_resolver=f"127.0.0.1:5353",
)
# Write new toml if running locally
if ctrld_cfg.get("mode") == "local":
cfg_path = _ctrld_config_path()
if cfg_path.parent.exists():
cfg_path.write_text(split_horizon_toml)
split_horizon = _generate_ctrld_split_horizon_block(
local_domain=body.local_domain or "lan"
)
return {
"success": True,
"entries": entries,
"dnsmasq_conf": conf,
"conf_path": str(DNSMASQ_CONF_PATH),
"split_horizon": split_horizon,
"full_toml": split_horizon_toml,
"docker_compose_snippet": (
" dnsmasq:\n"
" image: andyshinn/dnsmasq:latest\n"
" ports:\n"
" - \"5353:53/udp\"\n"
" - \"5353:53/tcp\"\n"
" volumes:\n"
" - /etc/switch-manager/dnsmasq.conf:/etc/dnsmasq.conf:ro\n"
" restart: unless-stopped\n"
" cap_add:\n"
" - NET_ADMIN\n"
),
"message": (
f"Saved {len(entries)} hostname(s). "
"Add the docker-compose snippet and split_horizon block to ctrld.toml, "
"then run: docker compose up -d dnsmasq"
),
}