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

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

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-23 13:16:59 +00:00
parent 81f6afd697
commit 360fbb5606
4 changed files with 1367 additions and 88 deletions
+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"
),
}