Every plane logged its state EXCEPT the broker success path, which was silent — so "authorise pillar working" was indistinguishable from "not logged". Add an ENABLED line matching the InfluxDB/agent/admin convention.
341 lines
12 KiB
Go
341 lines
12 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"
|
|
"strings"
|
|
"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. Enabled when the broker URL
|
|
// is set AND its client PKI is present. Missing PKI (not yet provisioned)
|
|
// DISABLES the pillar with a loud warning rather than crash-looping the whole
|
|
// manager — so a partial bring-up boots and the pillar lights up once the cert
|
|
// is staged. A malformed/unreadable cert still hard-fails (a real misconfig).
|
|
switch {
|
|
case cfg.brokerAdminURL == "":
|
|
log.Printf("authorise pillar DISABLED (set FLEET_MGR_BROKER_URL + client cert/key + CA to enable)")
|
|
default:
|
|
if miss := missingFiles(cfg.brokerClientCert, cfg.brokerClientKey, cfg.brokerCA); len(miss) > 0 {
|
|
log.Printf("WARNING: authorise pillar DISABLED — broker PKI not found: %s (run enrol-broker/admin-pki.sh; see DEPLOY.md)", strings.Join(miss, ", "))
|
|
break
|
|
}
|
|
broker, err := newBrokerClient(cfg)
|
|
if err != nil {
|
|
log.Fatalf("broker client: %v", err)
|
|
}
|
|
s.broker = broker
|
|
log.Printf("authorise pillar ENABLED -> enrol-broker %s (mTLS)", cfg.brokerAdminURL)
|
|
}
|
|
|
|
// 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.
|
|
switch {
|
|
case cfg.agentCert == "" || cfg.agentKey == "" || cfg.agentClientCA == "":
|
|
log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)")
|
|
default:
|
|
if miss := missingFiles(cfg.agentCert, cfg.agentKey, cfg.agentClientCA); len(miss) > 0 {
|
|
// Same soft-disable as the authorise pillar: boot without the agent
|
|
// plane until its fleet-CA PKI is staged, rather than crash-looping.
|
|
log.Printf("WARNING: agent plane DISABLED — PKI not found: %s (run server-pki.sh; see DEPLOY.md)", strings.Join(miss, ", "))
|
|
break
|
|
}
|
|
s.hub = newAgentHub(store, sink)
|
|
go func() {
|
|
if err := serveAgent(cfg, s.hub); err != nil {
|
|
log.Fatalf("agent plane: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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))
|
|
// SPA at the root (catch-all; the more specific /api, /auth, /healthz win).
|
|
// Embedded only in `-tags embedspa` builds; a stub otherwise (see spa_*.go).
|
|
root.Handle("/", s.spaHandler())
|
|
|
|
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
|
|
}
|
|
|
|
// missingFiles returns the subset of the given paths that don't exist (or aren't
|
|
// stat-able) on disk. Used to soft-disable an optional plane whose PKI hasn't
|
|
// been provisioned yet, instead of crash-looping the whole manager.
|
|
func missingFiles(paths ...string) []string {
|
|
var miss []string
|
|
for _, p := range paths {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(p); err != nil {
|
|
miss = append(miss, p)
|
|
}
|
|
}
|
|
return miss
|
|
}
|
|
|
|
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) }
|