From 4cf74335adcb1c44a340089985df38da4554508d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 12:33:33 +0000 Subject: [PATCH] Fix per-VLAN DNS: switch to proxy mode, Unbound on :5353 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- switch_backend.py | 131 +++++++++++++++++++++++++++++++--------------- 1 file changed, 90 insertions(+), 41 deletions(-) diff --git a/switch_backend.py b/switch_backend.py index eae56d4..ad4c096 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -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 @@ -2412,12 +2432,14 @@ 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", "router") - ctrld_port = cfg.get("ctrld_port", 5354) + profiles = cfg.get("vlan_profiles", []) + 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":