From da1d629e18d0e0237ca1fcab4dbbd9cab6142652 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 14:29:13 +0000 Subject: [PATCH 1/6] Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Unbound/:53 + ctrld/:5354 architecture change, fix-lan-zone wrote local-lan-zone.conf with only 'local-zone: "lan." static' and no local-data records. Unbound then returned NXDOMAIN for every .lan name not explicitly listed — including pbx.lan and any hostname in local-hostnames.json — because the static zone intercepts all .lan queries before they can reach dnsmasq. Fix: - Add _build_unbound_lan_zone_conf(entries, mgmt_ip) which builds a complete local-lan-zone.conf: the static zone declaration plus local-data A records for every entry in local-hostnames.json and the two built-in management aliases (switch.mgmt.lan, management.lan). - Update fix-lan-zone to use this helper instead of the bare zone-only string. Running fix-lan-zone now also pushes all saved hostnames. - Update save_local_hostnames to push the updated local-lan-zone.conf to Unbound via SSH and reload if OPNsense SSH is configured, so adding/editing hostnames in the DNS tab takes effect immediately without a separate fix-lan-zone call. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 55 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/switch_backend.py b/switch_backend.py index cad4b82..fce77d4 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -2853,6 +2853,29 @@ def _generate_dnsmasq_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str return "\n".join(lines) + "\n" +def _build_unbound_lan_zone_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str: + """ + Build the full local-lan-zone.conf for Unbound. + + Declares 'lan.' as a static zone (so .lan never leaks to ControlD) and + adds local-data A records for every entry in local-hostnames.json plus + the two built-in management aliases. Without these local-data lines every + .lan name that isn't listed gets NXDOMAIN — including pbx.lan and any + other custom hostname the user defined. + """ + lines = ['local-zone: "lan." static', ""] + # Management PC aliases — always present + for alias in ("switch.mgmt.lan", "management.lan"): + lines.append(f'local-data: "{alias}. A {mgmt_ip}"') + # User-defined entries from local-hostnames.json + for e in entries: + name = e.get("name", "").strip().rstrip(".") + ip = e.get("ip", "").strip() + if name and ip: + lines.append(f'local-data: "{name}. A {ip}"') + return "\n".join(lines) + "\n" + + def _generate_ctrld_split_horizon_block(local_domain: str = "lan", dnsmasq_port: int = 5353) -> str: """ @@ -2955,6 +2978,21 @@ def save_local_hostnames(body: LocalHostnamesUpdate): local_domain=body.local_domain or "lan" ) + # Push local-data records into Unbound on OPNsense if SSH is configured. + # Without this, Unbound's static lan. zone returns NXDOMAIN for any + # custom .lan hostname (pbx.lan, nas.lan, etc.) that isn't explicitly + # listed — even though they exist in dnsmasq. + unbound_push = None + try: + opn_cfg = _load_opnsense_cfg() + if opn_cfg.get("ssh_key_path"): + lan_zone_conf = _build_unbound_lan_zone_conf(entries, mgmt_ip) + _opnsense_sftp_write(opn_cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf) + _opnsense_ssh_run(opn_cfg, "unbound-control reload 2>&1") + unbound_push = f"Pushed {len(entries)} local-data record(s) to Unbound and reloaded" + except Exception as _upe: + unbound_push = f"Unbound push skipped: {_upe}" + return { "success": True, "entries": entries, @@ -2962,6 +3000,7 @@ def save_local_hostnames(body: LocalHostnamesUpdate): "conf_path": str(DNSMASQ_CONF_PATH), "split_horizon": split_horizon, "full_toml": split_horizon_toml, + "unbound_push": unbound_push, "docker_compose_snippet": ( " dnsmasq:\n" " image: andyshinn/dnsmasq:latest\n" @@ -3541,11 +3580,21 @@ def opnsense_unbound_fix_lan_zone(): raise HTTPException(503, "OPNsense SSH not configured") steps = [] errors = [] - # Write the correct local-lan-zone.conf via SFTP - lan_zone_conf = 'local-zone: "lan." static\n' + # Build local-lan-zone.conf with all local-data records so custom .lan + # hostnames (pbx.lan, nas.lan, etc.) resolve correctly from Unbound. + import socket as _sock2 + try: + mgmt_ip = _sock2.gethostbyname(_sock2.gethostname()) + except Exception: + mgmt_ip = "192.168.99.50" + entries = _load_local_hostnames() + lan_zone_conf = _build_unbound_lan_zone_conf(entries, mgmt_ip) try: _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf) - steps.append("Wrote local-lan-zone.conf: local-zone \"lan.\" static") + steps.append( + f"Wrote local-lan-zone.conf: local-zone \"lan.\" static + " + f"{len(entries)} local-data record(s)" + ) except Exception as e: errors.append(f"Write local-lan-zone.conf: {e}") raise HTTPException(500, "; ".join(errors)) From 91eaeffd9c79f5db2608440a62c9b96e7a4bda24 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 11:06:33 +0000 Subject: [PATCH 2/6] Fix local-lan-zone.conf: add server: wrapper + correct local-data syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the Unbound config file generation: 1. Missing server: wrapper OPNsense includes /var/unbound/etc/*.conf at the top level of unbound.conf (via include: or include-toplevel:). Server-level directives like local-zone: and local-data: must sit inside a server: {} block — without it they are outside any section and either silently ignored or rejected by unbound-checkconf. forward-zone: is a top-level section so forward_to_ctrld.conf correctly has no wrapper. Consequence: the original 'local-zone: "lan." static' without a server: wrapper was never actually applied, meaning the .lan leak prevention was not working. 2. No local-data records Even with a correct zone declaration, every .lan name not listed as local-data gets NXDOMAIN from the static zone. The previous commit added the local-data records; this commit gives them valid syntax inside the server: block. Generated file now looks like: server: local-zone: "lan." static local-data: "switch.mgmt.lan. A " local-data: "management.lan. A " local-data: "pbx.lan. A 192.168.50.10" ... https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/switch_backend.py b/switch_backend.py index fce77d4..bfa7fa8 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -2857,22 +2857,33 @@ def _build_unbound_lan_zone_conf(entries: list, mgmt_ip: str = "192.168.99.50") """ Build the full local-lan-zone.conf for Unbound. + OPNsense includes /var/unbound/etc/*.conf at the TOP LEVEL of unbound.conf + (either via include: or include-toplevel:). This means server-level + directives (local-zone:, local-data:) must be wrapped in a server: block. + Without the wrapper they land outside any section and are silently ignored + or cause unbound-checkconf to error. forward-zone: is a top-level section + and needs no wrapper — that's why forward_to_ctrld.conf works without one. + Declares 'lan.' as a static zone (so .lan never leaks to ControlD) and adds local-data A records for every entry in local-hostnames.json plus - the two built-in management aliases. Without these local-data lines every - .lan name that isn't listed gets NXDOMAIN — including pbx.lan and any + the two built-in management aliases. Without local-data entries every + .lan name not explicitly listed gets NXDOMAIN — including pbx.lan and any other custom hostname the user defined. """ - lines = ['local-zone: "lan." static', ""] + lines = [ + "server:", + ' local-zone: "lan." static', + "", + ] # Management PC aliases — always present for alias in ("switch.mgmt.lan", "management.lan"): - lines.append(f'local-data: "{alias}. A {mgmt_ip}"') + lines.append(f' local-data: "{alias}. A {mgmt_ip}"') # User-defined entries from local-hostnames.json for e in entries: name = e.get("name", "").strip().rstrip(".") ip = e.get("ip", "").strip() if name and ip: - lines.append(f'local-data: "{name}. A {ip}"') + lines.append(f' local-data: "{name}. A {ip}"') return "\n".join(lines) + "\n" From 2db5c3babc16d60767dd002473610c3fe901eff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 12:02:33 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Fix=20per-VLAN=20ControlD=20profiles=20?= =?UTF-8?q?=E2=80=94=20use=20gateway-listener=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-localhost-listener architecture (Unbound:53 → ctrld:5354) fundamentally cannot support per-VLAN ControlD profiles: all queries arrive at ctrld from Unbound as 127.0.0.1, so ctrld has no way to distinguish VLANs and routes everything to a single upstream. This broke Asterisk and IoT isolation — all traffic was hitting the same ControlD profile regardless of which VLAN it came from. New architecture when all VLAN profiles have a gateway IP set: Clients → ctrld on VLAN-gateway-IP:53 → per-VLAN ControlD profile Unbound stays on 127.0.0.1:53 (loopback only — no port conflict) ctrld sees real client source IPs → routes correctly per VLAN ctrld forwards *.lan / *.local → Unbound loopback (local-data) _build_ctrld_toml changes: - Detects when all active profiles have a gateway IP - Generates one [listener.N] per VLAN on its gateway IP:53 instead of a single [listener.0] on 127.0.0.1:ctrld_port - Each listener has its own [listener.N.policy] with the correct upstream.N (that VLAN's ControlD profile) - Adds upstream.local → 127.0.0.1:53 for .lan/.local split-horizon - Falls back to single-listener with a clear WARNING comment when gateways are missing _ctrld_generate_opnsense_cmd changes: - Detects which mode was generated and produces matching instructions - Gateway mode: tells user to restrict Unbound to loopback and disable Query Forwarding (ctrld is no longer downstream of Unbound) - Fallback mode: warns that per-VLAN profiles are not working Required OPNsense change to activate gateway mode: Services → Unbound DNS → General → Network Interfaces → Loopback only Services → Unbound DNS → Query Forwarding → disable/remove forward to ctrld https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 185 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 143 insertions(+), 42 deletions(-) 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, From 4cf74335adcb1c44a340089985df38da4554508d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 12:33:33 +0000 Subject: [PATCH 4/6] 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": From 35f6f8c94d2c938c068676860395fc46a7aebdfa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 14:48:28 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Revert=20DNS/ctrld=20changes=20from=20this?= =?UTF-8?q?=20session=20=E2=80=94=20restore=20to=20working=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-restores switch_backend.py to d9b6d05 (before this session's changes). Reverted commits: 4cf7433 Fix per-VLAN DNS: switch to proxy mode, Unbound on :5353 2db5c3b Fix per-VLAN ControlD profiles — use gateway-listener mode 91eaeff Fix local-lan-zone.conf: add server: wrapper + correct local-data syntax da1d629 Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.) These changes broke a working (mostly) system. Before touching the DNS and ctrld architecture again, the actual deployed state needs to be understood first. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 350 ++++++++++------------------------------------ 1 file changed, 70 insertions(+), 280 deletions(-) diff --git a/switch_backend.py b/switch_backend.py index ad4c096..cad4b82 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -2074,9 +2074,8 @@ 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 = 53, - deploy_mode: str = "proxy", - unbound_port: int = 5353) -> 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. @@ -2106,25 +2105,9 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53, 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", - arch_comment, + "# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port), "# Docs: https://docs.controld.com/docs/ctrld", "", "[service]", @@ -2154,57 +2137,19 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53, "", ] - # ── 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. + # ── 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. lines += [ - "# 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", + "# 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 (all VLANs)\'", + " name = \'Default Policy\'", " networks = []", " rules = []", ] @@ -2212,22 +2157,9 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53, lines[-1] = f" default = [\'upstream.0\']" lines.append("") - # ── PROXY MODE: 0.0.0.0:53 + per-VLAN CIDR routing ────────────────────── + # ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ───────────────── 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}", @@ -2245,13 +2177,7 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53, else: lines += [" networks = []"] - lines += [ - " rules = [", - " { \'*.lan\' = [\'upstream.local\'] },", - " { \'*.local\' = [\'upstream.local\'] },", - " ]", - "", - ] + lines += [" rules = []", ""] # Network sections for CIDR routing for i, vp in enumerate(active): @@ -2280,15 +2206,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] = "proxy" # "proxy" (ctrld on :53, per-VLAN CIDR) | "router" (ctrld on localhost) + deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0) vlan_profiles: list[CtrldVlanProfile] opnsense_host: Optional[str] = "" - 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) + 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 @@ -2432,14 +2358,12 @@ 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") - ctrld_port = cfg.get("ctrld_port", 53) - unbound_port = cfg.get("unbound_port", 5353) + 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, ctrld_port=ctrld_port, - deploy_mode=deploy_mode, unbound_port=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") @@ -2467,9 +2391,8 @@ def ctrld_save_config(body: CtrldInstallRequest): "toml_error": validation.get("toml_error", ""), }) - deploy_mode = body.config.deploy_mode or "proxy" - ctrld_port = body.config.ctrld_port or 53 - 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, @@ -2477,20 +2400,18 @@ 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, unbound_port=unbound_port) + 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, - ctrld_port=ctrld_port, unbound_port=unbound_port, local_domain=local_domain, + ctrld_port=ctrld_port, local_domain=local_domain, ) else: # Manual — just return the toml and instructions @@ -2644,23 +2565,23 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict: } def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, - deploy_mode: str = "proxy", - ctrld_port: int = 53, - unbound_port: int = 5353, + deploy_mode: str = "router", + ctrld_port: int = 5354, local_domain: str = "lan") -> dict: """ Generate the SSH command + step-by-step instructions to install ctrld on OPNsense. - 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. + Confirmed working architecture (verified after reboot — no manual intervention needed): + Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD - 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. + 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: @@ -2671,122 +2592,54 @@ 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, 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 = deploy_mode == "router" and bool(gateways) and all(gateways) + toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode) 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." - ) - 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 = [ - "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.", - ] - step2_note = f"Write {opnsense_cfg} with the TOML below, then: ctrld restart" - 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 — all queries appear from 127.0.0.1." - ) - setup_steps = [ - arch_line, + "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:", + "STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port), f" {write_toml_cmd}", - f" Then restart ctrld: ctrld restart", + " Then restart ctrld: ctrld restart", "", - ] + 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 (ctrld handles DoH/DoT upstream; plain DNS locally is fine)", + " • Click Apply / Save", "", - "STEP {} — Remove 'home.arpa' local-zone from Unbound if present:".format(6 if per_gateway else 4), + "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\"", - " (Tutorial artifact — breaks reverse DNS / PTR lookups)", + " (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", - "per_gateway": per_gateway, - "message": message, - "architecture": architecture, + "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": step2_note, + "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", "toml": toml, "toml_write_cmd": write_toml_cmd, "config_path": opnsense_cfg, @@ -2803,11 +2656,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") - 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) + 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(): @@ -3002,40 +2853,6 @@ def _generate_dnsmasq_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str return "\n".join(lines) + "\n" -def _build_unbound_lan_zone_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str: - """ - Build the full local-lan-zone.conf for Unbound. - - OPNsense includes /var/unbound/etc/*.conf at the TOP LEVEL of unbound.conf - (either via include: or include-toplevel:). This means server-level - directives (local-zone:, local-data:) must be wrapped in a server: block. - Without the wrapper they land outside any section and are silently ignored - or cause unbound-checkconf to error. forward-zone: is a top-level section - and needs no wrapper — that's why forward_to_ctrld.conf works without one. - - Declares 'lan.' as a static zone (so .lan never leaks to ControlD) and - adds local-data A records for every entry in local-hostnames.json plus - the two built-in management aliases. Without local-data entries every - .lan name not explicitly listed gets NXDOMAIN — including pbx.lan and any - other custom hostname the user defined. - """ - lines = [ - "server:", - ' local-zone: "lan." static', - "", - ] - # Management PC aliases — always present - for alias in ("switch.mgmt.lan", "management.lan"): - lines.append(f' local-data: "{alias}. A {mgmt_ip}"') - # User-defined entries from local-hostnames.json - for e in entries: - name = e.get("name", "").strip().rstrip(".") - ip = e.get("ip", "").strip() - if name and ip: - lines.append(f' local-data: "{name}. A {ip}"') - return "\n".join(lines) + "\n" - - def _generate_ctrld_split_horizon_block(local_domain: str = "lan", dnsmasq_port: int = 5353) -> str: """ @@ -3125,9 +2942,8 @@ 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", 53), - deploy_mode=ctrld_cfg.get("deploy_mode", "proxy"), - unbound_port=ctrld_cfg.get("unbound_port", 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": @@ -3139,21 +2955,6 @@ def save_local_hostnames(body: LocalHostnamesUpdate): local_domain=body.local_domain or "lan" ) - # Push local-data records into Unbound on OPNsense if SSH is configured. - # Without this, Unbound's static lan. zone returns NXDOMAIN for any - # custom .lan hostname (pbx.lan, nas.lan, etc.) that isn't explicitly - # listed — even though they exist in dnsmasq. - unbound_push = None - try: - opn_cfg = _load_opnsense_cfg() - if opn_cfg.get("ssh_key_path"): - lan_zone_conf = _build_unbound_lan_zone_conf(entries, mgmt_ip) - _opnsense_sftp_write(opn_cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf) - _opnsense_ssh_run(opn_cfg, "unbound-control reload 2>&1") - unbound_push = f"Pushed {len(entries)} local-data record(s) to Unbound and reloaded" - except Exception as _upe: - unbound_push = f"Unbound push skipped: {_upe}" - return { "success": True, "entries": entries, @@ -3161,7 +2962,6 @@ def save_local_hostnames(body: LocalHostnamesUpdate): "conf_path": str(DNSMASQ_CONF_PATH), "split_horizon": split_horizon, "full_toml": split_horizon_toml, - "unbound_push": unbound_push, "docker_compose_snippet": ( " dnsmasq:\n" " image: andyshinn/dnsmasq:latest\n" @@ -3741,21 +3541,11 @@ def opnsense_unbound_fix_lan_zone(): raise HTTPException(503, "OPNsense SSH not configured") steps = [] errors = [] - # Build local-lan-zone.conf with all local-data records so custom .lan - # hostnames (pbx.lan, nas.lan, etc.) resolve correctly from Unbound. - import socket as _sock2 - try: - mgmt_ip = _sock2.gethostbyname(_sock2.gethostname()) - except Exception: - mgmt_ip = "192.168.99.50" - entries = _load_local_hostnames() - lan_zone_conf = _build_unbound_lan_zone_conf(entries, mgmt_ip) + # Write the correct local-lan-zone.conf via SFTP + lan_zone_conf = 'local-zone: "lan." static\n' try: _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf) - steps.append( - f"Wrote local-lan-zone.conf: local-zone \"lan.\" static + " - f"{len(entries)} local-data record(s)" - ) + steps.append("Wrote local-lan-zone.conf: local-zone \"lan.\" static") except Exception as e: errors.append(f"Write local-lan-zone.conf: {e}") raise HTTPException(500, "; ".join(errors)) From 40c765e831be39cc324d8d719e78bf9ce7be8d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 15:06:22 +0000 Subject: [PATCH 6/6] =?UTF-8?q?Add=20WebRTC/Mattermost=20calls=20fix=20?= =?UTF-8?q?=E2=80=94=20static-port=20outbound=20NAT=20via=20OPNsense=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two endpoints: POST /api/opnsense/nat/fix-webrtc Fixes WebRTC ICE failures caused by symmetric NAT (the reason Mattermost calls fail on every VLAN but work through a commercial VPN). OPNsense default outbound NAT remaps UDP source ports per-destination: each flow to a different server gets a different external port, so STUN candidates reported by different servers never match and ICE fails. Fix: switch outbound NAT to Hybrid mode, then add a UDP static-port rule for each VLAN subnet. Static-port preserves the source port through NAT, making STUN candidates consistent regardless of which server reports them. ICE succeeds, calls work without VPN. Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" } GET /api/opnsense/nat/webrtc-status Returns current outbound NAT mode and existing static-port rules so the UI can show whether the fix has been applied. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/switch_backend.py b/switch_backend.py index cad4b82..d1e9506 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -3613,3 +3613,138 @@ def opnsense_unbound_write_forward_ctrld(body: dict): raise HTTPException(500, f"unbound-checkconf failed after write: {err or out}") _opnsense_ssh_run(cfg, "unbound-control reload 2>&1") return {"success": True, "content": content, "enabled": enabled, "port": port} + + +# ══════════════════════════════════════════════════════════════════════ +# OPNSENSE NAT — WEBRTC / MATTERMOST CALLS FIX +# ══════════════════════════════════════════════════════════════════════ +# +# Problem: OPNsense uses symmetric NAT by default (port address translation). +# Each UDP flow to a *different* destination gets a *different* external source +# port. WebRTC ICE relies on STUN to discover the external address, but with +# symmetric NAT the STUN server sees a different port than the TURN or peer +# server will see — ICE candidate matching fails and calls drop. A commercial +# VPN "fixes" it because the VPN encapsulates UDP inside a single TCP/UDP +# tunnel that is full-cone from OPNsense's perspective. +# +# Fix: add a "static port" outbound NAT rule for each VLAN subnet. +# "Static port" (fixedport in pfSense/OPNsense) preserves the source port +# number through NAT. The external port equals the internal port, so every +# STUN server sees the same address:port — ICE succeeds. +# +# This requires switching outbound NAT from "Automatic" to "Hybrid" mode +# (hybrid = keep automatic rules, also honour manual ones). The static-port +# rules are added for UDP only; TCP and other protocols are unaffected. +# ══════════════════════════════════════════════════════════════════════ + +@app.post("/api/opnsense/nat/fix-webrtc") +def opnsense_nat_fix_webrtc(body: dict): + """ + Fix WebRTC / Mattermost Calls / STUN failures caused by symmetric NAT. + + Steps: + 1. Switch outbound NAT mode to Hybrid (preserves automatic rules). + 2. For each VLAN subnet in body.vlans, add a UDP static-port outbound + NAT rule on the WAN interface. Static port = source port preserved + through NAT so STUN candidates are consistent across servers. + 3. Apply changes. + + Body: { token, vlans: [{id, name, subnet}], wan_interface: "wan" } + Returns: { success, mode_set, rules_added: [...], rules_failed: [...] } + """ + require_session(body.get("token", "")) + cfg = _load_opnsense_cfg() + if not cfg.get("host"): + raise HTTPException(503, "OPNsense API not configured") + + vlans = body.get("vlans", []) + wan_iface = body.get("wan_interface", "wan") + rules_added = [] + rules_failed = [] + + # Step 1 — switch to Hybrid outbound NAT mode + try: + _opnsense_request(cfg, "firewall/nat/outbound/setMode", + method="POST", body={"mode": "hybrid"}) + mode_set = True + except Exception as e: + raise HTTPException(500, f"Could not set outbound NAT to hybrid: {e}") + + # Step 2 — add a static-port UDP rule for each VLAN + for vlan in vlans: + subnet = vlan.get("subnet", "").strip() + name = vlan.get("name", f"VLAN{vlan.get('id','')}") + if not subnet: + continue + rule = { + "rule": { + "enabled": "1", + "sequence": "1", + "interface": wan_iface, + "ipprotocol": "inet", + "protocol": "UDP", + "source": {"network": subnet, "port": ""}, + "sourceport": "", + "destination": {"network": "any", "port": ""}, + "destinationport": "", + "target": "", + "targetip": "", + "targetip_subnet": "32", + "nonat": "0", + "staticnatport": "1", + "descr": f"Static port UDP — {name} WebRTC/STUN fix", + } + } + try: + resp = _opnsense_request(cfg, "firewall/nat/outbound/addRule", + method="POST", body=rule) + rules_added.append({"vlan": name, "subnet": subnet, "uuid": resp.get("uuid","")}) + except Exception as e: + rules_failed.append({"vlan": name, "subnet": subnet, "error": str(e)}) + + # Step 3 — apply + try: + _opnsense_request(cfg, "firewall/nat/outbound/apply", method="POST") + except Exception as e: + rules_failed.append({"vlan": "apply", "error": str(e)}) + + return { + "success": len(rules_added) > 0 and not rules_failed, + "mode_set": mode_set, + "rules_added": rules_added, + "rules_failed": rules_failed, + "explanation": ( + "Static-port NAT preserves UDP source ports through NAT. " + "STUN now sees the same external address regardless of destination server. " + "WebRTC ICE candidates match — calls work without VPN." + ), + } + + +@app.get("/api/opnsense/nat/webrtc-status") +def opnsense_nat_webrtc_status(): + """ + Check whether static-port outbound NAT rules exist for WebRTC. + Returns current outbound NAT mode and any existing static-port rules. + """ + cfg = _load_opnsense_cfg() + if not cfg.get("host"): + raise HTTPException(503, "OPNsense API not configured") + try: + data = _opnsense_request(cfg, "firewall/nat/outbound/get") + mode = data.get("natoutbound", {}).get("mode", "unknown") + rules = data.get("natoutbound", {}).get("rule", {}) + static_rules = [ + {"uuid": uid, "descr": r.get("descr",""), "source": r.get("source",{})} + for uid, r in (rules.items() if isinstance(rules, dict) else {}.items()) + if str(r.get("staticnatport","0")) == "1" + ] + return { + "mode": mode, + "hybrid": mode == "hybrid", + "static_rules": static_rules, + "webrtc_ready": mode == "hybrid" and len(static_rules) > 0, + } + except Exception as e: + raise HTTPException(500, str(e)) +