`.
+// Writes the base64-encoded ASN.1 SEQUENCE OF OID body to stdout. The
+// 204-No-Content case (no profile-derived hints) prints an empty
+// payload + a STDERR diagnostic so an operator running the smoke-test
+// case knows the endpoint succeeded.
+func (c *Client) EstCsrattrs(args []string) error {
+ fs := flag.NewFlagSet("est csrattrs", flag.ContinueOnError)
+ profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
+ out := fs.String("out", "-", "output file path; '-' = stdout")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ body, status, _, err := c.estGet(c.estPath(*profile, "csrattrs"))
+ if err != nil {
+ return err
+ }
+ if status == http.StatusNoContent {
+ fmt.Fprintln(os.Stderr, "no csrattrs hints configured (HTTP 204)")
+ return nil
+ }
+ return writeOutput(*out, body)
+}
+
+// EstEnroll implements `certctl-cli est enroll --profile
--csr `.
+// POSTs the CSR to /simpleenroll. The CSR body is sent as-is (whether
+// PEM or base64-DER); the server's readCSRFromRequest handles either.
+func (c *Client) EstEnroll(args []string) error {
+ return c.estEnrollOp("simpleenroll", "est enroll", args)
+}
+
+// EstReEnroll implements `certctl-cli est reenroll --profile
--csr `.
+func (c *Client) EstReEnroll(args []string) error {
+ return c.estEnrollOp("simplereenroll", "est reenroll", args)
+}
+
+// estEnrollOp shares the body of EstEnroll + EstReEnroll. The only
+// difference between the two is the URL suffix (simpleenroll vs
+// simplereenroll); the audit-action distinction is server-side.
+func (c *Client) estEnrollOp(op, helpName string, args []string) error {
+ fs := flag.NewFlagSet(helpName, flag.ContinueOnError)
+ profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
+ csrPath := fs.String("csr", "", "path to PKCS#10 CSR (PEM or base64-DER); '-' = stdin")
+ out := fs.String("out", "-", "output file path; '-' = stdout")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if *csrPath == "" {
+ return fmt.Errorf("--csr is required (path to a PKCS#10 CSR file or '-' for stdin)")
+ }
+ csrBytes, err := readCSRBytes(*csrPath)
+ if err != nil {
+ return fmt.Errorf("read CSR: %w", err)
+ }
+ // EST §4.2.1: client MAY send PEM or base64-DER. The server's
+ // readCSRFromRequest handles either; we forward what the operator
+ // supplied. Content-Type is application/pkcs10 per RFC 7030.
+ body, _, err := c.estPostBody(c.estPath(*profile, op), "application/pkcs10", csrBytes)
+ if err != nil {
+ return err
+ }
+ return writeOutput(*out, body)
+}
+
+// EstServerKeygen implements `certctl-cli est serverkeygen --profile
+// --csr --out `. The server returns multipart/mixed; we
+// split into the cert part and the encrypted-key part, write each to
+// .cert.pem + .key.enveloped so the operator can pipe
+// the latter into `openssl smime -decrypt -inkey `.
+func (c *Client) EstServerKeygen(args []string) error {
+ fs := flag.NewFlagSet("est serverkeygen", flag.ContinueOnError)
+ profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
+ csrPath := fs.String("csr", "", "path to PKCS#10 CSR (PEM or base64-DER); '-' = stdin")
+ outPrefix := fs.String("out", "keypair", "output file prefix; produces .cert.pem + .key.enveloped")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if *csrPath == "" {
+ return fmt.Errorf("--csr is required")
+ }
+ csrBytes, err := readCSRBytes(*csrPath)
+ if err != nil {
+ return fmt.Errorf("read CSR: %w", err)
+ }
+ body, hdr, err := c.estPostBody(c.estPath(*profile, "serverkeygen"), "application/pkcs10", csrBytes)
+ if err != nil {
+ return err
+ }
+ // Parse the multipart body so we can write each part to its own
+ // file. The handler emits two parts: certs-only PKCS#7 and
+ // EnvelopedData PKCS#7. We don't decrypt the key here — the
+ // operator's smime client owns the recipient private key.
+ contentType := hdr.Get("Content-Type")
+ certPart, keyPart, err := splitServerKeygenMultipart(body, contentType)
+ if err != nil {
+ return fmt.Errorf("parse multipart response: %w", err)
+ }
+ certFile := *outPrefix + ".cert.pem"
+ keyFile := *outPrefix + ".key.enveloped"
+ if err := os.WriteFile(certFile, certPart, 0o600); err != nil {
+ return fmt.Errorf("write cert: %w", err)
+ }
+ if err := os.WriteFile(keyFile, keyPart, 0o600); err != nil {
+ return fmt.Errorf("write key: %w", err)
+ }
+ fmt.Fprintf(os.Stderr, "wrote %s + %s\n", certFile, keyFile)
+ return nil
+}
+
+// EstTest is a smoke-test that hits cacerts + csrattrs in sequence and
+// prints a one-line OK/FAIL per endpoint. Useful for operator post-
+// deploy validation: `certctl-cli est test --profile corp` returns
+// exit-0 when both endpoints respond successfully.
+func (c *Client) EstTest(args []string) error {
+ fs := flag.NewFlagSet("est test", flag.ContinueOnError)
+ profile := fs.String("profile", "", "EST profile PathID (empty = legacy root)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if _, _, _, err := c.estGet(c.estPath(*profile, "cacerts")); err != nil {
+ fmt.Fprintf(os.Stderr, "FAIL cacerts: %v\n", err)
+ return err
+ }
+ fmt.Fprintln(os.Stderr, "OK cacerts")
+ if _, status, _, err := c.estGet(c.estPath(*profile, "csrattrs")); err != nil {
+ fmt.Fprintf(os.Stderr, "FAIL csrattrs: %v\n", err)
+ return err
+ } else {
+ fmt.Fprintf(os.Stderr, "OK csrattrs (HTTP %d)\n", status)
+ }
+ return nil
+}
+
+// writeOutput writes to disk or stdout depending on --out. Centralises
+// the open-truncate-permission semantics so every subcommand uses the
+// same shape.
+func writeOutput(path string, body []byte) error {
+ if path == "-" || path == "" {
+ _, err := os.Stdout.Write(body)
+ return err
+ }
+ return os.WriteFile(path, body, 0o600)
+}
+
+// splitServerKeygenMultipart cracks the RFC 7030 §4.4.2 multipart body
+// into its two PKCS#7 parts. The caller hands us the response body +
+// the Content-Type header value (which carries the boundary parameter
+// produced by handler.newMultipartBoundary).
+//
+// Light-weight on purpose — we use stdlib mime/multipart but keep the
+// helper self-contained here so the test can swap in fixture bytes
+// without spinning up the full ESTHandler.
+func splitServerKeygenMultipart(body []byte, contentType string) ([]byte, []byte, error) {
+ _, params, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse Content-Type %q: %w", contentType, err)
+ }
+ boundary := params["boundary"]
+ if boundary == "" {
+ return nil, nil, fmt.Errorf("multipart Content-Type %q missing boundary parameter", contentType)
+ }
+ mr := multipart.NewReader(bytes.NewReader(body), boundary)
+ var cert, key []byte
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ ct := part.Header.Get("Content-Type")
+ // The handler wraps each part body in base64 (see writeBase64Wrapped).
+ raw, _ := io.ReadAll(part)
+ decoded, decErr := decodeBase64Wrapped(raw)
+ if decErr != nil {
+ // Some clients want the base64 verbatim; fall through to raw.
+ decoded = raw
+ }
+ switch {
+ case strings.Contains(ct, "smime-type=certs-only"):
+ cert = decoded
+ case strings.Contains(ct, "smime-type=enveloped-data"):
+ key = decoded
+ }
+ }
+ if len(cert) == 0 || len(key) == 0 {
+ return nil, nil, fmt.Errorf("multipart response missing required parts (cert=%d bytes, key=%d bytes)", len(cert), len(key))
+ }
+ return cert, key, nil
+}
+
+// decodeBase64Wrapped strips CRLF wrapping then base64-decodes. The
+// EST handler emits the body as base64 with CRLF every 76 chars per
+// RFC 2045; client-side decode is "rip the whitespace, base64-decode
+// the rest".
+func decodeBase64Wrapped(in []byte) ([]byte, error) {
+ stripped := strings.Map(func(r rune) rune {
+ if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
+ return -1
+ }
+ return r
+ }, string(in))
+ return base64.StdEncoding.DecodeString(stripped)
+}
+
+// _ = pem.Decode is referenced by est_test.go to verify the cacerts
+// + enroll responses are parseable PEM. Keep the import live without
+// growing the public API.
+var _ = pem.Decode
diff --git a/internal/cli/est_test.go b/internal/cli/est_test.go
new file mode 100644
index 0000000..db1f039
--- /dev/null
+++ b/internal/cli/est_test.go
@@ -0,0 +1,209 @@
+package cli
+
+import (
+ "bytes"
+ "crypto/tls"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// EST RFC 7030 hardening master bundle Phase 9.3 — CLI subcommand tests.
+// Exercise each EST CLI subcommand against an httptest server that
+// asserts request shape (method + path + Content-Type) + emits a
+// canned response body. This pins the wire-format contract on the
+// CLI side without dragging the full ESTHandler into the test build.
+
+func newESTTestClient(t *testing.T, server *httptest.Server) *Client {
+ t.Helper()
+ // CLI defaults to TLS-1.3-min; the httptest TLS server uses an
+ // auto-generated leaf cert, so we set InsecureSkipVerify (the
+ // CLI's --insecure equivalent) for the test.
+ c := &Client{
+ baseURL: server.URL,
+ apiKey: "",
+ format: "table",
+ httpClient: server.Client(),
+ }
+ // Force TLS 1.3 to mirror NewClient's production setting.
+ if t, ok := c.httpClient.Transport.(*http.Transport); ok && t.TLSClientConfig != nil {
+ t.TLSClientConfig.MinVersion = tls.VersionTLS13
+ }
+ return c
+}
+
+func TestEstCacerts_GetsBaseRoute(t *testing.T) {
+ wantPath := "/.well-known/est/corp/cacerts"
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "GET" || r.URL.Path != wantPath {
+ t.Errorf("got %s %s, want GET %s", r.Method, r.URL.Path, wantPath)
+ }
+ w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
+ w.Write([]byte("MIIBaseinPKCS7Body"))
+ }))
+ defer server.Close()
+ c := newESTTestClient(t, server)
+ tmp := filepath.Join(t.TempDir(), "ca.p7")
+ if err := c.EstCacerts([]string{"--profile", "corp", "--out", tmp}); err != nil {
+ t.Fatalf("EstCacerts: %v", err)
+ }
+ got, err := os.ReadFile(tmp)
+ if err != nil {
+ t.Fatalf("read out: %v", err)
+ }
+ if string(got) != "MIIBaseinPKCS7Body" {
+ t.Errorf("body = %q, want MIIBaseinPKCS7Body", got)
+ }
+}
+
+func TestEstCsrattrs_204IsNotAnError(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/.well-known/est/corp/csrattrs" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+ c := newESTTestClient(t, server)
+ if err := c.EstCsrattrs([]string{"--profile", "corp"}); err != nil {
+ t.Errorf("204 should not surface as an error: %v", err)
+ }
+}
+
+func TestEstEnroll_PostsCSRAsApplicationPKCS10(t *testing.T) {
+ wantBody := "-----BEGIN CERTIFICATE REQUEST-----\nXXXX\n-----END CERTIFICATE REQUEST-----"
+ csrPath := filepath.Join(t.TempDir(), "device.csr")
+ if err := os.WriteFile(csrPath, []byte(wantBody), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" || r.URL.Path != "/.well-known/est/corp/simpleenroll" {
+ t.Errorf("got %s %s", r.Method, r.URL.Path)
+ }
+ if got := r.Header.Get("Content-Type"); got != "application/pkcs10" {
+ t.Errorf("Content-Type = %q, want application/pkcs10", got)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if string(body) != wantBody {
+ t.Errorf("body = %q, want %q", body, wantBody)
+ }
+ w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
+ w.Write([]byte("ISSUEDCERT"))
+ }))
+ defer server.Close()
+ c := newESTTestClient(t, server)
+ out := filepath.Join(t.TempDir(), "issued.p7")
+ if err := c.EstEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", out}); err != nil {
+ t.Fatalf("EstEnroll: %v", err)
+ }
+ got, _ := os.ReadFile(out)
+ if string(got) != "ISSUEDCERT" {
+ t.Errorf("issued body = %q, want ISSUEDCERT", got)
+ }
+}
+
+func TestEstReEnroll_HitsRenewalPath(t *testing.T) {
+ csrPath := filepath.Join(t.TempDir(), "device.csr")
+ os.WriteFile(csrPath, []byte("dummy-csr"), 0o600)
+ called := false
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/.well-known/est/corp/simplereenroll" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ called = true
+ w.Write([]byte("RENEWED"))
+ }))
+ defer server.Close()
+ c := newESTTestClient(t, server)
+ out := filepath.Join(t.TempDir(), "renewed.p7")
+ if err := c.EstReEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", out}); err != nil {
+ t.Fatalf("EstReEnroll: %v", err)
+ }
+ if !called {
+ t.Error("server never received the request")
+ }
+}
+
+func TestEstEnroll_RequiresCSR(t *testing.T) {
+ c := newESTTestClient(t, httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))
+ err := c.EstEnroll([]string{"--profile", "corp"})
+ if err == nil || !strings.Contains(err.Error(), "csr") {
+ t.Errorf("expected --csr-required error, got %v", err)
+ }
+}
+
+func TestEstEnroll_ServerErrorMappedToFailure(t *testing.T) {
+ csrPath := filepath.Join(t.TempDir(), "device.csr")
+ os.WriteFile(csrPath, []byte("dummy"), 0o600)
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ }))
+ defer server.Close()
+ c := newESTTestClient(t, server)
+ err := c.EstEnroll([]string{"--profile", "corp", "--csr", csrPath, "--out", filepath.Join(t.TempDir(), "out")})
+ if err == nil || !strings.Contains(err.Error(), "HTTP 500") {
+ t.Errorf("expected HTTP 500 error, got %v", err)
+ }
+}
+
+func TestSplitServerKeygenMultipart_RoundTrip(t *testing.T) {
+ // Build a multipart body with two base64-wrapped parts and assert
+ // the split helper hands back the matching bytes. This pins the
+ // CLI's parser against the handler's writer (handler emits via
+ // mime/multipart's same boundary semantics).
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ certPart, _ := w.CreatePart(textproto("application/pkcs7-mime; smime-type=certs-only"))
+ certPart.Write([]byte(base64.StdEncoding.EncodeToString([]byte("CERT_BYTES"))))
+ keyPart, _ := w.CreatePart(textproto("application/pkcs7-mime; smime-type=enveloped-data"))
+ keyPart.Write([]byte(base64.StdEncoding.EncodeToString([]byte("KEY_BYTES"))))
+ w.Close()
+ contentType := fmt.Sprintf("multipart/mixed; boundary=%s", w.Boundary())
+ cert, key, err := splitServerKeygenMultipart(buf.Bytes(), contentType)
+ if err != nil {
+ t.Fatalf("splitServerKeygenMultipart: %v", err)
+ }
+ if string(cert) != "CERT_BYTES" {
+ t.Errorf("cert part = %q, want CERT_BYTES", cert)
+ }
+ if string(key) != "KEY_BYTES" {
+ t.Errorf("key part = %q, want KEY_BYTES", key)
+ }
+}
+
+func TestEstTest_HitsBothEndpoints(t *testing.T) {
+ hits := map[string]int{}
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits[r.URL.Path]++
+ if strings.HasSuffix(r.URL.Path, "/csrattrs") {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ w.Write([]byte("OK"))
+ }))
+ defer server.Close()
+ c := newESTTestClient(t, server)
+ if err := c.EstTest([]string{"--profile", "corp"}); err != nil {
+ t.Fatalf("EstTest: %v", err)
+ }
+ if hits["/.well-known/est/corp/cacerts"] != 1 {
+ t.Errorf("cacerts hit count = %d, want 1", hits["/.well-known/est/corp/cacerts"])
+ }
+ if hits["/.well-known/est/corp/csrattrs"] != 1 {
+ t.Errorf("csrattrs hit count = %d, want 1", hits["/.well-known/est/corp/csrattrs"])
+ }
+}
+
+// textproto builds the small multipart-header form mime/multipart's
+// CreatePart wants (it's a textproto.MIMEHeader). Pulled out as a tiny
+// helper so the test reads cleanly.
+func textproto(contentType string) map[string][]string {
+ return map[string][]string{"Content-Type": {contentType}}
+}
diff --git a/internal/mcp/client.go b/internal/mcp/client.go
index d264eac..0a8a414 100644
--- a/internal/mcp/client.go
+++ b/internal/mcp/client.go
@@ -92,6 +92,39 @@ func (c *Client) DeleteWithQuery(path string, query url.Values) (json.RawMessage
return c.do("DELETE", path, query, nil)
}
+// PostRaw performs an HTTP POST with a non-JSON body and returns the raw
+// response bytes + content type. Used by EST enroll / reenroll where the
+// body is `application/pkcs10` (CSR bytes) and the response is
+// `application/pkcs7-mime; smime-type=certs-only` (base64-wrapped). EST
+// RFC 7030 hardening master bundle Phase 9.2.
+func (c *Client) PostRaw(path, contentType string, body []byte) ([]byte, string, error) {
+ u, err := url.JoinPath(c.baseURL, path)
+ if err != nil {
+ return nil, "", fmt.Errorf("invalid URL: %w", err)
+ }
+ req, err := http.NewRequest("POST", u, bytes.NewReader(body))
+ if err != nil {
+ return nil, "", fmt.Errorf("creating request: %w", err)
+ }
+ req.Header.Set("Content-Type", contentType)
+ if c.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", fmt.Errorf("reading response: %w", err)
+ }
+ if resp.StatusCode >= 400 {
+ return nil, "", fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(data))
+ }
+ return data, resp.Header.Get("Content-Type"), nil
+}
+
// GetRaw performs an HTTP GET and returns the raw response body bytes and content type.
// Used for binary responses (DER CRL, OCSP).
func (c *Client) GetRaw(path string) ([]byte, string, error) {
diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
index 3ff1f75..a35b94d 100644
--- a/internal/mcp/tools.go
+++ b/internal/mcp/tools.go
@@ -29,6 +29,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
registerMetricsTools(s, client)
registerDigestTools(s, client)
registerHealthTools(s, client)
+ registerESTTools(s, client)
}
// ── Helpers ─────────────────────────────────────────────────────────
diff --git a/internal/mcp/tools_est.go b/internal/mcp/tools_est.go
new file mode 100644
index 0000000..ef43bfe
--- /dev/null
+++ b/internal/mcp/tools_est.go
@@ -0,0 +1,142 @@
+package mcp
+
+// EST RFC 7030 hardening master bundle Phase 9.2 — MCP tools.
+//
+// 6 tools mapped to the EST endpoints + admin observability:
+//
+// est_list_profiles → GET /api/v1/admin/est/profiles (M-008 admin-gated)
+// est_get_cacerts → GET /.well-known/est/[/]cacerts
+// est_get_csrattrs → GET /.well-known/est/[/]csrattrs
+// est_enroll → POST /.well-known/est/[/]simpleenroll
+// est_reenroll → POST /.well-known/est/[/]simplereenroll
+// est_admin_stats → alias of est_list_profiles for parity with the
+// SCEP admin tool naming (admin GUI uses both
+// names interchangeably; we expose both for
+// LLM-friendly discovery).
+//
+// Each tool returns the raw response body wrapped via textResult so
+// the MCP fence semantics apply (LLM consumers see the body as
+// untrusted data, not instructions).
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ── Input types ─────────────────────────────────────────────────────
+
+type ESTProfileInput struct {
+ Profile string `json:"profile,omitempty" jsonschema:"EST profile PathID (empty = legacy /.well-known/est root)"`
+}
+
+type ESTEnrollInput struct {
+ Profile string `json:"profile,omitempty" jsonschema:"EST profile PathID (empty = legacy /.well-known/est root)"`
+ CSR string `json:"csr" jsonschema:"PKCS#10 CSR — PEM-encoded or base64-DER. Required."`
+}
+
+// ── Tool registration ──────────────────────────────────────────────
+
+func registerESTTools(s *gomcp.Server, c *Client) {
+ gomcp.AddTool(s, &gomcp.Tool{
+ Name: "est_list_profiles",
+ Description: "List per-profile EST observability snapshot (counters + mTLS trust-anchor expiries + auth-mode posture). Admin-gated. Returns one snapshot per configured EST profile.",
+ }, func(ctx context.Context, req *gomcp.CallToolRequest, input struct{}) (*gomcp.CallToolResult, any, error) {
+ data, err := c.Get("/api/v1/admin/est/profiles", nil)
+ if err != nil {
+ return errorResult(err)
+ }
+ return textResult(data)
+ })
+
+ gomcp.AddTool(s, &gomcp.Tool{
+ Name: "est_admin_stats",
+ Description: "Alias of est_list_profiles — returns the same per-profile EST stats snapshot. Provided so LLM tool discovery surfaces both naming conventions (mirrors the SCEP admin tool naming).",
+ }, func(ctx context.Context, req *gomcp.CallToolRequest, input struct{}) (*gomcp.CallToolResult, any, error) {
+ data, err := c.Get("/api/v1/admin/est/profiles", nil)
+ if err != nil {
+ return errorResult(err)
+ }
+ return textResult(data)
+ })
+
+ gomcp.AddTool(s, &gomcp.Tool{
+ Name: "est_get_cacerts",
+ Description: "EST GET /.well-known/est/[/]cacerts (RFC 7030 §4.1). Returns the base64-wrapped PKCS#7 certs-only response carrying the CA certificate chain. The response body is opaque from the MCP-consumer perspective; pipe into openssl smime / openssl pkcs7 to extract the chain.",
+ }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTProfileInput) (*gomcp.CallToolResult, any, error) {
+ body, contentType, err := c.GetRaw(estPathFor(input.Profile, "cacerts"))
+ if err != nil {
+ return errorResult(err)
+ }
+ return textResult(estRawResultJSON(body, contentType))
+ })
+
+ gomcp.AddTool(s, &gomcp.Tool{
+ Name: "est_get_csrattrs",
+ Description: "EST GET /.well-known/est/[/]csrattrs (RFC 7030 §4.5). Returns the base64-encoded ASN.1 SEQUENCE OF OID hint list the server wants the client to include in subsequent enrollments. Empty body (HTTP 204) when no profile-derived hints are configured.",
+ }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTProfileInput) (*gomcp.CallToolResult, any, error) {
+ body, contentType, err := c.GetRaw(estPathFor(input.Profile, "csrattrs"))
+ if err != nil {
+ return errorResult(err)
+ }
+ return textResult(estRawResultJSON(body, contentType))
+ })
+
+ gomcp.AddTool(s, &gomcp.Tool{
+ Name: "est_enroll",
+ Description: "EST POST /.well-known/est/[/]simpleenroll (RFC 7030 §4.2). Submits a PKCS#10 CSR (PEM or base64-DER) and receives the issued certificate chain as a base64-wrapped PKCS#7 certs-only response.",
+ }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTEnrollInput) (*gomcp.CallToolResult, any, error) {
+ if strings.TrimSpace(input.CSR) == "" {
+ return errorResult(fmt.Errorf("csr is required (PEM-encoded or base64-DER PKCS#10)"))
+ }
+ body, contentType, err := c.PostRaw(estPathFor(input.Profile, "simpleenroll"),
+ "application/pkcs10", []byte(input.CSR))
+ if err != nil {
+ return errorResult(err)
+ }
+ return textResult(estRawResultJSON(body, contentType))
+ })
+
+ gomcp.AddTool(s, &gomcp.Tool{
+ Name: "est_reenroll",
+ Description: "EST POST /.well-known/est/[/]simplereenroll (RFC 7030 §4.2.2). Same wire shape as est_enroll; the audit log distinguishes initial-vs-renewal under the `est_simple_reenroll` action code.",
+ }, func(ctx context.Context, req *gomcp.CallToolRequest, input ESTEnrollInput) (*gomcp.CallToolResult, any, error) {
+ if strings.TrimSpace(input.CSR) == "" {
+ return errorResult(fmt.Errorf("csr is required"))
+ }
+ body, contentType, err := c.PostRaw(estPathFor(input.Profile, "simplereenroll"),
+ "application/pkcs10", []byte(input.CSR))
+ if err != nil {
+ return errorResult(err)
+ }
+ return textResult(estRawResultJSON(body, contentType))
+ })
+}
+
+// estPathFor builds the per-profile EST URL path. Empty profile maps
+// to the legacy root for backward compat with v2.0.x deploys.
+func estPathFor(profile, op string) string {
+ if profile == "" {
+ return "/.well-known/est/" + op
+ }
+ return "/.well-known/est/" + profile + "/" + op
+}
+
+// estRawResultJSON wraps the raw EST response body in a JSON envelope
+// the MCP consumer can structurally consume. The body itself is base64-
+// encoded so the LLM doesn't have to handle binary-safe transport;
+// content_type is preserved verbatim. Mirrors the shape the CRL/OCSP
+// MCP tools use for their binary DER responses.
+func estRawResultJSON(body []byte, contentType string) json.RawMessage {
+ out := map[string]any{
+ "content_type": contentType,
+ "body_base64": base64.StdEncoding.EncodeToString(body),
+ "body_size_bytes": len(body),
+ }
+ raw, _ := json.Marshal(out)
+ return raw
+}
diff --git a/internal/mcp/tools_est_test.go b/internal/mcp/tools_est_test.go
new file mode 100644
index 0000000..a972912
--- /dev/null
+++ b/internal/mcp/tools_est_test.go
@@ -0,0 +1,168 @@
+package mcp
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// EST RFC 7030 hardening master bundle Phase 9.3 — MCP tool tests.
+// Mirror the SCEP/Intune tool test pattern: spin up a fake API, exercise
+// each tool's HTTP path, assert the request shape (method + path +
+// optional Content-Type/body) + the wrapped JSON response.
+
+// mockESTAPI returns a test server that records EST + admin EST requests.
+// Differs from mockCertctlAPI by handling the raw (non-JSON) /.well-known/est/*
+// surfaces — it returns binary-friendly bodies + the EST Content-Type
+// the CLI wire-format tests pin.
+func mockESTAPI(log *requestLog) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := ""
+ if r.Body != nil {
+ buf := make([]byte, 8192)
+ n, _ := r.Body.Read(buf)
+ body = string(buf[:n])
+ }
+ log.add(capturedRequest{
+ Method: r.Method,
+ Path: r.URL.Path,
+ Query: r.URL.RawQuery,
+ Body: body,
+ })
+
+ switch {
+ case strings.HasPrefix(r.URL.Path, "/api/v1/admin/est/profiles"):
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "profiles": []map[string]any{{"path_id": "corp", "issuer_id": "iss-corp"}},
+ "profile_count": 1,
+ "generated_at": "2026-04-29T00:00:00Z",
+ })
+ case strings.HasSuffix(r.URL.Path, "/cacerts"):
+ w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
+ w.Write([]byte("CACERTS-PKCS7-BYTES"))
+ case strings.HasSuffix(r.URL.Path, "/csrattrs"):
+ w.Header().Set("Content-Type", "application/csrattrs")
+ w.Write([]byte("CSRATTRS-DER"))
+ case strings.HasSuffix(r.URL.Path, "/simpleenroll"):
+ w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
+ w.Write([]byte("ENROLLED-CERT-BYTES"))
+ case strings.HasSuffix(r.URL.Path, "/simplereenroll"):
+ w.Header().Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only")
+ w.Write([]byte("REENROLLED-CERT-BYTES"))
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+}
+
+func TestEstPathFor(t *testing.T) {
+ cases := []struct {
+ profile string
+ op string
+ want string
+ }{
+ {"corp", "cacerts", "/.well-known/est/corp/cacerts"},
+ {"", "cacerts", "/.well-known/est/cacerts"},
+ {"iot", "simpleenroll", "/.well-known/est/iot/simpleenroll"},
+ }
+ for _, c := range cases {
+ got := estPathFor(c.profile, c.op)
+ if got != c.want {
+ t.Errorf("estPathFor(%q, %q) = %q, want %q", c.profile, c.op, got, c.want)
+ }
+ }
+}
+
+func TestEstRawResultJSON_EmbedsBase64Body(t *testing.T) {
+ body := []byte("\x00\x01\x02\x03binary")
+ raw := estRawResultJSON(body, "application/pkcs7-mime")
+ var got map[string]any
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got["content_type"] != "application/pkcs7-mime" {
+ t.Errorf("content_type = %v", got["content_type"])
+ }
+ if got["body_base64"] != base64.StdEncoding.EncodeToString(body) {
+ t.Errorf("body_base64 mismatch")
+ }
+ if int(got["body_size_bytes"].(float64)) != len(body) {
+ t.Errorf("body_size_bytes = %v, want %d", got["body_size_bytes"], len(body))
+ }
+}
+
+// TestRegisterESTTools_HitsExpectedPaths exercises each EST tool by
+// driving its handler through the registered mcp.Server's transport
+// surface. We use the Client directly (not the gomcp dispatch layer)
+// because the per-tool handlers are closures over the Client; the
+// request-shape assertion is what matters here.
+func TestRegisterESTTools_AllPathsExercised(t *testing.T) {
+ log := &requestLog{}
+ api := mockESTAPI(log)
+ defer api.Close()
+ client, err := NewClient(api.URL, "test-key", "", false)
+ if err != nil {
+ t.Fatalf("NewClient: %v", err)
+ }
+
+ // est_list_profiles + est_admin_stats both hit the admin endpoint.
+ if _, err := client.Get("/api/v1/admin/est/profiles", nil); err != nil {
+ t.Errorf("admin profiles: %v", err)
+ }
+ // est_get_cacerts via GetRaw.
+ if body, _, err := client.GetRaw(estPathFor("corp", "cacerts")); err != nil {
+ t.Errorf("cacerts GET: %v", err)
+ } else if string(body) != "CACERTS-PKCS7-BYTES" {
+ t.Errorf("cacerts body = %q, want CACERTS-PKCS7-BYTES", body)
+ }
+ // est_get_csrattrs via GetRaw.
+ if body, _, err := client.GetRaw(estPathFor("corp", "csrattrs")); err != nil {
+ t.Errorf("csrattrs GET: %v", err)
+ } else if string(body) != "CSRATTRS-DER" {
+ t.Errorf("csrattrs body = %q, want CSRATTRS-DER", body)
+ }
+ // est_enroll via PostRaw.
+ if body, _, err := client.PostRaw(estPathFor("corp", "simpleenroll"), "application/pkcs10",
+ []byte("CSR-PEM-BYTES")); err != nil {
+ t.Errorf("simpleenroll POST: %v", err)
+ } else if string(body) != "ENROLLED-CERT-BYTES" {
+ t.Errorf("simpleenroll body = %q, want ENROLLED-CERT-BYTES", body)
+ }
+ // est_reenroll via PostRaw.
+ if body, _, err := client.PostRaw(estPathFor("corp", "simplereenroll"), "application/pkcs10",
+ []byte("CSR-PEM-BYTES")); err != nil {
+ t.Errorf("simplereenroll POST: %v", err)
+ } else if string(body) != "REENROLLED-CERT-BYTES" {
+ t.Errorf("simplereenroll body = %q, want REENROLLED-CERT-BYTES", body)
+ }
+
+ // Pin every captured path so a future refactor that reroutes one
+ // tool gets caught.
+ wantPaths := []string{
+ "/api/v1/admin/est/profiles",
+ "/.well-known/est/corp/cacerts",
+ "/.well-known/est/corp/csrattrs",
+ "/.well-known/est/corp/simpleenroll",
+ "/.well-known/est/corp/simplereenroll",
+ }
+ gotPaths := make([]string, 0, len(log.requests))
+ for _, r := range log.requests {
+ gotPaths = append(gotPaths, r.Path)
+ }
+ for _, want := range wantPaths {
+ found := false
+ for _, got := range gotPaths {
+ if got == want {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing request to %s; got %v", want, gotPaths)
+ }
+ }
+}
diff --git a/internal/mcp/tools_per_tool_test.go b/internal/mcp/tools_per_tool_test.go
index 3f42723..f86ec97 100644
--- a/internal/mcp/tools_per_tool_test.go
+++ b/internal/mcp/tools_per_tool_test.go
@@ -359,6 +359,14 @@ var allHappyPathCases = []toolCase{
{"certctl_ready", map[string]any{}, http.MethodGet, "/ready"},
{"certctl_auth_check", map[string]any{}, http.MethodGet, "/api/v1/auth/check"},
{"certctl_auth_info", map[string]any{}, http.MethodGet, "/api/v1/auth/whoami"},
+
+ // EST RFC 7030 hardening Phase 9.2 — 6 EST tools.
+ {"est_list_profiles", map[string]any{}, http.MethodGet, "/api/v1/admin/est/profiles"},
+ {"est_admin_stats", map[string]any{}, http.MethodGet, "/api/v1/admin/est/profiles"},
+ {"est_get_cacerts", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/cacerts"},
+ {"est_get_csrattrs", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/csrattrs"},
+ {"est_enroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simpleenroll"},
+ {"est_reenroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simplereenroll"},
}
// TestMCP_AllTools_HappyPath dispatches every tool against the mock API in
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index cea9c46..cfb4435 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -1,4 +1,4 @@
-import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } from './types';
+import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse, ESTProfilesResponse, ESTReloadTrustResponse } from './types';
const BASE = '/api/v1';
@@ -320,6 +320,22 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
export const getAdminSCEPProfiles = () =>
fetchJSON(`${BASE}/admin/scep/profiles`);
+// EST RFC 7030 hardening master bundle Phase 7.2 admin endpoints.
+//
+// Backend handler: internal/api/handler/admin_est.go.
+// Both endpoints are M-008 admin-gated; the ESTAdminPage component
+// gates the React-Query `enabled` flag on useAuth().admin so non-admin
+// callers never see the page (the route itself is also conditional on
+// the admin flag in main.tsx).
+export const getAdminESTProfiles = () =>
+ fetchJSON(`${BASE}/admin/est/profiles`);
+
+export const reloadAdminESTTrust = (pathID: string) =>
+ fetchJSON(`${BASE}/admin/est/reload-trust`, {
+ method: 'POST',
+ body: JSON.stringify({ path_id: pathID }),
+ });
+
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
// (capability + posture). Synchronous — the caller blocks until the
// probe completes (cap: 30s server-side). Persists to the history
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index 26c2a92..c727c46 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -727,6 +727,43 @@ export interface SCEPProfilesResponse {
generated_at: string;
}
+// EST RFC 7030 hardening master bundle Phase 7.1 / 8 GUI:
+// per-profile snapshot returned by GET /api/v1/admin/est/profiles. Mirrors
+// the Go-side service.ESTStatsSnapshot 1:1.
+export interface ESTTrustAnchorInfo {
+ subject: string;
+ not_before: string;
+ not_after: string;
+ days_to_expiry: number;
+ expired: boolean;
+}
+
+export interface ESTStatsSnapshot {
+ path_id: string;
+ issuer_id: string;
+ profile_id?: string;
+ // 12 named labels — see service/est_counters.go.
+ counters: Record;
+ mtls_enabled: boolean;
+ basic_auth_configured: boolean;
+ server_keygen_enabled: boolean;
+ trust_anchors?: ESTTrustAnchorInfo[];
+ trust_anchor_path?: string;
+ now: string;
+}
+
+export interface ESTProfilesResponse {
+ profiles: ESTStatsSnapshot[];
+ profile_count: number;
+ generated_at: string;
+}
+
+export interface ESTReloadTrustResponse {
+ reloaded: boolean;
+ path_id: string;
+ reloaded_at: string;
+}
+
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
//
// Backs the SCEP Probe section on the Network Scan page. The probe
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index 17be72d..9363ad5 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -24,6 +24,7 @@ const nav = [
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
+ { to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 69e4c44..74a4005 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -33,6 +33,7 @@ import JobDetailPage from './pages/JobDetailPage';
import IssuerDetailPage from './pages/IssuerDetailPage';
import TargetDetailPage from './pages/TargetDetailPage';
import SCEPAdminPage from './pages/SCEPAdminPage';
+import ESTAdminPage from './pages/ESTAdminPage';
import './index.css';
const queryClient = new QueryClient({
@@ -91,6 +92,13 @@ createRoot(document.getElementById('root')!).render(
{/* Backward-compat alias for external bookmarks the Phase 9
release advertised. Lands on the Intune Monitoring tab. */}
} />
+ {/* EST RFC 7030 hardening master bundle Phase 8: per-profile
+ EST Administration page with Profiles / Recent Activity /
+ Trust Bundle tabs. Same admin-gate pattern as SCEP — the
+ route is unconditional; the page renders an "Admin access
+ required" banner for non-admin callers and skips the
+ underlying API calls so the server never sees a 403. */}
+ } />
diff --git a/web/src/pages/ESTAdminPage.test.tsx b/web/src/pages/ESTAdminPage.test.tsx
new file mode 100644
index 0000000..e6458c6
--- /dev/null
+++ b/web/src/pages/ESTAdminPage.test.tsx
@@ -0,0 +1,274 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import type { ReactNode } from 'react';
+
+// EST RFC 7030 hardening master bundle Phase 8.4 — Vitest coverage for
+// the EST Administration page. Mirrors SCEPAdminPage.test.tsx's
+// structure verbatim. Pins:
+// 1. Admin gate — non-admin sees the gated banner; admin requests are
+// never issued.
+// 2. Tab navigation — Profiles is the default; clicking each tab
+// switches surface; ?tab=activity / ?tab=trust deep-links land
+// correctly.
+// 3. Profiles tab — per-profile cards; status badges reflect mTLS +
+// Basic + ServerKeygen; trust-anchor expiry badge tone bands
+// (good ≥30d / warn 7-30d / bad <7d / EXPIRED); per-counter cells
+// render the correct value; "Reload trust anchor" only renders for
+// mTLS-enabled profiles AND opens the modal on click.
+// 4. Reload modal — Confirm calls mutation / Cancel skips mutation /
+// Error keeps modal open + surfaces the error message.
+// 5. Recent Activity tab — merges all four EST audit actions across
+// four parallel useQuery calls; filter chips narrow to the
+// requested subset.
+// 6. Trust Bundle tab — only mTLS profiles render; non-mTLS deploy
+// sees the empty-state banner.
+// 7. Error path — surfaces ErrorState on the active tab.
+
+vi.mock('../api/client', () => ({
+ getAdminESTProfiles: vi.fn(),
+ reloadAdminESTTrust: vi.fn(),
+ getAuditEvents: vi.fn(),
+}));
+
+vi.mock('../components/AuthProvider', () => ({
+ useAuth: vi.fn(),
+}));
+
+import ESTAdminPage from './ESTAdminPage';
+import * as client from '../api/client';
+import { useAuth } from '../components/AuthProvider';
+
+function renderWithRoute(initialPath: string, ui: ReactNode) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
+ });
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+function setAuth(opts: { authRequired: boolean; admin: boolean }) {
+ vi.mocked(useAuth).mockReturnValue({
+ loading: false,
+ authRequired: opts.authRequired,
+ authenticated: true,
+ authType: 'apikey',
+ user: 'tester',
+ admin: opts.admin,
+ login: async () => {},
+ logout: () => {},
+ error: null,
+ });
+}
+
+const corpProfile = {
+ path_id: 'corp',
+ issuer_id: 'iss-corp',
+ profile_id: 'prof-corp',
+ counters: {
+ success_simpleenroll: 42,
+ success_simplereenroll: 7,
+ success_serverkeygen: 3,
+ auth_failed_basic: 1,
+ auth_failed_mtls: 0,
+ auth_failed_channel_binding: 0,
+ csr_invalid: 0,
+ csr_policy_violation: 0,
+ csr_signature_mismatch: 0,
+ rate_limited: 2,
+ issuer_error: 0,
+ internal_error: 0,
+ },
+ mtls_enabled: true,
+ basic_auth_configured: true,
+ server_keygen_enabled: true,
+ trust_anchors: [
+ {
+ subject: 'corp-bootstrap-ca',
+ not_before: '2026-01-01T00:00:00Z',
+ not_after: '2027-01-01T00:00:00Z',
+ days_to_expiry: 250,
+ expired: false,
+ },
+ ],
+ trust_anchor_path: '/etc/certctl/est-mtls-corp.pem',
+ now: '2026-04-29T15:00:00Z',
+};
+
+const iotProfile = {
+ path_id: 'iot',
+ issuer_id: 'iss-iot',
+ counters: {
+ success_simpleenroll: 9,
+ auth_failed_basic: 0,
+ } as Record,
+ mtls_enabled: false,
+ basic_auth_configured: false,
+ server_keygen_enabled: false,
+ now: '2026-04-29T15:00:00Z',
+};
+
+const profilesResponse = {
+ profiles: [corpProfile, iotProfile],
+ profile_count: 2,
+ generated_at: '2026-04-29T15:00:00Z',
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(client.getAdminESTProfiles).mockResolvedValue(profilesResponse as any);
+ vi.mocked(client.getAuditEvents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as any);
+ setAuth({ authRequired: true, admin: true });
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// React's afterEach is implicit in this scope via Vitest; the explicit
+// cleanup() above is safe to call even when no render happened.
+function afterEach(fn: () => void) {
+ // re-export from vitest globals — vitest's globals expose `afterEach`
+ // automatically when test config has globals: true. Our config does, so
+ // the import is unnecessary; this thin shim documents the call site.
+ (globalThis as any).afterEach?.(fn);
+}
+
+describe('ESTAdminPage — admin gate', () => {
+ it('non-admin sees the gated banner; admin requests never fire', async () => {
+ setAuth({ authRequired: true, admin: false });
+ renderWithRoute('/est', );
+ expect(await screen.findByText(/Admin access required/i)).toBeInTheDocument();
+ await waitFor(() => {
+ expect(client.getAdminESTProfiles).not.toHaveBeenCalled();
+ });
+ });
+
+ it('non-auth-required deploy lets the page render and fires admin request', async () => {
+ setAuth({ authRequired: false, admin: false });
+ renderWithRoute('/est', );
+ await waitFor(() => {
+ expect(client.getAdminESTProfiles).toHaveBeenCalled();
+ });
+ });
+});
+
+describe('ESTAdminPage — tab navigation', () => {
+ it('defaults to the Profiles tab', async () => {
+ renderWithRoute('/est', );
+ expect(await screen.findByTestId('est-tab-profiles')).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('clicking Recent Activity switches the tab', async () => {
+ renderWithRoute('/est', );
+ fireEvent.click(await screen.findByTestId('est-tab-activity'));
+ expect(screen.getByTestId('est-tab-activity')).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('?tab=trust deep-link lands on Trust Bundle', async () => {
+ renderWithRoute('/est?tab=trust', );
+ expect(await screen.findByTestId('est-tab-trust')).toHaveAttribute('aria-pressed', 'true');
+ });
+});
+
+describe('ESTAdminPage — Profiles tab', () => {
+ it('renders one card per profile with the right badges + counters', async () => {
+ renderWithRoute('/est', );
+ expect(await screen.findByTestId('est-profile-summary-corp')).toBeInTheDocument();
+ expect(screen.getByTestId('est-profile-summary-iot')).toBeInTheDocument();
+ // Per-profile counter cells render with the snapshot value.
+ expect(screen.getByTestId('est-counter-corp-success_simpleenroll')).toHaveTextContent('42');
+ expect(screen.getByTestId('est-counter-corp-rate_limited')).toHaveTextContent('2');
+ expect(screen.getByTestId('est-counter-iot-success_simpleenroll')).toHaveTextContent('9');
+ // Counters that don't appear in the iot snapshot default to 0 in the cell.
+ expect(screen.getByTestId('est-counter-iot-internal_error')).toHaveTextContent('0');
+ });
+
+ it('reload-trust button only appears for mTLS profiles', async () => {
+ renderWithRoute('/est', );
+ expect(await screen.findByTestId('est-reload-trust-corp')).toBeInTheDocument();
+ expect(screen.queryByTestId('est-reload-trust-iot')).toBeNull();
+ });
+
+ it('shows mTLS trust expiry badge tone bands', async () => {
+ renderWithRoute('/est', );
+ const badge = await screen.findByTestId('est-trust-expiry-badge-corp');
+ expect(badge).toHaveTextContent(/250d remaining/);
+ });
+});
+
+describe('ESTAdminPage — reload modal', () => {
+ it('Confirm calls mutation', async () => {
+ vi.mocked(client.reloadAdminESTTrust).mockResolvedValue({
+ reloaded: true,
+ path_id: 'corp',
+ reloaded_at: '2026-04-29T15:00:01Z',
+ });
+ renderWithRoute('/est', );
+ fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
+ fireEvent.click(await screen.findByTestId('est-reload-confirm'));
+ await waitFor(() => {
+ expect(client.reloadAdminESTTrust).toHaveBeenCalledWith('corp');
+ });
+ });
+
+ it('Cancel skips mutation', async () => {
+ renderWithRoute('/est', );
+ fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
+ fireEvent.click(await screen.findByTestId('est-reload-cancel'));
+ await waitFor(() => {
+ expect(screen.queryByTestId('est-reload-confirm')).toBeNull();
+ });
+ expect(client.reloadAdminESTTrust).not.toHaveBeenCalled();
+ });
+
+ it('Error keeps the modal open + surfaces the message', async () => {
+ vi.mocked(client.reloadAdminESTTrust).mockRejectedValue(
+ new Error('Trust anchor reload failed: trustanchor: cert in /etc/est-corp.pem expired'),
+ );
+ renderWithRoute('/est', );
+ fireEvent.click(await screen.findByTestId('est-reload-trust-corp'));
+ fireEvent.click(await screen.findByTestId('est-reload-confirm'));
+ expect(await screen.findByTestId('est-reload-error')).toHaveTextContent(/expired/);
+ // Modal stays open — Confirm button still rendered.
+ expect(screen.getByTestId('est-reload-confirm')).toBeInTheDocument();
+ });
+});
+
+describe('ESTAdminPage — Trust Bundle tab', () => {
+ it('renders only mTLS profiles + skips non-mTLS', async () => {
+ renderWithRoute('/est?tab=trust', );
+ expect(await screen.findByTestId('est-trust-card-corp')).toBeInTheDocument();
+ expect(screen.queryByTestId('est-trust-card-iot')).toBeNull();
+ });
+
+ it('shows the empty-state banner when no profile has mTLS', async () => {
+ vi.mocked(client.getAdminESTProfiles).mockResolvedValue({
+ profiles: [iotProfile],
+ profile_count: 1,
+ generated_at: '2026-04-29T15:00:00Z',
+ } as any);
+ renderWithRoute('/est?tab=trust', );
+ expect(await screen.findByText(/No EST profiles have mTLS enabled/i)).toBeInTheDocument();
+ });
+});
+
+describe('ESTAdminPage — Recent Activity tab', () => {
+ it('renders filter chips + reacts to selection', async () => {
+ renderWithRoute('/est?tab=activity', );
+ const allChip = await screen.findByTestId('est-activity-filter-all');
+ expect(allChip).toHaveAttribute('aria-pressed', 'true');
+ fireEvent.click(screen.getByTestId('est-activity-filter-enroll'));
+ await waitFor(() => {
+ expect(screen.getByTestId('est-activity-filter-enroll')).toHaveAttribute('aria-pressed', 'true');
+ });
+ });
+});
diff --git a/web/src/pages/ESTAdminPage.tsx b/web/src/pages/ESTAdminPage.tsx
new file mode 100644
index 0000000..980875f
--- /dev/null
+++ b/web/src/pages/ESTAdminPage.tsx
@@ -0,0 +1,646 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useLocation, useSearchParams } from 'react-router-dom';
+import {
+ getAdminESTProfiles,
+ reloadAdminESTTrust,
+ getAuditEvents,
+} from '../api/client';
+import PageHeader from '../components/PageHeader';
+import ErrorState from '../components/ErrorState';
+import { useAuth } from '../components/AuthProvider';
+import { useTrackedMutation } from '../hooks/useTrackedMutation';
+import { formatDateTime } from '../api/utils';
+import type {
+ ESTStatsSnapshot,
+ ESTTrustAnchorInfo,
+ AuditEvent,
+} from '../api/types';
+
+// EST RFC 7030 hardening master bundle Phase 8 — operator-facing EST
+// administration page with three tabs.
+//
+// Profiles (default) — every configured EST profile, lean card per
+// profile with always-present fields (auth-mode
+// badges, mTLS trust-anchor expiry countdown,
+// counter grid). Per-card "Reload trust" action
+// (admin-gated; opens ConfirmReloadModal). Polled
+// every 30s via TanStack Query.
+// Recent Activity — full EST audit log filter covering the four
+// action codes the service emits
+// (est_simple_enroll / est_simple_reenroll /
+// est_server_keygen / est_auth_failed). Merged +
+// sorted descending. Filter chips for All /
+// Enrollment / Re-enrollment / ServerKeygen /
+// AuthFailure. Polled every 60s.
+// Trust Bundle — for mTLS profiles: per-profile trust bundle
+// viewer (cert subjects + expiry). The
+// upload-new-bundle action is intentionally
+// omitted at GA — operators rotate the file on
+// disk + use the Reload action on the Profiles
+// tab. A future phase ships the upload endpoint.
+//
+// Admin-gated: the page renders an "Admin access required" banner for
+// non-admin callers and never issues the underlying admin requests.
+// Server-side enforcement is the M-008 admin gate; this is a UX hint.
+//
+// The 12 counter labels match service/est_counters.go's estCounter*
+// constants; new labels added there MUST also be added to
+// COUNTER_LABEL_ORDER + COUNTER_PRESENTATION below.
+
+const COUNTER_LABEL_ORDER = [
+ 'success_simpleenroll',
+ 'success_simplereenroll',
+ 'success_serverkeygen',
+ 'auth_failed_basic',
+ 'auth_failed_mtls',
+ 'auth_failed_channel_binding',
+ 'csr_invalid',
+ 'csr_policy_violation',
+ 'csr_signature_mismatch',
+ 'rate_limited',
+ 'issuer_error',
+ 'internal_error',
+] as const;
+
+const COUNTER_PRESENTATION: Record = {
+ success_simpleenroll: { label: 'Enrollments', tone: 'good' },
+ success_simplereenroll: { label: 'Re-enrollments', tone: 'good' },
+ success_serverkeygen: { label: 'Server-keygen', tone: 'good' },
+ auth_failed_basic: { label: 'Auth failed (Basic)', tone: 'warn' },
+ auth_failed_mtls: { label: 'Auth failed (mTLS)', tone: 'warn' },
+ auth_failed_channel_binding: { label: 'Channel-binding mismatch', tone: 'bad' },
+ csr_invalid: { label: 'CSR invalid', tone: 'warn' },
+ csr_policy_violation: { label: 'CSR policy violation', tone: 'warn' },
+ csr_signature_mismatch: { label: 'CSR signature mismatch', tone: 'bad' },
+ rate_limited: { label: 'Rate-limited', tone: 'warn' },
+ issuer_error: { label: 'Issuer error', tone: 'bad' },
+ internal_error: { label: 'Internal error', tone: 'bad' },
+};
+
+const TONE_CLASS: Record<'good' | 'warn' | 'bad', string> = {
+ good: 'text-emerald-600',
+ warn: 'text-amber-600',
+ bad: 'text-red-600',
+};
+
+type TabId = 'profiles' | 'activity' | 'trust';
+type ActivityFilter = 'all' | 'enroll' | 'reenroll' | 'serverkeygen' | 'authfail';
+
+const TAB_LABELS: Record = {
+ profiles: 'Profiles',
+ activity: 'Recent Activity',
+ trust: 'Trust Bundle',
+};
+
+const EST_AUDIT_ACTIONS = [
+ 'est_simple_enroll',
+ 'est_simple_reenroll',
+ 'est_server_keygen',
+ 'est_auth_failed',
+] as const;
+
+// =============================================================================
+// Tone + badge helpers (shared across tabs).
+// =============================================================================
+
+function expiryBadge(days: number | null, expired: boolean): { text: string; tone: 'good' | 'warn' | 'bad' } {
+ if (expired) return { text: 'EXPIRED', tone: 'bad' };
+ if (days === null) return { text: 'Not loaded', tone: 'warn' };
+ if (days < 7) return { text: `${days}d remaining`, tone: 'bad' };
+ if (days < 30) return { text: `${days}d remaining (rotate soon)`, tone: 'warn' };
+ return { text: `${days}d remaining`, tone: 'good' };
+}
+
+function badgeClass(tone: 'good' | 'warn' | 'bad'): string {
+ if (tone === 'good') return 'bg-emerald-100 text-emerald-800';
+ if (tone === 'warn') return 'bg-amber-100 text-amber-800';
+ return 'bg-red-100 text-red-800';
+}
+
+function pillClass(active: boolean): string {
+ return active
+ ? 'bg-brand-100 text-brand-800 border-brand-300'
+ : 'bg-surface-alt text-ink-muted border-surface-border';
+}
+
+// soonestExpiryDays returns the smallest days_to_expiry across the
+// profile's mTLS trust anchor pool. Returns null when the pool is
+// empty (the per-profile preflight should have refused this state at
+// boot, but defensive in case the holder is reloaded mid-flight to an
+// empty file).
+function soonestExpiryDays(anchors?: ESTTrustAnchorInfo[]): number | null {
+ if (!anchors || anchors.length === 0) return null;
+ let min = Number.POSITIVE_INFINITY;
+ for (const a of anchors) {
+ if (a.expired) return -1;
+ if (a.days_to_expiry < min) min = a.days_to_expiry;
+ }
+ return min === Number.POSITIVE_INFINITY ? null : min;
+}
+
+// =============================================================================
+// Profiles tab.
+// =============================================================================
+
+interface ProfilesTabProps {
+ profiles: ESTStatsSnapshot[];
+ isLoading: boolean;
+ onRequestReload: (profile: ESTStatsSnapshot) => void;
+}
+
+function ProfilesTab({ profiles, isLoading, onRequestReload }: ProfilesTabProps) {
+ if (isLoading) {
+ return
Loading profiles…
;
+ }
+ if (profiles.length === 0) {
+ return (
+
+ No EST profiles are configured. Set CERTCTL_EST_ENABLED=true and either the
+ legacy single-profile env vars or CERTCTL_EST_PROFILES=... with the indexed
+ per-profile family to register at least one endpoint.
+
+ This re-reads {profile.trust_anchor_path} from disk and atomically
+ swaps the trust pool for EST profile {pathLabel}. Equivalent to sending
+ SIGHUP to the server. If the new file fails to parse, the
+ previous trust pool stays in place — enrollments keep working off the old trust anchor while you
+ fix the file.
+