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.
68 lines
2.5 KiB
Go
68 lines
2.5 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/novox/mesh-control/internal/broker"
|
|
"github.com/novox/mesh-control/internal/identity"
|
|
"github.com/novox/mesh-control/internal/inventory"
|
|
)
|
|
|
|
// Enrolment is what actually happens when a node presents a token: the token is spent, the key is
|
|
// recorded, and the node gets its own queue.
|
|
//
|
|
// It reaches across two contexts and reads neither one's store from the other (novox/hq ADR 0008)
|
|
// — it holds both grants and asks each for its part, which is what the process running them is
|
|
// for.
|
|
type Enrolment struct {
|
|
Inventory *inventory.Inventory
|
|
Identity *identity.Identity
|
|
Broker *broker.Management
|
|
}
|
|
|
|
// Enrol spends the token and records what the node presented.
|
|
//
|
|
// Order matters and it is the order things become irreversible. The token is spent first, in a
|
|
// single statement that both finds and marks it, so two machines racing on one secret produce one
|
|
// winner. Only then is a key recorded — because 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.
|
|
func (e Enrolment) Enrol(ctx context.Context, secret string, public ed25519.PublicKey,
|
|
profile map[string]any) (string, error) {
|
|
|
|
if len(public) != ed25519.PublicKeySize {
|
|
return "", fmt.Errorf("a node presented a %d-byte key, and an identity is %d",
|
|
len(public), ed25519.PublicKeySize)
|
|
}
|
|
|
|
node, err := e.Inventory.Redeem(ctx, secret)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// From here the token is gone whatever happens next, so anything that fails leaves a node
|
|
// record with no live key — which is visible and fixable with a new token, where a spent
|
|
// token believed to be unspent is neither.
|
|
if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil {
|
|
return "", fmt.Errorf("the token was spent and the key could not be recorded, so %s has "+
|
|
"no identity and needs a new token: %w", node.Name, err)
|
|
}
|
|
|
|
if profile != nil {
|
|
if err := e.Inventory.RecordProfile(ctx, node.ID, profile); err != nil {
|
|
// Not fatal. The profile is what the control plane needs in order to decide what this
|
|
// machine should run, and it is reported again on every connection — so losing it
|
|
// here costs a decision that can be made later, not the enrolment.
|
|
return node.Name, nil
|
|
}
|
|
}
|
|
return node.Name, nil
|
|
}
|
|
|
|
var _ Enroller = Enrolment{}
|
|
|
|
// ErrNoBrokerManagement is returned when an account cannot be made because nothing was configured.
|
|
var ErrNoBrokerManagement = errors.New("no broker management configured")
|