diff --git a/serving/enrol-broker/admin.go b/serving/enrol-broker/admin.go index 5f4b73e..d1280bb 100644 --- a/serving/enrol-broker/admin.go +++ b/serving/enrol-broker/admin.go @@ -15,6 +15,7 @@ import ( "log" "net/http" "os" + "strings" "time" ) @@ -105,7 +106,7 @@ func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) { return } log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin) - redirectOrJSON(w, r) + respondDecision(w, r, e) } func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) { @@ -117,16 +118,28 @@ func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) { return } log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin) - redirectOrJSON(w, r) + respondDecision(w, r, e) } -// redirectOrJSON sends a browser form back to the queue, or a 204 for API callers. -func redirectOrJSON(w http.ResponseWriter, r *http.Request) { - if isBrowser(r) { +// respondDecision replies to an approve/deny. A browser form gets redirected back +// to the queue; a caller that asks for JSON (Accept: application/json — e.g. the +// fleet-manager driving the broker as its authorise plane) gets the decided +// Enrolment (cert_serial, hostnqn, context, fingerprint — NOT the cert PEM, which +// is unexported and only reaches the requesting host); any other API caller keeps +// the original 204 (so verify-serving.sh and existing scripts are unaffected). +func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) { + switch { + case isBrowser(r): http.Redirect(w, r, "/", http.StatusSeeOther) - return + case wantsJSON(r): + writeJSON(w, http.StatusOK, e) + default: + w.WriteHeader(http.StatusNoContent) } - w.WriteHeader(http.StatusNoContent) +} + +func wantsJSON(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept"), "application/json") } func isBrowser(r *http.Request) bool { diff --git a/serving/fleet-manager/README.md b/serving/fleet-manager/README.md index ddb4d07..938827e 100644 --- a/serving/fleet-manager/README.md +++ b/serving/fleet-manager/README.md @@ -63,9 +63,17 @@ inspectable, reproducible; a DB swap is a drop-in behind the `Store` interface. | `GET /api/hosts` | ✅ ckpt 1 — fleet overview | | `GET /api/hosts/{serial}` | ✅ ckpt 1 — host detail | | `PUT /api/hosts/{serial}/desired` | ✅ ckpt 1 — set desired-state (audited to admin CN) | -| `GET /api/enrol/pending`, `POST /api/enrol/{id}/approve\|deny` | ⏳ ckpt 3 (drives the broker) | +| `GET /api/enrol/pending` | ✅ ckpt 3 — proxies the broker queue | +| `POST /api/enrol/{id}/approve` | ✅ ckpt 3 — signs via broker + **births inventory** (identity/binding/enrolled) | +| `POST /api/enrol/{id}/deny` | ✅ ckpt 3 | | `GET /api/hosts/{serial}/metrics` | ⏳ ckpt 4 (InfluxDB/Flux) | +The authorise pillar drives the enrol-broker's admin plane over mTLS (the manager +is a service client, gated by the dedicated admin CA) asking for JSON, so an +approve returns the decided enrolment; the manager records the **human** approver +(its own admin mTLS CN) and births the inventory record. The broker stays the +enrolment state machine + step-ca signer (v1 "alongside" fold-in). + **Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2. `Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down. Identity is the peer cert serial (never self-asserted); an unenrolled serial is @@ -84,6 +92,9 @@ inventory for offline delivery. Metrics writing is behind a `MetricsSink` | `FLEET_MGR_AGENT_ADDR` | `127.0.0.1:9600` | gRPC agent listener | | `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key | | `FLEET_MGR_AGENT_CLIENT_CA` | — | **fleet CA** (verifies host TPM certs). Agent plane starts only when all three are set. | +| `FLEET_MGR_BROKER_URL` | — | enrol-broker admin base URL (e.g. `https://127.0.0.1:9443`). Authorise pillar enabled only when set. | +| `FLEET_MGR_BROKER_CLIENT_CERT` / `_KEY` | — | manager's client cert for the broker admin mTLS (admin CA) | +| `FLEET_MGR_BROKER_CA` | — | CA that signs the broker's admin server cert | | `FLEET_MGR_INVENTORY` | `/var/lib/fleet-manager/inventory.json` | inventory file (mount a volume) | | `FLEET_MGR_SEED` | `false` | inject demo hosts so the SPA has data — dev only | | `HOSTNQN_BASE` | `nqn.2026-06.org.example.fleet:host-` | hostnqn prefix (match the broker) | @@ -106,5 +117,9 @@ curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name, - **Checkpoint 2 (done):** gRPC agent plane — proto contract, mTLS (fleet CA) server, stream registry, identity-from-cert, snapshot→inventory, live desired-state push, `MetricsSink` seam. Full mTLS integration test. -- Next: authorise/broker fold-in (ckpt 3) → InfluxDB read/write (ckpt 4) → themed - SPA (ckpt 5) → Docker/compose + PKI + docs (ckpt 6). +- **Checkpoint 3 (done):** authorise pillar — the enrol-broker gained an + `Accept`-gated JSON approve response (browsers/`verify-serving.sh` unchanged); + the manager drives it over mTLS and births the inventory record on approve. + httptest-mock-broker test. +- Next: InfluxDB read/write (ckpt 4) → themed SPA (ckpt 5) → Docker/compose + PKI + + docs (ckpt 6). diff --git a/serving/fleet-manager/admin.go b/serving/fleet-manager/admin.go index 5056859..85ae22d 100644 --- a/serving/fleet-manager/admin.go +++ b/serving/fleet-manager/admin.go @@ -9,6 +9,8 @@ package main import ( "encoding/json" + "io" + "log" "net/http" "time" ) @@ -27,7 +29,12 @@ func (s *server) routes(mux *http.ServeMux) { // LIFECYCLE — set server-authoritative desired-state for one host. mux.HandleFunc("PUT /api/hosts/{serial}/desired", s.handleSetDesired) - // AUTHORISE routes (/api/enrol/*) — checkpoint 3. + // 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 routes (/api/hosts/{serial}/metrics) — checkpoint 4. } @@ -75,6 +82,77 @@ func (s *server) handleSetDesired(w http.ResponseWriter, r *http.Request) { 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) +} + +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 { diff --git a/serving/fleet-manager/broker.go b/serving/fleet-manager/broker.go new file mode 100644 index 0000000..c42a314 --- /dev/null +++ b/serving/fleet-manager/broker.go @@ -0,0 +1,121 @@ +// broker.go — the manager's client for the enrol-broker's admin plane. +// +// The v1 "alongside" fold-in (per the plan): the manager owns the AUTHORISE UX +// (the SPA's queue + approve/deny), but the enrol-broker remains the enrolment +// state machine + step-ca signer. The manager drives the broker's admin API over +// mTLS as a service client (the dedicated admin CA gates it, same as a human +// admin), asking for JSON (Accept: application/json) so an approve returns the +// decided Enrolment — cert_serial + hostnqn + context + fingerprint — from which +// the manager BIRTHS the inventory record. Full absorption into one process is a +// later step; this keeps the bench-critical broker untouched. +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// brokerEnrolment is the subset of the broker's Enrolment JSON we consume. +type brokerEnrolment struct { + ID string `json:"id"` + State string `json:"state"` + Fingerprint string `json:"fingerprint"` + CN string `json:"cn"` + PubKeyHash string `json:"pubkey_hash"` + CertSerial string `json:"cert_serial"` + Hostnqn string `json:"hostnqn"` + Context brokerCtxContext `json:"context"` + CreatedAt time.Time `json:"created_at"` + DecidedBy string `json:"decided_by"` +} + +type brokerCtxContext struct { + MAC string `json:"mac"` + DMIUUID string `json:"dmi_uuid"` + DMISerial string `json:"dmi_serial"` + EKHash string `json:"ek_hash"` +} + +type brokerClient struct { + base string + hc *http.Client +} + +// newBrokerClient builds the mTLS client, or returns nil (authorise disabled) if +// no broker URL is configured — so the manager runs standalone in dev. +func newBrokerClient(cfg config) (*brokerClient, error) { + if cfg.brokerAdminURL == "" { + return nil, nil + } + cert, err := tls.LoadX509KeyPair(cfg.brokerClientCert, cfg.brokerClientKey) + if err != nil { + return nil, fmt.Errorf("broker client cert: %w", err) + } + caPEM, err := os.ReadFile(cfg.brokerCA) + if err != nil { + return nil, fmt.Errorf("broker CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no certificates in broker CA %s", cfg.brokerCA) + } + return &brokerClient{ + base: strings.TrimRight(cfg.brokerAdminURL, "/"), + hc: &http.Client{ + Timeout: 40 * time.Second, // approve blocks on step-ca signing + Transport: &http.Transport{TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: pool, + MinVersion: tls.VersionTLS12, + }}, + }, + }, nil +} + +func (c *brokerClient) Pending() ([]brokerEnrolment, error) { + var out []brokerEnrolment + if err := c.do(http.MethodGet, "/pending", &out); err != nil { + return nil, err + } + return out, nil +} + +// Approve signs + decides at the broker and returns the decided Enrolment. +func (c *brokerClient) Approve(id string) (brokerEnrolment, error) { + var e brokerEnrolment + err := c.do(http.MethodPost, "/approve/"+url.PathEscape(id), &e) + return e, err +} + +func (c *brokerClient) Deny(id string) error { + return c.do(http.MethodPost, "/deny/"+url.PathEscape(id), nil) +} + +func (c *brokerClient) do(method, path string, out any) error { + req, err := http.NewRequest(method, c.base+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return fmt.Errorf("broker %s %s -> %s: %s", method, path, resp.Status, strings.TrimSpace(string(body))) + } + if out != nil && len(body) > 0 { + return json.Unmarshal(body, out) + } + return nil +} diff --git a/serving/fleet-manager/broker_test.go b/serving/fleet-manager/broker_test.go new file mode 100644 index 0000000..bfc2aa1 --- /dev/null +++ b/serving/fleet-manager/broker_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// mockBroker stands in for the enrol-broker admin plane. It asserts the manager +// asks for JSON on approve, and returns a decided Enrolment. +func mockBroker(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /pending", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, []brokerEnrolment{{ + ID: "enr-1", State: "pending", Fingerprint: "amber-castle-river-delta-orbit-nova", + CN: "fleet-host", PubKeyHash: "pk123", Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"}, + }}) + }) + mux.HandleFunc("POST /approve/{id}", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept"), "application/json") { + t.Errorf("approve did not send Accept: application/json (got %q)", r.Header.Get("Accept")) + } + writeJSON(w, http.StatusOK, brokerEnrolment{ + ID: r.PathValue("id"), State: "approved", + Fingerprint: "amber-castle-river-delta-orbit-nova", CN: "fleet-host", + CertSerial: "beef0009", Hostnqn: "nqn.2026-06.org.example.fleet:host-beef0009", + Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"}, + }) + }) + mux.HandleFunc("POST /deny/{id}", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + return httptest.NewServer(mux) +} + +func TestAuthoriseApproveBirthsInventory(t *testing.T) { + bs := mockBroker(t) + defer bs.Close() + + store, _ := newJSONStore("") + s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}} + mux := http.NewServeMux() + s.routes(mux) + + // pending proxies through + pw := httptest.NewRecorder() + mux.ServeHTTP(pw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil)) + if pw.Code != http.StatusOK { + t.Fatalf("pending -> %d", pw.Code) + } + var plist struct { + Pending []brokerEnrolment `json:"pending"` + } + json.Unmarshal(pw.Body.Bytes(), &plist) + if len(plist.Pending) != 1 || plist.Pending[0].ID != "enr-1" { + t.Fatalf("pending list = %+v", plist.Pending) + } + + // approve with a binding -> inventory record born + aw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/enrol/enr-1/approve", + strings.NewReader(`{"asset_tag":"KIOSK-9","switch_port":"sw1/9","location":"Bay 3"}`)) + mux.ServeHTTP(aw, req) + if aw.Code != http.StatusOK { + t.Fatalf("approve -> %d: %s", aw.Code, aw.Body.String()) + } + + h, ok := store.Get("beef0009") + if !ok { + t.Fatal("inventory record was not born on approve") + } + if h.Identity.Hostnqn != "nqn.2026-06.org.example.fleet:host-beef0009" { + t.Errorf("hostnqn = %q", h.Identity.Hostnqn) + } + if h.Identity.MAC != "de:ad:be:ef:00:09" || h.Identity.EKHash != "ek-abc" { + t.Errorf("identity context not carried: %+v", h.Identity) + } + if h.Binding.AssetTag != "KIOSK-9" || h.Binding.SwitchPort != "sw1/9" || h.Binding.Location != "Bay 3" { + t.Errorf("binding not recorded: %+v", h.Binding) + } + if h.Enrolled.Fingerprint != "amber-castle-river-delta-orbit-nova" { + t.Errorf("fingerprint = %q", h.Enrolled.Fingerprint) + } + if h.Enrolled.Approver != "dev-insecure" { // no client cert in the test request + t.Errorf("approver = %q, want the manager admin CN", h.Enrolled.Approver) + } +} + +func TestAuthoriseDisabledWhenNoBroker(t *testing.T) { + store, _ := newJSONStore("") + s := &server{store: store, broker: nil} // authorise not configured + mux := http.NewServeMux() + s.routes(mux) + + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil)) + if rw.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when no broker, got %d", rw.Code) + } +} diff --git a/serving/fleet-manager/main.go b/serving/fleet-manager/main.go index 5882c60..96c6dc2 100644 --- a/serving/fleet-manager/main.go +++ b/serving/fleet-manager/main.go @@ -36,6 +36,12 @@ type config struct { agentKey string agentClientCA string // FLEET CA (verifies host TPM identity certs) + // enrol-broker admin plane (the authorise pillar drives it over mTLS). + brokerAdminURL string + brokerClientCert string + brokerClientKey string + brokerCA string + inventoryPath string seed bool hostnqnBase string @@ -54,6 +60,11 @@ func loadConfig() config { agentKey: env("FLEET_MGR_AGENT_KEY", ""), agentClientCA: env("FLEET_MGR_AGENT_CLIENT_CA", ""), + brokerAdminURL: env("FLEET_MGR_BROKER_URL", ""), + brokerClientCert: env("FLEET_MGR_BROKER_CLIENT_CERT", ""), + brokerClientKey: env("FLEET_MGR_BROKER_CLIENT_KEY", ""), + brokerCA: env("FLEET_MGR_BROKER_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-"), @@ -62,9 +73,10 @@ func loadConfig() config { // 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 + cfg config + store Store + hub *agentHub // live agent streams; nil if the agent plane isn't configured + broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured } func main() { @@ -83,6 +95,17 @@ func main() { s := &server{cfg: cfg, store: store} + // Authorise pillar: the enrol-broker admin client (nil = disabled, so the + // manager runs standalone before a broker is wired). + broker, err := newBrokerClient(cfg) + if err != nil { + log.Fatalf("broker client: %v", err) + } + s.broker = broker + if broker == nil { + log.Printf("authorise pillar DISABLED (set FLEET_MGR_BROKER_URL + client cert/key + CA to enable)") + } + // 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.