Files
mesh-host/internal/link/enrol.go
T
jschoubben a4445f5c0a A machine joins the mesh it raised
The last step of the first-node path, and the bundle now carries all of it: a
container runtime, the store, a database per context, their schemas, the broker
with a certificate it generated itself, and the control plane running.

Then the machine enrols against the mesh on its own disk. It dials the broker
over TLS, refuses anything but the pinned certificate, presents the one-time
secret with a public key it generated, and is told the name the mesh has for
it. Its specialness lasted two commands, which is what ADR 0004 asked for.

The identity is saved only after the mesh says it knows this node. A node
holding an identity the mesh never recorded would believe it had joined and be
believed by nobody, which is worse than not joining because nothing looks wrong.

An already-enrolled machine refuses a valid token rather than quietly acquiring
a second identity, and a spent token is refused by the mesh. Both checked.

Containers gained a network field. The control plane must reach the store and
the broker on the machine it was raised on, before there is any mesh to arrange
that; the alternative was publishing ports and guessing an address that works
from inside a container, which fails in a worse way.

The control plane talks to the broker over loopback in plaintext, deliberately.
The TLS on 5671 exists so a node crossing a network can pin a certificate, not
for a hop that never leaves the machine.

Verified on a sealed lab machine: eleven resources applied from bare, the
control plane consuming, a token issued from inside it, and the machine
enrolled -- with the recorded public key matching what the host printed, the
token marked spent, and the profile stored.
2026-08-29 16:03:15 +02:00

150 lines
4.9 KiB
Go

package link
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
// The wire format shared with the control plane, which defines it separately because this binary
// requires nothing present and does not import it. A test on each side asserts the field names.
const (
Exchange = "mesh"
KeyEnrol = "enrol"
)
// QueueFor is the queue this node consumes from — the only one its account may read.
func QueueFor(node string) string { return "node." + node }
// EnrolRequest is what this node says when joining.
type EnrolRequest struct {
Node string `json:"node"`
Secret string `json:"secret"`
PublicKey []byte `json:"public_key"`
Profile map[string]any `json:"profile,omitempty"`
}
// EnrolReply is what the mesh says back.
type EnrolReply struct {
Accepted bool `json:"accepted"`
Node string `json:"node,omitempty"`
Queue string `json:"queue,omitempty"`
Refusal string `json:"refusal,omitempty"`
}
// ErrRefused is what a node gets when the mesh will not have it.
var ErrRefused = errors.New("the mesh refused this enrolment")
// Enrol presents this node's key and its one-time secret, and waits to be told it is known.
//
// The broker has already authenticated this connection: the account was created when the token
// was issued and the secret is its password. So this is not how the node gets in — it is what it
// says once it is in, and the secret travels again because the control plane must not have to ask
// the broker who connected.
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
profile map[string]any, timeout time.Duration) (EnrolReply, error) {
config, err := PinnedConfig(pin)
if err != nil {
return EnrolReply{}, err
}
// The account name is the node's, and the password is the token's secret. Escaped because a
// name or secret containing a colon or an at-sign would otherwise change which host this
// connects to — a credential silently redirecting a connection is the worst shape this could
// take.
dsn := fmt.Sprintf("amqps://%s:%s@%s/",
url.QueryEscape(node), url.QueryEscape(secret), address)
conn, err := amqp.DialConfig(dsn, amqp.Config{
TLSClientConfig: config,
Dial: amqp.DefaultDial(timeout),
})
if err != nil {
if errors.Is(err, ErrWrongCertificate) {
return EnrolReply{}, err
}
// Not quoted back: the DSN carries the one-time secret.
return EnrolReply{}, fmt.Errorf("cannot reach the broker at %s as %s: %w", address, node, err)
}
defer conn.Close()
channel, err := conn.Channel()
if err != nil {
return EnrolReply{}, err
}
defer channel.Close()
// This node's own queue, which its account is scoped to and nothing else may read.
queue, err := channel.QueueDeclare(QueueFor(node), true, false, false, false, nil)
if err != nil {
return EnrolReply{}, fmt.Errorf(
"cannot declare this node's queue %s: %w", QueueFor(node), err)
}
replies, err := channel.Consume(queue.Name, "", true, false, false, false, nil)
if err != nil {
return EnrolReply{}, err
}
request := EnrolRequest{Node: node, Secret: secret, PublicKey: public, Profile: profile}
body, err := json.Marshal(request)
if err != nil {
return EnrolReply{}, err
}
correlation := fmt.Sprintf("%s-%d", node, time.Now().UnixNano())
publish, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := channel.PublishWithContext(publish, Exchange, KeyEnrol, false, false,
amqp.Publishing{
ContentType: "application/json",
CorrelationId: correlation,
ReplyTo: queue.Name,
Body: body,
}); err != nil {
return EnrolReply{}, fmt.Errorf("cannot publish to the %s exchange: %w", Exchange, err)
}
// Waited for rather than assumed. A published message that nothing answers means the control
// plane is not running, and a node that carried on regardless would believe it had joined a
// mesh that has never heard of it.
deadline := time.NewTimer(timeout)
defer deadline.Stop()
closed := conn.NotifyClose(make(chan *amqp.Error, 1))
for {
select {
case <-ctx.Done():
return EnrolReply{}, ctx.Err()
case reason := <-closed:
return EnrolReply{}, fmt.Errorf("the broker closed the connection: %v", reason)
case <-deadline.C:
return EnrolReply{}, fmt.Errorf(
"the broker accepted this node's connection and nothing answered within %s. The "+
"mesh's broker is running and its control plane is not", timeout)
case delivery, ok := <-replies:
if !ok {
return EnrolReply{}, errors.New("the broker stopped delivering")
}
// Anything else on this queue is not the answer to this question.
if delivery.CorrelationId != correlation {
continue
}
var reply EnrolReply
if err := json.Unmarshal(delivery.Body, &reply); err != nil {
return EnrolReply{}, fmt.Errorf("the mesh's answer could not be read: %w", err)
}
if !reply.Accepted {
return reply, fmt.Errorf("%w: %s", ErrRefused, reply.Refusal)
}
return reply, nil
}
}
}