Files
mesh-controller/examples/modules/modules_test.go
T
jschoubben ab1dd34d12 The resolver does not ask itself for upstreams
dnsmasq read /etc/resolv.conf to find where to forward. Whatever points a
machine at the mesh writes its own address into that file — so dnsmasq's
upstream was dnsmasq, and every query it could not answer locally looped. Its
receive queue filled with 15KB of them and every lookup on the machine hung,
which is why this arrived as a thirty-second timeout rather than a wrong
answer.

It needs no upstream at all: the asking module routes only the mesh's suffix
here and leaves everything else where the machine already sent it. And it names
none, because choosing one would send every query this machine makes somewhere
nobody agreed to.

Also corrected: the comment claiming it takes only 127.0.0.55. Listening on a
loopback address makes dnsmasq take the rest of loopback with it, 127.0.0.1
included — which is what claiming `the-dns-port` already says, and which the
comment was quietly denying. That is the same comfortable claim as ".54 is
free", in the same file, made twice.
2026-08-31 14:09:50 +02:00

217 lines
8.0 KiB
Go

package modules
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-control/internal/catalogue"
"github.com/novox/mesh-control/internal/overlay"
)
// The examples are manifests, so the thing to check is that the catalogue accepts them.
//
// A manifest that only ever appears in a document is a manifest nobody has run through the parser,
// and the parser refuses unknown keys — so a typo here would be discovered by whoever first tried
// to use one, which is the opposite of what an example is for.
func read(t *testing.T, name string) catalogue.Manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Fatal(err)
}
m, err := catalogue.ParseManifest(raw)
if err != nil {
t.Fatalf("%s is not a manifest this mesh accepts: %v", name, err)
}
return m
}
func TestEveryExampleIsAManifestTheMeshAccepts(t *testing.T) {
found, err := filepath.Glob("*.json")
if err != nil {
t.Fatal(err)
}
if len(found) == 0 {
t.Fatal("no examples, so this test proves nothing")
}
for _, name := range found {
read(t, name)
}
}
// The serving module reads what the mesh writes, and restarts when the mesh rewrites it.
//
// Without the second it would serve the names it started with for ever — every machine that
// joined afterwards unreachable by name, and every check passing.
func TestTheResolverReadsTheMeshsNamesAndFollowsThem(t *testing.T) {
m := read(t, "dnsmasq.json")
var config, service map[string]any
for _, r := range m.Resources {
switch r["id"] {
case "config":
config = r
case "service":
service = r
}
}
if config == nil || service == nil {
t.Fatal("the module has no configuration or no service")
}
if !strings.Contains(config["content"].(string), overlay.ResolverPath) {
t.Fatalf("it does not read what the mesh writes at %s", overlay.ResolverPath)
}
var follows bool
for _, id := range service["restart-on"].([]any) {
if id.(string) == overlay.Resolver+".nodes" {
follows = true
}
}
if !follows {
t.Fatalf("it does not restart when the mesh rewrites the names: %v", service["restart-on"])
}
}
// It binds names the mesh chose, so it needs to know nothing about the machine it is on.
//
// That is the whole reason these can be static manifests: a resolver must bind somewhere and a
// stub must be pointed somewhere, and neither address is knowable in advance — unless the mesh
// named it.
func TestTheResolverNeedsToKnowNothingAboutItsMachine(t *testing.T) {
config := read(t, "dnsmasq.json").Resources[1]["content"].(string)
// The directive, not the word: the comment above it names the interface too, so a plain
// Contains passes whatever the module actually binds. It did.
if !strings.Contains(config, "interface="+overlay.Interface+"\n") {
t.Fatalf("it does not bind the private network's interface %q:\n%s",
overlay.Interface, 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 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)
}
}
}
// The two ways of deciding what a machine asks claim the same thing, so the mesh refuses the pair.
func TestTwoWaysOfOwningTheResolverCannotBothBeAssigned(t *testing.T) {
shelf := map[string]catalogue.Manifest{}
for _, name := range []string{"resolved-split-dns.json", "resolv-conf.json", "dnsmasq.json"} {
m := read(t, name)
shelf[m.Module] = m
}
// Something has to answer `wildcard-resolution`, or they are refused for that instead and the
// test would pass without ever reaching the claim.
shelf["dnsmasq"] = read(t, "dnsmasq.json")
_, err := catalogue.Resolve(shelf,
[]string{"dnsmasq", "resolved-split-dns", "resolv-conf"},
catalogue.Node{Name: "anchor", Capabilities: map[string]bool{}},
catalogue.World{Unchecked: true})
if err == nil {
t.Fatal("both ways of owning the resolver were assigned to one machine")
}
said := err.Error()
if !strings.Contains(said, "the-resolver-configuration") {
t.Fatalf("the refusal does not name what they both want: %v", said)
}
}
// And the two roles are not the same claim: a machine runs one resolver AND one thing deciding
// what it asks, so serving and asking must be assignable together.
func TestServingAndAskingAreAssignableTogether(t *testing.T) {
shelf := map[string]catalogue.Manifest{}
for _, name := range []string{"dnsmasq.json", "resolved-split-dns.json"} {
m := read(t, name)
shelf[m.Module] = m
}
if _, err := catalogue.Resolve(shelf,
[]string{"dnsmasq", "resolved-split-dns"},
catalogue.Node{Name: "anchor", Capabilities: map[string]bool{}},
catalogue.World{Unchecked: true}); err != nil {
t.Fatalf("a resolver and the thing pointing at it cannot both be assigned: %v", err)
}
}
// The examples are JSON a person edits, so a stray comma is worth catching here rather than on a
// machine.
func TestTheExamplesAreWellFormed(t *testing.T) {
found, _ := filepath.Glob("*.json")
for _, name := range found {
raw, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
var any map[string]any
if err := json.Unmarshal(raw, &any); err != nil {
t.Fatalf("%s is not JSON: %v", name, err)
}
}
}
// The resolver must not look up its own upstreams.
//
// Whatever points a machine at the mesh writes that address into resolv.conf, so a resolver that
// read it would find itself — and every query it could not answer locally would loop until its
// receive queue filled. It did: 15KB of queries backed up and every lookup on the machine hung.
//
// It needs no upstream because it is never asked for anything else: the asking module routes only
// the mesh's suffix here and leaves the rest where the machine already sent it.
func TestTheResolverDoesNotAskItselfForUpstreams(t *testing.T) {
config := read(t, "dnsmasq.json").Resources[1]["content"].(string)
if !strings.Contains(config, "\nno-resolv\n") {
t.Fatalf("it reads resolv.conf for upstreams, which now points at itself:\n%s", config)
}
// And names no upstream of its own: choosing one would send every query this machine cannot
// answer somewhere nobody agreed to.
for _, line := range strings.Split(config, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "server=") {
t.Fatalf("it forwards to %q, which is not the mesh's to choose", line)
}
}
}