The control plane declares, and hears back
`declare` sends a node a signed declaration; `serve` now also consumes reports. Signed over the exact bytes published, which is what the node verifies. Anything re-encoding in between would sign one thing and check another, and a difference in key order alone would have a node refuse a declaration that was genuinely the mesh's. Sent to the node's queue directly rather than through the exchange: a declaration is for one node, and routing by name through a shared exchange means a binding per node that nothing removes when a node is retired. Enrolment now issues the node its own broker password, replacing the token's secret, and tells it the broker address, the fingerprint and the signing key -- so a node can reconnect after a restart without a person and a new token, which is what makes disconnection ordinary rather than a crisis. A report is a statement, not a write. What a node says it applied is its own account of its own machine, kept as a copy for recovery rather than as a source.
This commit is contained in:
+56
-15
@@ -3,6 +3,8 @@ package link
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -18,9 +20,10 @@ import (
|
||||
// — 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
|
||||
Inventory *inventory.Inventory
|
||||
Identity *identity.Identity
|
||||
Management *broker.Management
|
||||
Broker broker.Broker
|
||||
}
|
||||
|
||||
// Enrol spends the token and records what the node presented.
|
||||
@@ -30,35 +33,73 @@ type Enrolment struct {
|
||||
// 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) {
|
||||
profile map[string]any) (EnrolReply, error) {
|
||||
|
||||
if len(public) != ed25519.PublicKeySize {
|
||||
return "", fmt.Errorf("a node presented a %d-byte key, and an identity is %d",
|
||||
return EnrolReply{}, 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
|
||||
return EnrolReply{}, 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)
|
||||
return EnrolReply{}, 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)
|
||||
}
|
||||
|
||||
key, err := e.Identity.Active(ctx)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
|
||||
reply := EnrolReply{
|
||||
Accepted: true,
|
||||
Node: node.Name,
|
||||
Queue: QueueFor(node.Name),
|
||||
Broker: e.Broker.Address,
|
||||
Fingerprint: e.Broker.Fingerprint,
|
||||
Signer: key.Public,
|
||||
}
|
||||
|
||||
// The token's secret was the broker password up to this moment, which is what let this
|
||||
// connection exist at all. It is replaced now, so the one-time thing stays one-time and the
|
||||
// credential the node keeps for years is not the one that was pasted into a terminal.
|
||||
if e.Management != nil {
|
||||
password, err := freshPassword()
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
if err := e.Management.CreateNodeAccount(ctx, node.Name, password); err != nil {
|
||||
return EnrolReply{}, fmt.Errorf(
|
||||
"the token was spent and %s's broker password could not be replaced: %w",
|
||||
node.Name, err)
|
||||
}
|
||||
reply.Password = password
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// Not fatal if it fails. 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.
|
||||
_ = e.Inventory.RecordProfile(ctx, node.ID, profile)
|
||||
}
|
||||
return node.Name, nil
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// freshPassword is the node's own broker credential from enrolment onward.
|
||||
func freshPassword() (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("cannot generate a broker password: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
var _ Enroller = Enrolment{}
|
||||
|
||||
Reference in New Issue
Block a user