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:
+77
-5
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user