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".
244 lines
8.0 KiB
Go
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
|
|
}
|