Fix ctrld/Unbound architecture — Unbound stays on :53, ctrld on 127.0.0.1:5354

Previous code had the architecture completely backwards:
  WRONG: ctrld takes :53, Unbound moves to :5353 as a local resolver
  RIGHT: Unbound stays on :53, ctrld binds localhost:5354, Unbound
         uses Query Forwarding to push external queries through ctrld

This was verified working after reboot with no manual intervention.
The old approach caused a race at boot (whichever service won :53
first would work; the other would fail until manually restarted).

Changes:
- _build_ctrld_toml: router mode listener is now 127.0.0.1:5354
  (not per-VLAN gateway IPs); no split-horizon rules needed since
  Unbound handles all local resolution before queries reach ctrld
- CtrldConfig: unbound_port (5353) → ctrld_port (5354)
- _ctrld_generate_opnsense_cmd: rewritten with correct 5-step guide:
  install ctrld, write toml, configure Unbound Query Forwarding,
  remove home.arpa local-zone (tutorial artifact causing PTR failures),
  verify with dig
- All call sites updated to use ctrld_port instead of unbound_port/local_resolver

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-24 17:29:13 +00:00
parent c41e69d875
commit 8a3ef94310
+120 -163
View File
@@ -1998,20 +1998,28 @@ def _ctrld_config_path() -> _Path:
if p.exists(): return p if p.exists(): return p
return candidates[0] # default for new install return candidates[0] # default for new install
def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
local_resolver: str = "", deploy_mode: str = "router") -> str:
deploy_mode: str = "proxy") -> str:
""" """
Build a ctrld.toml using flat dotted-key section headers. Build a ctrld.toml using flat dotted-key section headers.
deploy_mode="router" — multiple listeners, one per VLAN gateway IP. Confirmed working architecture on OPNsense (verified after reboot):
Best for ctrld running ON the router (OPNsense). Each VLAN client Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
sends DNS to its gateway IP; ctrld binds there and knows the profile
from the listener — no CIDR lookup needed. Requires `gateway` on
every CtrldVlanProfile.
deploy_mode="proxy" — single listener on 0.0.0.0, CIDR-based policy Unbound stays on port 53 — never moved. ctrld binds to localhost only
routing. Use when ctrld runs on a management host (not the router). on port 5354 so it cannot conflict with Unbound at startup. Unbound's
Query Forwarding sends all external queries through ctrld. Local DNS
(host overrides, local zones) is handled entirely by Unbound before any
query reaches ctrld, so no split-horizon rules are needed here.
deploy_mode="router" — single listener on 127.0.0.1:ctrld_port.
Unbound forwards upstream queries here via Query Forwarding.
Per-VLAN policy differentiation is handled by Unbound (forward different
domains to different ctrld instances on different ports if needed).
deploy_mode="proxy" — single listener on 0.0.0.0:ctrld_port.
Use when ctrld runs on a management host (not the router) and clients
point directly at ctrld — CIDR-based policy routing applies.
Flat header rule: never write a parent [listener] / [network] / [upstream] Flat header rule: never write a parent [listener] / [network] / [upstream]
before the dotted subtables — Go's TOML v2 panics on table redefinition. before the dotted subtables — Go's TOML v2 panics on table redefinition.
@@ -2023,6 +2031,7 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
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),
"# Docs: https://docs.controld.com/docs/ctrld", "# Docs: https://docs.controld.com/docs/ctrld",
"", "",
"[service]", "[service]",
@@ -2031,10 +2040,7 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
"", "",
] ]
def _upstream_index(i: int) -> str: # ── Upstream sections ─────────────────────────────────────────────────────
return str(i)
# ── Upstream sections (shared by both modes) ──────────────────────────────
for i, vp in enumerate(active): for i, vp in enumerate(active):
vid = vp["vlan_id"] vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}") name = vp.get("name", f"VLAN{vid}")
@@ -2055,60 +2061,32 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
"", "",
] ]
if local_resolver: # ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────
domain_suffix = local_domain.strip(".")
lines += [
f"# Local resolver — *.{local_domain} and *.local",
"[upstream.local]",
" name = \'Local Resolver\'",
" type = \'legacy\'",
f" endpoint = \'{local_resolver}\'",
" timeout = 2000",
"",
]
# ── ROUTER MODE: one listener per VLAN gateway IP ─────────────────────────
if deploy_mode == "router": if deploy_mode == "router":
for i, vp in enumerate(active): # Single listener on localhost — Unbound's Query Forwarding points here.
vid = vp["vlan_id"] # No per-VLAN listeners needed: Unbound handles all local resolution
name = vp.get("name", f"VLAN{vid}") # before queries arrive; ctrld just proxies external queries upstream.
gateway = vp.get("gateway", "").strip() lines += [
if not gateway: "# Listens on localhost only — Unbound Query Forwarding sends external queries here",
# Fall back to 0.0.0.0 for this VLAN if no gateway specified "[listener.0]",
gateway = "0.0.0.0" f" ip = \'127.0.0.1\'",
f" port = {ctrld_port}",
"",
" [listener.0.policy]",
" name = \'Default Policy\'",
" networks = []",
" rules = []",
]
if active:
lines[-1] = f" default = [\'upstream.0\']"
lines.append("")
lines += [ # ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────
f"# Listener for VLAN {vid}{name}",
f"[listener.{i}]",
f" ip = \'{gateway}\'",
" port = 53",
"",
f" [listener.{i}.policy]",
f" name = \'VLAN {vid} {name} Policy\'",
f" networks = []", # listener IP is the discriminator
" rules = [",
]
if local_resolver:
domain_suffix = local_domain.strip(".")
lines += [
" { " + f"\'*.{domain_suffix}\' = [\'upstream.local\']" + " },",
" { " + "\'*.local\' = [\'upstream.local\']" + " },",
" { " + "\'*.home.arpa\' = [\'upstream.local\']" + " },",
" { " + "\'*.in-addr.arpa\' = [\'upstream.local\']" + " },",
" { " + "\'*.ip6.arpa\' = [\'upstream.local\']" + " },",
]
lines += [
" ]",
f" default = [\'upstream.{i}\']",
"",
]
# ── PROXY MODE: single listener + CIDR network policies ──────────────────
else: else:
lines += [ lines += [
"[listener.0]", "[listener.0]",
" ip = \'0.0.0.0\'", f" ip = \'0.0.0.0\'",
" port = 53", f" port = {ctrld_port}",
"", "",
" [listener.0.policy]", " [listener.0.policy]",
" name = \'VLAN Policy\'", " name = \'VLAN Policy\'",
@@ -2123,23 +2101,9 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
else: else:
lines += [" networks = []"] lines += [" networks = []"]
if local_resolver: lines += [" rules = []", ""]
domain_suffix = local_domain.strip(".")
lines += [
" rules = [",
" { " + f"\'*.{domain_suffix}\' = [\'upstream.local\']" + " },",
" { " + "\'*.local\' = [\'upstream.local\']" + " },",
" { " + "\'*.home.arpa\' = [\'upstream.local\']" + " },",
" { " + "\'*.in-addr.arpa\' = [\'upstream.local\']" + " },",
" { " + "\'*.ip6.arpa\' = [\'upstream.local\']" + " },",
" ]",
]
else:
lines += [" rules = []"]
lines += [""] # Network sections for CIDR routing
# Network sections (only needed for CIDR routing in proxy mode)
for i, vp in enumerate(active): for i, vp in enumerate(active):
vid = vp["vlan_id"] vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}") name = vp.get("name", f"VLAN{vid}")
@@ -2170,11 +2134,11 @@ class CtrldVlanProfile(BaseModel):
class CtrldConfig(BaseModel): class CtrldConfig(BaseModel):
mode: str # "local" | "opnsense" | "manual" mode: str # "local" | "opnsense" | "manual"
deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host) deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0)
vlan_profiles: list[CtrldVlanProfile] vlan_profiles: list[CtrldVlanProfile]
opnsense_host: Optional[str] = "" opnsense_host: Optional[str] = ""
unbound_port: Optional[int] = 5353 # port Unbound listens on after being moved off 53 ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)
local_domain: Optional[str] = "lan" # domain suffix forwarded to Unbound (*.lan, *.local) local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)
class CtrldInstallRequest(BaseModel): class CtrldInstallRequest(BaseModel):
token: str token: str
@@ -2318,19 +2282,13 @@ def ctrld_validate_endpoints(body: CtrldValidateRequest):
def ctrld_toml_preview(): def ctrld_toml_preview():
"""Generate and return the ctrld.toml without installing it.""" """Generate and return the ctrld.toml without installing it."""
cfg = _load_ctrld_cfg() cfg = _load_ctrld_cfg()
profiles = cfg.get("vlan_profiles", []) profiles = cfg.get("vlan_profiles", [])
deploy_mode = cfg.get("deploy_mode", "proxy") deploy_mode = cfg.get("deploy_mode", "router")
unbound_port = cfg.get("unbound_port", 5353) ctrld_port = cfg.get("ctrld_port", 5354)
local_domain = cfg.get("local_domain", "lan")
if not profiles: if not profiles:
raise HTTPException(400, "No VLAN profiles configured yet") raise HTTPException(400, "No VLAN profiles configured yet")
toml = _build_ctrld_toml( toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
profiles, return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}
local_domain=local_domain,
local_resolver=f"127.0.0.1:{unbound_port}",
deploy_mode=deploy_mode,
)
return {"toml": toml, "deploy_mode": deploy_mode, "local_resolver": f"127.0.0.1:{unbound_port}"}
@app.post("/api/ctrld/save-config") @app.post("/api/ctrld/save-config")
def ctrld_save_config(body: CtrldInstallRequest): def ctrld_save_config(body: CtrldInstallRequest):
@@ -2357,33 +2315,27 @@ def ctrld_save_config(body: CtrldInstallRequest):
"toml_error": validation.get("toml_error", ""), "toml_error": validation.get("toml_error", ""),
}) })
deploy_mode = body.config.deploy_mode or "proxy" deploy_mode = body.config.deploy_mode or "router"
unbound_port = body.config.unbound_port or 5353 ctrld_port = body.config.ctrld_port or 5354
local_domain = body.config.local_domain or "lan" local_domain = body.config.local_domain or "lan"
cfg_dict = { cfg_dict = {
"mode": body.config.mode, "mode": body.config.mode,
"deploy_mode": deploy_mode, "deploy_mode": deploy_mode,
"vlan_profiles": profiles, "vlan_profiles": profiles,
"opnsense_host": body.config.opnsense_host, "opnsense_host": body.config.opnsense_host,
"unbound_port": unbound_port, "ctrld_port": ctrld_port,
"local_domain": local_domain, "local_domain": local_domain,
} }
_save_ctrld_cfg(cfg_dict) _save_ctrld_cfg(cfg_dict)
local_resolver = f"127.0.0.1:{unbound_port}" toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
toml = _build_ctrld_toml(
profiles,
local_domain=local_domain,
local_resolver=local_resolver,
deploy_mode=deploy_mode,
)
if body.config.mode == "local": if body.config.mode == "local":
return _ctrld_install_local(toml, profiles) return _ctrld_install_local(toml, profiles)
elif body.config.mode == "opnsense": elif body.config.mode == "opnsense":
return _ctrld_generate_opnsense_cmd( return _ctrld_generate_opnsense_cmd(
body.config.opnsense_host, profiles, deploy_mode, body.config.opnsense_host, profiles, deploy_mode,
unbound_port=unbound_port, local_domain=local_domain, ctrld_port=ctrld_port, local_domain=local_domain,
) )
else: else:
# Manual — just return the toml and instructions # Manual — just return the toml and instructions
@@ -2538,15 +2490,22 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
deploy_mode: str = "router", deploy_mode: str = "router",
unbound_port: int = 5353, ctrld_port: int = 5354,
local_domain: str = "lan") -> dict: local_domain: str = "lan") -> dict:
""" """
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.
Port-conflict fix: Unbound moves to localhost:5353 so ctrld can own :53. Confirmed working architecture (verified after reboot — no manual intervention needed):
ctrld.toml gets [upstream.local] pointing at 127.0.0.1:<unbound_port> so Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
*.lan / *.local / *.home.arpa queries still resolve via Unbound.
Both services start cleanly after reboot with zero conflict. 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.
NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is
a common tutorial artifact that causes PTR/reverse DNS failures.
""" """
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:
@@ -2557,57 +2516,60 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
f"-s {first_rid} forced'" f"-s {first_rid} forced'"
) )
local_resolver = f"127.0.0.1:{unbound_port}" toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
toml = _build_ctrld_toml(
profiles,
local_domain=local_domain,
local_resolver=local_resolver,
deploy_mode=deploy_mode,
)
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml" opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
# Shell one-liner to write the TOML file via SSH (safe for embedding)
escaped_toml = toml.replace("'", "'\\''")
write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF" write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF"
unbound_steps = [ setup_steps = [
"FIRST: fix the Unbound port conflict (do this before installing ctrld)", "Architecture: Unbound (:53) → Query Forwarding ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
" OPNsense GUI → Services → Unbound DNS → General:", "",
f" • Listen Port: 53 → {unbound_port}", "STEP 1 — Install ctrld on OPNsense (SSH or shell):",
" • Network Interfaces: All (recommended) → Localhost", f" {install_cmd}",
"",
"STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port),
f" {write_toml_cmd}",
" 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", " • Click Apply / Save",
" This moves Unbound to localhost only so ctrld can own port 53.", "",
" After reboot: Unbound starts on 5353 (no conflict), ctrld starts on 53.", "STEP 4 — Remove 'home.arpa' local-zone from Unbound if present:",
" 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",
] ]
return { return {
"success": True, "success": True,
"mode": "opnsense", "mode": "opnsense",
"message": "Step-by-step OPNsense ctrld install with Unbound coexistence", "message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port),
"unbound_steps": unbound_steps, "setup_steps": setup_steps,
"unbound_warning": ( "architecture": "Unbound stays on :53. ctrld binds 127.0.0.1:{} only — no port conflict possible.".format(ctrld_port),
"Unbound MUST be on localhost:{} before ctrld is installed. " "step1_install": install_cmd,
"If ctrld starts first it will grab :53 and Unbound will fail — " "step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'",
"then on next reboot Unbound grabs :53 first and ctrld fails. " "step2_config": f"Write {opnsense_cfg} with the TOML below, then: ctrld restart",
"Change the port first, then install ctrld.".format(unbound_port) "step3_unbound": (
f"Services → Unbound DNS → Query Forwarding: "
f"Enable, add zone '.' → 127.0.0.1:{ctrld_port}, no TLS, Apply"
), ),
"step1_unbound": "Change Unbound: Listen Port → {} | Network Interfaces → Localhost | Apply".format(unbound_port), "step4_cleanup": "Remove 'home.arpa' local-zone from Unbound custom options if present",
"step2_install": install_cmd, "step5_verify": "dig @router_ip google.com && dig @router_ip -x 192.168.1.1",
"step2_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'", "toml": toml,
"step3_config": f"Replace {opnsense_cfg} with the TOML below, then: ctrld restart", "toml_write_cmd": write_toml_cmd,
"step4_dns": ( "config_path": opnsense_cfg,
f"In OPNsense DHCP server for each VLAN, set DNS (option 6) to that VLAN's " "ctrld_port": ctrld_port,
f"gateway IP (e.g. 192.168.10.1 for VLAN 10) — not {opnsense_host}." "local_domain": local_domain,
), "docs": "https://docs.controld.com/docs/routers-platform",
"step5_verify": "dig @192.168.10.1 google.com # should return via ctrld",
"step5_local": f"dig @192.168.10.1 myhost.{local_domain} # should return via Unbound",
"toml": toml,
"toml_write_cmd": write_toml_cmd,
"config_path": opnsense_cfg,
"local_resolver": local_resolver,
"local_domain": local_domain,
"docs": "https://docs.controld.com/docs/routers-platform",
} }
@app.post("/api/ctrld/update-profiles") @app.post("/api/ctrld/update-profiles")
@@ -2618,15 +2580,9 @@ def ctrld_update_profiles(body: CtrldUpdateProfile):
cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles] cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles]
_save_ctrld_cfg(cfg) _save_ctrld_cfg(cfg)
deploy_mode = cfg.get("deploy_mode", "proxy") deploy_mode = cfg.get("deploy_mode", "router")
unbound_port = cfg.get("unbound_port", 5353) ctrld_port = cfg.get("ctrld_port", 5354)
local_domain = cfg.get("local_domain", "lan") toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)
toml = _build_ctrld_toml(
cfg["vlan_profiles"],
local_domain=local_domain,
local_resolver=f"127.0.0.1:{unbound_port}",
deploy_mode=deploy_mode,
)
cfg_path = _ctrld_config_path() cfg_path = _ctrld_config_path()
if cfg.get("mode") == "local" and cfg_path.exists(): if cfg.get("mode") == "local" and cfg_path.exists():
@@ -2832,7 +2788,8 @@ def _generate_ctrld_split_horizon_block(local_domain: str = "lan",
Because the format uses indexed table sections ([network.N], [upstream.N]), 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 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'). _build_ctrld_toml(vlan_profiles). Note: on OPNsense, Unbound handles
local resolution — ctrld does not need split-horizon rules at all.
This function returns a plain-English example for display only. This function returns a plain-English example for display only.
""" """
@@ -2909,8 +2866,8 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
if ctrld_cfg.get("vlan_profiles"): if ctrld_cfg.get("vlan_profiles"):
split_horizon_toml = _build_ctrld_toml( split_horizon_toml = _build_ctrld_toml(
ctrld_cfg["vlan_profiles"], ctrld_cfg["vlan_profiles"],
local_domain=body.local_domain or "lan", ctrld_port=ctrld_cfg.get("ctrld_port", 5354),
local_resolver=f"127.0.0.1:5353", deploy_mode=ctrld_cfg.get("deploy_mode", "router"),
) )
# Write new toml if running locally # Write new toml if running locally
if ctrld_cfg.get("mode") == "local": if ctrld_cfg.get("mode") == "local":