diff --git a/switch_backend.py b/switch_backend.py index bfa7fa8..eae56d4 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -2105,9 +2105,25 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354, active = [vp for vp in vlan_profiles if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()] + # Per-gateway mode: every active profile has a VLAN gateway IP. + # ctrld listens on each gateway IP:53 so it sees the real client source IP + # and can route to the correct per-VLAN ControlD profile. + # Unbound stays on 127.0.0.1:53 (no interface overlap — no port conflict). + # This is the only mode that makes per-VLAN ControlD profiles actually work; + # the single-localhost-listener mode cannot differentiate VLANs because all + # queries arrive from Unbound as 127.0.0.1. + gateways = [vp.get("gateway", "").strip() for vp in active] + use_gateway_listeners = bool(active) and all(gateways) + + arch_comment = ( + "# Architecture: ctrld on each VLAN gateway IP:53 — per-VLAN ControlD profiles" + if use_gateway_listeners else + "# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port) + ) + lines = [ "# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager", - "# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port), + arch_comment, "# Docs: https://docs.controld.com/docs/ctrld", "", "[service]", @@ -2137,19 +2153,57 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354, "", ] - # ── ROUTER MODE: localhost listener, Unbound forwards here ─────────────── - if deploy_mode == "router": - # 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. + # ── ROUTER MODE: per-gateway listeners (preferred) or single localhost ──── + if deploy_mode == "router" and use_gateway_listeners: + # Per-VLAN gateway listeners. + # ctrld binds each VLAN gateway IP on port 53. Unbound stays on + # 127.0.0.1:53 — no overlap so no boot race. Each VLAN's client + # queries go to their gateway (OPNsense), hit ctrld which sees the + # real source IP, and are routed to the right ControlD profile. + # Unbound is reached as upstream.local for .lan/.local resolution + # so custom hostnames (pbx.lan etc.) resolve without ControlD. lines += [ - "# Listens on localhost only — Unbound Query Forwarding sends external queries here", + "# Unbound on 127.0.0.1:53 handles .lan/.local — ctrld forwards here", + "[upstream.local]", + " name = \'Local .lan resolver (Unbound loopback)\'", + " type = \'legacy\'", + " endpoint = \'127.0.0.1:53\'", + " timeout = 2000", + "", + ] + for i, (vp, gw) in enumerate(zip(active, gateways)): + vid = vp["vlan_id"] + name = vp.get("name", f"VLAN{vid}") + lines += [ + f"# VLAN {vid} — {name} — listens on {gw}:53", + f"[listener.{i}]", + f" ip = \'{gw}\'", + f" port = 53", + "", + f" [listener.{i}.policy]", + f" name = \'VLAN {vid} {name}\'", + f" networks = []", + f" rules = [", + f" {{ \'*.lan\' = [\'upstream.local\'] }},", + f" {{ \'*.local\' = [\'upstream.local\'] }},", + f" ]", + f" default = [\'upstream.{i}\']", + "", + ] + + elif deploy_mode == "router": + # Fallback: single localhost listener when gateways are not set. + # WARNING: all VLANs share upstream.0 — per-VLAN profiles do NOT work. + lines += [ + "# WARNING: single-listener mode — all VLANs share the same ControlD profile.", + "# Set a gateway IP on each VLAN profile to enable per-VLAN routing.", + "# Listens on localhost only — Unbound Query Forwarding sends queries here", "[listener.0]", f" ip = \'127.0.0.1\'", f" port = {ctrld_port}", "", " [listener.0.policy]", - " name = \'Default Policy\'", + " name = \'Default Policy (all VLANs)\'", " networks = []", " rules = []", ] @@ -2571,17 +2625,20 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, """ Generate the SSH command + step-by-step instructions to install ctrld on OPNsense. - Confirmed working architecture (verified after reboot — no manual intervention needed): - Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD + Architecture depends on whether VLAN profiles have gateway IPs set: - 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. + 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 - NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is - a common tutorial artifact that causes PTR/reverse DNS failures. + 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) if not first_rid: @@ -2594,52 +2651,96 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode) + # 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) + opnsense_cfg = "/usr/local/etc/controld/ctrld.toml" write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF" + if per_gateway: + gw_list = ", ".join(f"{gw}:53" for gw in gateways) + arch_line = f"Architecture: ctrld on [{gw_list}] — per-VLAN profiles active" + step3 = [ + "STEP 3 — Restrict Unbound to loopback only (so it doesn't conflict with ctrld on :53):", + " OPNsense GUI → Services → Unbound DNS → General:", + " Network Interfaces → select ONLY 'lo0 (Loopback)' and deselect all VLAN interfaces", + " Click Save + Apply", + " Verify: unbound-control status | grep interface", + " Unbound should show: interface: 127.0.0.1 (loopback only)", + "", + "STEP 4 — Disable Unbound Query Forwarding (ctrld is no longer downstream of Unbound):", + " OPNsense GUI → Services → Unbound DNS → Query Forwarding:", + " Disable / remove any forward zone pointing to 127.0.0.1", + " OR: use the Unbound panel in this tool to write a disabled forward_to_ctrld.conf", + "", + "STEP 5 — Verify each VLAN gets its own profile:", + ] + [ + f" dig @{gw} google.com # VLAN {p.get('vlan_id')} — should use {p.get('name')} ControlD profile" + for gw, p in zip(gateways, profiles) + if p.get("gateway", "").strip() + ] + [ + f" dig @127.0.0.1 myhost.{local_domain} # local .lan — answered by Unbound", + ] + step2_note = ( + f"Write {opnsense_cfg} with the TOML below, then: ctrld restart " + f"(ctrld will listen on {gw_list})" + ) + message = f"Per-VLAN gateway-listener mode: ctrld on [{gw_list}] — each VLAN gets its own ControlD profile" + architecture = ( + f"ctrld listens on VLAN gateway IPs ({gw_list}). " + "Unbound on 127.0.0.1:53 only — no port conflict. " + "Each VLAN's DNS traffic hits ctrld on its gateway IP; " + "ctrld routes to the correct ControlD profile by source subnet." + ) + else: + arch_line = "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD [WARNING: single shared profile]".format(ctrld_port) + step3 = [ + "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", + " • 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)" + 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." + ) + setup_steps = [ - "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port), + arch_line, "", "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), + "STEP 2 — Write the ctrld.toml:", f" {write_toml_cmd}", - " Then restart ctrld: ctrld restart", + f" 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", + ] + step3 + [ "", - "STEP 4 — Remove 'home.arpa' local-zone from Unbound if present:", + "STEP {} — Remove 'home.arpa' local-zone from Unbound if present:".format(6 if per_gateway else 4), " 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", + " (Tutorial artifact — breaks reverse DNS / PTR lookups)", ] return { "success": True, "mode": "opnsense", - "message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port), + "per_gateway": per_gateway, + "message": message, + "architecture": architecture, "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" - ), - "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", + "step2_config": step2_note, "toml": toml, "toml_write_cmd": write_toml_cmd, "config_path": opnsense_cfg,