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")