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:
+90
-41
@@ -2074,8 +2074,9 @@ 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, ctrld_port: int = 5354,
|
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
|
||||||
deploy_mode: str = "router") -> str:
|
deploy_mode: str = "proxy",
|
||||||
|
unbound_port: int = 5353) -> str:
|
||||||
"""
|
"""
|
||||||
Build a ctrld.toml using flat dotted-key section headers.
|
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[-1] = f" default = [\'upstream.0\']"
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────
|
# ── PROXY MODE: 0.0.0.0:53 + per-VLAN CIDR routing ──────────────────────
|
||||||
else:
|
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 += [
|
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]",
|
"[listener.0]",
|
||||||
f" ip = \'0.0.0.0\'",
|
f" ip = \'0.0.0.0\'",
|
||||||
f" port = {ctrld_port}",
|
f" port = {ctrld_port}",
|
||||||
@@ -2231,7 +2245,13 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
|||||||
else:
|
else:
|
||||||
lines += [" networks = []"]
|
lines += [" networks = []"]
|
||||||
|
|
||||||
lines += [" rules = []", ""]
|
lines += [
|
||||||
|
" rules = [",
|
||||||
|
" { \'*.lan\' = [\'upstream.local\'] },",
|
||||||
|
" { \'*.local\' = [\'upstream.local\'] },",
|
||||||
|
" ]",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
# Network sections for CIDR routing
|
# Network sections for CIDR routing
|
||||||
for i, vp in enumerate(active):
|
for i, vp in enumerate(active):
|
||||||
@@ -2260,15 +2280,15 @@ class CtrldVlanProfile(BaseModel):
|
|||||||
endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set
|
endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set
|
||||||
protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy
|
protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy
|
||||||
gateway: Optional[str] = "" # VLAN gateway IP on the router (e.g. "192.168.10.1")
|
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):
|
class CtrldConfig(BaseModel):
|
||||||
mode: str # "local" | "opnsense" | "manual"
|
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]
|
vlan_profiles: list[CtrldVlanProfile]
|
||||||
opnsense_host: Optional[str] = ""
|
opnsense_host: Optional[str] = ""
|
||||||
ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)
|
ctrld_port: Optional[int] = 53 # port ctrld listens on (53 in proxy mode)
|
||||||
local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)
|
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):
|
class CtrldInstallRequest(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
@@ -2412,12 +2432,14 @@ 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", "router")
|
deploy_mode = cfg.get("deploy_mode", "proxy")
|
||||||
ctrld_port = cfg.get("ctrld_port", 5354)
|
ctrld_port = cfg.get("ctrld_port", 53)
|
||||||
|
unbound_port = cfg.get("unbound_port", 5353)
|
||||||
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(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}
|
return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}
|
||||||
|
|
||||||
@app.post("/api/ctrld/save-config")
|
@app.post("/api/ctrld/save-config")
|
||||||
@@ -2445,8 +2467,9 @@ 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 "router"
|
deploy_mode = body.config.deploy_mode or "proxy"
|
||||||
ctrld_port = body.config.ctrld_port or 5354
|
ctrld_port = body.config.ctrld_port or 53
|
||||||
|
unbound_port = body.config.unbound_port or 5353
|
||||||
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,
|
||||||
@@ -2454,18 +2477,20 @@ def ctrld_save_config(body: CtrldInstallRequest):
|
|||||||
"vlan_profiles": profiles,
|
"vlan_profiles": profiles,
|
||||||
"opnsense_host": body.config.opnsense_host,
|
"opnsense_host": body.config.opnsense_host,
|
||||||
"ctrld_port": ctrld_port,
|
"ctrld_port": ctrld_port,
|
||||||
|
"unbound_port": unbound_port,
|
||||||
"local_domain": local_domain,
|
"local_domain": local_domain,
|
||||||
}
|
}
|
||||||
_save_ctrld_cfg(cfg_dict)
|
_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":
|
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,
|
||||||
ctrld_port=ctrld_port, local_domain=local_domain,
|
ctrld_port=ctrld_port, unbound_port=unbound_port, local_domain=local_domain,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Manual — just return the toml and instructions
|
# 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,
|
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||||||
deploy_mode: str = "router",
|
deploy_mode: str = "proxy",
|
||||||
ctrld_port: int = 5354,
|
ctrld_port: int = 53,
|
||||||
|
unbound_port: int = 5353,
|
||||||
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.
|
||||||
|
|
||||||
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):
|
Router mode (fallback — per-VLAN profiles DO NOT work):
|
||||||
Clients → ctrld on VLAN-gateway-IP:53 → ControlD per-VLAN profile
|
Clients → Unbound:53 → Query Forwarding → ctrld (127.0.0.1:ctrld_port)
|
||||||
Unbound stays on 127.0.0.1:53 for .lan resolution (no port conflict)
|
All VLANs share one upstream — Unbound strips the source IP.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
@@ -2649,11 +2671,12 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
|||||||
f"-s {first_rid} forced'"
|
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
|
# 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")]
|
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"
|
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"
|
||||||
@@ -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; "
|
"Each VLAN's DNS traffic hits ctrld on its gateway IP; "
|
||||||
"ctrld routes to the correct ControlD profile by source subnet."
|
"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:
|
else:
|
||||||
arch_line = "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD [WARNING: single shared profile]".format(ctrld_port)
|
arch_line = "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD [WARNING: single shared profile]".format(ctrld_port)
|
||||||
step3 = [
|
step3 = [
|
||||||
@@ -2704,13 +2751,12 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
|||||||
" • Click Apply / Save",
|
" • Click Apply / Save",
|
||||||
"",
|
"",
|
||||||
" WARNING: in this mode all VLANs share the same ControlD profile.",
|
" 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"
|
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 = (
|
architecture = (
|
||||||
f"Unbound on :53 forwards to ctrld on 127.0.0.1:{ctrld_port}. "
|
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 = [
|
setup_steps = [
|
||||||
@@ -2757,9 +2803,11 @@ 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", "router")
|
deploy_mode = cfg.get("deploy_mode", "proxy")
|
||||||
ctrld_port = cfg.get("ctrld_port", 5354)
|
ctrld_port = cfg.get("ctrld_port", 53)
|
||||||
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
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()
|
cfg_path = _ctrld_config_path()
|
||||||
|
|
||||||
if cfg.get("mode") == "local" and cfg_path.exists():
|
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"):
|
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"],
|
||||||
ctrld_port=ctrld_cfg.get("ctrld_port", 5354),
|
ctrld_port=ctrld_cfg.get("ctrld_port", 53),
|
||||||
deploy_mode=ctrld_cfg.get("deploy_mode", "router"),
|
deploy_mode=ctrld_cfg.get("deploy_mode", "proxy"),
|
||||||
|
unbound_port=ctrld_cfg.get("unbound_port", 5353),
|
||||||
)
|
)
|
||||||
# Write new toml if running locally
|
# Write new toml if running locally
|
||||||
if ctrld_cfg.get("mode") == "local":
|
if ctrld_cfg.get("mode") == "local":
|
||||||
|
|||||||
Reference in New Issue
Block a user