fleet-manager: checkpoint 1 — service skeleton + inventory store + admin API
First slice of the server-side fleet control plane (serving/fleet-manager/), the
backend for the admin SPA (monitor / authorise / lifecycle). See
docs/fleet-management-design.md + tpm-enrolment-design.md §5.
This checkpoint (stdlib-only):
- Inventory record (identity/binding/enrolled/version/health/usage/desired),
keyed by TPM cert serial = hostnqn key, per the design's 'inventory record born
at enrolment'. JSON-file Store (atomic writes, mutex) behind an interface so a
DB swap is a drop-in.
- Admin plane: mTLS server verified by the DEDICATED admin CA (never the fleet CA,
so a host cert can't reach admin) + a plaintext dev mode. JSON API:
GET /api/hosts, GET /api/hosts/{serial}, PUT /api/hosts/{serial}/desired
(desired-state audited to the admin client-cert CN).
- Dev seed (FLEET_MGR_SEED) so the SPA has data before real enrolment/agent flow.
Smoke-tested: list/get/desired-PUT (incl. version-drift + drain), 404s, atomic
persistence. go build + go vet + gofmt clean.
Agent gRPC plane, broker-fold-in authorise, InfluxDB metrics, and the themed SPA
follow in checkpoints 2-6.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 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)
|
||||
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", ""),
|
||||
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
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
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) }
|
||||
Reference in New Issue
Block a user