Files
diskless-workstation/serving/fleet-manager/admin.go
T
Audiobuzz 80cbb833cc fleet-manager: checkpoint 2 — gRPC agent plane (bidi mTLS stream)
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.
2026-07-04 03:40:55 +00:00

145 lines
5.8 KiB
Go

// admin.go — the admin JSON API (the SPA's backend) served on the mTLS admin
// plane. Checkpoint 1 covers the MONITOR read surface + LIFECYCLE desired-state;
// the AUTHORISE routes (enrolment approve/deny) land in checkpoint 3 and the
// InfluxDB-backed metrics routes in checkpoint 4.
//
// Every mutating action is attributed to the admin's mTLS client-cert CN
// (adminCN), the same audit convention the broker uses.
package main
import (
"encoding/json"
"net/http"
"time"
)
func (s *server) routes(mux *http.ServeMux) {
// Liveness (no auth-sensitive data; also reachable in insecure dev mode).
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok\n"))
})
// MONITOR — fleet overview + per-host detail.
mux.HandleFunc("GET /api/hosts", s.handleListHosts)
mux.HandleFunc("GET /api/hosts/{serial}", s.handleGetHost)
// LIFECYCLE — set server-authoritative desired-state for one host.
mux.HandleFunc("PUT /api/hosts/{serial}/desired", s.handleSetDesired)
// AUTHORISE routes (/api/enrol/*) — checkpoint 3.
// METRICS routes (/api/hosts/{serial}/metrics) — checkpoint 4.
}
func (s *server) handleListHosts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"hosts": s.store.List()})
}
func (s *server) handleGetHost(w http.ResponseWriter, r *http.Request) {
serial := r.PathValue("serial")
h, ok := s.store.Get(serial)
if !ok {
writeErr(w, http.StatusNotFound, "no such host")
return
}
writeJSON(w, http.StatusOK, h)
}
func (s *server) handleSetDesired(w http.ResponseWriter, r *http.Request) {
serial := r.PathValue("serial")
var d Desired
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&d); err != nil {
writeErr(w, http.StatusBadRequest, "invalid desired-state JSON: "+err.Error())
return
}
// Provenance: the client-cert CN is authoritative; ignore any body-supplied value.
d.UpdatedBy = adminCN(r)
h, ok, err := s.store.SetDesired(serial, d)
if err != nil {
writeErr(w, http.StatusInternalServerError, "persist failed: "+err.Error())
return
}
if !ok {
writeErr(w, http.StatusNotFound, "no such host")
return
}
// Push the new desired-state down the host's live agent stream if connected;
// an offline host (or one we can't reach right now) picks it up from the
// persisted inventory record on its next connect — inventory is authoritative.
pushed := false
if s.hub != nil {
pushed = s.hub.pushDesired(serial, h.Desired)
}
writeJSON(w, http.StatusOK, map[string]any{"host": h, "pushed_live": pushed})
}
// adminCN returns the CN of the admin's mTLS client cert, for audit attribution.
// In insecure dev mode there is no peer cert, so it reports a sentinel.
func adminCN(r *http.Request) string {
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
return cn
}
return "unknown-admin"
}
return "dev-insecure"
}
// ---- JSON response helpers ------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// ---- dev seed -------------------------------------------------------------
// seedHosts injects a couple of demo records so the SPA has something to render
// before real enrolment/agent data exists. Gated by FLEET_MGR_SEED=true; never
// overwrites an existing record. Returns how many it added.
func seedHosts(store Store, hostnqnBase string) int {
now := time.Now().UTC()
demo := []*Host{
{
Identity: Identity{CertSerial: "a1b2c3d4e5f60001", Hostnqn: hostnqnBase + "a1b2c3d4e5f60001", MAC: "de:ad:be:ef:00:01", StarshipName: "comet-beacon-54"},
Binding: Binding{AssetTag: "KIOSK-0001", SwitchPort: "sw1/12", Location: "Foyer"},
Enrolled: Enrolled{Approver: "seed", At: now.Add(-72 * time.Hour), Fingerprint: "amber-castle-river-delta-orbit-nova"},
Version: Version{ImageVersion: "0.5.0", Kernel: "5.14.0-687.10.1.el9_8.0.1.x86_64"},
Health: Health{LoadAvg: 0.42, CPUPct: 7, TempC: 44, MemUsedPct: 31, TmpfsUsedPct: 58, NVMeRoot: "ok", Auth: "up", SELinux: "enforcing", Chrony: "synced", KioskActive: true},
Usage: Usage{KioskURL: "http://boot.fleet.lab/kiosk/", SessionUptime: "3h 12m", Display: "1920x1080"},
Desired: Desired{TargetVersion: "0.5.0", KioskURL: "http://boot.fleet.lab/kiosk/"},
Online: true, FirstSeen: now.Add(-72 * time.Hour), LastSeen: now.Add(-15 * time.Second),
},
{
Identity: Identity{CertSerial: "a1b2c3d4e5f60002", Hostnqn: hostnqnBase + "a1b2c3d4e5f60002", MAC: "de:ad:be:ef:00:02", StarshipName: "orion-drifter-07"},
Binding: Binding{AssetTag: "KIOSK-0002", SwitchPort: "sw1/13", Location: "Lab B"},
Enrolled: Enrolled{Approver: "seed", At: now.Add(-48 * time.Hour), Fingerprint: "violet-harbor-signal-quartz-piano-lunar"},
Version: Version{ImageVersion: "0.4.2", Kernel: "5.14.0-687.10.1.el9_8.0.1.x86_64"},
Health: Health{LoadAvg: 0.11, CPUPct: 3, TempC: 39, MemUsedPct: 27, TmpfsUsedPct: 41, NVMeRoot: "ok", Auth: "up", SELinux: "enforcing", Chrony: "synced", KioskActive: true},
Usage: Usage{KioskURL: "http://boot.fleet.lab/kiosk/", SessionUptime: "1d 4h 9m", Display: "1920x1080"},
Desired: Desired{TargetVersion: "0.5.0", KioskURL: "http://boot.fleet.lab/kiosk/"}, // running 0.4.2 → drift
Online: false, FirstSeen: now.Add(-48 * time.Hour), LastSeen: now.Add(-9 * time.Minute),
},
}
added := 0
for _, h := range demo {
if _, exists := store.Get(h.Identity.CertSerial); exists {
continue
}
if store.Upsert(h) == nil {
added++
}
}
return added
}