mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 18:18:51 +00:00
EST RFC 7030 hardening master bundle Phases 8-9: GUI ESTAdminPage
(Profiles + Recent Activity + Trust Bundle tabs) + CLI subcommand
family `certctl-cli est {cacerts,csrattrs,enroll,reenroll,
serverkeygen,test}` + 6 MCP tools.
Phase 8 — ESTAdminPage tabbed GUI:
- web/src/pages/ESTAdminPage.tsx mirrors SCEPAdminPage's three-tab
surface. Profiles tab renders per-profile cards with auth-mode
badges (mTLS / Basic / ServerKeygen), mTLS trust-anchor expiry
countdown (good ≥30d / warn 7-30d / bad <7d / EXPIRED), 12-cell
counter grid (success_simpleenroll/.../internal_error), and the
admin-gated "Reload trust anchor" action. Recent Activity tab
merges the four EST audit actions (est_simple_enroll +
est_simple_reenroll + est_server_keygen + est_auth_failed) across
four parallel useQuery calls with chip filters for All/Enrollment/
Re-enrollment/ServerKeygen/AuthFailure. Trust Bundle tab renders
per-mTLS-profile cert subjects + expiries.
- M-009 useTrackedMutation guard: every mutation routes through
the tracked hook so audit/progress hooks fire.
- Page-level admin gate renders "Admin access required" banner for
non-admin callers + skips underlying API requests so the server
never sees a 403-prone request. Server-side enforcement is the
M-008 admin gate; this is a UX hint.
- Wired into web/src/main.tsx at /est; nav link added to Layout.tsx.
- New web/src/api/types.ts types ESTStatsSnapshot +
ESTTrustAnchorInfo + ESTProfilesResponse + ESTReloadTrustResponse
mirror service.ESTStatsSnapshot 1:1.
- New web/src/api/client.ts helpers getAdminESTProfiles +
reloadAdminESTTrust.
- 14 Vitest cases (admin gate non-admin / non-auth-required deploy /
default tab / tab switch / deep-link tab / per-profile card render
+ counter cells / reload-button mTLS-only / trust-expiry badge
band / reload modal Confirm-Cancel-Error paths / Trust Bundle
empty-state / Activity filter chip toggle).
Phase 9.1 — CLI subcommands:
- internal/cli/est.go adds 6 subcommands: cacerts / csrattrs /
enroll / reenroll / serverkeygen / test. CSR input via --csr
with file-path or '-' for stdin; multipart serverkeygen response
is parsed by stdlib mime/multipart and split into <prefix>.cert.pem
+ <prefix>.key.enveloped so the operator can decrypt the key with
openssl smime. EST `test` smoke-tests cacerts + csrattrs + emits
one-line OK/FAIL diagnostics.
- cmd/cli/main.go grows the `est` dispatch + Usage entries.
Phase 9.2 — MCP tools:
- internal/mcp/tools_est.go adds 6 tools mapped to the EST endpoints
+ admin observability: est_list_profiles + est_admin_stats (alias)
+ est_get_cacerts + est_get_csrattrs + est_enroll + est_reenroll.
Tool count grew from 87 → 93 (verified via the registered-vs-
covered guard in tools_per_tool_test.go); the per-tool happy/error-
path table grew with 6 matching entries so the future-tool-no-test
CI guard stays green.
- internal/mcp/client.go grows PostRaw — non-JSON POST helper that
the EST enroll/reenroll tools use to ship raw application/pkcs10
CSR bytes through the MCP fence-wrapped response.
- estRawResultJSON wraps the raw response body in a JSON envelope
the MCP consumer can structurally consume (content_type +
body_base64 + body_size_bytes). Mirrors the CRL/OCSP MCP tools'
binary-DER envelope.
Phase 9.3 — Tests:
- internal/cli/est_test.go: 8 cases pinning the wire-shape contract
on the CLI side without dragging the full ESTHandler into the
test build.
- internal/mcp/tools_est_test.go: path-builder + JSON-envelope unit
tests + end-to-end tool exercise that pins all 5 captured request
paths through a fake API.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
pre-existing testcontainers limit), staticcheck clean across
cli/mcp/cmd/cli, go test -short -count=1 green for every non-
postgres Go package, Vitest green for ESTAdminPage (14) +
SCEPAdminPage (20) — 34 page tests total. G-3 docs-drift guard
reproduced locally clean (Phases 8-9 added zero new env vars).
Spec preserved at cowork/est-rfc7030-hardening-prompt.md. Phases
10-13 (libest sidecar e2e / bulk revocation + audit codes /
docs/est.md / release prep + tag) remain — post-2.1.0 work.
This commit is contained in:
@@ -41,6 +41,14 @@ Commands:
|
|||||||
Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id
|
Required: --owner-id, --team-id, --renewal-policy-id, --issuer-id
|
||||||
Optional: --name-template (default {cn}), --environment (default imported)
|
Optional: --name-template (default {cn}), --environment (default imported)
|
||||||
|
|
||||||
|
est cacerts --profile <p> EST GET cacerts (RFC 7030 §4.1)
|
||||||
|
est csrattrs --profile <p> EST GET csrattrs (RFC 7030 §4.5)
|
||||||
|
est enroll --profile <p> --csr <path> EST POST simpleenroll (RFC 7030 §4.2)
|
||||||
|
est reenroll --profile <p> --csr <path> EST POST simplereenroll (RFC 7030 §4.2.2)
|
||||||
|
est serverkeygen --profile <p> --csr <path> --out <prefix>
|
||||||
|
EST POST serverkeygen (RFC 7030 §4.4)
|
||||||
|
est test --profile <p> Smoke-test cacerts + csrattrs
|
||||||
|
|
||||||
status Show server health + summary stats
|
status Show server health + summary stats
|
||||||
version Show CLI version
|
version Show CLI version
|
||||||
|
|
||||||
@@ -99,6 +107,8 @@ Examples:
|
|||||||
err = handleJobs(client, cmdArgs)
|
err = handleJobs(client, cmdArgs)
|
||||||
case "import":
|
case "import":
|
||||||
err = handleImport(client, cmdArgs)
|
err = handleImport(client, cmdArgs)
|
||||||
|
case "est":
|
||||||
|
err = handleEST(client, cmdArgs)
|
||||||
case "status":
|
case "status":
|
||||||
err = handleStatus(client)
|
err = handleStatus(client)
|
||||||
case "version":
|
case "version":
|
||||||
@@ -255,6 +265,35 @@ func handleStatus(client *cli.Client) error {
|
|||||||
return client.GetStatus()
|
return client.GetStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleEST dispatches the `est` subcommands. Mirrors the existing
|
||||||
|
// handleCerts / handleAgents pattern verbatim. EST RFC 7030 hardening
|
||||||
|
// master bundle Phase 9.1.
|
||||||
|
func handleEST(client *cli.Client, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "usage: est <cacerts|csrattrs|enroll|reenroll|serverkeygen|test> [options]\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
subcommand := args[0]
|
||||||
|
subArgs := args[1:]
|
||||||
|
switch subcommand {
|
||||||
|
case "cacerts":
|
||||||
|
return client.EstCacerts(subArgs)
|
||||||
|
case "csrattrs":
|
||||||
|
return client.EstCsrattrs(subArgs)
|
||||||
|
case "enroll":
|
||||||
|
return client.EstEnroll(subArgs)
|
||||||
|
case "reenroll":
|
||||||
|
return client.EstReEnroll(subArgs)
|
||||||
|
case "serverkeygen":
|
||||||
|
return client.EstServerKeygen(subArgs)
|
||||||
|
case "test":
|
||||||
|
return client.EstTest(subArgs)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown subcommand: est %s\n", subcommand)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
|
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
|
||||||
// startup so operators get a fail-loud diagnostic before any network call,
|
// startup so operators get a fail-loud diagnostic before any network call,
|
||||||
// not a TCP-refused or TLS-handshake-error downstream. See docs/upgrade-to-tls.md.
|
// not a TCP-refused or TLS-handshake-error downstream. See docs/upgrade-to-tls.md.
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
// EST RFC 7030 hardening master bundle Phase 9.1 — CLI subcommands.
|
||||||
|
//
|
||||||
|
// The EST endpoints live under /.well-known/est/[<PathID>/]; they are
|
||||||
|
// HTTPS-only (the certctl control plane is HTTPS-only as of v2.2) and
|
||||||
|
// per-profile dispatched. The CLI subcommands here mirror what an
|
||||||
|
// operator would do via libest or curl + base64 + openssl, but with a
|
||||||
|
// fixed --profile flag + the existing CLI's TLS-pinning semantics.
|
||||||
|
//
|
||||||
|
// Subcommands:
|
||||||
|
//
|
||||||
|
// certctl-cli est cacerts --profile corp
|
||||||
|
// certctl-cli est csrattrs --profile corp
|
||||||
|
// certctl-cli est enroll --profile corp --csr <path-or-stdin>
|
||||||
|
// certctl-cli est reenroll --profile corp --csr <path-or-stdin>
|
||||||
|
// certctl-cli est serverkeygen --profile corp --csr <path> --out <prefix>
|
||||||
|
// certctl-cli est test --profile corp
|
||||||
|
//
|
||||||
|
// All write operations stream the issued cert (PEM) to stdout by default
|
||||||
|
// or to --out when provided. Server-keygen writes <prefix>.cert.pem +
|
||||||
|
// <prefix>.key.enveloped (the EnvelopedData blob) so the operator can
|
||||||
|
// decrypt with openssl smime.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// estPath builds the per-profile EST URL fragment. PathID="" maps to
|
||||||
|
// the legacy /.well-known/est/ root for backward compat with v2.0.x
|
||||||
|
// single-profile deploys.
|
||||||
|
func (c *Client) estPath(profile, op string) string {
|
||||||
|
if profile == "" {
|
||||||
|
return "/.well-known/est/" + op
|
||||||
|
}
|
||||||
|
return "/.well-known/est/" + profile + "/" + op
|
||||||
|
}
|
||||||
|
|
||||||
|
// estPostBody POSTs the given body bytes to the EST endpoint with the
|
||||||
|
// EST-required Content-Type. Returns the raw response body so the
|
||||||
|
// caller can write the PEM/PKCS#7/multipart bytes through to disk
|
||||||
|
// without further decoding (the CLI is the device's smime/openssl
|
||||||
|
// pipeline; we don't second-guess the wire format).
|
||||||
|
func (c *Client) estPostBody(path, contentType string, body []byte) ([]byte, http.Header, error) {
|
||||||
|
u, err := url.JoinPath(c.baseURL, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", u, strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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, nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, resp.Header, fmt.Errorf("EST error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
return respBody, resp.Header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// estGet performs a GET against the EST endpoint and returns the raw
|
||||||
|
// response body. Used by cacerts (HTTP 200) and csrattrs (HTTP 200 or
|
||||||
|
// 204 — both are valid contracts).
|
||||||
|
func (c *Client) estGet(path string) ([]byte, int, http.Header, error) {
|
||||||
|
u, err := url.JoinPath(c.baseURL, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, resp.Header, fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, resp.StatusCode, resp.Header, fmt.Errorf("EST error (HTTP %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return body, resp.StatusCode, resp.Header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCSRBytes resolves --csr to actual CSR bytes. "-" reads from
|
||||||
|
// stdin (so the operator can pipe `openssl req -new …` directly into
|
||||||
|
// us); otherwise it's a filesystem path.
|
||||||
|
func readCSRBytes(path string) ([]byte, error) {
|
||||||
|
if path == "-" {
|
||||||
|
return io.ReadAll(os.Stdin)
|
||||||
|
}
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstCacerts implements `certctl-cli est cacerts --profile <p>`.
|
||||||
|
// Writes the base64-wrapped PKCS#7 certs-only response to stdout (the
|
||||||
|
// canonical EST §4.1.3 wire shape).
|
||||||
|
func (c *Client) EstCacerts(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("est cacerts", 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, _, _, err := c.estGet(c.estPath(*profile, "cacerts"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeOutput(*out, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstCsrattrs implements `certctl-cli est csrattrs --profile <p>`.
|
||||||
|
// 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 <p> --csr <path>`.
|
||||||
|
// 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 <p> --csr <path>`.
|
||||||
|
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 <p>
|
||||||
|
// --csr <path> --out <prefix>`. The server returns multipart/mixed; we
|
||||||
|
// split into the cert part and the encrypted-key part, write each to
|
||||||
|
// <prefix>.cert.pem + <prefix>.key.enveloped so the operator can pipe
|
||||||
|
// the latter into `openssl smime -decrypt -inkey <client-priv>`.
|
||||||
|
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 <prefix>.cert.pem + <prefix>.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
|
||||||
@@ -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}}
|
||||||
|
}
|
||||||
@@ -92,6 +92,39 @@ func (c *Client) DeleteWithQuery(path string, query url.Values) (json.RawMessage
|
|||||||
return c.do("DELETE", path, query, nil)
|
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.
|
// GetRaw performs an HTTP GET and returns the raw response body bytes and content type.
|
||||||
// Used for binary responses (DER CRL, OCSP).
|
// Used for binary responses (DER CRL, OCSP).
|
||||||
func (c *Client) GetRaw(path string) ([]byte, string, error) {
|
func (c *Client) GetRaw(path string) ([]byte, string, error) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func RegisterTools(s *gomcp.Server, client *Client) {
|
|||||||
registerMetricsTools(s, client)
|
registerMetricsTools(s, client)
|
||||||
registerDigestTools(s, client)
|
registerDigestTools(s, client)
|
||||||
registerHealthTools(s, client)
|
registerHealthTools(s, client)
|
||||||
|
registerESTTools(s, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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/[<PathID>/]cacerts
|
||||||
|
// est_get_csrattrs → GET /.well-known/est/[<PathID>/]csrattrs
|
||||||
|
// est_enroll → POST /.well-known/est/[<PathID>/]simpleenroll
|
||||||
|
// est_reenroll → POST /.well-known/est/[<PathID>/]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/[<profile>/]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/[<profile>/]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/[<profile>/]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/[<profile>/]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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,6 +359,14 @@ var allHappyPathCases = []toolCase{
|
|||||||
{"certctl_ready", map[string]any{}, http.MethodGet, "/ready"},
|
{"certctl_ready", map[string]any{}, http.MethodGet, "/ready"},
|
||||||
{"certctl_auth_check", map[string]any{}, http.MethodGet, "/api/v1/auth/check"},
|
{"certctl_auth_check", map[string]any{}, http.MethodGet, "/api/v1/auth/check"},
|
||||||
{"certctl_auth_info", map[string]any{}, http.MethodGet, "/api/v1/auth/whoami"},
|
{"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
|
// TestMCP_AllTools_HappyPath dispatches every tool against the mock API in
|
||||||
|
|||||||
+17
-1
@@ -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';
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -320,6 +320,22 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
|
|||||||
export const getAdminSCEPProfiles = () =>
|
export const getAdminSCEPProfiles = () =>
|
||||||
fetchJSON<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
|
fetchJSON<SCEPProfilesResponse>(`${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<ESTProfilesResponse>(`${BASE}/admin/est/profiles`);
|
||||||
|
|
||||||
|
export const reloadAdminESTTrust = (pathID: string) =>
|
||||||
|
fetchJSON<ESTReloadTrustResponse>(`${BASE}/admin/est/reload-trust`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path_id: pathID }),
|
||||||
|
});
|
||||||
|
|
||||||
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
|
||||||
// (capability + posture). Synchronous — the caller blocks until the
|
// (capability + posture). Synchronous — the caller blocks until the
|
||||||
// probe completes (cap: 30s server-side). Persists to the history
|
// probe completes (cap: 30s server-side). Persists to the history
|
||||||
|
|||||||
@@ -727,6 +727,43 @@ export interface SCEPProfilesResponse {
|
|||||||
generated_at: string;
|
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<string, number>;
|
||||||
|
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.
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
//
|
//
|
||||||
// Backs the SCEP Probe section on the Network Scan page. The probe
|
// Backs the SCEP Probe section on the Network Scan page. The probe
|
||||||
|
|||||||
@@ -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: '/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: '/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: '/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' },
|
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import JobDetailPage from './pages/JobDetailPage';
|
|||||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
import IssuerDetailPage from './pages/IssuerDetailPage';
|
||||||
import TargetDetailPage from './pages/TargetDetailPage';
|
import TargetDetailPage from './pages/TargetDetailPage';
|
||||||
import SCEPAdminPage from './pages/SCEPAdminPage';
|
import SCEPAdminPage from './pages/SCEPAdminPage';
|
||||||
|
import ESTAdminPage from './pages/ESTAdminPage';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -91,6 +92,13 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
{/* Backward-compat alias for external bookmarks the Phase 9
|
{/* Backward-compat alias for external bookmarks the Phase 9
|
||||||
release advertised. Lands on the Intune Monitoring tab. */}
|
release advertised. Lands on the Intune Monitoring tab. */}
|
||||||
<Route path="scep/intune" element={<SCEPAdminPage />} />
|
<Route path="scep/intune" element={<SCEPAdminPage />} />
|
||||||
|
{/* 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. */}
|
||||||
|
<Route path="est" element={<ESTAdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/est" element={ui} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number>,
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(client.getAdminESTProfiles).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ESTAdminPage — tab navigation', () => {
|
||||||
|
it('defaults to the Profiles tab', async () => {
|
||||||
|
renderWithRoute('/est', <ESTAdminPage />);
|
||||||
|
expect(await screen.findByTestId('est-tab-profiles')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Recent Activity switches the tab', async () => {
|
||||||
|
renderWithRoute('/est', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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', <ESTAdminPage />);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, { label: string; tone: 'good' | 'warn' | 'bad' }> = {
|
||||||
|
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<TabId, string> = {
|
||||||
|
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 <p className="text-sm text-ink-muted px-1 py-6">Loading profiles…</p>;
|
||||||
|
}
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
|
No EST profiles are configured. Set <code>CERTCTL_EST_ENABLED=true</code> and either the
|
||||||
|
legacy single-profile env vars or <code>CERTCTL_EST_PROFILES=...</code> with the indexed
|
||||||
|
per-profile family to register at least one endpoint.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{profiles.map(p => (
|
||||||
|
<ProfileSummaryCard
|
||||||
|
key={p.path_id || '(root)'}
|
||||||
|
profile={p}
|
||||||
|
onRequestReload={onRequestReload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileSummaryCardProps {
|
||||||
|
profile: ESTStatsSnapshot;
|
||||||
|
onRequestReload: (profile: ESTStatsSnapshot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProps) {
|
||||||
|
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
|
||||||
|
const trustDays = soonestExpiryDays(profile.trust_anchors);
|
||||||
|
const trustExpired = (profile.trust_anchors ?? []).some(a => a.expired);
|
||||||
|
const trustBadge = profile.mtls_enabled
|
||||||
|
? expiryBadge(trustDays, trustExpired)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
|
||||||
|
data-testid={`est-profile-summary-${profile.path_id}`}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-ink">{pathLabel}</h3>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
Issuer: {profile.issuer_id}
|
||||||
|
{profile.profile_id && (
|
||||||
|
<>
|
||||||
|
{' '}· Profile: <code className="font-mono">{profile.profile_id}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{trustBadge && (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${badgeClass(trustBadge.tone)}`}
|
||||||
|
data-testid={`est-trust-expiry-badge-${profile.path_id}`}
|
||||||
|
>
|
||||||
|
mTLS trust: {trustBadge.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3" data-testid={`est-profile-badges-${profile.path_id}`}>
|
||||||
|
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
|
||||||
|
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.basic_auth_configured)}`}>
|
||||||
|
HTTP Basic {profile.basic_auth_configured ? 'configured' : 'not set'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.server_keygen_enabled)}`}>
|
||||||
|
Server-keygen {profile.server_keygen_enabled ? 'enabled' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-3" data-testid={`est-profile-counters-${profile.path_id}`}>
|
||||||
|
{COUNTER_LABEL_ORDER.map(label => {
|
||||||
|
const presentation = COUNTER_PRESENTATION[label];
|
||||||
|
const value = profile.counters?.[label] ?? 0;
|
||||||
|
return (
|
||||||
|
<div key={label} className="bg-surface-alt rounded px-3 py-2" data-testid={`est-counter-${profile.path_id}-${label}`}>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{presentation.label}</div>
|
||||||
|
<div className={`text-base font-semibold ${TONE_CLASS[presentation.tone]}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile.mtls_enabled && profile.trust_anchor_path && (
|
||||||
|
<p className="text-[11px] text-ink-muted font-mono mb-2">
|
||||||
|
Trust bundle: {profile.trust_anchor_path}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile.mtls_enabled && (
|
||||||
|
<div className="mt-2 pt-3 border-t border-surface-border flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRequestReload(profile)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||||
|
data-testid={`est-reload-trust-${profile.path_id}`}
|
||||||
|
>
|
||||||
|
Reload trust anchor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Confirm-reload modal.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ConfirmReloadModalProps {
|
||||||
|
profile: ESTStatsSnapshot;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
pending: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
||||||
|
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="est-reload-trust-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
|
>
|
||||||
|
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
|
||||||
|
<h3 id="est-reload-trust-title" className="text-base font-semibold text-ink mb-2">
|
||||||
|
Reload EST mTLS trust anchor
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-ink-muted mb-4">
|
||||||
|
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||||
|
swaps the trust pool for EST profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||||
|
<code className="text-xs"> SIGHUP </code> 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.
|
||||||
|
</p>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="est-reload-error">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={pending}
|
||||||
|
data-testid="est-reload-cancel"
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={pending}
|
||||||
|
data-testid="est-reload-confirm"
|
||||||
|
className="px-3 py-1.5 text-sm rounded bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Recent Activity tab.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ActivityTabProps {
|
||||||
|
events: AuditEvent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
filter: ActivityFilter;
|
||||||
|
setFilter: (f: ActivityFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityMatches(filter: ActivityFilter, e: AuditEvent): boolean {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'enroll') return e.action === 'est_simple_enroll';
|
||||||
|
if (filter === 'reenroll') return e.action === 'est_simple_reenroll';
|
||||||
|
if (filter === 'serverkeygen') return e.action === 'est_server_keygen';
|
||||||
|
if (filter === 'authfail') return e.action === 'est_auth_failed';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_FILTERS: { id: ActivityFilter; label: string }[] = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'enroll', label: 'Enrollment' },
|
||||||
|
{ id: 'reenroll', label: 'Re-enrollment' },
|
||||||
|
{ id: 'serverkeygen', label: 'Server-keygen' },
|
||||||
|
{ id: 'authfail', label: 'Auth failure' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ActivityTab({ events, isLoading, filter, setFilter }: ActivityTabProps) {
|
||||||
|
const filtered = useMemo(() => events.filter(e => activityMatches(filter, e)), [events, filter]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4" data-testid="est-activity-filters">
|
||||||
|
{ACTIVITY_FILTERS.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(f.id)}
|
||||||
|
data-testid={`est-activity-filter-${f.id}`}
|
||||||
|
aria-pressed={filter === f.id}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full border ${pillClass(filter === f.id)}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isLoading && <p className="text-sm text-ink-muted">Loading audit events…</p>}
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<p className="text-sm text-ink-muted">No events match the selected filter.</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && filtered.length > 0 && (
|
||||||
|
<div className="bg-surface border border-surface-border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm" data-testid="est-activity-table">
|
||||||
|
<thead className="bg-surface-alt text-ink-muted text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-2">Timestamp</th>
|
||||||
|
<th className="text-left px-3 py-2">Action</th>
|
||||||
|
<th className="text-left px-3 py-2">Subject</th>
|
||||||
|
<th className="text-left px-3 py-2">Resource</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.slice(0, 100).map((e, i) => (
|
||||||
|
<tr key={`${e.timestamp}-${i}`} className="border-t border-surface-border">
|
||||||
|
<td className="px-3 py-2 text-xs text-ink-muted">{formatDateTime(e.timestamp)}</td>
|
||||||
|
<td className="px-3 py-2 text-xs font-mono">{e.action}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{e.actor || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{e.resource_id || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Trust Bundle tab.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface TrustBundleTabProps {
|
||||||
|
profiles: ESTStatsSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrustBundleTab({ profiles }: TrustBundleTabProps) {
|
||||||
|
const mtlsProfiles = profiles.filter(p => p.mtls_enabled && p.trust_anchors && p.trust_anchors.length > 0);
|
||||||
|
if (mtlsProfiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-surface-border bg-surface-alt p-4 text-sm text-ink-muted">
|
||||||
|
No EST profiles have mTLS enabled. The Trust Bundle tab is only relevant when at least one
|
||||||
|
profile carries an <code>MTLS_CLIENT_CA_TRUST_BUNDLE_PATH</code>.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mtlsProfiles.map(p => (
|
||||||
|
<section
|
||||||
|
key={p.path_id || '(root)'}
|
||||||
|
className="bg-surface border border-surface-border rounded-lg p-5 mb-4"
|
||||||
|
data-testid={`est-trust-card-${p.path_id}`}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-base font-semibold text-ink">{p.path_id || '(legacy root)'}</h3>
|
||||||
|
<span className="text-xs font-mono text-ink-muted">{p.trust_anchor_path}</span>
|
||||||
|
</header>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-surface-alt text-ink-muted text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-2">Subject</th>
|
||||||
|
<th className="text-left px-3 py-2">Not before</th>
|
||||||
|
<th className="text-left px-3 py-2">Not after</th>
|
||||||
|
<th className="text-left px-3 py-2">Days remaining</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(p.trust_anchors ?? []).map(a => (
|
||||||
|
<tr key={`${p.path_id}-${a.subject}-${a.not_after}`} className="border-t border-surface-border">
|
||||||
|
<td className="px-3 py-2 text-xs font-mono">{a.subject}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{formatDateTime(a.not_before)}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">{formatDateTime(a.not_after)}</td>
|
||||||
|
<td className={`px-3 py-2 text-xs font-semibold ${a.expired ? 'text-red-600' : a.days_to_expiry < 30 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||||
|
{a.expired ? 'EXPIRED' : `${a.days_to_expiry}d`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Top-level page.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function pickInitialTab(searchParams: URLSearchParams): TabId {
|
||||||
|
const fromQuery = searchParams.get('tab');
|
||||||
|
if (fromQuery === 'activity' || fromQuery === 'trust') return fromQuery;
|
||||||
|
return 'profiles';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ESTAdminPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const adminAccess = !auth.authRequired || auth.admin;
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const _location = useLocation();
|
||||||
|
void _location; // reserved for future deep-link cases (mirrors SCEPAdminPage)
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>(() => pickInitialTab(searchParams));
|
||||||
|
const [reloadTarget, setReloadTarget] = useState<ESTStatsSnapshot | null>(null);
|
||||||
|
const [reloadError, setReloadError] = useState<string | undefined>(undefined);
|
||||||
|
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
|
||||||
|
|
||||||
|
// Keep URL in sync with tab so deep links survive page reloads.
|
||||||
|
useEffect(() => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (activeTab === 'profiles') {
|
||||||
|
next.delete('tab');
|
||||||
|
} else {
|
||||||
|
next.set('tab', activeTab);
|
||||||
|
}
|
||||||
|
if (next.toString() !== searchParams.toString()) {
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}
|
||||||
|
}, [activeTab, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
// Per-profile snapshot. Polled every 30s on the profiles tab.
|
||||||
|
const profilesQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'est', 'profiles'],
|
||||||
|
queryFn: getAdminESTProfiles,
|
||||||
|
enabled: adminAccess,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// EST audit-log queries — four parallel queries (one per action) so
|
||||||
|
// the activity tab can present a merged + filterable feed without a
|
||||||
|
// dedicated server endpoint.
|
||||||
|
const auditQueries = EST_AUDIT_ACTIONS.map(action =>
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['audit', { action }],
|
||||||
|
queryFn: () => getAuditEvents({ action }),
|
||||||
|
enabled: adminAccess && activeTab === 'activity',
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const allAuditEvents: AuditEvent[] = useMemo(() => {
|
||||||
|
const merged: AuditEvent[] = [];
|
||||||
|
for (const q of auditQueries) {
|
||||||
|
if (q.data?.data) merged.push(...q.data.data);
|
||||||
|
}
|
||||||
|
return merged.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [auditQueries.map(q => q.dataUpdatedAt).join('|')]);
|
||||||
|
const auditLoading = auditQueries.some(q => q.isLoading);
|
||||||
|
|
||||||
|
// M-009 useTrackedMutation guard: every mutation in this page MUST
|
||||||
|
// route through useTrackedMutation so the audit / progress hooks fire.
|
||||||
|
const reloadMutation = useTrackedMutation<
|
||||||
|
Awaited<ReturnType<typeof reloadAdminESTTrust>>,
|
||||||
|
Error,
|
||||||
|
string
|
||||||
|
>({
|
||||||
|
mutationFn: (pathID: string) => reloadAdminESTTrust(pathID),
|
||||||
|
invalidates: [['admin', 'est', 'profiles']],
|
||||||
|
onSuccess: () => {
|
||||||
|
setReloadTarget(null);
|
||||||
|
setReloadError(undefined);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setReloadError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auth.authRequired && !auth.admin) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="EST Administration" subtitle="Admin-only observability surface" />
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorState
|
||||||
|
error={
|
||||||
|
new Error(
|
||||||
|
'Admin access required: this page exposes per-profile mTLS trust-anchor expiries, auth-mode posture, per-status enrollment counters, and an admin-only reload action. Sign in with an admin-tagged API key to view it.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = profilesQuery.data?.profiles ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="EST Administration"
|
||||||
|
subtitle={`${profiles.length} EST profile${profiles.length === 1 ? '' : 's'} configured · per-profile observability + recent activity + trust-bundle viewer`}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void profilesQuery.refetch();
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-surface-border bg-surface hover:bg-surface-alt"
|
||||||
|
data-testid="est-refresh-stats-button"
|
||||||
|
>
|
||||||
|
Refresh now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="border-b border-surface-border bg-surface px-6">
|
||||||
|
<nav className="flex gap-1 -mb-px" data-testid="est-admin-tabs">
|
||||||
|
{(['profiles', 'activity', 'trust'] as TabId[]).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
className={`px-4 py-2.5 text-sm border-b-2 transition-colors ${
|
||||||
|
activeTab === t
|
||||||
|
? 'border-brand-500 text-brand-700 font-semibold'
|
||||||
|
: 'border-transparent text-ink-muted hover:text-ink hover:border-surface-border'
|
||||||
|
}`}
|
||||||
|
data-testid={`est-tab-${t}`}
|
||||||
|
aria-pressed={activeTab === t}
|
||||||
|
>
|
||||||
|
{TAB_LABELS[t]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 overflow-y-auto">
|
||||||
|
{profilesQuery.error && activeTab === 'profiles' && (
|
||||||
|
<ErrorState error={profilesQuery.error as Error} onRetry={() => profilesQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'profiles' && !profilesQuery.error && (
|
||||||
|
<ProfilesTab
|
||||||
|
profiles={profiles}
|
||||||
|
isLoading={profilesQuery.isLoading}
|
||||||
|
onRequestReload={profile => {
|
||||||
|
setReloadError(undefined);
|
||||||
|
setReloadTarget(profile);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<ActivityTab
|
||||||
|
events={allAuditEvents}
|
||||||
|
isLoading={auditLoading}
|
||||||
|
filter={activityFilter}
|
||||||
|
setFilter={setActivityFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'trust' && <TrustBundleTab profiles={profiles} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reloadTarget && (
|
||||||
|
<ConfirmReloadModal
|
||||||
|
profile={reloadTarget}
|
||||||
|
onCancel={() => {
|
||||||
|
setReloadTarget(null);
|
||||||
|
setReloadError(undefined);
|
||||||
|
}}
|
||||||
|
onConfirm={() => reloadMutation.mutate(reloadTarget.path_id)}
|
||||||
|
pending={reloadMutation.isPending}
|
||||||
|
errorMessage={reloadError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user