// 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 = `
admin: {{.Admin}} · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving