fleet-manager: checkpoint 5 — admin WebAuthn/passkey auth (replaces browser mTLS)
The human->UI hop now authenticates with a hardware passkey (YubiKey), not an mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker stays mTLS — service-to-service.) - webauthn.go: credential store (registered admins + passkeys + a one-time-token jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup. - cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed enrolment link over a shared enrol key; the browser completes the WebAuthn create ceremony. No anyone-can-register endpoint; single-use via the ledger. - authapi.go: register/login ceremonies (usernameless discoverable login, userVerification required) + logout + whoami; short-lived in-memory ceremony store between begin/finish. - session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie) + requireSession middleware gating /api/*, with a CSRF header check on mutations. adminName now comes from the session (audit attribution). - main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org. /api/* mounted behind requireSession; /auth/* + /healthz public. Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate; DNS-01 proves control of bitasylum.org via TXT + supports wildcards). External ACME client issues+renews to the cert path; manager reloads. Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony (virtual authenticator / real YubiKey) is a bench item.
This commit is contained in:
+118
-40
@@ -1,27 +1,27 @@
|
||||
// fleet-manager — server-side control plane for the diskless kiosk fleet.
|
||||
//
|
||||
// Two planes, mirroring the enrol-broker's two-listener pattern but with distinct
|
||||
// trust anchors (see docs/tpm-enrolment-design.md §5 "identity"):
|
||||
// Two planes with DISTINCT trust models:
|
||||
//
|
||||
// - ADMIN plane (this checkpoint): HTTPS + mTLS verified by the DEDICATED admin
|
||||
// CA (admin-pki.sh) — NOT the fleet CA — so an enrolled host cert can never
|
||||
// reach approve/deny/lifecycle. Serves the JSON API and (later) the SPA to
|
||||
// admin browsers.
|
||||
// - AGENT plane (checkpoint 2): gRPC + mTLS verified by the FLEET CA — the
|
||||
// host's reused TPM identity cert. Bidi stream: health/metrics up,
|
||||
// desired-state down.
|
||||
// - ADMIN plane: HTTPS (server-auth TLS) + browser identity via WebAuthn/passkey
|
||||
// (YubiKey) → server-side session. Serves the JSON API + the SPA to admins.
|
||||
// (manager→broker is separately mTLS; that's service-to-service.)
|
||||
// - AGENT plane: gRPC + mTLS verified by the FLEET CA — the host's reused TPM
|
||||
// identity cert. Bidi stream: health/metrics up, desired-state down.
|
||||
//
|
||||
// Checkpoint 1 wires only the admin plane + inventory + a seed host.
|
||||
// A `register-admin` subcommand mints a one-time passkey-enrolment link.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
@@ -29,7 +29,6 @@ type config struct {
|
||||
adminInsecure bool
|
||||
adminCert string
|
||||
adminKey string
|
||||
adminClientCA string // dedicated admin CA (verifies admin browser certs)
|
||||
|
||||
agentAddr string
|
||||
agentCert string
|
||||
@@ -45,6 +44,14 @@ type config struct {
|
||||
// InfluxDB (the manager is the only reader/writer; token is server-side only).
|
||||
influx influxConfig
|
||||
|
||||
// Admin browser auth = WebAuthn/passkey (replaces browser mTLS). Origin-bound.
|
||||
rpID string
|
||||
rpOrigin string
|
||||
rpDisplayName string
|
||||
enrollKeyFile string
|
||||
credStorePath string
|
||||
sessionTTL time.Duration
|
||||
|
||||
inventoryPath string
|
||||
seed bool
|
||||
hostnqnBase string
|
||||
@@ -56,7 +63,6 @@ func loadConfig() config {
|
||||
adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true",
|
||||
adminCert: env("FLEET_MGR_ADMIN_CERT", ""),
|
||||
adminKey: env("FLEET_MGR_ADMIN_KEY", ""),
|
||||
adminClientCA: env("FLEET_MGR_ADMIN_CLIENT_CA", ""),
|
||||
|
||||
agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"),
|
||||
agentCert: env("FLEET_MGR_AGENT_CERT", ""),
|
||||
@@ -75,6 +81,13 @@ func loadConfig() config {
|
||||
bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"),
|
||||
},
|
||||
|
||||
rpID: env("FLEET_MGR_RP_ID", ""),
|
||||
rpOrigin: env("FLEET_MGR_RP_ORIGIN", ""),
|
||||
rpDisplayName: env("FLEET_MGR_RP_NAME", "Fleet Command"),
|
||||
enrollKeyFile: env("FLEET_MGR_ENROLL_KEY_FILE", ""),
|
||||
credStorePath: env("FLEET_MGR_ADMIN_STORE", "/var/lib/fleet-manager/admins.json"),
|
||||
sessionTTL: time.Duration(envInt("FLEET_MGR_SESSION_TTL_MIN", 720)) * time.Minute,
|
||||
|
||||
inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"),
|
||||
seed: env("FLEET_MGR_SEED", "") == "true",
|
||||
hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"),
|
||||
@@ -88,9 +101,22 @@ type server struct {
|
||||
hub *agentHub // live agent streams; nil if the agent plane isn't configured
|
||||
broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured
|
||||
influx *influxSink // metrics gateway; nil if InfluxDB isn't configured
|
||||
|
||||
// Admin passkey auth (nil in insecure dev mode).
|
||||
wa *webauthn.WebAuthn
|
||||
creds *credStore
|
||||
sessions *sessionStore
|
||||
ceremonies *ceremonyStore
|
||||
enrollKey []byte
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Subcommand: `fleet-manager register-admin <name>` mints a passkey-enrolment
|
||||
// link (no server needed) — the bootstrap for the WebAuthn admin plane.
|
||||
if len(os.Args) > 1 && os.Args[1] == "register-admin" {
|
||||
os.Exit(runRegisterAdmin(os.Args[2:]))
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -144,12 +170,42 @@ func main() {
|
||||
log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
// Admin browser auth = WebAuthn/passkey (secure mode only). Sets up the RP,
|
||||
// the credential store, the shared enrol key, sessions + ceremonies. The admin
|
||||
// TLS below is server-auth only — browser identity comes from the passkey.
|
||||
if !cfg.adminInsecure {
|
||||
wa, err := newWebAuthn(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("webauthn: %v", err)
|
||||
}
|
||||
creds, err := newCredStore(cfg.credStorePath)
|
||||
if err != nil {
|
||||
log.Fatalf("admin store: %v", err)
|
||||
}
|
||||
key, err := loadEnrollKey(cfg.enrollKeyFile)
|
||||
if err != nil {
|
||||
log.Fatalf("enrol key: %v", err)
|
||||
}
|
||||
s.wa, s.creds, s.enrollKey = wa, creds, key
|
||||
s.sessions = newSessionStore(cfg.sessionTTL)
|
||||
s.ceremonies = newCeremonyStore()
|
||||
if creds.count() == 0 {
|
||||
log.Printf("NOTE: no admins enrolled yet — run `fleet-manager register-admin <name>` and open the link to add a passkey")
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP handler: public liveness + WebAuthn ceremonies at the root; the admin
|
||||
// API under /api/ gated by the passkey session (requireSession). The SPA (ckpt
|
||||
// 6) will be served at / from an embedded FS.
|
||||
root := http.NewServeMux()
|
||||
s.authRoutes(root)
|
||||
apiMux := http.NewServeMux()
|
||||
s.apiRoutes(apiMux)
|
||||
root.Handle("/api/", s.requireSession(apiMux))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.adminAddr,
|
||||
Handler: mux,
|
||||
Handler: root,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
@@ -157,8 +213,7 @@ func main() {
|
||||
}
|
||||
|
||||
if cfg.adminInsecure {
|
||||
log.Printf("WARNING: admin plane INSECURE (plaintext, no client-cert auth) on %s — dev/lab only", cfg.adminAddr)
|
||||
log.Printf("fleet-manager admin (insecure) listening on %s", cfg.adminAddr)
|
||||
log.Printf("WARNING: admin plane INSECURE (plaintext, NO passkey auth) on %s — dev/lab only", cfg.adminAddr)
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
|
||||
@@ -167,41 +222,64 @@ func main() {
|
||||
log.Fatalf("admin TLS: %v", err)
|
||||
}
|
||||
srv.TLSConfig = tlsCfg
|
||||
log.Printf("fleet-manager admin (mTLS) listening on %s", cfg.adminAddr)
|
||||
log.Printf("fleet-manager admin (HTTPS + passkey) listening on %s (rp=%s)", cfg.adminAddr, cfg.rpID)
|
||||
log.Fatal(srv.ListenAndServeTLS("", "")) // certs are in TLSConfig
|
||||
}
|
||||
|
||||
// adminTLS builds the mTLS server config: our server cert + REQUIRE a client cert
|
||||
// signed by the dedicated admin CA. Reusing the fleet CA here would let any host
|
||||
// cert authenticate to the admin plane — privilege escalation — so it is a hard
|
||||
// error to omit the admin client CA in secure mode.
|
||||
// adminTLS builds the admin plane's SERVER-AUTH TLS. Browser identity is a passkey
|
||||
// (WebAuthn), not a client cert, so there is no ClientCAs/ClientAuth here — just
|
||||
// our server cert. (manager→broker mTLS is separate; see broker.go.)
|
||||
//
|
||||
// The cert is served via a hot-reloading GetCertificate so an out-of-band renewal
|
||||
// is picked up WITHOUT a restart. The intended deployment is a publicly-trusted
|
||||
// cert obtained by an external ACME client via Let's Encrypt DNS-01 (the UI is on
|
||||
// the mgmt network, not internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate;
|
||||
// DNS-01 proves control via a TXT record and supports wildcards). The ACME client
|
||||
// (lego/certbot + a DNS provider) writes cert+key to FLEET_MGR_ADMIN_CERT/_KEY on
|
||||
// a shared volume; this reloader notices the mtime bump and swaps the cert live.
|
||||
func adminTLS(cfg config) (*tls.Config, error) {
|
||||
if cfg.adminCert == "" || cfg.adminKey == "" {
|
||||
return nil, errString("FLEET_MGR_ADMIN_CERT and FLEET_MGR_ADMIN_KEY are required (or set FLEET_MGR_ADMIN_INSECURE=true for dev)")
|
||||
}
|
||||
if cfg.adminClientCA == "" {
|
||||
return nil, errString("FLEET_MGR_ADMIN_CLIENT_CA is required — the dedicated admin CA that verifies admin browser certs (must NOT be the fleet CA)")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(cfg.adminCert, cfg.adminKey)
|
||||
if err != nil {
|
||||
rl := &certReloader{certFile: cfg.adminCert, keyFile: cfg.adminKey}
|
||||
if _, err := rl.GetCertificate(nil); err != nil { // fail fast on a bad/missing cert
|
||||
return nil, err
|
||||
}
|
||||
caPEM, err := os.ReadFile(cfg.adminClientCA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errString("no certificates parsed from FLEET_MGR_ADMIN_CLIENT_CA")
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: pool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: rl.GetCertificate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// certReloader serves the admin server cert, reloading from disk when the file's
|
||||
// mtime advances (an ACME renewal), so Let's Encrypt DNS-01 rotations apply live.
|
||||
type certReloader struct {
|
||||
certFile, keyFile string
|
||||
mu sync.Mutex
|
||||
cached *tls.Certificate
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func (c *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fi, statErr := os.Stat(c.certFile)
|
||||
if statErr == nil && (c.cached == nil || fi.ModTime().After(c.mtime)) {
|
||||
cert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile)
|
||||
if err != nil {
|
||||
if c.cached != nil {
|
||||
return c.cached, nil // serve the last good cert through a transient mid-rotation error
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c.cached, c.mtime = &cert, fi.ModTime()
|
||||
}
|
||||
if c.cached == nil {
|
||||
return nil, fmt.Errorf("admin cert unavailable: %v", statErr)
|
||||
}
|
||||
return c.cached, nil
|
||||
}
|
||||
|
||||
// ---- tiny helpers ---------------------------------------------------------
|
||||
|
||||
func env(k, def string) string {
|
||||
|
||||
Reference in New Issue
Block a user