Files
diskless-workstation/serving/fleet-manager/main.go
T
Audiobuzz 76c3a6aaa7 fleet-manager: drop self-TLS admin mode — the manager never terminates TLS
Traefik (and the k8s Ingress later) owns UI TLS, so remove the vestigial
self-TLS path: adminTLS(), the certReloader type, the adminCert/adminKey config
+ FLEET_MGR_ADMIN_CERT/_KEY env, and the now-redundant adminBehindProxy flag.
The admin plane always serves plaintext now — two modes: INSECURE (dev, no auth)
or default (passkey behind a trusted TLS proxy). Drops the crypto/tls, fmt, sync
imports and the errString helper.
2026-07-05 01:44:23 +00:00

278 lines
9.8 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 (
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
type config struct {
// The admin plane always serves plaintext: passkey auth behind a trusted
// TLS-terminating proxy (Traefik / k8s Ingress), or no auth in insecure dev.
adminAddr string
adminInsecure bool
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",
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())
}
// Secure mode: passkey auth over PLAINTEXT behind a trusted TLS-terminating
// proxy (Traefik / k8s Ingress) that owns the cert — the manager never
// terminates TLS. WebAuthn only needs RP_ORIGIN to match the browser's HTTPS
// origin, not our transport; session cookies stay Secure. The listener MUST be
// bound private (loopback / a private net) so only the proxy reaches it.
log.Printf("fleet-manager admin (passkey, behind TLS proxy) plaintext listening on %s (rp=%s) — ensure only the proxy can reach this address", cfg.adminAddr, cfg.rpID)
log.Fatal(srv.ListenAndServe())
}
// ---- 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
}