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