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:
+143
-42
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user