The control plane serves, and a node can join

There was no chicken-and-egg to solve. The mesh runs the broker, so it creates
the node's account when it issues the token, and the one-time secret is that
account's password. A joining node's first connection is already authenticated;
enrolment is what it says once it is in. I had been treating this as a decision
that needed taking, and it did not.

The account is per node and scoped: it may read its own queue, write to the one
exchange, and configure nothing else. The patterns are anchored and the node
name is constrained to characters that cannot widen them, because a name
carrying a dot or a star would silently let that node read everybody's queues.

`serve` is the control plane running: one connection, one queue, one consumer.
One deliberately -- two consumers on a queue get round-robined and each receives
half of what it expects, which has happened on this project before, between a
module's daemon and its capability server.

Enrolment spends the token first, in the single statement that both finds and
marks it, and only then records the key. That order is the order things become
irreversible: recording a key for a node whose token turned out to be spent
would leave the mesh believing a machine that never had the right to join.

Refusals are one message for every reason. The log says which, where an
operator can see it; the node is told only that the token cannot be used.

Verified in the lab, on a sealed machine, through the whole first-node path.
This commit is contained in:
2026-08-29 16:03:14 +02:00
parent afb65c2201
commit 46e760fc94
9 changed files with 594 additions and 1 deletions
+55
View File
@@ -19,6 +19,7 @@ import (
"github.com/novox/mesh-control/internal/broker"
"github.com/novox/mesh-control/internal/identity"
"github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/link"
"github.com/novox/mesh-control/internal/store"
"github.com/novox/mesh-control/internal/token"
)
@@ -67,6 +68,8 @@ func run() error {
return identityCommand(ctx, args[1:])
case "broker":
return brokerCommand(args[1:])
case "serve":
return serve(ctx)
case "version":
fmt.Println(version)
return nil
@@ -89,6 +92,7 @@ func usage() {
token issue --new <name> create the record and issue for it
identity show this control plane's signing key
broker show where the broker is, and what to expect there
serve consume what nodes say, and answer
version what this binary is
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
@@ -253,6 +257,20 @@ func tokenCommand(ctx context.Context, args []string) error {
return err
}
// The account is created before the token is handed over, which is what removes the
// chicken-and-egg entirely: the mesh runs the broker, so a joining node's credentials can
// exist before it does. The one-time secret IS the password, so a node's first connection is
// already authenticated and enrolment is what happens over it.
if management, err := broker.ManagementFromEnvironment(); err == nil {
if err := management.CreateNodeAccount(ctx, issued.Node.Name, issued.Secret); err != nil {
return err
}
fmt.Printf("broker account %s created, scoped to %s and the %s exchange\n\n",
issued.Node.Name, link.QueueFor(issued.Node.Name), link.Exchange)
} else if !errors.Is(err, broker.ErrNotConfigured) {
return err
}
made := token.Token{Signer: key.Public, Secret: issued.Secret}
// Absent is a state, not a failure: a control plane can hold records and a key before it has
@@ -342,3 +360,40 @@ func brokerCommand(args []string) error {
"node checks it before sending anything (novox/hq ADR 0004).\n")
return nil
}
// serve is the control plane running: one connection to the broker, one queue, one consumer.
func serve(ctx context.Context) error {
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
ident, err := openIdentity(ctx)
if err != nil {
return err
}
defer ident.Close()
// Established at start rather than on first use. A control plane that cannot sign is one
// whose declarations every node correctly refuses, and that should be a startup failure
// rather than something discovered at the first declaration.
key, err := ident.Establish(ctx)
if err != nil {
return err
}
fmt.Printf("signing as %s\n", key.Fingerprint()[:16])
management, err := broker.ManagementFromEnvironment()
if err != nil && !errors.Is(err, broker.ErrNotConfigured) {
return err
}
server, err := link.Connect(link.Enrolment{Inventory: inv, Identity: ident, Broker: management})
if err != nil {
return err
}
defer server.Close()
return server.Serve(ctx)
}