From c88be88ccf2fb2addaf40ab117156c395abbc61b Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 8 Nov 2026 23:48:59 +0100 Subject: [PATCH] dnsmasq's public upstream resolvers are config values, defaulting to the two it always used The two server lines were literals in the template, so changing an upstream needed a build. They are now the config keys upstream-resolver-first (1.1.1.1) and upstream-resolver-second (8.8.8.8), placed with ${config:...}; the service already restarts on its config file. no-resolv and every other line stay. Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5 --- .../cmd/dnsmasq-tools/templates_test.go | 40 +++++++++++++++++++ modules/dnsmasq/files/dnsmasq.conf | 6 +-- modules/dnsmasq/module.json | 16 ++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go b/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go index 2536a2e14..45edf18c2 100644 --- a/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go +++ b/modules/dnsmasq/cmd/dnsmasq-tools/templates_test.go @@ -122,3 +122,43 @@ func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) { t.Fatal("mesh.conf has no ExecStartPre") } } + +// The two public upstreams are the module's settings, defaulting to the two the resolver always used, placed +// as server lines beside no-resolv: a person sets them per node or mesh-wide without a build. +func TestUpstreamsAreConfigValues(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + Config map[string]struct { + Default string `json:"default"` + Pattern string `json:"pattern"` + } `json:"config"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + conf, err := os.ReadFile(filepath.Join(moduleDir, "files", "dnsmasq.conf")) + if err != nil { + t.Fatal(err) + } + for key, def := range map[string]string{"upstream-resolver-first": "1.1.1.1", "upstream-resolver-second": "8.8.8.8"} { + c, ok := m.Config[key] + if !ok || c.Default != def { + t.Errorf("config %s = %+v, want default %s", key, c, def) + } + if !regexp.MustCompile(c.Pattern).MatchString(def) { + t.Errorf("pattern %q refuses the default %s", c.Pattern, def) + } + if !strings.Contains(string(conf), "\nserver=${config:"+key+"}\n") { + t.Errorf("dnsmasq.conf does not place %s as a server line", key) + } + } + if strings.Contains(string(conf), "server=1.1.1.1") || strings.Contains(string(conf), "server=8.8.8.8") { + t.Error("dnsmasq.conf still hard-codes an upstream") + } + if !strings.Contains(string(conf), "\nno-resolv\n") { + t.Error("dnsmasq.conf lost no-resolv") + } +} diff --git a/modules/dnsmasq/files/dnsmasq.conf b/modules/dnsmasq/files/dnsmasq.conf index 10f3926af..0169182e3 100644 --- a/modules/dnsmasq/files/dnsmasq.conf +++ b/modules/dnsmasq/files/dnsmasq.conf @@ -57,12 +57,12 @@ listen-address=127.0.0.1 # upstreams would find itself, and every query it could not answer locally would # loop until its receive queue filled. That is not theoretical: it filled with # 15KB of queries and every lookup on the machine hung. no-resolv makes that loop -# impossible: the upstreams are the two lines below, and nothing on the machine +# impossible: the upstreams are the two lines below (the module's settings upstream-resolver-first and -second), and nothing on the machine # can redirect them. The mesh's own names never reach them: the local= line in # the file above stops them here, answered or refused. no-resolv -server=1.1.1.1 -server=8.8.8.8 +server=${config:upstream-resolver-first} +server=${config:upstream-resolver-second} # Both upstreams validate DNSSEC and say so with the Authenticated Data bit, # and this passes that bit down rather than dropping it, which dnsmasq does diff --git a/modules/dnsmasq/module.json b/modules/dnsmasq/module.json index 7e8320673..75447825a 100644 --- a/modules/dnsmasq/module.json +++ b/modules/dnsmasq/module.json @@ -38,6 +38,22 @@ "dnsmasq_names", "dnsmasq_resolve" ], + "config": { + "upstream-resolver-first": { + "type": "string", + "description": "The first public resolver this resolver forwards to for every name that is not the mesh's own, as an IP address. It is placed as a server line beside no-resolv, so it must never be an address that resolves through this machine; both upstreams should validate DNSSEC, since the authenticated-data bit is passed down (ADR 0194, ADR 0223). Set per node or for the whole mesh.", + "trusted": false, + "pattern": "^[0-9A-Fa-f.:]+$", + "default": "1.1.1.1" + }, + "upstream-resolver-second": { + "type": "string", + "description": "The second public resolver this resolver forwards to, as an IP address, tried beside the first so one upstream being down does not stop name resolution. The same rules as upstream-resolver-first.", + "trusted": false, + "pattern": "^[0-9A-Fa-f.:]+$", + "default": "8.8.8.8" + } + }, "listens": [ { "name": "dns-udp", -- 2.54.0