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
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))