From e56416fa8c8644efbbad081bd85385802e413d4c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 00:38:48 +0200 Subject: [PATCH] The mesh's one resolver: its seat, a provider's address, zones, and a node's hosts file (hq ADR 0194, 0196, 0199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mesh-dns-resolver: a mesh seat delivering wildcard-resolution, so every node's resolver configuration resolves to its one holder; node-dns-resolver kept until nothing claims it. - ${bound::address}: the providing machine's private address, for the one consumer that cannot use a name — a machine's resolver configuration. - zone: a module declares the zone it answers and the listen that answers it; the controller settles it per node, refuses duplicates and shadowing, and hands the resolver .Zones to forward. - node-hosts-file: a node seat whose holder owns /etc/hosts, with entries/add/remove. The resolver tests follow the catalogue: no runtime dns (containers copy the machine's resolvers), live-restore held by resolv-conf, resolv.conf naming the resolver by address then a public one. --- cmd/mesh-controller/network_test.go | 16 +-- cmd/mesh-controller/plan.go | 76 +++++++++++- internal/catalogue/bound_into_files.go | 5 +- internal/catalogue/bound_into_files_test.go | 31 +++++ internal/catalogue/declaration.go | 16 ++- internal/catalogue/manifest.go | 5 + internal/catalogue/resolver_manifests_test.go | 67 ++++++---- internal/catalogue/roster.go | 27 ++++ internal/catalogue/seats.go | 24 ++++ internal/catalogue/seats_test.go | 8 +- internal/catalogue/zones.go | 117 ++++++++++++++++++ internal/catalogue/zones_test.go | 78 ++++++++++++ 12 files changed, 430 insertions(+), 40 deletions(-) create mode 100644 internal/catalogue/zones.go create mode 100644 internal/catalogue/zones_test.go diff --git a/cmd/mesh-controller/network_test.go b/cmd/mesh-controller/network_test.go index adea345..f6db431 100644 --- a/cmd/mesh-controller/network_test.go +++ b/cmd/mesh-controller/network_test.go @@ -254,11 +254,10 @@ func theResolver(t *testing.T) catalogue.Manifest { return m } -// The resolver is handed every machine on the private network as a wildcard, the same set and the -// same source as the hosts file, and is handed it again when a machine leaves — through the -// module's own manifest asking for the fact, with no module of the mesh's own in between (hal -// dnsmasq-app conversion, novox/hq 08-connectivity). The runtime on that machine is pointed at the -// machine's own address, where the resolver answers for its containers. +// The resolver is handed every machine on the private network as a wildcard, and is handed it again +// when a machine leaves — through the module's own manifest asking for the fact, with no module of the +// mesh's own in between (hal dnsmasq-app conversion, novox/hq 08-connectivity). It is the mesh's one +// resolver (ADR 0194), and the container runtime is given no resolver of its own (ADR 0196). func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) { open := aMesh(t) ctx := t.Context() @@ -293,11 +292,12 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t t.Errorf("the resolver's machines lack %q:\n%s", want, first) } } + // The container runtime is given no resolver of its own (novox/hq ADR 0196): it copies its + // machine's, which name the mesh's resolver first. A `dns` key would be a second account of where a + // container asks, read only when the runtime starts. for _, r := range composed(t, open, "anchor").Resources { if r["id"] == "dnsmasq.runtime-dns" { - if !strings.Contains(r["content"].(string), `"10.77.0.1"`) || r["into"] != "json" { - t.Errorf("the runtime is not pointed at this machine's own address, written into its file: %v", r) - } + t.Errorf("the resolver still writes the runtime's own dns: %v", r) } } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 95f2b33..85c98cb 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -657,6 +657,13 @@ func renderingFor(ctx context.Context, open *stores, node string, machines[name] = at } + // And every zone a module in the mesh answers itself (novox/hq ADR 0199), for the mesh's resolver + // to forward. + zones, err := zonesInTheMesh(ctx, open) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } + // **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the // broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol // before it had an address on the private network. A machine joins through the tunnel now, and @@ -726,14 +733,79 @@ func renderingFor(ctx context.Context, open *stores, node string, BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, - Machines: machines, - Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, + Machines: machines, Zones: zones, + Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, BusUsers: busUsers, }, record, nil } +// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR +// 0199): the zone settled from that node's settings, the node's private address, the port the +// answering listen is published on there. +// +// Read across every machine's resolution, as the roster once read routed names: a node whose set does +// not compose declares nothing and is passed over, so one broken machine does not cost the rest their +// zones; a store that cannot be read is raised, naming the machine, because returning the zones +// without it would withdraw them from the resolver as if the operator had (novox/hq 04-ISSUES/152). +// What the mesh refuses about the zones together — one declared twice, one shadowing the mesh's +// suffix or a node's public domain — is refused here, by name. +func zonesInTheMesh(ctx context.Context, open *stores) ([]catalogue.ZoneAt, error) { + inv := open.inventory + places, err := inv.Overlays(ctx) + if err != nil { + return nil, fmt.Errorf("where the machines are cannot be read: %w", err) + } + address := map[string]string{} + for _, p := range places { + if strings.TrimSpace(p.Address) != "" { + address[p.Name] = p.Address + } + } + nodes, err := inv.Nodes(ctx) + if err != nil { + return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err) + } + var zones []catalogue.ZoneAt + var public []string + for _, n := range nodes { + plan, _, err := planFor(ctx, open, n.Name) + switch { + case unresolvable(err): + continue + case err != nil: + return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err) + } + if plan.PublicDomain != "" { + public = append(public, plan.PublicDomain) + } + for _, m := range plan.Modules { + if m.Zone == nil { + continue + } + at := address[n.Name] + if at == "" { + // Not on the private network yet: nothing could reach its answerer. + continue + } + published, layers, err := portsGivenOn(ctx, inv, n.Name, m) + if err != nil { + return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) + } + z, err := catalogue.ZoneOn(m, layers, published, n.Name, at) + if err != nil { + return nil, err + } + zones = append(zones, *z) + } + } + if problems := catalogue.ZonesProblems(zones, overlay.Suffix(), public); len(problems) > 0 { + return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) + } + return zones, nil +} + // certificateFor is what the mesh certifies about one machine's internal name. // // It reaches across two contexts and reads neither one's store from the other: `inventory` knows diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go index cf4ef56..b0baffe 100644 --- a/internal/catalogue/bound_into_files.go +++ b/internal/catalogue/bound_into_files.go @@ -21,8 +21,9 @@ import ( // formats and gains no fields. // // **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is: -// `at`, `as` and `from` are facts about any provision at all, and everything else comes from what -// the provider said it serves — whose keys are agreed by the requirement's name, not by this file. +// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are +// facts about any provision at all, and everything else comes from what the provider said it +// serves — whose keys are agreed by the requirement's name, not by this file. // bound is where a module says a value from one of its bindings belongs: // ${bound:.}. diff --git a/internal/catalogue/bound_into_files_test.go b/internal/catalogue/bound_into_files_test.go index 011a1ce..7abfa3e 100644 --- a/internal/catalogue/bound_into_files_test.go +++ b/internal/catalogue/bound_into_files_test.go @@ -232,3 +232,34 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) { t.Fatal("one module on two machines shares an identity") } } + +// A machine's resolver configuration must name its resolver by address — it cannot resolve the name +// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing +// machine's private address beside its name, and only when the machine has one. +func TestABindingOffersTheProvidersAddress(t *testing.T) { + consumer := func() Resolution { + return Resolution{ + Node: "workstation", + Modules: []Manifest{{ + Module: "resolv-conf", + Requires: []string{"wildcard-resolution"}, + Resources: []map[string]any{{ + "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", + "content": "nameserver ${bound:wildcard-resolution:address}\n", + }}, + }}, + Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}}, + } + } + out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" { + t.Fatalf("the resolver is not named by its address: %q", got) + } + // A machine with no address yet: refused, never written with a blank where the address belongs. + if _, err := consumer().Declaration(Rendering{}); err == nil { + t.Fatal("a file naming an address the mesh does not have was composed") + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index dbd83f6..edf6c77 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -155,6 +155,10 @@ type Rendering struct { // standing beside the machines and looking as real as they do. Machines map[string]string + // Zones is every zone a module in the mesh answers itself, where it is answered (novox/hq ADR + // 0199): the mesh's resolver forwards each one there. + Zones []ZoneAt + Settings SettingsBy Generators map[string]Generator // Grants are the credentials this node must create, for the provisions it offers. Passed in @@ -732,6 +736,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string, known[provision] = values } } + // And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is + // what nearly every consumer wants; the one that cannot use it is a machine's resolver + // configuration, which must reach the resolver before it can resolve anything — the resolver's + // own name included. Absent when the machine has no address yet, so a file naming it is refused + // rather than written with a blank where an address belongs. + for _, values := range known { + if address := with.Machines[values["at"]]; address != "" { + values["address"] = address + } + } // And what the module is called through each requirement it contributes to (novox/hq // 04-ISSUES/122) — the same composition its binding file carries. for provision, values := range known { @@ -901,7 +915,7 @@ 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 := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix) + given, err := FactsWithZonesInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix, with.Zones) if err != nil { return nil, err } diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index aef8497..868f8c4 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -540,6 +540,10 @@ type Manifest struct { // `restart-on` names to restart when the roster changes. Facts map[string]RosterFile `json:"facts,omitempty"` + // Zone is the zone of names this module answers itself, and the listen that answers it (novox/hq + // ADR 0199). The mesh's resolver forwards the zone to it; nothing here names an address. + Zone *Zone `json:"zone,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. // @@ -1742,6 +1746,7 @@ func ParseManifest(raw []byte) (Manifest, error) { } } + problems = append(problems, zoneProblems(m)...) if len(problems) > 0 { sort.Strings(problems) return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go index 234e0ce..0f5474a 100644 --- a/internal/catalogue/resolver_manifests_test.go +++ b/internal/catalogue/resolver_manifests_test.go @@ -48,9 +48,12 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T } for _, want := range []string{ "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", - // Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its - // address there (novox/hq issue 198). - "\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n", + // The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a + // member cannot reach what the mesh's names point at. + "\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n", + // No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199). + "\nno-hosts\n", + "\nconf-file=" + m.Facts["zones"].Path + "\n", "\ndomain-needed\n", "\nbogus-priv\n", "\nconf-file=" + m.Facts["node-zones"].Path + "\n", } { @@ -73,7 +76,15 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T t.Errorf("the resolver listens on %s", taken) } } - // And the file that decides what the machine asks names it there, alone. + // Never a directory or a file the operator keeps: a line written for one machine's programs would + // become an answer for every node (ADR 0199). + for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} { + if strings.Contains(config, never) { + t.Errorf("the mesh's resolver still reads or listens on %q", never) + } + } + // And the file that decides what the machine asks names the mesh's resolver first, by address, + // and a public one second, asked only when the first is silent (ADR 0196). var resolv string for _, r := range catalogueManifest(t, "resolv-conf").Resources { if r["path"] == "/etc/resolv.conf" { @@ -86,13 +97,16 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver ")) } } - if len(nameservers) != 1 || nameservers[0] != "127.0.0.1" { - t.Errorf("resolv.conf names %v; the predecessor's names the mesh's resolver alone at 127.0.0.1", nameservers) + if len(nameservers) != 2 || nameservers[0] != "${bound:wildcard-resolution:address}" || nameservers[1] != "1.1.1.1" { + t.Errorf("resolv.conf names %v; the mesh's resolver by address first, a public one second", nameservers) } - // The split-DNS alternative points at the same address, or a machine that keeps + if !strings.Contains(resolv, "\noptions timeout:1 attempts:1") { + t.Errorf("the fallback is not reached after one short attempt:\n%s", resolv) + } + // The split-DNS alternative points at the same resolver, or a machine that keeps // systemd-resolved in charge would route the mesh's suffix to nothing. for _, r := range catalogueManifest(t, "resolved-split-dns").Resources { - if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=127.0.0.1\n") { + if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=${bound:wildcard-resolution:address}\n") { t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content) } } @@ -100,7 +114,8 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T // The resolver and what points the machine at it compose on one machine, and what arrives is the // mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on -// that file, and the runtime pointed at this machine's own address. +// that file, the machine pointed at the resolver by address, and the runtime given no resolver of +// its own but kept running across a restart (ADR 0196). func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"}, Node{Name: "anchor", At: "anchor.internal"}, World{}) @@ -115,6 +130,7 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { // issue 111) — the resolver's zones read only the second, and in this scenario the two // happen to be the same map, since nothing routed is part of it. Names: twoMachines, Machines: twoMachines, Suffix: "internal", + Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}}, 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"}}}}, }) @@ -144,24 +160,29 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { for _, id := range service["restart-on"].([]any) { reflects[id.(string)] = true } - if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] { - t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"]) + if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] { + t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"]) + } + if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") { + t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z) } - // The runtime's own file, written into (novox/hq ADR 0102) with the keys this module states: - // where containers resolve, and that a restart keeps them running — because the runtime reads - // `dns` only when it starts, and the one restart that needs is the operator's (issue 110). - runtime := ids["dnsmasq.runtime-dns"] + // The runtime's own file, written into (novox/hq ADR 0102) with one key, by what decides how the + // machine resolves: a restart keeps every container running. No `dns` — a container copies its + // machine's resolvers (ADR 0196), and the mesh's resolver is not written into the runtime twice. + if ids["dnsmasq.runtime-dns"] != nil { + t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"]) + } + runtime := ids["resolv-conf.runtime-config"] if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" { - t.Fatalf("the runtime's dns is not written into its file: %v", runtime) + t.Fatalf("live-restore is not written into the runtime's file: %v", runtime) } var keys map[string]any if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil { t.Fatalf("the runtime's keys are not JSON: %v", err) } - dns, _ := keys["dns"].([]any) - if len(keys) != 2 || len(dns) != 1 || dns[0] != "10.42.0.1" || keys["live-restore"] != true { - t.Errorf("the runtime is given %v; containers resolve at this machine's own private-network address, a restart keeps them, and nothing else is written", keys) + if len(keys) != 1 || keys["live-restore"] != true { + t.Errorf("the runtime is given %v; live-restore and nothing else", keys) } // The runtime is reloaded when that file changes, and never restarted: a restart stops every // container on the machine (ADR 0102), and a reload is what turns live-restore on. @@ -171,10 +192,10 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { continue } if _, restarts := r["restart-on"]; restarts { - t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r) + t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r) } for _, on := range asStrings(r["reload-on"]) { - if on == "dnsmasq.runtime-dns" { + if on == "resolv-conf.runtime-config" { reloaded = true } } @@ -184,8 +205,8 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { } resolv := ids["resolv-conf.resolv"] - if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 127.0.0.1\n") { - t.Fatalf("the machine is not pointed at the resolver: %v", resolv) + if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\nnameserver 1.1.1.1\n") { + t.Fatalf("the machine is not pointed at the resolver by address, with the public fallback: %v", resolv) } } diff --git a/internal/catalogue/roster.go b/internal/catalogue/roster.go index 55edc22..7c28213 100644 --- a/internal/catalogue/roster.go +++ b/internal/catalogue/roster.go @@ -61,6 +61,16 @@ type rosterView struct { Suffix string Names []rosterEntry Machines []rosterEntry + // Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq + // ADR 0199) — what the mesh's resolver forwards. Ordered by zone. + Zones []rosterZone +} + +// rosterZone is one zone as a template sees it: the zone, and the address and port answering it. +type rosterZone struct { + Zone string + Address string + Port int } // rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address, @@ -80,6 +90,12 @@ type rosterEntry struct { // `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both // are given and the template chooses. func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) { + return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil) +} + +// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them. +func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string, + zones []ZoneAt) ([]map[string]any, error) { if len(m.Facts) == 0 { return nil, nil } @@ -94,6 +110,7 @@ func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]st Suffix: strings.TrimPrefix(suffixOr(suffix), "."), Names: entriesFrom(every, accounts, suffix), Machines: entriesFrom(machines, accounts, suffix), + Zones: zonesFrom(zones), } out := make([]map[string]any, 0, len(names)) @@ -223,3 +240,13 @@ func sortedNames(addresses map[string]string) []string { sort.Strings(out) return out } + +// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file. +func zonesFrom(zones []ZoneAt) []rosterZone { + out := make([]rosterZone, 0, len(zones)) + for _, z := range zones { + out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone }) + return out +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 536f9ec..bb34240 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -107,7 +107,31 @@ var defaultSeats = []Seat{ // that machine unresolvable in the meantime. Deleted once no registered manifest claims it. {Name: "mesh-build-machine", Scope: ScopeMesh, Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"}, + // **The mesh's one resolver** (novox/hq ADR 0194, 0196): every node's internal domain, held in one + // place, and every node and container asks it first. Delivers what a machine's resolver + // configuration requires, so that requirement resolves to the holder wherever it is placed. + {Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Decision: "novox/hq ADR 0194"}, + // **Retired by ADR 0194, kept while a manifest still claims it** — the same reason as + // mesh-build-machine above: a machine still holds it until the mesh's resolver replaces it, and + // removing the row first would make that machine unresolvable. Deleted once nothing claims it. {Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"}, + // **A machine's /etc/hosts is one module's** (novox/hq ADR 0199): its holder writes the machine's + // own lines and keeps every other line as the operator's, changed through these three verbs on that + // machine alone. The controller holds none of it. + {Name: "node-hosts-file", Scope: ScopeNode, Decision: "novox/hq ADR 0199", + Serves: []Verb{ + {Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " + + "the operator's, or the block of the module or tool that writes it.", + Input: schema(map[string]string{}, nil)}, + {Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " + + "/etc/hosts — a name for this machine's own programs, not the mesh's.", + Input: schema(map[string]string{"address": "the IPv4 or IPv6 address", + "names": "the names for it, separated by spaces"}, []string{"address", "names"})}, + {Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " + + "lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.", + Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"}, + []string{"name"})}, + }}, // The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list // whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all // four; the jails themselves are composed from the modules the machine runs (to-be 31). diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 22f3db7..f06de95 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,10 +44,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired - // mesh-build-machine row goes, when no registered manifest claims it any more. - if len(Seats()) != 17 { - t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+ + // Nineteen since mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199) — two fewer + // once the retired mesh-build-machine and node-dns-resolver rows go, when no manifest claims either. + if len(Seats()) != 19 { + t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } } diff --git a/internal/catalogue/zones.go b/internal/catalogue/zones.go new file mode 100644 index 0000000..a3a938b --- /dev/null +++ b/internal/catalogue/zones.go @@ -0,0 +1,117 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// Zones: names a module answers itself (novox/hq ADR 0199). +// +// The mesh's resolver holds each node's internal domain and nothing else (ADR 0191, 0194). A module +// whose names are its own — the lab's scenario machines, known only while a scenario runs — declares +// the zone it answers and the listen that answers it; the controller hands the resolver's holder every +// zone with the declaring node's private address and the port that listen is published on, and the +// holder forwards the zone there. **A definition names no address** (ADR 0112): the zone is a setting, +// the listen is the module's own, and where they are is the mesh's fact. + +// Zone is the manifest's declaration that a module answers the names in one zone. +type Zone struct { + // Name is the zone: a label or a dotted name, normally `${setting:}`, so the operator chooses + // it and the definition does not. + Name string `json:"name"` + // Listen names one of the module's listens: the DNS answerer for the zone. + Listen string `json:"listen"` +} + +// ZoneAt is a declared zone where the mesh placed it: what the resolver's holder forwards, and where. +type ZoneAt struct { + Zone string + Node string + Module string + Address string + Port int +} + +// zoneProblems is what is wrong with a module's zone declaration on its own, before any node. +func zoneProblems(m Manifest) []string { + if m.Zone == nil { + return nil + } + var problems []string + if strings.TrimSpace(m.Zone.Name) == "" { + problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module)) + } + if !m.hasListen(m.Zone.Listen) { + problems = append(problems, fmt.Sprintf( + "%s declares zone %q answered by listen %q, and has no listen of that name", + m.Module, m.Zone.Name, m.Zone.Listen)) + } + return problems +} + +func (m Manifest) hasListen(name string) bool { + if name == "" { + return false + } + for _, l := range m.Listens { + if l.Name == name { + return true + } + } + return false +} + +// ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the +// port its answering listen is published on there. Nothing when the module declares no zone. +func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) { + if m.Zone == nil { + return nil, nil + } + settled, err := Settle(map[string]any{"zone": m.Zone.Name}, layers) + if err != nil { + return nil, fmt.Errorf("%s's zone on %s: %w", m.Module, node, err) + } + zone := strings.Trim(strings.ToLower(fmt.Sprint(settled["zone"])), ".") + var port int + for _, l := range m.Listens { + if l.Name == m.Zone.Listen { + port = l.Port + if at, given := published[l.Port]; given { + port = at + } + } + } + return &ZoneAt{Zone: zone, Node: node, Module: m.Module, Address: address, Port: port}, nil +} + +// ZonesProblems is what the mesh refuses about its zones together: one zone declared twice, a zone +// that is the mesh's suffix or under it, a zone that is a node's public domain or under one. A module +// may not shadow names the mesh's resolver or the public DNS answers. +func ZonesProblems(zones []ZoneAt, suffix string, publicDomains []string) []string { + var problems []string + under := func(zone, domain string) bool { + domain = strings.Trim(strings.ToLower(domain), ".") + return domain != "" && (zone == domain || strings.HasSuffix(zone, "."+domain)) + } + seen := map[string]ZoneAt{} + for _, z := range zones { + if other, twice := seen[z.Zone]; twice && (other.Node != z.Node || other.Module != z.Module) { + problems = append(problems, fmt.Sprintf("zone %q is declared by %s on %s and by %s on %s; one module answers a zone", + z.Zone, other.Module, other.Node, z.Module, z.Node)) + } + seen[z.Zone] = z + if under(z.Zone, suffix) { + problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the mesh's own suffix or under it", + z.Module, z.Node, z.Zone)) + } + for _, d := range publicDomains { + if under(z.Zone, d) { + problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the public domain %q or under it", + z.Module, z.Node, z.Zone, d)) + } + } + } + sort.Strings(problems) + return problems +} diff --git a/internal/catalogue/zones_test.go b/internal/catalogue/zones_test.go new file mode 100644 index 0000000..515c0cb --- /dev/null +++ b/internal/catalogue/zones_test.go @@ -0,0 +1,78 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199). +func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"lab","version":"1", + "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], + "zone":{"name":"${setting:zone}","listen":"web"}}`)) + if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) { + t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err) + } + if _, err := ParseManifest([]byte(`{"module":"lab","version":"1", + "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], + "zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil { + t.Fatalf("a well-formed zone was refused: %v", err) + } +} + +// The zone is the operator's (a setting) and the port is where this machine publishes the listen — +// neither is the definition's to state. +func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) { + m := Manifest{Module: "lab", Zone: &Zone{Name: "${setting:zone}", Listen: "dns"}, + Listens: []Listening{{Name: "dns", Port: 5353, From: FromMesh}}} + z, err := ZoneOn(m, []Layer{{From: "node", Values: map[string]any{"zone": "Incus."}}}, + map[int]int{5353: 15353}, "workstation", "10.77.0.3") + if err != nil { + t.Fatal(err) + } + if z.Zone != "incus" || z.Address != "10.77.0.3" || z.Port != 15353 || z.Node != "workstation" { + t.Fatalf("the zone was placed as %+v", *z) + } + if _, err := ZoneOn(m, nil, nil, "workstation", "10.77.0.3"); err == nil { + t.Fatal("a zone nobody named was placed") + } +} + +// One module answers a zone, and none may shadow the mesh's names or a public domain. +func TestTheMeshRefusesAZoneTwiceOrOneThatShadows(t *testing.T) { + one := ZoneAt{Zone: "incus", Node: "workstation", Module: "lab", Address: "10.77.0.3", Port: 53} + if p := ZonesProblems([]ZoneAt{one}, "internal", []string{"example.tld"}); len(p) != 0 { + t.Fatalf("one ordinary zone was refused: %v", p) + } + twice := one + twice.Node, twice.Module = "laptop", "other" + cases := map[string][]ZoneAt{ + "declared by": {one, twice}, + "mesh's own suffix": {{Zone: "lab.internal", Node: "a", Module: "m"}}, + "public domain": {{Zone: "dev.example.tld", Node: "a", Module: "m"}}, + } + for want, zones := range cases { + p := strings.Join(ZonesProblems(zones, "internal", []string{"example.tld"}), "\n") + if !strings.Contains(p, want) { + t.Errorf("not refused for %q: %q", want, p) + } + } +} + +// The resolver's template sees every zone with where it is answered, in zone order. +func TestTheResolversTemplateRangesTheZones(t *testing.T) { + m := Manifest{Module: "dnsmasq", Facts: map[string]RosterFile{"zones": { + Path: "/etc/mesh-resolver/zones.conf", + Template: "{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}", + }}} + out, err := FactsWithZonesInto(m, Resolution{Node: "anchor"}, nil, nil, nil, "", []ZoneAt{ + {Zone: "zeta", Address: "10.77.0.2", Port: 53}, + {Zone: "incus", Address: "10.77.0.3", Port: 15353}, + }) + if err != nil { + t.Fatal(err) + } + if got := out[0]["content"]; got != "server=/incus/10.77.0.3#15353\nserver=/zeta/10.77.0.2#53\n" { + t.Fatalf("the resolver was told %q", got) + } +}