The human->UI hop now authenticates with a hardware passkey (YubiKey), not an mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker stays mTLS — service-to-service.) - webauthn.go: credential store (registered admins + passkeys + a one-time-token jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup. - cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed enrolment link over a shared enrol key; the browser completes the WebAuthn create ceremony. No anyone-can-register endpoint; single-use via the ledger. - authapi.go: register/login ceremonies (usernameless discoverable login, userVerification required) + logout + whoami; short-lived in-memory ceremony store between begin/finish. - session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie) + requireSession middleware gating /api/*, with a CSRF header check on mutations. adminName now comes from the session (audit attribution). - main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org. /api/* mounted behind requireSession; /auth/* + /healthz public. Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate; DNS-01 proves control of bitasylum.org via TXT + supports wildcards). External ACME client issues+renews to the cert path; manager reloads. Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony (virtual authenticator / real YubiKey) is a bench item.
244 lines
9.4 KiB
Go
244 lines
9.4 KiB
Go
// admin.go — the session-gated admin JSON API (the SPA's backend). Mounted under
|
|
// /api/ behind requireSession; browser identity is the WebAuthn passkey session
|
|
// (session.go), so every mutating action is attributed to the signed-in admin
|
|
// (adminName), not a client cert.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// apiRoutes registers the session-gated admin API (mounted under /api/ behind
|
|
// requireSession). The public liveness + WebAuthn ceremonies live in authRoutes.
|
|
func (s *server) apiRoutes(mux *http.ServeMux) {
|
|
// Session probe (lets the SPA resume + recover its CSRF token on reload).
|
|
mux.HandleFunc("GET /api/whoami", s.handleWhoami)
|
|
|
|
// 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 signed-in admin is authoritative; ignore any body value.
|
|
d.UpdatedBy = adminName(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 signed-in admin's identity
|
|
// (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: adminName(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, adminName(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)
|
|
}
|
|
|
|
// ---- 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
|
|
}
|