Files
diskless-workstation/serving/fleet-manager/main.go
T
Audiobuzz 9c86cd4c5a fleet-manager: checkpoint 4 — InfluxDB metrics gateway (write + Flux read)
The manager is the ONLY thing that talks to InfluxDB, both directions; hosts hold
no token and never reach it.

- influx.go: influxSink implements MetricsSink — each agent HealthSnapshot is
  written as a fleet_health point, identity (serial/hostnqn/starship) as TAGS
  from the AUTHENTICATED stream (a host can only write its own series), telemetry
  as fields. QueryField answers the SPA's read: a Flux query built with a strict
  FIELD WHITELIST + hex-serial guard + parsed-duration range, so no caller input
  is interpolated raw (injection-safe). Server-side influxdb-client-go/v2.
- admin.go: GET /api/hosts/{serial}/metrics?field=&range= -> time-series JSON;
  503 when InfluxDB isn't wired, 400 on a bad range, 404 for unknown host.
- main.go: sink created when FLEET_MGR_INFLUX_URL+_TOKEN set, fed to the agent
  hub; nil -> no-op sink so the manager runs without metrics.

Tests: snapshotPoint tags/fields/time, fluxQuery contents + %q quoting, and
QueryField rejects a non-hex serial + non-whitelisted field. build/test/vet/gofmt
clean. Live InfluxDB path validated in the compose bench (ckpt 6).
2026-07-04 03:59:18 +00:00

226 lines
7.3 KiB
Go

// 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"):
//
// - 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.
//
// Checkpoint 1 wires only the admin plane + inventory + a seed host.
package main
import (
"crypto/tls"
"crypto/x509"
"log"
"net/http"
"os"
"strconv"
"time"
)
type config struct {
adminAddr string
adminInsecure bool
adminCert string
adminKey string
adminClientCA string // dedicated admin CA (verifies admin browser certs)
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
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", ""),
adminClientCA: env("FLEET_MGR_ADMIN_CLIENT_CA", ""),
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"),
},
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
}
func main() {
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)")
}
mux := http.NewServeMux()
s.routes(mux)
srv := &http.Server{
Addr: cfg.adminAddr,
Handler: mux,
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 client-cert auth) on %s — dev/lab only", cfg.adminAddr)
log.Printf("fleet-manager admin (insecure) listening on %s", 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 (mTLS) listening on %s", cfg.adminAddr)
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.
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 {
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,
}, 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) }