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:
+518
-49
@@ -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 (1–4094)."""
|
||||
_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 (1–52 for the ERS 5952)."""
|
||||
p = int(v)
|
||||
if not 1 <= p <= 52:
|
||||
raise ValueError("port: must be 1–52")
|
||||
@@ -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 1–48 are FastEthernet; ports 49–52 are GigabitEthernet SFP uplinks.
|
||||
PoE is only available on ports 1–48.
|
||||
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"
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user