mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +00:00
3155b9475f
Breaking change release. Plaintext HTTP listener removed. The certctl control plane now terminates TLS 1.3 on :8443 via http.Server.ListenAndServeTLS. No CERTCTL_TLS_ENABLED=false escape hatch. No dual-listener mode. One-step cutover per docs/upgrade-to-tls.md. Server - cmd/server/tls.go: certHolder with SIGHUP hot-reload + atomic cert swap, buildServerTLSConfig (TLS 1.3 min, GetCertificate callback), preflightServerTLS validation - cmd/server/main.go: ListenAndServeTLS in place of ListenAndServe, watchSIGHUP wiring, cert/key path config threading - tls_test.go: 418-line regression coverage of reload, preflight, callback behavior, SAN validation Config - CERTCTL_TLS_CERT_PATH / CERTCTL_TLS_KEY_PATH (required) - Plaintext rejection: agents/CLI/MCP pre-flight-fail on http:// URLs with a pointer to docs/upgrade-to-tls.md Agents, CLI, MCP - All three pre-flight-reject http:// URLs with fail-loud diagnostic - CERTCTL_SERVER_CA_BUNDLE_PATH for private-CA trust - CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY for dev-only bypass (loud warning on startup) - install-agent.sh emits both vars as commented template lines docker-compose - certctl-tls-init sidecar generates SAN-valid self-signed cert into deploy/test/certs/ on first boot - All demo-stack curls pin against ca.crt with --cacert Helm chart - Three TLS provisioning modes, exactly one required: - server.tls.existingSecret (operator-supplied) - server.tls.certManager.enabled (cert-manager integration) - server.tls.selfSigned.enabled (eval only — not for production) - server-certificate.yaml template for cert-manager mode - helm install without a TLS source fails at template render with a pointer to docs/tls.md CI - .github/workflows/ci.yml Helm Chart Validation step renders the chart in both existingSecret and cert-manager modes, plus an inverse guard-regression test that asserts helm template MUST refuse to render when no TLS source is configured. Previously the single `helm template` invocation hit the certctl.tls.required fail-loud guard and exit-1'd CI. Four invocations now: lint (existingSecret), template (existingSecret), template (cert-manager), template (no args — must fail). Integration tests - deploy/test/integration_test.go stands up the Compose stack over HTTPS, extracts the CA bundle, and exercises every certctl API over https://localhost:8443 - All 34 integration subtests green (per Phase 8 local CI-parity) Documentation - New: docs/tls.md (provisioning patterns, rotation, SIGHUP reload) - New: docs/upgrade-to-tls.md (one-step cutover, no-downgrade warnings, fleet-roll sequencing) - CHANGELOG.md: v2.2.0 "HTTPS Everywhere — The Irony" entry (file heading unchanged; release tag is v2.0.47) - All curls in docs/, examples/, deploy/helm/ guides use https://localhost:8443 --cacert Verification - grep -rn "ListenAndServe[^T]" cmd/ internal/ → 0 hits - grep -rn "\"http://" cmd/ internal/ → 2 benign hits (Caddy admin API default, SSRF doc comment) — zero certctl endpoints - Tasks #197–#206 (Phases 0–8) all closed in the tracker Files: 65 changed, 3489 insertions, 372 deletions (pre-CI-fix).
185 lines
5.5 KiB
Go
185 lines
5.5 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)
|
|
}
|
|
|
|
// 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
|
|
}
|