Revert DNS/ctrld changes from this session — restore to working state
Hard-restores switch_backend.py tod9b6d05(before this session's changes). Reverted commits:4cf7433Fix per-VLAN DNS: switch to proxy mode, Unbound on :53532db5c3bFix per-VLAN ControlD profiles — use gateway-listener mode91eaeffFix local-lan-zone.conf: add server: wrapper + correct local-data syntaxda1d629Fix .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:
+70
-280
@@ -2074,9 +2074,8 @@ def _ctrld_config_path() -> _Path:
|
||||
if p.exists(): return p
|
||||
return candidates[0] # default for new install
|
||||
|
||||
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
|
||||
deploy_mode: str = "proxy",
|
||||
unbound_port: int = 5353) -> str:
|
||||
def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 5354,
|
||||
deploy_mode: str = "router") -> str:
|
||||
"""
|
||||
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
|
||||
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 = [
|
||||
"# 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",
|
||||
"",
|
||||
"[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 ────
|
||||
if deploy_mode == "router" and use_gateway_listeners:
|
||||
# Per-VLAN gateway listeners.
|
||||
# ctrld binds each VLAN gateway IP on port 53. Unbound stays on
|
||||
# 127.0.0.1:53 — no overlap so no boot race. Each VLAN's client
|
||||
# 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.
|
||||
# ── ROUTER MODE: localhost listener, Unbound forwards here ───────────────
|
||||
if deploy_mode == "router":
|
||||
# Single listener on localhost — Unbound's Query Forwarding points here.
|
||||
# No per-VLAN listeners needed: Unbound handles all local resolution
|
||||
# before queries arrive; ctrld just proxies external queries upstream.
|
||||
lines += [
|
||||
"# Unbound on 127.0.0.1:53 handles .lan/.local — ctrld forwards 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",
|
||||
"# Listens on localhost only — Unbound Query Forwarding sends external queries here",
|
||||
"[listener.0]",
|
||||
f" ip = \'127.0.0.1\'",
|
||||
f" port = {ctrld_port}",
|
||||
"",
|
||||
" [listener.0.policy]",
|
||||
" name = \'Default Policy (all VLANs)\'",
|
||||
" name = \'Default Policy\'",
|
||||
" networks = []",
|
||||
" rules = []",
|
||||
]
|
||||
@@ -2212,22 +2157,9 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
|
||||
lines[-1] = f" default = [\'upstream.0\']"
|
||||
lines.append("")
|
||||
|
||||
# ── PROXY MODE: 0.0.0.0:53 + per-VLAN CIDR routing ──────────────────────
|
||||
# ── PROXY MODE: 0.0.0.0 listener + CIDR network policies ─────────────────
|
||||
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 += [
|
||||
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]",
|
||||
f" ip = \'0.0.0.0\'",
|
||||
f" port = {ctrld_port}",
|
||||
@@ -2245,13 +2177,7 @@ def _build_ctrld_toml(vlan_profiles: list, ctrld_port: int = 53,
|
||||
else:
|
||||
lines += [" networks = []"]
|
||||
|
||||
lines += [
|
||||
" rules = [",
|
||||
" { \'*.lan\' = [\'upstream.local\'] },",
|
||||
" { \'*.local\' = [\'upstream.local\'] },",
|
||||
" ]",
|
||||
"",
|
||||
]
|
||||
lines += [" rules = []", ""]
|
||||
|
||||
# Network sections for CIDR routing
|
||||
for i, vp in enumerate(active):
|
||||
@@ -2280,15 +2206,15 @@ class CtrldVlanProfile(BaseModel):
|
||||
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"
|
||||
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]
|
||||
opnsense_host: Optional[str] = ""
|
||||
ctrld_port: Optional[int] = 53 # port ctrld listens on (53 in proxy mode)
|
||||
unbound_port: Optional[int] = 5353 # port Unbound listens on (change in OPNsense UI)
|
||||
local_domain: Optional[str] = "lan" # local domain Unbound handles (forwarded to Unbound by ctrld)
|
||||
ctrld_port: Optional[int] = 5354 # port ctrld listens on (Unbound Query Forwarding points here)
|
||||
local_domain: Optional[str] = "lan" # local domain handled by Unbound (not forwarded to ctrld)
|
||||
|
||||
class CtrldInstallRequest(BaseModel):
|
||||
token: str
|
||||
@@ -2432,14 +2358,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", [])
|
||||
deploy_mode = cfg.get("deploy_mode", "proxy")
|
||||
ctrld_port = cfg.get("ctrld_port", 53)
|
||||
unbound_port = cfg.get("unbound_port", 5353)
|
||||
profiles = cfg.get("vlan_profiles", [])
|
||||
deploy_mode = cfg.get("deploy_mode", "router")
|
||||
ctrld_port = cfg.get("ctrld_port", 5354)
|
||||
if not profiles:
|
||||
raise HTTPException(400, "No VLAN profiles configured yet")
|
||||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port,
|
||||
deploy_mode=deploy_mode, unbound_port=unbound_port)
|
||||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||||
return {"toml": toml, "deploy_mode": deploy_mode, "ctrld_port": ctrld_port}
|
||||
|
||||
@app.post("/api/ctrld/save-config")
|
||||
@@ -2467,9 +2391,8 @@ def ctrld_save_config(body: CtrldInstallRequest):
|
||||
"toml_error": validation.get("toml_error", ""),
|
||||
})
|
||||
|
||||
deploy_mode = body.config.deploy_mode or "proxy"
|
||||
ctrld_port = body.config.ctrld_port or 53
|
||||
unbound_port = body.config.unbound_port or 5353
|
||||
deploy_mode = body.config.deploy_mode or "router"
|
||||
ctrld_port = body.config.ctrld_port or 5354
|
||||
local_domain = body.config.local_domain or "lan"
|
||||
cfg_dict = {
|
||||
"mode": body.config.mode,
|
||||
@@ -2477,20 +2400,18 @@ def ctrld_save_config(body: CtrldInstallRequest):
|
||||
"vlan_profiles": profiles,
|
||||
"opnsense_host": body.config.opnsense_host,
|
||||
"ctrld_port": ctrld_port,
|
||||
"unbound_port": unbound_port,
|
||||
"local_domain": local_domain,
|
||||
}
|
||||
_save_ctrld_cfg(cfg_dict)
|
||||
|
||||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port,
|
||||
deploy_mode=deploy_mode, unbound_port=unbound_port)
|
||||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, 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, deploy_mode,
|
||||
ctrld_port=ctrld_port, unbound_port=unbound_port, local_domain=local_domain,
|
||||
ctrld_port=ctrld_port, local_domain=local_domain,
|
||||
)
|
||||
else:
|
||||
# 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,
|
||||
deploy_mode: str = "proxy",
|
||||
ctrld_port: int = 53,
|
||||
unbound_port: int = 5353,
|
||||
deploy_mode: str = "router",
|
||||
ctrld_port: int = 5354,
|
||||
local_domain: str = "lan") -> dict:
|
||||
"""
|
||||
Generate the SSH command + step-by-step instructions to install ctrld on OPNsense.
|
||||
|
||||
Proxy mode (default, recommended for per-VLAN profiles):
|
||||
Clients → ctrld (0.0.0.0:53) → ControlD per-VLAN profile via CIDR routing
|
||||
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.
|
||||
Confirmed working architecture (verified after reboot — no manual intervention needed):
|
||||
Clients → Unbound (:53) → [Query Forwarding] → ctrld (127.0.0.1:5354) → ControlD
|
||||
|
||||
Router mode (fallback — per-VLAN profiles DO NOT work):
|
||||
Clients → Unbound:53 → Query Forwarding → ctrld (127.0.0.1:ctrld_port)
|
||||
All VLANs share one upstream — Unbound strips the source IP.
|
||||
Unbound stays on port 53. ctrld binds to 127.0.0.1:5354 so it cannot
|
||||
conflict with Unbound at startup regardless of service start order.
|
||||
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)
|
||||
if not first_rid:
|
||||
@@ -2671,122 +2592,54 @@ def _ctrld_generate_opnsense_cmd(opnsense_host: str, profiles: list,
|
||||
f"-s {first_rid} forced'"
|
||||
)
|
||||
|
||||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port,
|
||||
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)
|
||||
toml = _build_ctrld_toml(profiles, ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||||
|
||||
opnsense_cfg = "/usr/local/etc/controld/ctrld.toml"
|
||||
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 = [
|
||||
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):",
|
||||
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" 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:",
|
||||
" 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 {
|
||||
"success": True,
|
||||
"mode": "opnsense",
|
||||
"per_gateway": per_gateway,
|
||||
"message": message,
|
||||
"architecture": architecture,
|
||||
"message": "Unbound (:53) → Query Forwarding → ctrld (127.0.0.1:{}) — verified working after reboot".format(ctrld_port),
|
||||
"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_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_write_cmd": write_toml_cmd,
|
||||
"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]
|
||||
_save_ctrld_cfg(cfg)
|
||||
|
||||
deploy_mode = cfg.get("deploy_mode", "proxy")
|
||||
ctrld_port = cfg.get("ctrld_port", 53)
|
||||
unbound_port = cfg.get("unbound_port", 5353)
|
||||
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port,
|
||||
deploy_mode=deploy_mode, unbound_port=unbound_port)
|
||||
deploy_mode = cfg.get("deploy_mode", "router")
|
||||
ctrld_port = cfg.get("ctrld_port", 5354)
|
||||
toml = _build_ctrld_toml(cfg["vlan_profiles"], ctrld_port=ctrld_port, deploy_mode=deploy_mode)
|
||||
cfg_path = _ctrld_config_path()
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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",
|
||||
dnsmasq_port: int = 5353) -> str:
|
||||
"""
|
||||
@@ -3125,9 +2942,8 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
|
||||
if ctrld_cfg.get("vlan_profiles"):
|
||||
split_horizon_toml = _build_ctrld_toml(
|
||||
ctrld_cfg["vlan_profiles"],
|
||||
ctrld_port=ctrld_cfg.get("ctrld_port", 53),
|
||||
deploy_mode=ctrld_cfg.get("deploy_mode", "proxy"),
|
||||
unbound_port=ctrld_cfg.get("unbound_port", 5353),
|
||||
ctrld_port=ctrld_cfg.get("ctrld_port", 5354),
|
||||
deploy_mode=ctrld_cfg.get("deploy_mode", "router"),
|
||||
)
|
||||
# Write new toml if running locally
|
||||
if ctrld_cfg.get("mode") == "local":
|
||||
@@ -3139,21 +2955,6 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
|
||||
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 {
|
||||
"success": True,
|
||||
"entries": entries,
|
||||
@@ -3161,7 +2962,6 @@ def save_local_hostnames(body: LocalHostnamesUpdate):
|
||||
"conf_path": str(DNSMASQ_CONF_PATH),
|
||||
"split_horizon": split_horizon,
|
||||
"full_toml": split_horizon_toml,
|
||||
"unbound_push": unbound_push,
|
||||
"docker_compose_snippet": (
|
||||
" dnsmasq:\n"
|
||||
" image: andyshinn/dnsmasq:latest\n"
|
||||
@@ -3741,21 +3541,11 @@ def opnsense_unbound_fix_lan_zone():
|
||||
raise HTTPException(503, "OPNsense SSH not configured")
|
||||
steps = []
|
||||
errors = []
|
||||
# Build local-lan-zone.conf with all local-data records so custom .lan
|
||||
# hostnames (pbx.lan, nas.lan, etc.) resolve correctly from Unbound.
|
||||
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)
|
||||
# Write the correct local-lan-zone.conf via SFTP
|
||||
lan_zone_conf = 'local-zone: "lan." static\n'
|
||||
try:
|
||||
_opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/local-lan-zone.conf", lan_zone_conf)
|
||||
steps.append(
|
||||
f"Wrote local-lan-zone.conf: local-zone \"lan.\" static + "
|
||||
f"{len(entries)} local-data record(s)"
|
||||
)
|
||||
steps.append("Wrote local-lan-zone.conf: local-zone \"lan.\" static")
|
||||
except Exception as e:
|
||||
errors.append(f"Write local-lan-zone.conf: {e}")
|
||||
raise HTTPException(500, "; ".join(errors))
|
||||
|
||||
Reference in New Issue
Block a user