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)