Fix ctrld TOML generator and add pre-flight endpoint validation
TOML structural fix (critical): - Remove wrapper [listener]/[network]/[upstream] headers; use flat dotted-key notation ([listener.0], [network.0], etc.) that ctrld's Go TOML v2 parser requires — the old nested style triggered a table redefinition panic at startup - Add 'name' field to every [upstream.N] section (required by ctrld) - Add [listener.0.policy] name field DoH3 and protocol support: - CtrldVlanProfile gains protocol (default "doh3") and endpoint_url fields; endpoint_url overrides the ControlD resolver_id URL if set - Upstream type now uses the profile's protocol instead of hardcoded "doh" — enables DoH3 connection-pool reuse added in ctrld 2025 Endpoint pre-flight validation: - New _validate_doh_endpoint(): sends RFC 8484 DoH GET query over plain HTTPS (works for DoH3 URLs too — ControlD serves both) and measures latency; no ctrld binary or Docker required - New POST /api/ctrld/validate-endpoints: tests all profile endpoints, validates TOML syntax via tomllib (Python 3.11+), returns per-profile results + toml_preview - ctrld_save_config now runs validation before writing anything and returns HTTP 400 with per-profile probe results on failure — configs are never pushed with a broken endpoint OPNsense plugin verdict: documented in code — the os-controld plugin kills Unbound and breaks OPNsense DNS advertisement; SSH-based deploy with our own TOML remains the correct path https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
+212
-74
@@ -2001,28 +2001,33 @@ def _ctrld_config_path() -> _Path:
|
||||
def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
|
||||
local_resolver: str = "") -> str:
|
||||
"""
|
||||
Build a ctrld.toml in the correct format — table notation, not TOML arrays.
|
||||
Build a ctrld.toml using flat dotted-key section headers — the only format
|
||||
ctrld's Go TOML parser accepts without a redefinition panic.
|
||||
|
||||
The correct ctrld format uses [listener.0], [network.N], [upstream.N] table
|
||||
sections, NOT [[listener]] / [[upstream]] / [[rule]] array tables. Source
|
||||
VLAN routing is done via [network.N] sections (CIDR-based) referenced in the
|
||||
[listener.0.policy].networks array. Domain-specific overrides go in .rules.
|
||||
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
|
||||
|
||||
vlan_profiles: list of { vlan_id, name, subnet, resolver_id }
|
||||
local_domain: suffix for internal hostnames (default 'lan')
|
||||
local_resolver: if set (e.g. '127.0.0.1:5353'), adds split-horizon upstream
|
||||
and rules so *.lan / *.local go to the local resolver instead
|
||||
of Control D — keeps .lan names working for all VLAN clients.
|
||||
|
||||
Control D bootstrap IP 76.76.2.0 is used for cold-start before DoH is up.
|
||||
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.
|
||||
"""
|
||||
BOOTSTRAP = "76.76.2.0" # Control D anycast — required for cold-start
|
||||
BOOTSTRAP = "76.76.2.0" # ControlD anycast — needed for cold-start
|
||||
|
||||
active = [vp for vp in vlan_profiles if vp.get("resolver_id", "").strip()]
|
||||
active = [vp for vp in vlan_profiles
|
||||
if vp.get("resolver_id", "").strip() or vp.get("endpoint_url", "").strip()]
|
||||
|
||||
lines = [
|
||||
"# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager",
|
||||
"# Documentation: https://docs.controld.com/docs/ctrld",
|
||||
"# Docs: https://docs.controld.com/docs/ctrld",
|
||||
"",
|
||||
"[service]",
|
||||
" log_level = 'info'",
|
||||
@@ -2030,89 +2035,95 @@ def _build_ctrld_toml(vlan_profiles: list, local_domain: str = "lan",
|
||||
"",
|
||||
]
|
||||
|
||||
# ── Listener with per-VLAN policy ────────────────────────────────────────
|
||||
# The policy.networks array maps each [network.N] to one [upstream.N].
|
||||
# This is how ctrld routes different VLAN subnets to different profiles.
|
||||
# ── Listener — flat header, NO parent [listener] wrapper ─────────────────
|
||||
lines += [
|
||||
"[listener]",
|
||||
" [listener.0]",
|
||||
" ip = '0.0.0.0'",
|
||||
" port = 53",
|
||||
" [listener.0.policy]",
|
||||
"[listener.0]",
|
||||
" ip = '0.0.0.0'",
|
||||
" port = 53",
|
||||
"",
|
||||
" [listener.0.policy]",
|
||||
" name = 'VLAN Policy'",
|
||||
]
|
||||
|
||||
if active:
|
||||
net_entries = [f" " + "{ " + f"'network.{i}' = ['upstream.{i}']" + " },"
|
||||
for i in range(len(active))]
|
||||
lines += [" networks = ["] + net_entries + [" ]"]
|
||||
net_entries = [
|
||||
" { " + f"'network.{i}' = ['upstream.{i}']" + " },"
|
||||
for i in range(len(active))
|
||||
]
|
||||
lines += [" networks = ["] + net_entries + [" ]"]
|
||||
else:
|
||||
lines += [" networks = []"]
|
||||
lines += [" networks = []"]
|
||||
|
||||
# Domain-specific rules (split-horizon for .lan / .local → local resolver)
|
||||
if local_resolver:
|
||||
domain_suffix = local_domain.strip(".")
|
||||
lines += [
|
||||
" rules = [",
|
||||
f" " + "{ " + f"'*.{domain_suffix}' = ['upstream.local']" + " },",
|
||||
" " + "{ " + "'*.local' = ['upstream.local']" + " },",
|
||||
" ]",
|
||||
" rules = [",
|
||||
" { " + f"'*.{domain_suffix}' = ['upstream.local']" + " },",
|
||||
" { " + "'*.local' = ['upstream.local']" + " },",
|
||||
" ]",
|
||||
]
|
||||
else:
|
||||
lines += [" rules = []"]
|
||||
lines += [" rules = []"]
|
||||
|
||||
lines += [""]
|
||||
|
||||
# ── Network sections — one per VLAN ──────────────────────────────────────
|
||||
if active:
|
||||
lines += ["[network]"]
|
||||
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 — one per VLAN plus optional local ──────────────────
|
||||
lines += ["[upstream]"]
|
||||
# ── Network sections — flat headers, one per active VLAN ─────────────────
|
||||
for i, vp in enumerate(active):
|
||||
vid = vp["vlan_id"]
|
||||
rid = vp["resolver_id"].strip()
|
||||
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} — {vp.get('name', '')}",
|
||||
f" [upstream.{i}]",
|
||||
f" type = 'doh'",
|
||||
f" endpoint = 'https://dns.controld.com/{rid}'",
|
||||
f" bootstrap_ip = '{BOOTSTRAP}'",
|
||||
f" timeout = 5000",
|
||||
f"# VLAN {vid} — {name}",
|
||||
f"[network.{i}]",
|
||||
f" name = '{name}'",
|
||||
f" cidrs = ['{subnet}']",
|
||||
"",
|
||||
]
|
||||
|
||||
# Optional local resolver for split-horizon .lan resolution (dnsmasq/Unbound)
|
||||
# ── 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 — handles *.{local_domain} and *.local",
|
||||
f" # dnsmasq on port 5353 (Docker) or Unbound on 127.0.0.1:5353 (OPNsense)",
|
||||
f" [upstream.local]",
|
||||
f" type = 'legacy'",
|
||||
f" endpoint = '{local_resolver}'",
|
||||
f" timeout = 2000",
|
||||
f"# Local resolver — *.{local_domain} and *.local",
|
||||
"[upstream.local]",
|
||||
" name = 'Local Resolver'",
|
||||
" type = 'legacy'",
|
||||
f" endpoint = '{local_resolver}'",
|
||||
" timeout = 2000",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── ctrld API models ────────────────────────────────────────────────────────
|
||||
|
||||
# ── ctrld API models ─────────────────────────────────────────────────────────
|
||||
|
||||
class CtrldVlanProfile(BaseModel):
|
||||
vlan_id: int
|
||||
name: str
|
||||
subnet: str
|
||||
resolver_id: str
|
||||
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
|
||||
|
||||
class CtrldConfig(BaseModel):
|
||||
mode: str # "local" | "opnsense" | "manual"
|
||||
@@ -2127,7 +2138,7 @@ class CtrldUpdateProfile(BaseModel):
|
||||
token: str
|
||||
vlan_profiles: list[CtrldVlanProfile]
|
||||
|
||||
# ── ctrld endpoints ─────────────────────────────────────────────────────────
|
||||
# ── ctrld endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/ctrld/status")
|
||||
def ctrld_status():
|
||||
@@ -2144,6 +2155,119 @@ def ctrld_status():
|
||||
"docs_url": "https://docs.controld.com/docs/ctrld",
|
||||
}
|
||||
|
||||
def _validate_doh_endpoint(endpoint_url: str) -> dict:
|
||||
"""
|
||||
Test a DoH endpoint using a plain HTTPS GET query (RFC 8484).
|
||||
|
||||
Works for both DoH and DoH3 endpoints — ControlD and most public resolvers
|
||||
serve DoH over HTTPS/2 at the same URL they use for DoH3, so a successful
|
||||
HTTP response proves the URL is live and well-formed before ctrld ever
|
||||
touches it.
|
||||
|
||||
Sends: GET {url}?dns=<base64url(A? ping.controld.com)>
|
||||
Expects: 200, Content-Type: application/dns-message
|
||||
|
||||
Returns: {ok, latency_ms, status_code, error}
|
||||
"""
|
||||
import struct, base64, time, urllib.request, urllib.error
|
||||
|
||||
# Build a minimal DNS A query for "ping.controld.com" in wire format
|
||||
def _make_query(domain: str = "ping.controld.com") -> bytes:
|
||||
hdr = struct.pack(">HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)
|
||||
qname = b""
|
||||
for label in domain.rstrip(".").split("."):
|
||||
qname += bytes([len(label)]) + label.encode()
|
||||
qname += b"\x00"
|
||||
qtype = struct.pack(">HH", 1, 1) # A IN
|
||||
return hdr + qname + qtype
|
||||
|
||||
dns_bytes = _make_query()
|
||||
dns_b64 = base64.urlsafe_b64encode(dns_bytes).rstrip(b"=").decode()
|
||||
test_url = f"{endpoint_url.rstrip('/')}?dns={dns_b64}"
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
test_url,
|
||||
headers={"Accept": "application/dns-message"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||||
ct = resp.headers.get("Content-Type", "")
|
||||
body = resp.read(12) # just enough to check it's a DNS response
|
||||
ok = resp.status == 200 and "dns-message" in ct
|
||||
return {
|
||||
"ok": ok,
|
||||
"latency_ms": latency_ms,
|
||||
"status_code": resp.status,
|
||||
"error": "" if ok else f"Unexpected Content-Type: {ct}",
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||||
return {"ok": False, "latency_ms": latency_ms,
|
||||
"status_code": e.code, "error": str(e)}
|
||||
except Exception as e:
|
||||
latency_ms = int((time.monotonic() - t0) * 1000)
|
||||
return {"ok": False, "latency_ms": latency_ms,
|
||||
"status_code": 0, "error": str(e)}
|
||||
|
||||
|
||||
class CtrldValidateRequest(BaseModel):
|
||||
vlan_profiles: list[CtrldVlanProfile]
|
||||
|
||||
|
||||
@app.post("/api/ctrld/validate-endpoints")
|
||||
def ctrld_validate_endpoints(body: CtrldValidateRequest):
|
||||
"""
|
||||
Test each profile's DoH/DoH3 endpoint URL before writing any config.
|
||||
Returns per-profile results plus an overall ok flag.
|
||||
Intended to be called from the UI before calling save-config.
|
||||
"""
|
||||
results = []
|
||||
for vp in body.vlan_profiles:
|
||||
p = vp.dict()
|
||||
url = (p.get("endpoint_url") or "").strip()
|
||||
rid = (p.get("resolver_id") or "").strip()
|
||||
if not url and rid:
|
||||
url = f"https://dns.controld.com/{rid}"
|
||||
if not url:
|
||||
results.append({
|
||||
"vlan_id": p["vlan_id"],
|
||||
"name": p.get("name", ""),
|
||||
"ok": False,
|
||||
"error": "No endpoint URL or resolver_id provided",
|
||||
})
|
||||
continue
|
||||
probe = _validate_doh_endpoint(url)
|
||||
results.append({
|
||||
"vlan_id": p["vlan_id"],
|
||||
"name": p.get("name", ""),
|
||||
"url": url,
|
||||
**probe,
|
||||
})
|
||||
|
||||
# Also validate the generated TOML can be parsed (catches structural issues)
|
||||
toml_ok = True
|
||||
toml_error = ""
|
||||
try:
|
||||
import sys
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
tomllib.loads(_build_ctrld_toml([p.dict() for p in body.vlan_profiles]))
|
||||
# tomllib not available in 3.10 — skip structural check
|
||||
except Exception as e:
|
||||
toml_ok = False
|
||||
toml_error = str(e)
|
||||
|
||||
return {
|
||||
"all_ok": all(r["ok"] for r in results) and toml_ok,
|
||||
"results": results,
|
||||
"toml_ok": toml_ok,
|
||||
"toml_error": toml_error,
|
||||
"toml_preview": _build_ctrld_toml([p.dict() for p in body.vlan_profiles]),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/ctrld/toml-preview")
|
||||
def ctrld_toml_preview():
|
||||
"""Generate and return the ctrld.toml without installing it."""
|
||||
@@ -2158,21 +2282,35 @@ def ctrld_toml_preview():
|
||||
def ctrld_save_config(body: CtrldInstallRequest):
|
||||
"""
|
||||
Save ctrld configuration.
|
||||
Validates all endpoints before writing anything — returns 400 with per-profile
|
||||
probe results if any endpoint is unreachable so the user can fix it first.
|
||||
For 'local' mode: installs ctrld on this machine, writes config, starts service.
|
||||
For 'opnsense' mode: generates the SSH install command.
|
||||
For 'manual' mode: saves config for reference, generates toml only.
|
||||
"""
|
||||
require_session(body.token)
|
||||
|
||||
profiles = [p.dict() for p in body.config.vlan_profiles]
|
||||
|
||||
# ── Endpoint validation — block on failure ────────────────────────────────
|
||||
validation = ctrld_validate_endpoints(
|
||||
CtrldValidateRequest(vlan_profiles=body.config.vlan_profiles)
|
||||
)
|
||||
if not validation["all_ok"]:
|
||||
raise HTTPException(400, {
|
||||
"message": "One or more DNS endpoints failed validation — fix before saving",
|
||||
"results": validation["results"],
|
||||
"toml_error": validation.get("toml_error", ""),
|
||||
})
|
||||
|
||||
cfg_dict = {
|
||||
"mode": body.config.mode,
|
||||
"vlan_profiles": [p.dict() for p in body.config.vlan_profiles],
|
||||
"vlan_profiles": profiles,
|
||||
"opnsense_host": body.config.opnsense_host,
|
||||
}
|
||||
_save_ctrld_cfg(cfg_dict)
|
||||
|
||||
profiles = [p.dict() for p in body.config.vlan_profiles]
|
||||
toml = _build_ctrld_toml(profiles)
|
||||
toml = _build_ctrld_toml(profiles)
|
||||
|
||||
if body.config.mode == "local":
|
||||
return _ctrld_install_local(toml, profiles)
|
||||
|
||||
Reference in New Issue
Block a user