// 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 ( "context" "encoding/json" "io" "log" "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 — enrolment queue; approval drives the broker and BIRTHS the // inventory record (identity/binding/enrolled). mux.HandleFunc("GET /api/enrol/pending", s.handleEnrolPending) mux.HandleFunc("POST /api/enrol/{id}/approve", s.handleEnrolApprove) mux.HandleFunc("POST /api/enrol/{id}/deny", s.handleEnrolDeny) // METRICS — time-series for one host/field, read from InfluxDB via the manager. mux.HandleFunc("GET /api/hosts/{serial}/metrics", s.handleMetrics) } 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}) } // ---- AUTHORISE (enrolment queue, via the broker) -------------------------- func (s *server) handleEnrolPending(w http.ResponseWriter, r *http.Request) { if s.broker == nil { writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)") return } pending, err := s.broker.Pending() if err != nil { writeErr(w, http.StatusBadGateway, "broker: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"pending": pending}) } // handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS // the inventory record from the returned enrolment + the optional binding the // human records at approval time. The approver is THIS admin's client-cert CN // (the human at the SPA), not the broker's view of the manager service. func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) { if s.broker == nil { writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)") return } id := r.PathValue("id") // Optional binding (asset tag / switch port / location) — the physical↔crypto // tie the operator records. Empty body is fine. var binding Binding if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&binding); err != nil && err != io.EOF { writeErr(w, http.StatusBadRequest, "invalid binding JSON: "+err.Error()) return } e, err := s.broker.Approve(id) if err != nil { writeErr(w, http.StatusBadGateway, "broker approve: "+err.Error()) return } if e.CertSerial == "" { writeErr(w, http.StatusBadGateway, "broker approved but returned no cert serial") return } host := &Host{ Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash}, Binding: binding, Enrolled: Enrolled{Approver: adminCN(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint}, } if err := s.store.Upsert(host); err != nil { writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error()) return } log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminCN(r), id) born, _ := s.store.Get(e.CertSerial) writeJSON(w, http.StatusOK, born) } // ---- METRICS (InfluxDB time-series via the manager gateway) --------------- func (s *server) handleMetrics(w http.ResponseWriter, r *http.Request) { if s.influx == nil { writeErr(w, http.StatusServiceUnavailable, "metrics not configured (no InfluxDB wired)") return } serial := r.PathValue("serial") if _, ok := s.store.Get(serial); !ok { writeErr(w, http.StatusNotFound, "no such host") return } field := r.URL.Query().Get("field") if field == "" { field = "tmpfs_used_pct" } rng := time.Hour if rs := r.URL.Query().Get("range"); rs != "" { d, err := time.ParseDuration(rs) if err != nil || d <= 0 { writeErr(w, http.StatusBadRequest, "invalid range (use e.g. 30m, 1h, 24h)") return } rng = d } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() pts, err := s.influx.QueryField(ctx, serial, field, rng) if err != nil { writeErr(w, http.StatusBadGateway, "metrics query: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"serial": serial, "field": field, "range": rng.String(), "points": pts}) } func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) { if s.broker == nil { writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)") return } if err := s.broker.Deny(r.PathValue("id")); err != nil { writeErr(w, http.StatusBadGateway, "broker deny: "+err.Error()) return } w.WriteHeader(http.StatusNoContent) } // 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 }