A node makes its own identity, and checks the broker before speaking

The host side of enrolment. It parses a token the control plane issued, dials
the broker, refuses anything but the pinned certificate, and generates an
Ed25519 keypair whose private half never leaves the machine.

Verified against a real LavinMQ serving a real certificate: the pin matched and
the node proceeded. Then against a second broker with a different certificate
on another port, which was refused -- with an error that says retrying will not
help, because it does not mean the network is down, it means the mesh was
substituted.

InsecureSkipVerify is set and that is the point rather than a weakening. At
bootstrap the broker is self-signed and reached at an address, so there is no
authority to trace and no name to match. Chain and hostname checks are replaced
with something stricter: this exact certificate or nothing, checked in
VerifyPeerCertificate, which runs before the handshake completes -- so nothing
is sent to the wrong broker. There is a test that counts the bytes an impostor
receives, and it is zero.

The token format is defined separately here and in the control plane, because
this binary requires nothing present and does not import it. They are held
together by a test on each side asserting the exact field names, so a rename
breaks both immediately rather than at enrolment on a real machine.

Two distinctions the identity file has to keep. A machine that never joined has
no identity, which is an ordinary state and not a fault. A machine whose
identity cannot be read is a different thing entirely, and must not take the
same path -- re-enrolling would discard the identity the mesh still believes and
need a person with a new token. Fault injection found the second case untested:
the corrupt-file test was passing on the parse check, so the read-error path had
nothing defending it. It does now.

An already-enrolled machine refuses to enrol again rather than quietly
acquiring a second identity.

What is not built is the link. Enrolment stops after verifying the broker and
generating the identity, having saved nothing, so it can be run again unchanged.

132 tests, plus 32 launcher and 9 rollback.
This commit is contained in:
2026-08-29 15:38:19 +02:00
parent a740959cb0
commit 65d896d96e
7 changed files with 860 additions and 5 deletions
+77 -5
View File
@@ -8,12 +8,14 @@ package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"text/tabwriter"
"time"
@@ -21,7 +23,9 @@ import (
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/bundle"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/identity"
"github.com/novox/mesh-host/internal/inventory"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/profile"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
@@ -72,11 +76,13 @@ func main() {
}
type options struct {
json bool
timeout time.Duration
state string
dryRun bool
file string
json bool
timeout time.Duration
state string
token string
nodeName string
dryRun bool
file string
}
// parseArgs takes the subcommand first, then its flags.
@@ -101,6 +107,8 @@ func parseArgs(args []string) (string, options, error) {
set.DurationVar(&opts.timeout, "timeout", opts.timeout, "how long any single probe may take")
set.StringVar(&opts.state, "state", opts.state, "where this node keeps what it knows")
set.BoolVar(&opts.dryRun, "dry-run", false, "read and check the declaration, change nothing")
set.StringVar(&opts.token, "token", "", "enrol: the one-time token, carried here by a person")
set.StringVar(&opts.nodeName, "name", "", "enrol: what this machine is called in the mesh")
// Parsed in a loop, because the standard library stops at the FIRST non-flag argument.
// `mesh-host inventory --json` hit that once, and taking the subcommand off the front
@@ -213,6 +221,9 @@ func run(ctx context.Context, command string, opts options) error {
}
return w.Flush()
case "enrol", "enroll":
return enrol(opts)
case "version":
fmt.Println(version)
return nil
@@ -367,3 +378,64 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, sou
fmt.Printf("%s: applied — %d resource(s)\n", source, len(report.Outcomes))
return nil
}
// enrol joins this machine to a mesh.
//
// novox/hq 09-the-node-lifecycle: the token carries four things, the node dials the broker over
// the underlay, checks the certificate against the pin *before sending anything*, and presents
// the one-time secret together with a public key it generated itself.
//
// The mesh issues no identity. This machine arrives holding one; what it receives is being known.
func enrol(opts options) error {
tokenText, name := &opts.token, &opts.nodeName
if strings.TrimSpace(*tokenText) == "" {
return errors.New("enrol --token <token>: the token is carried to this machine by a " +
"person, and is the only thing it needs")
}
// Refused whole if incomplete. A token without the fingerprint would have this machine
// connect to whatever answers; without the signing key it could not tell a declaration from
// a forgery, and it applies whatever the link delivers.
token, err := identity.ParseToken(*tokenText)
if err != nil {
return err
}
// Before anything else: an already-enrolled machine must not quietly acquire a second
// identity. The mesh believes the first one, and re-enrolling is a deliberate act that
// starts with a person issuing a new token for that node record.
identityPath := identity.Path(opts.state)
switch existing, err := identity.Load(identityPath); {
case err == nil:
return fmt.Errorf(
"this machine is already node %q. Re-enrolling replaces the identity the mesh "+
"believes, so it is done deliberately: remove %s first",
existing.Node, identityPath)
case errors.Is(err, identity.ErrNoIdentity):
default:
return err
}
fmt.Printf("token for broker %s\n", token.Broker)
fmt.Printf(" pinned certificate %s\n", token.Fingerprint)
fmt.Printf(" signing key %s\n",
base64.StdEncoding.EncodeToString(token.Signer)[:16]+"...")
// The check that has to happen before this machine says anything.
conn, err := link.Dial(token.Broker, token.Fingerprint, opts.timeout)
if err != nil {
return err
}
defer conn.Close()
fmt.Println("\nthe broker presented the certificate this token pins")
mine, err := identity.Generate(*name)
if err != nil {
return err
}
fmt.Printf("generated this node's identity: %s\n", mine.PublicBase64())
return errors.New("the link is not built: this machine has verified the broker and made its " +
"identity, and there is nothing yet to present them to.\n" +
"Nothing has been saved, so this can be run again unchanged")
}