Files
Audiobuzz a89c3d2af1 fleet-manager: fix CA install, add register-host, surface agent status
Four fixes from the on-host agent bring-up:

1. Bake the real fleet CA into the image. New `fleet_ca` build var (default =
   the committed placeholder) file-provisions the trust anchor to
   /etc/dot1x/radius-ca.pem — used by wpa_supplicant (RADIUS) AND the fleet-agent
   (verify the manager). CI materializes a FLEET_CA secret to a runner-temp file
   and passes -var fleet_ca; unset => placeholder (lab). No CA material committed.

2. server-pki.sh installs the agent CLIENT CA as root+intermediate (not root
   only). Host TPM certs are leaf-only in NV and signed by the step-ca
   intermediate, so a root-only client CA makes the manager advertise only the
   root in its CertificateRequest -> Go clients send no cert ("tls: certificate
   required") and it couldn't verify a leaf-only cert anyway. bootstrap.sh now
   exports intermediate_ca.crt so FLEET_CA_INTERMEDIATE resolves.

3. New `fleet-manager register-host <serial> [-name] [-hostnqn]` subcommand:
   seeds/updates an inventory record for a host enrolled out of band (bench
   broker, migrated fleet) through the real store, so its agent stream is
   accepted. The manual counterpart to the authorise-approve flow.

4. The agent surfaces the authoritative recv-side gRPC status instead of the
   bare io.EOF gRPC returns on the send side when the server ends the stream —
   so "host is not enrolled" (PermissionDenied) shows up instead of "EOF".
2026-07-10 05:51:57 +00:00

244 lines
8.0 KiB
Go

// cli.go — the `register-admin` bootstrap subcommand and the one-time enrolment
// token it mints.
//
// WebAuthn registration is a BROWSER ceremony (the credential is born on the
// YubiKey via navigator.credentials.create) — a CLI can't do it directly. So the
// CLI's job is to AUTHORISE an enrolment: it mints a short-lived HMAC-signed token
// (over a shared enrol key on the mgmt host) and prints an enrolment URL. The
// browser completes the ceremony; the register endpoint verifies the token. This
// keeps registration closed (no anyone-can-register endpoint) while the actual
// key material never leaves the YubiKey. Single-use is enforced by the credStore
// jti ledger.
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"flag"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type enrollClaims struct {
Name string
JTI string
Exp int64
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
func hmacSum(key []byte, msg string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(msg))
return m.Sum(nil)
}
// mintEnrollToken -> base64url(name|exp|jti) "." base64url(hmac).
func mintEnrollToken(key []byte, name string, ttl time.Duration) (string, error) {
jb := make([]byte, 12)
if _, err := rand.Read(jb); err != nil {
return "", err
}
jti := hex.EncodeToString(jb)
exp := time.Now().Add(ttl).Unix()
msg := name + "|" + strconv.FormatInt(exp, 10) + "|" + jti
return b64([]byte(msg)) + "." + b64(hmacSum(key, msg)), nil
}
func verifyEnrollToken(key []byte, token string) (enrollClaims, error) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return enrollClaims{}, fmt.Errorf("malformed token")
}
msg, err := unb64(parts[0])
if err != nil {
return enrollClaims{}, fmt.Errorf("bad payload")
}
sig, err := unb64(parts[1])
if err != nil {
return enrollClaims{}, fmt.Errorf("bad signature encoding")
}
if subtle.ConstantTimeCompare(sig, hmacSum(key, string(msg))) != 1 {
return enrollClaims{}, fmt.Errorf("bad signature")
}
f := strings.SplitN(string(msg), "|", 3)
if len(f) != 3 {
return enrollClaims{}, fmt.Errorf("bad claims")
}
exp, _ := strconv.ParseInt(f[1], 10, 64)
if time.Now().Unix() > exp {
return enrollClaims{}, fmt.Errorf("token expired")
}
return enrollClaims{Name: f[0], JTI: f[2], Exp: exp}, nil
}
// loadEnrollKey reads the shared enrol key (>=32 bytes). Used by the SERVICE — it
// must already exist (the same key the CLI mints with).
func loadEnrollKey(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("no enrol key file configured (FLEET_MGR_ENROLL_KEY_FILE)")
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
// The key is created by the admin bootstrap — a fresh secure-mode start
// before any admin exists lands here. Point at the fix, not the errno.
return nil, fmt.Errorf("enrol key %s not found — bootstrap the admin plane first: `fleet-manager register-admin <name>` (or set FLEET_MGR_ADMIN_INSECURE=true for dev)", path)
}
if err != nil {
return nil, err
}
if len(b) < 32 {
return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path)
}
return b, nil
}
// loadOrCreateEnrollKey is the CLI's variant: it generates the key on first use.
func loadOrCreateEnrollKey(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("set -key or FLEET_MGR_ENROLL_KEY_FILE")
}
if b, err := os.ReadFile(path); err == nil {
if len(b) < 32 {
return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path)
}
return b, nil
} else if !os.IsNotExist(err) {
return nil, err
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "note: generated a new enrol key at %s (mount the SAME file into the service)\n", path)
return key, nil
}
// runRegisterAdmin implements: fleet-manager register-admin [-key F] [-origin URL] <name>
func runRegisterAdmin(args []string) int {
fs := flag.NewFlagSet("register-admin", flag.ContinueOnError)
keyFile := fs.String("key", env("FLEET_MGR_ENROLL_KEY_FILE", ""), "shared enrol key file")
origin := fs.String("origin", env("FLEET_MGR_RP_ORIGIN", ""), "admin UI origin (for the enrolment URL)")
ttl := fs.Duration("ttl", 10*time.Minute, "enrolment token validity")
if err := fs.Parse(args); err != nil {
return 2
}
name := fs.Arg(0)
if name == "" {
fmt.Fprintln(os.Stderr, "usage: fleet-manager register-admin [-key F] [-origin URL] [-ttl D] <name>")
return 2
}
key, err := loadOrCreateEnrollKey(*keyFile)
if err != nil {
fmt.Fprintf(os.Stderr, "enrol key: %v\n", err)
return 1
}
tok, err := mintEnrollToken(key, name, *ttl)
if err != nil {
fmt.Fprintf(os.Stderr, "mint: %v\n", err)
return 1
}
// RP_ORIGIN may list several origins (comma-separated); the enrolment link
// uses the first one.
base := *origin
if i := strings.IndexByte(base, ','); i >= 0 {
base = base[:i]
}
base = strings.TrimRight(strings.TrimSpace(base), "/")
if base == "" {
base = "https://<admin-ui-origin>"
}
fmt.Printf("Enrolment link for %q (valid %s) — open on the mgmt network, then tap the YubiKey:\n\n %s/#/enrol?token=%s\n\n", name, ttl.String(), base, tok)
return 0
}
// runRegisterHost implements:
//
// fleet-manager register-host [-name S] [-hostnqn N] [-inventory F] <cert-serial-hex>
//
// It seeds or updates an inventory record for a host enrolled OUT OF BAND (the
// standalone bench broker, or a manager inheriting an existing fleet), so the
// agent stream — which the manager refuses for an unknown serial (agent.go
// "host is not enrolled") — is accepted. Normally the record is born by the
// authorise-approve flow; this is the manual import path. It writes through the
// real jsonStore (atomic save), so it must point at the SAME inventory file the
// service uses (FLEET_MGR_INVENTORY, default /data in the container). Run it via
// `docker compose run --rm fleet-manager register-host ...`.
func runRegisterHost(args []string) int {
fs := flag.NewFlagSet("register-host", flag.ContinueOnError)
name := fs.String("name", "", "starship name (dnsmasq fleet-namer join; advisory)")
inv := fs.String("inventory", env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"), "inventory file (must match the service's FLEET_MGR_INVENTORY)")
nqn := fs.String("hostnqn", "", "host NQN (default: $HOSTNQN_BASE + serial)")
if err := fs.Parse(args); err != nil {
return 2
}
serial := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
if !isHexSerial(serial) {
fmt.Fprintln(os.Stderr, "usage: fleet-manager register-host [-name S] [-hostnqn N] [-inventory F] <cert-serial-hex>")
return 2
}
store, err := newJSONStore(*inv)
if err != nil {
fmt.Fprintf(os.Stderr, "inventory %s: %v\n", *inv, err)
return 1
}
hostnqn := *nqn
if hostnqn == "" {
hostnqn = env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-") + serial
}
now := time.Now().UTC()
h, existed := store.Get(serial)
if !existed {
h = &Host{Identity: Identity{CertSerial: serial}, FirstSeen: now}
}
h.Identity.CertSerial = serial
h.Identity.Hostnqn = hostnqn
if *name != "" {
h.Identity.StarshipName = *name
}
if h.Enrolled.At.IsZero() {
h.Enrolled = Enrolled{Approver: "register-host CLI", At: now}
}
h.LastSeen = now
if err := store.Upsert(h); err != nil {
fmt.Fprintf(os.Stderr, "write inventory %s: %v\n", *inv, err)
return 1
}
verb := "registered"
if existed {
verb = "updated"
}
fmt.Printf("%s host %s (starship=%q hostnqn=%s) in %s\n", verb, serial, h.Identity.StarshipName, hostnqn, *inv)
return 0
}
// isHexSerial reports whether s is a non-empty lowercase-hex cert serial (the
// inventory key form). Serials can be odd-length hex (big-int %x), so we validate
// characters rather than decoding.
func isHexSerial(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}