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,139 @@
|
||||
// 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
|
||||
}
|
||||
// checkpoint 2: after persisting, push the new desired-state down the host's
|
||||
// live agent stream here (if connected); offline hosts get it on reconnect.
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user