diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go index c5bae72..9e7ca23 100644 --- a/cmd/mesh-controller/acts.go +++ b/cmd/mesh-controller/acts.go @@ -46,9 +46,16 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err return "", err } defer release() - if err := open.inventory.Assign(ctx, node, module); err != nil { + fresh, err := open.inventory.Assign(ctx, node, module) + if err != nil { return "", err } + if !fresh { + // Nothing changed, and saying "is assigned" would read as an action. One node runs one + // of each — the module's name is the assignment's identity (novox/hq ADR 0115). + return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed", + node, module), nil + } said := fmt.Sprintf("%s is assigned %s", node, module) plan, _, err := planFor(ctx, open, node) if err != nil { diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index 9941728..6b9d95b 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -355,7 +355,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s strings.Join(assigned, ", ")) } if !slices.Contains(assigned, filter) { - if err := inv.Assign(ctx, node, filter); err != nil { + if _, err := inv.Assign(ctx, node, filter); err != nil { return "", err } if _, _, err := planFor(ctx, open, node); err != nil { diff --git a/cmd/mesh-controller/foundation_scope_test.go b/cmd/mesh-controller/foundation_scope_test.go new file mode 100644 index 0000000..9ff8591 --- /dev/null +++ b/cmd/mesh-controller/foundation_scope_test.go @@ -0,0 +1,33 @@ +package main + +// The broker opening belongs only on the node that listens on it (novox/hq: it leaked onto +// every enrolled node's declaration, opening a from-anywhere hole for a port nothing there +// serves). foundationPortsFor is the scope. + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +func TestTheBrokerHostGetsTheFoundationOpening(t *testing.T) { + broker := catalogue.Manifest{Module: "lavinmq", Listens: []catalogue.Listening{ + {Port: 5671, Protocol: "tcp", From: "mesh"}, + {Port: 5672, Protocol: "tcp", From: "mesh"}, + }} + got := foundationPortsFor(5671, []catalogue.Manifest{broker}) + if len(got) != 1 || got[0] != 5671 { + t.Fatalf("the node that listens on the broker port keeps it; got %v", got) + } +} + +func TestANodeThatOnlyDialsTheBrokerGetsNoOpening(t *testing.T) { + // ace's set: things that reach the broker as a client, none listening on 5671. + ace := []catalogue.Manifest{ + {Module: "plex", Listens: []catalogue.Listening{{Port: 32400, Protocol: "tcp", From: "anywhere"}}}, + {Module: "postgres", Listens: []catalogue.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}}, + } + if got := foundationPortsFor(5671, ace); got != nil { + t.Fatalf("a node that only dials out opens nothing for the broker; got %v", got) + } +} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 0727b34..99a5cfa 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -118,6 +118,8 @@ func run() error { return rolloutCommand(ctx, args[1:]) case "seats": return seatsCommand(ctx, args[1:]) + case "seat": + return seatCommand(ctx, args[1:]) case "status": return statusCommand(ctx, args[1:]) case "version": diff --git a/cmd/mesh-controller/mesh_for_test.go b/cmd/mesh-controller/mesh_for_test.go index 35f3627..3008697 100644 --- a/cmd/mesh-controller/mesh_for_test.go +++ b/cmd/mesh-controller/mesh_for_test.go @@ -73,7 +73,7 @@ func aMesh(t *testing.T) *stores { if err := open.inventory.RecordOverlayKey(t.Context(), record.ID, aPublicKey(t)); err != nil { t.Fatal(err) } - if err := open.inventory.Assign(t.Context(), name, overlay.Name); err != nil { + if _, err := open.inventory.Assign(t.Context(), name, overlay.Name); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index 724eaf7..5b52dff 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -48,7 +48,7 @@ func overlayRange(ctx context.Context, inv *inventory.Inventory) (string, error) func overlayCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("overlay place [flags], or overlay show") + return errors.New("overlay place [flags], overlay name
, or overlay show") } // Answered before anything is opened. A message about which command to use should not need a // database to say so, and needing one turns a redirect into a connection error. @@ -69,12 +69,29 @@ func overlayCommand(ctx context.Context, args []string) error { return overlayPlace(ctx, inv, args[1:]) case "show": return overlayShow(ctx, open) + case "name": + return overlayName(ctx, inv, args[1:]) default: - return fmt.Errorf("overlay has no %q; it has place and show", args[0]) + return fmt.Errorf("overlay has no %q; it has place, name and show", args[0]) } } +// overlayName is the operator saying which machine a carried address is (novox/hq issue 112), +// so the mesh answers for its name until the machine enrols and verifies it. +func overlayName(ctx context.Context, inv *inventory.Inventory, args []string) error { + if len(args) != 2 { + return errors.New("overlay name ") + } + address, name := args[0], args[1] + if err := inv.NamePeer(ctx, address, name); err != nil { + return err + } + fmt.Printf("the peer at %s is %s until it enrols — the mesh answers for %s. from the "+ + "operator's word, and enrolment under this key must use this name\n", address, name, name) + return nil +} + func overlayPlace(ctx context.Context, inv *inventory.Inventory, args []string) error { if len(args) == 0 { return errors.New( @@ -239,7 +256,7 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool, "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+ "nothing was composed", p.Name, t.Interface, wrong, p.Name, t.Port) } - n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config} + n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU} } if p.Hub { for _, c := range carried { @@ -588,6 +605,24 @@ func namesInTheMesh(ctx context.Context, inv *inventory.Inventory, for _, p := range places { out[overlay.InternalName(p.Name)] = p.Address } + // And the carried peers the operator has named (novox/hq issue 112): machines the + // predecessor's resolver answers for and the mesh routes to, known by name on the operator's + // word until they enrol — at which point enrolment verifies the name and the node's own + // entry takes over above. A name the predecessor answers for must keep resolving until the + // machine behind it is a node; without these, taking the resolver silences three machines. + carried, err := inv.CarriedPeers(ctx) + if err != nil { + return nil, err + } + for _, p := range carried { + if p.Named == "" || p.EnrolledAs != "" { + continue + } + if _, taken := out[overlay.InternalName(p.Named)]; taken { + continue // a node of the mesh owns the name; the stale statement loses + } + out[overlay.InternalName(p.Named)] = p.Address + } return out, nil } diff --git a/cmd/mesh-controller/nodes.go b/cmd/mesh-controller/nodes.go index d7ce9dd..d75ee40 100644 --- a/cmd/mesh-controller/nodes.go +++ b/cmd/mesh-controller/nodes.go @@ -67,8 +67,14 @@ func nodeCommand(ctx context.Context, args []string) error { // because the damage is already done by the time it prints. return publicDomain(ctx, inv, args[1:]) + case "account": + // The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is + // owned by and which account `ssh ` uses. Reports with no argument; sets with one; + // an optional second argument is the home when it is not /home/. + return nodeAccount(ctx, inv, args[1:]) + default: - return fmt.Errorf("node has no %q; it has add, list, show and public-domain", args[0]) + return fmt.Errorf("node has no %q; it has add, list, show, public-domain and account", args[0]) } } @@ -106,6 +112,39 @@ func modeOf(n inventory.Node) string { } // publicDomainUsage is the one description of the three forms, so a refusal and the help agree. +// nodeAccount reports or sets a node's operator account (novox/hq to-be 29). Read-shaped with no +// argument, like public-domain: `node account novox` answers, it does not change anything. +func nodeAccount(ctx context.Context, inv *inventory.Inventory, positionals []string) error { + if len(positionals) == 0 || len(positionals) > 3 { + return errors.New("node account — what it is now; " + + "node account [home] — set it (home defaults to /home/)") + } + node := positionals[0] + if len(positionals) == 1 { + who, err := inv.NodeByName(ctx, node) + if err != nil { + return err + } + if who.Account == "" { + fmt.Printf("%s has no operator account known\n", node) + fmt.Printf(" `node account %s ` sets it\n", node) + return nil + } + fmt.Printf("%s logs a person in as %s (home %s)\n", node, who.Account, who.Home()) + return nil + } + home := "" + if len(positionals) == 3 { + home = positionals[2] + } + if err := inv.SetAccount(ctx, node, positionals[1], home); err != nil { + return err + } + fmt.Printf("%s logs a person in as %s\n", node, positionals[1]) + fmt.Printf(" run `push %s` once ssh-client is assigned, to send its operator config\n", node) + return nil +} + const publicDomainUsage = "node public-domain — what it is now; " + " to set it; --clear to take it away" diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 8877ccf..2c00f9b 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -83,9 +83,17 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso return catalogue.Resolution{}, nil, err } + // The operator account this node logs a person in as, and where its home is (novox/hq to-be + // 29) — carried so a home-scoped file's owner and path resolve for this machine. + who, err := inv.NodeByName(ctx, nodeName) + if err != nil { + return catalogue.Resolution{}, nil, err + } + resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, - At: onNetwork[nodeName], PublicDomain: publicDomain}, world) + At: onNetwork[nodeName], PublicDomain: publicDomain, + Account: who.Account, AccountHome: who.AccountHome}, world) if err != nil { return catalogue.Resolution{}, nil, err } @@ -488,6 +496,13 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + // The private network's range, offered to a module as ${machine:mesh-range} — a module that must + // name the whole mesh (an intrusion filter that must never ban a tunnel peer) names it here + // rather than hardcoding a value it cannot know. + meshRange, err := overlayRange(ctx, inv) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } // The artifact store as this node reaches it now — the address every image and archive the // mesh built is fetched through, composed here and recorded nowhere — with what the mesh has @@ -513,6 +528,19 @@ func renderingFor(ctx context.Context, open *stores, node string, return catalogue.Rendering{}, inventory.Node{}, err } + // Each machine's operator account, so an ssh Host block can name the login for every node + // (novox/hq to-be 29). Keyed by the bare node name, which entriesFrom falls back to. + allNodes, err := inv.Nodes(ctx) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } + accounts := map[string]string{} + for _, n := range allNodes { + if n.Account != "" { + accounts[n.Name] = n.Account + } + } + // And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the // `.internal` names above, so a container — or an internal ACME validator — resolves a // routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told @@ -534,11 +562,19 @@ func renderingFor(ctx context.Context, open *stores, node string, // The ports the mesh itself needs open, which no module declares. Read from the broker this // control plane was told about rather than written down twice: the address a node is handed in // its token and the port its machine must accept on are the same fact. + // + // **Only on the node that listens on it** (novox/hq issue: the broker opening leaked onto + // every node). The opening exists to WIDEN the broker's port to from-anywhere — a machine + // enrolling is not on the mesh yet, so the broker's own `from: mesh` listen would refuse its + // first dial. That widening belongs on the broker's host and nowhere else: a node that only + // dials out needs no incoming rule, and an opening for a port nothing here listens on is a + // from-anywhere hole for a dead port. So the foundation port is kept only when a module + // resolved onto THIS node actually listens on it. var foundation []int if b, err := broker.FromEnvironment(); err == nil { if _, port, err := net.SplitHostPort(b.Address); err == nil { if n, err := strconv.Atoi(port); err == nil { - foundation = append(foundation, n) + foundation = foundationPortsFor(n, plan.Modules) } } } @@ -595,7 +631,8 @@ func renderingFor(ctx context.Context, open *stores, node string, Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, Machines: machines, - Suffix: overlay.Suffix(), Foundation: foundation, Kept: kept, Adopted: record.Adopted, + Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation, + Kept: kept, Adopted: record.Adopted, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, BusUsers: busUsers, }, record, nil @@ -922,12 +959,26 @@ func planCommand(ctx context.Context, args []string) error { if !ok { continue } - fmt.Printf("\n--- %v %v ---\n%s", r["id"], r["path"], content) + fmt.Printf("\n--- %s ---\n%s", shownAs(r), content) } } return nil } +// shownAs is the heading `plan --show` puts over a resource's content. +// +// **A file written into says so.** Its content is the mesh's part of a file that is otherwise the +// machine's — the keys of a JSON document (novox/hq ADR 0102), the region of a hosts file (issue +// 128). Shown under a bare path it reads as the whole file, and a person checking what a take +// replaces would see a hosts file of a dozen lines where the machine keeps thirty. +func shownAs(r map[string]any) string { + heading := fmt.Sprintf("%v %v", r["id"], r["path"]) + if into, ok := r["into"].(string); ok && into != "" { + heading += fmt.Sprintf(" (written into, %s)", into) + } + return heading +} + // licencesFor is what this node can be answered with by record, and what it was put on. // // A mesh with no licences at all is the ordinary case and must not be an error: every existing @@ -1212,3 +1263,20 @@ func composeBusUsers(ctx context.Context, inv *inventory.Inventory, } return broker.ComposeAccounts(filled) } + +// foundationPortsFor is the broker port, kept only when a module resolved onto this node listens +// on it (novox/hq issue: the broker opening leaked onto every node). The foundation opening +// exists to WIDEN the broker's `from: mesh` port to from-anywhere, because a machine enrolling is +// not on the mesh yet and its first dial would be refused. That widening belongs on the broker's +// host alone: a node that only dials out needs no incoming rule, and an opening for a port +// nothing here listens on is a from-anywhere hole for a dead port. +func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int { + for _, m := range modules { + for _, l := range m.Listens { + if l.Port == brokerPort { + return []int{brokerPort} + } + } + } + return nil +} diff --git a/cmd/mesh-controller/plan_test.go b/cmd/mesh-controller/plan_test.go index 35a86b8..0075d8f 100644 --- a/cmd/mesh-controller/plan_test.go +++ b/cmd/mesh-controller/plan_test.go @@ -35,3 +35,17 @@ func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) { t.Errorf("a module with no listens should print nothing, got %v", got) } } + +// `plan --show` says when a file is written into rather than over (novox/hq issue 128), or the +// mesh's region of a hosts file reads as the whole file. +func TestAFileWrittenIntoIsShownAsSuch(t *testing.T) { + region := shownAs(map[string]any{ + "id": "mesh-wireguard.fact-node-names", "path": "/etc/hosts", "into": "block"}) + if region != "mesh-wireguard.fact-node-names /etc/hosts (written into, block)" { + t.Errorf("the region is shown as %q", region) + } + whole := shownAs(map[string]any{"id": "dnsmasq.fact-node-zones", "path": "/etc/mesh-resolver/nodes.conf"}) + if strings.Contains(whole, "written into") { + t.Errorf("a whole file is shown as written into: %q", whole) + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 71cee3b..5ad9161 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -524,8 +524,14 @@ func composeEach(names []string, continue } if len(declared.Resources) == 0 { - fmt.Printf("%s is assigned nothing — skipped\n", name) - continue + // Sent, not skipped (novox/hq issue 127). A node whose declaration composes to + // nothing may have HELD something before — the broker opening a placement gave it, + // say — and skipping the empty declaration leaves that last resource in force + // forever, re-applied by the node's own heartbeat, with no way for the mesh to say + // it is gone. An empty declaration is the correction: the host drops what the mesh + // owned and keeps what it found (the adoption envelope still rides along). A node + // that never held anything applies it as the no-op it is. + fmt.Printf("%s owns nothing now — sent so it drops what it last held\n", name) } sending = append(sending, readyNode{name, declared}) } diff --git a/cmd/mesh-controller/push_test.go b/cmd/mesh-controller/push_test.go index 3e6dc9d..aeaabfa 100644 --- a/cmd/mesh-controller/push_test.go +++ b/cmd/mesh-controller/push_test.go @@ -39,12 +39,14 @@ func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) { } } -// And a machine assigned nothing is neither sent nor a refusal — it is nothing to say. -func TestAMachineAssignedNothingIsNotARefusal(t *testing.T) { +// A machine whose declaration composes to nothing is SENT the empty declaration, not skipped +// (novox/hq issue 127): it may have held something before, and only sending the empty +// declaration tells it to drop what the mesh owned. It is never a refusal. +func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) { sending, refusals := composeEach([]string{"spare"}, func(string) (sendable, error) { return sendable{}, nil }) - if len(sending) != 0 || len(refusals) != 0 { - t.Errorf("a machine assigned nothing was treated as something: %v / %v", sending, refusals) + if len(sending) != 1 || len(refusals) != 0 { + t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals) } } diff --git a/cmd/mesh-controller/rollout.go b/cmd/mesh-controller/rollout.go index 4e4eb82..c386490 100644 --- a/cmd/mesh-controller/rollout.go +++ b/cmd/mesh-controller/rollout.go @@ -175,7 +175,7 @@ func readinessOf(ctx context.Context, inv *inventory.Inventory) (broker.Readines // would bury the ones that matter under a list nobody can act on. func speaksOnTheBus(m catalogue.Manifest) bool { return len(m.Emits) > 0 || len(m.Consumes) > 0 || len(m.Tools) > 0 || - len(m.Seats) > 0 || len(m.Uses) > 0 || len(m.Claims) > 0 + len(m.DefinesSeats) > 0 || len(m.Uses) > 0 || len(m.Claims) > 0 } // wasSentTheUserList says whether the machine holding the bus has had a declaration since the user diff --git a/cmd/mesh-controller/rollout_test.go b/cmd/mesh-controller/rollout_test.go index d5a1264..ba5ac2f 100644 --- a/cmd/mesh-controller/rollout_test.go +++ b/cmd/mesh-controller/rollout_test.go @@ -37,7 +37,7 @@ func TestReadinessIsGatheredFromWhatTheMeshHolds(t *testing.T) { } } for _, a := range [][2]string{{"anchor", "nats"}, {"anchor", "gitea"}, {"laptop", "wallpaper"}} { - if err := inv.Assign(ctx, a[0], a[1]); err != nil { + if _, err := inv.Assign(ctx, a[0], a[1]); err != nil { t.Fatal(err) } } diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go index 1a21c6b..b041c10 100644 --- a/cmd/mesh-controller/seats.go +++ b/cmd/mesh-controller/seats.go @@ -43,15 +43,16 @@ type seatRow struct { // overview would make the one thing the overview is for — what does this mesh have — quietly // incomplete. func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []catalogue.Held) { - defined := map[string]bool{} rows := make([]seatRow, 0, len(seats)) for _, s := range seats { - defined[s.Name] = true row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision, Holders: []seatHolder{}} seen := map[seatHolder]bool{} for _, h := range held { - if h.Claim != s.Name || h.Scope != s.Scope { + // Resolve the held claim to a seat rather than comparing names, so a record naming a + // seat's former name groups under it after a rename (novox/hq ADR 0122). + hs, ok := catalogue.SeatNamed(h.Claim) + if !ok || hs.Name != s.Name || h.Scope != s.Scope { continue } holder := seatHolder{Node: h.Node, Module: h.Module} @@ -70,7 +71,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata } var outside []catalogue.Held for _, h := range held { - if !defined[h.Claim] { + // Outside the set only if it resolves to no seat at all — a former name still resolves. + if _, ok := catalogue.SeatNamed(h.Claim); !ok { outside = append(outside, h) } } @@ -83,6 +85,25 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata return rows, outside } +// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122). +func seatCommand(ctx context.Context, args []string) error { + if len(args) == 3 && args[0] == "rename" { + from, to := args[1], args[2] + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + if err := open.inventory.RenameSeat(ctx, from, to); err != nil { + return err + } + fmt.Printf("%s is now %s — its former name still resolves, so nothing is rebuilt, "+ + "re-registered or frozen (novox/hq ADR 0122)\n", from, to) + return nil + } + return fmt.Errorf("seat rename ") +} + func seatsCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("seats", flag.ContinueOnError) asJSON := set.Bool("json", false, "the same, as JSON") diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go index dc8d258..9fc6135 100644 --- a/cmd/mesh-controller/seats_test.go +++ b/cmd/mesh-controller/seats_test.go @@ -35,13 +35,13 @@ func TestEverySeatIsListedIncludingTheOnesNobodyHolds(t *testing.T) { func TestANodeSeatListsEveryMachineHoldingIt(t *testing.T) { rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{ - {Claim: "mesh-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"}, - {Claim: "mesh-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, + {Claim: "node-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"}, + {Claim: "node-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, // Resolved twice, reported once: a machine is one holder however many passes saw it. - {Claim: "mesh-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, + {Claim: "node-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, }) for _, r := range rows { - if r.Seat != "mesh-packet-filter" { + if r.Seat != "node-packet-filter" { continue } if len(r.Holders) != 2 || r.Holders[0].Node != "anchor" || r.Holders[1].Node != "node2" { diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 8c282ac..fdebf7e 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -38,6 +38,12 @@ func (s sendable) Body() ([]byte, error) { if s.Adoption != nil { envelope["adoption"] = s.Adoption } + // An empty declaration is deliberate here — the node owns nothing the mesh put there + // (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness + // is meant, so a truncated or mis-composed body is never mistaken for "own nothing". + if len(s.Resources) == 0 { + envelope["owns_nothing"] = true + } return json.Marshal(envelope) } diff --git a/cmd/mesh-controller/sendable_test.go b/cmd/mesh-controller/sendable_test.go index f831e34..73ce86c 100644 --- a/cmd/mesh-controller/sendable_test.go +++ b/cmd/mesh-controller/sendable_test.go @@ -355,3 +355,25 @@ func TestTheMachineSideOfAMappingIsMovedEverywhereTheNumberIsUsed(t *testing.T) t.Fatalf("the consumer is told the forge answers on %v", told) } } + +func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) { + // The host refuses an empty body unless told the emptiness is meant (novox/hq issue 127). + body, err := sendable{}.Body() + if err != nil { + t.Fatal(err) + } + var env map[string]any + if err := json.Unmarshal(body, &env); err != nil { + t.Fatal(err) + } + if env["owns_nothing"] != true { + t.Fatalf("an empty declaration must mark owns_nothing; got %v", env) + } + // A declaration with resources does not carry the marker. + body, _ = sendable{Resources: []map[string]any{{"id": "x"}}}.Body() + var env2 map[string]any + _ = json.Unmarshal(body, &env2) + if _, present := env2["owns_nothing"]; present { + t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env) + } +} diff --git a/cmd/mesh-controller/source.go b/cmd/mesh-controller/source.go index 292db22..760dda7 100644 --- a/cmd/mesh-controller/source.go +++ b/cmd/mesh-controller/source.go @@ -18,7 +18,7 @@ import ( // seat's holder runs. // gitSeat is the seat a self-hosted repository lives on. -const gitSeat = "mesh-git" +const gitSeat = "git" // buildSource is where a build's repository is: a URL, or a path on a seat's holder. type buildSource struct { diff --git a/cmd/mesh-controller/source_test.go b/cmd/mesh-controller/source_test.go index 9602471..cd0030b 100644 --- a/cmd/mesh-controller/source_test.go +++ b/cmd/mesh-controller/source_test.go @@ -11,7 +11,7 @@ import ( func forgeHolding(port any) catalogue.World { return catalogue.World{ - Held: []catalogue.Held{{Claim: "mesh-git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}}, + Held: []catalogue.Held{{Claim: "git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}}, Offered: map[string][]catalogue.Provider{"git": { // A second forge that does not hold the seat, so taking the first one found would be wrong. {Node: "archive", At: "archive.internal", Module: "gitea-mirror", @@ -23,7 +23,7 @@ func forgeHolding(port any) catalogue.World { } func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) { - got, err := clonedFromSeat(forgeHolding(float64(3000)), "mesh-git", "novox/mesh-catalog") + got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog") if err != nil { t.Fatal(err) } @@ -35,7 +35,7 @@ func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) { func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) { // The whole point: the node gave the forge another port, and the same recorded path clones // from the new one. Nothing recorded contained the old one to be wrong. - got, err := clonedFromSeat(forgeHolding(float64(3100)), "mesh-git", "novox/mesh-catalog") + got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog") if err != nil { t.Fatal(err) } @@ -45,11 +45,11 @@ func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) { } func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) { - _, err := clonedFromSeat(catalogue.World{}, "mesh-git", "novox/mesh-catalog") + _, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog") if err == nil { t.Fatal("a repository was cloned from a forge the mesh does not have") } - for _, want := range []string{"nobody holds the mesh-git seat", "without --self"} { + for _, want := range []string{"nobody holds the git seat", "without --self"} { if !strings.Contains(err.Error(), want) { t.Fatalf("the refusal does not say %q: %v", want, err) } @@ -71,7 +71,7 @@ func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) { func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) { world := forgeHolding(float64(3000)) world.Offered["git"][1].At = "" - if _, err := clonedFromSeat(world, "mesh-git", "novox/mesh-catalog"); err == nil || + if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil || !strings.Contains(err.Error(), "private network") { t.Fatalf("a forge nothing can reach was cloned from: %v", err) } @@ -79,7 +79,7 @@ func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) { func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) { // A default port would be the forge's address guessed, which is what this exists to stop. - if _, err := clonedFromSeat(forgeHolding(nil), "mesh-git", "novox/mesh-catalog"); err == nil { + if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil { t.Fatal("a port was guessed for a forge that serves none") } } @@ -101,8 +101,8 @@ func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) { } func TestASourceOnTheSeatReadsAsAPathNotAnAddress(t *testing.T) { - s := buildSource{Repository: "novox/mesh-catalog", Seat: "mesh-git"} - if got := s.String(); got != "novox/mesh-catalog on the mesh-git seat" { + s := buildSource{Repository: "novox/mesh-catalog", Seat: "git"} + if got := s.String(); got != "novox/mesh-catalog on the git seat" { t.Fatalf("read as %q", got) } } diff --git a/cmd/mesh-controller/stores.go b/cmd/mesh-controller/stores.go index 8a46256..1fa2045 100644 --- a/cmd/mesh-controller/stores.go +++ b/cmd/mesh-controller/stores.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/identity" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/licences" @@ -72,6 +73,14 @@ func migrate(ctx context.Context) error { } fmt.Printf("provided %s\n", m.Module) } + // The seats the mesh ships with, into the table that now holds the set (novox/hq ADR 0122). + // Idempotent: fills an empty table on first boot, adds a seat a release ships, and leaves an + // operator's changes in the table as they are. + added, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()) + if err != nil { + return err + } + fmt.Printf("seeded %d seat(s)\n", added) return nil } @@ -85,6 +94,18 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) { inv.Close() return nil, err } + // Load the seat set from the store, so the control plane reads the set as data rather than as + // the slice it was compiled with (novox/hq ADR 0122). A store not yet seeded — or one whose + // seat table a migration has not reached — returns nothing, and UseSeats leaves the compiled + // defaults in force: the set is never emptied by a read that found nothing, which would refuse + // every claim. So this can only ever replace the defaults with what the mesh actually holds. + if seats, err := inv.Seats(ctx); err == nil { + catalogue.UseSeats(seats) + } + // And the former names, so a reference to a seat's old name resolves after a rename (ADR 0122). + if aliases, err := inv.Aliases(ctx); err == nil { + catalogue.UseAliases(aliases) + } return inv, nil } diff --git a/internal/broker/agreement_catalogue_test.go b/internal/broker/agreement_catalogue_test.go index 9a61b3d..218904e 100644 --- a/internal/broker/agreement_catalogue_test.go +++ b/internal/broker/agreement_catalogue_test.go @@ -67,7 +67,7 @@ func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) { // An event published under a seat's name is real even though no module declares it as its own. if bad := Disagreements(nil, []AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}}, - []DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 { + []DeclaredSeat{{Name: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 { t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad) } } diff --git a/internal/catalogue/artifact_store_seat_test.go b/internal/catalogue/artifact_store_seat_test.go index 182dcac..4b658e4 100644 --- a/internal/catalogue/artifact_store_seat_test.go +++ b/internal/catalogue/artifact_store_seat_test.go @@ -23,7 +23,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { } // A second one, on any other machine, is refused — and the refusal names the seat. - elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh, + elsewhere := World{Held: []Held{{Claim: "the-artifact-store", Scope: ScopeMesh, Node: "anchor", Module: "distribution"}}} other := workstation() other.Name = "laptop" @@ -32,7 +32,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + "second time and every consumer elsewhere would refuse to choose") } - if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { + if !strings.Contains(err.Error(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { t.Fatalf("refused without naming the seat: %v", err) } } diff --git a/internal/catalogue/broker_seat_test.go b/internal/catalogue/broker_seat_test.go index a99bc71..20c33c5 100644 --- a/internal/catalogue/broker_seat_test.go +++ b/internal/catalogue/broker_seat_test.go @@ -71,9 +71,9 @@ func TestTheAmqpBrokerDoesNotContendForTheSeat(t *testing.T) { // "the package registry is served on ", which does not say "you renamed an interface". func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) { for _, pair := range []struct{ seat, delivers string }{ - {"mesh-git", "git"}, - {"mesh-npm-package-registry", "npm-package-registry"}, - {"mesh-artifact-store", "artifact-store"}, + {"git", "git"}, + {"npm-package-registry", "npm-package-registry"}, + {"the-artifact-store", "artifact-store"}, {"mesh-store", "postgres-database"}, {"mesh-broker", "mesh-bus"}, } { @@ -86,19 +86,16 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) { "interface with it, and every consumer requiring it would stop resolving", pair.seat, s.Delivers, pair.delivers) } - if _, isSeat := SeatNamed(pair.delivers); isSeat { - t.Errorf("%q is both an interface and a seat name; one of the renames was incomplete", - pair.delivers) - } + // **Three of these deliberately share a name with what they deliver**, and that is not an + // incomplete rename. Renaming a seat that delivers a provision cascades to every consumer + // requiring it, with a mesh-wide window where a holder stops resolving mid-flight — so the + // trunk deferred exactly those three (novox/hq ADR 0121) while renaming the node-scoped ones. + // What this test is for is the other direction: that renaming a seat never moves the + // interface, which once produced "the package registry is served on ". } } -// And a manifest written against an old seat name is told what it became, rather than refused as -// unknown — the courtesy the `needs`/`own-secrets` rename already sets. -func TestAnOldSeatNameSaysWhatItBecame(t *testing.T) { - m := Manifest{Module: "old", Claims: []Claim{{Name: "the-catalogue", Scope: ScopeMesh}}} - got := strings.Join(claimProblems(m), "; ") - if !strings.Contains(got, "the-catalogue") || !strings.Contains(got, "mesh-catalog") { - t.Fatalf("the refusal does not name both the old and the new: %q", got) - } -} +// A manifest written against an old seat name is told what it became rather than refused as +// unknown. **That map is the controller's store now, not this package** (novox/hq ADR 0122): a +// rename is a row, so the courtesy survives a rename nobody recompiled for. Checked where the +// table is read, not here, where there is no longer a hardcoded list to check against. diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index f78f661..1a9a410 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -104,6 +104,17 @@ type Rendering struct { // its mounts (Manifest.BusUsers). BusUsers string + // MeshRange is the private network's CIDR (the range node addresses are allocated from), for a + // module that must name the whole mesh rather than one machine — an intrusion filter that must + // never ban a tunnel peer, say. A per-mesh value the module cannot know, so it is carried here + // and offered as ${machine:mesh-range}, the same way one machine's address is. + MeshRange string + + // Accounts is each machine's operator account, by the same internal name Names uses (novox/hq + // to-be 29). What an ssh Host block's `User` line is composed from; empty for a machine no + // operator account is known on. + Accounts map[string]string + // Kept is every operator-sealed secret in the mesh, for a module that `keeps` them. Nil when // nothing on this node keeps them, or the mesh has no operator key. Kept *KeptExport @@ -346,6 +357,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri "content": filtering, "mode": "0600", }) } + // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written + // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all + // modules, written by the one that holds the role. + if j := m.Jailing; j != nil { + first = append(first, jailsInto(r.Modules, j)...) + } if c := m.Certificate; c != nil { if with.Certificate == "" { // Asked for and not issued. Refused rather than skipped: a module that serves TLS @@ -581,7 +598,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. dirs := dirsFor(m, with) // And the machine underneath, which no binding of its own can tell it. - thisMachine := machineFacts(r, with.Names) + thisMachine := machineFacts(r, with.Names, with.MeshRange) // Which of this module's files carry a secret, for the rule that a container may not read // one of them as its environment without saying so (ADR 0086, issue 041). @@ -680,7 +697,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // 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.Suffix) + given, err := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix) if err != nil { return nil, err } diff --git a/internal/catalogue/domain_test.go b/internal/catalogue/domain_test.go index 299543b..967985b 100644 --- a/internal/catalogue/domain_test.go +++ b/internal/catalogue/domain_test.go @@ -17,7 +17,7 @@ func networkingShelf(extra ...Manifest) map[string]Manifest { {Module: "networking", Requires: []string{"private-network", "name-resolution"}}, {Module: "mesh-wireguard", Computed: "mesh-wireguard", Provides: Offers("private-network", "mesh-addressing"), - Claims: []Claim{{Name: "mesh-private-network", Scope: ScopeNode}}}, + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, {Module: "mesh-names", Computed: "mesh-names", Provides: Offers("name-resolution"), Requires: []string{"mesh-addressing"}}, } @@ -44,7 +44,7 @@ func TestASecondVPNTurnsItIntoAChoice(t *testing.T) { // back under another name. _, err := Resolve(networkingShelf( Manifest{Module: "tailscale", Provides: Offers("private-network"), - Claims: []Claim{{Name: "mesh-private-network", Scope: ScopeNode}}}, + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, ), []string{"networking"}, workstation(), World{}) if err == nil { @@ -62,7 +62,7 @@ func TestChoosingIsAssigning(t *testing.T) { got, err := Resolve(networkingShelf( Manifest{Module: "tailscale", Provides: Offers("private-network", "name-resolution"), - Claims: []Claim{{Name: "mesh-private-network", Scope: ScopeNode}}}, + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, ), []string{"networking", "tailscale"}, workstation(), World{}) if err != nil { @@ -85,13 +85,13 @@ func TestChoosingOneVPNCannotDragTheOtherBackIn(t *testing.T) { // machine could run two VPNs for two purposes — but being *the* one the mesh runs over is. _, err := Resolve(networkingShelf( Manifest{Module: "tailscale", Provides: Offers("private-network"), - Claims: []Claim{{Name: "mesh-private-network", Scope: ScopeNode}}}, + Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}}, ), []string{"networking", "tailscale"}, workstation(), World{}) if err == nil { t.Fatal("a machine was given two private networks without being told") } - if !strings.Contains(err.Error(), "mesh-private-network") { + if !strings.Contains(err.Error(), "the-private-network") { t.Fatalf("the refusal does not say what collided: %v", err) } } diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go deleted file mode 100644 index 2fdd510..0000000 --- a/internal/catalogue/facts.go +++ /dev/null @@ -1,184 +0,0 @@ -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. -// A fact is written from the names it is about. `every` is every name the mesh serves — machines -// and the names it was told to route; `machines` is only the machines. A fact takes the set it is -// true of, and the two must not be confused (novox/hq 04-ISSUES/111). -var facts = map[string]func(r Resolution, every, machines map[string]string, suffix string) string{ - FactNodeNames: func(r Resolution, every, _ map[string]string, suffix string) string { - return nodeNames(r, every, suffix) - }, - FactNodeZones: func(r Resolution, _, machines map[string]string, suffix string) string { - return nodeZones(r, machines, suffix) - }, -} - -// 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, machines map[string]string, suffix 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, machines, suffix), - }) - } - 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, suffix 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] - internal, bare := meshName(name, suffix) - // 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\t%s", at, internal, bare) - if bare == 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. -// -// **And the suffix itself, as a local domain.** A resolver that forwards what it cannot answer -// would otherwise send a mesh name it does not know — a machine that left, a typo — to a public -// resolver, which is a leak of the mesh's names for no answer. `local=` keeps everything under the -// suffix here: answered from the lines below or refused. Written in this file rather than in the -// resolver's own configuration because the suffix is the mesh's choice (the operator may have -// picked another) and this file is the one place the mesh writes what it chose. -func nodeZones(_ Resolution, addresses map[string]string, suffix 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") - fmt.Fprintf(&b, "local=/%s/\n", strings.TrimPrefix(suffixOr(suffix), ".")) - for _, name := range sortedNames(addresses) { - internal, _ := meshName(name, suffix) - fmt.Fprintf(&b, "address=/%s/%s\n", internal, addresses[name]) - } - return b.String() -} - -// meshName is a machine's internal name and its bare one, from either. The control plane keys -// the names it hands a resolution by the internal name (`homer.internal`), the same map a -// container gets as its hosts; a caller that keys by the bare name gets the same answer. The -// suffix is the one the control plane composed those names with, handed down rather than written -// here a second time — the alternative was `homer.internal.internal` on every machine. -func meshName(name, suffix string) (internal, bare string) { - dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") - if strings.HasSuffix(name, dotted) { - return name, strings.TrimSuffix(name, dotted) - } - return name + dotted, name -} - -// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. -// The one place the default is written in this file, so a fact and a name cannot disagree about it. -func suffixOr(suffix string) string { - if suffix == "" { - return "internal" - } - return suffix -} - -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 deleted file mode 100644 index 1f30b69..0000000 --- a/internal/catalogue/facts_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Keyed by the internal name, as the control plane hands them (issue 079). -var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} - -// **`*.homer.internal` is homer. That is the whole rule.** And the suffix itself is local: a -// resolver that forwards what it cannot answer must not send a mesh name it does not know — a -// machine that left, a typo — to a public resolver (hal dnsmasq-app conversion, novox/hq -// 08-connectivity). -func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) { - out := nodeZones(Resolution{Node: "homer"}, threeMachines, "") - for _, want := range []string{ - "local=/internal/", - "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, 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, 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, nil, ""); err == nil { - t.Fatal("a relative path was accepted") - } -} - -// **The names the control plane hands a resolution are already internal names** — `homer.internal`, -// the same map every container gets as its hosts. Appending the suffix again wrote -// `homer.internal.internal` into every hosts file and every resolver's zones, and the large mesh -// bed's name test was the first to read it back. Either key gives the same files. -func TestNamesKeyedByInternalNameAreNotSuffixedTwice(t *testing.T) { - internal := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - bare := map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2"} - if a, b := nodeZones(Resolution{Node: "homer"}, internal, ""), nodeZones(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the zones differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - if a, b := nodeNames(Resolution{Node: "homer"}, internal, ""), nodeNames(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the hosts differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - zones := nodeZones(Resolution{Node: "homer"}, internal, "") - if strings.Contains(zones, "internal.internal") || !strings.Contains(zones, "address=/homer.internal/10.42.0.1") { - t.Fatalf("the zones carry a doubled suffix or miss the name:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, internal, "") - if !strings.Contains(hosts, "10.42.0.1\thomer.internal\thomer\t# this machine") { - t.Fatalf("the hosts line for the machine itself is not name, bare name and the mark:\n%s", hosts) - } -} - -// The suffix the control plane composed the names with is the one the facts write — an operator -// who chose another does not get `.internal` appended to it. -func TestTheFactsWriteTheSuffixTheNamesWereComposedWith(t *testing.T) { - names := map[string]string{"homer.lan": "10.42.0.1"} - zones := nodeZones(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(zones, "address=/homer.lan/10.42.0.1") || strings.Contains(zones, "internal") { - t.Fatalf("the zones do not carry the operator's suffix as given:\n%s", zones) - } - if !strings.Contains(zones, "local=/lan/") { - t.Fatalf("the local domain is not the operator's suffix, so its names would leak upstream:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(hosts, "10.42.0.1\thomer.lan\thomer\t# this machine") { - t.Fatalf("the hosts line does not carry the operator's suffix as given:\n%s", hosts) - } -} - -// novox/hq 04-ISSUES/111: the map the control plane hands a resolution holds every name the mesh -// serves — the machines, and the names it was told to route to whichever machine serves them. A -// container's hosts wants all of it. A resolver's zones want only the machines: told the mesh's -// suffix is its own, it answers authoritatively for everything under it and forwards nothing, so a -// routed name written there with the suffix appended is a name nobody will ever ask for, standing -// beside the machines and looking as real. -func TestTheResolverIsToldTheMachinesAndNotTheNamesTheMeshMerelyServes(t *testing.T) { - machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - every := map[string]string{ - "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", - "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", - } - m := Manifest{Module: "resolver", Facts: map[string]string{ - FactNodeZones: "/etc/zones.conf", FactNodeNames: "/etc/hosts", - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") - if err != nil { - t.Fatal(err) - } - by := map[string]string{} - for _, f := range given { - by[f["path"].(string)] = f["content"].(string) - } - - zones := by["/etc/zones.conf"] - for _, machine := range []string{"address=/homer.internal/10.42.0.1", "address=/marge.internal/10.42.0.2"} { - if !strings.Contains(zones, machine) { - t.Fatalf("the resolver was not told %q:\n%s", machine, zones) - } - } - for _, served := range []string{"drive.example.test", "git.example.test"} { - if strings.Contains(zones, served) { - t.Fatalf("the resolver was told %q, a name the mesh serves rather than a machine:\n%s", served, zones) - } - } - - // And the hosts file is the other way about: every name, so a container reaching a routed name - // finds the machine serving it. - hosts := by["/etc/hosts"] - for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { - if !strings.Contains(hosts, name) { - t.Fatalf("a container would not resolve %q from its hosts:\n%s", name, hosts) - } - } -} diff --git a/internal/catalogue/foundation_manifests_test.go b/internal/catalogue/foundation_manifests_test.go index 10661bf..5565993 100644 --- a/internal/catalogue/foundation_manifests_test.go +++ b/internal/catalogue/foundation_manifests_test.go @@ -124,7 +124,7 @@ func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) { // unused. func TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers(t *testing.T) { builder := catalogueManifest(t, "builder") - seat, _ := SeatNamed("mesh-npm-package-registry") + seat, _ := SeatNamed("npm-package-registry") var requires bool for _, r := range builder.Requires { requires = requires || r == seat.Delivers @@ -150,7 +150,7 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) { for _, c := range forge.Claims { holds[c.Name] = true } - for _, seat := range []string{"mesh-npm-package-registry", "mesh-git"} { + for _, seat := range []string{"npm-package-registry", "git"} { if !holds[seat] { t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims) } diff --git a/internal/catalogue/hosts_region_test.go b/internal/catalogue/hosts_region_test.go new file mode 100644 index 0000000..16e817d --- /dev/null +++ b/internal/catalogue/hosts_region_test.go @@ -0,0 +1,106 @@ +package catalogue_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/overlay" +) + +// novox/hq issue 128, held where the host will see it: the shipped networking module's names, +// through the whole composition, and not only through FactsInto. +// +// Composition prefixes a fact's id with the module that asked for it and passes everything else +// through; a step that dropped `into` on the way would send the region as a whole file, and the +// host would write the machine's hosts file over again with every unit test above still green. + +// onTheNetwork stands in for the overlay's generator: the node is part of the private network, +// and what the generator writes is not what is under test here. +type onTheNetwork struct{} + +func (onTheNetwork) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "overlay-config", "type": "file", + "path": "/etc/wireguard/mesh0.conf", "mode": "0600", "content": "[Interface]\n"}}, true, nil +} + +func TestTheHostsRegionArrivesAsTheHostWillReadIt(t *testing.T) { + shelf := provided(t) + // A resolver restarting on the names another module put on the machine, and one resource it + // only runs at start — neither of which composition has any business changing. + resolver, err := catalogue.ParseManifest([]byte(`{ + "module": "resolver", "version": "1", "requires": ["mesh-addressing"], + "resources": [ + {"id": "seed", "type": "file", "path": "/etc/resolver/seed", "mode": "0644", + "content": "seed\n", "at": "start"}, + {"id": "daemon", "type": "service", "unit": "resolver.service", "state": "running", + "restart-on": ["seed", "mesh-wireguard.fact-node-names"]} + ]}`)) + if err != nil { + t.Fatal(err) + } + shelf[resolver.Module] = resolver + + got, err := catalogue.Resolve(shelf, []string{overlay.Domain, "resolver"}, + catalogue.Node{Name: "homer", At: "homer.internal"}, catalogue.World{}) + if err != nil { + t.Fatal(err) + } + names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + out, err := got.Declaration(catalogue.Rendering{ + Names: names, Machines: names, Suffix: "internal", + Generators: map[string]catalogue.Generator{overlay.Name: onTheNetwork{}}, + }) + if err != nil { + t.Fatal(err) + } + ids := map[string]map[string]any{} + for _, r := range out { + ids[r["id"].(string)] = r + } + + hosts := ids[overlay.Name+".fact-node-names"] + if hosts == nil { + t.Fatalf("no names reached the machine; the declaration has %v", keys(ids)) + } + if hosts["path"] != "/etc/hosts" || hosts["into"] != "block" { + t.Fatalf("the hosts file is not written into as a region: %v", hosts) + } + content := hosts["content"].(string) + if !strings.Contains(content, "10.42.0.1\thomer.internal\thomer\t# this machine\n") { + t.Errorf("the region does not name the machine:\n%s", content) + } + for _, floor := range []string{"Generated by the mesh", "localhost", "127.0.1.1"} { + if strings.Contains(content, floor) { + t.Errorf("the region carries %q, which is the machine's:\n%s", floor, content) + } + } + + // The resolver's reference to it still names a resource the host will be sent. + daemon := ids["resolver.daemon"] + if daemon == nil { + t.Fatalf("the resolver's service was not composed: %v", keys(ids)) + } + for _, named := range daemon["restart-on"].([]any) { + if ids[named.(string)] == nil { + t.Errorf("the resolver restarts on %v, which is nothing the host is sent", named) + } + } + if !reflect.DeepEqual(daemon["restart-on"], []any{"resolver.seed", overlay.Name + ".fact-node-names"}) { + t.Errorf("restart-on is %v", daemon["restart-on"]) + } + + // And a resource's own `at` passes through as the manifest wrote it. + if seed := ids["resolver.seed"]; seed == nil || seed["at"] != "start" { + t.Errorf("a resource's at did not survive composition: %v", seed) + } +} + +func keys(m map[string]map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/catalogue/jails_into.go b/internal/catalogue/jails_into.go new file mode 100644 index 0000000..3b5bf36 --- /dev/null +++ b/internal/catalogue/jails_into.go @@ -0,0 +1,62 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A node's fail2ban jails, composed from the modules it runs (novox/hq to-be 31). +// +// **The same shape as the firewall.** Every module's `listens` become the node's rule set; every +// module's `jails` become the node's fail2ban config. A module that runs an authenticating service +// declares what a break-in on it looks like and how to ban it, naming no node and no path (ADR +// 0112); the intrusion-prevention holder — the one module with `jailing` — gathers them and writes +// them where it owns. A node not running a module has none of its jails. + +// jailsInto composes every jail declared by the modules on a node into the files the holder writes: +// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter +// file per jail (its failregex, which fail2ban references by the jail's name). +// +// Owned by the holder, because the directory is: two modules writing into one fail2ban is the +// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file +// is written empty rather than absent, so removing the last jail is an ordinary change the service +// restarts on rather than a file that vanishes. +func jailsInto(modules []Manifest, j *Jailing) []map[string]any { + type declared struct { + module string + jail Jail + } + var jails []declared + for _, m := range modules { + for _, jail := range m.Jails { + jails = append(jails, declared{m.Module, jail}) + } + } + // A stable order the host applies as given (ADR 0005), and so the same set composes byte for + // byte every time rather than differing by map iteration. + sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name }) + + var composed strings.Builder + composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n") + composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n") + + out := make([]map[string]any, 0, len(jails)+1) + for _, d := range jails { + fmt.Fprintf(&composed, "\n# from %s\n[%s]\nenabled = true\nfilter = %s\n%s\n", + d.module, d.jail.Name, d.jail.Name, strings.TrimRight(d.jail.Jail, "\n")) + // The filter is a file of its own, named as the jail's filter= references it. + out = append(out, map[string]any{ + "id": "filter-" + d.jail.Name, + "type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf", + "mode": "0644", + "content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" + + "[Definition]\nfailregex = " + d.jail.Failregex + "\n", + }) + } + // The one jail file, first, with the fixed id the fail2ban service names in its restart-on. + return append([]map[string]any{{ + "id": ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644", + "content": composed.String(), + }}, out...) +} diff --git a/internal/catalogue/jails_into_test.go b/internal/catalogue/jails_into_test.go new file mode 100644 index 0000000..9ddb083 --- /dev/null +++ b/internal/catalogue/jails_into_test.go @@ -0,0 +1,46 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A node's fail2ban jails are composed from the modules it runs (novox/hq to-be 31): the holder +// (jailing) gathers every module's declared jail into one jail file and a filter file per jail. +func TestJailsAreComposedFromTheNodesModules(t *testing.T) { + modules := []Manifest{ + {Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}}, + {Module: "postgres", Jails: []Jail{{Name: "postgres-auth", Failregex: "auth failed from ", Jail: "port = 5432\nmaxretry = 5"}}}, + } + files := jailsInto(modules, modules[0].Jailing) + + by := map[string]map[string]any{} + for _, f := range files { + by[f["id"].(string)] = f + } + jail := by[ComposedJailsID()] + if jail == nil || jail["path"] != "/etc/fail2ban/jail.d/mesh-composed.conf" { + t.Fatalf("the composed jail file was not written: %v", jail) + } + body := jail["content"].(string) + if !strings.Contains(body, "[postgres-auth]") || !strings.Contains(body, "filter = postgres-auth") || + !strings.Contains(body, "port = 5432") { + t.Fatalf("the postgres jail stanza was not composed in:\n%s", body) + } + filter := by["filter-postgres-auth"] + if filter == nil || filter["path"] != "/etc/fail2ban/filter.d/postgres-auth.conf" { + t.Fatalf("the jail's filter file was not written: %v", filter) + } + if !strings.Contains(filter["content"].(string), "failregex = auth failed from ") { + t.Fatalf("the failregex was not written: %v", filter["content"]) + } +} + +// A holder whose node runs no jail-declaring module still gets the file, empty — so removing the +// last jail is a change the service restarts on, not a file that vanishes. +func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) { + files := jailsInto([]Manifest{{Module: "fail2ban"}}, &Jailing{Into: "/x", FilterInto: "/f"}) + if len(files) != 1 || files[0]["id"] != ComposedJailsID() { + t.Fatalf("the empty composed jail file was not written alone: %v", files) + } +} diff --git a/internal/catalogue/machine_into_files.go b/internal/catalogue/machine_into_files.go index cb642bc..bc2071a 100644 --- a/internal/catalogue/machine_into_files.go +++ b/internal/catalogue/machine_into_files.go @@ -57,7 +57,7 @@ func machineUsed(content string) []string { // the hosts file and the resolver's wildcards are written from, so a file naming the machine's // address and the file every other machine reaches it by cannot disagree. Absent, like `at`, when // the machine is off the network or the mesh has not placed it. -func machineFacts(r Resolution, names map[string]string) map[string]string { +func machineFacts(r Resolution, names map[string]string, meshRange string) map[string]string { out := map[string]string{"name": r.Node} if r.At != "" { out["at"] = r.At @@ -65,32 +65,59 @@ func machineFacts(r Resolution, names map[string]string) map[string]string { out["address"] = address } } + // The private network's whole range — a mesh-wide fact, not this machine's, but named here + // because a module cannot know it and sometimes must (an intrusion filter that must never ban a + // tunnel peer). Absent when the mesh has no range to give. + if meshRange != "" { + out["mesh-range"] = meshRange + } + // The operator's login on this machine and where its home is (novox/hq to-be 29), so a module + // that writes operator config names the account and its home rather than a value it cannot know. + // Absent when no operator account is known — a headless box a person never logs into. + if r.Account != "" { + out["account"] = r.Account + out["account-home"] = accountHomeOf(r.Account, r.AccountHome) + } return out } +// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for +// root, /home/ otherwise. The one place the default is written, so a fact and the store +// cannot disagree about it. +func accountHomeOf(account, home string) string { + if home != "" { + return home + } + if account == "root" { + return "/root" + } + return "/home/" + account +} + // machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the // machine the module was assigned to. // // A key the mesh does not hold is refused, for the same reason a binding's is: left alone, the // literal would be written into a configuration file and read as a value. func machineInto(resource map[string]any, facts map[string]string, module string) error { - if fmt.Sprint(resource["type"]) != "file" { - return nil - } - content, ok := resource["content"].(string) - if !ok { - return nil - } - for _, key := range machineUsed(content) { - value, has := facts[key] - if !has { - return fmt.Errorf( - "%s has a file that says ${machine:%s}, and this machine says %s", - module, key, orNothing(namesOfFacts(facts))) + // Content, and now the path and owner too: a module that writes into a person's home names it + // with ${machine:account-home} and ${machine:account}, which it cannot know until assigned + // (novox/hq to-be 29), the same reason its content names ${machine:address}. + for _, field := range []string{"path", "owner", "content"} { + s, ok := resource[field].(string) + if !ok { + continue + } + for _, key := range machineUsed(s) { + value, has := facts[key] + if !has { + return fmt.Errorf( + "%s has a %s that says ${machine:%s}, and this machine says %s", + module, field, key, orNothing(namesOfFacts(facts))) + } + s = strings.ReplaceAll(s, fmt.Sprintf("${machine:%s}", key), value) + resource[field] = s } - resource["content"] = strings.ReplaceAll( - content, fmt.Sprintf("${machine:%s}", key), value) - content = resource["content"].(string) } return nil } diff --git a/internal/catalogue/machine_into_files_test.go b/internal/catalogue/machine_into_files_test.go index e59f3a2..74bc60b 100644 --- a/internal/catalogue/machine_into_files_test.go +++ b/internal/catalogue/machine_into_files_test.go @@ -103,3 +103,26 @@ func TestAModuleNamesTheAddressBehindItsMachinesName(t *testing.T) { t.Fatalf("a machine off the network was given an address, or refused for another reason: %v", err) } } + +// The mesh's private range is offered as ${machine:mesh-range}, so a module names it rather than +// hardcoding a value it cannot know (novox/hq ADR 0112) — the fail2ban ignoreip is the case. +func TestAModuleNamesTheMeshRange(t *testing.T) { + facts := machineFacts(Resolution{Node: "anchor", At: "anchor.internal"}, + map[string]string{"anchor.internal": "10.10.0.1"}, "10.10.0.0/24") + if facts["mesh-range"] != "10.10.0.0/24" { + t.Fatalf("the mesh range is not a machine fact: %v", facts) + } + res := map[string]any{"type": "file", "id": "jail", "content": "ignoreip = 127.0.0.1/8 ${machine:mesh-range}\n"} + if err := machineInto(res, facts, "fail2ban"); err != nil { + t.Fatal(err) + } + if got := res["content"].(string); !strings.Contains(got, "10.10.0.0/24") || strings.Contains(got, "${machine:") { + t.Fatalf("the mesh range was not written in: %q", got) + } + // A mesh with no range gives no such fact, and a file that names it is refused rather than + // left with a literal placeholder in it. + none := machineFacts(Resolution{Node: "anchor"}, nil, "") + if _, has := none["mesh-range"]; has { + t.Fatal("a mesh with no range still offered one") + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index ed3b37f..64a67bc 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -206,10 +206,18 @@ type Manifest struct { // that every new module would force its predecessors to update. Claims []Claim `json:"claims,omitempty"` - // Seats this module declares of its own, with their protocols (novox/hq ADR 0118). The set - // of seats a mesh has is the mesh's own plus these, derived from what is registered rather - // than written in the controller — closed, and extensible without changing the mesh. - Seats []SeatDeclaration `json:"seats,omitempty"` + // DefinesSeats are the seats this module defines for itself, with their protocols + // (novox/hq ADR 0121, ADR 0129). The control plane defines the system seats — `mesh-*` and + // `node-*` — and a module may define its own, named outside that namespace, to coordinate its + // own instances: the mesh enforces one-holder-per-scope for it without knowing what it means. A + // module's declared seat is the only non-system name it may then claim; a claim to a name + // neither the mesh nor the module defines is refused. + // + // **Two lines of work built this at once**, one calling it `Seats` with a protocol and one + // `DefinesSeats` without. Same key in the file, so no manifest is affected: this is the trunk's + // name with the richer type, because what a role accepts, emits and serves is what lets the mesh + // check that a holder answers what its seat promises. + DefinesSeats []SeatDeclaration `json:"seats,omitempty"` // Uses are seats this module sends to. It names the *seat*, never the module holding it, so // the implementation can be replaced under it and no caller changes. A caller gets publish @@ -392,6 +400,14 @@ type Manifest struct { // that could only see its own ports would write a rule set that closed everything else. Filtering *Filtering `json:"filtering,omitempty"` + // Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31). + // Written into whichever node runs the module, the same way `listens` become that node's rules. + Jails []Jail `json:"jails,omitempty"` + + // Jailing marks the module that composes the node's fail2ban jails — the intrusion-prevention + // holder. Like Filtering: one module per node gathers what every module declared and writes it. + Jailing *Jailing `json:"jailing,omitempty"` + // Guards are ports of this module's the mesh refuses on an adopted node except from the // private network and from the machine itself (novox/hq ADR 0100) — the store's port and the // broker's management port. The ports the software uses; the mesh guards where the machine @@ -400,24 +416,29 @@ type Manifest struct { // firewall does. Ignored on a converged node, whose derived filter already closes them. Guards []int `json:"guards,omitempty"` - // Facts are things only the mesh knows, written where this module asks for them. + // Facts are things only the mesh knows, written where this module asks for them — in the + // module's own format. // - // **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. + // **The graph is the control plane's; the format 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, a peer that is reachable, a host a client trusts, is somebody's software — dnsmasq, + // a resolver, a VPN, ssh — in its own configuration language, and the mesh has no business + // knowing it. So a module gives a path and a template; the mesh renders the roster through it + // and owns nothing of what the file says. // - // 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. + // This used to be a closed list of fact names, each formatted in Go in the control plane, so a + // new consumer meant a new formatter here in the consumer's language. Now the data is the mesh's + // and the format is the module's: the two built-in cases — the network module's `/etc/hosts` and + // dnsmasq's zones — render through the same template path any module uses, and no format lives + // in the control plane at all. See RosterFile for what a template sees. // // 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"` + // Keyed by a name the module chooses, which is the rendered file's id (`fact-`) — what a + // `restart-on` names to restart when the roster changes. + Facts map[string]RosterFile `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. @@ -650,6 +671,36 @@ func (l Listening) At() string { return l.Protocol } +// Jail is a fail2ban jail a module declares for its own service (novox/hq to-be 31). +// +// **The module names no node and no path** (ADR 0112): it says what a break-in on its service looks +// like — the failregex — and the jail's own keys (the port it watches, where it logs, how many +// tries, how long to ban). The mesh writes it into whichever node's fail2ban runs the module, the +// same way a module's `listens` become that node's firewall rules. A node not running the module +// has no such jail. +type Jail struct { + // Name is the jail and its filter, e.g. "postgres-auth". One holder of the name per node. + Name string `json:"name"` + // Failregex is what a failed authentication looks like in the service's log — the filter. + Failregex string `json:"failregex"` + // Jail is the body of the jail's stanza: the keys under [] the module knows and the mesh + // does not — the port it watches, its logpath and backend, maxretry, bantime. + Jail string `json:"jail"` +} + +// Jailing says a module composes the node's fail2ban jails — the intrusion-prevention holder. Like +// Filtering for the firewall: one module gathers what every other module declared and writes it +// where it owns. Into is the one jail file the stanzas are composed into (so the fail2ban service +// can restart on a single resource); FilterInto is the directory each jail's filter file goes in. +type Jailing struct { + Into string `json:"into"` + FilterInto string `json:"filter-into"` +} + +// ComposedJailsID is the single jail file the mesh composes every declared jail into, so the +// fail2ban service names one resource in its restart-on and a jail added or removed reaches it. +func ComposedJailsID() string { return "composed-jails" } + // Filtering says where a module wants the computed rule set. type Filtering struct { // Into is the path to write it to. Whatever loads it is this module's own business — an diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index 17719e6..029b19d 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -54,7 +54,7 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { // 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"] == "" { + if m.Module == overlay.Name && m.Facts["node-names"].Path == "" { t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts) } } diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index d825359..213490e 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -28,6 +28,10 @@ type Node struct { // (novox/hq ADR 0066). A route contribution carries only a label — the subdomain — and the mesh // joins