From da1d629e18d0e0237ca1fcab4dbbd9cab6142652 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 14:29:13 +0000 Subject: [PATCH] Fix .lan NXDOMAIN for custom hostnames (pbx.lan, nas.lan, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- switch_backend.py | 55 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/switch_backend.py b/switch_backend.py index cad4b82..fce77d4 100644 --- a/switch_backend.py +++ b/switch_backend.py @@ -2853,6 +2853,29 @@ 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. + + 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", dnsmasq_port: int = 5353) -> str: """ @@ -2955,6 +2978,21 @@ 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, @@ -2962,6 +3000,7 @@ 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" @@ -3541,11 +3580,21 @@ def opnsense_unbound_fix_lan_zone(): raise HTTPException(503, "OPNsense SSH not configured") steps = [] errors = [] - # Write the correct local-lan-zone.conf via SFTP - lan_zone_conf = 'local-zone: "lan." static\n' + # 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) try: _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: errors.append(f"Write local-lan-zone.conf: {e}") raise HTTPException(500, "; ".join(errors))