diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 0024654..b001ac6 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1035,7 +1035,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // 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 := FactsFrom(m, r, with) + // The resolver file is the node-resolver holder's where one is held here, and the uplink's + // holder steps back from it (novox/hq ADR 0247). + given, err := FactsFrom(stepsBack(m, r.Modules), r, with) if err != nil { return nil, err } diff --git a/internal/catalogue/node_resolver.go b/internal/catalogue/node_resolver.go new file mode 100644 index 0000000..8ba464d --- /dev/null +++ b/internal/catalogue/node_resolver.go @@ -0,0 +1,124 @@ +package catalogue + +// The machine's own resolver (novox/hq ADR 0247). +// +// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing +// else, and the module holding its uplink writes that file (ADR 0223). A machine with a VPN client that +// pushes its own resolvers for its own domains needs a third answer: those domains to the VPN's servers, +// over the VPN's link, and every other name to the mesh's resolvers as before. One file cannot list both +// sets of servers — musl takes the first reply, glibc the first server's "no such name" — so the domains +// are routed by a resolver on the machine itself: the `node-resolver` seat's holder. +// +// **Where it is held, it owns the resolver file.** The file then names the machine's own resolver, which +// routes; the uplink's holder steps back from writing it on that machine, by the rule below and not by +// two modules writing one path. There are two uplink holders (NetworkManager's and systemd-networkd's), +// and the resolver is its own module so it is written once, not once in each. +// +// **It knows nothing of any VPN.** Its verbs route a set of domains to a set of servers over one link, +// list what is routed and remove a route. A module wrapping a VPN client that writes /etc/resolv.conf +// itself carries its own adapter and calls those verbs; a VPN that tells systemd-resolved its link's DNS +// itself needs none. + +// ResolverSeat is the machine's own resolver (novox/hq ADR 0247). +const ResolverSeat = "node-resolver" + +// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where +// one is held. +const ResolverFile = "/etc/resolv.conf" + +// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed +// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to +// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the +// operator's way to read and correct the same. +func resolverVerbs() []Verb { + return []Verb{ + {Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " + + "which answer every name not routed elsewhere, and each link given servers of its own with the " + + "domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " + + "wrote it as far as the file says, whether a module took it, and when the resolver's own file was " + + "put back.", + Input: schema(map[string]string{}, nil), + Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}}, + {Name: "route", Description: "Send these domains, and every name under them, to these servers over " + + "this link — and only them: the link is never the machine's default route for names, and the " + + "mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " + + "takes its route with it.", + Input: schema(map[string]string{ + "link": "the network link the servers are reached over, by name (a VPN's tunnel interface)", + "domains": "the domains to route there, separated by spaces or commas", + "servers": "the servers' addresses, separated by spaces or commas", + }, []string{"link", "domains", "servers"}), + Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}}, + {Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " + + "resolvers. Nothing changes when the link has none.", + Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}), + Replaces: []string{"resolvectl revert"}}, + } +} + +// holdsSeat says whether a module claims a seat, by its current name. +func holdsSeat(m Manifest, seat string) bool { + for _, c := range m.Claims { + if canonicalSeat(c.Name) == seat { + return true + } + } + return false +} + +// rendersResolverFile says whether a module asks the mesh to render the machine's resolver file. +func rendersResolverFile(m Manifest) bool { + for _, f := range m.Facts { + if !f.Home && f.Path == ResolverFile { + return true + } + } + return false +} + +// ResolverFileOwner is the module that writes the machine's resolver file among the modules on one +// machine (novox/hq ADR 0247): the holder of node-resolver when one renders it, else nobody is named here +// and the file is whoever's it always was — the uplink holder's (ADR 0223). +func ResolverFileOwner(modules []Manifest) string { + for _, m := range modules { + if holdsSeat(m, ResolverSeat) && rendersResolverFile(m) { + return m.Module + } + } + return "" +} + +// stepsBack is the module as it composes on a machine whose resolver file is the node-resolver holder's: +// **the uplink holder's rendering of that file is left out**, and nothing else of it changes. Only the +// uplink's holder steps back — any other module rendering the file beside the resolver's is still two +// owners of one path, and is refused as before. +func stepsBack(m Manifest, modules []Manifest) Manifest { + if !holdsSeat(m, "node-uplink") || !rendersResolverFile(m) { + return m + } + owner := ResolverFileOwner(modules) + if owner == "" || owner == m.Module { + return m + } + facts := make(map[string]RosterFile, len(m.Facts)) + for name, f := range m.Facts { + if !f.Home && f.Path == ResolverFile { + continue + } + facts[name] = f + } + m.Facts = facts + return m +} + +// steppedBack is every module of one machine as it composes there (stepsBack, each). +func steppedBack(modules []Manifest) []Manifest { + if ResolverFileOwner(modules) == "" { + return modules + } + out := make([]Manifest, len(modules)) + for i, m := range modules { + out[i] = stepsBack(m, modules) + } + return out +} diff --git a/internal/catalogue/node_resolver_test.go b/internal/catalogue/node_resolver_test.go new file mode 100644 index 0000000..24caf3e --- /dev/null +++ b/internal/catalogue/node_resolver_test.go @@ -0,0 +1,178 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires +// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it. + +// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder +// exists only once the module serving them does, so nothing has to be optional while it catches up. +func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) { + s, ok := SeatNamed(ResolverSeat) + if !ok { + t.Fatalf("%s is not in the mesh's set", ResolverSeat) + } + if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated { + t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s) + } + var got []string + for _, v := range s.Serves { + got = append(got, v.Name) + if v.Optional { + t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name) + } + if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 { + t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name) + } + } + if strings.Join(got, " ") != "routes route unroute" { + t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got) + } + // The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one. + for _, v := range s.Serves { + if strings.Contains(strings.ToLower(v.Description), "forti") { + t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description) + } + } +} + +// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the +// machine's own address. What is under test is the controller's rule, not the catalogue's module. +func localResolver() Manifest { + return Manifest{Module: "local-resolver", Version: "1", + Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, + Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}} +} + +func splitDNSLaptop() Node { + return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}} +} + +// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming +// the machine's own address; the uplink's holder composes everything else it declares, and not that file. +func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) { + shelf := resolverShelf(t) + shelf["local-resolver"] = localResolver() + got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + var files []string + for _, r := range out { + if r["path"] == ResolverFile { + files = append(files, r["id"].(string)) + } + } + if strings.Join(files, " ") != "local-resolver.fact-resolvers" { + t.Fatalf("the resolver file is composed as %v; once, the resolver's", files) + } + content := byID(out)["local-resolver.fact-resolvers"]["content"].(string) + if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") { + t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content) + } + // The uplink's holder is still composed: only the one file moved. + uplinkComposed := false + for _, r := range out { + if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") { + uplinkComposed = true + } + } + if !uplinkComposed { + t.Errorf("the uplink's holder composed nothing once the resolver was held") + } +} + +// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's +// resolvers (ADR 0223) — every machine but the one that requires split-dns. +func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) { + got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}}, + Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}, + Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}, + }) + if err != nil { + t.Fatal(err) + } + if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile { + t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r) + } +} + +// Only the uplink's holder steps back. A third module rendering or declaring the file beside the +// resolver's is two owners of one path, refused as before; and a module rendering it without holding +// the seat is not the resolver, so the uplink's holder does not step back for it. +func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) { + uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}}, + Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} + if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 { + t.Errorf("the uplink's holder and the resolver's were refused together: %v", p) + } + other := Manifest{Module: "other", Version: "1", + Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}} + if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 { + t.Error("a third module wrote the resolver file beside the resolver's") + } + if p := checkResources([]Manifest{uplink, other}); len(p) == 0 { + t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder") + } + declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{ + {"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}} + if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 { + t.Error("a module declaring the resolver file was let beside the resolver's") + } + // What steps back is the one file: the uplink's other facts stay. + uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"} + if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" { + t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts) + } + if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 { + t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts) + } +} + +// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own +// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides +// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls +// the resolver in. Skipped while the catalogue has no holder. +func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) { + held := 0 + for _, m := range theCatalogue(t) { + if !holdsSeat(m, ResolverSeat) { + continue + } + held++ + f, ok := m.Facts["resolvers"] + if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") { + t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f) + } + provides := false + for _, o := range m.Provides { + if o.Name == "split-dns" && o.Reach == ReachMachine { + provides = true + } + } + if !provides { + t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat) + } + } + if held == 0 { + t.Skip("no module of the catalogue holds node-resolver yet") + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 8547968..163a435 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -930,6 +930,9 @@ func canonicalSeat(name string) string { // resolves with no refusal. What is refused is the contradiction — a path one module owns and // another merely accesses — because shared data is the operator's and nobody's to own. func checkResources(modules []Manifest) []string { + // Judged as the machine composes them: where the node-resolver holder writes the resolver file, the + // uplink holder's rendering of it is not composed, so it is not a second owner (novox/hq ADR 0247). + modules = steppedBack(modules) var problems []string owner := map[string]string{} ownedPath := map[string]string{} // path → owning module, for the access check below diff --git a/internal/catalogue/resolver_file_is_the_uplinks_test.go b/internal/catalogue/resolver_file_is_the_uplinks_test.go index 0141b33..768d37e 100644 --- a/internal/catalogue/resolver_file_is_the_uplinks_test.go +++ b/internal/catalogue/resolver_file_is_the_uplinks_test.go @@ -20,7 +20,7 @@ func TestTheResolverConfigSeatIsGone(t *testing.T) { } // The catalogue as it is: nothing claims the retired seat, and every manager the mesh knows holds the -// uplink and writes the resolver file. +// uplink and writes the resolver file; beside them only a holder of node-resolver does (ADR 0247). func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T) { cat := map[string]Manifest{} for _, m := range theCatalogue(t) { @@ -41,8 +41,10 @@ func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T isUplink = isUplink || u == name } for _, f := range m.Facts { - if f.Path == "/etc/resolv.conf" && !isUplink { - t.Errorf("%s writes /etc/resolv.conf and does not hold the uplink", name) + // Or the machine's own resolver's, where it is held (novox/hq ADR 0247): the uplink's holder + // steps back from the file there, and nowhere else. + if f.Path == "/etc/resolv.conf" && !isUplink && !holdsSeat(m, ResolverSeat) { + t.Errorf("%s writes /etc/resolv.conf and holds neither the uplink nor the machine's resolver", name) } } for _, r := range m.Resources { diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 5ad9ea2..82c519c 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -292,6 +292,11 @@ var defaultSeats = append([]Seat{ // the manager knows for it — the same for every holder, so a question about a machine's names is asked // the same way whatever manages its network. Optional until both holders serve them. {Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs()}, + // The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a + // VPN client whose domains must go to its own servers while every other name goes to the mesh's. + // Where it is held, its holder writes the resolver file and the uplink's holder steps back from it + // (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link. + {Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs()}, }, // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index fba6951..f0d7119 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -46,7 +46,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired + // Thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired // into node-uplink (novox/hq ADR 0223); thirty-seven // since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with // node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215); @@ -57,8 +57,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { // node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203, // ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired // mesh-build-machine row goes, when no registered manifest claims it. - if len(Seats()) != 37 { - t.Errorf("the mesh defines %d seats rather than 37; the set is closed, so a change here is "+ + if len(Seats()) != 38 { + t.Errorf("the mesh defines %d seats rather than 38; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } }