diff --git a/switch_backend.py b/switch_backend.py index 77649fa..ef286dc 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -1998,20 +1998,28 @@ def _ctrld_config_path() -> _Path: if p.exists(): return p return candidates[0] # default for new install -def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", - local_resolver: str = "", - deploy_mode: str = "proxy") -> str: +def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354, + deploy_mode: str = "router") -> str: """ Build a ctrld.toml using flat dotted-key section headers. - deploy_mode="router" — multiple listeners, one per VLAN gateway IP. - Best for ctrld running ON the router (OPNsense). Each VLAN client - sends DNS to its gateway IP; ctrld binds there and knows the profile - from the listener — no CIDR lookup needed. Requires `gateway` on - every CtrldVlanProfile. + Confirmed working architecture on OPNsense (verified after reboot): + Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD - deploy_mode="proxy" — single listener on 0.0.0.0, CIDR-based policy - routing. Use when ctrld runs on a management host (not the router). + Unbound stays on port 53 — never moved. ctrld binds to localhost only + on port 5354 so it cannot conflict with Unbound at startup. Unbound's + Query Forwarding sends all external queries through ctrld. Local DNS + (host overrides, local zones) is handled entirely by Unbound before any + query reaches ctrld, so no split-horizon rules are needed here. + + deploy_mode="router" — single listener on 127.0.0.1:ctrld_port. + Unbound forwards upstream queries here via Query Forwarding. + Per-VLAN policy differentiation is handled by Unbound (forward different + domains to different ctrld instances on different ports if needed). + + deploy_mode="proxy" — single listener on 0.0.0.0:ctrld_port. + Use when ctrld runs on a management host (not the router) and clients + point directly at ctrld — CIDR-based policy routing applies. Flat header rule: never write a parent [listener] / [network] / [upstream] before the dotted subtables — Go's TOML v2 panics on table redefinition. @@ -2023,6 +2031,7 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", lines = [ "# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager", + "# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port), "# Docs: https://docs.controld.com/docs/ctrld", "", "[service]", @@ -2031,10 +2040,7 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", "", ] - def _upstream_index(i: int) -> str: - return str(i) - - # ── Upstream sections (shared by both modes) ────────────────────────────── + # ── Upstream sections ───────────────────────────────────────────────────── for i, vp in enumerate(active): vid = vp["vlan_id"] name = vp.get("name", f"VLAN{vid}") @@ -2055,60 +2061,32 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", "", ] - if local_resolver: - domain_suffix = local_domain.strip(".") - lines += [ - f"# Local resolver — *.{local_domain} and *.local", - "[upstream.local]", - " name = \'Local Resolver\'", - " type = \'legacy\'", - f" endpoint = \'{local_resolver}\'", - " timeout = 2000", - "", - ] - - # ── ROUTER MODE: one listener per VLAN gateway IP ───────────────────────── + # ── ROUTER MODE: localhost listener, Unbound forwards here ─────────────── if deploy_mode == "router": - for i, vp in enumerate(active): - vid = vp["vlan_id"] - name = vp.get("name", f"VLAN{vid}") - gateway = vp.get("gateway", "").strip() - if not gateway: - # Fall back to 0.0.0.0 for this VLAN if no gateway specified - gateway = "0.0.0.0" + # Single listener on localhost — Unbound's Query Forwarding points here. + # No per-VLAN listeners needed: Unbound handles all local resolution + # before queries arrive; ctrld just proxies external queries upstream. + lines += [ + "# Listens on localhost only — Unbound Query Forwarding sends external queries here", + "[listener.0]", + f" ip = \'127.0.0.1\'", + f" port = {ctrld_port}", + "", + " [listener.0.policy]", + " name = \'Default Policy\'", + " networks = []", + " rules = []", + ] + if active: + lines[-1] = f" default = [\'upstream.0\']" + lines.append("") - lines += [ - f"# Listener for VLAN {vid} — {name}", - f"[listener.{i}]", - f" ip = \'{gateway}\'", - " port = 53", - "", - f" [listener.{i}.policy]", - f" name = \'VLAN {vid} {name} Policy\'", - f" networks = []", # listener IP is the discriminator - " rules = [", - ] - if local_resolver: - domain_suffix = local_domain.strip(".") - lines += [ - " { " + f"\'*.{domain_suffix}\' = [\'upstream.local\']" + " },", - " { " + "\'*.local\' = [\'upstream.local\']" + " },", - " { " + "\'*.home.arpa\' = [\'upstream.local\']" + " },", - " { " + "\'*.in-addr.arpa\' = [\'upstream.local\']" + " },", - " { " + "\'*.ip6.arpa\' = [\'upstream.local\']" + " },", - ] - lines += [ - " ]", - f" default = [\'upstream.{i}\']", - "", - ] - - # ── PROXY MODE: single listener + CIDR network policies ────────────────── + # ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ───────────────── else: lines += [ "[listener.0]", - " ip = \'0.0.0.0\'", - " port = 53", + f" ip = \'0.0.0.0\'", + f" port = {ctrld_port}", "", " [listener.0.policy]", " name = \'VLAN Policy\'", @@ -2123,23 +2101,9 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", else: lines += [" networks = []"] - if local_resolver: - domain_suffix = local_domain.strip(".") - lines += [ - " rules = [", - " { " + f"\'*.{domain_suffix}\' = [\'upstream.local\']" + " },", - " { " + "\'*.local\' = [\'upstream.local\']" + " },", - " { " + "\'*.home.arpa\' = [\'upstream.local\']" + " },", - " { " + "\'*.in-addr.arpa\' = [\'upstream.local\']" + " },", - " { " + "\'*.ip6.arpa\' = [\'upstream.local\']" + " },", - " ]", - ] - else: - lines += [" rules = []"] + lines += [" rules = []", ""] - lines += [""] - - # Network sections (only needed for CIDR routing in proxy mode) + # Network sections for CIDR routing for i, vp in enumerate(active): vid = vp["vlan_id"] name = vp.get("name", f"VLAN{vid}") @@ -2170,11 +2134,11 @@ class CtrldVlanProfile(BaseModel): class CtrldConfig(BaseModel): mode: str # "local" | "opnsense" | "manual" - deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host) + deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0) 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) + 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) class CtrldInstallRequest(BaseModel): token: str @@ -2318,19 +2282,13 @@ 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") - unbound_port = cfg.get("unbound_port", 5353) - local_domain = cfg.get("local_domain", "lan") + profiles = cfg.get("vlan_profiles", []) + deploy_mode = cfg.get("deploy_mode", "router") + ctrld_port = cfg.get("ctrld_port", 5354) if not profiles: raise HTTPException(400, "No VLAN profiles configured yet") - 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}"} + toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode) + return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port} @app.post("/api/ctrld/save-config") def ctrld_save_config(body: CtrldInstallRequest): @@ -2357,33 +2315,27 @@ def ctrld_save_config(body: CtrldInstallRequest): "toml_error": validation.get("toml_error", ""), }) - deploy_mode = body.config.deploy_mode or "proxy" - unbound_port = body.config.unbound_port or 5353 + deploy_mode = body.config.deploy_mode or "router" + ctrld_port = body.config.ctrld_port or 5354 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, + "ctrld_port": ctrld_port, "local_domain": local_domain, } _save_ctrld_cfg(cfg_dict) - 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, - ) + toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, 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, - unbound_port=unbound_port, local_domain=local_domain, + ctrld_port=ctrld_port, local_domain=local_domain, ) else: # Manual — just return the toml and instructions @@ -2538,15 +2490,22 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict: def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, deploy_mode: str = "router", - unbound_port: int = 5353, + ctrld_port: int = 5354, local_domain: str = "lan") -> dict: """ 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: so - *.lan / *.local / *.home.arpa queries still resolve via Unbound. - Both services start cleanly after reboot with zero conflict. + Confirmed working architecture (verified after reboot — no manual intervention needed): + Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD + + Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot + conflict with Unbound at startup regardless of service start order. + Unbound's Query Forwarding sends external queries through ctrld. + Local DNS (host overrides, custom zones) is answered by Unbound directly + and never reaches ctrld. + + NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is + a common tutorial artifact that causes PTR/reverse DNS failures. """ first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None) if not first_rid: @@ -2557,57 +2516,60 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, f"-s {first_rid} forced'" ) - 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, - ) + toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode) 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", + setup_steps = [ + "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port), + "", + "STEP 1 — Install ctrld on OPNsense (SSH or shell):", + f" {install_cmd}", + "", + "STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port), + f" {write_toml_cmd}", + " Then restart ctrld: ctrld restart", + "", + "STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):", + " OPNsense GUI → Services → Unbound DNS → Query Forwarding:", + " • Enable Query Forwarding: checked", + f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}", + " • Use TLS: No (ctrld handles DoH/DoT upstream; plain DNS locally is fine)", " • 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.", + "", + "STEP 4 — Remove 'home.arpa' local-zone from Unbound if present:", + " OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:", + " Remove any line containing: local-zone: \"home.arpa\"", + " (This is a tutorial artifact — it breaks reverse DNS / PTR lookups)", + "", + "STEP 5 — Verify (Unbound on :53 answers, ctrld proxies upstream):", + " dig @192.168.1.1 google.com # external — goes through ctrld → ControlD", + f" dig @192.168.1.1 myhost.{local_domain} # local — answered by Unbound directly", + " dig @192.168.1.1 -x 192.168.1.1 # reverse PTR — answered by Unbound directly", ] return { - "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) + "success": True, + "mode": "opnsense", + "message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port), + "setup_steps": setup_steps, + "architecture": "Unbound stays on :53. ctrld binds 127.0.0.1:{} only — no port conflict possible.".format(ctrld_port), + "step1_install": install_cmd, + "step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'", + "step2_config": f"Write {opnsense_cfg} with the TOML below, then: ctrld restart", + "step3_unbound": ( + f"Services → Unbound DNS → Query Forwarding: " + f"Enable, add zone '.' → 127.0.0.1:{ctrld_port}, no TLS, Apply" ), - "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", + "step4_cleanup": "Remove 'home.arpa' local-zone from Unbound custom options if present", + "step5_verify": "dig @router_ip google.com && dig @router_ip -x 192.168.1.1", + "toml": toml, + "toml_write_cmd": write_toml_cmd, + "config_path": opnsense_cfg, + "ctrld_port": ctrld_port, + "local_domain": local_domain, + "docs": "https://docs.controld.com/docs/routers-platform", } @app.post("/api/ctrld/update-profiles") @@ -2618,15 +2580,9 @@ 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") - 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, - ) + 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) cfg_path = _ctrld_config_path() if cfg.get("mode") == "local" and cfg_path.exists(): @@ -2832,7 +2788,8 @@ def _generate_ctrld_split_horizon_block(local_domain: str = "lan", Because the format uses indexed table sections ([network.N], [upstream.N]), you can't simply append a fragment — the full toml must be regenerated via - _build_ctrld_toml(vlan_profiles, local_resolver='127.0.0.1:5353'). + _build_ctrld_toml(vlan_profiles). Note: on OPNsense, Unbound handles + local resolution — ctrld does not need split-horizon rules at all. This function returns a plain-English example for display only. """ @@ -2909,8 +2866,8 @@ def save_local_hostnames(body: LocalHostnamesUpdate): if ctrld_cfg.get("vlan_profiles"): split_horizon_toml = _build_ctrld_toml( ctrld_cfg["vlan_profiles"], - local_domain=body.local_domain or "lan", - local_resolver=f"127.0.0.1:5353", + ctrld_port=ctrld_cfg.get("ctrld_port", 5354), + deploy_mode=ctrld_cfg.get("deploy_mode", "router"), ) # Write new toml if running locally if ctrld_cfg.get("mode") == "local":