Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.)

After the Unbound/:53 + ctrld/:5354 architecture change, fix-lan-zone
wrote local-lan-zone.conf with only 'local-zone: "lan." static' and no
local-data records.  Unbound then returned NXDOMAIN for every .lan name
not explicitly listed — including pbx.lan and any hostname in
local-hostnames.json — because the static zone intercepts all .lan
queries before they can reach dnsmasq.

Fix:
- Add _build_unbound_lan_zone_conf(entries, mgmt_ip) which builds a
  complete local-lan-zone.conf: the static zone declaration plus
  local-data A records for every entry in local-hostnames.json and the
  two built-in management aliases (switch.mgmt.lan, management.lan).
- Update fix-lan-zone to use this helper instead of the bare zone-only
  string.  Running fix-lan-zone now also pushes all saved hostnames.
- Update save_local_hostnames to push the updated local-lan-zone.conf
  to Unbound via SSH and reload if OPNsense SSH is configured, so
  adding/editing hostnames in the DNS tab takes effect immediately
  without a separate fix-lan-zone call.

https://claude.ai/code/session_01JR2EMK7rwrZJowpstcaxQ6
This commit is contained in:
Claude
2026-03-25 14:29:13 +00:00
parent d9b6d05862
commit da1d629e18
+52 -3
View File
@@ -2853,6 +2853,29 @@ 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.
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 these local-data lines every
.lan name that isn't listed gets NXDOMAIN — including pbx.lan and any
other custom hostname the user defined.
"""
lines = ['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:
""" """
@@ -2955,6 +2978,21 @@ 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,
@@ -2962,6 +3000,7 @@ 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"
@@ -3541,11 +3580,21 @@ def opnsense_unbound_fix_lan_zone():
raise HTTPException(503, "OPNsense SSH not configured") raise HTTPException(503, "OPNsense SSH not configured")
steps = [] steps = []
errors = [] errors = []
# Write the correct local-lan-zone.conf via SFTP # Build local-lan-zone.conf with all local-data records so custom .lan
lan_zone_conf = 'local-zone: "lan." static\n' # 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)
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("Wrote local-lan-zone.conf: local-zone \"lan.\" static") steps.append(
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))