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
+131 -88
View File
@@ -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)