Revert DNS/ctrld changes from this session — restore to working state

Hard-restores switch_backend.py to d9b6d05 (before this session's changes).

Reverted commits:
  4cf7433 Fix per-VLAN DNS: switch to proxy mode, Unbound on :5353
  2db5c3b Fix per-VLAN ControlD profiles — use gateway-listener mode
  91eaeff Fix local-lan-zone.conf: add server: wrapper + correct local-data syntax
  da1d629 Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.)

These changes broke a working (mostly) system. Before touching the DNS
and ctrld architecture again, the actual deployed state needs to be
understood first.

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-27 14:48:28 +00:00
parent 4cf74335ad
commit 35f6f8c94d
+70 -280
View File
@@ -2074,9 +2074,8 @@ def _ctrld_config_path() -> _Path:
if p.exists(): return p if p.exists(): return p
return candidates[0] # default for new install return candidates[0] # default for new install
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53, def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
deploy_mode: str = "proxy", deploy_mode: str = "router") -> str:
unbound_port: int = 5353) -> str:
""" """
Build a ctrld.toml using flat dotted-key section headers. Build a ctrld.toml using flat dotted-key section headers.
@@ -2106,25 +2105,9 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
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()]
# Per-gateway mode: every active profile has a VLAN gateway IP.
# ctrld listens on each gateway IP:53 so it sees the real client source IP
# and can route to the correct per-VLAN ControlD profile.
# Unbound stays on 127.0.0.1:53 (no interface overlap — no port conflict).
# This is the only mode that makes per-VLAN ControlD profiles actually work;
# the single-localhost-listener mode cannot differentiate VLANs because all
# queries arrive from Unbound as 127.0.0.1.
gateways = [vp.get("gateway", "").strip() for vp in active]
use_gateway_listeners = bool(active) and all(gateways)
arch_comment = (
"# Architecture: ctrld on each VLAN gateway IP:53 — per-VLAN ControlD profiles"
if use_gateway_listeners else
"# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port)
)
lines = [ lines = [
"# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager", "# ctrld configuration — generated by Avaya 59100GTS-PWR+ Switch Manager",
arch_comment, "# Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
"# Docs: https://docs.controld.com/docs/ctrld", "# Docs: https://docs.controld.com/docs/ctrld",
"", "",
"[service]", "[service]",
@@ -2154,57 +2137,19 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
"", "",
] ]
# ── ROUTER MODE: per-gateway listeners (preferred) or single localhost ──── # ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────
if deploy_mode == "router" and use_gateway_listeners: if deploy_mode == "router":
# Per-VLAN gateway listeners. # Single listener on localhost — Unbound's Query Forwarding points here.
# ctrld binds each VLAN gateway IP on port 53. Unbound stays on # No per-VLAN listeners needed: Unbound handles all local resolution
# 127.0.0.1:53 — no overlap so no boot race. Each VLAN's client # before queries arrive; ctrld just proxies external queries upstream.
# queries go to their gateway (OPNsense), hit ctrld which sees the
# real source IP, and are routed to the right ControlD profile.
# Unbound is reached as upstream.local for .lan/.local resolution
# so custom hostnames (pbx.lan etc.) resolve without ControlD.
lines += [ lines += [
"# Unbound on 127.0.0.1:53 handles .lan/.local — ctrld forwards here", "# Listens on localhost only — Unbound Query Forwarding sends external queries here",
"[upstream.local]",
" name = \'Local .lan resolver (Unbound loopback)\'",
" type = \'legacy\'",
" endpoint = \'127.0.0.1:53\'",
" timeout = 2000",
"",
]
for i, (vp, gw) in enumerate(zip(active, gateways)):
vid = vp["vlan_id"]
name = vp.get("name", f"VLAN{vid}")
lines += [
f"# VLAN {vid}{name} — listens on {gw}:53",
f"[listener.{i}]",
f" ip = \'{gw}\'",
f" port = 53",
"",
f" [listener.{i}.policy]",
f" name = \'VLAN {vid} {name}\'",
f" networks = []",
f" rules = [",
f" {{ \'*.lan\' = [\'upstream.local\'] }},",
f" {{ \'*.local\' = [\'upstream.local\'] }},",
f" ]",
f" default = [\'upstream.{i}\']",
"",
]
elif deploy_mode == "router":
# Fallback: single localhost listener when gateways are not set.
# WARNING: all VLANs share upstream.0 — per-VLAN profiles do NOT work.
lines += [
"# WARNING: single-listener mode — all VLANs share the same ControlD profile.",
"# Set a gateway IP on each VLAN profile to enable per-VLAN routing.",
"# Listens on localhost only — Unbound Query Forwarding sends queries here",
"[listener.0]", "[listener.0]",
f" ip = \'127.0.0.1\'", f" ip = \'127.0.0.1\'",
f" port = {ctrld_port}", f" port = {ctrld_port}",
"", "",
" [listener.0.policy]", " [listener.0.policy]",
" name = \'Default Policy (all VLANs)\'", " name = \'Default Policy\'",
" networks = []", " networks = []",
" rules = []", " rules = []",
] ]
@@ -2212,22 +2157,9 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
lines[-1] = f" default = [\'upstream.0\']" lines[-1] = f" default = [\'upstream.0\']"
lines.append("") lines.append("")
# ── PROXY MODE: 0.0.0.0:53 + per-VLAN CIDR routing ────────────────────── # ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────
else: else:
# ctrld is the primary resolver on port 53.
# Unbound runs on a different port (unbound_port, default 5353) so
# there is no port conflict regardless of start order.
# ctrld sees real client source IPs and routes each VLAN to the
# correct ControlD profile via [network.N] CIDR entries.
# .lan / .local queries are split-horizon'd to Unbound via upstream.local.
lines += [ lines += [
f"# Unbound on 127.0.0.1:{unbound_port} handles .lan/.local — ctrld forwards here",
"[upstream.local]",
" name = \'Local .lan resolver (Unbound)\'",
" type = \'legacy\'",
f" endpoint = \'127.0.0.1:{unbound_port}\'",
" timeout = 2000",
"",
"[listener.0]", "[listener.0]",
f" ip = \'0.0.0.0\'", f" ip = \'0.0.0.0\'",
f" port = {ctrld_port}", f" port = {ctrld_port}",
@@ -2245,13 +2177,7 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
else: else:
lines += [" networks = []"] lines += [" networks = []"]
lines += [ lines += [" rules = []", ""]
" rules = [",
" { \'*.lan\' = [\'upstream.local\'] },",
" { \'*.local\' = [\'upstream.local\'] },",
" ]",
"",
]
# Network sections for CIDR routing # Network sections for CIDR routing
for i, vp in enumerate(active): for i, vp in enumerate(active):
@@ -2280,15 +2206,15 @@ class CtrldVlanProfile(BaseModel):
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") 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" # "proxy" (ctrld on :53, per-VLAN CIDR) | "router" (ctrld on localhost) deploy_mode: Optional[str] = "router" # "router" (OPNsense, localhost) | "proxy" (management host, 0.0.0.0)
vlan_profiles: list[CtrldVlanProfile] vlan_profiles: list[CtrldVlanProfile]
opnsense_host: Optional[str] = "" opnsense_host: Optional[str] = ""
ctrld_port: Optional[int] = 53 # port ctrld listens on (53 in proxy mode) ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)
unbound_port: Optional[int] = 5353 # port Unbound listens on (change in OPNsense UI) local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)
local_domain: Optional[str] = "lan" # local domain Unbound handles (forwarded to Unbound by ctrld)
class CtrldInstallRequest(BaseModel): class CtrldInstallRequest(BaseModel):
token: str token: str
@@ -2432,14 +2358,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") deploy_mode = cfg.get("deploy_mode", "router")
ctrld_port = cfg.get("ctrld_port", 53) ctrld_port = cfg.get("ctrld_port", 5354)
unbound_port = cfg.get("unbound_port", 5353)
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, ctrld_port=ctrld_port, toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
deploy_mode=deploy_mode, unbound_port=unbound_port)
return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port} return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}
@app.post("/api/ctrld/save-config") @app.post("/api/ctrld/save-config")
@@ -2467,9 +2391,8 @@ 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" deploy_mode = body.config.deploy_mode or "router"
ctrld_port = body.config.ctrld_port or 53 ctrld_port = body.config.ctrld_port or 5354
unbound_port = body.config.unbound_port or 5353
local_domain = body.config.local_domain or "lan" local_domain = body.config.local_domain or "lan"
cfg_dict = { cfg_dict = {
"mode": body.config.mode, "mode": body.config.mode,
@@ -2477,20 +2400,18 @@ def ctrld_save_config(body: CtrldInstallRequest):
"vlan_profiles": profiles, "vlan_profiles": profiles,
"opnsense_host": body.config.opnsense_host, "opnsense_host": body.config.opnsense_host,
"ctrld_port": ctrld_port, "ctrld_port": ctrld_port,
"unbound_port": unbound_port,
"local_domain": local_domain, "local_domain": local_domain,
} }
_save_ctrld_cfg(cfg_dict) _save_ctrld_cfg(cfg_dict)
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
deploy_mode=deploy_mode, unbound_port=unbound_port)
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( return _ctrld_generate_opnsense_cmd(
body.config.opnsense_host, profiles, deploy_mode, body.config.opnsense_host, profiles, deploy_mode,
ctrld_port=ctrld_port, unbound_port=unbound_port, local_domain=local_domain, ctrld_port=ctrld_port, local_domain=local_domain,
) )
else: else:
# Manual — just return the toml and instructions # Manual — just return the toml and instructions
@@ -2644,23 +2565,23 @@ def _ctrld_install_local(toml: str, profiles: list) -> dict:
} }
def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list, def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
deploy_mode: str = "proxy", deploy_mode: str = "router",
ctrld_port: int = 53, ctrld_port: int = 5354,
unbound_port: int = 5353,
local_domain: str = "lan") -> dict: local_domain: str = "lan") -> dict:
""" """
Generate the SSH command + step-by-step instructions to install ctrld on OPNsense. Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.
Proxy mode (default, recommended for per-VLAN profiles): Confirmed working architecture (verified after reboot — no manual intervention needed):
Clients → ctrld (0.0.0.0:53) → ControlD per-VLAN profile via CIDR routing Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
Unbound on 127.0.0.1:unbound_port (default 5353) for .lan resolution
No port conflict — different ports, any start order is fine.
ctrld sees real client source IPs → per-VLAN ControlD profiles work.
.lan / .local queries split-horizon'd to Unbound via upstream.local.
Router mode (fallback — per-VLAN profiles DO NOT work): Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot
Clients → Unbound:53 → Query Forwarding → ctrld (127.0.0.1:ctrld_port) conflict with Unbound at startup regardless of service start order.
All VLANs share one upstream — Unbound strips the source IP. Unbound's Query Forwarding sends external queries through ctrld.
Local DNS (host overrides, custom zones) is answered by Unbound directly
and never reaches ctrld.
NOTE: Remove any 'home.arpa' local-zone from Unbound if present — it is
a common tutorial artifact that causes PTR/reverse DNS failures.
""" """
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:
@@ -2671,122 +2592,54 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
f"-s {first_rid} forced'" f"-s {first_rid} forced'"
) )
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
deploy_mode=deploy_mode, unbound_port=unbound_port)
# Detect which mode the TOML was built in
gateways = [p.get("gateway", "").strip() for p in profiles if p.get("resolver_id") or p.get("endpoint_url")]
per_gateway = deploy_mode == "router" and bool(gateways) and all(gateways)
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml" opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF" write_toml_cmd = f"cat > {opnsense_cfg} << 'CTRLDEOF'\n{toml}\nCTRLDEOF"
if per_gateway:
gw_list = ", ".join(f"{gw}:53" for gw in gateways)
arch_line = f"Architecture: ctrld on [{gw_list}] — per-VLAN profiles active"
step3 = [
"STEP 3 — Restrict Unbound to loopback only (so it doesn't conflict with ctrld on :53):",
" OPNsense GUI → Services → Unbound DNS → General:",
" Network Interfaces → select ONLY 'lo0 (Loopback)' and deselect all VLAN interfaces",
" Click Save + Apply",
" Verify: unbound-control status | grep interface",
" Unbound should show: interface: 127.0.0.1 (loopback only)",
"",
"STEP 4 — Disable Unbound Query Forwarding (ctrld is no longer downstream of Unbound):",
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
" Disable / remove any forward zone pointing to 127.0.0.1",
" OR: use the Unbound panel in this tool to write a disabled forward_to_ctrld.conf",
"",
"STEP 5 — Verify each VLAN gets its own profile:",
] + [
f" dig @{gw} google.com # VLAN {p.get('vlan_id')} — should use {p.get('name')} ControlD profile"
for gw, p in zip(gateways, profiles)
if p.get("gateway", "").strip()
] + [
f" dig @127.0.0.1 myhost.{local_domain} # local .lan — answered by Unbound",
]
step2_note = (
f"Write {opnsense_cfg} with the TOML below, then: ctrld restart "
f"(ctrld will listen on {gw_list})"
)
message = f"Per-VLAN gateway-listener mode: ctrld on [{gw_list}] — each VLAN gets its own ControlD profile"
architecture = (
f"ctrld listens on VLAN gateway IPs ({gw_list}). "
"Unbound on 127.0.0.1:53 only — no port conflict. "
"Each VLAN's DNS traffic hits ctrld on its gateway IP; "
"ctrld routes to the correct ControlD profile by source subnet."
)
elif deploy_mode == "proxy":
arch_line = f"Architecture: ctrld (0.0.0.0:{ctrld_port}) ← clients; Unbound (127.0.0.1:{unbound_port}) ← .lan — per-VLAN profiles active"
step3 = [
f"STEP 3 — Change Unbound's Listen Port to {unbound_port} (so ctrld can own port 53):",
" OPNsense GUI → Services → Unbound DNS → General:",
f" Listen Port: change from 53 to {unbound_port}",
" Network Interfaces: leave as 'All (recommended)'",
" Click Apply",
"",
"STEP 4 — Disable Unbound Query Forwarding (ctrld IS the resolver, not downstream):",
" Services → Unbound DNS → Query Forwarding → disable / remove any forward zone",
"",
"STEP 5 — Verify per-VLAN routing:",
" From a device on each VLAN, run: nslookup google.com",
f" From any device, run: nslookup pbx.{local_domain}",
" Check ControlD dashboard — each VLAN's traffic should appear under its own resolver",
]
step2_note = f"Write {opnsense_cfg} with the TOML below, then: ctrld restart (ctrld owns port {ctrld_port})"
message = f"Proxy mode: ctrld on :{ctrld_port}, Unbound on :{unbound_port} — per-VLAN ControlD profiles active"
architecture = (
f"ctrld listens on 0.0.0.0:{ctrld_port} — clients query their VLAN gateway, "
f"ctrld sees real source IPs and routes to the correct ControlD profile. "
f"Unbound on 127.0.0.1:{unbound_port} handles .lan/.local (no port conflict)."
)
else:
arch_line = "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD [WARNING: single shared profile]".format(ctrld_port)
step3 = [
"STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):",
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
" • Enable Query Forwarding: checked",
f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}",
" • Use TLS: No",
" • Click Apply / Save",
"",
" WARNING: in this mode all VLANs share the same ControlD profile.",
]
step2_note = f"Write {opnsense_cfg} with the TOML below, then: ctrld restart"
message = f"Router fallback mode — all VLANs share one ControlD profile (use proxy mode for per-VLAN routing)"
architecture = (
f"Unbound on :53 forwards to ctrld on 127.0.0.1:{ctrld_port}. "
"Per-VLAN ControlD profiles DO NOT work — all queries appear from 127.0.0.1."
)
setup_steps = [ setup_steps = [
arch_line, "Architecture: Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) → ControlD".format(ctrld_port),
"", "",
"STEP 1 — Install ctrld on OPNsense (SSH or shell):", "STEP 1 — Install ctrld on OPNsense (SSH or shell):",
f" {install_cmd}", f" {install_cmd}",
"", "",
"STEP 2 — Write the ctrld.toml:", "STEP 2 — Write the ctrld.toml (ctrld listens on 127.0.0.1:{}, NOT port 53):".format(ctrld_port),
f" {write_toml_cmd}", f" {write_toml_cmd}",
f" Then restart ctrld: ctrld restart", " Then restart ctrld: ctrld restart",
"", "",
] + step3 + [ "STEP 3 — Configure Unbound Query Forwarding (Unbound stays on port 53):",
" OPNsense GUI → Services → Unbound DNS → Query Forwarding:",
" • Enable Query Forwarding: checked",
f" • Add forward zone: Domain=. (dot) Address=127.0.0.1 Port={ctrld_port}",
" • Use TLS: No (ctrld handles DoH/DoT upstream; plain DNS locally is fine)",
" • Click Apply / Save",
"", "",
"STEP {} — Remove 'home.arpa' local-zone from Unbound if present:".format(6 if per_gateway else 4), "STEP 4 — Remove 'home.arpa' local-zone from Unbound if present:",
" OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:", " OPNsense GUI → Services → Unbound DNS → Advanced → Custom options:",
" Remove any line containing: local-zone: \"home.arpa\"", " Remove any line containing: local-zone: \"home.arpa\"",
" (Tutorial artifact — breaks reverse DNS / PTR lookups)", " (This is a tutorial artifact — it breaks reverse DNS / PTR lookups)",
"",
"STEP 5 — Verify (Unbound on :53 answers, ctrld proxies upstream):",
" dig @192.168.1.1 google.com # external — goes through ctrld → ControlD",
f" dig @192.168.1.1 myhost.{local_domain} # local — answered by Unbound directly",
" dig @192.168.1.1 -x 192.168.1.1 # reverse PTR — answered by Unbound directly",
] ]
return { return {
"success": True, "success": True,
"mode": "opnsense", "mode": "opnsense",
"per_gateway": per_gateway, "message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port),
"message": message,
"architecture": architecture,
"setup_steps": setup_steps, "setup_steps": setup_steps,
"architecture": "Unbound stays on :53. ctrld binds 127.0.0.1:{} only — no port conflict possible.".format(ctrld_port),
"step1_install": install_cmd, "step1_install": install_cmd,
"step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'", "step1_ssh": f"ssh root@{opnsense_host or 'your-opnsense-ip'} '{install_cmd}'",
"step2_config": step2_note, "step2_config": f"Write {opnsense_cfg} with the TOML below, then: ctrld restart",
"step3_unbound": (
f"Services → Unbound DNS → Query Forwarding: "
f"Enable, add zone '.' → 127.0.0.1:{ctrld_port}, no TLS, Apply"
),
"step4_cleanup": "Remove 'home.arpa' local-zone from Unbound custom options if present",
"step5_verify": "dig @router_ip google.com && dig @router_ip -x 192.168.1.1",
"toml": toml, "toml": toml,
"toml_write_cmd": write_toml_cmd, "toml_write_cmd": write_toml_cmd,
"config_path": opnsense_cfg, "config_path": opnsense_cfg,
@@ -2803,11 +2656,9 @@ 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)
deploy_mode = cfg.get("deploy_mode", "proxy") deploy_mode = cfg.get("deploy_mode", "router")
ctrld_port = cfg.get("ctrld_port", 53) ctrld_port = cfg.get("ctrld_port", 5354)
unbound_port = cfg.get("unbound_port", 5353) toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port,
deploy_mode=deploy_mode, unbound_port=unbound_port)
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():
@@ -3002,40 +2853,6 @@ def _generate_dnsmasq_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def _build_unbound_lan_zone_conf(entries: list, mgmt_ip: str = "192.168.99.50") -> str:
"""
Build the full local-lan-zone.conf for Unbound.
OPNsense includes /var/unbound/etc/*.conf at the TOP LEVEL of unbound.conf
(either via include: or include-toplevel:). This means server-level
directives (local-zone:, local-data:) must be wrapped in a server: block.
Without the wrapper they land outside any section and are silently ignored
or cause unbound-checkconf to error. forward-zone: is a top-level section
and needs no wrapper — that's why forward_to_ctrld.conf works without one.
Declares 'lan.' as a static zone (so .lan never leaks to ControlD) and
adds local-data A records for every entry in local-hostnames.json plus
the two built-in management aliases. Without local-data entries every
.lan name not explicitly listed gets NXDOMAIN — including pbx.lan and any
other custom hostname the user defined.
"""
lines = [
"server:",
' local-zone: "lan." static',
"",
]
# Management PC aliases — always present
for alias in ("switch.mgmt.lan", "management.lan"):
lines.append(f' local-data: "{alias}. A {mgmt_ip}"')
# User-defined entries from local-hostnames.json
for e in entries:
name = e.get("name", "").strip().rstrip(".")
ip = e.get("ip", "").strip()
if name and ip:
lines.append(f' local-data: "{name}. A {ip}"')
return "\n".join(lines) + "\n"
def _generate_ctrld_split_horizon_block(local_domain: str = "lan", def _generate_ctrld_split_horizon_block(local_domain: str = "lan",
dnsmasq_port: int = 5353) -> str: dnsmasq_port: int = 5353) -> str:
""" """
@@ -3125,9 +2942,8 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
if ctrld_cfg.get("vlan_profiles"): if ctrld_cfg.get("vlan_profiles"):
split_horizon_toml = _build_ctrld_toml( split_horizon_toml = _build_ctrld_toml(
ctrld_cfg["vlan_profiles"], ctrld_cfg["vlan_profiles"],
ctrld_port=ctrld_cfg.get("ctrld_port", 53), ctrld_port=ctrld_cfg.get("ctrld_port", 5354),
deploy_mode=ctrld_cfg.get("deploy_mode", "proxy"), deploy_mode=ctrld_cfg.get("deploy_mode", "router"),
unbound_port=ctrld_cfg.get("unbound_port", 5353),
) )
# Write new toml if running locally # Write new toml if running locally
if ctrld_cfg.get("mode") == "local": if ctrld_cfg.get("mode") == "local":
@@ -3139,21 +2955,6 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
local_domain=body.local_domain or "lan" local_domain=body.local_domain or "lan"
) )
# Push local-data records into Unbound on OPNsense if SSH is configured.
# Without this, Unbound's static lan. zone returns NXDOMAIN for any
# custom .lan hostname (pbx.lan, nas.lan, etc.) that isn't explicitly
# listed — even though they exist in dnsmasq.
unbound_push = None
try:
opn_cfg = _load_opnsense_cfg()
if opn_cfg.get("ssh_key_path"):
lan_zone_conf = _build_unbound_lan_zone_conf(entries, mgmt_ip)
_opnsense_sftp_write(opn_cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf)
_opnsense_ssh_run(opn_cfg, "unbound-control reload 2>&1")
unbound_push = f"Pushed {len(entries)} local-data record(s) to Unbound and reloaded"
except Exception as _upe:
unbound_push = f"Unbound push skipped: {_upe}"
return { return {
"success": True, "success": True,
"entries": entries, "entries": entries,
@@ -3161,7 +2962,6 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
"conf_path": str(DNSMASQ_CONF_PATH), "conf_path": str(DNSMASQ_CONF_PATH),
"split_horizon": split_horizon, "split_horizon": split_horizon,
"full_toml": split_horizon_toml, "full_toml": split_horizon_toml,
"unbound_push": unbound_push,
"docker_compose_snippet": ( "docker_compose_snippet": (
" dnsmasq:\n" " dnsmasq:\n"
" image: andyshinn/dnsmasq:latest\n" " image: andyshinn/dnsmasq:latest\n"
@@ -3741,21 +3541,11 @@ def opnsense_unbound_fix_lan_zone():
raise HTTPException(503, "OPNsense SSH not configured") raise HTTPException(503, "OPNsense SSH not configured")
steps = [] steps = []
errors = [] errors = []
# Build local-lan-zone.conf with all local-data records so custom .lan # Write the correct local-lan-zone.conf via SFTP
# hostnames (pbx.lan, nas.lan, etc.) resolve correctly from Unbound. lan_zone_conf = 'local-zone: "lan." static\n'
import socket as _sock2
try:
mgmt_ip = _sock2.gethostbyname(_sock2.gethostname())
except Exception:
mgmt_ip = "192.168.99.50"
entries = _load_local_hostnames()
lan_zone_conf = _build_unbound_lan_zone_conf(entries, mgmt_ip)
try: try:
_opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf) _opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf)
steps.append( steps.append("Wrote local-lan-zone.conf: local-zone \"lan.\" static")
f"Wrote local-lan-zone.conf: local-zone \"lan.\" static + "
f"{len(entries)} local-data record(s)"
)
except Exception as e: except Exception as e:
errors.append(f"Write local-lan-zone.conf: {e}") errors.append(f"Write local-lan-zone.conf: {e}")
raise HTTPException(500, "; ".join(errors)) raise HTTPException(500, "; ".join(errors))