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.
215 lines
7.7 KiB
Go
215 lines
7.7 KiB
Go
// admin.go — the mTLS admin plane: the pending-queue web UI + approve/deny.
|
|
//
|
|
// This listener is the AUTHENTICATED half of the broker and must live on the
|
|
// management network / loopback only — NEVER the boot LAN (docs invariant: the
|
|
// management plane is recon-valuable). Admins authenticate with a client cert
|
|
// issued by the fleet CA; the cert CN is recorded as who approved each enrolment.
|
|
// The fingerprint shown here is what the admin cross-checks against the device's
|
|
// Plymouth splash before approving (the human OOB gate).
|
|
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (b *broker) serveAdmin() {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /", b.handleAdminIndex)
|
|
mux.HandleFunc("GET /pending", b.handleAdminPending)
|
|
mux.HandleFunc("POST /approve/{id}", b.handleApprove)
|
|
mux.HandleFunc("POST /deny/{id}", b.handleDeny)
|
|
|
|
srv := &http.Server{
|
|
Addr: b.cfg.adminAddr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 40 * time.Second, // approve blocks on step-ca
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
if b.cfg.adminInsecure {
|
|
log.Printf("WARNING: BROKER_ADMIN_INSECURE=true — admin UI is PLAINTEXT with NO client-cert auth. DEV ONLY. Bind to loopback and never expose it.")
|
|
log.Fatalf("admin listener: %v", srv.ListenAndServe())
|
|
return
|
|
}
|
|
|
|
if b.cfg.adminCert == "" || b.cfg.adminKey == "" || b.cfg.adminClientCA == "" {
|
|
log.Fatalf("admin mTLS requires BROKER_ADMIN_CERT, BROKER_ADMIN_KEY and BROKER_ADMIN_CLIENT_CA (or set BROKER_ADMIN_INSECURE=true for dev)")
|
|
}
|
|
cert, err := tls.LoadX509KeyPair(b.cfg.adminCert, b.cfg.adminKey)
|
|
if err != nil {
|
|
log.Fatalf("admin server cert: %v", err)
|
|
}
|
|
caPEM, err := os.ReadFile(b.cfg.adminClientCA)
|
|
if err != nil {
|
|
log.Fatalf("admin client CA: %v", err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
log.Fatalf("admin client CA %s contains no certificates", b.cfg.adminClientCA)
|
|
}
|
|
srv.TLSConfig = &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
|
ClientCAs: pool,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
log.Fatalf("admin listener: %v", srv.ListenAndServeTLS("", ""))
|
|
}
|
|
|
|
func adminCN(r *http.Request) string {
|
|
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
|
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
|
|
return cn
|
|
}
|
|
return "unknown-admin"
|
|
}
|
|
return "dev-insecure"
|
|
}
|
|
|
|
func (b *broker) handleAdminPending(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, b.store.Pending())
|
|
}
|
|
|
|
func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
admin := adminCN(r)
|
|
csrPEM, ok := b.store.csrFor(id)
|
|
if !ok {
|
|
http.Error(w, "enrol-id not pending (expired/decided/unknown)", http.StatusConflict)
|
|
return
|
|
}
|
|
csr, err := parseCSR(csrPEM)
|
|
if err != nil {
|
|
http.Error(w, "stored CSR unparsable: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ctx, cancel := ctxWithTimeout(35 * time.Second)
|
|
defer cancel()
|
|
certPEM, serial, err := b.signer.Sign(ctx, csrPEM, csr.Subject.CommonName)
|
|
if err != nil {
|
|
log.Printf("audit approve-FAILED id=%s admin=%q err=%v", id, admin, err)
|
|
http.Error(w, "signing failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
e, err := b.store.Approve(id, certPEM, serial, admin)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin)
|
|
respondDecision(w, r, e)
|
|
}
|
|
|
|
func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
admin := adminCN(r)
|
|
e, err := b.store.Deny(id, admin)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin)
|
|
respondDecision(w, r, e)
|
|
}
|
|
|
|
// 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)
|
|
case wantsJSON(r):
|
|
writeJSON(w, http.StatusOK, e)
|
|
default:
|
|
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 {
|
|
a := r.Header.Get("Accept")
|
|
return len(a) >= 9 && a[:9] == "text/html" || // explicit
|
|
r.Header.Get("Sec-Fetch-Mode") == "navigate"
|
|
}
|
|
|
|
func (b *broker) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|
data := struct {
|
|
Admin string
|
|
Pending []*Enrolment
|
|
Now time.Time
|
|
}{
|
|
Admin: adminCN(r),
|
|
Pending: b.store.Pending(),
|
|
Now: time.Now(),
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := adminTmpl.Execute(w, data); err != nil {
|
|
log.Printf("admin template: %v", err)
|
|
}
|
|
}
|
|
|
|
var adminTmpl = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|
"age": func(t time.Time) string { return time.Since(t).Round(time.Second).String() },
|
|
}).Parse(adminHTML))
|
|
|
|
const adminHTML = `<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>fleet enrolment — pending</title>
|
|
<style>
|
|
:root{color-scheme:dark}
|
|
body{background:#0b0f14;color:#cfe3ee;font:14px/1.5 ui-monospace,Menlo,Consolas,monospace;margin:0;padding:1.5rem}
|
|
h1{font-size:1.1rem;color:#7fd1ff;margin:0 0 .25rem}
|
|
.sub{color:#6b8294;margin:0 0 1.25rem}
|
|
.empty{color:#6b8294;padding:2rem 0}
|
|
.card{background:#111923;border:1px solid #1f2d3a;border-radius:8px;padding:1rem 1.1rem;margin:0 0 1rem}
|
|
.fp{font-size:1.25rem;color:#aef9b0;letter-spacing:.5px;word-break:break-word}
|
|
.meta{display:grid;grid-template-columns:max-content 1fr;gap:.1rem .8rem;margin:.6rem 0;color:#9fb6c4}
|
|
.meta b{color:#6b8294;font-weight:400}
|
|
.actions{margin-top:.7rem}
|
|
button{font:inherit;border:0;border-radius:6px;padding:.45rem .9rem;cursor:pointer}
|
|
.approve{background:#15602f;color:#d6ffe0}
|
|
.deny{background:#5e1b1b;color:#ffd9d9;margin-left:.5rem}
|
|
form{display:inline}
|
|
code{color:#cfe3ee}
|
|
</style></head><body>
|
|
<h1>fleet enrolment — pending approvals</h1>
|
|
<p class="sub">admin: <code>{{.Admin}}</code> · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving</p>
|
|
{{if not .Pending}}<div class="empty">no pending enrolments.</div>{{end}}
|
|
{{range .Pending}}
|
|
<div class="card">
|
|
<div class="fp">{{.Fingerprint}}</div>
|
|
<div class="meta">
|
|
<b>enrol-id</b><code>{{.ID}}</code>
|
|
<b>CN</b><code>{{.CN}}</code>
|
|
{{if .Context.MAC}}<b>MAC</b><code>{{.Context.MAC}}</code>{{end}}
|
|
{{if .Context.DMISerial}}<b>DMI serial</b><code>{{.Context.DMISerial}}</code>{{end}}
|
|
{{if .Context.DMIUUID}}<b>DMI uuid</b><code>{{.Context.DMIUUID}}</code>{{end}}
|
|
{{if .Context.EKHash}}<b>TPM EK</b><code>{{.Context.EKHash}}</code>{{end}}
|
|
<b>pubkey</b><code>{{.PubKeyHash}}</code>
|
|
<b>age</b><code>{{age .CreatedAt}}</code>
|
|
</div>
|
|
<div class="actions">
|
|
<form method="post" action="/approve/{{.ID}}"><button class="approve" type="submit">approve</button></form>
|
|
<form method="post" action="/deny/{{.ID}}"><button class="deny" type="submit">deny</button></form>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</body></html>`
|