From e3a2790acdfe5c7fb7706f60189015e12e9dc605 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 13:39:58 +0200 Subject: [PATCH] The resolver answers on an address systemd does not hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `127.0.0.54` is systemd-resolved's DNS *proxy* stub. The module asserted it was free, in a comment that read as reasoned — "not .53, that is systemd-resolved's" — and it was simply wrong: resolved holds both. dnsmasq could not create the socket and never started. Nothing in a unit test could have caught it. They checked the module names an address and that the asking modules point at the same one, and all of that passed while the daemon could not start. Only a machine knows which addresses are spare, which is the argument for proving a module that asserts facts about machines on a machine, before believing the assertions. So it moves to .55, and says what that is: a convention, not a reservation. If a future systemd takes it, this line changes and nothing else does. The tests now derive the address from the serving module and check the two asking modules agree with it, rather than naming it a fourth time — that fourth place is the one nobody would think to change. And the lab assigns `resolved-split-dns` rather than `resolv-conf`: those machines run systemd-resolved, which owns the file. The two claim the same thing precisely so the wrong choice is a refusal rather than a fight, and picking the wrong one was testing the fight. --- examples/modules/dnsmasq.json | 2 +- examples/modules/modules_test.go | 49 +++++++++++++++++++++--- examples/modules/resolv-conf.json | 2 +- examples/modules/resolved-split-dns.json | 2 +- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/examples/modules/dnsmasq.json b/examples/modules/dnsmasq.json index 15d2059..fffc274 100644 --- a/examples/modules/dnsmasq.json +++ b/examples/modules/dnsmasq.json @@ -15,7 +15,7 @@ {"id": "package", "type": "package", "package": "dnsmasq"}, {"id": "config", "type": "file", "path": "/etc/dnsmasq.conf", "mode": "0644", - "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n\n# What the mesh computed: one wildcard per machine, its name and everything\n# under it. Rewritten whenever a machine joins or leaves, which is why the\n# service below reflects it.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it — including a container\n# on this machine — can ask.\n# 127.0.0.54 this machine's own use. Not 127.0.0.1 and not 127.0.0.53:\n# the first is where everything else expects a resolver, and the\n# second is systemd-resolved's. Taking either would be this\n# module claiming something it did not say it claims.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.54\n\n# It answers for the mesh and forwards nothing it was not asked about. Names\n# outside the mesh are somebody else's business, and a resolver that answered\n# them would be this module taking over more than it claims.\ndomain-needed\nbogus-priv\n"}, + "content": "# Managed by the mesh. dnsmasq's own defaults are replaced whole rather than\n# patched, because this module owns the file and a patch would leave whatever\n# was there before to be discovered later.\n\n# What the mesh computed: one wildcard per machine, its name and everything\n# under it. Rewritten whenever a machine joins or leaves, which is why the\n# service below reflects it.\nconf-file=/etc/mesh-resolver/nodes.conf\n\n# Where it answers. Both are names the mesh chose, so this file needs to know\n# nothing about this particular machine:\n#\n# mesh0 the private network, so anything on it — including a container\n# on this machine — can ask.\n# 127.0.0.55 this machine's own use, for whatever points resolution at the\n# mesh. Not 127.0.0.1, where everything else expects a resolver.\n# Not .53 or .54 either: systemd-resolved holds BOTH — .53 is its\n# stub and .54 its proxy stub — which this module asserted was\n# free until a machine said otherwise.\n#\n# .55 is a convention and not a reservation. If a future systemd\n# takes it, this line changes and nothing else does, which is the\n# reason it is written once here rather than in each module that\n# points at it.\n#\n# bind-dynamic rather than bind-interfaces: mesh0 does not exist until the\n# machine is on the private network, and binding an interface that is not there\n# yet fails to start rather than waiting for it.\nbind-dynamic\ninterface=mesh0\nlisten-address=127.0.0.55\n\n# It answers for the mesh and forwards nothing it was not asked about. Names\n# outside the mesh are somebody else's business, and a resolver that answered\n# them would be this module taking over more than it claims.\ndomain-needed\nbogus-priv\n"}, {"id": "service", "type": "service", "unit": "dnsmasq.service", "state": "running", "boot": "enabled", diff --git a/examples/modules/modules_test.go b/examples/modules/modules_test.go index 530e862..622f089 100644 --- a/examples/modules/modules_test.go +++ b/examples/modules/modules_test.go @@ -89,13 +89,50 @@ func TestTheResolverNeedsToKnowNothingAboutItsMachine(t *testing.T) { t.Fatalf("it does not bind the private network's interface %q:\n%s", overlay.Interface, config) } - if !strings.Contains(config, "listen-address=127.0.0.54\n") { - t.Fatalf("it does not answer on the address the asking modules point at:\n%s", config) + // That it binds one, not which. Which address it is belongs in the manifests, where the + // asking modules can be checked against it — naming it here too would be a fourth place to + // keep in step, and the one nobody would think to change. + if !strings.Contains(config, "\nlisten-address=127.0.0.") { + t.Fatalf("it answers on no address for the machine's own use:\n%s", config) } - // Not the two addresses that belong to something else. - for _, taken := range []string{"listen-address=127.0.0.1", "listen-address=127.0.0.53"} { - if strings.Contains(config, taken) { - t.Fatalf("it takes %q, which belongs to something it did not claim:\n%s", taken, config) + // Not an address that belongs to something else. + // + // **systemd-resolved holds .53 AND .54** — the stub and the proxy stub. This module asserted + // .54 was free, in a comment that read as reasoned, and a machine said otherwise: dnsmasq + // could not start at all. A unit test cannot know which addresses a machine has spare, but it + // can hold on to what one has already told us. + for _, taken := range []string{"127.0.0.1", "127.0.0.53", "127.0.0.54"} { + if strings.Contains(config, "listen-address="+taken) { + t.Fatalf("it takes %s, which belongs to something else:\n%s", taken, config) + } + } +} + +// Everything that points resolution at the mesh points at the same place. +// +// Three files name this address — one binds it and two send queries to it — and a change to one +// of them alone is a resolver answering where nobody asks. +func TestTheAskingModulesPointAtWhereTheResolverAnswers(t *testing.T) { + serving := read(t, "dnsmasq.json").Resources[1]["content"].(string) + var at string + for _, line := range strings.Split(serving, "\n") { + if rest, found := strings.CutPrefix(strings.TrimSpace(line), "listen-address="); found { + at = rest + } + } + if at == "" { + t.Fatal("the resolver binds no address for the machine's own use") + } + for _, asking := range []string{"resolved-split-dns.json", "resolv-conf.json"} { + m := read(t, asking) + var mentions bool + for _, r := range m.Resources { + if content, ok := r["content"].(string); ok && strings.Contains(content, at) { + mentions = true + } + } + if !mentions { + t.Fatalf("%s does not point at %s, where the resolver answers", asking, at) } } } diff --git a/examples/modules/resolv-conf.json b/examples/modules/resolv-conf.json index 5631573..d7b161f 100644 --- a/examples/modules/resolv-conf.json +++ b/examples/modules/resolv-conf.json @@ -7,6 +7,6 @@ "resources": [ {"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver first, because it answers only the mesh's names and\n# forwards nothing: a query it does not recognise falls through to the next\n# line rather than being answered wrongly.\nnameserver 127.0.0.54\n\n# And what this machine used before. Replace this line with the resolver this\n# machine should use for everything that is not the mesh — it is not the mesh's\n# to choose, and a public one written here by default would send every query\n# this machine makes somewhere nobody agreed to.\nnameserver 127.0.0.53\n"} + "content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver first, because it answers only the mesh's names and\n# forwards nothing: a query it does not recognise falls through to the next\n# line rather than being answered wrongly.\nnameserver 127.0.0.55\n\n# And what this machine used before. Replace this line with the resolver this\n# machine should use for everything that is not the mesh — it is not the mesh's\n# to choose, and a public one written here by default would send every query\n# this machine makes somewhere nobody agreed to.\nnameserver 127.0.0.53\n"} ] } diff --git a/examples/modules/resolved-split-dns.json b/examples/modules/resolved-split-dns.json index cbe99ea..dd3ea6d 100644 --- a/examples/modules/resolved-split-dns.json +++ b/examples/modules/resolved-split-dns.json @@ -10,7 +10,7 @@ {"id": "route", "type": "file", "path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644", - "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims.\n#\n# 127.0.0.54 is where the mesh's resolver answers on every machine — a name the\n# mesh chose, so this file needs to know nothing about this particular one.\n[Resolve]\nDNS=127.0.0.54\nDomains=~internal\n"}, + "content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims.\n#\n# 127.0.0.55 is where the mesh's resolver answers on every machine — a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54, which is why it is neither.\n[Resolve]\nDNS=127.0.0.55\nDomains=~internal\n"}, {"id": "resolved", "type": "service", "unit": "systemd-resolved.service", "state": "running", "boot": "enabled", "restart-on": ["route"]}