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:
Claude
2026-03-27 12:33:33 +00:00
parent 2db5c3babc
commit 4cf74335ad
+90 -41
View File
@@ -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":