Fix Unbound/ctrld port 53 conflict on OPNsense — coexistence via port 5353

Root cause: OPNsense enables Unbound on 0.0.0.0:53 at boot. ctrld also
needs :53. Whoever starts second loses. After upgrades/reboots Unbound
wins and ctrld silently fails (or vice-versa).

Fix: move Unbound to localhost:5353 only, ctrld owns :53 on VLAN IPs.
Both services now start cleanly after every reboot with zero conflict.

Changes:
- CtrldConfig adds unbound_port (default 5353) and local_domain ("lan")
- _build_ctrld_toml now always receives local_resolver="127.0.0.1:5353"
  in OPNsense/opnsense mode; adds [upstream.local] type=legacy so *.lan,
  *.local, *.home.arpa queries still resolve through Unbound
- _ctrld_generate_opnsense_cmd emits clear step-by-step instructions:
    step1_unbound  — change Unbound port to 5353 + restrict to Localhost
    step2_install  — ctrld install command
    step3_config   — write ctrld.toml (includes local upstream for Unbound)
    step4_dns      — set DHCP option 6 to per-VLAN gateway IP
    step5_verify   — test both internet and local DNS after deploy
  Includes unbound_warning explaining why the order matters
- save-config, toml-preview, and update-profiles all persist and reload
  unbound_port + local_domain from ctrld.json

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-24 15:17:21 +00:00
parent 0c977eace4
commit 13670000c2
+98 -29
View File
@@ -2163,10 +2163,12 @@ class CtrldVlanProfile(BaseModel):
# Required for router-mode multi-listener TOML
class CtrldConfig(BaseModel):
mode: str # "local" | "opnsense" | "manual"
deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host)
vlan_profiles: list[CtrldVlanProfile]
opnsense_host: Optional[str] = ""
mode: str # "local" | "opnsense" | "manual"
deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host)
vlan_profiles: list[CtrldVlanProfile]
opnsense_host: Optional[str] = ""
unbound_port: Optional[int] = 5353 # port Unbound listens on after being moved off 53
local_domain: Optional[str] = "lan" # domain suffix forwarded to Unbound (*.lan, *.local)
class CtrldInstallRequest(BaseModel):
token: str
@@ -2310,12 +2312,19 @@ def ctrld_validate_endpoints(body: CtrldValidateRequest):
def ctrld_toml_preview():
"""Generate and return the ctrld.toml without installing it."""
cfg = _load_ctrld_cfg()
profiles = cfg.get("vlan_profiles", [])
deploy_mode = cfg.get("deploy_mode", "proxy")
profiles = cfg.get("vlan_profiles", [])
deploy_mode = cfg.get("deploy_mode", "proxy")
unbound_port = cfg.get("unbound_port", 5353)
local_domain = cfg.get("local_domain", "lan")
if not profiles:
raise HTTPException(400, "No VLAN profiles configured yet")
toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode)
return {"toml": toml, "deploy_mode": deploy_mode}
toml = _build_ctrld_toml(
profiles,
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")
def ctrld_save_config(body: CtrldInstallRequest):
@@ -2342,21 +2351,34 @@ def ctrld_save_config(body: CtrldInstallRequest):
"toml_error": validation.get("toml_error", ""),
})
deploy_mode = body.config.deploy_mode or "proxy"
deploy_mode = body.config.deploy_mode or "proxy"
unbound_port = body.config.unbound_port or 5353
local_domain = body.config.local_domain or "lan"
cfg_dict = {
"mode": body.config.mode,
"deploy_mode": deploy_mode,
"vlan_profiles": profiles,
"opnsense_host": body.config.opnsense_host,
"unbound_port": unbound_port,
"local_domain": local_domain,
}
_save_ctrld_cfg(cfg_dict)
toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode)
local_resolver = f"127.0.0.1:{unbound_port}"
toml = _build_ctrld_toml(
profiles,
local_domain=local_domain,
local_resolver=local_resolver,
deploy_mode=deploy_mode,
)
if body.config.mode == "local":
return _ctrld_install_local(toml, profiles)
elif body.config.mode == "opnsense":
return _ctrld_generate_opnsense_cmd(body.config.opnsense_host, profiles, deploy_mode)
return _ctrld_generate_opnsense_cmd(
body.config.opnsense_host, profiles, deploy_mode,
unbound_port=unbound_port, local_domain=local_domain,
)
else:
# Manual — just return the toml and instructions
return {
@@ -2509,10 +2531,16 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
}
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
deploy_mode: str = "router") -> dict:
deploy_mode: str = "router",
unbound_port: int = 5353,
local_domain: str = "lan") -> dict:
"""
Generate the SSH command to install ctrld on OPNsense.
Defaults to router deploy_mode — OPNsense has per-VLAN gateway IPs.
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.
ctrld.toml gets [upstream.local] pointing at 127.0.0.1:<unbound_port> so
*.lan / *.local / *.home.arpa queries still resolve via Unbound.
Both services start cleanly after reboot with zero conflict.
"""
first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
if not first_rid:
@@ -2523,23 +2551,57 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
f"-s {first_rid} forced'"
)
toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode)
local_resolver = f"127.0.0.1:{unbound_port}"
toml = _build_ctrld_toml(
profiles,
local_domain=local_domain,
local_resolver=local_resolver,
deploy_mode=deploy_mode,
)
# For OPNsense the config path is different
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"
unbound_steps = [
"FIRST: fix the Unbound port conflict (do this before installing ctrld)",
" OPNsense GUI → Services → Unbound DNS → General:",
f" • Listen Port: 53 → {unbound_port}",
" • Network Interfaces: All (recommended) → Localhost",
" • 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.",
]
return {
"success": True,
"mode": "opnsense",
"message": "Run the install command in OPNsense shell (SSH or console)",
"install_cmd": install_cmd,
"ssh_cmd": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'",
"toml": toml,
"config_path": opnsense_cfg,
"step2": f"After install, replace {opnsense_cfg} with the toml config shown below",
"step3": "Run: ctrld restart",
"step4": f"Set DNS (option 6) to {opnsense_host or 'OPNsense IP'} on each VLAN pool",
"docs": "https://docs.controld.com/docs/routers-platform",
"success": True,
"mode": "opnsense",
"message": "Step-by-step OPNsense ctrld install with Unbound coexistence",
"unbound_steps": unbound_steps,
"unbound_warning": (
"Unbound MUST be on localhost:{} before ctrld is installed. "
"If ctrld starts first it will grab :53 and Unbound will fail — "
"then on next reboot Unbound grabs :53 first and ctrld fails. "
"Change the port first, then install ctrld.".format(unbound_port)
),
"step1_unbound": "Change Unbound: Listen Port → {} | Network Interfaces → Localhost | Apply".format(unbound_port),
"step2_install": install_cmd,
"step2_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'",
"step3_config": f"Replace {opnsense_cfg} with the TOML below, then: ctrld restart",
"step4_dns": (
f"In OPNsense DHCP server for each VLAN, set DNS (option 6) to that VLAN's "
f"gateway IP (e.g. 192.168.10.1 for VLAN 10) — not {opnsense_host}."
),
"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")
@@ -2550,8 +2612,15 @@ def ctrld_update_profiles(body: CtrldUpdateProfile):
cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles]
_save_ctrld_cfg(cfg)
deploy_mode = cfg.get("deploy_mode", "proxy")
toml = _build_ctrld_toml(cfg["vlan_profiles"], deploy_mode=deploy_mode)
deploy_mode = cfg.get("deploy_mode", "proxy")
unbound_port = cfg.get("unbound_port", 5353)
local_domain = cfg.get("local_domain", "lan")
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()
if cfg.get("mode") == "local" and cfg_path.exists():