Files
Audiobuzz df4fa95bdd fleet-manager: checkpoint 3 — authorise pillar (broker fold-in, births inventory)
The AUTHORISE pillar: the manager owns the enrolment queue UX, the enrol-broker
stays the state machine + step-ca signer (v1 'alongside' fold-in).

enrol-broker (minimal, backward-compatible change):
- admin approve/deny now return the decided Enrolment as JSON when the caller
  sends Accept: application/json. Browsers still redirect; other API callers
  (verify-serving.sh) still get 204. The cert PEM stays unexported (only the
  requesting host gets it via /status) — the JSON carries cert_serial, hostnqn,
  context, fingerprint, cn.

fleet-manager:
- broker.go: mTLS client for the broker admin plane (Pending/Approve/Deny),
  nil when unconfigured so the manager runs standalone.
- admin.go: GET /api/enrol/pending (proxy), POST /api/enrol/{id}/approve
  (drives broker, then BIRTHS the inventory record — identity from the returned
  serial/hostnqn/context, binding from the operator's optional body, enrolled
  with the HUMAN approver = the manager's admin mTLS CN), POST .../deny.
  503 when the broker isn't wired.

Test: httptest mock broker asserts the Accept:application/json approve, pending
proxy, inventory birth (identity+binding+enrolled), and the disabled-503 path.
Broker + manager both build/vet/test/gofmt clean.
2026-07-04 03:51:26 +00:00

122 lines
3.7 KiB
Go

// 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
}