From eb245b1d5100aea2895f044a0cc93b20a4167e60 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Wed, 28 Oct 2026 11:07:07 +0100 Subject: [PATCH] dnsmasq: forward outside names (systemd-resolved stub or resolv.conf) Debian's dnsmasq looks for /run/dnsmasq/resolv.conf, which only resolvconf writes; without it every name outside the cluster domain was refused, so nodes could not resolve mirrors or site services. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ cmd/rollcall/network_test.go | 19 +++++++++++++ cmd/rollcall/rackctl.go | 52 +++++++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2201dc4..f8cec6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ publish a tag that has no section here. - Release notes from `CHANGELOG.md`. ### Fixed +- Rack controllers' dnsmasq had no upstream DNS on Debian (it looks for a resolvconf file that is + usually missing) and refused every name outside the cluster domain; the generated configuration + now forwards to systemd-resolved's stub (keeping split DNS) or the host's resolv.conf servers. - Virtual BMC addresses are configured in netplan on the lab PXE server (they were lost on a network reconfigure, which made every BMC call fail). - Commissioning console no longer shows a failing autologin (`noautologin`). diff --git a/cmd/rollcall/network_test.go b/cmd/rollcall/network_test.go index 9792a0d..279aa5d 100644 --- a/cmd/rollcall/network_test.go +++ b/cmd/rollcall/network_test.go @@ -2,6 +2,8 @@ package main import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" ) @@ -224,3 +226,20 @@ func TestDnsmasqIPv6(t *testing.T) { t.Errorf("router lifetime 0 although the rack is the gateway:\n%s", conf) } } + +func TestDnsmasqUpstream(t *testing.T) { + d := t.TempDir() + stub, resolv := filepath.Join(d, "stub"), filepath.Join(d, "resolv") + if got := dnsmasqUpstream(stub, resolv, nil); got != "" { + t.Errorf("no files: %q", got) + } + _ = os.WriteFile(resolv, []byte("# x\nnameserver 127.0.0.1\nnameserver 10.213.77.1\nnameserver 10.0.0.1\nnameserver 1.1.1.1\nsearch lan\n"), 0o644) + got := dnsmasqUpstream(stub, resolv, []string{"10.213.77.1/24"}) + if !strings.Contains(got, "no-resolv\nserver=10.0.0.1\nserver=1.1.1.1\n") || strings.Contains(got, "127.0.0.1") || strings.Contains(got, "10.213.77.1") { + t.Errorf("resolv.conf upstream:\n%s", got) + } + _ = os.WriteFile(stub, []byte("nameserver 127.0.0.53\n"), 0o644) + if got := dnsmasqUpstream(stub, resolv, nil); !strings.Contains(got, "server=127.0.0.53") { + t.Errorf("resolved stub: %q", got) + } +} diff --git a/cmd/rollcall/rackctl.go b/cmd/rollcall/rackctl.go index 8c4f13c..b8687ee 100644 --- a/cmd/rollcall/rackctl.go +++ b/cmd/rollcall/rackctl.go @@ -590,7 +590,7 @@ func applyRackState(st *rackState, tftp, dnsDir string) (changed []string, err e note("hosts", ch, e) conf := filepath.Join(dnsDir, "conf.d", "rollcall.conf") if st.DnsmasqConf != "" { - ch, e = writeIfChanged(conf, st.DnsmasqConf, 0o644) + ch, e = writeIfChanged(conf, st.DnsmasqConf+dnsmasqUpstream("/run/systemd/resolve/stub-resolv.conf", "/etc/resolv.conf", localAddrs()), 0o644) note("dnsmasq.conf", ch, e) } else if _, e := os.Stat(conf); e == nil { note("-dnsmasq.conf", true, os.Remove(conf)) @@ -873,3 +873,53 @@ func (a *App) handleRackDHCP(w http.ResponseWriter, r *http.Request) { } fmt.Fprint(w, st.DnsmasqConf, "\n# --- hosts.dhcp\n", st.HostsDHCP) } + +// dnsmasqUpstream tells dnsmasq where to forward names it does not answer +// itself. Without it Debian's dnsmasq looks for a resolvconf-made file that +// usually does not exist and refuses every outside name, so nodes could not +// resolve mirrors or site services. With systemd-resolved the stub is used, so +// its per-link routing (split DNS such as ~int.example.com) keeps working; +// otherwise the host's resolv.conf servers, never ourselves. +func dnsmasqUpstream(stub, resolv string, own []string) string { + if _, err := os.Stat(stub); err == nil { + return "# upstream: systemd-resolved on this host (keeps its split DNS)\nno-resolv\nserver=127.0.0.53\n" + } + b, err := os.ReadFile(resolv) + if err != nil { + return "" + } + mine := map[string]bool{} + for _, a := range own { + mine[stripPrefix(a)] = true + } + var servers []string + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 2 || f[0] != "nameserver" { + continue + } + ip, ok := parseAddr(f[1]) + if !ok || mine[ip.String()] || (ip.IsLoopback() && ip.String() != "127.0.0.53") { + continue + } + servers = append(servers, ip.String()) + } + if len(servers) == 0 { + return "" + } + out := "# upstream: this host's resolv.conf\nno-resolv\n" + for _, sv := range servers { + out += "server=" + sv + "\n" + } + return out +} + +// localAddrs: this host's interface addresses (CIDR form). +func localAddrs() []string { + var out []string + addrs, _ := net.InterfaceAddrs() + for _, a := range addrs { + out = append(out, a.String()) + } + return out +}