// 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 ` 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 ` 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 } 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) }