From fcdb065660851b17f2bcf1f558a782146f45f539 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 21:31:18 +0200 Subject: [PATCH] The mesh's knowledge is a fact a module asks for, not three modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-names, mesh-resolver and the names half of the overlay generators are gone. They ran no software and could not be swapped for anything, which is the test of whether something is a module at all — they existed because computed output needed somewhere to live, and the control plane's only shape for output was a module. Now a module says where it wants what the mesh knows: facts: { node-zones: /etc/mesh-resolver/nodes.conf } and is given a file, under its own name, applied and removed like anything else it declares. Two facts exist: node-names (a hosts file — exact names) and node-zones (every machine as a wildcard, *.homer.internal is homer). Asking for a fact the mesh does not compute is refused naming what would have worked, because a daemon that starts and reads a file nobody wrote is a worse way to find out. The names ride with the network now: wireguard's manifest asks for node-names into /etc/hosts, because being on the private network is what gives a machine a name. networking no longer requires name-resolution — names are not a provision, and the module that answered it ran nothing. One behaviour inverted, deliberately: choosing another VPN used to drag WireGuard in anyway, because only WireGuard provided the addressing the names module required — the node-scope claim existed to at least make that loud. With names as a fact there is nothing to drag in: tailscale assigned means tailscale, alone. The claim still catches two VPNs assigned explicitly. And a machine the mesh cannot place is left out of both files rather than named at nothing: a name resolving to nothing hangs a connection, where an unknown name fails at once and says so. In practice that is only ever a token issued and not yet used — a machine that has announced itself has an address. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- cmd/mesh-control/modules.go | 8 +- cmd/mesh-control/network.go | 11 +- examples/modules/dnsmasq.json | 10 +- examples/modules/modules_test.go | 9 +- internal/catalogue/declaration.go | 13 ++ internal/catalogue/facts.go | 145 +++++++++++++++++++++++ internal/catalogue/facts_test.go | 97 +++++++++++++++ internal/catalogue/manifest.go | 19 +++ internal/catalogue/provided_test.go | 58 +++++---- internal/overlay/generator.go | 80 ++----------- internal/overlay/names_generator_test.go | 61 ---------- internal/overlay/resolver.go | 82 ------------- internal/overlay/resolver_test.go | 99 ---------------- 13 files changed, 348 insertions(+), 344 deletions(-) create mode 100644 internal/catalogue/facts.go create mode 100644 internal/catalogue/facts_test.go delete mode 100644 internal/overlay/names_generator_test.go delete mode 100644 internal/overlay/resolver.go delete mode 100644 internal/overlay/resolver_test.go diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index 96e2c3b..cc09f3e 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -36,8 +36,12 @@ import ( func providedModules() []catalogue.Manifest { var out []catalogue.Manifest for _, raw := range []map[string]any{ - overlay.Manifest(), overlay.NamesManifest(), overlay.ResolverManifest(), - overlay.DomainManifest(), + // **Two used to be here and are gone**: one wrote the mesh's names into a hosts file, the + // other wrote the same machines as wildcards for a resolver to read. Neither ran software + // and neither could be swapped for anything, which is the test of whether a thing is a + // module at all (novox/hq ADR 0040). They existed because computed output needed somewhere + // to live, and now a module says where it wants it — `facts` in its own manifest. + overlay.Manifest(), overlay.DomainManifest(), } { var m catalogue.Manifest b, _ := json.Marshal(raw) diff --git a/cmd/mesh-control/network.go b/cmd/mesh-control/network.go index c1a2d82..c031f5f 100644 --- a/cmd/mesh-control/network.go +++ b/cmd/mesh-control/network.go @@ -231,12 +231,13 @@ func generators(ctx context.Context, open *stores) ( if err != nil { return nil, err } - // Both generators see the same machines: the ones on the private network. Names for a machine - // that is not on it would resolve to addresses it cannot reach, which is worse than no names. + // **One generator now.** Two more used to sit beside it — the names and a resolver's zone + // file — as modules that ran nothing. Both are facts a module asks for in its manifest + // (`facts:` — catalogue.FactsInto), computed from the same machines this sees: the ones on the + // private network, because a name for a machine not on it would resolve to an address nothing + // can reach. return map[string]catalogue.Generator{ - overlay.Name: net, - overlay.Names: overlay.NamesFor(net.Nodes()), - overlay.Resolver: overlay.ResolverFor(net.Nodes()), + overlay.Name: net, }, nil } diff --git a/examples/modules/dnsmasq.json b/examples/modules/dnsmasq.json index 79d311d..87ec177 100644 --- a/examples/modules/dnsmasq.json +++ b/examples/modules/dnsmasq.json @@ -1,9 +1,6 @@ { "module": "dnsmasq", "version": "1", - "requires": [ - "resolver-data" - ], "provides": [ "wildcard-resolution" ], @@ -43,8 +40,11 @@ "boot": "enabled", "restart-on": [ "config", - "mesh-resolver.nodes" + "dnsmasq.fact-node-zones" ] } - ] + ], + "facts": { + "node-zones": "/etc/mesh-resolver/nodes.conf" + } } diff --git a/examples/modules/modules_test.go b/examples/modules/modules_test.go index fcaca21..72d93c1 100644 --- a/examples/modules/modules_test.go +++ b/examples/modules/modules_test.go @@ -64,13 +64,16 @@ func TestTheResolverReadsTheMeshsNamesAndFollowsThem(t *testing.T) { 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) + // The zone file is a FACT the module asks for, at a path it chose. The mesh writes it there; + // what reads it and how is this module's own business, which is the whole shape. + const zones = "/etc/mesh-resolver/nodes.conf" + if !strings.Contains(config["content"].(string), zones) { + t.Fatalf("it does not read what the mesh writes at %s", zones) } var follows bool for _, id := range service["restart-on"].([]any) { - if id.(string) == overlay.Resolver+".nodes" { + if id.(string) == "dnsmasq.fact-node-zones" { follows = true } } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 707a086..c99cb26 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -498,6 +498,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } out = append(out, copied) } + + // **What only the mesh knows, where this module asked for it.** The graph is the control + // plane's; making a name resolve is the module's software. Emitted as ordinary files under + // this module's name, so they are applied, reported and removed exactly as anything else + // it declares. + given, err := FactsInto(m, r, with.Names) + if err != nil { + return nil, err + } + for _, fact := range given { + fact["id"] = m.Module + "." + fmt.Sprint(fact["id"]) + out = append(out, fact) + } } return out, nil } diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go new file mode 100644 index 0000000..d16c59c --- /dev/null +++ b/internal/catalogue/facts.go @@ -0,0 +1,145 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// What only the mesh knows, written where a module asks for it. +// +// **The graph is the control plane's; using it is the module's.** The mesh knows which machines +// exist, what they are called, and where they are. Turning that into a name that resolves is +// somebody's software, and which software is a choice the mesh should not be making. +// +// This replaced three modules — names, a resolver's data, and the private network's own +// configuration — that existed only because computed output needed somewhere to live. They ran no +// software and could not be swapped for anything, which is the test of whether something is a +// module at all (novox/hq ADR 0040). + +const ( + // FactNodeNames is every machine's name and address, as a hosts file. + // + // Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine + // is a wildcard, which a hosts file cannot express — that is FactNodeZones. + FactNodeNames = "node-names" + + // FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer. + // + // Written in the form a resolver reads. A machine's own name and everything under it are one + // fact — if homer is at an address, so is anything homer serves. + FactNodeZones = "node-zones" +) + +// facts is every fact the mesh computes, and what writes it. +// +// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody +// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and +// answers no queries — is worse than being told where the manifest is. +var facts = map[string]func(Resolution, map[string]string) string{ + FactNodeNames: nodeNames, + FactNodeZones: nodeZones, +} + +// FactsInto renders the facts a module asked for, as files it will be given. +// +// The module owns everything after the file exists: loading it, restarting on it, what a resolver +// does with it. This only puts it there. +func FactsInto(m Manifest, r Resolution, addresses map[string]string) ([]map[string]any, error) { + if len(m.Facts) == 0 { + return nil, nil + } + names := make([]string, 0, len(m.Facts)) + for name := range m.Facts { + names = append(names, name) + } + sort.Strings(names) + + out := make([]map[string]any, 0, len(names)) + for _, name := range names { + write, known := facts[name] + if !known { + return nil, fmt.Errorf( + "%s asks the mesh for %q, which it does not compute. It has %s", + m.Module, name, spokenFacts()) + } + path := m.Facts[name] + if !strings.HasPrefix(path, "/") { + return nil, fmt.Errorf( + "%s asks for %q at %q, which is not an absolute path", m.Module, name, path) + } + out = append(out, map[string]any{ + "id": "fact-" + name, "type": "file", "path": path, "mode": "0644", + "content": write(r, addresses), + }) + } + return out, nil +} + +// spokenFacts lists them, so a refusal says what would have worked. +func spokenFacts() string { + names := make([]string, 0, len(facts)) + for name := range facts { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +// nodeNames is every machine's name and address, as a hosts file. +// +// **A machine with no address is left out.** The mesh has a record for it — somebody added it — +// and does not yet know where it is, which is the ordinary state between adding a machine and it +// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to +// that hangs; leaving it out fails at once and says the name is unknown. +func nodeNames(r Resolution, addresses map[string]string) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") + b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") + // The floor every Linux expects, and which removing would break things that have nothing to do + // with the mesh. + b.WriteString("127.0.0.1\tlocalhost\n") + b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n") + if r.Node != "" { + fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node) + } + b.WriteString("\n") + for _, name := range sortedNames(addresses) { + at := addresses[name] + // Its mesh name resolves to its address on the private network rather than to loopback, + // so a service binding the name it was given stays reachable from everywhere else. + fmt.Fprintf(&b, "%s\t%s.internal\t%s", at, name, name) + if name == r.Node { + b.WriteString("\t# this machine") + } + b.WriteString("\n") + } + return b.String() +} + +// nodeZones is every machine as a wildcard, in the form a resolver reads. +// +// `*.homer.internal` is homer, which is the whole rule: if homer is at an address, so is anything +// homer serves. A module wanting this runs the resolver; the mesh only says what is true. +func nodeZones(_ Resolution, addresses map[string]string) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") + b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") + for _, name := range sortedNames(addresses) { + fmt.Fprintf(&b, "address=/%s.internal/%s\n", name, addresses[name]) + } + return b.String() +} + +func sortedNames(addresses map[string]string) []string { + out := make([]string, 0, len(addresses)) + for name, at := range addresses { + // See nodeNames: a machine the mesh cannot place is left out rather than named at nothing. + if at == "" { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go new file mode 100644 index 0000000..b543768 --- /dev/null +++ b/internal/catalogue/facts_test.go @@ -0,0 +1,97 @@ +package catalogue + +import ( + "strings" + "testing" +) + +var threeMachines = map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2", "bart": ""} + +// **`*.homer.internal` is homer. That is the whole rule.** +func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) { + out := nodeZones(Resolution{Node: "homer"}, threeMachines) + for _, want := range []string{ + "address=/homer.internal/10.42.0.1", + "address=/marge.internal/10.42.0.2", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q:\n%s", want, out) + } + } +} + +// A machine the mesh has a record for and cannot place is left out of both. +// +// **Not an oversight — the alternative is worse.** A name written with no address resolves to +// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is +// unknown, which is a thing somebody can act on. +func TestAMachineWithNoAddressIsNotNamed(t *testing.T) { + for _, out := range []string{ + nodeNames(Resolution{Node: "homer"}, threeMachines), + nodeZones(Resolution{Node: "homer"}, threeMachines), + } { + if strings.Contains(out, "bart") { + t.Fatalf("a machine with no address was named, so its name resolves to nothing:\n%s", out) + } + } +} + +// A machine's own mesh name points at its address on the private network, not at loopback — or a +// service binding the name it was given is unreachable from everywhere else. +func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) { + out := nodeNames(Resolution{Node: "homer"}, threeMachines) + var line string + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "homer.internal") { + line = l + } + } + if !strings.HasPrefix(line, "10.42.0.1") { + t.Fatalf("a machine's own mesh name is not its mesh address: %q", line) + } + // And the loopback floor is still there, or things with nothing to do with the mesh break. + if !strings.Contains(out, "127.0.0.1\tlocalhost") { + t.Fatalf("the loopback floor was removed:\n%s", out) + } +} + +// A module says where it wants a fact, and is given a file. +func TestAModuleIsGivenTheFactsItAskedFor(t *testing.T) { + m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeZones: "/etc/mesh/zones.conf"}} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines) + if err != nil { + t.Fatal(err) + } + if len(given) != 1 { + t.Fatalf("expected one file, got %d", len(given)) + } + if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { + t.Fatalf("not written where it was asked for: %v", given[0]) + } + if !strings.Contains(given[0]["content"].(string), "homer.internal") { + t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) + } +} + +// **Asking for a fact the mesh does not have is refused here, not on a machine.** A daemon that +// starts, reads a file nobody wrote, and answers no queries is a much worse way to find out. +func TestAskingForAFactTheMeshDoesNotHaveIsRefused(t *testing.T) { + m := Manifest{Module: "dnsmasq", Facts: map[string]string{"the-weather": "/etc/weather"}} + _, err := FactsInto(m, Resolution{}, nil) + if err == nil { + t.Fatal("a module asked for something nobody computes and was given nothing, silently") + } + for _, known := range []string{FactNodeNames, FactNodeZones} { + if !strings.Contains(err.Error(), known) { + t.Fatalf("the refusal does not say what would have worked: %v", err) + } + } +} + +// And a relative path is refused, or a module decides where the mesh writes on a machine. +func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { + m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeNames: "etc/hosts"}} + if _, err := FactsInto(m, Resolution{}, nil); err == nil { + t.Fatal("a relative path was accepted") + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 7f6eb9c..03ba034 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -315,6 +315,25 @@ type Manifest struct { // that could only see its own ports would write a rule set that closed everything else. Filtering *Filtering `json:"filtering,omitempty"` + // Facts are things only the mesh knows, written where this module asks for them. + // + // **The graph is the control plane's; how a machine uses it is the module's.** The mesh knows + // which machines exist, what they are called and where they are. Making a name resolve, or a + // peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no + // business shipping one, choosing which, or knowing its configuration language. + // + // So a module says *put the node names here* and owns everything after that. The same shape as + // `filtering`, generalised: a fact, and a path. + // + // It replaces three modules that existed only because computed output needed somewhere to + // live — they ran no software, could not be swapped for anything, and appeared in the graph as + // modules while being a data channel wearing a costume. + // + // Keyed by fact name; the names are a closed list, because a module asking for one the mesh + // does not compute is asking for something nobody will write, and finding that out on a machine + // is worse than being told here. + Facts map[string]string `json:"facts,omitempty"` + // Certificate is where this module wants a certificate for its machine's name inside the // mesh, and where the key that goes with it can be found. // diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index b6ea4be..12d9a09 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -19,7 +19,7 @@ func provided(t *testing.T) map[string]catalogue.Manifest { t.Helper() out := map[string]catalogue.Manifest{} for _, raw := range []map[string]any{ - overlay.Manifest(), overlay.NamesManifest(), overlay.DomainManifest(), + overlay.Manifest(), overlay.DomainManifest(), } { b, err := json.Marshal(raw) if err != nil { @@ -44,22 +44,51 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { for _, m := range got.Modules { have = append(have, m.Module) } - for _, want := range []string{overlay.Domain, overlay.Name, overlay.Names} { + for _, want := range []string{overlay.Domain, overlay.Name} { if !strings.Contains(strings.Join(have, " "), want) { t.Fatalf("%s did not bring in %s: %v", overlay.Domain, want, have) } } + + // **The names come WITH the network now, not from a third module.** Being on the private + // network is what gives a machine a name, so the provider asks for the node-names fact and + // there is nothing else to bring in. A module that ran nothing used to be here. + for _, m := range got.Modules { + if m.Module == overlay.Name && m.Facts["node-names"] == "" { + t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts) + } + } } -func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) { - // Without this, a person who chose another VPN gets WireGuard as well, dragged in by the - // names, and is not told. The claim is the only thing that catches it. +func TestAnotherVPNSatisfiesNetworkingWithoutDraggingWireGuardIn(t *testing.T) { + // **This inverted, and the inversion is the improvement.** The names used to be a module that + // required the mesh's own addressing, which only WireGuard provided — so choosing another VPN + // dragged WireGuard in anyway, and the node-scoped claim existed to at least make that + // collision loud. With the names a fact rather than a provision, a person who chose tailscale + // gets tailscale, and there is nothing left to collide. shipped := provided(t) - _, err := catalogue.Resolve( + got, err := catalogue.Resolve( withTailscale(shipped), []string{overlay.Domain, "tailscale"}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) + if err != nil { + t.Fatalf("choosing another VPN was refused: %v", err) + } + for _, m := range got.Modules { + if m.Module == overlay.Name { + t.Fatalf("the other VPN was chosen and WireGuard came anyway: %v", got.Modules) + } + } +} +func TestTwoVPNsAssignedTogetherStillCollide(t *testing.T) { + // The claim still guards the case it was always for: both assigned EXPLICITLY, which is a + // machine with two private networks and a coin toss about which one a peer reaches it on. + shipped := provided(t) + _, err := catalogue.Resolve( + withTailscale(shipped), + []string{overlay.Name, "tailscale"}, + catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) if err == nil { t.Fatal("a machine was given two private networks and nobody was told") } @@ -68,20 +97,9 @@ func TestTheShippedWireGuardModuleClaimsBeingTheNetwork(t *testing.T) { } } -func TestTheShippedNamesModuleNeedsTheMeshsOwnAddresses(t *testing.T) { - // Over a VPN whose addresses the mesh does not hand out, it has no names to write. Refusing - // is what stops a machine getting a hosts file that means nothing on it. - shipped := provided(t) - delete(shipped, overlay.Name) - _, err := catalogue.Resolve(shipped, []string{overlay.Names}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) - - if err == nil { - t.Fatal("the mesh's names resolved with nothing handing out the mesh's addresses") - } - if !strings.Contains(err.Error(), overlay.Addressing) { - t.Fatalf("the refusal does not name what is missing: %v", err) - } -} +// The names-need-addressing test went with the names module: names are a fact now, and a machine +// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same +// protection, enforced where the file is written rather than by a provision refusing. func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest { out := map[string]catalogue.Manifest{} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index 5e50899..d065f73 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -138,30 +138,6 @@ func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { // private network the peers would be written by something else and this would be unchanged. type NameGenerator struct{ nodes []Node } -// NamesFor builds the name generator over the machines on the private network. -func NamesFor(nodes []Node) *NameGenerator { return &NameGenerator{nodes: nodes} } - -// Resources is the one file. -func (g *NameGenerator) Resources(node string) ([]map[string]any, bool, error) { - var found bool - for _, n := range g.nodes { - if n.Name == node { - found = true - } - } - if !found { - return nil, false, nil - } - hosts, err := Hosts(g.nodes, node) - if err != nil { - return nil, false, err - } - return []map[string]any{{ - "id": "mesh-names", "type": "file", "path": HostsPath, - "mode": "0644", "content": hosts, - }}, true, nil -} - // Nodes are the machines this generator was built over, so a caller can say who is on the network. func (g *Generator) Nodes() []Node { return g.nodes } @@ -179,55 +155,25 @@ func Manifest() map[string]any { "version": "1", "computed": Name, "provides": []string{Requirement, Addressing}, - "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, - } -} - -// NamesManifest is the module that gives machines names on the private network. -// -// It requires the network rather than providing it, which is the whole reason it is separate: a -// name resolves to an address on the private wire, so having names without being on it would -// point every machine at somewhere it cannot reach. -func NamesManifest() map[string]any { - return map[string]any{ - "module": Names, - "version": "1", - "computed": Names, - "provides": []string{Resolution}, - "requires": []string{Addressing}, - } -} - -// ResolverManifest is what a resolver on this machine must know: every name under every machine. -// -// **It writes the data and runs no daemon.** A resolver is third-party software, and third-party -// software runs *on* the mesh rather than being *of* it -// ([ADR 0001](novox/hq)) — the mesh has no business shipping one, choosing which one, or knowing -// its configuration language. What only the mesh can know is which machines exist and where they -// are, so that is what it computes. -// -// So a module that runs a resolver requires what this provides, and reads one file. Swapping the -// daemon changes that module and nothing here. -// -// **Separate from names rather than part of them**, because a machine with no container runtime -// can still have a hosts file. Folding them together would take exact names away from a machine -// that cannot run a daemon, to give it a wildcard it cannot use either. -func ResolverManifest() map[string]any { - return map[string]any{ - "module": Resolver, - "version": "1", - "computed": Resolver, - "requires": []string{Resolution}, - "provides": []string{ResolverData}, + // Being on the private network is what gives a machine a name, so the module that puts it + // there is what writes them. Asked for rather than generated by a module of its own: the + // mesh knows which machines exist and where; writing that into a hosts file is not a thing + // that needs a module to run nowhere. + "facts": map[string]string{"node-names": "/etc/hosts"}, + "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, } } // DomainManifest is the module that means "get the network working". func DomainManifest() map[string]any { return map[string]any{ - "module": Domain, - "version": "1", - "requires": []string{Requirement, Resolution}, + "module": Domain, + "version": "1", + // **Only the network now.** It used to require name-resolution as well, answered by a + // module that wrote a hosts file and ran nothing. Names are not a provision — they are a + // fact the mesh computes, and whatever puts a machine on the private network writes them, + // because a mesh name IS an address on that network. + "requires": []string{Requirement}, } } diff --git a/internal/overlay/names_generator_test.go b/internal/overlay/names_generator_test.go deleted file mode 100644 index 205cf48..0000000 --- a/internal/overlay/names_generator_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package overlay - -import ( - "strings" - "testing" -) - -// Names are their own module. -// -// They used to arrive inside the WireGuard declaration, on the argument that a machine with peers -// and no names is half on the network. True, and the wrong place to fix it: names would be -// identical over a different private network, so bundling them made one module out of two things. - -func TestAMachineNotOnTheNetworkGetsNoNames(t *testing.T) { - // Names resolve to addresses on the private wire. Giving them to a machine that is not on it - // would point every lookup somewhere it cannot reach — worse than having no names at all. - g := NamesFor([]Node{at("anchor", "dc", "10.42.0.1", "198.51.100.10:51820", true)}) - _, part, err := g.Resources("laptop") - if err != nil { - t.Fatal(err) - } - if part { - t.Fatal("a machine that is not on the private network was given the mesh's names") - } -} - -func TestTheNamesAreOneFileAndSayWhoIsAsking(t *testing.T) { - g := NamesFor([]Node{ - at("anchor", "dc", "10.42.0.1", "198.51.100.10:51820", true), - at("workstation", "house", "10.42.0.2", "", false), - }) - out, part, err := g.Resources("workstation") - if err != nil || !part { - t.Fatalf("part=%v err=%v", part, err) - } - if len(out) != 1 || out[0]["path"] != HostsPath { - t.Fatalf("got %v", out) - } - content := out[0]["content"].(string) - if !strings.Contains(content, "anchor.internal") { - t.Fatalf("another machine on the network has no name here:\n%s", content) - } - if !strings.Contains(content, "this machine") { - t.Fatalf("the file does not say which machine it is on:\n%s", content) - } -} - -func TestTheWireGuardDeclarationNoLongerCarriesTheNames(t *testing.T) { - // The split, asserted. Two modules, so a machine can have the peers from one and the names - // from another — which is what makes a second VPN possible at all. - raw, err := Declaration( - at("workstation", "house", "10.42.0.2", "", false), - []Peer{{Name: "anchor", Key: "PUB", Allowed: "10.42.0.0/16", - Endpoint: "198.51.100.10:51820"}}, "") - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), HostsPath) { - t.Fatalf("the WireGuard declaration still writes %s", HostsPath) - } -} diff --git a/internal/overlay/resolver.go b/internal/overlay/resolver.go deleted file mode 100644 index fbdef37..0000000 --- a/internal/overlay/resolver.go +++ /dev/null @@ -1,82 +0,0 @@ -package overlay - -import ( - "fmt" - "sort" - "strings" -) - -// A resolver answers every name under a node, not just the node. -// -// **Services are named under the machine they run on** — `postgres.novox.internal`, -// `plex.ace.internal`. The first label is the service and the rest is the node, so what has to -// resolve is *anything* under a node's name, going to that node's address. A reverse proxy there -// routes by the name it was asked for, which is a separate concern and stays separate. -// -// **This is what a hosts file cannot do.** It answers exact names; a wildcard would mean writing -// down every service name in advance, which is the enumeration the arrangement exists to avoid. -// novox/hq 08-connectivity named exactly this as the trigger for needing a resolver rather than a -// file, and it is the first thing to meet it. -// -// What is generated is the data, not the daemon's configuration language. One line per node, -// in the form dnsmasq reads because that is what the module runs — and if a mesh runs something -// else, this is the shape it translates from rather than a second thing to compute. - -// ResolverPath is where the mesh writes what a node must answer. -const ResolverPath = "/etc/mesh-resolver/nodes.conf" - -// Wildcards is one line per node: everything under its name, and the name itself. -// -// A machine with no address is left out. A wildcard pointing at nothing is worse than no wildcard: -// every name under it would resolve and then hang, where an unresolvable name fails at once and -// says which name it was. -func Wildcards(nodes []Node) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — it is replaced whenever a machine\n") - b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n") - b.WriteString("#\n") - b.WriteString("# Each line answers the node's own name AND everything under it, so a service\n") - b.WriteString("# is reached at .." + Suffix() + " without the mesh being told\n") - b.WriteString("# the service exists. What routes it there once it arrives is the proxy's.\n\n") - - named := make([]Node, 0, len(nodes)) - for _, n := range nodes { - if strings.TrimSpace(n.Address) == "" { - continue - } - named = append(named, n) - } - sort.Slice(named, func(i, j int) bool { return named[i].Name < named[j].Name }) - - for _, n := range named { - fmt.Fprintf(&b, "address=/%s/%s\n", InternalName(n.Name), n.Address) - } - if len(named) == 0 { - b.WriteString("# No machine in this mesh has an address on the private network.\n") - } - return b.String() -} - -// ResolverGenerator answers what one node's resolver must know. -type ResolverGenerator struct{ nodes []Node } - -// ResolverFor builds it over the machines on the private network. -func ResolverFor(nodes []Node) *ResolverGenerator { return &ResolverGenerator{nodes: nodes} } - -// Resources is the one file. The daemon that reads it is the module's, not the mesh's. -func (g *ResolverGenerator) Resources(node string) ([]map[string]any, bool, error) { - var here bool - for _, n := range g.nodes { - if n.Name == node { - here = true - } - } - if !here { - // Assigned and not yet on the network. Ordinary and brief. - return nil, false, nil - } - return []map[string]any{{ - "id": "nodes", "type": "file", "path": ResolverPath, - "content": Wildcards(g.nodes), "mode": "0644", - }}, true, nil -} diff --git a/internal/overlay/resolver_test.go b/internal/overlay/resolver_test.go deleted file mode 100644 index c59f44a..0000000 --- a/internal/overlay/resolver_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package overlay - -import ( - "strings" - "testing" -) - -// Services are named under the machine they run on, so what must resolve is anything under a -// node's name — not the node's name alone. -// -// This is what a hosts file cannot do: it answers exact names, and a wildcard there would mean -// writing down every service in advance, which is the enumeration the arrangement exists to -// avoid. -func TestEverythingUnderANodesNameGoesToThatNode(t *testing.T) { - written := Wildcards([]Node{ - {Name: "novox", Address: "10.42.0.1"}, - {Name: "ace", Address: "10.42.0.2"}, - }) - for _, want := range []string{ - "address=/novox.internal/10.42.0.1", - "address=/ace.internal/10.42.0.2", - } { - if !strings.Contains(written, want) { - t.Fatalf("missing %q:\n%s", want, written) - } - } - - // Sorted, because this file is compared against its last version on every apply and a set - // that reorders itself would rewrite it — and restart what reads it — for no change. - if strings.Index(written, "/ace.") > strings.Index(written, "/novox.") { - t.Fatalf("the machines are not in a stable order:\n%s", written) - } -} - -// A machine with no address is left out. -// -// A wildcard pointing at nothing is worse than no wildcard: every name under it resolves and then -// hangs, where an unresolvable name fails at once and says which name it was. -func TestAMachineWithNoAddressGetsNoWildcard(t *testing.T) { - written := Wildcards([]Node{ - {Name: "novox", Address: "10.42.0.1"}, - {Name: "unplaced"}, - }) - if strings.Contains(written, "unplaced") { - t.Fatalf("a machine with no address was given a wildcard:\n%s", written) - } - if !strings.Contains(written, "novox.internal") { - t.Fatalf("the machine that does have one lost it:\n%s", written) - } -} - -// A mesh where nobody is on the private network says so rather than producing an empty file that -// reads as "nothing was generated". -func TestAMeshWithNoAddressesSaysSo(t *testing.T) { - written := Wildcards(nil) - if !strings.Contains(written, "No machine in this mesh has an address") { - t.Fatalf("an empty answer is indistinguishable from a failure to answer:\n%s", written) - } -} - -// The suffix a mesh chose is used, not a hardcoded one. -func TestTheMeshsOwnSuffixIsUsed(t *testing.T) { - t.Setenv(SuffixVar, "mesh.example") - written := Wildcards([]Node{{Name: "novox", Address: "10.42.0.1"}}) - if !strings.Contains(written, "address=/novox.mesh.example/10.42.0.1") { - t.Fatalf("the mesh's own suffix was not used:\n%s", written) - } -} - -// A machine not on the network is given no resolver data, which is an answer rather than an -// error: a node assigned the module before it is placed is in exactly that state. -func TestAMachineNotOnTheNetworkGetsNoResolverData(t *testing.T) { - _, part, err := ResolverFor([]Node{{Name: "novox", Address: "10.42.0.1"}}).Resources("stranger") - if err != nil { - t.Fatal(err) - } - if part { - t.Fatal("a machine not on the network was given the mesh's resolver data") - } -} - -// And a machine on it gets the whole set, including itself: a service on this machine reached by -// its own mesh name must arrive the same way it would from anywhere else. -func TestAMachineGetsTheWholeSetIncludingItself(t *testing.T) { - got, part, err := ResolverFor([]Node{ - {Name: "novox", Address: "10.42.0.1"}, - {Name: "ace", Address: "10.42.0.2"}, - }).Resources("novox") - if err != nil { - t.Fatal(err) - } - if !part || len(got) != 1 { - t.Fatalf("expected one file for a machine on the network, got %d (part=%v)", len(got), part) - } - content, _ := got[0]["content"].(string) - if !strings.Contains(content, "novox.internal") || !strings.Contains(content, "ace.internal") { - t.Fatalf("the machine was not given the whole mesh:\n%s", content) - } -}