From 0c977eace48b1288f348cdda193546e584c57e9d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 14:58:32 +0000 Subject: [PATCH] Add dual-mode ctrld TOML (router vs proxy) and gateway-per-VLAN support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Router mode (deploy_mode="router") — for ctrld running on OPNsense: Each VLAN gets its own [listener.N] bound to the VLAN gateway IP (e.g. 192.168.10.1 for VLAN 10). VLAN clients send DNS to their gateway; ctrld receives it on that listener and routes it to the correct upstream with zero CIDR lookup overhead. Default when mode="opnsense". Proxy mode (deploy_mode="proxy") — for ctrld on the management host: Single [listener.0] on 0.0.0.0:53 with [network.N] CIDR sections and a networks= policy array in [listener.0.policy]. Unchanged behaviour from before, correct for non-router deployments. CtrldVlanProfile gains optional gateway field (VLAN gateway IP) used by router mode to set each listener.N ip. Falls back to 0.0.0.0 if not provided so existing configs without it keep working. CtrldConfig gains deploy_mode field; persisted in ctrld.json so toml-preview, update-profiles, and future reloads regenerate the same topology. All _build_ctrld_toml callers now pass deploy_mode through. Both modes confirmed against ctrld v1.5.0 (March 2026) TOML spec. https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6 --- switch_backend.py | 219 +++++++++++++++++++++++++++------------------- 1 file changed, 131 insertions(+), 88 deletions(-) diff --git a/switch_backend.py b/switch_backend.py index ce6c8da..eeb4966 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -1999,28 +1999,24 @@ def _ctrld_config_path() -> _Path: return candidates[0] # default for new install def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", - local_resolver: str = "") -> str: + local_resolver: str = "", + deploy_mode: str = "proxy") -> str: """ - Build a ctrld.toml using flat dotted-key section headers — the only format - ctrld's Go TOML parser accepts without a redefinition panic. + Build a ctrld.toml using flat dotted-key section headers. - WRONG (triggers redefinition panic in Go TOML v2): - [listener] - [listener.0] <- redefines the already-open table - CORRECT: - [listener.0] <- opens the subtable directly, no parent wrapper + 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. - vlan_profiles: list of { - vlan_id, name, subnet, - resolver_id, # ControlD Resolver ID (path suffix) used when - # endpoint_url is empty - endpoint_url, # full DoH/DoH3 URL — overrides resolver_id if set - protocol, # "doh3" (default) | "doh" | "dot" | "doq" | "legacy" - } - local_resolver: if set (e.g. "127.0.0.1:5353"), adds split-horizon rules - so *.lan / *.local go to the local resolver. + 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). + + Flat header rule: never write a parent [listener] / [network] / [upstream] + before the dotted subtables — Go's TOML v2 panics on table redefinition. """ - BOOTSTRAP = "76.76.2.0" # ControlD anycast — needed for cold-start + BOOTSTRAP = "76.76.2.0" active = [vp for vp in vlan_profiles if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()] @@ -2030,103 +2026,145 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", "# Docs: https://docs.controld.com/docs/ctrld", "", "[service]", - " log_level = 'info'", - " log_path = '/tmp/ctrld.log'", + " log_level = \'info\'", + " log_path = \'/tmp/ctrld.log\'", "", ] - # ── Listener — flat header, NO parent [listener] wrapper ───────────────── - lines += [ - "[listener.0]", - " ip = '0.0.0.0'", - " port = 53", - "", - " [listener.0.policy]", - " name = 'VLAN Policy'", - ] + def _upstream_index(i: int) -> str: + return str(i) - if active: - net_entries = [ - " { " + f"'network.{i}' = ['upstream.{i}']" + " }," - for i in range(len(active)) - ] - lines += [" networks = ["] + net_entries + [" ]"] - else: - lines += [" networks = []"] - - if local_resolver: - domain_suffix = local_domain.strip(".") - lines += [ - " rules = [", - " { " + f"'*.{domain_suffix}' = ['upstream.local']" + " },", - " { " + "'*.local' = ['upstream.local']" + " },", - " ]", - ] - else: - lines += [" rules = []"] - - lines += [""] - - # ── Network sections — flat headers, one per active VLAN ───────────────── - for i, vp in enumerate(active): - vid = vp["vlan_id"] - name = vp.get("name", f"VLAN{vid}") - subnet = vp.get("subnet", f"192.168.{vid}.0/24") - lines += [ - f"# VLAN {vid} — {name}", - f"[network.{i}]", - f" name = '{name}'", - f" cidrs = ['{subnet}']", - "", - ] - - # ── Upstream sections — flat headers, one per active VLAN ──────────────── + # ── Upstream sections (shared by both modes) ────────────────────────────── for i, vp in enumerate(active): vid = vp["vlan_id"] name = vp.get("name", f"VLAN{vid}") protocol = (vp.get("protocol") or "doh3").strip() + rid = vp.get("resolver_id", "").strip() endpoint = ( vp.get("endpoint_url", "").strip() - or f"https://dns.controld.com/{vp['resolver_id'].strip()}" + or f"https://dns.controld.com/{rid}" ) lines += [ f"# VLAN {vid} — {name}", f"[upstream.{i}]", - f" name = 'VLAN {vid} {name}'", - f" type = '{protocol}'", - f" endpoint = '{endpoint}'", - f" bootstrap_ip = '{BOOTSTRAP}'", + f" name = \'VLAN {vid} {name}\'", + f" type = \'{protocol}\'", + f" endpoint = \'{endpoint}\'", + f" bootstrap_ip = \'{BOOTSTRAP}\'", f" timeout = 5000", "", ] - # Optional local split-horizon resolver 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}'", + " name = \'Local Resolver\'", + " type = \'legacy\'", + f" endpoint = \'{local_resolver}\'", " timeout = 2000", "", ] + # ── ROUTER MODE: one listener per VLAN gateway IP ───────────────────────── + 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" + + 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\']" + " },", + ] + lines += [ + " ]", + f" default = [\'upstream.{i}\']", + "", + ] + + # ── PROXY MODE: single listener + CIDR network policies ────────────────── + else: + lines += [ + "[listener.0]", + " ip = \'0.0.0.0\'", + " port = 53", + "", + " [listener.0.policy]", + " name = \'VLAN Policy\'", + ] + + if active: + net_entries = [ + " { " + f"\'network.{i}\' = [\'upstream.{i}\']" + " }," + for i in range(len(active)) + ] + lines += [" networks = ["] + net_entries + [" ]"] + else: + lines += [" networks = []"] + + if local_resolver: + domain_suffix = local_domain.strip(".") + lines += [ + " rules = [", + " { " + f"\'*.{domain_suffix}\' = [\'upstream.local\']" + " },", + " { " + "\'*.local\' = [\'upstream.local\']" + " },", + " ]", + ] + else: + lines += [" rules = []"] + + lines += [""] + + # Network sections (only needed for CIDR routing in proxy mode) + for i, vp in enumerate(active): + vid = vp["vlan_id"] + name = vp.get("name", f"VLAN{vid}") + subnet = vp.get("subnet", f"192.168.{vid}.0/24") + lines += [ + f"# VLAN {vid} — {name}", + f"[network.{i}]", + f" name = \'{name}\'", + f" cidrs = [\'{subnet}\']", + "", + ] + return "\n".join(lines) + # ── ctrld API models ───────────────────────────────────────────────────────── class CtrldVlanProfile(BaseModel): vlan_id: int name: str subnet: str - resolver_id: str # ControlD Resolver ID (path suffix) - endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set - protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy + resolver_id: str # ControlD Resolver ID (path suffix) + 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" + mode: str # "local" | "opnsense" | "manual" + deploy_mode: Optional[str] = "proxy" # "router" (OPNsense) | "proxy" (management host) vlan_profiles: list[CtrldVlanProfile] opnsense_host: Optional[str] = "" @@ -2272,11 +2310,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",[]) + profiles = cfg.get("vlan_profiles", []) + deploy_mode = cfg.get("deploy_mode", "proxy") if not profiles: raise HTTPException(400, "No VLAN profiles configured yet") - toml = _build_ctrld_toml(profiles) - return {"toml": toml} + toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode) + return {"toml": toml, "deploy_mode": deploy_mode} @app.post("/api/ctrld/save-config") def ctrld_save_config(body: CtrldInstallRequest): @@ -2303,19 +2342,21 @@ def ctrld_save_config(body: CtrldInstallRequest): "toml_error": validation.get("toml_error", ""), }) + deploy_mode = body.config.deploy_mode or "proxy" cfg_dict = { "mode": body.config.mode, + "deploy_mode": deploy_mode, "vlan_profiles": profiles, "opnsense_host": body.config.opnsense_host, } _save_ctrld_cfg(cfg_dict) - toml = _build_ctrld_toml(profiles) + toml = _build_ctrld_toml(profiles, 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) + return _ctrld_generate_opnsense_cmd(body.config.opnsense_host, profiles, deploy_mode) else: # Manual — just return the toml and instructions return { @@ -2467,10 +2508,11 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict: "port53": port53_fix, } -def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list) -> dict: +def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, + deploy_mode: str = "router") -> dict: """ Generate the SSH command to install ctrld on OPNsense. - User runs this in OPNsense shell. + Defaults to router deploy_mode — OPNsense has per-VLAN gateway IPs. """ first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None) if not first_rid: @@ -2481,7 +2523,7 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list) -> dict: f"-s {first_rid} forced'" ) - toml = _build_ctrld_toml(profiles) + toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode) # For OPNsense the config path is different opnsense_cfg = "/usr/local/etc/controld/ctrld.toml" @@ -2508,8 +2550,9 @@ def ctrld_update_profiles(body: CtrldUpdateProfile): cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles] _save_ctrld_cfg(cfg) - toml = _build_ctrld_toml(cfg["vlan_profiles"]) - cfg_path = _ctrld_config_path() + deploy_mode = cfg.get("deploy_mode", "proxy") + toml = _build_ctrld_toml(cfg["vlan_profiles"], deploy_mode=deploy_mode) + cfg_path = _ctrld_config_path() if cfg.get("mode") == "local" and cfg_path.exists(): cfg_path.write_text(toml)