Add dual-mode ctrld TOML (router vs proxy) and gateway-per-VLAN support

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
This commit is contained in:
Claude
2026-03-24 14:58:32 +00:00
parent cb6f618514
commit 0c977eace4
+113 -70
View File
@@ -1999,28 +1999,24 @@ def _ctrld_config_path() -> _Path:
return candidates[0] # default for new install return candidates[0] # default for new install
def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan", 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 Build a ctrld.toml using flat dotted-key section headers.
ctrld's Go TOML parser accepts without a redefinition panic.
WRONG (triggers redefinition panic in Go TOML v2): deploy_mode="router" — multiple listeners, one per VLAN gateway IP.
[listener] Best for ctrld running ON the router (OPNsense). Each VLAN client
[listener.0] <- redefines the already-open table sends DNS to its gateway IP; ctrld binds there and knows the profile
CORRECT: from the listener — no CIDR lookup needed. Requires `gateway` on
[listener.0] <- opens the subtable directly, no parent wrapper every CtrldVlanProfile.
vlan_profiles: list of { deploy_mode="proxy" — single listener on 0.0.0.0, CIDR-based policy
vlan_id, name, subnet, routing. Use when ctrld runs on a management host (not the router).
resolver_id, # ControlD Resolver ID (path suffix) used when
# endpoint_url is empty Flat header rule: never write a parent [listener] / [network] / [upstream]
endpoint_url, # full DoH/DoH3 URL — overrides resolver_id if set before the dotted subtables — Go's TOML v2 panics on table redefinition.
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.
""" """
BOOTSTRAP = "76.76.2.0" # ControlD anycast — needed for cold-start BOOTSTRAP = "76.76.2.0"
active = [vp for vp in vlan_profiles active = [vp for vp in vlan_profiles
if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()] if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()]
@@ -2030,24 +2026,94 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
"# Docs: https://docs.controld.com/docs/ctrld", "# Docs: https://docs.controld.com/docs/ctrld",
"", "",
"[service]", "[service]",
" log_level = 'info'", " log_level = \'info\'",
" log_path = '/tmp/ctrld.log'", " log_path = \'/tmp/ctrld.log\'",
"", "",
] ]
# ── Listener — flat header, NO parent [listener] wrapper ───────────────── def _upstream_index(i: int) -> str:
return str(i)
# ── 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/{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" timeout = 5000",
"",
]
if local_resolver:
domain_suffix = local_domain.strip(".")
lines += [
f"# Local resolver — *.{local_domain} and *.local",
"[upstream.local]",
" name = \'Local Resolver\'",
" type = \'legacy\'",
f" endpoint = \'{local_resolver}\'",
" timeout = 2000",
"",
]
# ── ROUTER MODE: one listener per VLAN gateway IP ─────────────────────────
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 += [ lines += [
"[listener.0]", "[listener.0]",
" ip = '0.0.0.0'", " ip = \'0.0.0.0\'",
" port = 53", " port = 53",
"", "",
" [listener.0.policy]", " [listener.0.policy]",
" name = 'VLAN Policy'", " name = \'VLAN Policy\'",
] ]
if active: if active:
net_entries = [ net_entries = [
" { " + f"'network.{i}' = ['upstream.{i}']" + " }," " { " + f"\'network.{i}\' = [\'upstream.{i}\']" + " },"
for i in range(len(active)) for i in range(len(active))
] ]
lines += [" networks = ["] + net_entries + [" ]"] lines += [" networks = ["] + net_entries + [" ]"]
@@ -2058,8 +2124,8 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
domain_suffix = local_domain.strip(".") domain_suffix = local_domain.strip(".")
lines += [ lines += [
" rules = [", " rules = [",
" { " + f"'*.{domain_suffix}' = ['upstream.local']" + " },", " { " + f"\'*.{domain_suffix}\' = [\'upstream.local\']" + " },",
" { " + "'*.local' = ['upstream.local']" + " },", " { " + "\'*.local\' = [\'upstream.local\']" + " },",
" ]", " ]",
] ]
else: else:
@@ -2067,7 +2133,7 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
lines += [""] lines += [""]
# ── Network sections — flat headers, one per active VLAN ───────────────── # Network sections (only needed for CIDR routing in proxy mode)
for i, vp in enumerate(active): for i, vp in enumerate(active):
vid = vp["vlan_id"] vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}") name = vp.get("name", f"VLAN{vid}")
@@ -2075,46 +2141,15 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
lines += [ lines += [
f"# VLAN {vid}{name}", f"# VLAN {vid}{name}",
f"[network.{i}]", f"[network.{i}]",
f" name = '{name}'", f" name = \'{name}\'",
f" cidrs = ['{subnet}']", f" cidrs = [\'{subnet}\']",
"",
]
# ── Upstream sections — flat headers, one per active VLAN ────────────────
for i, vp in enumerate(active):
vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}")
protocol = (vp.get("protocol") or "doh3").strip()
endpoint = (
vp.get("endpoint_url", "").strip()
or f"https://dns.controld.com/{vp['resolver_id'].strip()}"
)
lines += [
f"# VLAN {vid}{name}",
f"[upstream.{i}]",
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:
lines += [
f"# Local resolver — *.{local_domain} and *.local",
"[upstream.local]",
" name = 'Local Resolver'",
" type = 'legacy'",
f" endpoint = '{local_resolver}'",
" timeout = 2000",
"", "",
] ]
return "\n".join(lines) return "\n".join(lines)
# ── ctrld API models ───────────────────────────────────────────────────────── # ── ctrld API models ─────────────────────────────────────────────────────────
class CtrldVlanProfile(BaseModel): class CtrldVlanProfile(BaseModel):
@@ -2124,9 +2159,12 @@ class CtrldVlanProfile(BaseModel):
resolver_id: str # ControlD Resolver ID (path suffix) resolver_id: str # ControlD Resolver ID (path suffix)
endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set endpoint_url: Optional[str] = "" # full URL — overrides resolver_id if set
protocol: Optional[str] = "doh3" # doh3 | doh | dot | doq | legacy 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): 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] vlan_profiles: list[CtrldVlanProfile]
opnsense_host: Optional[str] = "" opnsense_host: Optional[str] = ""
@@ -2272,11 +2310,12 @@ def ctrld_validate_endpoints(body: CtrldValidateRequest):
def ctrld_toml_preview(): def ctrld_toml_preview():
"""Generate and return the ctrld.toml without installing it.""" """Generate and return the ctrld.toml without installing it."""
cfg = _load_ctrld_cfg() cfg = _load_ctrld_cfg()
profiles = cfg.get("vlan_profiles",[]) profiles = cfg.get("vlan_profiles", [])
deploy_mode = cfg.get("deploy_mode", "proxy")
if not profiles: if not profiles:
raise HTTPException(400, "No VLAN profiles configured yet") raise HTTPException(400, "No VLAN profiles configured yet")
toml = _build_ctrld_toml(profiles) toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode)
return {"toml": toml} return {"toml": toml, "deploy_mode": deploy_mode}
@app.post("/api/ctrld/save-config") @app.post("/api/ctrld/save-config")
def ctrld_save_config(body: CtrldInstallRequest): def ctrld_save_config(body: CtrldInstallRequest):
@@ -2303,19 +2342,21 @@ def ctrld_save_config(body: CtrldInstallRequest):
"toml_error": validation.get("toml_error", ""), "toml_error": validation.get("toml_error", ""),
}) })
deploy_mode = body.config.deploy_mode or "proxy"
cfg_dict = { cfg_dict = {
"mode": body.config.mode, "mode": body.config.mode,
"deploy_mode": deploy_mode,
"vlan_profiles": profiles, "vlan_profiles": profiles,
"opnsense_host": body.config.opnsense_host, "opnsense_host": body.config.opnsense_host,
} }
_save_ctrld_cfg(cfg_dict) _save_ctrld_cfg(cfg_dict)
toml = _build_ctrld_toml(profiles) toml = _build_ctrld_toml(profiles, deploy_mode=deploy_mode)
if body.config.mode == "local": if body.config.mode == "local":
return _ctrld_install_local(toml, profiles) return _ctrld_install_local(toml, profiles)
elif body.config.mode == "opnsense": 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: else:
# Manual — just return the toml and instructions # Manual — just return the toml and instructions
return { return {
@@ -2467,10 +2508,11 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
"port53": port53_fix, "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. 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) first_rid = next((p["resolver_id"] for p in profiles if p.get("resolver_id")), None)
if not first_rid: if not first_rid:
@@ -2481,7 +2523,7 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list) -> dict:
f"-s {first_rid} forced'" 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 # For OPNsense the config path is different
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml" opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
@@ -2508,7 +2550,8 @@ def ctrld_update_profiles(body: CtrldUpdateProfile):
cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles] cfg["vlan_profiles"] = [p.dict() for p in body.vlan_profiles]
_save_ctrld_cfg(cfg) _save_ctrld_cfg(cfg)
toml = _build_ctrld_toml(cfg["vlan_profiles"]) deploy_mode = cfg.get("deploy_mode", "proxy")
toml = _build_ctrld_toml(cfg["vlan_profiles"], deploy_mode=deploy_mode)
cfg_path = _ctrld_config_path() cfg_path = _ctrld_config_path()
if cfg.get("mode") == "local" and cfg_path.exists(): if cfg.get("mode") == "local" and cfg_path.exists():