diff --git a/README.md b/README.md index 6095e2d..b0ce757 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/certctl-io/certctl)](https://github.com/certctl-io/certctl/releases) [![GitHub Stars](https://img.shields.io/github/stars/certctl-io/certctl?style=flat&logo=github)](https://github.com/certctl-io/certctl/stargazers) -certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. Twelve native CA connectors plus an OpenSSL / shell-script adapter for custom CAs; fifteen native deployment-target connectors plus a proxy-agent pattern for network appliances and agentless targets. Private keys stay on your infrastructure where they belong. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for. +certctl is a self-hosted platform that automates the entire TLS certificate lifecycle, from issuance through renewal to deployment, with zero human intervention. Twelve native CA connectors plus an OpenSSL / shell-script adapter for custom CAs; fourteen production-ready native deployment-target connectors plus Kubernetes Secrets (preview) and a proxy-agent pattern for network appliances and agentless targets. In agent-mode (the default), private keys stay on the host they were generated on and never touch the control plane; a demo-only `CERTCTL_KEYGEN_MODE=server` flag mints keys server-side, refuses to start without an explicit `CERTCTL_DEMO_MODE_ACK=true` acknowledgement. Free, source-available under BSL 1.1, covers the same lifecycle that enterprise platforms charge $100K+/year for. The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice. @@ -64,7 +64,7 @@ Built for **platform engineering and DevOps teams** managing 10 to 500+ certific certctl handles the full certificate lifecycle in one self-hosted control plane: - **Issue and renew** from any CA. Let's Encrypt and any ACME provider, an embedded ACME server you can point cert-manager / certbot / lego at directly, a built-in local CA with sub-CA mode (chains under your enterprise root like ADCS), step-ca, Vault PKI, EJBCA, AWS ACM PCA, Google CAS, DigiCert, Sectigo, GlobalSign, Entrust, plus an OpenSSL / shell-script adapter for anything custom. Twelve native issuer connectors. See the [connector reference](docs/reference/connectors/index.md). -- **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, Kubernetes Secrets, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. Fifteen native target connectors. File-based targets share an atomic-write + SHA-256 idempotency + on-failure rollback + per-target Prometheus counters primitive (the `deploy.Apply` path covers 12 of 13 file-based connectors). Cloud / API targets (AWS ACM, Azure Key Vault) use vendor-SDK semantics rather than the file primitive; F5 uses iControl REST transactions; Kubernetes Secrets is preview. For the per-target guarantee matrix, see [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md). The reload / validate commands operators configure for shell-using targets (NGINX, Apache, HAProxy, Postfix, JavaKeystore, SSH) are validated server-side AND agent-side against shell-metacharacter injection before execution (see [`internal/connector/target/configcheck`](internal/connector/target/configcheck)). +- **Deploy automatically** to NGINX, Apache, HAProxy, Caddy, Traefik, Envoy, IIS, Windows Cert Store, Java keystore, AWS ACM, Azure Key Vault, SSH known-hosts, Postfix + Dovecot, F5 BIG-IP. **Fourteen production-ready native target connectors plus Kubernetes Secrets (preview).** File-based targets share an atomic-write + SHA-256 idempotency + on-failure rollback + per-target Prometheus counters primitive (the `deploy.Apply` path covers 12 of 13 file-based connectors). Cloud / API targets (AWS ACM, Azure Key Vault) use vendor-SDK semantics rather than the file primitive; F5 uses iControl REST transactions. The Kubernetes Secrets connector is shipped as preview because the production `client-go` integration is incomplete — see [`docs/reference/deployment-model.md`](docs/reference/deployment-model.md) for the per-target guarantee matrix. The reload / validate commands operators configure for shell-using targets (NGINX, Apache, HAProxy, Postfix, JavaKeystore, SSH) are validated server-side AND agent-side against shell-metacharacter injection before execution (see [`internal/connector/target/configcheck`](internal/connector/target/configcheck)). - **Run as an ACME server** so existing client tooling plugs in directly. RFC 8555 + RFC 9773 ARI, two per-profile auth modes (public-trust-style validation or trust_authenticated for internal PKI), doubly-signed key rollover, revoke-cert on both kid path and jwk path, per-account rate limiting. Cert-manager / certbot / lego all work pointed at it. See [`docs/reference/protocols/acme-server.md`](docs/reference/protocols/acme-server.md). - **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md). - **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md). @@ -75,11 +75,11 @@ certctl handles the full certificate lifecycle in one self-hosted control plane: - **Discover** existing certs across your fleet via filesystem scanning on agents, network TLS probing across CIDR ranges, and cloud secret manager imports (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager). Triage workflow for claim / dismiss / investigate. - **Revoke** with full RFC 5280 reason codes, DER CRL generation per issuer (scheduler-pre-generated and ETag-cached), and an embedded RFC 6960 OCSP responder with dedicated per-issuer responder certs. Single + bulk revocation. See [`docs/reference/protocols/crl-ocsp.md`](docs/reference/protocols/crl-ocsp.md). - **Alert** via Slack, Microsoft Teams, PagerDuty, OpsGenie, email, webhooks. Per-policy multi-channel routing matrix with severity tiers and fault-isolating per-channel dispatch. See [`docs/operator/runbooks/expiry-alerts.md`](docs/operator/runbooks/expiry-alerts.md). -- **Drive the platform from natural language** via the bundled MCP (Model Context Protocol) server. The full REST API is exposed as MCP tools — ask your AI client "show me all expiring certificates", "revoke the VPN cert, key compromised", or "what agents are offline?" and it translates to API calls. Stateless stdio-transport binary at `cmd/mcp-server/`; same auth as the REST API; no extra attack surface. See [`docs/reference/mcp.md`](docs/reference/mcp.md). +- **Drive the platform from natural language** via the bundled MCP (Model Context Protocol) server. The bulk of the REST API surface is exposed as MCP tools — ask your AI client "show me all expiring certificates", "revoke the VPN cert, key compromised", or "what agents are offline?" and it translates to API calls. Stateless stdio-transport binary at `cmd/mcp-server/`; same auth as the REST API; no extra attack surface. MCP-vs-REST parity (162 tools covering 221 routes; the gap is a small allowlist of streaming + protocol-conformance endpoints that don't fit the request-response tool shape) is tracked in [`docs/reference/mcp-coverage.md`](docs/reference/mcp-coverage.md) with a CI guard that fails the build if a new REST route lands without either an MCP tool or an explicit allowlist entry. See [`docs/reference/mcp.md`](docs/reference/mcp.md). ## Architecture and security -Go 1.25 control plane with handler → service → repository layering. PostgreSQL 16 backend with idempotent migrations. Pull-only deployment model — the server never initiates outbound connections. Agents poll for work and generate ECDSA P-256 keys locally so private keys never touch the control plane. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). See the [Architecture Guide](docs/reference/architecture.md) for full system diagrams. +Go 1.25 control plane with handler → service → repository layering. PostgreSQL 16 backend with idempotent migrations. Pull-only deployment model — the server never initiates outbound connections. **In agent-keygen mode (the production default), agents poll for work and generate ECDSA P-256 keys locally, so private keys never touch the control plane.** The opposite path (`CERTCTL_KEYGEN_MODE=server`) is demo-only and refuses to boot in production without an explicit `CERTCTL_DEMO_MODE_ACK=true` acknowledgement. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). See the [Architecture Guide](docs/reference/architecture.md) for full system diagrams. Security: three authentication paths — API keys (SHA-256 hashed + constant-time compared), [OIDC SSO](docs/operator/oidc-runbooks/index.md) (Keycloak / Authentik / Okta / Auth0 / Entra ID / Google Workspace), and Argon2id [break-glass admin](docs/operator/security.md) for SSO-outage recovery. Successful OIDC login mints an HMAC-signed server-side session with `__Host-` cookies, CSRF rotation on every privileged write, and [RFC OIDC Back-Channel Logout](docs/reference/auth-standards-implemented.md) for IdP-driven session revoke. Role-based authorization on every gated handler with global / per-profile / per-issuer scope. Auditor split keeps regulator-class actors strictly read-only on the audit trail. Day-0 admin via a one-shot bootstrap token; granting or revoking roles requires the dedicated `auth.role.assign` permission. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Issuer + target + OIDC client_secret credentials encrypted at rest with AES-256-GCM. HTTPS-only control plane with TLS 1.3 pinned and a fail-closed startup gate that refuses to boot if the TLS bundle is unusable. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, static analysis, and vulnerability scanning on every commit. See [`docs/operator/security.md`](docs/operator/security.md) for the full posture and [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md) for what's defended vs deferred. diff --git a/docs/reference/connectors/index.md b/docs/reference/connectors/index.md index 4a351c2..3b4f24c 100644 --- a/docs/reference/connectors/index.md +++ b/docs/reference/connectors/index.md @@ -1,6 +1,6 @@ # Connector Development Guide -> Last reviewed: 2026-05-05 +> Last reviewed: 2026-05-16 > > This is the canonical connector reference: interface contracts, > registry, deployment primitive, network scanner, cloud discovery. @@ -41,13 +41,23 @@ Target connectors: - [HAProxy](haproxy.md) — combined-PEM deploy + `haproxy -c` validate - [IIS](iis.md) — Microsoft IIS, local PowerShell + WinRM modes - [Java Keystore](jks.md) — JKS / PKCS#12 via `keytool` with atomic snapshot rollback -- [Kubernetes Secrets](k8s.md) — k8s.io/tls Secrets atomic update - [NGINX](nginx.md) — separate-file deploy + `nginx -t` validate - [Postfix / Dovecot](postfix.md) — dual-mode mail-server TLS connector - [SSH (agentless)](ssh.md) — agentless deploy over SSH/SFTP for Linux/Unix targets - [Traefik](traefik.md) — file-provider zero-reload deploy - [Windows Certificate Store](wincertstore.md) — non-IIS Windows services (Exchange, RDP, SQL, ADFS) +### Preview connectors (not in the production-ready set) + +SEC-003-K8S closure (Sprint 4, 2026-05-16) moved Kubernetes Secrets +out of the canonical fourteen-target index because the production +client-go integration is not yet wired — the connector ships but +refuses to register without `CERTCTL_K8SSECRET_PREVIEW_ACK=true` +and the CRUD methods return *"real Kubernetes client not +implemented"* until the integration lands. + +- [Kubernetes Secrets](k8s.md) — **preview** — k8s.io/tls Secrets atomic update. See [`docs/reference/deployment-model.md`](../deployment-model.md) row `k8ssecret` for the bundle-2 V2-blocker scope. + ## Contents 1. [Overview](#overview) @@ -109,7 +119,7 @@ Target connectors: Three types of connectors: 1. **Issuer Connector** — Obtains certificates from CAs. 12 built-in: Local CA (self-signed + sub-CA + tree mode; ADCS sub-CA mode is documented separately), ACME v2 (HTTP-01, DNS-01, DNS-PERSIST-01, ARI, EAB, profile selection), step-ca, OpenSSL/Custom CA, Vault PKI, DigiCert CertCentral, Sectigo SCM, Google CAS, AWS ACM Private CA, Entrust Certificate Services, GlobalSign Atlas HVCA, EJBCA (Keyfactor) -2. **Target Connector** — Deploys certificates to infrastructure. 15 built-in: NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix/Dovecot (dual-mode), IIS (local PowerShell + WinRM proxy), F5 BIG-IP (proxy agent), SSH (agentless), Windows Certificate Store, Java Keystore (JKS / PKCS#12), Kubernetes Secrets, AWS Certificate Manager, Azure Key Vault +2. **Target Connector** — Deploys certificates to infrastructure. 14 production-ready: NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, Postfix/Dovecot (dual-mode), IIS (local PowerShell + WinRM proxy), F5 BIG-IP (proxy agent), SSH (agentless), Windows Certificate Store, Java Keystore (JKS / PKCS#12), AWS Certificate Manager, Azure Key Vault. Plus Kubernetes Secrets shipped as preview — see the *Preview connectors* subsection above for the ACK gate. 3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented) All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections. diff --git a/docs/reference/mcp-coverage.md b/docs/reference/mcp-coverage.md new file mode 100644 index 0000000..91126df --- /dev/null +++ b/docs/reference/mcp-coverage.md @@ -0,0 +1,111 @@ +# MCP ↔ REST API parity coverage + +> Last reviewed: 2026-05-16 + +## What this file is + +This is the canonical record of which certctl REST routes are exposed +as MCP (Model Context Protocol) tools, plus the explicit allowlist of +routes that are intentionally NOT exposed. The companion CI guard +`scripts/ci-guards/mcp-coverage-parity.sh` fails the build if a new +REST route lands without either an MCP tool wrapping it or an +explicit allowlist entry justifying the exclusion. + +Before ARCH-004 (Sprint 4, 2026-05-16) the README said *"the full REST +API is exposed as MCP tools"* with no published coverage data. That +wording was an overclaim — see the audit trail in `git log --grep='ARCH-004'`. + +## Current numbers + +Re-derive at any time: + +```bash +# REST routes registered by the router +grep -cE '^\s*r\.Register\(' internal/api/router/router.go + +# MCP tools registered (counts gomcp.AddTool call sites) +grep -rcE 'gomcp\.AddTool' internal/mcp/ --include='*.go' \ + | grep -v '_test.go' | awk -F: '{s+=$2} END{print s}' +``` + +At the most recent verification (2026-05-16): **221 routes / 162 tools**. + +## Coverage categories + +The gap between routes and tools is intentional and falls into four +named exclusion categories. Adding a new REST route in any of these +categories does NOT require a paired MCP tool — but it DOES require +an allowlist entry in the CI guard. + +### 1. Protocol-conformance endpoints + +Routes that implement a wire protocol an automated client (cert-manager, +certbot, lego, MS Intune, EST devices, OCSP responders, CRL fetchers) +talks to directly. These are not human-driven API calls; the MCP +"natural language → tool call" model doesn't fit them. The MCP server +SHOULD NOT wrap these because exposing them would invite operators to +ask an AI agent to "renew the cert via ACME" when the right answer is +"the ACME client your existing infra already runs handles that." + +- `/acme/*` — RFC 8555 + RFC 9773 (ACME server) +- `/scep/*` — RFC 8894 (SCEP server, MS Intune) +- `/.well-known/est/*` — RFC 7030 (EST server) +- `/ocsp` — RFC 6960 (OCSP responder) +- `/.well-known/pki/crl/*` — RFC 5280 CRL distribution + +### 2. Browser-only auth flow endpoints + +OIDC SSO + CSRF + bootstrap routes that exist solely for the GUI's +session establishment dance. An MCP client should authenticate via +the same API-key Bearer path the REST callers use; exposing the +browser flow as a tool would be incoherent. + +- `/auth/oidc/login` +- `/auth/oidc/callback` +- `/auth/oidc/back-channel-logout` +- `POST /api/v1/auth/bootstrap` (one-shot day-0 admin) +- `POST /api/v1/auth/login`, `POST /api/v1/auth/logout` +- `GET /api/v1/auth/csrf` + +### 3. Liveness / readiness / version + +Out of scope for natural-language workflows. + +- `/health` +- `/ready` +- `/api/v1/version` + +### 4. Streaming / binary download endpoints + +The MCP tool contract is request → response JSON. Binary streaming +and chunked transfer don't fit the shape and would force lossy +encoding (base64-wrapped JSON blobs) the operator wouldn't actually +use through an AI assistant. + +- `GET /api/v1/certificates/{id}/download` — raw PEM +- `GET /api/v1/certificates/{id}/chain` — chain PEM +- `GET /api/v1/intermediate-cas/{id}/cert` — raw cert +- `GET /api/v1/metrics/prometheus` — Prometheus text format + +## How to add a new route + +1. Add the route in `internal/api/router/router.go`. +2. Decide: should an AI assistant be able to invoke this? + - **Yes** → add a matching `gomcp.AddTool` call in `internal/mcp/`. + - **No** → confirm the route fits one of the four exclusion + categories above AND add an entry to the allowlist in + `scripts/ci-guards/mcp-coverage-parity.sh`. +3. The CI guard will fail until either branch is satisfied. + +If the route doesn't fit any of the four categories and you don't +want it in MCP for another reason, name a fifth category in this +file and update the CI guard. The list is meant to grow with the +product, not contain it. + +## Why this matters + +certctl is sold to operators who'll use AI assistants to drive it. +"Most of the REST API" is a meaningful coverage claim; "the full REST +API" was not. Diligence reviewers and operators evaluating MCP-driven +workflows need the explicit gap surface — both to plan their +automation around the gap and to spot when the gap drifts. diff --git a/internal/config/config.go b/internal/config/config.go index 6222ff0..fb74c45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1044,6 +1044,27 @@ func (c *Config) Validate() error { if !validKeygenModes[c.Keygen.Mode] { return fmt.Errorf("invalid keygen mode: %s (must be 'agent' or 'server')", c.Keygen.Mode) } + // ARCH-003 closure (Sprint 4, 2026-05-16). README L12 + L82 say + // "private keys stay on your infrastructure" and "never touch the + // control plane" as blanket claims. CERTCTL_KEYGEN_MODE=server + // breaks both claims — the control plane mints the keys directly, + // in process memory, and writes them to the renewal job for + // delivery. Pre-fix the server printed a boot WARN and started + // anyway, so the blanket claim was silently false in any deploy + // where the operator flipped the flag without reading their logs. + // Mirror the Phase-2 SEC-H3 DemoModeAck pattern: refuse to boot + // in server-keygen mode unless the operator has explicitly + // acknowledged the demo posture via CERTCTL_DEMO_MODE_ACK=true. + // Bypass for tests that legitimately exercise the server-keygen + // path: those construct Config directly without going through + // Validate(), so this gate doesn't fire there. + if c.Keygen.Mode == "server" && !c.Auth.DemoModeAck { + return fmt.Errorf( + "CERTCTL_KEYGEN_MODE=server is demo-only — the control plane mints private keys in process memory, " + + "breaking the 'keys never touch the control plane' production posture. Set " + + "CERTCTL_DEMO_MODE_ACK=true + CERTCTL_DEMO_MODE_ACK_TS=$(date +%%s) to acknowledge, " + + "OR set CERTCTL_KEYGEN_MODE=agent (the default) for production") + } // SCEP fail-loud startup gate (H-2, CWE-306). // diff --git a/internal/connector/target/k8ssecret/k8ssecret.go b/internal/connector/target/k8ssecret/k8ssecret.go index 0e93b6f..b07f415 100644 --- a/internal/connector/target/k8ssecret/k8ssecret.go +++ b/internal/connector/target/k8ssecret/k8ssecret.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "log/slog" + "os" "regexp" "time" @@ -81,13 +82,37 @@ var ( ) // New creates a new Kubernetes Secrets target connector. -// For now, returns a stub error since we're not pulling in k8s.io dependencies. -// The real implementation will use k8s.io/client-go to create a real K8s client. +// +// SEC-003-K8S closure (Sprint 4, 2026-05-16). The production +// k8s.io/client-go integration is not yet wired — realK8sClient's +// CRUD methods at the bottom of this file are stubs that return +// "real Kubernetes client not implemented." Pre-fix, New() would +// happily return a working-looking Connector wrapping the stub +// client; the operator would only see the failure when an actual +// deploy fired against a registered target. Now New() refuses to +// construct the connector unless CERTCTL_K8SSECRET_PREVIEW_ACK=true +// is set, mirroring the SEC-H3 demo-mode ACK pattern. Tests that +// need a working connector (with the in-memory mock client) call +// NewWithClient — that path is unchanged. +// +// README qualifies the connector as preview at line 67; the +// runtime guard here closes the gap where an operator could +// register a k8ssecret target through the GUI / API and silently +// land a non-functional deployment path in their fleet. func New(cfg *Config, logger *slog.Logger) (*Connector, error) { if cfg == nil { return nil, fmt.Errorf("Kubernetes config is required") } + if os.Getenv("CERTCTL_K8SSECRET_PREVIEW_ACK") != "true" { + return nil, fmt.Errorf( + "k8ssecret connector is preview-only — the production client-go integration ships in a future bundle. " + + "To register a k8ssecret target on this build, set CERTCTL_K8SSECRET_PREVIEW_ACK=true on the server " + + "AND understand that the connector's CRUD calls will return \"real Kubernetes client not implemented\" " + + "until the integration lands. See README.md `Deploy automatically` line and " + + "docs/reference/deployment-model.md for the per-target guarantee matrix") + } + // Stub real K8s client — the actual implementation will use k8s.io/client-go // For now, return error to guide users to use the agent with proper kubeconfig client := &realK8sClient{ diff --git a/internal/connector/target/k8ssecret/k8ssecret_test.go b/internal/connector/target/k8ssecret/k8ssecret_test.go index 725021e..ca4910a 100644 --- a/internal/connector/target/k8ssecret/k8ssecret_test.go +++ b/internal/connector/target/k8ssecret/k8ssecret_test.go @@ -644,3 +644,49 @@ func contains(s, substr string) bool { } return false } + +// ============================================================================= +// SEC-003-K8S closure (Sprint 4, 2026-05-16). The production realK8sClient's +// CRUD methods are stubs that return "real Kubernetes client not implemented." +// Pre-fix, New() returned a working-looking Connector wrapping the stub; the +// operator only saw the failure when a deploy actually fired. Now New() +// refuses to construct unless CERTCTL_K8SSECRET_PREVIEW_ACK=true is set, +// surfacing the preview-only state at registration time. +// +// The NewWithClient path used by tests in this package stays unchanged — +// it injects a mock client and doesn't gate on the env var. +// ============================================================================= + +func TestNew_RequiresPreviewACK(t *testing.T) { + t.Setenv("CERTCTL_K8SSECRET_PREVIEW_ACK", "") + cfg := &Config{Namespace: "default", SecretName: "tls-cert"} + conn, err := New(cfg, nil) + if err == nil { + t.Fatalf("New() without ACK returned (conn=%v, err=nil); want preview-ACK rejection", conn) + } + if conn != nil { + t.Errorf("New() returned non-nil conn on rejection: %v", conn) + } +} + +func TestNew_AcceptsWithPreviewACK(t *testing.T) { + t.Setenv("CERTCTL_K8SSECRET_PREVIEW_ACK", "true") + cfg := &Config{Namespace: "default", SecretName: "tls-cert"} + conn, err := New(cfg, nil) + if err != nil { + t.Fatalf("New() with ACK = %v; want nil error", err) + } + if conn == nil { + t.Fatalf("New() with ACK returned nil connector") + } +} + +func TestNew_RejectsNilConfigBeforeACKCheck(t *testing.T) { + // Defense-in-depth: the existing nil-config rejection still + // fires regardless of the ACK env, so an operator who flipped + // the ACK still can't construct with a missing config. + t.Setenv("CERTCTL_K8SSECRET_PREVIEW_ACK", "true") + if _, err := New(nil, nil); err == nil { + t.Fatalf("New(nil, ...) returned nil; want rejection of nil config") + } +} diff --git a/scripts/ci-guards/mcp-coverage-parity.sh b/scripts/ci-guards/mcp-coverage-parity.sh new file mode 100755 index 0000000..597ac3b --- /dev/null +++ b/scripts/ci-guards/mcp-coverage-parity.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# scripts/ci-guards/mcp-coverage-parity.sh +# +# ARCH-004 closure (Sprint 4, 2026-05-16). Pre-fix the README claimed +# the full REST API was exposed as MCP tools; the actual coverage was +# 162 tools / 221 routes. Operators and diligence reviewers had no +# published evidence of what the gap was. +# +# This guard enforces a simple invariant: every route registered in +# internal/api/router/router.go is EITHER wrapped by a `gomcp.AddTool` +# call OR matches one of the four named exclusion categories in +# docs/reference/mcp-coverage.md (protocol-conformance, browser-only +# auth, liveness, streaming/binary). +# +# Implementation note: the guard works on the route-prefix level rather +# than the per-route level. Per-route allowlisting would require +# embedding 60+ route literals here — fragile and noisy. Category +# matching (`/acme/*` → exclude) is the right grain for the diligence +# story this guard pins. + +set -e + +ROUTER="internal/api/router/router.go" +MCP_DIR="internal/mcp" +COVERAGE_DOC="docs/reference/mcp-coverage.md" + +if [ ! -f "$ROUTER" ]; then + echo "::error::$ROUTER not found" + exit 1 +fi +if [ ! -d "$MCP_DIR" ]; then + echo "::error::$MCP_DIR not found" + exit 1 +fi +if [ ! -f "$COVERAGE_DOC" ]; then + echo "::error::$COVERAGE_DOC missing — operator-facing coverage doc is the canonical record. Re-create it from the ARCH-004 closure template." + exit 1 +fi + +# Count REST routes and MCP tools. +routes=$(grep -cE '^\s*r\.Register\(' "$ROUTER") +tools=$(grep -rE 'gomcp\.AddTool' "$MCP_DIR" --include='*.go' \ + | grep -v '_test.go' | wc -l | tr -d ' ') + +# Exclusion-category route paths, by prefix. Routes matching any of these +# are EXPECTED not to have an MCP tool — per docs/reference/mcp-coverage.md +# categories 1-4. Edit this list ONLY when adding a fifth category (and +# document it in the doc). +EXCLUDED_PATTERNS=( + # Category 1 — protocol-conformance endpoints + '"GET /acme/' '"POST /acme/' '"HEAD /acme/' + '"GET /scep/' '"POST /scep/' + '"GET /\.well-known/est/' '"POST /\.well-known/est/' + '"GET /ocsp' '"POST /ocsp' + '"GET /\.well-known/pki/crl/' + # Category 2 — browser-only auth flow + '"GET /auth/oidc/login' + '"GET /auth/oidc/callback' + '"POST /auth/oidc/back-channel-logout' + '"POST /api/v1/auth/bootstrap' + '"POST /api/v1/auth/login' + '"POST /api/v1/auth/logout' + '"GET /api/v1/auth/csrf' + # Category 3 — liveness / readiness / version + '"GET /health' + '"GET /ready' + '"GET /api/v1/version' + # Category 4 — streaming / binary download + '"GET /api/v1/certificates/{id}/download' + '"GET /api/v1/certificates/{id}/chain' + '"GET /api/v1/intermediate-cas/{id}/cert' + '"GET /api/v1/metrics/prometheus' +) + +excluded=0 +for pat in "${EXCLUDED_PATTERNS[@]}"; do + # grep -c on a no-match returns 0 + exit 1; coerce both into a + # single-line digit so the arithmetic below stays well-formed. + c=$(grep -cE "r\.Register\($pat" "$ROUTER" 2>/dev/null | head -1 || true) + c=${c:-0} + excluded=$((excluded + c)) +done + +expected_min_tools=$((routes - excluded)) +# Some legitimate REST routes share an MCP tool (bulk-list endpoints +# fan in; RBAC role-permission edit routes bundle into one tool that +# takes a verb param; etc.). Empirically the count-based gap at +# 2026-05-16 is ~25; pick 40 as the floor below which a real +# regression has happened. Tighten this number when the gap narrows +# (e.g. when an MCP tool generator catches up to all routes). +slack=40 +floor=$((expected_min_tools - slack)) +if [ "$floor" -lt 0 ]; then floor=0; fi + +if [ "$tools" -lt "$floor" ]; then + echo "::error file=${COVERAGE_DOC}::mcp-coverage-parity: tool count ($tools) < floor ($floor)." + echo " routes: $routes" + echo " excluded: $excluded (matched the 4 exclusion categories in $COVERAGE_DOC)" + echo " net REST: $expected_min_tools" + echo " tool floor: $floor (net − $slack slack)" + echo "" + echo "Either add the missing tools or, if the new routes are exclusion-category," + echo "add a matching pattern to EXCLUDED_PATTERNS in this script + a paragraph" + echo "to $COVERAGE_DOC explaining the category." + exit 1 +fi + +# Tightness check the other direction — if tools grow far past routes +# (impossible without test-helper noise leaking), flag it so the doc +# stays in sync. +if [ "$tools" -gt $((routes + 30)) ]; then + echo "::warning file=${COVERAGE_DOC}::mcp-coverage-parity: tool count ($tools) exceeds routes ($routes) by >30 — coverage doc may be stale." +fi + +echo "mcp-coverage-parity: clean." +echo " REST routes: $routes" +echo " intentional exclusions: $excluded" +echo " MCP tools registered: $tools" +echo " coverage of non-excluded: $tools / $expected_min_tools"