mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:41:30 +00:00
36885da2da
(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.
218 lines
6.7 KiB
Go
218 lines
6.7 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// Client is a thin HTTP client that forwards requests to the certctl REST API.
|
|
// It handles auth, base URL resolution, and JSON marshaling.
|
|
type Client struct {
|
|
baseURL string
|
|
apiKey string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new certctl API client. The control plane is HTTPS-only
|
|
// as of v2.2, so the transport is pinned to TLS 1.3 and optionally loads a
|
|
// PEM-encoded CA bundle from caBundlePath (empty means "trust the system
|
|
// roots"). The insecure flag disables certificate verification and is a
|
|
// dev-only opt-in documented in docs/tls.md — it must never be set in
|
|
// production. Returns an error if the CA bundle path is non-empty but the
|
|
// file is missing or contains no valid PEM-encoded certificates, so the
|
|
// caller can fail loud before any network call.
|
|
func NewClient(baseURL, apiKey, caBundlePath string, insecure bool) (*Client, error) {
|
|
tlsConfig := &tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
InsecureSkipVerify: insecure, //nolint:gosec // opt-in dev toggle, documented in docs/tls.md
|
|
}
|
|
if caBundlePath != "" {
|
|
pemBytes, err := os.ReadFile(caBundlePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading CA bundle at %q: %w", caBundlePath, err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(pemBytes) {
|
|
return nil, fmt.Errorf("CA bundle at %q contains no valid PEM-encoded certificates", caBundlePath)
|
|
}
|
|
tlsConfig.RootCAs = pool
|
|
}
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
apiKey: apiKey,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: tlsConfig,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Get performs an HTTP GET and returns the raw JSON response body.
|
|
func (c *Client) Get(path string, query url.Values) (json.RawMessage, error) {
|
|
return c.do("GET", path, query, nil)
|
|
}
|
|
|
|
// Post performs an HTTP POST with a JSON body and returns the raw JSON response.
|
|
func (c *Client) Post(path string, body interface{}) (json.RawMessage, error) {
|
|
return c.do("POST", path, nil, body)
|
|
}
|
|
|
|
// Put performs an HTTP PUT with a JSON body and returns the raw JSON response.
|
|
func (c *Client) Put(path string, body interface{}) (json.RawMessage, error) {
|
|
return c.do("PUT", path, nil, body)
|
|
}
|
|
|
|
// Delete performs an HTTP DELETE and returns the raw JSON response (may be empty for 204).
|
|
func (c *Client) Delete(path string) (json.RawMessage, error) {
|
|
return c.do("DELETE", path, nil, nil)
|
|
}
|
|
|
|
// DeleteWithQuery performs an HTTP DELETE with query parameters. I-004 adds
|
|
// this transport so MCP tools can target endpoints that carry flags in the
|
|
// query string (e.g. DELETE /api/v1/agents/{id}?force=true&reason=…). Client.Delete
|
|
// is path-only; without this method the retire tool silently drops force/reason,
|
|
// turning every cascade retire into a default soft-retire. Shares do()'s 204
|
|
// normalization and 4xx/5xx error propagation so tool authors get one contract.
|
|
func (c *Client) DeleteWithQuery(path string, query url.Values) (json.RawMessage, error) {
|
|
return c.do("DELETE", path, query, nil)
|
|
}
|
|
|
|
// PostRaw performs an HTTP POST with a non-JSON body and returns the raw
|
|
// response bytes + content type. Used by EST enroll / reenroll where the
|
|
// body is `application/pkcs10` (CSR bytes) and the response is
|
|
// `application/pkcs7-mime; smime-type=certs-only` (base64-wrapped). EST
|
|
// RFC 7030 hardening master bundle Phase 9.2.
|
|
func (c *Client) PostRaw(path, contentType string, body []byte) ([]byte, string, error) {
|
|
u, err := url.JoinPath(c.baseURL, path)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
req, err := http.NewRequest("POST", u, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", contentType)
|
|
if c.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("reading response: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, "", fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(data))
|
|
}
|
|
return data, resp.Header.Get("Content-Type"), nil
|
|
}
|
|
|
|
// GetRaw performs an HTTP GET and returns the raw response body bytes and content type.
|
|
// Used for binary responses (DER CRL, OCSP).
|
|
func (c *Client) GetRaw(path string) ([]byte, string, error) {
|
|
u, err := url.JoinPath(c.baseURL, path)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", u, nil)
|
|
if err != nil {
|
|
return 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, "", 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
|
|
}
|
|
|
|
func (c *Client) do(method, path string, query url.Values, body interface{}) (json.RawMessage, error) {
|
|
u, err := url.JoinPath(c.baseURL, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
if query != nil && len(query) > 0 {
|
|
u = u + "?" + query.Encode()
|
|
}
|
|
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling request body: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, u, bodyReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
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()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
// 204 No Content — return empty JSON object
|
|
if resp.StatusCode == 204 {
|
|
return json.RawMessage(`{"status":"deleted"}`), nil
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return json.RawMessage(respBody), nil
|
|
}
|