Files
diskless-workstation/serving/fleet-manager/main.go
T
Audiobuzz c40dd5ac93 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.
2026-07-04 04:26:30 +00:00

304 lines
10 KiB
Go

// fleet-manager — server-side control plane for the diskless kiosk fleet.
//
// Two planes with DISTINCT trust models:
//
// - 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.
//
// A `register-admin` subcommand mints a one-time passkey-enrolment link.
package main
import (
"crypto/tls"
"fmt"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type config struct {
adminAddr string
adminInsecure bool
adminCert string
adminKey string
agentAddr string
agentCert string
agentKey string
agentClientCA string // FLEET CA (verifies host TPM identity certs)
// enrol-broker admin plane (the authorise pillar drives it over mTLS).
brokerAdminURL string
brokerClientCert string
brokerClientKey string
brokerCA string
// 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
}
func loadConfig() config {
return config{
adminAddr: env("FLEET_MGR_ADMIN_ADDR", "127.0.0.1:9500"),
adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true",
adminCert: env("FLEET_MGR_ADMIN_CERT", ""),
adminKey: env("FLEET_MGR_ADMIN_KEY", ""),
agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"),
agentCert: env("FLEET_MGR_AGENT_CERT", ""),
agentKey: env("FLEET_MGR_AGENT_KEY", ""),
agentClientCA: env("FLEET_MGR_AGENT_CLIENT_CA", ""),
brokerAdminURL: env("FLEET_MGR_BROKER_URL", ""),
brokerClientCert: env("FLEET_MGR_BROKER_CLIENT_CERT", ""),
brokerClientKey: env("FLEET_MGR_BROKER_CLIENT_KEY", ""),
brokerCA: env("FLEET_MGR_BROKER_CA", ""),
influx: influxConfig{
url: env("FLEET_MGR_INFLUX_URL", ""),
token: env("FLEET_MGR_INFLUX_TOKEN", ""),
org: env("FLEET_MGR_INFLUX_ORG", "fleet"),
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-"),
}
}
// server bundles the shared dependencies the handlers close over.
type server struct {
cfg config
store Store
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()
store, err := newJSONStore(cfg.inventoryPath)
if err != nil {
log.Fatalf("inventory: %v", err)
}
if cfg.seed {
if n := seedHosts(store, cfg.hostnqnBase); n > 0 {
log.Printf("seeded %d demo host(s) (FLEET_MGR_SEED=true — dev only)", n)
}
}
s := &server{cfg: cfg, store: store}
// Authorise pillar: the enrol-broker admin client (nil = disabled, so the
// manager runs standalone before a broker is wired).
broker, err := newBrokerClient(cfg)
if err != nil {
log.Fatalf("broker client: %v", err)
}
s.broker = broker
if broker == nil {
log.Printf("authorise pillar DISABLED (set FLEET_MGR_BROKER_URL + client cert/key + CA to enable)")
}
// Metrics gateway: the manager is the only reader/writer of InfluxDB. nil (a
// no-op MetricsSink) when unconfigured, so the manager runs without metrics.
var sink MetricsSink
if cfg.influx.url != "" && cfg.influx.token != "" {
s.influx = newInfluxSink(cfg.influx)
defer s.influx.Close()
sink = s.influx
log.Printf("InfluxDB metrics gateway -> %s (org=%s bucket=%s)", cfg.influx.url, cfg.influx.org, cfg.influx.bucket)
} else {
log.Printf("InfluxDB DISABLED (set FLEET_MGR_INFLUX_URL + _TOKEN to enable metrics)")
}
// Agent plane (gRPC/mTLS, fleet CA). Started only when configured; the admin
// plane can run standalone (e.g. before any host is enrolled). The hub is the
// live-stream registry the lifecycle API pushes desired-state through, and it
// writes each snapshot to the metrics sink.
if cfg.agentCert != "" && cfg.agentKey != "" && cfg.agentClientCA != "" {
s.hub = newAgentHub(store, sink)
go func() {
if err := serveAgent(cfg, s.hub); err != nil {
log.Fatalf("agent plane: %v", err)
}
}()
} else {
log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)")
}
// 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: root,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
if cfg.adminInsecure {
log.Printf("WARNING: admin plane INSECURE (plaintext, NO passkey auth) on %s — dev/lab only", cfg.adminAddr)
log.Fatal(srv.ListenAndServe())
}
tlsCfg, err := adminTLS(cfg)
if err != nil {
log.Fatalf("admin TLS: %v", err)
}
srv.TLSConfig = tlsCfg
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 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)")
}
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
}
return &tls.Config{
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 {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func envInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
type errString string
func (e errString) Error() string { return string(e) }