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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user