diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 43a58d6..613f709 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -135,6 +135,7 @@ func usage() { module list what modules this mesh knows about module moved the source has a newer commit than the mesh built module forget remove one, unless a node is running it + module issue --node a broker account for a module, scoped to its emits and consumes status [--json] what is wrong, what is quiet, and what is out of date board [--listen ADDR] the same three questions, as a page that holds nothing api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here diff --git a/cmd/mesh-control/modules.go b/cmd/mesh-control/modules.go index cfa81cd..3d10bd7 100644 --- a/cmd/mesh-control/modules.go +++ b/cmd/mesh-control/modules.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "flag" @@ -10,6 +12,7 @@ import ( "sort" "strings" + "github.com/novox/mesh-control/internal/broker" "github.com/novox/mesh-control/internal/catalogue" "github.com/novox/mesh-control/internal/inventory" "github.com/novox/mesh-control/internal/overlay" @@ -194,8 +197,93 @@ func moduleCommand(ctx context.Context, args []string) error { fmt.Printf("%s forgotten\n", args[1]) return nil + case "issue": + // A module's broker account, scoped by its emits and consumes (novox/hq ADR 0048) and + // sealed to the machine that will run it — the generic case the builder was the first of. + set := flag.NewFlagSet("module issue", flag.ContinueOnError) + forNode := set.String("node", "", + "the machine that will run it, so the credential is delivered instead of printed") + positionals, err := parseAround(set, args[1:]) + if err != nil { + return err + } + if len(positionals) != 1 { + return errors.New("module issue --node ") + } + module := positionals[0] + if *forNode == "" { + return errors.New("module issue needs --node: a module's account is sealed to the " + + "machine that runs it, and the mesh cannot read it back to print") + } + + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + m, ok := shelf[module] + if !ok { + return fmt.Errorf("this mesh knows no module %q; `module add` it first", module) + } + + management, err := broker.ManagementFromEnvironment() + if err != nil { + return err + } + // The substrate owns the bus; make sure it exists before a module binds onto it. + if err := management.EnsureEventExchanges(ctx); err != nil { + return err + } + + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + password := base64.RawURLEncoding.EncodeToString(secret) + account, err := management.CreateModuleAccount(ctx, *forNode, module, password, m.Emits, m.Consumes) + if err != nil { + return err + } + // A consumer's queue, with its dead-letter, is the substrate's to declare — its own account + // may not (ADR 0048). Made now, so it exists before the module binds onto it. + if len(m.Consumes) > 0 { + if err := management.EnsureModuleQueue(ctx, *forNode, module); err != nil { + return err + } + } + + known, err := broker.FromEnvironment() + if err != nil { + return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err) + } + // The URL and what verifies the broker, together — a mesh's broker presents its own + // certificate, in no public trust store, so a URL alone fails at TLS (as `builder issue`). + held, err := json.Marshal(struct { + URL string `json:"url"` + Fingerprint string `json:"fingerprint,omitempty"` + Node string `json:"node"` + Module string `json:"module"` + }{ + URL: fmt.Sprintf("amqps://%s:%s@%s/", account, password, known.Address), + Fingerprint: known.Fingerprint, + // The node and module the account is for, so the runtime names its queue as the mesh + // scoped it (..events) without a manifest having to interpolate a node. + Node: *forNode, + Module: module, + }) + if err != nil { + return err + } + if err := inv.AcceptSecretForModule(ctx, *forNode, module, "broker", string(held)); err != nil { + return err + } + fmt.Printf("broker account %s created for %s, scoped to what it emits and consumes\n", + account, module) + fmt.Printf(" sealed to %s. It arrives with the next push — `push %s` to send it\n", + *forNode, *forNode) + return nil + default: - return fmt.Errorf("module has no %q; it has add, list, moved and forget", args[0]) + return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0]) } } diff --git a/cmd/mesh-control/plan.go b/cmd/mesh-control/plan.go index 56fe989..bc58ff3 100644 --- a/cmd/mesh-control/plan.go +++ b/cmd/mesh-control/plan.go @@ -520,9 +520,25 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran // working for ever after its consumer went away. from = "" } + // The consumer's identity slug, from its own manifest, carried on the grant so the provider + // derives the same login the consumer does (novox/hq ADR 0054). Refused here if it still would + // not fit the tightest backend — the mesh chose the name, so the mesh refuses it, with the + // remedy a short slug rather than a login a provider silently shortened. + slug := "" + for _, mm := range plan.Modules { + if mm.Module == s.ConsumerModule { + slug = mm.Slug + break + } + } + if from != "" { + if err := catalogue.CheckIdentity(s.Consumer, catalogue.IdentitySource(slug, s.ConsumerModule)); err != nil { + return nil, err + } + } out = append(out, catalogue.Grant{ Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer], - From: from, Values: values, Sealed: s.ForProvider}) + From: from, Values: values, Slug: slug, Sealed: s.ForProvider}) } return out, nil } diff --git a/internal/broker/management.go b/internal/broker/management.go index 2873408..3ec9277 100644 --- a/internal/broker/management.go +++ b/internal/broker/management.go @@ -67,6 +67,126 @@ const ExchangeName = "mesh" // constant that differed would be caught by the test that asserts they agree. const BuildQueueName = "builds" +// The events bus (novox/hq ADR 0047): one topic exchange every event rides, a second for tool +// RPC kept apart, and a dead-letter home for a poison event. The substrate owns these — a module's +// account cannot declare them, only bind its own queue to the events one. +const ( + EventsExchangeName = "mesh.events" + RPCExchangeName = "mesh.rpc" + DeadExchangeName = "mesh.events.dead" +) + +// ModuleQueueFor is the durable queue a module consumes its events from — one per module per node +// (novox/hq ADR 0047), named so the account that may read it is exactly this module's. +func ModuleQueueFor(node, module string) string { return node + "." + module + ".events" } + +// modulePermissions is a module's authority on the bus, derived from its manifest (novox/hq +// ADR 0048): what it consumes and what it emits, and nothing else. Pure, so the scope is tested as +// patterns without a broker — the way a builder's is. +// +// A note on the limit: the broker's write permission is per exchange, not per routing key (LavinMQ +// has no topic permissions), so an emitting module is granted the events exchange whole. ADR 0047's +// origin reservation — a module publishes only under `module..*` — is stamped by the sdk, not +// enforced here; that gap is the broker's, and is recorded rather than hidden. A pure consumer like +// the audit logger is unaffected: it is granted no write to the exchange at all. +func modulePermissions(node, module string, emits, consumes []string) (configure, write, read string) { + queue := regexp.QuoteMeta(ModuleQueueFor(node, module)) + events := regexp.QuoteMeta(EventsExchangeName) + rpc := regexp.QuoteMeta(RPCExchangeName) + // A module serves each of its tools on its own queue, namespaced by the module (novox/hq + // ADR 0052) — serve.. — so the account may declare, bind and read exactly its own, + // and no other module's. + serve := "serve\\." + regexp.QuoteMeta(module) + "\\..*" + + // Declare its own events queue and its own tool serve queues. + configure = "^(" + queue + "|" + serve + ")$" + + // Write to bind its queue and serve queues (binding is a write on the queue), and to the RPC + // exchange to publish replies (ADR 0052: replies ride mesh.rpc, never the default exchange, which + // would let it publish into any queue). To the events exchange only if it emits. + writes := []string{queue, serve, rpc} + if len(emits) > 0 { + writes = append(writes, events) + } + write = "^(" + strings.Join(writes, "|") + ")$" + + // Read its own queue and serve queues to consume them, and the RPC exchange to bind its serve + // queues onto. The events exchange to bind onto only if it consumes. + reads := []string{queue, serve, rpc} + if len(consumes) > 0 { + reads = append(reads, events) + } + read = "^(" + strings.Join(reads, "|") + ")$" + return configure, write, read +} + +// CreateModuleAccount gives an assigned module its own broker account, scoped by what it emits and +// consumes (novox/hq ADR 0048). The account name carries the node so the same module on two machines +// holds two accounts, each sealed to its own; the permissions carry the module so one module cannot +// read another's queue. Generic — the builder is one instance of this rule, not a separate kind. +func (m *Management) CreateModuleAccount(ctx context.Context, node, module, password string, emits, consumes []string) (string, error) { + if !safeName.MatchString(node) { + return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", node) + } + if !safeName.MatchString(module) { + return "", fmt.Errorf("%q cannot be part of a broker account: it is a permission pattern", module) + } + account := node + "-" + module + if !safeName.MatchString(account) { + return "", fmt.Errorf("%q is not a usable broker account name", account) + } + + if err := m.put(ctx, "/api/users/"+url.PathEscape(account), + map[string]string{"password": password, "tags": ""}); err != nil { + return "", fmt.Errorf("cannot create the broker account for %s on %s: %w", module, node, err) + } + + configure, write, read := modulePermissions(node, module, emits, consumes) + if err := m.put(ctx, "/api/permissions/%2f/"+url.PathEscape(account), map[string]string{ + "configure": configure, "write": write, "read": read, + }); err != nil { + return "", fmt.Errorf("cannot scope the broker account for %s on %s: %w", module, node, err) + } + return account, nil +} + +// EnsureModuleQueue declares a consuming module's queue with its dead-letter exchange, idempotently. +// The substrate declares it because a scoped module account may not: the broker refuses a queue with +// a dead-letter exchange to a non-administrator (novox/hq ADR 0048), so a consumer passively checks +// the queue the mesh made rather than declaring its own. +func (m *Management) EnsureModuleQueue(ctx context.Context, node, module string) error { + queue := ModuleQueueFor(node, module) + if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(queue), map[string]any{ + "durable": true, + "arguments": map[string]any{"x-dead-letter-exchange": DeadExchangeName}, + }); err != nil { + return fmt.Errorf("cannot declare the queue for %s on %s: %w", module, node, err) + } + return nil +} + +// EnsureEventExchanges declares the bus's exchanges and the dead-letter home, idempotently. The +// substrate owns them (a module's account may not declare an exchange), and a dead-letter exchange +// with no queue behind it drops what it receives — so a durable queue bound to `#` retains a poison +// event for inspection, which is the whole reason the trail exists. +func (m *Management) EnsureEventExchanges(ctx context.Context) error { + for _, exchange := range []string{EventsExchangeName, RPCExchangeName, DeadExchangeName} { + if err := m.put(ctx, "/api/exchanges/%2f/"+url.PathEscape(exchange), + map[string]any{"type": "topic", "durable": true}); err != nil { + return fmt.Errorf("cannot declare the %s exchange: %w", exchange, err) + } + } + if err := m.put(ctx, "/api/queues/%2f/"+url.PathEscape(DeadExchangeName), + map[string]any{"durable": true}); err != nil { + return fmt.Errorf("cannot declare the dead-letter queue: %w", err) + } + if err := m.post(ctx, "/api/bindings/%2f/e/"+url.PathEscape(DeadExchangeName)+ + "/q/"+url.PathEscape(DeadExchangeName), map[string]string{"routing_key": "#"}); err != nil { + return fmt.Errorf("cannot bind the dead-letter queue: %w", err) + } + return nil +} + // CreateNodeAccount gives a node its own broker account, with the token's secret as the password. // // Scoped so a node can reach its own queue and the one exchange, and nothing else. The patterns @@ -167,6 +287,10 @@ func (m *Management) put(ctx context.Context, path string, body any) error { return m.do(ctx, http.MethodPut, path, body) } +func (m *Management) post(ctx context.Context, path string, body any) error { + return m.do(ctx, http.MethodPost, path, body) +} + func (m *Management) get(ctx context.Context, path string) ([]byte, error) { request, err := m.request(ctx, http.MethodGet, path, nil) if err != nil { diff --git a/internal/broker/module_account_test.go b/internal/broker/module_account_test.go new file mode 100644 index 0000000..d14bce8 --- /dev/null +++ b/internal/broker/module_account_test.go @@ -0,0 +1,96 @@ +package broker_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/novox/mesh-control/internal/broker" +) + +// The audit logger consumes everything and emits nothing. Its account must let it declare and read +// its own queue and read the events exchange to bind onto — and must not reach another module's +// queue, nor grant any write to the events exchange (novox/hq ADR 0048). +func TestAConsumerReadsItsOwnQueueAndTheEventsExchangeAndNoOthers(t *testing.T) { + // Rebuilt through CreateModuleAccount's own path by asking for the same scope it would apply. + // The queue this module reads: + mine := broker.ModuleQueueFor("anchor", "audit-logger") + other := broker.ModuleQueueFor("anchor", "plex") + + read := scope(t, "read", "anchor", "audit-logger", nil, []string{"#"}) + if !read.MatchString(mine) { + t.Error("the audit logger may not read its own queue, so it consumes nothing") + } + if !read.MatchString(broker.EventsExchangeName) { + t.Error("the audit logger may not read the events exchange, so it cannot bind onto it") + } + if read.MatchString(other) { + t.Error("the audit logger may read another module's queue") + } + + // It emits nothing, so it is granted no write to the events exchange — only its own queue, to bind. + write := scope(t, "write", "anchor", "audit-logger", nil, []string{"#"}) + if write.MatchString(broker.EventsExchangeName) { + t.Error("a pure consumer was granted write to the events exchange") + } + if !write.MatchString(mine) { + t.Error("the audit logger may not write to its own queue, so it cannot bind it") + } +} + +// An emitter is granted the events exchange to write; a consumer is not. +func TestAnEmitterMayWriteTheEventsExchangeAndAConsumerMayNot(t *testing.T) { + emitter := scope(t, "write", "anchor", "umami", []string{"module.umami.site.created"}, nil) + if !emitter.MatchString(broker.EventsExchangeName) { + t.Error("an emitting module may not write the events exchange, so it cannot emit") + } +} + +// scope reconstructs one of the three permission patterns CreateModuleAccount would apply, by +// reading it back from a captured request against a stub management API. +func scope(t *testing.T, which, node, module string, emits, consumes []string) *regexp.Regexp { + t.Helper() + pat := capturePermission(t, which, node, module, emits, consumes) + re, err := regexp.Compile(pat) + if err != nil { + t.Fatalf("the %s pattern does not compile: %v", which, err) + } + return re +} + +// capturePermission runs CreateModuleAccount against a stub management API and returns the pattern +// it set for `which` ("configure"/"write"/"read"). The scope is tested where it is applied, not +// reconstructed by the test — so a change to the mapping cannot pass a test that hard-codes the old +// one. +func capturePermission(t *testing.T, which, node, module string, emits, consumes []string) string { + t.Helper() + var captured map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/permissions/") { + _ = json.NewDecoder(r.Body).Decode(&captured) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv(broker.ManagementVar, server.URL) + m, err := broker.ManagementFromEnvironment() + if err != nil { + t.Fatalf("stub management not usable: %v", err) + } + if _, err := m.CreateModuleAccount(context.Background(), node, module, "pw", emits, consumes); err != nil { + t.Fatalf("CreateModuleAccount: %v", err) + } + if captured == nil { + t.Fatal("no permissions were set") + } + pattern, ok := captured[which] + if !ok { + t.Fatalf("no %s permission was set; got %v", which, captured) + } + return pattern +} diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go index 7bcd674..614d1da 100644 --- a/internal/catalogue/bound_into_files.go +++ b/internal/catalogue/bound_into_files.go @@ -57,7 +57,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str values := map[string]string{ "at": n.At, "from": n.From, - "as": ConsumerIdentity(node, m.Module), + "as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)), } for key, value := range n.Serves { // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index ea5d8ed..558fc50 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -59,6 +59,10 @@ type Grant struct { // Values are what that module contributed — the name it wants, and anything else the // provision's own vocabulary defines. Values map[string]any + // Slug is the consumer module's identity slug, if it declared one — carried on the grant so the + // provider side derives the same login the consumer does, even across nodes where the consumer's + // manifest is not in view (novox/hq ADR 0054). Empty means "use the module name". + Slug string // At is where the consuming machine is on the private network, empty if it is not on one. // // Passed in with the grant because it is a fact about another machine, and resolution answers @@ -138,8 +142,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } // Once, from every module's listens -- not per module. A module receiving only its own ports - // would write a rule set that closed every other module on the machine. - rules, err := r.Filtering(with.Generators, with.Ports) + // would write a rule set that closed every other module on the machine. Each module's per-node + // exposure settings override its listens' source first (novox/hq ADR 0051). + exposure := map[string]map[int]string{} + for _, m := range r.Modules { + e, err := Exposure(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + if e != nil { + exposure[m.Module] = e + } + } + rules, err := r.Filtering(with.Generators, with.Ports, exposure) if err != nil { return nil, err } @@ -282,7 +297,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } found = here } - file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, m.Module)) + file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))) if err != nil { return nil, err } @@ -479,7 +494,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, } out[g.Provision] = append(out[g.Provision], Contribution{ From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, - As: ConsumerIdentity(g.Consumer, g.From), + As: ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), Secret: grantPath(directories[g.Provision], g.Consumer, g.From), }) } diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go index 36a1aba..53e9def 100644 --- a/internal/catalogue/filtering.go +++ b/internal/catalogue/filtering.go @@ -3,6 +3,7 @@ package catalogue import ( "fmt" "sort" + "strconv" "strings" ) @@ -31,7 +32,7 @@ type Rule struct { // a consequence of what runs on it, not a second list kept in step by hand. Nothing else opens a // port: **what is not declared is closed**, which is the property that makes the derivation worth // having rather than merely tidy. -func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int) ([]Rule, error) { +func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]Rule, error) { // Keyed by what actually distinguishes an opening. Two modules wanting :443 from the mesh is // one rule with two sources; one wanting it from the mesh and another from anywhere is two, // and they are collapsed below -- deliberately, and only in the widening direction. @@ -70,10 +71,17 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma if at, known := ports[m.Module][l.Port]; known { port = at } - at := opening{port: port, protocol: l.At(), from: l.From} + // The source, with any per-node exposure setting applied. The override names the port + // the module declares, so it travels with that port to wherever the machine publishes + // it (novox/hq ADR 0051): the same module is internal on one node and public on another. + from := l.From + if override, set := exposure[m.Module][l.Port]; set { + from = override + } + at := opening{port: port, protocol: l.At(), from: from} rule, seen := found[at] if !seen { - rule = &Rule{Port: port, Protocol: l.At(), From: l.From} + rule = &Rule{Port: port, Protocol: l.At(), From: from} found[at] = rule } rule.Because = append(rule.Because, m.Module) @@ -100,6 +108,62 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma return widest(out), nil } +// ExposeSetting is the settings key that overrides a listen's source per node (novox/hq ADR 0051): +// +// {"expose": {"5432": "anywhere"}} +// +// makes the module's port 5432 open to the public on the node this is set for, whatever the +// manifest's default. It keys on the port the module declares — the mesh maps that to where the +// machine publishes, and the override travels with it. +const ExposeSetting = "expose" + +// Exposure reads a module's per-node exposure overrides from its settings: declared-port → source. +// +// It refuses an override for a port the module does not listen on, or to a source that is not a +// real one — an exposure setting that reaches no port, or names a source nothing enforces, is the +// "reads as a restriction and is none" fault this whole mechanism exists to prevent (novox/hq +// ADR 0048/0050). A module with no `expose` setting yields nothing and keeps its manifest defaults. +func Exposure(m Manifest, layers []Layer) (map[int]string, error) { + listened := make(map[int]bool, len(m.Listens)) + for _, l := range m.Listens { + listened[l.Port] = true + } + + out := map[int]string{} + for _, layer := range layers { + raw, ok := layer.Values[ExposeSetting] + if !ok { + continue + } + entries, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: %s is a { port: source } map, and %q set it to something else", + m.Module, ExposeSetting, layer.From) + } + for portText, value := range entries { + port, err := strconv.Atoi(portText) + if err != nil { + return nil, fmt.Errorf("%s exposes %q, which is not a port", m.Module, portText) + } + if !listened[port] { + return nil, fmt.Errorf( + "%s exposes port %d, which it does not listen on — the setting reaches nothing", + m.Module, port) + } + source, ok := value.(string) + if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) { + return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q", + m.Module, port, value, FromMesh, FromEverywhere, FromMachine) + } + out[port] = source + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + // widest drops a rule that another already covers. // // A port open to anywhere is not additionally restricted by a second rule opening it to the mesh: diff --git a/internal/catalogue/filtering_test.go b/internal/catalogue/filtering_test.go index 204e37b..e222189 100644 --- a/internal/catalogue/filtering_test.go +++ b/internal/catalogue/filtering_test.go @@ -369,7 +369,7 @@ func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T) // mustFilter is the rule set, refusing to continue if it could not be computed. func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule { t.Helper() - rules, err := r.Filtering(computed, nil) + rules, err := r.Filtering(computed, nil, nil) if err != nil { t.Fatalf("no rule set could be computed: %v", err) } @@ -440,7 +440,7 @@ func TestAComputedModulesOwnListensAreNotLost(t *testing.T) { func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) { _, err := Resolution{Node: "anchor", Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}}, - }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil) + }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil, nil) if err == nil { t.Fatal("a machine whose open ports could not be computed was given a rule set anyway") } @@ -578,3 +578,47 @@ func named(r Resolution) []string { } return out } + +// A port's source is a per-node setting, not a manifest constant: the same module is internal on +// one machine and public on another (novox/hq ADR 0051). postgres listens from the mesh by default; +// a setting on one node exposes it to anywhere, and the rule set follows. +func TestExposureSettingOverridesAListensSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Version: "1", + Listens: []Listening{{Port: 5432, From: FromMesh, Why: "the database"}}} + + base, err := Resolution{Node: "ace", Modules: []Manifest{postgres}}.Filtering(nil, nil, nil) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(base) != 1 || base[0].From != FromMesh { + t.Fatalf("without a setting, the default source is the mesh: %+v", base) + } + + expose := map[string]map[int]string{"postgres": {5432: FromEverywhere}} + exposed, err := Resolution{Node: "novox", Modules: []Manifest{postgres}}.Filtering(nil, nil, expose) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(exposed) != 1 || exposed[0].From != FromEverywhere { + t.Fatalf("the setting did not open the port to anywhere: %+v", exposed) + } +} + +// Exposure refuses a setting that names a port the module does not listen on, or a source that is +// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0048/0051). +func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}} + layer := func(port, source string) []Layer { + return []Layer{{From: "node", Values: map[string]any{ExposeSetting: map[string]any{port: source}}}} + } + + if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil { + t.Fatalf("a valid exposure was refused: %v", err) + } + if _, err := Exposure(postgres, layer("6379", FromEverywhere)); err == nil { + t.Error("a port the module does not listen on was accepted") + } + if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil { + t.Error("a source that is not mesh/anywhere/machine was accepted") + } +} diff --git a/internal/catalogue/identity.go b/internal/catalogue/identity.go index dd93ac8..502ee6b 100644 --- a/internal/catalogue/identity.go +++ b/internal/catalogue/identity.go @@ -34,7 +34,18 @@ var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) // everything else alone. Withdrawal depends on it entirely. const IdentityPrefix = "mesh_" -// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. +// IdentitySource is the name the mesh derives a consumer's identity from: the module's slug when it +// has declared one, otherwise its name (novox/hq ADR 0054). A module with a name short enough to fit +// the tightest backend needs no slug; one whose name would overflow declares a short legible one. +func IdentitySource(slug, name string) string { + if slug != "" { + return slug + } + return name +} + +// ConsumerIdentity is what one module on one machine is called, wherever it authenticates. The +// `module` argument is the identity source — a slug or a name; see IdentitySource. // // A dot and a dash both become an underscore, so `home-server` and `home.server` would collide — // which cannot happen, because a machine has one name and it is either. @@ -45,24 +56,26 @@ func ConsumerIdentity(node, module string) string { return IdentityPrefix + clean(node) + "_" + clean(module) } -// identityLimit is the shortest identifier limit among the systems these names reach: -// PostgreSQL's NAMEDATALEN - 1. -const identityLimit = 63 +// identityLimit is the shortest identifier limit among the systems these names reach: an S3 access +// key's 20 (novox/hq 04-ISSUES/010). PostgreSQL keeps 63 and MinIO 20, so 20 is the one that binds — +// the comment used to name PostgreSQL and was wrong. A name over it is refused, with the remedy a +// short slug (ADR 0054), not silently cut to fit. +const identityLimit = 20 -// CheckIdentity refuses a name a provider would silently shorten. +// CheckIdentity refuses an identity that would not fit the tightest backend a consumer reaches. // -// **Truncation is not an error in PostgreSQL** — a name past the limit is cut to fit and the -// statement succeeds. Two consumers agreeing for the first 63 bytes would become one login, which -// is 022 again at a length nobody would think to test. Refused here rather than in each -// provisioner, because the mesh chose the name and is the only thing that can choose another. +// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and +// the statement succeeds, so two consumers agreeing for the first N bytes would become one login +// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the +// name and is the only thing that can choose another. The remedy is a first-class one: give the +// module a short `slug` (ADR 0054), or shorten the machine's name. func CheckIdentity(node, module string) error { got := ConsumerIdentity(node, module) if len(got) <= identityLimit { return nil } return fmt.Errorf( - "%s on %s would be identified as %q, which is %d characters and some providers keep %d — "+ - "another consumer shortened to the same name would share its login. Shorten the "+ - "machine's name or the module's", + "%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+ + "give the module a shorter `slug` or shorten the machine's name", module, node, got, len(got), identityLimit) } diff --git a/internal/catalogue/identity_test.go b/internal/catalogue/identity_test.go new file mode 100644 index 0000000..07e7cef --- /dev/null +++ b/internal/catalogue/identity_test.go @@ -0,0 +1,53 @@ +package catalogue + +import "testing" + +// Each test names the decision it defends (novox/hq ADR 0017). + +func TestASlugIsPreferredOverTheModuleName(t *testing.T) { + // A module that declared a slug is identified by it, so a long name can be made to fit the + // tightest backend without a hash (novox/hq ADR 0054). + if got := IdentitySource("kc", "keycloak"); got != "kc" { + t.Errorf("the slug was not preferred: %q", got) + } + if got := IdentitySource("", "redis"); got != "redis" { + t.Errorf("without a slug, the name should be used: %q", got) + } + if ConsumerIdentity("anchor", IdentitySource("bkt", "bucketuser")) != "mesh_anchor_bkt" { + t.Error("a slug did not shape the identity") + } +} + +func TestCheckIdentityFitsTheTightestBackend(t *testing.T) { + // 20 is an S3 access key's limit (04-ISSUES/010), and the one that binds. mesh_anchor_keycloak + // is exactly 20 and allowed; the un-slugged bucketuser is 22 and refused, with the remedy a slug. + if err := CheckIdentity("anchor", "keycloak"); err != nil { // mesh_anchor_keycloak = 20 + t.Errorf("a 20-character identity was refused: %v", err) + } + if err := CheckIdentity("anchor", "bucketuser"); err == nil { // mesh_anchor_bucketuser = 22 + t.Error("an over-long identity was accepted") + } + // But with a slug it fits, and is accepted. + if err := CheckIdentity("anchor", IdentitySource("bkt", "bucketuser")); err != nil { + t.Errorf("a slugged identity that fits was refused: %v", err) + } +} + +func TestTheRefusalNamesTheRemedy(t *testing.T) { + err := CheckIdentity("anchor", "bucketuser") + if err == nil { + t.Fatal("expected a refusal") + } + if !contains(err.Error(), "slug") { + t.Errorf("the refusal did not point at the slug as the remedy: %v", err) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index aab61d2..4912d2a 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -119,6 +119,13 @@ type Manifest struct { Module string `json:"module"` Version string `json:"version,omitempty"` + // Slug is a short identifier the mesh uses in place of the module name when it derives a + // consumer's login (novox/hq ADR 0054). Optional: a module with a short name needs none. It + // exists because `mesh__` must fit the tightest backend a consumer reaches — an S3 + // access key is 20 characters — and a long module name would overflow it. A person choosing + // `kc` for keycloak keeps the identity legible where a hash would not. + Slug string `json:"slug,omitempty"` + // Provides are the names other modules may require. A module always provides its own name; // this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`. Provides []Offer `json:"provides,omitempty"` @@ -126,6 +133,17 @@ type Manifest struct { // Requires are names that must be provided by something assigned to the same node. Requires []string `json:"requires,omitempty"` + // Emits are the event types this module publishes onto the broker — dotted topic keys, e.g. + // "module.umami.site.created". Declared so the mesh knows the event graph; events are + // provisioning's lighter sibling — 1:many and broadcast, no credential (novox/hq ADR 0046). + Emits []string `json:"emits,omitempty"` + + // Consumes are the event patterns this module subscribes to — topic patterns over module, + // mesh and node events alike, e.g. "node.*.joined" or "#" (the audit logger). The runtime + // wires the subscription; the module ships the handler. A Consumes for an event nothing on + // the mesh Emits is a dangling edge. + Consumes []string `json:"consumes,omitempty"` + // Claims are singular resources. Two modules claiming one thing within a scope cannot both // be assigned there — which is how exclusivity is expressed, rather than as a list of rivals // that every new module would force its predecessors to update. @@ -456,6 +474,13 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, fmt.Sprintf( "%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module)) } + // A slug is a short identifier the mesh derives a login from (novox/hq ADR 0054). The same + // charset as a name; its length is checked against a backend's limit at assignment, where the + // node it joins is known — a slug that is fine on one machine's short name can overflow another's. + if m.Slug != "" && !name.MatchString(m.Slug) { + problems = append(problems, fmt.Sprintf( + "%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug)) + } for _, offer := range m.Provides { p := offer.Name if !name.MatchString(p) { diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index ef1fc8d..608f8a6 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -171,6 +171,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string { var unused []string for _, layer := range layers { for key := range layer.Values { + // `expose` is a real destination for a module that listens: it overrides a port's + // source (novox/hq ADR 0051), validated in Exposure, so it is not stray here. + if key == ExposeSetting && len(m.Listens) > 0 { + continue + } unused = append(unused, fmt.Sprintf( "%s sets %q, and %s has no file or contribution to merge it into", layer.From, key, m.Module)) diff --git a/internal/secrets/seal.go b/internal/secrets/seal.go index 422c94f..1759880 100644 --- a/internal/secrets/seal.go +++ b/internal/secrets/seal.go @@ -54,7 +54,10 @@ func Make(consumerKey, providerKey string) (Sealed, error) { return Sealed{}, fmt.Errorf("both ends need a sealing key before a secret can be made") } - value := make([]byte, 32) + // 30 bytes, not 32: base64url of 30 is exactly 40 characters, and 40 is the longest secret an + // S3 access key accepts (8–40), the tightest of the backends a minted password reaches — the same + // "fit the tightest backend" rule ADR 0054 sets for the login, on the secret. 240 bits is ample. + value := make([]byte, 30) if _, err := rand.Read(value); err != nil { return Sealed{}, err }