broker: a module account scopes its tool serve queues + mesh.rpc (ADR 0052) #5
@@ -135,6 +135,7 @@ func usage() {
|
|||||||
module list what modules this mesh knows about
|
module list what modules this mesh knows about
|
||||||
module moved <name> <commit> the source has a newer commit than the mesh built
|
module moved <name> <commit> the source has a newer commit than the mesh built
|
||||||
module forget <name> remove one, unless a node is running it
|
module forget <name> remove one, unless a node is running it
|
||||||
|
module issue <name> --node <m> 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
|
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
|
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
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-control/internal/broker"
|
||||||
"github.com/novox/mesh-control/internal/catalogue"
|
"github.com/novox/mesh-control/internal/catalogue"
|
||||||
"github.com/novox/mesh-control/internal/inventory"
|
"github.com/novox/mesh-control/internal/inventory"
|
||||||
"github.com/novox/mesh-control/internal/overlay"
|
"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])
|
fmt.Printf("%s forgotten\n", args[1])
|
||||||
return nil
|
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 <module> --node <machine>")
|
||||||
|
}
|
||||||
|
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 (<node>.<module>.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:
|
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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -520,9 +520,25 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
// working for ever after its consumer went away.
|
// working for ever after its consumer went away.
|
||||||
from = ""
|
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{
|
out = append(out, catalogue.Grant{
|
||||||
Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer],
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,126 @@ const ExchangeName = "mesh"
|
|||||||
// constant that differed would be caught by the test that asserts they agree.
|
// constant that differed would be caught by the test that asserts they agree.
|
||||||
const BuildQueueName = "builds"
|
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.<self>.*` — 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.<module>.<tool> — 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.
|
// 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
|
// 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)
|
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) {
|
func (m *Management) get(ctx context.Context, path string) ([]byte, error) {
|
||||||
request, err := m.request(ctx, http.MethodGet, path, nil)
|
request, err := m.request(ctx, http.MethodGet, path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"at": n.At,
|
"at": n.At,
|
||||||
"from": n.From,
|
"from": n.From,
|
||||||
"as": ConsumerIdentity(node, m.Module),
|
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
|
||||||
}
|
}
|
||||||
for key, value := range n.Serves {
|
for key, value := range n.Serves {
|
||||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ type Grant struct {
|
|||||||
// Values are what that module contributed — the name it wants, and anything else the
|
// Values are what that module contributed — the name it wants, and anything else the
|
||||||
// provision's own vocabulary defines.
|
// provision's own vocabulary defines.
|
||||||
Values map[string]any
|
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.
|
// 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
|
// 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
|
// 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.
|
// would write a rule set that closed every other module on the machine. Each module's per-node
|
||||||
rules, err := r.Filtering(with.Generators, with.Ports)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -282,7 +297,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
}
|
}
|
||||||
found = here
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -479,7 +494,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
}
|
}
|
||||||
out[g.Provision] = append(out[g.Provision], Contribution{
|
out[g.Provision] = append(out[g.Provision], Contribution{
|
||||||
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
|
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),
|
Secret: grantPath(directories[g.Provision], g.Consumer, g.From),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"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
|
// 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
|
// port: **what is not declared is closed**, which is the property that makes the derivation worth
|
||||||
// having rather than merely tidy.
|
// 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
|
// 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,
|
// 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.
|
// 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 {
|
if at, known := ports[m.Module][l.Port]; known {
|
||||||
port = at
|
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]
|
rule, seen := found[at]
|
||||||
if !seen {
|
if !seen {
|
||||||
rule = &Rule{Port: port, Protocol: l.At(), From: l.From}
|
rule = &Rule{Port: port, Protocol: l.At(), From: from}
|
||||||
found[at] = rule
|
found[at] = rule
|
||||||
}
|
}
|
||||||
rule.Because = append(rule.Because, m.Module)
|
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
|
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.
|
// 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:
|
// A port open to anywhere is not additionally restricted by a second rule opening it to the mesh:
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T)
|
|||||||
// mustFilter is the rule set, refusing to continue if it could not be computed.
|
// 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 {
|
func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rules, err := r.Filtering(computed, nil)
|
rules, err := r.Filtering(computed, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("no rule set could be computed: %v", err)
|
t.Fatalf("no rule set could be computed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -440,7 +440,7 @@ func TestAComputedModulesOwnListensAreNotLost(t *testing.T) {
|
|||||||
func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) {
|
func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) {
|
||||||
_, err := Resolution{Node: "anchor",
|
_, err := Resolution{Node: "anchor",
|
||||||
Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}},
|
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 {
|
if err == nil {
|
||||||
t.Fatal("a machine whose open ports could not be computed was given a rule set anyway")
|
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
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,18 @@ var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`)
|
|||||||
// everything else alone. Withdrawal depends on it entirely.
|
// everything else alone. Withdrawal depends on it entirely.
|
||||||
const IdentityPrefix = "mesh_"
|
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 —
|
// 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.
|
// 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)
|
return IdentityPrefix + clean(node) + "_" + clean(module)
|
||||||
}
|
}
|
||||||
|
|
||||||
// identityLimit is the shortest identifier limit among the systems these names reach:
|
// identityLimit is the shortest identifier limit among the systems these names reach: an S3 access
|
||||||
// PostgreSQL's NAMEDATALEN - 1.
|
// key's 20 (novox/hq 04-ISSUES/010). PostgreSQL keeps 63 and MinIO 20, so 20 is the one that binds —
|
||||||
const identityLimit = 63
|
// 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
|
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
|
||||||
// statement succeeds. Two consumers agreeing for the first 63 bytes would become one login, which
|
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
|
||||||
// is 022 again at a length nobody would think to test. Refused here rather than in each
|
// (04-ISSUES/022) — and S3 refuses outright. Refused here, at the mesh, because the mesh chose the
|
||||||
// provisioner, because the mesh chose the name and is the only thing that can choose another.
|
// 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 {
|
func CheckIdentity(node, module string) error {
|
||||||
got := ConsumerIdentity(node, module)
|
got := ConsumerIdentity(node, module)
|
||||||
if len(got) <= identityLimit {
|
if len(got) <= identityLimit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%s on %s would be identified as %q, which is %d characters and some providers keep %d — "+
|
"%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+
|
||||||
"another consumer shortened to the same name would share its login. Shorten the "+
|
"give the module a shorter `slug` or shorten the machine's name",
|
||||||
"machine's name or the module's",
|
|
||||||
module, node, got, len(got), identityLimit)
|
module, node, got, len(got), identityLimit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -119,6 +119,13 @@ type Manifest struct {
|
|||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
Version string `json:"version,omitempty"`
|
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_<node>_<module>` 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;
|
// 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`.
|
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
|
||||||
Provides []Offer `json:"provides,omitempty"`
|
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 are names that must be provided by something assigned to the same node.
|
||||||
Requires []string `json:"requires,omitempty"`
|
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
|
// 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
|
// 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.
|
// 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(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module))
|
"%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 {
|
for _, offer := range m.Provides {
|
||||||
p := offer.Name
|
p := offer.Name
|
||||||
if !name.MatchString(p) {
|
if !name.MatchString(p) {
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
var unused []string
|
var unused []string
|
||||||
for _, layer := range layers {
|
for _, layer := range layers {
|
||||||
for key := range layer.Values {
|
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(
|
unused = append(unused, fmt.Sprintf(
|
||||||
"%s sets %q, and %s has no file or contribution to merge it into",
|
"%s sets %q, and %s has no file or contribution to merge it into",
|
||||||
layer.From, key, m.Module))
|
layer.From, key, m.Module))
|
||||||
|
|||||||
@@ -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")
|
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 {
|
if _, err := rand.Read(value); err != nil {
|
||||||
return Sealed{}, err
|
return Sealed{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user