// 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" "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) redirectOrJSON(w, r) } 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) redirectOrJSON(w, r) } // 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) { http.Redirect(w, r, "/", http.StatusSeeOther) return } w.WriteHeader(http.StatusNoContent) } 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 = ` fleet enrolment — pending

fleet enrolment — pending approvals

admin: {{.Admin}} · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving

{{if not .Pending}}
no pending enrolments.
{{end}} {{range .Pending}}
{{.Fingerprint}}
enrol-id{{.ID}} CN{{.CN}} {{if .Context.MAC}}MAC{{.Context.MAC}}{{end}} {{if .Context.DMISerial}}DMI serial{{.Context.DMISerial}}{{end}} {{if .Context.DMIUUID}}DMI uuid{{.Context.DMIUUID}}{{end}} {{if .Context.EKHash}}TPM EK{{.Context.EKHash}}{{end}} pubkey{{.PubKeyHash}} age{{age .CreatedAt}}
{{end}} `