The steady-state host<->manager transport, resolving the design's OPEN FORK to bespoke gRPC (not OTLP+Wazuh). One long-lived, host-dialed, mTLS bidi stream: health/metrics up, desired-state/commands down — no polling, real-time control, still no inbound host port. - proto/fleet_agent.proto: FleetAgent.Session(stream AgentUp) -> stream ManagerDown. AgentUp = Hello | HealthSnapshot | CommandResult; ManagerDown = DesiredState | Command(reboot/drain/resync). int64 unix times (no well-known-type dep). Generated stubs committed (CI needs no protoc). - agent.go: gRPC server with mTLS verified by the FLEET CA (the host's reused TPM identity cert — distinct from the admin plane's admin CA). Identity is the peer cert SERIAL, taken from the verified cert = inventory key, never self-asserted; an unenrolled serial is refused (enrolment gates the stream). Per-conn send channel decouples admin pushes from stream.Send. Stream registry (serial->conn) with reconnect eviction. On connect + on change, desired-state is pushed live; inventory stays authoritative so offline hosts reconcile on reconnect. Metrics go through a MetricsSink seam (no-op now; InfluxDB in ckpt 4). - main.go: agent plane starts only when its cert/key/CA are set; admin can run standalone. Lifecycle PUT now live-pushes and reports pushed_live. Full mTLS integration test (in-process fleet CA + host client cert): initial desired-state on connect, Hello+snapshot -> inventory + online presence, live desired-state push, and unenrolled-serial rejection. go build/test/vet/gofmt clean. Deps (server-side only): google.golang.org/grpc + protobuf.
179 lines
5.5 KiB
Go
179 lines
5.5 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)
|
|
|
|
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", ""),
|
|
|
|
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
|
|
}
|
|
|
|
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}
|
|
|
|
// 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.
|
|
if cfg.agentCert != "" && cfg.agentKey != "" && cfg.agentClientCA != "" {
|
|
s.hub = newAgentHub(store, nil) // InfluxDB sink wired in checkpoint 4
|
|
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) }
|