Fix per-VLAN ControlD profiles — use gateway-listener mode

The single-localhost-listener architecture (Unbound:53 → ctrld:5354)
fundamentally cannot support per-VLAN ControlD profiles: all queries
arrive at ctrld from Unbound as 127.0.0.1, so ctrld has no way to
distinguish VLANs and routes everything to a single upstream.  This
broke Asterisk and IoT isolation — all traffic was hitting the same
ControlD profile regardless of which VLAN it came from.

New architecture when all VLAN profiles have a gateway IP set:

  Clients → ctrld on VLAN-gateway-IP:53 → per-VLAN ControlD profile
  Unbound stays on 127.0.0.1:53 (loopback only — no port conflict)
  ctrld sees real client source IPs → routes correctly per VLAN
  ctrld forwards *.lan / *.local → Unbound loopback (local-data)

_build_ctrld_toml changes:
  - Detects when all active profiles have a gateway IP
  - Generates one [listener.N] per VLAN on its gateway IP:53 instead
    of a single [listener.0] on 127.0.0.1:ctrld_port
  - Each listener has its own [listener.N.policy] with the correct
    upstream.N (that VLAN's ControlD profile)
  - Adds upstream.local → 127.0.0.1:53 for .lan/.local split-horizon
  - Falls back to single-listener with a clear WARNING comment when
    gateways are missing

_ctrld_generate_opnsense_cmd changes:
  - Detects which mode was generated and produces matching instructions
  - Gateway mode: tells user to restrict Unbound to loopback and
    disable Query Forwarding (ctrld is no longer downstream of Unbound)
  - Fallback mode: warns that per-VLAN profiles are not working

Required OPNsense change to activate gateway mode:
  Services → Unbound DNS → General → Network Interfaces → Loopback only
  Services → Unbound DNS → Query Forwarding → disable/remove forward to ctrld

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-27 12:02:33 +00:00
parent 91eaeffd9c
commit 2db5c3babc
+143 -42
View File
@@ -2105,9 +2105,25 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
active = [vp for vp in vlan_profiles
if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()]
# Per-gateway mode: every active profile has a VLAN gateway IP.
# ctrld listens on each gateway IP:53 so it sees the real client source IP
# and can route to the correct per-VLAN ControlD profile.
# Unbound stays on 127.0.0.1:53 (no interface overlap — no port conflict).
# This is the only mode that makes per-VLAN ControlD profiles actually work;
# the single-localhost-listener mode cannot differentiate VLANs because all
# queries arrive from Unbound as 127.0.0.1.
gateways = [vp.get("gateway", "").strip() for vp in active]
use_gateway_listeners = bool(active) and all(gateways)
arch_comment = (
"# Architecture: ctrld on each VLAN gateway IP:53 — per-VLAN ControlD profiles"
if use_gateway_listeners else
"# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port)
)
lines = [
"# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager",
"# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
arch_comment,
"# Docs: https://docs.controld.com/docs/ctrld",
"",
"[service]",
@@ -2137,19 +2153,57 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
"",
]
# ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────
if deploy_mode == "router":
# Single listener on localhost — Unbound's Query Forwarding points here.
# No per-VLAN listeners needed: Unbound handles all local resolution
# before queries arrive; ctrld just proxies external queries upstream.
# ── ROUTER MODE: per-gateway listeners (preferred) or single localhost ────
if deploy_mode == "router" and use_gateway_listeners:
# Per-VLAN gateway listeners.
# ctrld binds each VLAN gateway IP on port 53. Unbound stays on
# 127.0.0.1:53 — no overlap so no boot race. Each VLAN's client
# queries go to their gateway (OPNsense), hit ctrld which sees the
# real source IP, and are routed to the right ControlD profile.
# Unbound is reached as upstream.local for .lan/.local resolution
# so custom hostnames (pbx.lan etc.) resolve without ControlD.
lines += [
"# Listens on localhost only — Unbound Query Forwarding sends external queries here",
"# Unbound on 127.0.0.1:53 handles .lan/.local — ctrld forwards here",
"[upstream.local]",
" name = \'Local .lan resolver (Unbound loopback)\'",
" type = \'legacy\'",
" endpoint = \'127.0.0.1:53\'",
" timeout = 2000",
"",
]
for i, (vp, gw) in enumerate(zip(active, gateways)):
vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}")
lines += [
f"# VLAN {vid}{name} — listens on {gw}:53",
f"[listener.{i}]",
f" ip = \'{gw}\'",
f" port = 53",
"",
f" [listener.{i}.policy]",
f" name = \'VLAN {vid} {name}\'",
f" networks = []",
f" rules = [",
f" {{ \'*.lan\' = [\'upstream.local\'] }},",
f" {{ \'*.local\' = [\'upstream.local\'] }},",
f" ]",
f" default = [\'upstream.{i}\']",
"",
]
elif deploy_mode == "router":
# Fallback: single localhost listener when gateways are not set.
# WARNING: all VLANs share upstream.0 — per-VLAN profiles do NOT work.
lines += [
"# WARNING: single-listener mode — all VLANs share the same ControlD profile.",
"# Set a gateway IP on each VLAN profile to enable per-VLAN routing.",
"# Listens on localhost only — Unbound Query Forwarding sends queries here",
"[listener.0]",
f" ip = \'127.0.0.1\'",
f" port = {ctrld_port}",
"",
" [listener.0.policy]",
" name = \'Default Policy\'",
" name = \'Default Policy (all VLANs)\'",
" networks = []",
" rules = []",
]
@@ -2571,17 +2625,20 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
"""
Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.
Confirmed working architecture (verified after reboot — no manual intervention needed):
Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
Architecture depends on whether VLAN profiles have gateway IPs set:
Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot
conflict with Unbound at startup regardless of service start order.
Unbound's Query Forwarding sends external queries through ctrld.
Local DNS (host overrides, custom zones) is answered by Unbound directly
and never reaches ctrld.
A) Per-gateway-listener mode (preferred — requires gateway set on each VLAN profile):
Clients → ctrld on VLAN-gateway-IP:53 → ControlD per-VLAN profile
Unbound stays on 127.0.0.1:53 for .lan resolution (no port conflict)
ctrld sees real client source IPs → per-VLAN ControlD profiles work correctly
NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is
a common tutorial artifact that causes PTR/reverse DNS failures.
B) Single-listener fallback (no gateway IPs):
Clients → Unbound:53 → Query Forwarding → ctrld (127.0.0.1:ctrld_port) → ControlD
WARNING: all VLANs share one ControlD profile — per-VLAN profiles DO NOT work
The previous single-listener-on-localhost approach was architecturally broken for
per-VLAN DNS: Unbound forwards from 127.0.0.1, so ctrld sees every query as
coming from localhost and cannot route to per-VLAN profiles.
"""
first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
if not first_rid:
@@ -2594,52 +2651,96 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
# Detect which mode the TOML was built in
gateways = [p.get("gateway", "").strip() for p in profiles if p.get("resolver_id") or p.get("endpoint_url")]
per_gateway = bool(gateways) and all(gateways)
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF"
if per_gateway:
gw_list = ", ".join(f"{gw}:53" for gw in gateways)
arch_line = f"Architecture: ctrld on [{gw_list}] — per-VLAN profiles active"
step3 = [
"STEP 3 — Restrict Unbound to loopback only (so it doesn't conflict with ctrld on :53):",
" OPNsense GUI → Services → Unbound DNS → General:",
" Network Interfaces → select ONLY 'lo0 (Loopback)' and deselect all VLAN interfaces",
" Click Save + Apply",
" Verify: unbound-control status | grep interface",
" Unbound should show: interface: 127.0.0.1 (loopback only)",
"",
"STEP 4 — Disable Unbound Query Forwarding (ctrld is no longer downstream of Unbound):",
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
" Disable / remove any forward zone pointing to 127.0.0.1",
" OR: use the Unbound panel in this tool to write a disabled forward_to_ctrld.conf",
"",
"STEP 5 — Verify each VLAN gets its own profile:",
] + [
f" dig @{gw} google.com # VLAN {p.get('vlan_id')} — should use {p.get('name')} ControlD profile"
for gw, p in zip(gateways, profiles)
if p.get("gateway", "").strip()
] + [
f" dig @127.0.0.1 myhost.{local_domain} # local .lan — answered by Unbound",
]
step2_note = (
f"Write {opnsense_cfg} with the TOML below, then: ctrld restart "
f"(ctrld will listen on {gw_list})"
)
message = f"Per-VLAN gateway-listener mode: ctrld on [{gw_list}] — each VLAN gets its own ControlD profile"
architecture = (
f"ctrld listens on VLAN gateway IPs ({gw_list}). "
"Unbound on 127.0.0.1:53 only — no port conflict. "
"Each VLAN's DNS traffic hits ctrld on its gateway IP; "
"ctrld routes to the correct ControlD profile by source subnet."
)
else:
arch_line = "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD [WARNING: single shared profile]".format(ctrld_port)
step3 = [
"STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):",
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
" • Enable Query Forwarding: checked",
f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}",
" • Use TLS: No",
" • Click Apply / Save",
"",
" WARNING: in this mode all VLANs share the same ControlD profile.",
" Set a gateway IP on each VLAN profile to enable per-VLAN profiles.",
]
step2_note = f"Write {opnsense_cfg} with the TOML below, then: ctrld restart"
message = f"Single-listener mode — all VLANs share upstream.0 (set gateway IPs for per-VLAN routing)"
architecture = (
f"Unbound on :53 forwards to ctrld on 127.0.0.1:{ctrld_port}. "
"Per-VLAN ControlD profiles DO NOT work in this mode — all queries appear from 127.0.0.1."
)
setup_steps = [
"Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
arch_line,
"",
"STEP 1 — Install ctrld on OPNsense (SSH or shell):",
f" {install_cmd}",
"",
"STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port),
"STEP 2 — Write the ctrld.toml:",
f" {write_toml_cmd}",
" Then restart ctrld: ctrld restart",
f" Then restart ctrld: ctrld restart",
"",
"STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):",
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
" • Enable Query Forwarding: checked",
f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}",
" • Use TLS: No (ctrld handles DoH/DoT upstream; plain DNS locally is fine)",
" • Click Apply / Save",
] + step3 + [
"",
"STEP 4 — Remove 'home.arpa' local-zone from Unbound if present:",
"STEP {} — Remove 'home.arpa' local-zone from Unbound if present:".format(6 if per_gateway else 4),
" OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:",
" Remove any line containing: local-zone: \"home.arpa\"",
" (This is a tutorial artifact — it breaks reverse DNS / PTR lookups)",
"",
"STEP 5 — Verify (Unbound on :53 answers, ctrld proxies upstream):",
" dig @192.168.1.1 google.com # external — goes through ctrld → ControlD",
f" dig @192.168.1.1 myhost.{local_domain} # local — answered by Unbound directly",
" dig @192.168.1.1 -x 192.168.1.1 # reverse PTR — answered by Unbound directly",
" (Tutorial artifact — breaks reverse DNS / PTR lookups)",
]
return {
"success": True,
"mode": "opnsense",
"message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port),
"per_gateway": per_gateway,
"message": message,
"architecture": architecture,
"setup_steps": setup_steps,
"architecture": "Unbound stays on :53. ctrld binds 127.0.0.1:{} only — no port conflict possible.".format(ctrld_port),
"step1_install": install_cmd,
"step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'",
"step2_config": f"Write {opnsense_cfg} with the TOML below, then: ctrld restart",
"step3_unbound": (
f"Services → Unbound DNS → Query Forwarding: "
f"Enable, add zone '.' → 127.0.0.1:{ctrld_port}, no TLS, Apply"
),
"step4_cleanup": "Remove 'home.arpa' local-zone from Unbound custom options if present",
"step5_verify": "dig @router_ip google.com && dig @router_ip -x 192.168.1.1",
"step2_config": step2_note,
"toml": toml,
"toml_write_cmd": write_toml_cmd,
"config_path": opnsense_cfg,