A machine the mesh could not read is not a machine that runs nothing #147

Merged
jschoubben merged 4 commits from fix/152-a-lookup-failure-is-not-an-absence into main 2026-09-29 21:39:23 +00:00
7 changed files with 231 additions and 22 deletions
Showing only changes of commit e5c2eb20f2 - Show all commits
+87
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -12,7 +13,10 @@ import (
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
)
// The bus's own certificate, made by the mesh rather than borrowed from an image.
@@ -169,3 +173,86 @@ func writeBusCertificate(crt, key string) error {
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
return nil
}
// busAccounts writes the mesh's composed user list to a file.
//
// **For genesis, where no declaration can deliver it** (novox/hq 04-ISSUES/146). Everywhere else
// the list reaches the machine running the bus as a resource of the module that holds it — which
// requires that machine to be an enrolled node, and at genesis it is not: the first node cannot
// enrol because the account it would enrol with cannot be composed onto a bus it has no declaration
// for. The installer breaks that circle by placing the file itself, once, and the module takes the
// file over from its first push.
//
// The same composition, not a second one: this asks the store for the same records and renders them
// with the same composer the declaration uses. A genesis that hand-wrote an account would be a
// second statement of who may say what, able to disagree with the first.
//
// **It writes to standard output unless told a file**, and that is the point: the control plane
// composes and says what it composed, and whoever is raising the machine puts it where that
// machine's bus reads it. A control plane that wrote into the bus's own directory would have to
// know where that is and how to make the server re-read it — which is the module's knowledge, and
// the module is what takes this over on the first push.
//
// broker accounts > /var/lib/mesh-bus-conf/accounts.conf
func busAccounts(ctx context.Context, args []string) error {
into := ""
for i := 0; i < len(args); i++ {
switch args[i] {
case "--into":
if i+1 >= len(args) {
return errors.New("--into needs a file")
}
into = args[i+1]
i++
default:
return fmt.Errorf("broker accounts --into <file>: %q", args[i])
}
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
records, err := open.inventory.BusRecords(ctx)
if err != nil {
return err
}
users, err := broker.Users(records)
if err != nil {
return err
}
kept, err := open.inventory.BusUsers(ctx)
if err != nil {
return err
}
hashes := make(map[string]string, len(kept))
for name, u := range kept {
hashes[name] = u.PasswordHash
}
filled, missing := broker.WithPasswords(users, hashes)
if len(missing) > 0 {
// To standard error, always: the composed file may be going to standard output, and a
// remark in the middle of it is a configuration the server refuses to parse.
fmt.Fprintf(os.Stderr, "leaving out %d user(s) the mesh has minted no credential for: %s\n",
len(missing), strings.Join(missing, ", "))
}
if len(filled) == 0 {
return errors.New("not one user has a credential, so this list would refuse every " +
"connection in the mesh")
}
accounts, err := broker.ComposeAccounts(filled)
if err != nil {
return err
}
if into == "" {
fmt.Print(accounts)
return nil
}
if err := os.WriteFile(into, []byte(accounts), 0o600); err != nil {
return err
}
fmt.Printf("wrote %d user(s) to %s\n", len(filled), into)
return nil
}
+1 -1
View File
@@ -88,7 +88,7 @@ func run() error {
case "identity":
return identityCommand(ctx, args[1:])
case "broker":
return brokerCommand(args[1:])
return brokerCommand(ctx, args[1:])
case "serve":
return serve(ctx)
case "upgrade":
+5 -2
View File
@@ -363,12 +363,15 @@ func identityCommand(ctx context.Context, args []string) error {
return nil
}
func brokerCommand(args []string) error {
func brokerCommand(ctx context.Context, args []string) error {
if len(args) > 0 && args[0] == "certificate" {
return busCertificate(args[1:])
}
if len(args) > 0 && args[0] == "accounts" {
return busAccounts(ctx, args[1:])
}
if len(args) == 0 || args[0] != "show" {
return errors.New("broker show | broker certificate [--check] --into <directory>")
return errors.New("broker show | broker certificate [--check] --into <directory> | broker accounts --into <file>")
}
known, err := broker.FromEnvironment()
if errors.Is(err, broker.ErrNotConfigured) {
+17 -11
View File
@@ -62,6 +62,22 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
func theCarriedAccounts(t *testing.T) string {
t.Helper()
for _, r := range theTemplate(t) {
if r["id"] == "bus-accounts" {
content, _ := r["content"].(string)
if content == "" {
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
}
return content
}
}
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
return ""
}
// theTemplate is the installer's bundle, as resources.
func theTemplate(t *testing.T) []map[string]any {
t.Helper()
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
raw, err := os.ReadFile(path)
@@ -81,17 +97,7 @@ func theCarriedAccounts(t *testing.T) string {
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
t.Fatalf("the template is not readable: %v", err)
}
for _, r := range bundle.Resources {
if r["id"] == "bus-accounts" {
content, _ := r["content"].(string)
if content == "" {
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
}
return content
}
}
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
return ""
return bundle.Resources
}
// subjectsIn reads one allow-list out of a composed accounts file.
+61
View File
@@ -8,6 +8,7 @@ import (
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"golang.org/x/crypto/bcrypt"
)
// Reading the bus's user list out of the mesh's records, against a real store.
@@ -164,3 +165,63 @@ func granted(all []string, one string) bool {
}
return false
}
// **A token is an account on the bus, or it is a string nothing accepts** (novox/hq 04-ISSUES/146).
//
// The composed list names an enrolment user for every machine with a live token, and nothing minted
// a credential for it — so the composer left it out as a user with no password, and every enrolment
// since the mesh moved to this bus was refused by the server before the mesh heard of it. Nothing
// caught it because nothing had enrolled since.
//
// The password cannot be minted, because it is the token's own secret: the machine will present
// exactly that string. So this checks the two halves that make the account usable — that a row
// exists under the name the composer asks for, and that the secret handed out is what that row
// accepts.
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
inv, ctx := aMeshWith(t)
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
if err != nil {
t.Fatal(err)
}
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
user, has := users[name]
if !has {
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
"machine would be refused before the mesh heard of it: %v", name, users)
}
if user.Kind != BusEnrolment || user.Node != "joiner" {
t.Errorf("the account is %+v, not this node's enrolment", user)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
t.Error("the account does not accept the secret the token carries, so presenting the " +
"token would be refused by the server")
}
// And the composition contains it, which is the thing the server reads.
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
derived, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
hashes := map[string]string{}
for n, u := range users {
hashes[n] = u.PasswordHash
}
_, missing := broker.WithPasswords(derived, hashes)
for _, m := range missing {
if m == name {
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
}
}
}
+36 -8
View File
@@ -51,6 +51,40 @@ const (
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
// is meant to feel like one.
// RecordBusPassword records a hash for a password the caller already holds.
//
// **For the one credential the mesh does not choose**: an enrolment token's secret is the password
// of the user that presents it (novox/hq ADR 0004, design 25 §6), so the token cannot be given a
// minted password — it already has one, and the machine will connect with exactly that string.
// Everything else goes through Mint, which chooses and returns the plaintext once.
func (i *Inventory) RecordBusPassword(ctx context.Context, u BusUser, password string) error {
if u.Username == "" || u.Kind == "" {
return errors.New("a bus user needs a username and a kind")
}
if password == "" {
return errors.New("a bus user needs a password")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("cannot hash a bus password: %w", err)
}
return i.writeBusUser(ctx, u, string(hash))
}
// writeBusUser is the row, whoever chose the password.
func (i *Inventory) writeBusUser(ctx context.Context, u BusUser, hash string) error {
if _, err := i.store.Pool().Exec(ctx,
`insert into bus_user (username, kind, node, module, password_hash)
values ($1, $2, $3, $4, $5)
on conflict (username) do update
set kind = excluded.kind, node = excluded.node, module = excluded.module,
password_hash = excluded.password_hash, minted_at = now()`,
u.Username, u.Kind, u.Node, u.Module, hash); err != nil {
return fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
}
return nil
}
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
if u.Username == "" || u.Kind == "" {
return "", errors.New("a bus user needs a username and a kind")
@@ -69,14 +103,8 @@ func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, err
return "", fmt.Errorf("cannot hash a bus password: %w", err)
}
if _, err := i.store.Pool().Exec(ctx,
`insert into bus_user (username, kind, node, module, password_hash)
values ($1, $2, $3, $4, $5)
on conflict (username) do update
set kind = excluded.kind, node = excluded.node, module = excluded.module,
password_hash = excluded.password_hash, minted_at = now()`,
u.Username, u.Kind, u.Node, u.Module, string(hash)); err != nil {
return "", fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
if err := i.writeBusUser(ctx, u, string(hash)); err != nil {
return "", err
}
return password, nil
}
+24
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/store"
)
@@ -263,6 +264,29 @@ func (i *Inventory) IssueToken(ctx context.Context, nodeName string, validFor ti
return Issued{}, err
}
// **And the account that secret is the password of** (novox/hq 04-ISSUES/146). The composed
// user list names an enrolment user for every node with a live token, and nothing minted a
// credential for it — so the composer left it out as a user with no password and every
// enrolment was refused by the server before the mesh heard of it.
//
// Recorded rather than minted: the token's secret IS the password, which is what lets a
// machine's first connection be authenticated by the thing it is enrolling with. It cannot be
// chosen here, because it has already been handed to whoever will present it.
//
// Outside the transaction on purpose. The token is what the mesh promised; a credential that
// the next composition rewrites anyway is not worth failing an issue over, and a token with no
// account is recoverable by issuing another, while an account with no token is a user nobody
// can be.
if err := i.RecordBusPassword(ctx, BusUser{
Username: broker.Principal{Kind: broker.KindEnrolment, Node: node.Name}.Username(),
Kind: BusEnrolment,
Node: node.Name,
}, secret); err != nil {
return Issued{}, fmt.Errorf(
"the token for %s was issued and the bus account it is the password of was not "+
"recorded, so this token cannot connect: %w", node.Name, err)
}
return Issued{Node: node, Secret: secret, Expires: expires}, nil
}