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
|
active = [vp for vp in vlan_profiles
|
||||||
if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()]
|
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 = [
|
lines = [
|
||||||
"# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager",
|
"# 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",
|
"# Docs: https://docs.controld.com/docs/ctrld",
|
||||||
"",
|
"",
|
||||||
"[service]",
|
"[service]",
|
||||||
@@ -2137,19 +2153,57 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────
|
# ── ROUTER MODE: per-gateway listeners (preferred) or single localhost ────
|
||||||
if deploy_mode == "router":
|
if deploy_mode == "router" and use_gateway_listeners:
|
||||||
# Single listener on localhost — Unbound's Query Forwarding points here.
|
# Per-VLAN gateway listeners.
|
||||||
# No per-VLAN listeners needed: Unbound handles all local resolution
|
# ctrld binds each VLAN gateway IP on port 53. Unbound stays on
|
||||||
# before queries arrive; ctrld just proxies external queries upstream.
|
# 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 += [
|
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]",
|
"[listener.0]",
|
||||||
f" ip = \'127.0.0.1\'",
|
f" ip = \'127.0.0.1\'",
|
||||||
f" port = {ctrld_port}",
|
f" port = {ctrld_port}",
|
||||||
"",
|
"",
|
||||||
" [listener.0.policy]",
|
" [listener.0.policy]",
|
||||||
" name = \'Default Policy\'",
|
" name = \'Default Policy (all VLANs)\'",
|
||||||
" networks = []",
|
" networks = []",
|
||||||
" rules = []",
|
" 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.
|
Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.
|
||||||
|
|
||||||
Confirmed working architecture (verified after reboot — no manual intervention needed):
|
Architecture depends on whether VLAN profiles have gateway IPs set:
|
||||||
Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
|
|
||||||
|
|
||||||
Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot
|
A) Per-gateway-listener mode (preferred — requires gateway set on each VLAN profile):
|
||||||
conflict with Unbound at startup regardless of service start order.
|
Clients → ctrld on VLAN-gateway-IP:53 → ControlD per-VLAN profile
|
||||||
Unbound's Query Forwarding sends external queries through ctrld.
|
Unbound stays on 127.0.0.1:53 for .lan resolution (no port conflict)
|
||||||
Local DNS (host overrides, custom zones) is answered by Unbound directly
|
ctrld sees real client source IPs → per-VLAN ControlD profiles work correctly
|
||||||
and never reaches ctrld.
|
|
||||||
|
|
||||||
NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is
|
B) Single-listener fallback (no gateway IPs):
|
||||||
a common tutorial artifact that causes PTR/reverse DNS failures.
|
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)
|
first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
|
||||||
if not first_rid:
|
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)
|
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"
|
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
|
||||||
write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF"
|
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 = [
|
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):",
|
"STEP 1 — Install ctrld on OPNsense (SSH or shell):",
|
||||||
f" {install_cmd}",
|
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}",
|
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):",
|
] + step3 + [
|
||||||
" 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",
|
|
||||||
"",
|
"",
|
||||||
"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:",
|
" OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:",
|
||||||
" Remove any line containing: local-zone: \"home.arpa\"",
|
" Remove any line containing: local-zone: \"home.arpa\"",
|
||||||
" (This is a tutorial artifact — it breaks reverse DNS / PTR lookups)",
|
" (Tutorial artifact — 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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"mode": "opnsense",
|
"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,
|
"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_install": install_cmd,
|
||||||
"step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{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",
|
"step2_config": step2_note,
|
||||||
"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",
|
|
||||||
"toml": toml,
|
"toml": toml,
|
||||||
"toml_write_cmd": write_toml_cmd,
|
"toml_write_cmd": write_toml_cmd,
|
||||||
"config_path": opnsense_cfg,
|
"config_path": opnsense_cfg,
|
||||||
|
|||||||
Reference in New Issue
Block a user