Fix per-VLAN DNS: switch to proxy mode, Unbound on :5353
The gateway-listener approach (ctrld on each VLAN gateway IP:53) requires
Unbound to stop listening on those IPs, but OPNsense has no loopback option
in the Network Interfaces list — only named interfaces (LAN, vlan20, etc.).
Restricting Unbound that way is impractical.
Correct approach: proxy mode — ctrld owns port 53, Unbound moves to a
different port (5353). No interface restrictions needed, no port conflict
regardless of start order, and ctrld sees real client source IPs so
per-VLAN CIDR routing works correctly.
Architecture:
Clients → ctrld (0.0.0.0:53) → ControlD per-VLAN profile
ctrld → Unbound (127.0.0.1:5353) for *.lan / *.local (split-horizon)
Unbound has local-data records for all custom .lan hostnames
Changes:
_build_ctrld_toml: new unbound_port param (default 5353); proxy mode now
adds upstream.local → 127.0.0.1:unbound_port and split-horizon rules
for *.lan / *.local in the listener policy; defaults changed from
deploy_mode="router"/ctrld_port=5354 to deploy_mode="proxy"/ctrld_port=53
CtrldConfig: default deploy_mode="proxy", ctrld_port=53; added unbound_port=5353
_ctrld_generate_opnsense_cmd: proxy mode instructions now say to change
Unbound Listen Port to 5353 in OPNsense GUI (one field change, visible
in Services → Unbound DNS → General) and disable Query Forwarding
All call sites updated to pass unbound_port and use new defaults
OPNsense steps to activate:
1. Services → Unbound DNS → General → Listen Port: 5353 → Apply
2. Services → Unbound DNS → Query Forwarding → disable/remove forward zone
3. Regenerate and push ctrld.toml from DNS Filtering tab
https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
+89
-40
@@ -2074,8 +2074,9 @@ def _ctrld_config_path() -> _Path:
|
||||
if p.exists(): return p
|
||||
return candidates[0] # default for new install
|
||||
|
||||
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
||||
deploy_mode: str = "router") -> str:
|
||||
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
|
||||
deploy_mode: str = "proxy",
|
||||
unbound_port: int = 5353) -> str:
|
||||
"""
|
||||
Build a ctrld.toml using flat dotted-key section headers.
|
||||
|
||||
@@ -2211,9 +2212,22 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
||||
lines[-1] = f" default = [\'upstream.0\']"
|
||||
lines.append("")
|
||||
|
||||
# ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────
|
||||
# ── PROXY MODE: 0.0.0.0:53 + per-VLAN CIDR routing ──────────────────────
|
||||
else:
|
||||
# ctrld is the primary resolver on port 53.
|
||||
# Unbound runs on a different port (unbound_port, default 5353) so
|
||||
# there is no port conflict regardless of start order.
|
||||
# ctrld sees real client source IPs and routes each VLAN to the
|
||||
# correct ControlD profile via [network.N] CIDR entries.
|
||||
# .lan / .local queries are split-horizon'd to Unbound via upstream.local.
|
||||
lines += [
|
||||
f"# Unbound on 127.0.0.1:{unbound_port} handles .lan/.local — ctrld forwards here",
|
||||
"[upstream.local]",
|
||||
" name = \'Local .lan resolver (Unbound)\'",
|
||||
" type = \'legacy\'",
|
||||
f" endpoint = \'127.0.0.1:{unbound_port}\'",
|
||||
" timeout = 2000",
|
||||
"",
|
||||
"[listener.0]",
|
||||
f" ip = \'0.0.0.0\'",
|
||||
f" port = {ctrld_port}",
|
||||
@@ -2231,7 +2245,13 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
||||
else:
|
||||
lines += [" networks = []"]
|
||||
|
||||
lines += [" rules = []", ""]
|
||||
lines += [
|
||||
" rules = [",
|
||||
" { \'*.lan\' = [\'upstream.local\'] },",
|
||||
" { \'*.local\' = [\'upstream.local\'] },",
|
||||
" ]",
|
||||
"",
|
||||
]
|
||||
|
||||
# Network sections for CIDR routing
|
||||
for i, vp in enumerate(active):
|
||||
@@ -2260,15 +2280,15 @@ class CtrldVlanProfile(BaseModel):
|
||||
endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set
|
||||
protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy
|
||||
gateway: Optional[str] = "" # VLAN gateway IP on the router (e.g. "192.168.10.1")
|
||||
# Required for router-mode multi-listener TOML
|
||||
|
||||
class CtrldConfig(BaseModel):
|
||||
mode: str # "local" | "opnsense" | "manual"
|
||||
deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0)
|
||||
deploy_mode: Optional[str] = "proxy" # "proxy" (ctrld on :53, per-VLAN CIDR) | "router" (ctrld on localhost)
|
||||
vlan_profiles: list[CtrldVlanProfile]
|
||||
opnsense_host: Optional[str] = ""
|
||||
ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)
|
||||
local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)
|
||||
ctrld_port: Optional[int] = 53 # port ctrld listens on (53 in proxy mode)
|
||||
unbound_port: Optional[int] = 5353 # port Unbound listens on (change in OPNsense UI)
|
||||
local_domain: Optional[str] = "lan" # local domain Unbound handles (forwarded to Unbound by ctrld)
|
||||
|
||||
class CtrldInstallRequest(BaseModel):
|
||||
token: str
|
||||
@@ -2413,11 +2433,13 @@ 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", "router")
|
||||
ctrld_port = cfg.get("ctrld_port", 5354)
|
||||
deploy_mode = cfg.get("deploy_mode", "proxy")
|
||||
ctrld_port = cfg.get("ctrld_port", 53)
|
||||
unbound_port = cfg.get("unbound_port", 5353)
|
||||
if not profiles:
|
||||
raise HTTPException(400, "No VLAN profiles configured yet")
|
||||
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, unbound_port=unbound_port)
|
||||
return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}
|
||||
|
||||
@app.post("/api/ctrld/save-config")
|
||||
@@ -2445,8 +2467,9 @@ def ctrld_save_config(body: CtrldInstallRequest):
|
||||
"toml_error": validation.get("toml_error", ""),
|
||||
})
|
||||
|
||||
deploy_mode = body.config.deploy_mode or "router"
|
||||
ctrld_port = body.config.ctrld_port or 5354
|
||||
deploy_mode = body.config.deploy_mode or "proxy"
|
||||
ctrld_port = body.config.ctrld_port or 53
|
||||
unbound_port = body.config.unbound_port or 5353
|
||||
local_domain = body.config.local_domain or "lan"
|
||||
cfg_dict = {
|
||||
"mode": body.config.mode,
|
||||
@@ -2454,18 +2477,20 @@ def ctrld_save_config(body: CtrldInstallRequest):
|
||||
"vlan_profiles": profiles,
|
||||
"opnsense_host": body.config.opnsense_host,
|
||||
"ctrld_port": ctrld_port,
|
||||
"unbound_port": unbound_port,
|
||||
"local_domain": local_domain,
|
||||
}
|
||||
_save_ctrld_cfg(cfg_dict)
|
||||
|
||||
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, unbound_port=unbound_port)
|
||||
|
||||
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,
|
||||
ctrld_port=ctrld_port, local_domain=local_domain,
|
||||
ctrld_port=ctrld_port, unbound_port=unbound_port, local_domain=local_domain,
|
||||
)
|
||||
else:
|
||||
# Manual — just return the toml and instructions
|
||||
@@ -2619,26 +2644,23 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
|
||||
}
|
||||
|
||||
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||||
deploy_mode: str = "router",
|
||||
ctrld_port: int = 5354,
|
||||
deploy_mode: str = "proxy",
|
||||
ctrld_port: int = 53,
|
||||
unbound_port: int = 5353,
|
||||
local_domain: str = "lan") -> dict:
|
||||
"""
|
||||
Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.
|
||||
|
||||
Architecture depends on whether VLAN profiles have gateway IPs set:
|
||||
Proxy mode (default, recommended for per-VLAN profiles):
|
||||
Clients → ctrld (0.0.0.0:53) → ControlD per-VLAN profile via CIDR routing
|
||||
Unbound on 127.0.0.1:unbound_port (default 5353) for .lan resolution
|
||||
No port conflict — different ports, any start order is fine.
|
||||
ctrld sees real client source IPs → per-VLAN ControlD profiles work.
|
||||
.lan / .local queries split-horizon'd to Unbound via upstream.local.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
Router mode (fallback — per-VLAN profiles DO NOT work):
|
||||
Clients → Unbound:53 → Query Forwarding → ctrld (127.0.0.1:ctrld_port)
|
||||
All VLANs share one upstream — Unbound strips the source IP.
|
||||
"""
|
||||
first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
|
||||
if not first_rid:
|
||||
@@ -2649,11 +2671,12 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||||
f"-s {first_rid} forced'"
|
||||
)
|
||||
|
||||
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, unbound_port=unbound_port)
|
||||
|
||||
# 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)
|
||||
per_gateway = deploy_mode == "router" and bool(gateways) and all(gateways)
|
||||
|
||||
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
|
||||
write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF"
|
||||
@@ -2693,6 +2716,30 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||||
"Each VLAN's DNS traffic hits ctrld on its gateway IP; "
|
||||
"ctrld routes to the correct ControlD profile by source subnet."
|
||||
)
|
||||
elif deploy_mode == "proxy":
|
||||
arch_line = f"Architecture: ctrld (0.0.0.0:{ctrld_port}) ← clients; Unbound (127.0.0.1:{unbound_port}) ← .lan — per-VLAN profiles active"
|
||||
step3 = [
|
||||
f"STEP 3 — Change Unbound's Listen Port to {unbound_port} (so ctrld can own port 53):",
|
||||
" OPNsense GUI → Services → Unbound DNS → General:",
|
||||
f" Listen Port: change from 53 to {unbound_port}",
|
||||
" Network Interfaces: leave as 'All (recommended)'",
|
||||
" Click Apply",
|
||||
"",
|
||||
"STEP 4 — Disable Unbound Query Forwarding (ctrld IS the resolver, not downstream):",
|
||||
" Services → Unbound DNS → Query Forwarding → disable / remove any forward zone",
|
||||
"",
|
||||
"STEP 5 — Verify per-VLAN routing:",
|
||||
" From a device on each VLAN, run: nslookup google.com",
|
||||
f" From any device, run: nslookup pbx.{local_domain}",
|
||||
" Check ControlD dashboard — each VLAN's traffic should appear under its own resolver",
|
||||
]
|
||||
step2_note = f"Write {opnsense_cfg} with the TOML below, then: ctrld restart (ctrld owns port {ctrld_port})"
|
||||
message = f"Proxy mode: ctrld on :{ctrld_port}, Unbound on :{unbound_port} — per-VLAN ControlD profiles active"
|
||||
architecture = (
|
||||
f"ctrld listens on 0.0.0.0:{ctrld_port} — clients query their VLAN gateway, "
|
||||
f"ctrld sees real source IPs and routes to the correct ControlD profile. "
|
||||
f"Unbound on 127.0.0.1:{unbound_port} handles .lan/.local (no port conflict)."
|
||||
)
|
||||
else:
|
||||
arch_line = "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD [WARNING: single shared profile]".format(ctrld_port)
|
||||
step3 = [
|
||||
@@ -2704,13 +2751,12 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||||
" • 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)"
|
||||
message = f"Router fallback mode — all VLANs share one ControlD profile (use proxy mode 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."
|
||||
"Per-VLAN ControlD profiles DO NOT work — all queries appear from 127.0.0.1."
|
||||
)
|
||||
|
||||
setup_steps = [
|
||||
@@ -2757,9 +2803,11 @@ 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", "router")
|
||||
ctrld_port = cfg.get("ctrld_port", 5354)
|
||||
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||||
deploy_mode = cfg.get("deploy_mode", "proxy")
|
||||
ctrld_port = cfg.get("ctrld_port", 53)
|
||||
unbound_port = cfg.get("unbound_port", 5353)
|
||||
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port,
|
||||
deploy_mode=deploy_mode, unbound_port=unbound_port)
|
||||
cfg_path = _ctrld_config_path()
|
||||
|
||||
if cfg.get("mode") == "local" and cfg_path.exists():
|
||||
@@ -3077,8 +3125,9 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
|
||||
if ctrld_cfg.get("vlan_profiles"):
|
||||
split_horizon_toml = _build_ctrld_toml(
|
||||
ctrld_cfg["vlan_profiles"],
|
||||
ctrld_port=ctrld_cfg.get("ctrld_port", 5354),
|
||||
deploy_mode=ctrld_cfg.get("deploy_mode", "router"),
|
||||
ctrld_port=ctrld_cfg.get("ctrld_port", 53),
|
||||
deploy_mode=ctrld_cfg.get("deploy_mode", "proxy"),
|
||||
unbound_port=ctrld_cfg.get("unbound_port", 5353),
|
||||
)
|
||||
# Write new toml if running locally
|
||||
if ctrld_cfg.get("mode") == "local":
|
||||
|
||||
Reference in New Issue
Block a user