v2.0.47: HTTPS Everywhere — TLS-only control plane, agents/CLI/MCP

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).
This commit is contained in:
shankar0123
2026-04-20 03:31:05 +00:00
parent 04c7eca615
commit 52248be717
66 changed files with 3518 additions and 375 deletions
+29 -3
View File
@@ -148,8 +148,34 @@ jobs:
with:
version: '3.13.0'
# HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is
# configured. Every lint/template invocation below must pick exactly one
# provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl
# (certctl.tls.required) and docs/tls.md.
- name: Lint Helm Chart
run: helm lint deploy/helm/certctl/
run: |
helm lint deploy/helm/certctl/ \
--set server.tls.existingSecret=certctl-tls-ci
- name: Template Helm Chart
run: helm template certctl deploy/helm/certctl/ > /dev/null
- name: Template Helm Chart (existingSecret mode)
run: |
helm template certctl deploy/helm/certctl/ \
--set server.tls.existingSecret=certctl-tls-ci \
> /dev/null
- name: Template Helm Chart (cert-manager mode)
run: |
helm template certctl deploy/helm/certctl/ \
--set server.tls.certManager.enabled=true \
--set server.tls.certManager.issuerRef.name=letsencrypt-prod \
> /dev/null
- name: Template Helm Chart (guard fails without TLS)
run: |
# Inverse test: the chart MUST refuse to render when no TLS source is
# configured. If this ever renders successfully, the fail-loud guard
# in certctl.tls.required has regressed.
if helm template certctl deploy/helm/certctl/ > /dev/null 2>&1; then
echo "::error::Helm chart rendered without a TLS source — fail-loud guard regressed"
exit 1
fi
+8 -1
View File
@@ -63,6 +63,7 @@ certctl-cli
/server
/agent
/cli
/mcp-server
# Private strategy docs
strategy.md
@@ -71,7 +72,6 @@ SECURITY_REMEDIATION.md
# OS
.DS_Store
Thumbs.db
mcp-server
# Local Go build/module caches (session-scoped, never committed)
/.gocache/
@@ -82,3 +82,10 @@ mcp-server
# Design scratch files (session-scoped)
/.i004-design.md
/.i005-design.md
# HTTPS-Everywhere (M-007) Phase 6: the docker-compose.test.yml tls-init
# container writes ca.crt / server.crt / server.key into this directory so
# the host-side integration_test.go binary can pin the CA via
# CERTCTL_TEST_CA_BUNDLE=./certs/ca.crt. Material is regenerated on every
# `docker compose up` and never belongs in git.
/deploy/test/certs/
+50
View File
@@ -0,0 +1,50 @@
# Changelog
All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/).
## [2.2.0] — 2026-04-19
### HTTPS Everywhere — The Irony
> certctl manages other teams' certificates. Until v2.2, it didn't terminate TLS on its own control plane. We treated the server as an internal service sitting behind whatever TLS-terminating infrastructure the operator already owned — reverse proxies, Kubernetes Ingress controllers, service mesh sidecars. Working through an EST coverage-gap audit surfaced this as a credibility problem we wanted to fix head-on: a cert-lifecycle product should ship with HTTPS by default. This release flips that. Self-signed bootstrap for docker-compose demos, operator-supplied Secret for Helm (with optional cert-manager integration), and a one-step cutover with no backward-compat bridge. Out-of-date agents will fail at the TLS handshake layer on upgrade; the upgrade guide walks operators through the roll.
### Breaking Changes
- **HTTPS-only control plane. The plaintext HTTP listener is gone.** There is no `CERTCTL_TLS_ENABLED=false` escape hatch and no `:8080` fallback. Operators who were running certctl behind their own TLS terminator must either (a) continue doing so and let the downstream TLS terminator talk to certctl's HTTPS listener, or (b) bring their own cert/key and terminate on certctl directly. Either path requires config changes — see `docs/upgrade-to-tls.md` for a one-step cutover.
- **Agents reject `CERTCTL_SERVER_URL=http://...` at startup.** This is a pre-flight config validation failure with a fail-loud diagnostic pointing at `docs/upgrade-to-tls.md`. Not a TCP-refused, not a TLS-handshake-error — the agent will not even attempt the network call. Every agent deployment must be reconfigured before upgrading the server.
- **CLI and MCP clients require `https://` URLs.** Same pre-flight rejection of plaintext schemes.
- **TLS 1.2 is not supported. TLS 1.3 only.** The server's `tls.Config.MinVersion` is pinned to `tls.VersionTLS13`. Any client still negotiating TLS 1.2 will fail at the handshake. Modern curl, Go stdlib, browsers, and Kubernetes tooling all default to 1.3-capable; legacy clients may need an upgrade.
- **Helm chart requires a TLS source.** `helm install` without one of `server.tls.existingSecret`, `server.tls.certManager.enabled`, or (for eval only) `server.tls.selfSigned.enabled` fails at template time with a diagnostic pointing at `docs/tls.md`. There is no default-to-plaintext path.
### Added
- **Self-signed bootstrap for Docker Compose demos.** A `certctl-tls-init` init container runs before the server on first boot, generates a SAN-valid self-signed cert into `deploy/test/certs/`, and exits. The server mounts the resulting cert/key. Every curl in the demo stack pins against `./deploy/test/certs/ca.crt` with `--cacert`.
- **Helm chart TLS provisioning — three modes.** Operator-supplied Secret (`server.tls.existingSecret`), cert-manager integration (`server.tls.certManager.enabled` with issuer selection), or self-signed (`server.tls.selfSigned.enabled` — eval only, not supported for production). Chart templates enforce exactly one is active.
- **Hot-reload of TLS cert/key on `SIGHUP`.** Overwrite the cert/key on disk, send `SIGHUP` to the server PID, watch the `slog.Info("tls.reload", ...)` log line, and new TLS connections use the new cert. Failure during reload is logged and does not crash the server; the previous cert remains in use.
- **Agent CA-bundle env vars.** `CERTCTL_SERVER_CA_BUNDLE_PATH` points at a PEM file the agent's HTTP client will trust. `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` disables verification (development only — the agent logs a loud warning at startup). `install-agent.sh` writes both as commented template lines into the generated `agent.env`.
- **Integration test suite runs over HTTPS.** `go test -tags=integration ./deploy/test/...` stands up the full Compose stack, extracts the self-signed CA bundle, and exercises every certctl API over `https://localhost:8443`. All 34 subtests green.
- **`docs/tls.md`** — cert provisioning patterns: bring-your-own Secret, cert-manager, self-signed bootstrap, SAN requirements, rotation workflows, SIGHUP reload semantics, troubleshooting.
- **`docs/upgrade-to-tls.md`** — one-step cutover guide for existing v2.1 operators. Walks through the agent fleet roll, Helm upgrade sequencing, downgrade-is-not-supported warnings, and cert-provisioning decision tree.
### Changed
- `cmd/server/main.go` now calls `http.Server.ListenAndServeTLS(certFile, keyFile)`. The plaintext `ListenAndServe` code path is deleted — `grep -rn "ListenAndServe[^T]" cmd/ internal/` returns zero hits.
- All documentation curls (`docs/testing-guide.md`, `docs/quickstart.md`, `deploy/helm/INSTALLATION.md`, `deploy/helm/DEPLOYMENT_GUIDE.md`, `deploy/ENVIRONMENTS.md`, `docs/openapi.md`, migration guides, example READMEs) use `https://localhost:8443` and `--cacert` against the demo stack's bundle.
- OpenAPI spec (`api/openapi.yaml`) `servers` blocks default to `https://localhost:8443`.
### Security
- TLS 1.3 pinned via `tls.Config.MinVersion = tls.VersionTLS13`.
- Plaintext HTTP listener removed entirely — no port 8080, no `Upgrade-Insecure-Requests`, no HSTS-required redirect dance. There is only one port: 8443, TLS 1.3.
- `grep -rn "http://" cmd/ internal/` returns zero hits outside test fixtures and the agent-side URL-scheme rejection error message.
### Upgrade Notes
Read `docs/upgrade-to-tls.md` before upgrading. The short version:
1. Pick a TLS source — bring-your-own cert, cert-manager, or self-signed bootstrap.
2. Upgrade the server with TLS configured. First boot over HTTPS.
3. Roll the agent fleet: set `CERTCTL_SERVER_URL=https://...` and, if using a private CA, `CERTCTL_SERVER_CA_BUNDLE_PATH`. Old agents will fail loud at startup — expected.
4. Roll CLI/MCP clients the same way.
There is no backward-compat bridge. There is no dual-listener mode. The cutover is one step.
+13 -6
View File
@@ -197,7 +197,7 @@ cd certctl
docker compose -f deploy/docker-compose.yml up -d --build
```
Wait ~30 seconds, then open **http://localhost:8443** in your browser. The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
Wait ~30 seconds, then open **https://localhost:8443** in your browser. (The shipped `docker-compose.yml` self-signs a cert via the `certctl-tls-init` init container on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.) The onboarding wizard walks you through connecting a CA, deploying an agent, and issuing your first certificate.
**Want a pre-populated demo instead?** Add the demo override to see 32 certificates across 10 issuers, 8 agents, and 180 days of realistic history:
@@ -208,10 +208,12 @@ docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up
The `deploy/` directory has four compose files: `docker-compose.yml` (base platform), `docker-compose.demo.yml` (demo data overlay), `docker-compose.dev.yml` (PgAdmin + debug logging), and `docker-compose.test.yml` (standalone integration tests with real CA backends). See the [Docker Compose Environments Guide](deploy/ENVIRONMENTS.md) for a service-by-service walkthrough, or the [Quick Start](docs/quickstart.md#docker-compose-environments) for a summary.
```bash
curl http://localhost:8443/health
curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health
# {"status":"healthy"}
```
The control plane is HTTPS-only (TLS 1.3, no plaintext listener). See [`docs/tls.md`](docs/tls.md) for cert provisioning patterns and [`docs/upgrade-to-tls.md`](docs/upgrade-to-tls.md) if you're upgrading from a pre-v2.2 release.
### Agent Install (One-Liner)
```bash
@@ -326,8 +328,9 @@ Each directory contains a `docker-compose.yml` and a `README.md` explaining the
go install github.com/shankar0123/certctl/cmd/cli@latest
# Configure
export CERTCTL_SERVER_URL=http://localhost:8443
export CERTCTL_SERVER_URL=https://localhost:8443
export CERTCTL_API_KEY=your-api-key
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # or --ca-bundle on the CLI; --insecure for dev self-signed
# Usage
certctl-cli certs list # List all certificates
@@ -347,11 +350,14 @@ certctl ships a standalone MCP (Model Context Protocol) server that exposes all
```bash
# Install and run
go install github.com/shankar0123/certctl/cmd/mcp-server@latest
export CERTCTL_SERVER_URL=http://localhost:8443
export CERTCTL_SERVER_URL=https://localhost:8443
export CERTCTL_API_KEY=your-api-key
export CERTCTL_SERVER_CA_BUNDLE_PATH=/path/to/ca.crt # required for self-signed bootstrap
mcp-server
```
The MCP server is env-vars-only — there are no CLI flags for TLS. If you must bypass verification for local development against a self-signed cert, set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`. Never set that in production.
**Claude Desktop** (`claude_desktop_config.json`):
```json
{
@@ -359,8 +365,9 @@ mcp-server
"certctl": {
"command": "mcp-server",
"env": {
"CERTCTL_SERVER_URL": "http://localhost:8443",
"CERTCTL_API_KEY": "your-api-key"
"CERTCTL_SERVER_URL": "https://localhost:8443",
"CERTCTL_API_KEY": "your-api-key",
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/ca.crt"
}
}
}
+2 -4
View File
@@ -17,10 +17,8 @@ info:
url: https://github.com/shankar0123/certctl/blob/master/LICENSE
servers:
- url: http://localhost:8080
description: Local development
- url: http://localhost:8443
description: Docker Compose demo
- url: https://localhost:8443
description: Docker Compose demo (self-signed cert; pin with ./deploy/test/certs/ca.crt)
security:
- bearerAuth: []
+273 -31
View File
@@ -7,6 +7,7 @@ import (
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
@@ -72,7 +73,7 @@ func TestAgent_Heartbeat_Success(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Should not panic
agent.sendHeartbeat(context.Background())
@@ -93,7 +94,7 @@ func TestAgent_Heartbeat_ServerError(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Should increment consecutive failures
failureBefore := agent.consecutiveFailures
@@ -115,7 +116,7 @@ func TestAgent_Heartbeat_ConnectionError(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Should fail due to connection error
agent.sendHeartbeat(context.Background())
@@ -150,7 +151,7 @@ func TestAgent_PollWork_NoWork(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Should not panic
agent.pollForWork(context.Background())
@@ -195,7 +196,7 @@ func TestAgent_PollWork_Success(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Should not panic; work items are processed in separate gorines in real usage
agent.pollForWork(context.Background())
@@ -285,7 +286,7 @@ func TestParsePEMFile(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Parse the file
entries := agent.parsePEMFile(certPath)
@@ -336,7 +337,7 @@ func TestParsePEMFile_MultipleCerts(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
entries := agent.parsePEMFile(certPath)
@@ -362,7 +363,7 @@ func TestParseDERFile(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
entry, err := agent.parseDERFile(derPath)
if err != nil {
@@ -397,7 +398,7 @@ func TestParseDERFile_Invalid(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
_, err := agent.parseDERFile(derPath)
if err == nil {
@@ -439,7 +440,7 @@ func TestScanDirectory(t *testing.T) {
DiscoveryDirs: []string{tmpdir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Simulate directory walk manually (as runDiscoveryScan does)
var certs []discoveredCertEntry
@@ -474,7 +475,7 @@ func TestCreateTargetConnector_NGINX(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
connector, err := agent.createTargetConnector("NGINX", configJSON)
@@ -496,7 +497,7 @@ func TestCreateTargetConnector_Unsupported(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
_, err := agent.createTargetConnector("UnsupportedType", nil)
@@ -530,7 +531,7 @@ func TestFetchCertificate_Success(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
certPEM, err := agent.fetchCertificate(context.Background(), "mc-001")
if err != nil {
@@ -556,7 +557,7 @@ func TestFetchCertificate_NotFound(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
_, err := agent.fetchCertificate(context.Background(), "mc-nonexistent")
if err == nil {
@@ -592,7 +593,7 @@ func TestReportJobStatus_Success(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
err := agent.reportJobStatus(context.Background(), "j-001", "Completed", "")
if err != nil {
@@ -624,7 +625,7 @@ func TestReportJobStatus_WithError(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
err := agent.reportJobStatus(context.Background(), "j-001", "Failed", "deployment failed")
if err != nil {
@@ -658,7 +659,7 @@ func TestMakeRequest_Success(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
resp, err := agent.makeRequest(context.Background(), http.MethodPost, "/test", map[string]string{"key": "value"})
if err != nil {
@@ -680,7 +681,7 @@ func TestMakeRequest_InvalidURL(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
_, err := agent.makeRequest(context.Background(), http.MethodGet, "/test", nil)
if err == nil {
@@ -765,7 +766,7 @@ func TestNewAgent(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
if agent.config != cfg {
t.Error("config not set correctly")
@@ -791,7 +792,7 @@ func TestNewAgent_WithLogger(t *testing.T) {
Hostname: "test-host",
}
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
if agent.logger != logger {
t.Error("logger not set correctly")
@@ -954,7 +955,7 @@ func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -1007,7 +1008,7 @@ func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
invalidJSON := json.RawMessage("{invalid json}")
@@ -1031,7 +1032,7 @@ func TestCreateTargetConnector_UnknownType(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
_, err := agent.createTargetConnector("MagicBox", nil)
@@ -1061,7 +1062,7 @@ func TestCreateTargetConnector_EmptyConfig(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
for _, typeName := range tests {
t.Run(typeName, func(t *testing.T) {
@@ -1137,7 +1138,7 @@ func TestRunDiscoveryScan_ValidCerts(t *testing.T) {
DiscoveryDirs: []string{tmpDir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Run discovery scan
agent.runDiscoveryScan(context.Background())
@@ -1165,7 +1166,7 @@ func TestRunDiscoveryScan_NoCertificates(t *testing.T) {
DiscoveryDirs: []string{tmpDir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Run discovery scan - should complete without error even with empty directory
agent.runDiscoveryScan(context.Background())
@@ -1222,7 +1223,7 @@ func TestRunDiscoveryScan_MultipleCerts(t *testing.T) {
DiscoveryDirs: []string{tmpDir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Run discovery scan
agent.runDiscoveryScan(context.Background())
@@ -1273,7 +1274,7 @@ func TestRunDiscoveryScan_DERCertificate(t *testing.T) {
DiscoveryDirs: []string{tmpDir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Run discovery scan
agent.runDiscoveryScan(context.Background())
@@ -1331,7 +1332,7 @@ func TestRunDiscoveryScan_Subdirectories(t *testing.T) {
DiscoveryDirs: []string{tmpDir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Run discovery scan - should recursively find certs in subdirs
agent.runDiscoveryScan(context.Background())
@@ -1369,7 +1370,7 @@ func TestRunDiscoveryScan_ServerError(t *testing.T) {
DiscoveryDirs: []string{tmpDir},
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
// Should handle server error gracefully without panicking
agent.runDiscoveryScan(context.Background())
@@ -1396,7 +1397,7 @@ func TestDiscoveredCertEntry_ValidFields(t *testing.T) {
Hostname: "test-host",
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent := NewAgent(cfg, logger)
agent, _ := NewAgent(cfg, logger)
entries := agent.parsePEMFile(certPath)
@@ -1447,3 +1448,244 @@ func TestDiscoveredCertEntry_ValidFields(t *testing.T) {
t.Error("PEMData should not be empty")
}
}
// ---------------------------------------------------------------------------
// HTTPS-Everywhere milestone (v2.2, §3.2 / §7) — Phase 5 client-side tests.
//
// These tests pin the agent's pre-flight HTTPS-scheme guard and the TLS
// configuration surface (CA bundle loading + TLS 1.3 round-trip) so that
// regressions surface at unit-test time, not at the first heartbeat of a
// production rollout. Matches the same contract asserted by the sibling
// binaries cmd/cli/main_test.go and cmd/mcp-server/main_test.go — the three
// must stay in lock-step because all three are HTTPS-only clients of the
// same control plane.
// ---------------------------------------------------------------------------
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
// HTTPS-Everywhere milestone requires on the agent binary startup path. The
// agent's diagnostic is distinct from the CLI/MCP variants because it names
// CERTCTL_SERVER_URL (the only input channel — no --server flag on the
// agent). Every case here mirrors the dispatch arms in cmd/agent/main.go:
// validateHTTPSScheme; drifting the error-message substrings is what this
// test is here to catch.
func TestValidateHTTPSScheme(t *testing.T) {
tests := []struct {
name string
serverURL string
wantErr bool
wantErrSub string
}{
{
name: "https URL passes",
serverURL: "https://certctl-server:8443",
wantErr: false,
},
{
name: "https URL with path passes",
serverURL: "https://certctl.example.com/api/v1",
wantErr: false,
},
{
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
serverURL: "HTTPS://certctl-server:8443",
wantErr: false,
},
{
name: "empty URL rejected names CERTCTL_SERVER_URL",
serverURL: "",
wantErr: true,
wantErrSub: "CERTCTL_SERVER_URL is empty",
},
{
name: "plaintext http rejected",
serverURL: "http://certctl-server:8443",
wantErr: true,
wantErrSub: "plaintext http://",
},
{
name: "bare host missing scheme falls through to unsupported",
serverURL: "localhost:8443",
wantErr: true,
// url.Parse treats "localhost:8443" as scheme=localhost,
// opaque=8443 — exercises the default arm (unsupported scheme)
// rather than the empty-scheme arm. Both are fail-closed, which
// is what we care about.
wantErrSub: "unsupported scheme",
},
{
name: "path-only URL rejected",
serverURL: "//certctl-server:8443",
wantErr: true,
wantErrSub: "missing a scheme",
},
{
name: "unsupported scheme rejected",
serverURL: "ftp://certctl-server:8443",
wantErr: true,
wantErrSub: "unsupported scheme",
},
{
name: "ws scheme rejected",
serverURL: "ws://certctl-server:8443",
wantErr: true,
wantErrSub: "unsupported scheme",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateHTTPSScheme(tt.serverURL)
if (err != nil) != tt.wantErr {
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
}
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
tt.serverURL, err.Error(), tt.wantErrSub)
}
})
}
}
// writeTestCABundle PEM-encodes a cert's DER bytes and writes the result to a
// tmp file inside dir. Used by CA-bundle tests so each case owns a distinct
// file path (matters for the "missing file" case which must point at a path
// that provably does not exist). Returns the path.
func writeTestCABundle(t *testing.T, dir string, certDER []byte, filename string) string {
t.Helper()
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
path := filepath.Join(dir, filename)
if err := os.WriteFile(path, pemBytes, 0644); err != nil {
t.Fatalf("writing CA bundle %q: %v", path, err)
}
return path
}
// TestNewAgent_CABundle_Success confirms that a well-formed PEM bundle gets
// parsed into an x509.CertPool and wired onto the agent's HTTP client
// transport. This is the happy path the docs/tls.md "Private CA signed
// server cert" section depends on.
func TestNewAgent_CABundle_Success(t *testing.T) {
cert, err := generateTestCertWithCN("test.certctl.local")
if err != nil {
t.Fatalf("generateTestCertWithCN: %v", err)
}
bundlePath := writeTestCABundle(t, t.TempDir(), cert.Raw, "ca-bundle.pem")
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent, err := NewAgent(&AgentConfig{
ServerURL: "https://certctl-server:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
CABundlePath: bundlePath,
}, logger)
if err != nil {
t.Fatalf("NewAgent with valid CA bundle err=%v want nil", err)
}
transport, ok := agent.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("agent.client.Transport is %T; want *http.Transport", agent.client.Transport)
}
if transport.TLSClientConfig == nil {
t.Fatal("TLSClientConfig is nil; HTTPS-everywhere milestone requires a non-nil TLS config")
}
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion=%x want TLS 1.3 (%x) per §2.3 of the milestone spec",
transport.TLSClientConfig.MinVersion, tls.VersionTLS13)
}
if transport.TLSClientConfig.RootCAs == nil {
t.Error("RootCAs is nil; the configured CA bundle was silently dropped")
}
}
// TestNewAgent_CABundle_MissingFile pins the fail-loud behavior when the
// operator points CERTCTL_SERVER_CA_BUNDLE_PATH at a path that does not
// exist. Falling back to system roots here would mask a misconfiguration as
// a much harder-to-debug TLS handshake failure downstream.
func TestNewAgent_CABundle_MissingFile(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
missingPath := filepath.Join(t.TempDir(), "does-not-exist.pem")
_, err := NewAgent(&AgentConfig{
ServerURL: "https://certctl-server:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
CABundlePath: missingPath,
}, logger)
if err == nil {
t.Fatal("NewAgent err=nil for missing CA bundle path; must fail loud at startup")
}
if !strings.Contains(err.Error(), "reading CA bundle") {
t.Errorf("err=%q must contain \"reading CA bundle\" so operators can trace the cause", err.Error())
}
}
// TestNewAgent_CABundle_EmptyPEM covers the "file exists but contains no
// valid certs" case (garbage, wrong-format, stripped PEM). AppendCertsFromPEM
// returns false in this case; NewAgent must translate that into a fail-loud
// startup error rather than quietly carry on with an empty pool.
func TestNewAgent_CABundle_EmptyPEM(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
bundlePath := filepath.Join(t.TempDir(), "empty.pem")
if err := os.WriteFile(bundlePath, []byte("not a pem-encoded certificate, just garbage\n"), 0644); err != nil {
t.Fatalf("writing garbage bundle: %v", err)
}
_, err := NewAgent(&AgentConfig{
ServerURL: "https://certctl-server:8443",
APIKey: "test-key",
AgentID: "a-test",
Hostname: "test-host",
CABundlePath: bundlePath,
}, logger)
if err == nil {
t.Fatal("NewAgent err=nil for empty-PEM CA bundle; must fail loud at startup")
}
if !strings.Contains(err.Error(), "no valid PEM-encoded certificates") {
t.Errorf("err=%q must contain \"no valid PEM-encoded certificates\" so operators see why the bundle was rejected", err.Error())
}
}
// TestNewAgent_TLSRoundTrip is the end-to-end integration-style check: spin
// up an httptest.NewTLSServer (which presents a self-signed cert over TLS
// 1.3), feed that cert into the agent as a CA bundle, and confirm the agent
// successfully completes a heartbeat round-trip over HTTPS. This proves that
// (a) the CA pool is actually being consulted during verification and (b)
// the TLS 1.3 MinVersion doesn't break against httptest's default
// negotiation. Equivalent to the "TLS handshake succeeds against a
// self-signed control plane" integration gate, but runs in-process with no
// Docker dependency.
func TestNewAgent_TLSRoundTrip(t *testing.T) {
var heartbeatHit int
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/agents/a-tls-test/heartbeat" && r.Method == http.MethodPost {
heartbeatHit++
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
// server.Certificate() returns the *x509.Certificate httptest presents;
// PEM-encode its DER bytes so NewAgent's AppendCertsFromPEM can ingest it.
bundlePath := writeTestCABundle(t, t.TempDir(), server.Certificate().Raw, "httptest-ca.pem")
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
agent, err := NewAgent(&AgentConfig{
ServerURL: server.URL,
APIKey: "test-key",
AgentID: "a-tls-test",
Hostname: "tls-test-host",
CABundlePath: bundlePath,
}, logger)
if err != nil {
t.Fatalf("NewAgent with httptest CA bundle err=%v want nil", err)
}
agent.sendHeartbeat(context.Background())
if heartbeatHit != 1 {
t.Fatalf("heartbeat handler hit %d times; want 1 — the TLS round-trip must actually complete", heartbeatHit)
}
}
+121 -6
View File
@@ -8,6 +8,7 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
@@ -19,6 +20,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
@@ -46,13 +48,15 @@ import (
// AgentConfig represents the agent-side configuration.
type AgentConfig struct {
ServerURL string // Control plane server URL (e.g., http://localhost:8443)
ServerURL string // Control plane server URL (e.g., https://localhost:8443) — must be https:// scheme
APIKey string // Agent API key for authentication
AgentName string // Agent name for identification
AgentID string // Agent ID for API calls (set after registration or from env)
Hostname string // Server hostname
KeyDir string // Directory for storing private keys (default: /var/lib/certctl/keys)
DiscoveryDirs []string // Directories to scan for certificates (comma-separated via env)
CABundlePath string // Optional path to a PEM-encoded CA bundle that signed the server's cert (empty = system roots)
InsecureSkipVerify bool // Dev-only: skip TLS certificate verification. Never enable in production. See docs/tls.md.
}
// ErrAgentRetired is the sentinel returned by [Agent.Run] when the control
@@ -113,16 +117,57 @@ type JobItem struct {
}
// NewAgent creates a new agent instance.
func NewAgent(cfg *AgentConfig, logger *slog.Logger) *Agent {
//
// The returned HTTP client enforces HTTPS-only control-plane access per the
// HTTPS-Everywhere milestone (see docs/tls.md). TLS 1.3 is required; the
// optional CABundlePath loads a PEM bundle into RootCAs so the agent can
// trust internal / self-signed server certs without touching system trust
// stores. InsecureSkipVerify is a dev-only escape hatch — callers must log a
// loud warning when it's set; never enable in production (see §2.4 of the
// milestone spec and docs/upgrade-to-tls.md).
//
// Returns an error if CABundlePath is set but unreadable or malformed — fail
// loud at startup rather than silently fall back to system roots, which would
// turn a misconfigured bundle path into a cryptic "x509: certificate signed
// by unknown authority" on the first heartbeat.
func NewAgent(cfg *AgentConfig, logger *slog.Logger) (*Agent, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS13,
InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // opt-in dev escape hatch, documented in docs/tls.md
}
if cfg.CABundlePath != "" {
pemBytes, err := os.ReadFile(cfg.CABundlePath)
if err != nil {
return nil, fmt.Errorf("reading CA bundle at %q: %w", cfg.CABundlePath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemBytes) {
return nil, fmt.Errorf("CA bundle at %q contains no valid PEM-encoded certificates", cfg.CABundlePath)
}
tlsConfig.RootCAs = pool
}
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,
},
}
return &Agent{
config: cfg,
logger: logger,
client: &http.Client{Timeout: 30 * time.Second},
client: httpClient,
heartbeatInterval: 60 * time.Second,
pollInterval: 30 * time.Second,
discoveryInterval: 6 * time.Hour, // scan for certs every 6 hours
retiredSignal: make(chan struct{}),
}
}, nil
}
// markRetired records that the control plane has declared this agent retired
@@ -1118,12 +1163,14 @@ func certKeyInfo(cert *x509.Certificate) (string, int) {
func main() {
// Parse command-line flags (with env var fallbacks for Docker deployment)
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "http://localhost:8443"), "Control plane server URL")
serverURL := flag.String("server", getEnvDefault("CERTCTL_SERVER_URL", "https://localhost:8443"), "Control plane server URL (must be https://)")
apiKey := flag.String("api-key", getEnvDefault("CERTCTL_API_KEY", ""), "Agent API key")
agentName := flag.String("name", getEnvDefault("CERTCTL_AGENT_NAME", "certctl-agent"), "Agent name")
agentID := flag.String("agent-id", getEnvDefault("CERTCTL_AGENT_ID", ""), "Agent ID (from registration)")
keyDir := flag.String("key-dir", getEnvDefault("CERTCTL_KEY_DIR", "/var/lib/certctl/keys"), "Directory for storing private keys")
discoveryDirsStr := flag.String("discovery-dirs", getEnvDefault("CERTCTL_DISCOVERY_DIRS", ""), "Comma-separated directories to scan for certificates")
caBundlePath := flag.String("ca-bundle", getEnvDefault("CERTCTL_SERVER_CA_BUNDLE_PATH", ""), "Path to a PEM-encoded CA bundle that signed the server's TLS cert (optional; falls back to system roots)")
insecureSkipVerify := flag.Bool("insecure-skip-verify", getEnvBoolDefault("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY", false), "Dev-only: skip TLS certificate verification. Never enable in production. See docs/tls.md.")
flag.Parse()
if *apiKey == "" {
@@ -1137,6 +1184,18 @@ func main() {
os.Exit(1)
}
// Pre-flight URL-scheme validation — reject plaintext http:// before any
// network call. The HTTPS-Everywhere milestone (§2.4, §7) mandates that
// mis-configured agents fail loudly at startup with a diagnostic pointing
// at the upgrade guide, rather than producing a TCP-refused or
// TLS-handshake-error that obscures the actual cause.
if err := validateHTTPSScheme(*serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
os.Exit(1)
}
// Set up structured logging
logLevel := slog.LevelInfo
if getEnvDefault("CERTCTL_LOG_LEVEL", "info") == "debug" {
@@ -1172,10 +1231,20 @@ func main() {
Hostname: hostname,
KeyDir: *keyDir,
DiscoveryDirs: discoveryDirs,
CABundlePath: *caBundlePath,
InsecureSkipVerify: *insecureSkipVerify,
}
if agentCfg.InsecureSkipVerify {
logger.Warn("TLS certificate verification is disabled (CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true) — never enable this in production")
}
// Create and start agent
agent := NewAgent(agentCfg, logger)
agent, err := NewAgent(agentCfg, logger)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to initialize agent: %v\n", err)
os.Exit(1)
}
// Create context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
@@ -1233,3 +1302,49 @@ func getEnvDefault(key, defaultValue string) string {
}
return defaultValue
}
// getEnvBoolDefault parses an environment variable as a boolean. Accepts "1",
// "t", "true", "T", "TRUE", "True" as true; anything else (including empty)
// returns the provided default. Kept permissive on purpose so operators can
// flip the dev-only TLS skip-verify toggle with any common truthy spelling
// without having to remember exactly what we parse.
func getEnvBoolDefault(key string, defaultValue bool) bool {
raw := os.Getenv(key)
if raw == "" {
return defaultValue
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "t", "true", "yes", "on":
return true
case "0", "f", "false", "no", "off":
return false
default:
return defaultValue
}
}
// validateHTTPSScheme enforces the HTTPS-Everywhere milestone's §7 acceptance
// criterion: "Agent with CERTCTL_SERVER_URL=http://... fails at startup with
// a fail-loud diagnostic pointing at docs/upgrade-to-tls.md. Not TCP-refused,
// not TLS-handshake-error — a pre-flight config validation failure before any
// network call." Returns a descriptive error; the caller prints the upgrade
// guide pointer and exits non-zero.
func validateHTTPSScheme(serverURL string) error {
if serverURL == "" {
return fmt.Errorf("CERTCTL_SERVER_URL is empty — set it to an https:// URL (e.g., https://certctl-server:8443)")
}
u, err := url.Parse(serverURL)
if err != nil {
return fmt.Errorf("CERTCTL_SERVER_URL %q is not a valid URL: %w", serverURL, err)
}
switch strings.ToLower(u.Scheme) {
case "https":
return nil
case "http":
return fmt.Errorf("CERTCTL_SERVER_URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
case "":
return fmt.Errorf("CERTCTL_SERVER_URL %q is missing a scheme — expected https://", serverURL)
default:
return fmt.Errorf("CERTCTL_SERVER_URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
}
}
+3 -3
View File
@@ -228,7 +228,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
ServerURL: server.URL,
APIKey: "test-api-key",
}
agent := NewAgent(cfg, nil)
agent, _ := NewAgent(cfg, nil)
result := &VerificationResult{
ExpectedFingerprint: "abc123",
@@ -244,7 +244,7 @@ func TestReportVerificationResult_Success(t *testing.T) {
}
func TestReportVerificationResult_MissingFields(t *testing.T) {
agent := NewAgent(&AgentConfig{}, nil)
agent, _ := NewAgent(&AgentConfig{}, nil)
result := &VerificationResult{
Verified: true,
@@ -343,7 +343,7 @@ func TestReportVerificationResult_ServerError(t *testing.T) {
ServerURL: server.URL,
APIKey: "test-api-key",
}
agent := NewAgent(cfg, nil)
agent, _ := NewAgent(cfg, nil)
result := &VerificationResult{
ExpectedFingerprint: "abc123",
+46 -6
View File
@@ -3,7 +3,9 @@ package main
import (
"flag"
"fmt"
"net/url"
"os"
"strings"
"github.com/shankar0123/certctl/internal/cli"
)
@@ -43,22 +45,34 @@ Commands:
version Show CLI version
Examples:
certctl-cli --server http://localhost:8443 --api-key mykey certs list
certctl-cli --server https://localhost:8443 --api-key mykey certs list
certctl-cli certs renew mc-prod --format json
certctl-cli import certs.pem
`)
}
serverURL := fs.String("server", os.Getenv("CERTCTL_SERVER_URL"), "certctl server URL (env: CERTCTL_SERVER_URL)")
if *serverURL == "" {
*serverURL = "http://localhost:8443"
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL uses
// https://; plaintext http:// is rejected by validateHTTPSScheme below.
defaultServer := os.Getenv("CERTCTL_SERVER_URL")
if defaultServer == "" {
defaultServer = "https://localhost:8443"
}
serverURL := fs.String("server", defaultServer, "certctl server URL — must be https:// (env: CERTCTL_SERVER_URL)")
apiKey := fs.String("api-key", os.Getenv("CERTCTL_API_KEY"), "API key for authentication (env: CERTCTL_API_KEY)")
format := fs.String("format", "table", "Output format: table, json")
caBundlePath := fs.String("ca-bundle", os.Getenv("CERTCTL_SERVER_CA_BUNDLE_PATH"), "Path to a PEM-encoded CA bundle that signed the server cert (env: CERTCTL_SERVER_CA_BUNDLE_PATH)")
insecure := fs.Bool("insecure", strings.EqualFold(os.Getenv("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY"), "true"), "Skip TLS certificate verification — dev only, never set in production (env: CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY)")
fs.Parse(os.Args[1:])
if err := validateHTTPSScheme(*serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
os.Exit(1)
}
args := fs.Args()
if len(args) == 0 {
fs.Usage()
@@ -66,13 +80,16 @@ Examples:
}
// Create client
client := cli.NewClient(*serverURL, *apiKey, *format)
client, err := cli.NewClient(*serverURL, *apiKey, *format, *caBundlePath, *insecure)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Dispatch to appropriate command
command := args[0]
cmdArgs := args[1:]
var err error
switch command {
case "certs":
err = handleCerts(client, cmdArgs)
@@ -237,3 +254,26 @@ func handleImport(client *cli.Client, args []string) error {
func handleStatus(client *cli.Client) error {
return client.GetStatus()
}
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
// 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.
func validateHTTPSScheme(serverURL string) error {
if serverURL == "" {
return fmt.Errorf("server URL is empty — set --server (or CERTCTL_SERVER_URL) to an https:// URL (e.g., https://certctl-server:8443)")
}
u, err := url.Parse(serverURL)
if err != nil {
return fmt.Errorf("server URL %q is not a valid URL: %w", serverURL, err)
}
switch strings.ToLower(u.Scheme) {
case "https":
return nil
case "http":
return fmt.Errorf("server URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
case "":
return fmt.Errorf("server URL %q is missing a scheme — expected https://", serverURL)
default:
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
}
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"strings"
"testing"
)
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
// HTTPS-Everywhere milestone (v2.2, §3.2) requires on the certctl-cli binary
// startup path. The CLI's diagnostic is distinct from the agent and MCP server
// because it surfaces the --server flag alongside CERTCTL_SERVER_URL — so the
// empty-URL case pins that flag-name substring separately. Every other case
// mirrors the dispatch arms in cmd/cli/main.go:validateHTTPSScheme; drifting
// the substrings is what this test is here to catch.
func TestValidateHTTPSScheme(t *testing.T) {
tests := []struct {
name string
serverURL string
wantErr bool
wantErrSub string // substring that MUST appear in the error message
}{
{
name: "https URL passes",
serverURL: "https://certctl-server:8443",
wantErr: false,
},
{
name: "https URL with path passes",
serverURL: "https://certctl.example.com/api/v1",
wantErr: false,
},
{
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
serverURL: "HTTPS://certctl-server:8443",
wantErr: false,
},
{
name: "empty URL rejected mentions --server flag",
serverURL: "",
wantErr: true,
wantErrSub: "--server",
},
{
name: "empty URL rejected also mentions CERTCTL_SERVER_URL",
serverURL: "",
wantErr: true,
wantErrSub: "CERTCTL_SERVER_URL",
},
{
name: "plaintext http rejected",
serverURL: "http://certctl-server:8443",
wantErr: true,
wantErrSub: "plaintext http://",
},
{
name: "bare host missing scheme rejected",
serverURL: "localhost:8443",
wantErr: true,
// url.Parse treats "localhost:8443" as scheme=localhost, opaque=8443
// — exercises the default arm (unsupported scheme) rather than the
// empty-scheme arm. Both are fail-closed, which is what we care about.
wantErrSub: "unsupported scheme",
},
{
name: "path-only URL rejected",
serverURL: "//certctl-server:8443",
wantErr: true,
wantErrSub: "missing a scheme",
},
{
name: "unsupported scheme rejected",
serverURL: "ftp://certctl-server:8443",
wantErr: true,
wantErrSub: "unsupported scheme",
},
{
name: "ws scheme rejected",
serverURL: "ws://certctl-server:8443",
wantErr: true,
wantErrSub: "unsupported scheme",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateHTTPSScheme(tt.serverURL)
if (err != nil) != tt.wantErr {
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
}
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
tt.serverURL, err.Error(), tt.wantErrSub)
}
})
}
}
+46 -2
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"strings"
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -16,14 +18,33 @@ import (
var Version = "dev"
func main() {
// HTTPS-Everywhere (v2.2): the server is HTTPS-only. The default URL
// uses https://; plaintext http:// is rejected by validateHTTPSScheme
// below with a fail-loud pre-flight diagnostic pointing at
// docs/upgrade-to-tls.md, so operators never get a TCP-refused or
// TLS-handshake-error downstream. See docs/tls.md for CA bundle and
// insecure-skip-verify guidance.
serverURL := os.Getenv("CERTCTL_SERVER_URL")
if serverURL == "" {
serverURL = "http://localhost:8443"
serverURL = "https://localhost:8443"
}
if err := validateHTTPSScheme(serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintf(os.Stderr, "\nThe certctl control plane is HTTPS-only as of v2.2.\n")
fmt.Fprintf(os.Stderr, "See docs/upgrade-to-tls.md for the cutover walkthrough.\n")
os.Exit(1)
}
apiKey := os.Getenv("CERTCTL_API_KEY")
caBundlePath := os.Getenv("CERTCTL_SERVER_CA_BUNDLE_PATH")
insecure := strings.EqualFold(os.Getenv("CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY"), "true")
client := mcp.NewClient(serverURL, apiKey)
client, err := mcp.NewClient(serverURL, apiKey, caBundlePath, insecure)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
server := gomcp.NewServer(&gomcp.Implementation{
Name: "certctl",
@@ -41,3 +62,26 @@ func main() {
log.Fatalf("MCP server error: %v", err)
}
}
// validateHTTPSScheme rejects plaintext and empty-scheme server URLs at
// 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.
func validateHTTPSScheme(serverURL string) error {
if serverURL == "" {
return fmt.Errorf("server URL is empty — set CERTCTL_SERVER_URL to an https:// URL (e.g., https://certctl-server:8443)")
}
u, err := url.Parse(serverURL)
if err != nil {
return fmt.Errorf("server URL %q is not a valid URL: %w", serverURL, err)
}
switch strings.ToLower(u.Scheme) {
case "https":
return nil
case "http":
return fmt.Errorf("server URL %q uses plaintext http:// — the certctl control plane is HTTPS-only", serverURL)
case "":
return fmt.Errorf("server URL %q is missing a scheme — expected https://", serverURL)
default:
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
}
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"strings"
"testing"
)
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
// HTTPS-Everywhere milestone (v2.2, §3.2) requires on the MCP server binary
// startup path. The whole point is to fail loud with a diagnostic that points
// at docs/upgrade-to-tls.md *before* any network call — not a cryptic
// TCP-refused or TLS-handshake-error two ticks later. Every case here mirrors
// the dispatch arms in cmd/mcp-server/main.go:validateHTTPSScheme; drifting
// the error-message substrings is what this test is here to catch.
func TestValidateHTTPSScheme(t *testing.T) {
tests := []struct {
name string
serverURL string
wantErr bool
wantErrSub string // substring that MUST appear in the error message
}{
{
name: "https URL passes",
serverURL: "https://certctl-server:8443",
wantErr: false,
},
{
name: "https URL with path passes",
serverURL: "https://certctl.example.com/api/v1",
wantErr: false,
},
{
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
serverURL: "HTTPS://certctl-server:8443",
wantErr: false,
},
{
name: "empty URL rejected",
serverURL: "",
wantErr: true,
wantErrSub: "server URL is empty",
},
{
name: "plaintext http rejected",
serverURL: "http://certctl-server:8443",
wantErr: true,
wantErrSub: "plaintext http://",
},
{
name: "bare host missing scheme rejected",
serverURL: "localhost:8443",
wantErr: true,
// url.Parse treats "localhost:8443" as scheme=localhost, opaque=8443
// — exercises the default arm (unsupported scheme) rather than the
// empty-scheme arm. Both are fail-closed, which is what we care about.
wantErrSub: "unsupported scheme",
},
{
name: "path-only URL rejected",
serverURL: "//certctl-server:8443",
wantErr: true,
wantErrSub: "missing a scheme",
},
{
name: "unsupported scheme rejected",
serverURL: "ftp://certctl-server:8443",
wantErr: true,
wantErrSub: "unsupported scheme",
},
{
name: "ws scheme rejected",
serverURL: "ws://certctl-server:8443",
wantErr: true,
wantErrSub: "unsupported scheme",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateHTTPSScheme(tt.serverURL)
if (err != nil) != tt.wantErr {
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
}
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
tt.serverURL, err.Error(), tt.wantErrSub)
}
})
}
}
+38 -6
View File
@@ -736,22 +736,54 @@ func main() {
logger.Info("dashboard directory not found, serving API only")
}
// HTTPS-everywhere milestone §2.1: fail-loud if the TLS configuration is
// missing or malformed. Duplicates config.Validate() for defense in depth
// (same pattern as preflightSCEPChallengePassword).
if err := preflightServerTLS(cfg.Server.TLS.CertPath, cfg.Server.TLS.KeyPath); err != nil {
logger.Error("startup refused: HTTPS cert unusable; control plane is HTTPS-only",
"error", err,
"cert_path", cfg.Server.TLS.CertPath,
"key_path", cfg.Server.TLS.KeyPath)
os.Exit(1)
}
// Load the cert+key into a SIGHUP-reloadable holder. Any subsequent
// SIGHUP triggers a fresh read and atomic swap so rotations do not need
// a restart. Reload failures keep the previous cert and log a warning.
tlsCertHolder, err := newCertHolder(cfg.Server.TLS.CertPath, cfg.Server.TLS.KeyPath)
if err != nil {
logger.Error("startup refused: failed to load TLS cert holder",
"error", err,
"cert_path", cfg.Server.TLS.CertPath,
"key_path", cfg.Server.TLS.KeyPath)
os.Exit(1)
}
stopTLSWatcher := tlsCertHolder.watchSIGHUP(logger)
defer stopTLSWatcher()
// Server configuration
addr := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
httpServer := &http.Server{
Addr: addr,
Handler: finalHandler,
TLSConfig: buildServerTLSConfig(tlsCertHolder),
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 120 * time.Second, // Must accommodate ACME issuance (order + challenge + finalize)
IdleTimeout: 60 * time.Second,
}
// Start HTTP server in background
logger.Info("starting HTTP server", "address", addr)
// Start HTTPS server in background. ListenAndServeTLS is called with
// empty cert+key arguments because the cert is sourced through
// TLSConfig.GetCertificate (the SIGHUP-reloadable holder). Passing file
// paths here would pin the first-loaded cert and defeat hot reload.
logger.Info("HTTPS server listening",
"address", addr,
"cert_path", cfg.Server.TLS.CertPath,
"min_version", "TLS1.3")
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP server error", "error", err)
if err := httpServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
logger.Error("HTTPS server error", "error", err)
}
}()
@@ -774,9 +806,9 @@ func main() {
logger.Warn("scheduler work did not complete in time", "error", err)
}
logger.Info("shutting down HTTP server")
logger.Info("shutting down HTTPS server")
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server shutdown error", "error", err)
logger.Error("HTTPS server shutdown error", "error", err)
}
// Drain in-flight audit-recording goroutines before closing the DB pool.
+44
View File
@@ -214,6 +214,8 @@ func TestMain_ServerConfigFromEnvironment(t *testing.T) {
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
oldServerHost := os.Getenv("CERTCTL_SERVER_HOST")
oldServerPort := os.Getenv("CERTCTL_SERVER_PORT")
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
defer func() {
if oldAuthType != "" {
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
@@ -230,12 +232,32 @@ func TestMain_ServerConfigFromEnvironment(t *testing.T) {
} else {
os.Unsetenv("CERTCTL_SERVER_PORT")
}
if oldTLSCert != "" {
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", oldTLSCert)
} else {
os.Unsetenv("CERTCTL_SERVER_TLS_CERT_PATH")
}
if oldTLSKey != "" {
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", oldTLSKey)
} else {
os.Unsetenv("CERTCTL_SERVER_TLS_KEY_PATH")
}
}()
// HTTPS-only control plane: Validate() refuses to pass without a readable
// cert/key pair on disk. Materialize a throwaway ECDSA P-256 pair using the
// same generator cmd/server/tls_test.go uses for the certHolder tests.
dir := t.TempDir()
certPath := dir + "/server.crt"
keyPath := dir + "/server.key"
generateTestCert(t, certPath, keyPath, "main-test-cn")
// Set test env vars
os.Setenv("CERTCTL_AUTH_TYPE", "none")
os.Setenv("CERTCTL_SERVER_HOST", "127.0.0.1")
os.Setenv("CERTCTL_SERVER_PORT", "8080")
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
cfg, err := config.Load()
if err != nil {
@@ -260,6 +282,8 @@ func TestMain_AuthTypeConfiguration(t *testing.T) {
// Save original env vars
oldAuthType := os.Getenv("CERTCTL_AUTH_TYPE")
oldAuthSecret := os.Getenv("CERTCTL_AUTH_SECRET")
oldTLSCert := os.Getenv("CERTCTL_SERVER_TLS_CERT_PATH")
oldTLSKey := os.Getenv("CERTCTL_SERVER_TLS_KEY_PATH")
defer func() {
if oldAuthType != "" {
os.Setenv("CERTCTL_AUTH_TYPE", oldAuthType)
@@ -271,8 +295,28 @@ func TestMain_AuthTypeConfiguration(t *testing.T) {
} else {
os.Unsetenv("CERTCTL_AUTH_SECRET")
}
if oldTLSCert != "" {
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", oldTLSCert)
} else {
os.Unsetenv("CERTCTL_SERVER_TLS_CERT_PATH")
}
if oldTLSKey != "" {
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", oldTLSKey)
} else {
os.Unsetenv("CERTCTL_SERVER_TLS_KEY_PATH")
}
}()
// HTTPS-only control plane: config.Load()→Validate() refuses to pass
// without a readable cert/key pair. Mint one throwaway pair for the whole
// sub-test cohort — auth type toggles don't care about the TLS surface.
dir := t.TempDir()
certPath := dir + "/server.crt"
keyPath := dir + "/server.key"
generateTestCert(t, certPath, keyPath, "main-test-cn")
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
// Set auth secret for api-key mode
os.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"crypto/tls"
"fmt"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
)
// certHolder stores the server's TLS certificate under a mutex so it can be
// swapped atomically by a SIGHUP handler without restarting the server. A
// *tls.Config that wires GetCertificate → (*certHolder).GetCertificate reads
// through the holder on every ClientHello, so a successful reload takes
// effect on the next new connection immediately and without dropping
// in-flight requests.
//
// Concurrency: GetCertificate is invoked from crypto/tls handshake goroutines
// on every new inbound connection; Reload is invoked from the SIGHUP watcher
// goroutine. sync.Mutex is sufficient — TLS handshakes are not an inner-loop
// hot path and the critical section is a single pointer read.
type certHolder struct {
mu sync.Mutex
cert *tls.Certificate
certPath string
keyPath string
}
// newCertHolder loads the initial cert+key pair from disk and returns a
// holder ready to serve handshakes. Returns a non-nil error if either file
// is missing, unreadable, or the pair does not round-trip through
// tls.LoadX509KeyPair (for example the key does not sign the cert). The
// caller is expected to treat a non-nil error as a fail-loud startup gate
// and os.Exit(1) — the HTTPS-everywhere milestone (§3 locked decisions)
// prohibits plaintext HTTP fallback.
func newCertHolder(certPath, keyPath string) (*certHolder, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("load TLS cert/key (cert=%q key=%q): %w", certPath, keyPath, err)
}
return &certHolder{
cert: &cert,
certPath: certPath,
keyPath: keyPath,
}, nil
}
// GetCertificate is the tls.Config.GetCertificate hook. Returns the current
// cert under the holder's mutex. ClientHelloInfo is ignored — the control
// plane does not multiplex by SNI.
func (h *certHolder) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
h.mu.Lock()
defer h.mu.Unlock()
return h.cert, nil
}
// Reload re-reads the cert+key pair from disk and swaps the holder
// atomically on success. On failure the holder retains its previous cert
// and the error is propagated to the caller — the SIGHUP watcher logs and
// keeps serving the previous cert rather than crashing on a bad reload.
// This is deliberately "fail-safe on reload, fail-loud on startup": an
// operator rotating certs wants a recoverable error, not a restart loop.
func (h *certHolder) Reload() error {
cert, err := tls.LoadX509KeyPair(h.certPath, h.keyPath)
if err != nil {
return fmt.Errorf("reload TLS cert/key (cert=%q key=%q): %w", h.certPath, h.keyPath, err)
}
h.mu.Lock()
h.cert = &cert
h.mu.Unlock()
return nil
}
// watchSIGHUP installs a signal handler that calls Reload() on each SIGHUP.
// The returned stop function closes the internal done channel and stops
// signal delivery so the goroutine can exit cleanly during shutdown. Errors
// from Reload are logged but do not terminate the watcher — the operator
// can fix the files and send another SIGHUP.
//
// Defensive design note: this deliberately does NOT panic on Reload error
// even though HTTPS is mission-critical. A rotation that writes half-files
// (operator overwrites cert.pem then key.pem as two separate copies) would
// otherwise crash the server mid-rotation. Logging + retaining the old
// cert gives the operator a bounded window to fix and re-SIGHUP.
func (h *certHolder) watchSIGHUP(logger *slog.Logger) (stop func()) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGHUP)
done := make(chan struct{})
go func() {
for {
select {
case <-ch:
if err := h.Reload(); err != nil {
logger.Error("TLS cert reload failed; continuing with previous cert",
"error", err,
"cert_path", h.certPath,
"key_path", h.keyPath)
continue
}
logger.Info("TLS cert reloaded via SIGHUP",
"cert_path", h.certPath,
"key_path", h.keyPath)
case <-done:
signal.Stop(ch)
return
}
}
}()
return func() { close(done) }
}
// buildServerTLSConfig returns the TLS 1.3-only *tls.Config for the HTTPS
// server. Pinned per HTTPS-everywhere milestone §2.1 + §3 locked decisions:
//
// - MinVersion: TLS 1.3 (no TLS 1.2 escape hatch). Go 1.25's crypto/tls
// automatically rejects older versions.
// - CurvePreferences: explicit [X25519, P-256]. Explicit ordering keeps
// the handshake deterministic and documents the accepted curves.
// - No CipherSuites field: TLS 1.3 cipher suites are not negotiable in
// the handshake (all three mandatory suites — AES-128-GCM-SHA256,
// AES-256-GCM-SHA384, CHACHA20-POLY1305-SHA256 — are always offered).
// Go's crypto/tls ignores CipherSuites for TLS 1.3.
// - GetCertificate: reads through the holder so SIGHUP rotations take
// effect on the next new connection without a restart. Setting
// tls.Config.Certificates directly would pin the first-loaded cert
// and defeat SIGHUP reload.
func buildServerTLSConfig(holder *certHolder) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
GetCertificate: holder.GetCertificate,
}
}
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
// non-nil error when the TLS configuration is missing or the cert+key pair
// cannot be parsed, so the caller refuses to start the control plane
// (HTTPS-everywhere §3 locked decisions: no plaintext HTTP fallback).
//
// Duplicates the emptiness + stat + parse checks in config.Validate() for
// defense in depth, mirroring the pattern established by
// preflightSCEPChallengePassword (which itself duplicates
// config.Validate()'s SCEP check for CWE-306). Extracted into a separate
// function so the gate is unit-testable without booting the full server.
func preflightServerTLS(certPath, keyPath string) error {
if certPath == "" {
return fmt.Errorf("CERTCTL_SERVER_TLS_CERT_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
}
if keyPath == "" {
return fmt.Errorf("CERTCTL_SERVER_TLS_KEY_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
}
if _, err := os.Stat(certPath); err != nil {
return fmt.Errorf("TLS cert file %q unreadable: %w (see docs/tls.md)", certPath, err)
}
if _, err := os.Stat(keyPath); err != nil {
return fmt.Errorf("TLS key file %q unreadable: %w (see docs/tls.md)", keyPath, err)
}
if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil {
return fmt.Errorf("TLS cert/key pair invalid (cert=%q key=%q): %w (see docs/tls.md)", certPath, keyPath, err)
}
return nil
}
+418
View File
@@ -0,0 +1,418 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"io"
"log/slog"
"math/big"
"net"
"os"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
)
// generateTestCert writes a PEM-encoded self-signed leaf cert + ECDSA P-256
// key pair to certPath/keyPath. The subject is derived from cn so tests can
// tell reloaded certs apart from original certs by re-parsing the served
// Certificate and comparing the CN.
func generateTestCert(t *testing.T, certPath, keyPath, cn string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("MarshalECPrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
}
// readCertCN returns the CommonName from the leaf cert currently held by the
// holder, by exercising the same GetCertificate path the tls handshake would
// take. Lets tests assert which generation of the cert is being served.
func readCertCN(t *testing.T, h *certHolder) string {
t.Helper()
c, err := h.GetCertificate(&tls.ClientHelloInfo{})
if err != nil {
t.Fatalf("GetCertificate: %v", err)
}
leaf, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
t.Fatalf("ParseCertificate: %v", err)
}
return leaf.Subject.CommonName
}
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
func TestNewCertHolder_ValidPair_LoadsCert(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-initial")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
if got := readCertCN(t, h); got != "cn-initial" {
t.Fatalf("CN mismatch: got %q want %q", got, "cn-initial")
}
}
func TestNewCertHolder_MissingFile_Fails(t *testing.T) {
_, err := newCertHolder("/nonexistent/cert.pem", "/nonexistent/key.pem")
if err == nil {
t.Fatal("expected error for missing files, got nil")
}
}
func TestNewCertHolder_MalformedCert_Fails(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "bad.crt")
keyPath := filepath.Join(dir, "bad.key")
if err := os.WriteFile(certPath, []byte("not a pem cert"), 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, []byte("not a pem key"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
_, err := newCertHolder(certPath, keyPath)
if err == nil {
t.Fatal("expected error for malformed PEM, got nil")
}
}
func TestCertHolder_Reload_SwapsCert(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-v1")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
if got := readCertCN(t, h); got != "cn-v1" {
t.Fatalf("initial CN: got %q want cn-v1", got)
}
// Rotate on disk and reload.
generateTestCert(t, certPath, keyPath, "cn-v2")
if err := h.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if got := readCertCN(t, h); got != "cn-v2" {
t.Fatalf("post-reload CN: got %q want cn-v2", got)
}
}
func TestCertHolder_Reload_FailureRetainsPreviousCert(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-v1")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
// Corrupt the cert file and attempt reload.
if err := os.WriteFile(certPath, []byte("garbage"), 0o600); err != nil {
t.Fatalf("corrupt cert: %v", err)
}
if err := h.Reload(); err == nil {
t.Fatal("expected Reload error for corrupt file, got nil")
}
// Holder should still serve the v1 cert.
if got := readCertCN(t, h); got != "cn-v1" {
t.Fatalf("post-failed-reload CN: got %q want cn-v1 (reload must not clobber on failure)", got)
}
}
func TestCertHolder_GetCertificate_Concurrent(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-concurrent")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
// 64 readers + 1 rotator for 500ms. Race detector catches any unsynchronized
// swap of h.cert. Rotator writes fresh files + Reload, readers call
// GetCertificate in a tight loop.
var wg sync.WaitGroup
done := make(chan struct{})
const readers = 64
for i := 0; i < readers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
if _, err := h.GetCertificate(&tls.ClientHelloInfo{}); err != nil {
t.Errorf("GetCertificate: %v", err)
return
}
}
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 20; i++ {
generateTestCert(t, certPath, keyPath, "cn-concurrent")
_ = h.Reload()
time.Sleep(10 * time.Millisecond)
}
}()
time.Sleep(300 * time.Millisecond)
close(done)
wg.Wait()
}
func TestCertHolder_WatchSIGHUP_ReloadsOnSignal(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-before-sighup")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
stop := h.watchSIGHUP(silentLogger())
defer stop()
// Rotate on disk, then fire SIGHUP to our own process and poll for the swap.
generateTestCert(t, certPath, keyPath, "cn-after-sighup")
if err := syscall.Kill(syscall.Getpid(), syscall.SIGHUP); err != nil {
t.Fatalf("SIGHUP: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if readCertCN(t, h) == "cn-after-sighup" {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("watcher did not reload cert within 2s (CN still %q)", readCertCN(t, h))
}
func TestCertHolder_WatchSIGHUP_StopExits(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-stop")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
stop := h.watchSIGHUP(silentLogger())
// Closing should be synchronous and safe; a subsequent SIGHUP must not
// cause a reload (the watcher goroutine is gone).
stop()
time.Sleep(50 * time.Millisecond) // let goroutine exit
// After stop, the signal may still be delivered to the process but the
// watcher has called signal.Stop so this channel is no longer receiving.
// Simply assert that calling stop() twice does not panic — the goroutine
// has already exited, so a second close would panic on the `done`
// channel; we do NOT call stop twice. Instead verify no regression in
// the held cert.
if got := readCertCN(t, h); got != "cn-stop" {
t.Fatalf("unexpected cert rotation after stop: got %q want cn-stop", got)
}
}
func TestBuildServerTLSConfig_IsTLS13Only(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-cfg")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
cfg := buildServerTLSConfig(h)
if cfg.MinVersion != tls.VersionTLS13 {
t.Fatalf("MinVersion: got %#x want %#x (TLS 1.3)", cfg.MinVersion, tls.VersionTLS13)
}
wantCurves := []tls.CurveID{tls.X25519, tls.CurveP256}
if len(cfg.CurvePreferences) != len(wantCurves) {
t.Fatalf("CurvePreferences length: got %d want %d", len(cfg.CurvePreferences), len(wantCurves))
}
for i, c := range cfg.CurvePreferences {
if c != wantCurves[i] {
t.Fatalf("CurvePreferences[%d]: got %v want %v", i, c, wantCurves[i])
}
}
if cfg.GetCertificate == nil {
t.Fatal("GetCertificate: nil (holder not wired; SIGHUP reload would be broken)")
}
if len(cfg.Certificates) != 0 {
t.Fatalf("Certificates: got %d want 0 (static cert would pin the first load and defeat reload)", len(cfg.Certificates))
}
}
func TestBuildServerTLSConfig_Handshake_TLS12Rejected(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-handshake")
h, err := newCertHolder(certPath, keyPath)
if err != nil {
t.Fatalf("newCertHolder: %v", err)
}
serverCfg := buildServerTLSConfig(h)
ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
if err != nil {
t.Fatalf("tls.Listen: %v", err)
}
defer ln.Close()
// Server loop: accept and immediately close (we only care about the
// handshake outcome).
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
// Force handshake so the server-side error surfaces.
_ = conn.(*tls.Conn).Handshake()
conn.Close()
}
}()
// TLS 1.3 client — should succeed.
clientOK := &tls.Config{
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
InsecureSkipVerify: true,
}
c, err := tls.Dial("tcp", ln.Addr().String(), clientOK)
if err != nil {
t.Fatalf("TLS 1.3 dial failed (expected success): %v", err)
}
if c.ConnectionState().Version != tls.VersionTLS13 {
t.Fatalf("negotiated version: got %#x want TLS 1.3 (%#x)", c.ConnectionState().Version, tls.VersionTLS13)
}
c.Close()
// TLS 1.2 client — must be rejected at handshake.
clientOld := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
}
if _, err := tls.Dial("tcp", ln.Addr().String(), clientOld); err == nil {
t.Fatal("TLS 1.2 dial succeeded; HTTPS-everywhere requires server to refuse TLS 1.2")
}
}
func TestPreflightServerTLS_MissingCertPath(t *testing.T) {
err := preflightServerTLS("", "/any/key.pem")
if err == nil {
t.Fatal("expected error for empty cert path, got nil")
}
}
func TestPreflightServerTLS_MissingKeyPath(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-preflight")
err := preflightServerTLS(certPath, "")
if err == nil {
t.Fatal("expected error for empty key path, got nil")
}
}
func TestPreflightServerTLS_CertFileNotReadable(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "tls.key")
if err := os.WriteFile(keyPath, []byte("k"), 0o600); err != nil {
t.Fatal(err)
}
err := preflightServerTLS(filepath.Join(dir, "nope.crt"), keyPath)
if err == nil {
t.Fatal("expected error for unreadable cert path, got nil")
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected os.ErrNotExist wrapped in error chain, got: %v", err)
}
}
func TestPreflightServerTLS_InvalidKeyPair(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
// Pair of valid cert + garbage key — files are readable but the pair
// doesn't round-trip tls.LoadX509KeyPair.
generateTestCert(t, certPath, keyPath, "cn-bad-pair")
if err := os.WriteFile(keyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nBAD\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
t.Fatal(err)
}
err := preflightServerTLS(certPath, keyPath)
if err == nil {
t.Fatal("expected error for invalid key pair, got nil")
}
}
func TestPreflightServerTLS_ValidPair_NoError(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
generateTestCert(t, certPath, keyPath, "cn-ok")
if err := preflightServerTLS(certPath, keyPath); err != nil {
t.Fatalf("unexpected error for valid pair: %v", err)
}
}
+8 -5
View File
@@ -55,7 +55,7 @@ A compose file defines **services** (containers), **networks** (how they talk to
**Overlay files** let you layer changes. Running `docker compose -f base.yml -f overlay.yml up` merges both files. The overlay can add services, change environment variables, or mount extra volumes without editing the base.
**Port mapping** (`"8443:8443"`) maps host port (left) to container port (right). After startup, `http://localhost:8443` on your machine reaches the certctl server inside its container.
**Port mapping** (`"8443:8443"`) maps host port (left) to container port (right). After startup, `https://localhost:8443` on your machine reaches the certctl server inside its container (HTTPS-only as of v2.2; the `certctl-tls-init` init container bootstraps a self-signed cert into `deploy/test/certs/`).
---
@@ -91,11 +91,13 @@ Wait about 30 seconds, then verify:
docker compose -f deploy/docker-compose.yml ps
# All three services should show "Up (healthy)"
curl http://localhost:8443/health
curl --cacert ./deploy/test/certs/ca.crt https://localhost:8443/health
# {"status":"healthy"}
```
Open **http://localhost:8443** in your browser. You'll see the onboarding wizard guiding you through: connecting a CA, deploying an agent, and adding your first certificate.
The control plane is HTTPS-only as of v2.2. The `certctl-tls-init` init container bootstraps a self-signed cert into `deploy/test/certs/` on first boot; pin it with `--cacert` (as above) or pass `-k` for one-off smoke tests (never in production).
Open **https://localhost:8443** in your browser. You'll see the onboarding wizard guiding you through: connecting a CA, deploying an agent, and adding your first certificate. Your browser will flag the self-signed cert as untrusted — accept the warning for local evaluation, or import `deploy/test/certs/ca.crt` into your OS trust store to make the warning go away.
### Service-by-service walkthrough
@@ -307,8 +309,9 @@ docker compose -f deploy/docker-compose.test.yml up --build
Wait for all health checks to pass (about 60 seconds for step-ca's first-run bootstrap). Then:
```bash
# Dashboard with auth enabled
open http://localhost:8443
# Dashboard with auth enabled (HTTPS-only as of v2.2; browser will warn on the self-signed cert —
# accept the warning or trust `deploy/test/certs/ca.crt` in your OS keychain)
open https://localhost:8443
# API key: test-key-2026
# NGINX serving a self-signed placeholder
+100 -5
View File
@@ -4,8 +4,12 @@
#
# Spins up the full certctl platform with real CA backends for manual QA:
#
# 0. certctl-tls-init — one-shot init container; writes self-signed
# server.crt/.key/ca.crt into ./test/certs (bind
# mount, not a named volume — host-readable for
# the Go integration test binary)
# 1. PostgreSQL 16 — database (clean, no demo data)
# 2. certctl-server — control plane API + web dashboard on :8443
# 2. certctl-server — control plane API + web dashboard on :8443 (HTTPS)
# 3. certctl-agent — polls for work, deploys certs to NGINX
# 4. step-ca — private CA (JWK provisioner, auto-bootstraps)
# 5. Pebble — ACME test server (simulates Let's Encrypt)
@@ -16,15 +20,74 @@
# cd deploy
# docker compose -f docker-compose.test.yml up --build
#
# Dashboard: http://localhost:8443
# Dashboard: https://localhost:8443 (self-signed — use --cacert test/certs/ca.crt)
# API key: test-key-2026
# NGINX: https://localhost:8444 (self-signed placeholder until cert deployed)
#
# Integration tests: `go test -tags integration ./deploy/test/...` picks up
# the CA bundle at ./test/certs/ca.crt automatically via CERTCTL_TEST_CA_BUNDLE.
#
# See docs/test-env.md for the full walkthrough.
# =============================================================================
services:
# ---------------------------------------------------------------------------
# HTTPS-Everywhere Phase 6 — self-signed TLS bootstrap for the test harness.
# ---------------------------------------------------------------------------
# Mirrors the production `certctl-tls-init` (see docker-compose.yml §10-43)
# but writes into a *host bind mount* (./test/certs) instead of a named
# volume. The named-volume approach works fine inside Docker but hides the
# CA bundle from the Go integration test binary that runs on the host; the
# bind mount exposes /etc/certctl/tls/ca.crt at deploy/test/certs/ca.crt
# so `newTestClient()` can load it into an x509.CertPool and validate the
# self-signed server cert. Test-only divergence, explicitly documented.
#
# The generated cert has SAN=DNS:certctl-server,DNS:localhost,IP:127.0.0.1
# so both in-cluster traffic (agent → certctl-server:8443) and host traffic
# (go test → localhost:8443) validate cleanly. Destroy via
# `docker compose -f docker-compose.test.yml down -v` + `rm -rf test/certs`
# to force regeneration. Keys written 0600, certs 0644, owned 1000:1000
# (the UID the server binary runs as inside its container per Dockerfile:64).
certctl-tls-init:
image: alpine/openssl:latest
container_name: certctl-test-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/etc/certctl/tls/server.crt
KEY=/etc/certctl/tls/server.key
CA=/etc/certctl/tls/ca.crt
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
echo "TLS cert already present at $$CERT — skipping generation"
else
mkdir -p /etc/certctl/tls
openssl req -x509 -newkey ed25519 -nodes \
-keyout "$$KEY" \
-out "$$CERT" \
-days 3650 \
-subj "/CN=certctl-server" \
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
cp "$$CERT" "$$CA"
echo "Generated self-signed TLS cert for certctl-test-server (ed25519, 3650d, CN=certctl-server)"
fi
# The test server container runs as root (see `user: "0:0"` below)
# because setup-trust.sh needs to update the system trust store, so
# the perms here are really about host-side readability — 0644 on
# the CA/cert lets `go test` on the host read the bundle without a
# chown dance.
chown 1000:1000 "$$CERT" "$$KEY" "$$CA" || true
chmod 0644 "$$CERT" "$$CA"
chmod 0600 "$$KEY"
volumes:
- ./test/certs:/etc/certctl/tls
networks:
certctl-test:
ipv4_address: 10.30.50.9
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
@@ -168,6 +231,12 @@ services:
condition: service_started
step-ca:
condition: service_healthy
# HTTPS-Everywhere Phase 6: block server boot until the init container
# has written server.crt / server.key / ca.crt into ./test/certs. The
# init container runs once and exits 0; service_completed_successfully
# makes that a gating dependency rather than a liveness one.
certctl-tls-init:
condition: service_completed_successfully
# Run as root so update-ca-certificates can write to /etc/ssl/certs.
# Container isolation provides the security boundary.
user: "0:0"
@@ -179,6 +248,12 @@ services:
# Server
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
# HTTPS-Everywhere Phase 6: point the server at the init-container-generated
# cert/key pair (bind-mounted from ./test/certs). Same paths as production
# compose so the server binary code path is identical; only the host-side
# storage differs (bind mount vs named volume — see §certctl-tls-init block).
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: debug
# Auth — API key required (production-like)
@@ -224,12 +299,22 @@ services:
- ./test/setup-trust.sh:/app/setup-trust.sh:ro
# step-ca data volume (root cert at /certs/root_ca.crt, key at /secrets/provisioner_key)
- stepca_data:/stepca-data:ro
# HTTPS-Everywhere Phase 6: read-only bind mount of the init-generated
# TLS material. The init container writes here; server reads here; the
# agent mounts the same host path at the same container path (see below)
# so /etc/certctl/tls/ca.crt resolves to the *same* bytes on both sides.
- ./test/certs:/etc/certctl/tls:ro
networks:
certctl-test:
ipv4_address: 10.30.50.6
healthcheck:
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the Bearer token
test: ["CMD", "curl", "-f", "-H", "Authorization: Bearer test-key-2026", "http://localhost:8443/health"]
# HTTPS-Everywhere Phase 6: healthcheck now speaks TLS with --cacert to
# verify the self-signed server cert against the init-generated bundle.
# /health requires auth when CERTCTL_AUTH_TYPE=api-key, so include the
# Bearer token. curl exits non-zero on both TLS handshake failure and
# non-2xx status — either failure keeps depends_on: {condition:
# service_healthy} from unblocking the agent, which is what we want.
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "-H", "Authorization: Bearer test-key-2026", "https://localhost:8443/health"]
interval: 10s
timeout: 5s
start_period: 30s
@@ -290,7 +375,13 @@ services:
certctl-server:
condition: service_healthy
environment:
CERTCTL_SERVER_URL: http://certctl-server:8443
# HTTPS-Everywhere Phase 6: agent dials the server over TLS and validates
# the self-signed cert against the CA bundle pinned by
# CERTCTL_SERVER_CA_BUNDLE_PATH. Same env vars + container paths as
# production compose so the agent binary code path (loadCABundle →
# x509.CertPool → *tls.Config{RootCAs, MinVersion: TLS13}) is identical.
CERTCTL_SERVER_URL: https://certctl-server:8443
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
CERTCTL_API_KEY: test-key-2026
CERTCTL_AGENT_NAME: test-agent-01
CERTCTL_AGENT_ID: agent-test-01
@@ -300,6 +391,10 @@ services:
volumes:
- agent_keys:/var/lib/certctl/keys
- nginx_certs:/nginx-certs
# HTTPS-Everywhere Phase 6: same bind mount as the server, same path,
# so /etc/certctl/tls/ca.crt resolves to the identical bytes. This is
# the only way the CN=certctl-server cert validates on the agent side.
- ./test/certs:/etc/certctl/tls:ro
networks:
certctl-test:
ipv4_address: 10.30.50.8
+55 -2
View File
@@ -1,4 +1,47 @@
services:
# HTTPS-Everywhere Phase 3 — self-signed TLS bootstrap (init container).
# Generates a CN=certctl-server ed25519 cert with the SAN list locked by
# milestone §3.6 on first boot; subsequent boots see the cert already
# present in the `certs` named volume and no-op out. Server + agent mount
# the volume read-only. Destroy via `docker compose down -v` to force
# regeneration. This bootstrap is for docker-compose demos and local dev
# only; Helm operators supply a Secret / cert-manager Certificate per
# docs/tls.md.
certctl-tls-init:
image: alpine/openssl:latest
container_name: certctl-tls-init
restart: "no"
entrypoint: /bin/sh
command:
- -c
- |
set -eu
CERT=/etc/certctl/tls/server.crt
KEY=/etc/certctl/tls/server.key
CA=/etc/certctl/tls/ca.crt
if [ -f "$$CERT" ] && [ -f "$$KEY" ] && [ -f "$$CA" ]; then
echo "TLS cert already present at $$CERT — skipping generation"
else
mkdir -p /etc/certctl/tls
openssl req -x509 -newkey ed25519 -nodes \
-keyout "$$KEY" \
-out "$$CERT" \
-days 3650 \
-subj "/CN=certctl-server" \
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
cp "$$CERT" "$$CA"
echo "Generated self-signed TLS cert for certctl-server (ed25519, 3650d, CN=certctl-server)"
fi
# certctl binary runs as UID 1000 inside the server container per
# Dockerfile:64-65; the cert + key must be readable by that UID.
chown 1000:1000 "$$CERT" "$$KEY" "$$CA"
chmod 0644 "$$CERT" "$$CA"
chmod 0600 "$$KEY"
volumes:
- certs:/etc/certctl/tls
networks:
- certctl-network
# PostgreSQL database
postgres:
image: postgres:16-alpine
@@ -50,10 +93,14 @@ services:
depends_on:
postgres:
condition: service_healthy
certctl-tls-init:
condition: service_completed_successfully
environment:
CERTCTL_DATABASE_URL: postgres://certctl:${POSTGRES_PASSWORD:-certctl}@postgres:5432/certctl?sslmode=disable
CERTCTL_SERVER_HOST: 0.0.0.0
CERTCTL_SERVER_PORT: 8443
CERTCTL_SERVER_TLS_CERT_PATH: /etc/certctl/tls/server.crt
CERTCTL_SERVER_TLS_KEY_PATH: /etc/certctl/tls/server.key
CERTCTL_LOG_LEVEL: info
CERTCTL_AUTH_TYPE: none
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
@@ -61,10 +108,12 @@ services:
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
ports:
- "8443:8443"
volumes:
- certs:/etc/certctl/tls:ro
networks:
- certctl-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8443/health"]
test: ["CMD", "curl", "--cacert", "/etc/certctl/tls/ca.crt", "-f", "https://localhost:8443/health"]
interval: 10s
timeout: 5s
retries: 5
@@ -99,13 +148,15 @@ services:
certctl-server:
condition: service_healthy
environment:
CERTCTL_SERVER_URL: http://certctl-server:8443
CERTCTL_SERVER_URL: https://certctl-server:8443
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
CERTCTL_AGENT_NAME: docker-agent
CERTCTL_LOG_LEVEL: info
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
volumes:
- agent_keys:/var/lib/certctl/keys
- certs:/etc/certctl/tls:ro
networks:
- certctl-network
healthcheck:
@@ -134,3 +185,5 @@ volumes:
driver: local
agent_keys:
driver: local
certs:
driver: local
+7 -4
View File
@@ -236,10 +236,12 @@ kubectl get svc -l app.kubernetes.io/instance=certctl
kubectl get ingress
kubectl describe ingress certctl
# Test API connectivity
# Test API connectivity (HTTPS-only as of v2.2)
POD=$(kubectl get pods -l app.kubernetes.io/component=server -o jsonpath='{.items[0].metadata.name}')
kubectl port-forward $POD 8443:8443 &
curl -H "Authorization: Bearer $API_KEY" http://localhost:8443/health
# If the chart provisioned a self-signed cert, fetch the CA bundle from the TLS secret first:
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
curl --cacert /tmp/certctl-ca.crt -H "Authorization: Bearer $API_KEY" https://localhost:8443/health
```
### Step 6: Access the Dashboard
@@ -333,9 +335,10 @@ kubectl logs $POD | tail -20
# Port forward to API
kubectl port-forward svc/certctl-server 8443:8443 &
# Create a test certificate
# Create a test certificate (HTTPS-only as of v2.2 — pin the chart-provisioned CA bundle)
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
API_KEY="your-api-key"
curl -X POST http://localhost:8443/api/v1/certificates \
curl --cacert /tmp/certctl-ca.crt -X POST https://localhost:8443/api/v1/certificates \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
+4 -2
View File
@@ -33,9 +33,11 @@ kubectl get pods -l app.kubernetes.io/instance=certctl
# View server logs
kubectl logs -l app.kubernetes.io/component=server -f
# Access the API
# Access the API (HTTPS-only as of v2.2; use --cacert or -k depending on your cert provisioning)
kubectl port-forward svc/certctl-server 8443:8443 &
curl http://localhost:8443/health
# If the chart provisioned a self-signed cert, fetch the CA bundle from the secret first:
# kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
```
## Next Steps
+20 -14
View File
@@ -4,36 +4,46 @@
{{- else if contains "NodePort" .Values.server.service.type }}
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "certctl.fullname" . }}-server)
echo http://$NODE_IP:$NODE_PORT
echo https://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.server.service.type }}
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server --template "{.status.loadBalancer.ingress[0].ip}")
echo http://$SERVICE_IP:{{ .Values.server.service.port }}
echo https://$SERVICE_IP:{{ .Values.server.service.port }}
{{- else }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/instance={{ .Release.Name }},app.kubernetes.io/component=server" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
echo "Visit https://127.0.0.1:8443 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8443:$CONTAINER_PORT
{{- end }}
2. Get the default API key:
2. Talk to the HTTPS-only server from your workstation:
# Export the CA bundle that signed the server cert (self-signed or cert-manager-issued)
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.tls.secretName" . }} \
-o jsonpath='{.data.ca\.crt}' | base64 --decode > /tmp/certctl-ca.crt
# (If ca.crt is empty, fall back to tls.crt — typical when the Secret
# was created from a self-signed bootstrap cert without a separate CA.)
# Adapt the URL below to match the Server URL printed in step 1.
curl --cacert /tmp/certctl-ca.crt https://127.0.0.1:8443/health
3. Get the default API key:
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-server -o jsonpath="{.data.api-key}" | base64 --decode; echo
3. Get PostgreSQL connection details:
4. Get PostgreSQL connection details:
Host: {{ include "certctl.fullname" . }}-postgres.{{ .Release.Namespace }}.svc.cluster.local
Port: 5432
Database: {{ .Values.postgresql.auth.database }}
Username: {{ .Values.postgresql.auth.username }}
Password: $(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "certctl.fullname" . }}-postgres -o jsonpath="{.data.password}" | base64 --decode)
4. Check deployment status:
5. Check deployment status:
kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }}
5. View server logs:
6. View server logs:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=server -f
{{- if .Values.agent.enabled }}
6. View agent logs:
7. View agent logs:
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ include "certctl.name" . }},app.kubernetes.io/component=agent -f
{{- end }}
@@ -58,11 +68,7 @@ IMPORTANT NOTES FOR PRODUCTION:
- Use an external PostgreSQL managed service (AWS RDS, Cloud SQL, etc.)
- Set postgresql.enabled=false and configure CERTCTL_DATABASE_URL in values
5. Enable HTTPS/TLS using an Ingress with certificate management:
- Configure cert-manager for automatic TLS certificate renewal
- Update ingress values with your domain and certificate issuer
6. Review security contexts and network policies:
5. Review security contexts and network policies:
- All containers run as non-root
- Implement network policies to restrict traffic between components
- Consider pod security policies or security standards for your cluster
+48 -2
View File
@@ -118,8 +118,54 @@ postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ includ
{{- end }}
{{/*
Server URL (for agents)
Server URL (for agents). HTTPS-only as of v2.2 — see docs/tls.md.
*/}}
{{- define "certctl.serverURL" -}}
http://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
https://{{ include "certctl.fullname" . }}-server:{{ .Values.server.service.port }}
{{- end }}
{{/*
TLS Secret name resolver.
Operator-facing precedence:
1. server.tls.existingSecret — operator points at a pre-existing kubernetes.io/tls Secret
2. server.tls.certManager.secretName — explicit secret name for the cert-manager Certificate CR
3. "<fullname>-tls" — default when cert-manager is enabled but secretName is blank
Never emits an empty string — that case is already excluded by certctl.tls.required below,
which must be invoked by any template that depends on the resolved secret name.
*/}}
{{- define "certctl.tls.secretName" -}}
{{- if .Values.server.tls.existingSecret -}}
{{- .Values.server.tls.existingSecret -}}
{{- else if .Values.server.tls.certManager.secretName -}}
{{- .Values.server.tls.certManager.secretName -}}
{{- else -}}
{{- printf "%s-tls" (include "certctl.fullname" .) -}}
{{- end -}}
{{- end }}
{{/*
TLS configuration gate.
HTTPS is the only supported listener mode (v2.2+). The server refuses to start
without a cert/key pair mounted at server.tls.mountPath, so `helm template` /
`helm install` must fail loudly at render-time rather than shipping a broken
Deployment that crash-loops with "tls config required".
Operators MUST configure EXACTLY ONE of:
(a) server.tls.existingSecret: <name-of-kubernetes.io/tls-secret>
(b) server.tls.certManager.enabled: true (+ issuerRef.name populated)
Any template that mounts the TLS Secret must call
`{{ include "certctl.tls.required" . }}` at the top so this guard runs once
per affected resource. No-op when configured correctly.
*/}}
{{- define "certctl.tls.required" -}}
{{- if and (not .Values.server.tls.existingSecret) (not .Values.server.tls.certManager.enabled) -}}
{{- fail "\n\ncertctl refuses to start without TLS.\n\nSet EXACTLY ONE of:\n --set server.tls.existingSecret=<your-kubernetes.io/tls-secret-name>\nOR\n --set server.tls.certManager.enabled=true \\\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md for the full setup walkthrough, including bootstrap\nguidance for air-gapped clusters without cert-manager.\n" -}}
{{- end -}}
{{- if and .Values.server.tls.certManager.enabled (not .Values.server.tls.certManager.issuerRef.name) -}}
{{- fail "\n\nserver.tls.certManager.enabled=true but server.tls.certManager.issuerRef.name is empty.\n\nSet:\n --set server.tls.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nSee docs/tls.md.\n" -}}
{{- end -}}
{{- end }}
@@ -1,4 +1,5 @@
{{- if .Values.agent.enabled }}
{{- include "certctl.tls.required" . }}
{{- if eq .Values.agent.kind "DaemonSet" }}
apiVersion: apps/v1
kind: DaemonSet
@@ -53,6 +54,8 @@ spec:
fieldPath: metadata.name
- name: CERTCTL_KEY_DIR
value: {{ .Values.agent.keyDir }}
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
{{- if .Values.agent.discoveryDirs }}
- name: CERTCTL_DISCOVERY_DIRS
valueFrom:
@@ -70,12 +73,19 @@ spec:
mountPath: {{ .Values.agent.keyDir }}
- name: tmp
mountPath: /tmp
- name: server-tls
mountPath: {{ .Values.server.tls.mountPath }}
readOnly: true
volumes:
- name: agent-keys
emptyDir:
sizeLimit: 1Gi
- name: tmp
emptyDir: {}
- name: server-tls
secret:
secretName: {{ include "certctl.tls.secretName" . }}
defaultMode: 0400
{{- else if eq .Values.agent.kind "Deployment" }}
apiVersion: apps/v1
kind: Deployment
@@ -135,6 +145,8 @@ spec:
{{- end }}
- name: CERTCTL_KEY_DIR
value: {{ .Values.agent.keyDir }}
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
value: "{{ .Values.server.tls.mountPath }}/ca.crt"
{{- if .Values.agent.discoveryDirs }}
- name: CERTCTL_DISCOVERY_DIRS
valueFrom:
@@ -152,11 +164,18 @@ spec:
mountPath: {{ .Values.agent.keyDir }}
- name: tmp
mountPath: /tmp
- name: server-tls
mountPath: {{ .Values.server.tls.mountPath }}
readOnly: true
volumes:
- name: agent-keys
emptyDir:
sizeLimit: 1Gi
- name: tmp
emptyDir: {}
- name: server-tls
secret:
secretName: {{ include "certctl.tls.secretName" . }}
defaultMode: 0400
{{- end }}
{{- end }}
+12 -2
View File
@@ -1,12 +1,22 @@
{{- if .Values.ingress.enabled }}
{{- if and .Values.ingress.certManager.enabled (not .Values.ingress.certManager.issuerRef.name) -}}
{{- fail "\n\ningress.certManager.enabled=true but ingress.certManager.issuerRef.name is empty.\n\nSet:\n --set ingress.certManager.issuerRef.name=<your-issuer-or-clusterissuer>\n\nThis is separate from server.tls.certManager — it issues the external-facing\nIngress cert, not the in-cluster server TLS cert. See docs/tls.md.\n" -}}
{{- end -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "certctl.fullname" . }}
labels:
{{- include "certctl.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- if .Values.ingress.certManager.enabled }}
{{- if eq .Values.ingress.certManager.issuerRef.kind "ClusterIssuer" }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
{{- else }}
cert-manager.io/issuer: {{ .Values.ingress.certManager.issuerRef.name | quote }}
{{- end }}
{{- end }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
@@ -33,7 +43,7 @@ spec:
pathType: {{ .pathType }}
backend:
service:
name: {{ include "certctl.fullname" . }}-server
name: {{ include "certctl.fullname" $ }}-server
port:
number: {{ $.Values.server.service.port }}
{{- end }}
@@ -0,0 +1,31 @@
{{- if .Values.server.tls.certManager.enabled }}
{{- include "certctl.tls.required" . }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "certctl.fullname" . }}-server-tls
labels:
{{- include "certctl.labels" . | nindent 4 }}
app.kubernetes.io/component: server
spec:
secretName: {{ include "certctl.tls.secretName" . }}
commonName: {{ .Values.server.tls.certManager.commonName | quote }}
dnsNames:
{{- range .Values.server.tls.certManager.dnsNames }}
- {{ . | quote }}
{{- end }}
duration: {{ .Values.server.tls.certManager.duration }}
renewBefore: {{ .Values.server.tls.certManager.renewBefore }}
usages:
- server auth
- digital signature
- key encipherment
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
issuerRef:
name: {{ .Values.server.tls.certManager.issuerRef.name | quote }}
kind: {{ .Values.server.tls.certManager.issuerRef.kind }}
group: {{ .Values.server.tls.certManager.issuerRef.group }}
{{- end }}
@@ -1,3 +1,4 @@
{{- include "certctl.tls.required" . }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -32,7 +33,7 @@ spec:
image: {{ include "certctl.serverImage" . }}
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
ports:
- name: http
- name: https
containerPort: {{ .Values.server.port }}
protocol: TCP
env:
@@ -40,6 +41,10 @@ spec:
value: "0.0.0.0"
- name: CERTCTL_SERVER_PORT
value: "{{ .Values.server.port }}"
- name: CERTCTL_SERVER_TLS_CERT_PATH
value: "{{ .Values.server.tls.mountPath }}/tls.crt"
- name: CERTCTL_SERVER_TLS_KEY_PATH
value: "{{ .Values.server.tls.mountPath }}/tls.key"
- name: CERTCTL_DATABASE_URL
valueFrom:
secretKeyRef:
@@ -172,12 +177,19 @@ spec:
volumeMounts:
- name: tmp
mountPath: /tmp
- name: tls
mountPath: {{ .Values.server.tls.mountPath }}
readOnly: true
{{- if .Values.server.volumeMounts }}
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
- name: tls
secret:
secretName: {{ include "certctl.tls.secretName" . }}
defaultMode: 0400
{{- if .Values.server.volumes }}
{{- toYaml .Values.server.volumes | nindent 8 }}
{{- end }}
@@ -13,8 +13,8 @@ spec:
type: {{ .Values.server.service.type }}
ports:
- port: {{ .Values.server.service.port }}
targetPort: http
targetPort: https
protocol: TCP
name: http
name: https
selector:
{{- include "certctl.serverSelectorLabels" . | nindent 4 }}
+52 -4
View File
@@ -48,11 +48,12 @@ server:
drop:
- ALL
# Liveness and readiness probes
# Liveness and readiness probes (HTTPS-only as of v2.2)
livenessProbe:
httpGet:
path: /health
port: http
port: https
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
@@ -61,12 +62,50 @@ server:
readinessProbe:
httpGet:
path: /readyz
port: http
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
# TLS configuration — REQUIRED. HTTPS is the only supported mode (v2.2+).
# Operator must configure EXACTLY ONE of:
# (a) server.tls.existingSecret: <name> # pre-existing kubernetes.io/tls Secret
# (b) server.tls.certManager.enabled: true # provision a cert-manager Certificate CR
# Refusing to set either makes `helm template` fail with a diagnostic pointing at docs/tls.md.
tls:
# Name of a pre-existing Secret (type kubernetes.io/tls) holding tls.crt + tls.key (+ optional ca.crt).
# Leave empty to fall through to the cert-manager path.
existingSecret: ""
# Mount path for the TLS Secret inside the server + agent containers.
mountPath: /etc/certctl/tls
# cert-manager auto-provisioning. Opt-in (off by default per milestone §3.4).
certManager:
enabled: false
# Secret name the cert-manager Certificate CR writes into. Agents and the server
# both read from this Secret. If empty, defaults to "<fullname>-tls".
secretName: ""
# Cert-manager issuer reference.
issuerRef:
name: "" # e.g. "letsencrypt-prod" or "internal-ca"
kind: ClusterIssuer # ClusterIssuer or Issuer
group: cert-manager.io
# Subject fields on the issued cert.
commonName: "certctl-server"
dnsNames:
- certctl-server
- localhost
# Certificate lifetime + renewal window.
duration: 2160h # 90 days
renewBefore: 360h # 15 days
# Service type (ClusterIP, LoadBalancer, NodePort)
service:
type: ClusterIP
@@ -356,7 +395,16 @@ ingress:
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
# Optional cert-manager integration for the public-facing Ingress cert.
# This is completely independent of server.tls.* — the Ingress terminates
# an *additional* TLS hop between the internet and the in-cluster Service.
# Leave disabled unless an Ingress is exposing certctl to the outside world.
certManager:
enabled: false
issuerRef:
name: "" # e.g. "letsencrypt-prod"
kind: ClusterIssuer # ClusterIssuer or Issuer
hosts:
- host: certctl.local
paths:
+89 -5
View File
@@ -47,11 +47,30 @@ func envOr(key, fallback string) string {
return fallback
}
// HTTPS-Everywhere Phase 6: the test harness now dials the server over TLS and
// validates the self-signed cert against the init-container-generated CA bundle
// bind-mounted at ./test/certs/ca.crt. The defaults assume the compose setup in
// deploy/docker-compose.test.yml; override via the usual env vars when pointing
// the suite at a different deployment.
//
// - CERTCTL_TEST_SERVER_URL — must be https:// for the Phase 6 wiring
// - CERTCTL_TEST_CA_BUNDLE — PEM bundle; must contain the server's issuing
// CA (self-signed in the compose setup, so server.crt doubles as ca.crt)
// - CERTCTL_TEST_INSECURE — set to "true" to fall back to
// InsecureSkipVerify when the CA bundle path is unavailable (CI smoke or
// exploratory runs only — CI-parity runs MUST use the pinned bundle).
//
// Under no circumstance does the suite silently downgrade to plaintext HTTP:
// Phase 5 (#203) pre-flight guards in cmd/server will refuse to start with an
// http:// URL anyway, so a misconfiguration fails loud at test-harness startup
// rather than flaking mid-suite.
var (
serverURL = envOr("CERTCTL_TEST_SERVER_URL", "http://localhost:8443")
serverURL = envOr("CERTCTL_TEST_SERVER_URL", "https://localhost:8443")
apiKey = envOr("CERTCTL_TEST_API_KEY", "test-key-2026")
dbURL = envOr("CERTCTL_TEST_DB_URL", "postgres://certctl:testpass@localhost:5432/certctl?sslmode=disable")
nginxTLS = envOr("CERTCTL_TEST_NGINX_TLS", "localhost:8444")
caBundlePath = envOr("CERTCTL_TEST_CA_BUNDLE", "./certs/ca.crt")
insecureTLS = strings.EqualFold(os.Getenv("CERTCTL_TEST_INSECURE"), "true")
)
// ---------------------------------------------------------------------------
@@ -75,16 +94,74 @@ type testClient struct {
apiKey string
}
// buildTLSConfig wires up the x509.CertPool with the self-signed CA bundle
// emitted by the certctl-tls-init container. Panics via t.Fatal on the happy
// path if both CERTCTL_TEST_CA_BUNDLE is unreadable *and* CERTCTL_TEST_INSECURE
// is not set — that combination is almost always a misconfigured test harness
// and silently downgrading to InsecureSkipVerify would hide real failures.
//
// MinVersion is pinned to TLS 1.3 so this matches what cmd/server negotiates
// by default; a drift there would surface here first.
func buildTLSConfig() *tls.Config {
cfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if insecureTLS {
// Opt-in smoke-run mode; log but don't fail so operators running
// `CERTCTL_TEST_INSECURE=true go test -tags integration ./deploy/test/...`
// against an ad-hoc environment still get a green suite when the server
// is reachable. CI must not set this.
cfg.InsecureSkipVerify = true
return cfg
}
pem, err := os.ReadFile(caBundlePath)
if err != nil {
// Can't use t.Fatal here (called from package-level helpers); fall
// back to a panic so the harness dies loud at the first HTTP call.
// Operators see a clear "CA bundle missing" message and fix their
// setup instead of chasing a confusing TLS handshake error.
panic(fmt.Sprintf("integration test: read CA bundle %q: %v — "+
"run `docker compose -f deploy/docker-compose.test.yml up` first, or "+
"set CERTCTL_TEST_CA_BUNDLE to a valid PEM path, or "+
"set CERTCTL_TEST_INSECURE=true for a smoke run", caBundlePath, err))
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
panic(fmt.Sprintf("integration test: no PEM certificates parsed from %q", caBundlePath))
}
cfg.RootCAs = pool
return cfg
}
// newTestClient builds a Bearer-authenticated HTTPS client pinned to the
// init-container CA. Every phase uses this for REST calls.
func newTestClient() *testClient {
return &testClient{
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: buildTLSConfig(),
},
},
baseURL: serverURL,
apiKey: apiKey,
}
}
// newUnauthHTTPClient returns an *http.Client with the same TLS configuration
// but no Bearer token. Used for the Phase 7 RFC 5280 CRL / RFC 8615
// `/.well-known/pki/*` probes — those endpoints must be reachable by
// *unauthenticated* relying parties per M-006, so we explicitly omit the
// Authorization header to prove it.
func newUnauthHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: buildTLSConfig(),
},
}
}
func (c *testClient) do(method, path string, body io.Reader) (*http.Response, error) {
url := c.baseURL + path
req, err := http.NewRequest(method, url, body)
@@ -724,11 +801,18 @@ func TestIntegrationSuite(t *testing.T) {
}
// Check DER CRL served unauthenticated under /.well-known/pki/ per
// RFC 5280 §5 + RFC 8615 (M-006). Use a plain http.Get — no Bearer
// token — to prove the endpoint is reachable by relying parties that
// have no certctl API credentials.
// RFC 5280 §5 + RFC 8615 (M-006). Use newUnauthHTTPClient() — no
// Bearer token — to prove the endpoint is reachable by relying
// parties that have no certctl API credentials. Post HTTPS-Everywhere
// (M-007, Phase 6) the client still speaks TLS 1.3 against the pinned
// CA bundle from ./certs/ca.crt; we just skip the Authorization header
// to exercise the unauthenticated RFC 5280 / RFC 8615 relying-party
// path. Switching from the stdlib http.DefaultClient (plaintext OK,
// system trust store only) to the helper keeps the no-auth semantic
// while preventing silent plaintext downgrade — the whole point of
// this milestone.
t.Run("CRL_DER_Unauthenticated", func(t *testing.T) {
resp, err := http.Get(serverURL + "/.well-known/pki/crl/iss-local")
resp, err := newUnauthHTTPClient().Get(serverURL + "/.well-known/pki/crl/iss-local")
if err != nil {
t.Fatalf("GET DER CRL: %v", err)
}
+47 -3
View File
@@ -19,16 +19,29 @@
//
// Environment overrides:
//
// CERTCTL_QA_SERVER_URL (default: http://localhost:8443)
// CERTCTL_QA_SERVER_URL (default: https://localhost:8443)
// CERTCTL_QA_API_KEY (default: change-me-in-production)
// CERTCTL_QA_DB_URL (default: postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable)
// CERTCTL_QA_REPO_DIR (default: ../.. — the certctl repo root)
// CERTCTL_QA_CA_BUNDLE (default: ./certs/ca.crt — the demo stack's init container writes here)
// CERTCTL_QA_INSECURE (default: false — set to "true" to skip TLS verify, e.g. before the init container finishes)
//
// TLS note (HTTPS-Everywhere M-007, Phase 6): the demo compose stack now
// listens on https://localhost:8443 with a self-signed cert written by the
// tls-init container. This suite pins the issuing CA via
// CERTCTL_QA_CA_BUNDLE so cert rotation or a tampered proxy fails the
// handshake instead of being silently trusted. CERTCTL_QA_INSECURE="true"
// is an explicit opt-out for bootstrap scenarios — there is no silent
// plaintext downgrade, matching the server-side pre-flight guard added in
// Phase 5 (task #203).
package integration_test
import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
@@ -50,10 +63,12 @@ func qaEnv(key, fallback string) string {
}
var (
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "http://localhost:8443")
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "https://localhost:8443")
qaAPIKey = qaEnv("CERTCTL_QA_API_KEY", "change-me-in-production")
qaDBURL = qaEnv("CERTCTL_QA_DB_URL", "postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable")
qaRepoDir = qaEnv("CERTCTL_QA_REPO_DIR", filepath.Join("..", ".."))
qaCABundlePath = qaEnv("CERTCTL_QA_CA_BUNDLE", "./certs/ca.crt")
qaInsecure = strings.EqualFold(os.Getenv("CERTCTL_QA_INSECURE"), "true")
)
// ---------------------------------------------------------------------------
@@ -66,9 +81,38 @@ type qaClient struct {
apiKey string
}
// buildQATLSConfig returns the *tls.Config used by every qaClient. TLS 1.3
// minimum matches the server-side config pinned in Phase 2 (cmd/server).
// When CERTCTL_QA_INSECURE=true we skip verification entirely — useful
// when running against a compose stack where the tls-init container hasn't
// written ca.crt yet, or when pointing at a dev server with a rotated cert.
// Otherwise we pin CERTCTL_QA_CA_BUNDLE and panic on read/parse failure
// rather than silently downgrading to the system trust store (which would
// mask a missing init container).
func buildQATLSConfig() *tls.Config {
cfg := &tls.Config{MinVersion: tls.VersionTLS13}
if qaInsecure {
cfg.InsecureSkipVerify = true
return cfg
}
pem, err := os.ReadFile(qaCABundlePath)
if err != nil {
panic(fmt.Sprintf("qa test: read CA bundle %q: %v — set CERTCTL_QA_CA_BUNDLE or CERTCTL_QA_INSECURE=true", qaCABundlePath, err))
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
panic(fmt.Sprintf("qa test: no PEM certificates parsed from %q", qaCABundlePath))
}
cfg.RootCAs = pool
return cfg
}
func newQAClient() *qaClient {
return &qaClient{
http: &http.Client{Timeout: 30 * time.Second},
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{TLSClientConfig: buildQATLSConfig()},
},
baseURL: qaServerURL,
apiKey: qaAPIKey,
}
+30 -4
View File
@@ -1,5 +1,30 @@
#!/usr/bin/env bash
# =============================================================================
# DEPRECATED — prefer `go test -tags integration ./deploy/test/...`
# =============================================================================
#
# This bash harness predates the Go integration test suite in
# deploy/test/integration_test.go (build tag `integration`, 34 subtests across
# 13 phases — health, agent heartbeat, Local CA issuance, ACME, step-ca, EST,
# S/MIME, discovery, network scan, revocation + CRL, deployment verification).
# The Go suite uses crypto/x509, crypto/tls, and database/sql to parse certs,
# probe TLS, and talk to PostgreSQL directly — no openssl text-scraping or
# brittle curl pipelines. It is the authoritative integration test surface as
# of milestone M-007 (HTTPS Everywhere, Phase 6), where the test compose
# stack wires the server on https://localhost:8443 behind a pinned CA bundle
# at ./certs/ca.crt.
#
# Run the Go suite:
# (cd deploy && docker compose -f docker-compose.test.yml up -d --build)
# go test -tags integration -v -count=1 ./deploy/test/...
#
# Keep this bash script around because:
# * It is cited in docs/test-env.md and muscle-memory for contributors.
# * It exercises the CLI / curl path end-to-end (a different failure mode
# than the Go HTTP client path).
# But any NEW integration coverage goes in integration_test.go — not here.
#
# =============================================================================
# certctl End-to-End Test Script
# =============================================================================
#
@@ -32,10 +57,11 @@ set -euo pipefail
# Config
# ---------------------------------------------------------------------------
COMPOSE_FILE="docker-compose.test.yml"
API_URL="http://localhost:8443"
API_URL="https://localhost:8443"
API_KEY="test-key-2026"
NGINX_TLS="localhost:8444"
AUTH_HEADER="Authorization: Bearer ${API_KEY}"
CACERT="./certs/ca.crt"
# Flags
BUILD=true
@@ -91,7 +117,7 @@ header() {
# API helper: GET endpoint, return JSON body. Exits 1 on HTTP error.
api_get() {
local path="$1"
curl -sf -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
curl -sf --cacert "${CACERT}" -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
}
# API helper: POST with optional JSON body
@@ -99,10 +125,10 @@ api_post() {
local path="$1"
local body="${2:-}"
if [ -n "$body" ]; then
curl -sf -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/json" \
curl -sf --cacert "${CACERT}" -X POST -H "${AUTH_HEADER}" -H "Content-Type: application/json" \
-d "$body" "${API_URL}${path}" 2>/dev/null
else
curl -sf -X POST -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
curl -sf --cacert "${CACERT}" -X POST -H "${AUTH_HEADER}" "${API_URL}${path}" 2>/dev/null
fi
}
+3 -2
View File
@@ -39,7 +39,7 @@ Deploy certctl control plane once (Docker Compose, Kubernetes Helm chart, or sel
```bash
cd /opt/certctl
docker compose up -d
# Dashboard & API: http://localhost:8443
# Dashboard & API: https://localhost:8443 (self-signed cert — pin with --cacert ./deploy/test/certs/ca.crt)
```
**Option B: Kubernetes** (recommended for prod)
@@ -59,7 +59,8 @@ chmod +x /usr/local/bin/certctl-agent
# Config
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
CERTCTL_SERVER_URL=http://certctl-control-plane:8443
CERTCTL_SERVER_URL=https://certctl-control-plane:8443
CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt
CERTCTL_API_KEY=your-api-key
CERTCTL_DISCOVERY_DIRS=/etc/nginx/certs,/etc/ssl,/etc/letsencrypt/live
CERTCTL_KEY_DIR=/var/lib/certctl/keys
+3 -3
View File
@@ -387,12 +387,12 @@ This requirement covers key generation, storage, rotation, and destruction. Cert
- API key transmitted in Authorization header (not URL parameter, not cookie).
- Browser to server: TLS.
- Agent to server: TLS.
- No credential logging (API key hash only, never plaintext).
- No credential logging (audit records the per-key actor `Name`, never the Bearer token; logs redact the `Authorization` header).
**Evidence You Can Provide**:
- API configuration: `CERTCTL_AUTH_TYPE=api-key` in deployment manifest.
- Database schema: `api_keys` table showing SHA-256 hash column, not plaintext.
- API audit log: `GET /api/v1/audit?action=api_call` showing Bearer token validation (no plaintext keys logged).
- Key inventory: `CERTCTL_API_KEYS_NAMED` env var (format `name:key:admin,...`) — seeds the in-memory `NamedAPIKey{Name, Key, Admin}` struct at `internal/api/middleware/middleware.go:29`. Keys are constant-time-compared (`subtle.ConstantTimeCompare`) against the Bearer token. No database table stores them; protect the env var contents at rest via a secrets manager (Vault / AWS Secrets Manager / Kubernetes Secrets / Docker Secrets).
- API audit log: `GET /api/v1/audit?action=api_call` showing per-key actor names (`Name` field of matched `NamedAPIKey`) on every call, with zero plaintext or hashed key material recorded.
- TLS certificate on control plane: `openssl s_client -connect {server}:8443` showing valid certificate, TLS 1.2+, strong cipher.
- GUI login flow: browser network tab showing Authorization header (token value redacted in compliance report).
+16 -10
View File
@@ -50,14 +50,17 @@ docker compose -f deploy/docker-compose.yml up -d --build
docker compose -f deploy/docker-compose.yml ps
```
Open **http://localhost:8443** in your browser alongside your terminal. You'll watch changes appear in the dashboard as you make API calls.
Open **https://localhost:8443** in your browser alongside your terminal. The default compose stack ships a self-signed cert; your browser will show a warning the first time — click through (or trust `deploy/test/certs/ca.crt` in your OS keychain). You'll watch changes appear in the dashboard as you make API calls.
Set up a base variable for convenience:
Set up base variables for convenience:
```bash
API="http://localhost:8443"
API="https://localhost:8443"
CA="$PWD/deploy/test/certs/ca.crt" # pin the self-signed CA for curl
```
Every `curl` in this guide uses `--cacert "$CA"` so the TLS handshake verifies against the compose-stack CA instead of the system trust store.
## How the pieces fit together
Before we start, here's the high-level flow of what we're about to do:
@@ -730,7 +733,7 @@ Check the CRL (Certificate Revocation List) — served unauthenticated under the
# DER-encoded X.509 CRL for the local CA (binary — pipe to openssl for inspection).
# Note: no -H "Authorization: Bearer ..." — the endpoint is deliberately
# unauthenticated. Content-Type is application/pkix-crl.
curl -s http://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
openssl crl -inform DER -in /tmp/crl.der -text -noout
```
@@ -740,7 +743,7 @@ Check OCSP status (RFC 6960, also unauthenticated, `application/ocsp-response`):
# Replace SERIAL with the actual serial number from the certificate version.
# The embedded OCSP responder returns a signed DER response — parse it with
# `openssl ocsp -respin` or similar tooling.
curl -s http://localhost:8443/.well-known/pki/ocsp/iss-local/SERIAL -o /tmp/ocsp.der
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/ocsp/iss-local/SERIAL -o /tmp/ocsp.der
openssl ocsp -respin /tmp/ocsp.der -noverify -resp_text | head -40
```
@@ -946,7 +949,8 @@ certctl includes a standalone CLI tool for command-line users:
cd cmd/cli && go build -o certctl-cli .
# Export credentials
export CERTCTL_SERVER_URL="http://localhost:8443"
export CERTCTL_SERVER_URL="https://localhost:8443"
export CERTCTL_SERVER_CA_BUNDLE_PATH="$PWD/deploy/test/certs/ca.crt"
export CERTCTL_API_KEY="test-key-123"
# List certificates (JSON or table format)
@@ -990,7 +994,8 @@ certctl exposes the full REST API via the Model Context Protocol (MCP), enabling
cd cmd/mcp-server && go build -o mcp-server .
# Export credentials
export CERTCTL_SERVER_URL="http://localhost:8443"
export CERTCTL_SERVER_URL="https://localhost:8443"
export CERTCTL_SERVER_CA_BUNDLE_PATH="$PWD/deploy/test/certs/ca.crt"
export CERTCTL_API_KEY="test-key-123"
# Start the MCP server (listens on stdin/stdout)
@@ -1048,7 +1053,7 @@ docker compose -f deploy/docker-compose.yml run -e CERTCTL_DISCOVERY_DIRS=/tmp/c
Or with the CLI flag:
```bash
certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/certs --server http://localhost:8443 --api-key test-key-123
certctl-agent --agent-id a-demo-1 --key-dir /tmp/keys --discovery-dirs /tmp/certs --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-123
```
### Network Discovery (Server-Side)
@@ -1191,7 +1196,8 @@ Here's a single script that runs the entire demo end-to-end. Save it as `demo.sh
#!/bin/bash
set -e
API="http://localhost:8443"
API="https://localhost:8443"
CA="$PWD/deploy/test/certs/ca.crt" # pin the self-signed CA for curl
BLUE='\033[0;34m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -1299,7 +1305,7 @@ echo " 5. Revoked the certificate with RFC 5280 reason codes"
echo " 6. Checked dashboard stats and metrics"
echo " 7. All actions recorded in the audit trail"
echo ""
echo -e "Open ${GREEN}http://localhost:8443${NC} to see everything in the dashboard."
echo -e "Open ${GREEN}https://localhost:8443${NC} to see everything in the dashboard."
echo "Look for certificate: $CERT_ID"
```
+11 -5
View File
@@ -29,15 +29,18 @@ The binary has zero runtime dependencies beyond the certctl server it connects t
## Configuration
The MCP server reads two environment variables:
The MCP server reads three environment variables:
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CERTCTL_SERVER_URL` | No | `http://localhost:8443` | URL of the certctl REST API |
| `CERTCTL_SERVER_URL` | No | `https://localhost:8443` | URL of the certctl REST API (HTTPS-only as of v2.2) |
| `CERTCTL_API_KEY` | No | (empty) | API key for authentication (passed as `Bearer` token) |
| `CERTCTL_SERVER_CA_BUNDLE_PATH` | Yes (for self-signed / internal CA) | (empty) | Path to PEM CA bundle that signed the server cert. Required when the server cert isn't rooted in the system trust store (the default compose stack ships a self-signed cert at `deploy/test/certs/ca.crt`). |
If your certctl server has auth enabled (the default), you must provide the API key. The MCP server passes it through to every HTTP request.
Since v2.2 the certctl control plane is HTTPS-only. If the server cert is self-signed or chained to an internal CA, set `CERTCTL_SERVER_CA_BUNDLE_PATH` so the MCP server can verify the TLS handshake. Never set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true` outside local development — it disables all certificate validation.
## Setting Up with Claude Desktop
Add this to your Claude Desktop MCP configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
@@ -48,7 +51,8 @@ Add this to your Claude Desktop MCP configuration file (`~/Library/Application S
"certctl": {
"command": "/path/to/certctl-mcp",
"env": {
"CERTCTL_SERVER_URL": "http://localhost:8443",
"CERTCTL_SERVER_URL": "https://localhost:8443",
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/certctl/deploy/test/certs/ca.crt",
"CERTCTL_API_KEY": "your-api-key-here"
}
}
@@ -67,7 +71,8 @@ In Cursor, go to Settings → MCP Servers and add:
"certctl": {
"command": "/path/to/certctl-mcp",
"env": {
"CERTCTL_SERVER_URL": "http://localhost:8443",
"CERTCTL_SERVER_URL": "https://localhost:8443",
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/certctl/deploy/test/certs/ca.crt",
"CERTCTL_API_KEY": "your-api-key-here"
}
}
@@ -84,7 +89,8 @@ Add certctl as an MCP server in your project's `.mcp.json`:
"certctl": {
"command": "/path/to/certctl-mcp",
"env": {
"CERTCTL_SERVER_URL": "http://localhost:8443",
"CERTCTL_SERVER_URL": "https://localhost:8443",
"CERTCTL_SERVER_CA_BUNDLE_PATH": "/path/to/certctl/deploy/test/certs/ca.crt",
"CERTCTL_API_KEY": "your-api-key-here"
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ cd certctl/deploy
docker compose up -d
```
Access the dashboard at `http://localhost:8443` with API key from `.env` file.
Access the dashboard at `https://localhost:8443` with the API key from `.env`. The default compose stack ships a self-signed cert; pin with `--cacert ./deploy/test/certs/ca.crt` when calling the API from the host.
### 2. Deploy Agents
+3 -2
View File
@@ -22,7 +22,7 @@ Option A: Docker Compose (quickest for evaluation)
```bash
cd /opt/certctl
docker compose up -d
# Dashboard & API: http://localhost:8443
# Dashboard & API: https://localhost:8443 (self-signed cert — use --cacert ./deploy/test/certs/ca.crt for the default compose stack)
# Default API key in logs (grep CERTCTL_API_KEY docker logs certctl-server)
```
@@ -45,7 +45,8 @@ chmod +x /usr/local/bin/certctl-agent
# Create config
sudo mkdir -p /etc/certctl /var/lib/certctl/keys
sudo tee /etc/certctl/agent.env > /dev/null <<EOF
CERTCTL_SERVER_URL=http://certctl-control-plane.example.com:8443
CERTCTL_SERVER_URL=https://certctl-control-plane.example.com:8443
CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt
CERTCTL_API_KEY=your-api-key-here
CERTCTL_DISCOVERY_DIRS=/etc/letsencrypt/live
CERTCTL_KEY_DIR=/var/lib/certctl/keys
+9 -4
View File
@@ -68,8 +68,10 @@ The spec organizes endpoints into 16 tags:
The spec declares a `bearerAuth` security scheme applied globally. All endpoints under `/api/v1/` require a Bearer token by default:
```bash
curl -H "Authorization: Bearer your-api-key" \
http://localhost:8443/api/v1/certificates
# The default compose stack uses a self-signed cert; pin with --cacert
curl --cacert ./deploy/test/certs/ca.crt \
-H "Authorization: Bearer your-api-key" \
https://localhost:8443/api/v1/certificates
```
Three endpoints are exempt from auth (declared with `security: []` in the spec): `/health`, `/ready`, and `/api/v1/auth/info`. The auth info endpoint tells clients whether authentication is enabled and what type is required — useful for GUIs that need to show/hide a login screen.
@@ -150,8 +152,9 @@ Import the spec directly into Postman:
1. Open Postman → Import → File → select `api/openapi.yaml`
2. Postman creates a collection with all 78 documented operations organized by tag
3. Set the `baseUrl` variable to `http://localhost:8443`
3. Set the `baseUrl` variable to `https://localhost:8443` (HTTPS-only as of v2.2)
4. Add an `Authorization: Bearer your-api-key` header to the collection
5. Import the demo stack CA bundle (`deploy/test/certs/ca.crt`) into Postman's Settings → Certificates → CA Certificates, or disable certificate verification for the `localhost` host (Settings → General → SSL certificate verification)
## Key Schemas
@@ -176,8 +179,10 @@ Use the spec to generate contract tests that verify the API matches the spec:
```bash
# Using schemathesis for fuzz testing against the spec
pip install schemathesis
# The default compose stack uses a self-signed cert — export a CA bundle or set REQUESTS_CA_BUNDLE
export REQUESTS_CA_BUNDLE=$(pwd)/deploy/test/certs/ca.crt
schemathesis run api/openapi.yaml \
--base-url http://localhost:8443 \
--base-url https://localhost:8443 \
--header "Authorization: Bearer your-api-key"
```
+5 -3
View File
@@ -85,10 +85,12 @@ go test -tags qa -v -timeout 10m ./...
| Variable | Default | Description |
|---|---|---|
| `CERTCTL_QA_SERVER_URL` | `http://localhost:8443` | certctl server URL |
| `CERTCTL_QA_SERVER_URL` | `https://localhost:8443` | certctl server URL (HTTPS-only as of v2.2) |
| `CERTCTL_QA_API_KEY` | `change-me-in-production` | API key for Bearer auth |
| `CERTCTL_QA_DB_URL` | `postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable` | PostgreSQL connection string |
| `CERTCTL_QA_REPO_DIR` | `../..` | Path to certctl repo root (for source file checks) |
| `CERTCTL_QA_CA_BUNDLE` | `./certs/ca.crt` | PEM CA bundle pinned for TLS verification. The demo stack's `certctl-tls-init` container writes here. |
| `CERTCTL_QA_INSECURE` | `false` | Set to `"true"` to skip TLS verification (e.g. before the init container finishes). Never use outside the demo harness. |
## Part-by-Part Coverage Map
@@ -256,8 +258,8 @@ docker compose -f docker-compose.yml -f docker-compose.demo.yml ps
# Check server logs
docker compose -f docker-compose.yml -f docker-compose.demo.yml logs certctl-server
# Check if the port is exposed
curl -s http://localhost:8443/health
# Check if the port is exposed (self-signed cert — pin CA bundle)
curl --cacert ./deploy/test/certs/ca.crt -s https://localhost:8443/health
```
### "connect to QA DB" failure
+59 -46
View File
@@ -105,16 +105,24 @@ certctl-server Up (healthy)
certctl-agent Up
```
The control plane is HTTPS-only as of v2.2. The `certctl-tls-init` init container in the shipped `deploy/docker-compose.yml` self-signs a cert on first boot and drops it into a named volume. Extract the CA bundle once and reuse it for every API call in this guide:
```bash
curl http://localhost:8443/health
export CA=/tmp/certctl-ca.crt
docker compose -f deploy/docker-compose.yml exec -T certctl-server \
cat /etc/certctl/tls/ca.crt > "$CA"
curl --cacert "$CA" https://localhost:8443/health
```
```json
{"status":"healthy"}
```
If you're bringing your own cert (internal CA, cert-manager, operator-supplied Secret), see [`docs/tls.md`](tls.md) for the full provisioning matrix. If you're cutting over an existing install, see [`docs/upgrade-to-tls.md`](upgrade-to-tls.md) for the failure modes (out-of-date `http://…` agents fail at the TLS handshake) and the one-step procedure.
## Open the Dashboard
Open **http://localhost:8443** in your browser.
Open **https://localhost:8443** in your browser. Your browser will warn about the self-signed cert — that's expected for the demo bootstrap. Trust the CA bundle you just exported, or click through the warning.
> **Note:** The Docker Compose demo runs with authentication disabled (`CERTCTL_AUTH_TYPE=none`) so you can explore immediately. For production, set `CERTCTL_AUTH_TYPE=api-key` and `CERTCTL_AUTH_SECRET=<your-secret>` in your environment, then pass `Authorization: Bearer <your-secret>` on all API requests. The dashboard will prompt for your API key on first load.
>
@@ -154,62 +162,64 @@ Everything you see in the dashboard is backed by the REST API. All endpoints liv
### Core operations
Every request below uses `--cacert "$CA"` to pin the self-signed CA bundle extracted above. In production, point `$CA` at your internal CA root or the bundle you distributed to the fleet.
```bash
# List all certificates
curl -s http://localhost:8443/api/v1/certificates | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates | jq .
# Filter by status
curl -s "http://localhost:8443/api/v1/certificates?status=Expiring" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?status=Expiring" | jq .
# Filter by environment
curl -s "http://localhost:8443/api/v1/certificates?environment=production" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?environment=production" | jq .
# Get a specific certificate
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates/mc-api-prod | jq .
# Get deployment targets for a certificate
curl -s http://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates/mc-api-prod/deployments | jq .
# List agents
curl -s http://localhost:8443/api/v1/agents | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/agents | jq .
# Check agent pending work
curl -s http://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/agents/ag-web-prod/work | jq .
# View audit trail
curl -s http://localhost:8443/api/v1/audit | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/audit | jq .
# View policies and violations
curl -s http://localhost:8443/api/v1/policies | jq .
curl -s http://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/policies | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/policies/pr-require-owner/violations | jq .
# Notifications
curl -s http://localhost:8443/api/v1/notifications | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/notifications | jq .
# Profiles and agent groups
curl -s http://localhost:8443/api/v1/profiles | jq .
curl -s http://localhost:8443/api/v1/agent-groups | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/profiles | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/agent-groups | jq .
```
### Sorting, filtering, and pagination
```bash
# Sort by expiration date (ascending)
curl -s "http://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?sort=notAfter" | jq .
# Sort descending (prefix with -)
curl -s "http://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?sort=-createdAt" | jq .
# Time-range filters (RFC3339)
curl -s "http://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
curl -s "http://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?expires_before=2026-05-01T00:00:00Z" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?created_after=2026-03-01T00:00:00Z" | jq .
# Sparse fields — request only what you need
curl -s "http://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?fields=id,common_name,status,expires_at" | jq .
# Cursor pagination — efficient for large inventories
curl -s "http://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
curl -s "http://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?page_size=5" | jq '{next_cursor: .next_cursor, count: (.data | length)}'
curl --cacert "$CA" -s "https://localhost:8443/api/v1/certificates?cursor=<next_cursor_value>&page_size=5" | jq .
```
Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commonName`, `name`, `status`, `environment`.
@@ -218,22 +228,22 @@ Supported sort fields: `notAfter`, `expiresAt`, `createdAt`, `updatedAt`, `commo
```bash
# Dashboard summary
curl -s http://localhost:8443/api/v1/stats/summary | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/stats/summary | jq .
# Certificates by status
curl -s http://localhost:8443/api/v1/stats/certificates-by-status | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/stats/certificates-by-status | jq .
# Expiration timeline (next 90 days)
curl -s "http://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/stats/expiration-timeline?days=90" | jq .
# Job trends (last 30 days)
curl -s "http://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/stats/job-trends?days=30" | jq .
# JSON metrics
curl -s http://localhost:8443/api/v1/metrics | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/metrics | jq .
# Prometheus format (for Prometheus, Grafana Agent, Datadog)
curl -s http://localhost:8443/api/v1/metrics/prometheus
curl --cacert "$CA" -s https://localhost:8443/api/v1/metrics/prometheus
```
## Create Your First Certificate
@@ -241,7 +251,7 @@ curl -s http://localhost:8443/api/v1/metrics/prometheus
Create a certificate record that certctl will track, renew, and deploy automatically.
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
-H "Content-Type: application/json" \
-d '{
"name": "My First Certificate",
@@ -264,22 +274,22 @@ CERT_ID="<paste the id from the response>"
Trigger renewal:
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/$CERT_ID/renew | jq .
```
Check the result:
```bash
curl -s http://localhost:8443/api/v1/certificates/$CERT_ID | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/certificates/$CERT_ID | jq .
```
Refresh the dashboard at http://localhost:8443 — your new certificate appears in the inventory.
Refresh the dashboard at https://localhost:8443 — your new certificate appears in the inventory.
### Revoke a certificate
When a private key is compromised or a service is decommissioned:
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/$CERT_ID/revoke \
-H "Content-Type: application/json" \
-d '{"reason": "superseded"}' | jq .
```
@@ -289,7 +299,8 @@ Supported RFC 5280 reason codes: `unspecified`, `keyCompromise`, `caCompromise`,
Confirm via the unauthenticated DER CRL (RFC 5280 §5, RFC 8615):
```bash
# Fetch the CRL without any API key — relying parties shouldn't need one.
curl -s http://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
# The CRL path is unauthenticated, but it's still served over TLS.
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
```
@@ -299,15 +310,15 @@ For high-value certificates where you want human oversight. The demo includes 2
```bash
# List jobs awaiting approval (demo includes 2)
curl -s "http://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}'
curl --cacert "$CA" -s "https://localhost:8443/api/v1/jobs?status=AwaitingApproval" | jq '.data[] | {id, certificate_id, status}'
# Approve a pending job
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/approve \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/approve \
-H "Content-Type: application/json" \
-d '{"reason": "Approved for production deployment"}' | jq .
# Reject a pending job
curl -s -X POST http://localhost:8443/api/v1/jobs/JOB_ID/reject \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/jobs/JOB_ID/reject \
-H "Content-Type: application/json" \
-d '{"reason": "Key type does not meet compliance requirements"}' | jq .
```
@@ -333,7 +344,7 @@ export CERTCTL_DISCOVERY_DIRS="/etc/nginx/certs,/etc/ssl/certs,/var/lib/certs"
export CERTCTL_NETWORK_SCAN_ENABLED=true
# Create a scan target
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets \
-H "Content-Type: application/json" \
-d '{
"name": "Internal Network",
@@ -345,20 +356,20 @@ curl -s -X POST http://localhost:8443/api/v1/network-scan-targets \
}' | jq .
# Trigger an immediate scan
curl -s -X POST http://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/network-scan-targets/nst-internal-network/scan | jq .
```
### Triage discovered certificates
```bash
# List discovered certs
curl -s "http://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
curl --cacert "$CA" -s "https://localhost:8443/api/v1/discovered-certificates?agent_id=agent-nginx-prod" | jq .
# Summary counts
curl -s http://localhost:8443/api/v1/discovery-summary | jq .
curl --cacert "$CA" -s https://localhost:8443/api/v1/discovery-summary | jq .
# Claim a discovered cert (bring under management)
curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
curl --cacert "$CA" -s -X POST "https://localhost:8443/api/v1/discovered-certificates/DISCOVERY_ID/claim" \
-H "Content-Type: application/json" \
-d '{"managed_certificate_id": "mc-api-prod"}' | jq .
```
@@ -368,8 +379,9 @@ curl -s -X POST "http://localhost:8443/api/v1/discovered-certificates/DISCOVERY_
```bash
cd cmd/cli && go build -o certctl-cli .
export CERTCTL_SERVER_URL="http://localhost:8443"
export CERTCTL_SERVER_URL="https://localhost:8443"
export CERTCTL_API_KEY="test-key-123"
export CERTCTL_SERVER_CA_BUNDLE_PATH="$CA" # or pass --ca-bundle; --insecure for dev self-signed
./certctl-cli certs list # List certificates
./certctl-cli certs get mc-api-prod # Certificate details
@@ -402,10 +414,10 @@ export CERTCTL_DIGEST_RECIPIENTS=ops@example.com,security@example.com
Preview the digest HTML before enabling scheduled delivery:
```bash
curl http://localhost:8443/api/v1/digest/preview | jq '.html' | grep -o '<html>' # Shows HTML is ready
curl --cacert "$CA" https://localhost:8443/api/v1/digest/preview | jq '.html' | grep -o '<html>' # Shows HTML is ready
# Trigger a digest send immediately (outside of schedule)
curl -X POST http://localhost:8443/api/v1/digest/send
curl --cacert "$CA" -X POST https://localhost:8443/api/v1/digest/send
```
If no recipients are configured (`CERTCTL_DIGEST_RECIPIENTS` empty), the digest falls back to certificate owner emails. Digests include total certificates, expiring soon, expired, active agents, completed/failed jobs (30-day summary), and a table of expiring certs color-coded by urgency (7/14/30 days).
@@ -415,8 +427,9 @@ If no recipients are configured (`CERTCTL_DIGEST_RECIPIENTS` empty), the digest
```bash
cd cmd/mcp-server && go build -o mcp-server .
export CERTCTL_SERVER_URL="http://localhost:8443"
export CERTCTL_SERVER_URL="https://localhost:8443"
export CERTCTL_API_KEY="test-key-123"
export CERTCTL_SERVER_CA_BUNDLE_PATH="$CA" # MCP is env-vars-only; no CLI flags
./mcp-server
```
+64 -47
View File
@@ -16,7 +16,7 @@ You'll start 7 Docker containers that talk to each other:
| **pebble-challtestsrv** | DNS/HTTP challenge test server for Pebble | 10.30.50.3 | Not directly — Pebble talks to it |
| **Pebble** | A fake Let's Encrypt (tests the ACME protocol without touching the real internet) | 10.30.50.4 | Not directly — the server talks to it |
| **step-ca** | A private Certificate Authority (think: your company's internal CA) | 10.30.50.5 | Not directly — the server talks to it |
| **certctl-server** | The brain. API + web dashboard + scheduler + ACME challenge server | 10.30.50.6 | **http://localhost:8443** |
| **certctl-server** | The brain. API + web dashboard + scheduler + ACME challenge server | 10.30.50.6 | **https://localhost:8443** (self-signed — see CA-bundle note below) |
| **NGINX** | A web server. The agent deploys certificates here. | 10.30.50.7 | **https://localhost:8444** |
| **certctl-agent** | The hands. Generates keys, deploys certs to NGINX | 10.30.50.8 | Not directly — it talks to the server |
@@ -123,7 +123,7 @@ docker compose -f docker-compose.test.yml up --build
```
certctl-test-server | {"level":"INFO","msg":"server started","address":"0.0.0.0:8443"}
certctl-test-agent | {"level":"INFO","msg":"agent starting","server_url":"http://certctl-server:8443"}
certctl-test-agent | {"level":"INFO","msg":"agent starting","server_url":"https://certctl-server:8443"}
certctl-test-stepca | Serving HTTPS on :9000 ...
certctl-test-pebble | Listening on: 0.0.0.0:14000
```
@@ -159,13 +159,29 @@ certctl-test-stepca Up (healthy)
**If certctl-test-server says "Restarting"**: It probably started before step-ca or Pebble were ready. Wait 30 seconds and check again. If it keeps restarting, see [Troubleshooting](#troubleshooting).
### Get the CA bundle for curl
The test harness runs HTTPS-only (the `certctl-tls-init` init container self-signs an ed25519 server cert into a bind-mounted directory before the server starts — see `docker-compose.test.yml` §`certctl-tls-init` for details). The CA cert that signed it is materialized on the host at `./test/certs/ca.crt` (relative to the `deploy/` directory). Every `curl` in the rest of this doc expects it in `$CA`:
```bash
export CA=$PWD/test/certs/ca.crt
ls -la "$CA" # sanity check: file should exist and be non-empty
curl --cacert "$CA" -f https://localhost:8443/health
```
Expect `{"status":"ok"}`. If `curl` errors with `SSL certificate problem: unable to get local issuer certificate`, the init container hasn't finished yet — wait a few seconds and retry. If the file doesn't exist at all, the bind mount didn't populate; `docker compose -f docker-compose.test.yml logs certctl-tls-init` should show the self-sign ran.
For a full explanation of the cert provisioning patterns (self-signed bootstrap, operator-supplied, cert-manager), see [`tls.md`](tls.md). For the one-step cutover from the old plaintext test harness to HTTPS, see [`upgrade-to-tls.md`](upgrade-to-tls.md).
---
## Step 2: Open the Dashboard
Open your web browser and go to:
**http://localhost:8443**
**https://localhost:8443**
Your browser will warn you that the cert is self-signed ("Your connection is not private" / "NET::ERR_CERT_AUTHORITY_INVALID"). That's expected for the test harness — the CA that signed the cert lives at `deploy/test/certs/ca.crt` and isn't in your system trust store. Click through the warning (Chrome: "Advanced" → "Proceed"; Firefox: "Accept the Risk"; Safari: "Show Details" → "visit this website").
You'll see a login screen asking for an API key. Enter:
@@ -198,12 +214,13 @@ Go back to your second terminal. Let's verify the data loaded correctly.
### Check the agent
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/agents | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/agents | python3 -m json.tool
```
**What this command does**:
- `curl` makes an HTTP request (like a browser but from the terminal)
- `curl` makes an HTTPS request (like a browser but from the terminal)
- `--cacert "$CA"` pins the test harness's self-signed root as the only trust anchor for this call — matches what you exported in Step 1
- `-s` means "silent" (don't show progress bars)
- `-H "Authorization: Bearer test-key-2026"` sends the API key (same one you used to log in)
- `python3 -m json.tool` formats the JSON response so it's readable
@@ -233,8 +250,8 @@ The important parts: `"id": "agent-test-01"` and `"status": "online"`. If the st
### Check the issuers
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/issuers | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/issuers | python3 -m json.tool
```
You should see three issuers:
@@ -245,8 +262,8 @@ You should see three issuers:
### Check the target
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/targets | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/targets | python3 -m json.tool
```
You should see `target-test-nginx` — the NGINX deployment target, assigned to `agent-test-01`.
@@ -255,7 +272,7 @@ The target config uses no-op commands for `reload_command` and `validate_command
### See it all in the dashboard
Open the dashboard at http://localhost:8443 and click through the sidebar:
Open the dashboard at https://localhost:8443 and click through the sidebar:
- **Agents** — you should see `test-agent-01`
- **Issuers** — you should see all three CAs
- **Targets** — you should see `Test NGINX`
@@ -287,7 +304,7 @@ The private key **never leaves the agent**. The server only ever sees the CSR (p
### Step 4a: Create the certificate record
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
-H "Authorization: Bearer test-key-2026" \
-H "Content-Type: application/json" \
-d '{
@@ -338,7 +355,7 @@ docker exec certctl-test-postgres psql -U certctl -d certctl -c \
### Step 4c: Trigger issuance
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-local-test/renew \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-local-test/renew \
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
```
@@ -395,7 +412,7 @@ The `subject` should match the domain name you chose. The `issuer` should say "c
### Step 4f: Check the dashboard
Open the dashboard at http://localhost:8443 and:
Open the dashboard at https://localhost:8443 and:
1. Click **Certificates** in the sidebar — you should see `mc-local-test` with status "Active"
2. Click on it to see the detail page — you should see version history, the signed certificate details, and the deployment timeline
@@ -414,7 +431,7 @@ This is the real deal. ACME is the protocol that Let's Encrypt uses to issue cer
### Step 5a: Create the certificate record
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
-H "Authorization: Bearer test-key-2026" \
-H "Content-Type: application/json" \
-d '{
@@ -441,7 +458,7 @@ docker exec certctl-test-postgres psql -U certctl -d certctl -c \
"INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-acme-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
# Trigger issuance
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-acme-test/renew \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-acme-test/renew \
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
```
@@ -502,7 +519,7 @@ Revocation means "this certificate is no longer trusted, even though it hasn't e
### Step 7a: Revoke the Local CA cert
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-local-test/revoke \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-local-test/revoke \
-H "Authorization: Bearer test-key-2026" \
-H "Content-Type: application/json" \
-d '{"reason": "superseded"}' | python3 -m json.tool
@@ -516,7 +533,7 @@ The CRL is a DER-encoded X.509 v2 CRL (RFC 5280 §5) served under the RFC 8615 w
```bash
# No Authorization header — the endpoint is public by design.
curl -s http://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
curl --cacert "$CA" -s https://localhost:8443/.well-known/pki/crl/iss-local -o /tmp/crl.der
openssl crl -inform der -in /tmp/crl.der -noout -text | head -40
```
@@ -533,8 +550,8 @@ Go to **Certificates** in the sidebar. The `mc-local-test` cert should now show
The agent is configured to scan `/nginx-certs` every 6 hours for existing certificates. It already ran a scan when it started up. Let's see what it found.
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/discovered-certificates | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/discovered-certificates | python3 -m json.tool
```
**What you should see**: Any certificates that exist in the NGINX cert directory, including the ones you deployed in Steps 4-5. The discovery system extracts metadata (CN, SANs, issuer, expiry, fingerprint) from the PEM files.
@@ -542,8 +559,8 @@ curl -s -H "Authorization: Bearer test-key-2026" \
Check the summary:
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/discovery-summary | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/discovery-summary | python3 -m json.tool
```
This shows counts: how many are Unmanaged, Managed, and Dismissed.
@@ -557,7 +574,7 @@ In the dashboard: click **Discovery** in the sidebar to see the triage view.
Force a renewal on the ACME certificate to see the full cycle happen again:
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-acme-test/renew \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-acme-test/renew \
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
```
@@ -584,7 +601,7 @@ The test environment enables EST with `CERTCTL_EST_ENABLED=true` and `CERTCTL_ES
### Step 10a: Check available CA certificates
```bash
curl -sk http://localhost:8443/.well-known/est/cacerts \
curl --cacert "$CA" -s https://localhost:8443/.well-known/est/cacerts \
-H "Authorization: Bearer test-key-2026"
```
@@ -595,7 +612,7 @@ curl -sk http://localhost:8443/.well-known/est/cacerts \
### Step 10b: Check CSR attributes
```bash
curl -sk http://localhost:8443/.well-known/est/csrattrs \
curl --cacert "$CA" -s https://localhost:8443/.well-known/est/csrattrs \
-H "Authorization: Bearer test-key-2026"
```
@@ -615,7 +632,7 @@ openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
EST_CSR=$(openssl req -in /tmp/est-test.csr -outform DER | base64 -w 0)
# Submit to EST simpleenroll endpoint
curl -sk -X POST http://localhost:8443/.well-known/est/simpleenroll \
curl --cacert "$CA" -s -X POST https://localhost:8443/.well-known/est/simpleenroll \
-H "Authorization: Bearer test-key-2026" \
-H "Content-Type: application/pkcs10" \
-d "$EST_CSR"
@@ -628,8 +645,8 @@ curl -sk -X POST http://localhost:8443/.well-known/est/simpleenroll \
Decode and inspect the response (if you saved it to a variable):
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/audit-events | python3 -m json.tool | head -30
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/audit-events | python3 -m json.tool | head -30
```
Check the audit trail — you should see an `est_enrollment` event with the CN `est-device.certctl.test`.
@@ -639,7 +656,7 @@ Check the audit trail — you should see an `est_enrollment` event with the CN `
EST also supports re-enrollment (certificate renewal). The same CSR format works:
```bash
curl -sk -X POST http://localhost:8443/.well-known/est/simplereenroll \
curl --cacert "$CA" -s -X POST https://localhost:8443/.well-known/est/simplereenroll \
-H "Authorization: Bearer test-key-2026" \
-H "Content-Type: application/pkcs10" \
-d "$EST_CSR"
@@ -658,7 +675,7 @@ S/MIME certificates are used for email signing and encryption — a different us
### Step 11a: Create an S/MIME certificate record
```bash
curl -s -X POST http://localhost:8443/api/v1/certificates \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates \
-H "Authorization: Bearer test-key-2026" \
-H "Content-Type: application/json" \
-d '{
@@ -686,7 +703,7 @@ Notice:
docker exec certctl-test-postgres psql -U certctl -d certctl -c \
"INSERT INTO certificate_target_mappings (certificate_id, target_id) VALUES ('mc-smime-test', 'target-test-nginx') ON CONFLICT DO NOTHING;"
curl -s -X POST http://localhost:8443/api/v1/certificates/mc-smime-test/renew \
curl --cacert "$CA" -s -X POST https://localhost:8443/api/v1/certificates/mc-smime-test/renew \
-H "Authorization: Bearer test-key-2026" | python3 -m json.tool
```
@@ -695,15 +712,15 @@ curl -s -X POST http://localhost:8443/api/v1/certificates/mc-smime-test/renew \
After the agent processes the job (30-60 seconds), check the certificate details:
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/certificates/mc-smime-test | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/certificates/mc-smime-test | python3 -m json.tool
```
The certificate should show `"status": "active"`. To verify the EKU on the actual cert, you can export it:
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/certificates/mc-smime-test/export/pem | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/certificates/mc-smime-test/export/pem | python3 -m json.tool
```
If you decode the certificate PEM, you should see:
@@ -768,16 +785,16 @@ If you have Go installed, you can build and test the CLI tool:
go build -o certctl-cli ./cmd/cli
# List certificates
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 list-certs
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 list-certs
# Get a specific certificate
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 get-cert mc-acme-test
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 get-cert mc-acme-test
# Check health
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 health
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 health
# Get metrics (JSON format)
./certctl-cli --server http://localhost:8443 --api-key test-key-2026 --format json metrics
./certctl-cli --server https://localhost:8443 --ca-bundle "$CA" --api-key test-key-2026 --format json metrics
```
---
@@ -924,15 +941,15 @@ Look for error messages. Common ones:
**Step 2**: Verify the agent is registered:
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
http://localhost:8443/api/v1/agents/agent-test-01 | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
https://localhost:8443/api/v1/agents/agent-test-01 | python3 -m json.tool
```
**Step 3**: Check for pending jobs:
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
"http://localhost:8443/api/v1/jobs?status=Pending&status=AwaitingCSR" | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
"https://localhost:8443/api/v1/jobs?status=Pending&status=AwaitingCSR" | python3 -m json.tool
```
If there are pending jobs but the agent isn't picking them up, check that the job's `agent_id` matches `agent-test-01`.
@@ -962,8 +979,8 @@ docker exec certctl-test-nginx nginx -s reload
**Step 3**: If the files aren't there, the deployment job hasn't completed. Check the jobs:
```bash
curl -s -H "Authorization: Bearer test-key-2026" \
"http://localhost:8443/api/v1/jobs?type=Deployment" | python3 -m json.tool
curl --cacert "$CA" -s -H "Authorization: Bearer test-key-2026" \
"https://localhost:8443/api/v1/jobs?type=Deployment" | python3 -m json.tool
```
Look at the job status. If it's "Running" and stuck, the server's job processor may have picked it up instead of the agent (this was a known bug — the fix skips deployment jobs with `agent_id` in the server's `ProcessPendingJobs`).
@@ -1008,7 +1025,7 @@ Change it to a different port, like:
- "9443:8443"
```
Then access the dashboard at http://localhost:9443 instead.
Then access the dashboard at https://localhost:9443 instead.
### Starting completely fresh
@@ -1054,7 +1071,7 @@ docker compose -f docker-compose.test.yml up --build
| What | Value |
|---|---|
| Dashboard URL | http://localhost:8443 |
| Dashboard URL | https://localhost:8443 (use `--cacert ./test/certs/ca.crt`) |
| API key | `test-key-2026` |
| NGINX HTTP | http://localhost:8080 |
| NGINX HTTPS | https://localhost:8444 |
+179
View File
@@ -0,0 +1,179 @@
# TLS on the Control Plane
certctl's control plane is HTTPS-only as of v2.2. There is no plaintext `http://` listener, no `auto` mode, no dual-listener bridge, no TLS 1.2 escape hatch. The server refuses to start without a cert+key pair, the agent/CLI/MCP clients reject `http://` URLs at startup, and the Helm chart refuses to render without either an operator-supplied Secret or a cert-manager Certificate CR.
This doc covers four cert provisioning patterns, SIGHUP-based cert rotation, and the client-side CA-trust configuration agents and the CLI need to talk to the server. If you are upgrading from a pre-HTTPS release and want the step-by-step cutover procedure, read [`upgrade-to-tls.md`](upgrade-to-tls.md) first and come back here for reference.
## What you get
The server binds TLS 1.3 only with an explicit curve preference of `[X25519, P-256]`. TLS 1.3 cipher suites are non-negotiable (all three mandatory suites — AES-128-GCM-SHA256, AES-256-GCM-SHA384, CHACHA20-POLY1305-SHA256 — are always offered), so there is no `CipherSuites` knob to misconfigure. No TLS 1.2 fallback is available.
Two env vars are required on the server:
- `CERTCTL_SERVER_TLS_CERT_PATH` — filesystem path to the PEM-encoded server certificate
- `CERTCTL_SERVER_TLS_KEY_PATH` — filesystem path to the PEM-encoded private key that signs the cert
Both paths are read during a fail-loud preflight in `cmd/server/main.go` (see `preflightServerTLS` in `cmd/server/tls.go`). If either is unset, unreadable, or the cert+key pair does not round-trip through `tls.LoadX509KeyPair`, the process refuses to start and emits a diagnostic pointing back at this doc. The rationale lives in §3 of the HTTPS-Everywhere milestone: a cert-lifecycle product should not silently bind plaintext.
## Pattern 1 — Self-signed bootstrap for docker-compose demos
This is the default for the `deploy/docker-compose.yml` stack. It exists so `docker compose up -d --build` just works on a laptop without the operator standing up a CA first. It is not appropriate for any non-demo environment.
An init container named `certctl-tls-init` runs once before the server starts. It uses the `alpine/openssl` image and generates an ed25519 self-signed cert:
```
openssl req -x509 -newkey ed25519 -nodes \
-keyout /etc/certctl/tls/server.key \
-out /etc/certctl/tls/server.crt \
-days 3650 \
-subj "/CN=certctl-server" \
-addext "subjectAltName=DNS:certctl-server,DNS:localhost,IP:127.0.0.1,IP:::1"
```
The cert, its matching key, and a copy of the cert published as `ca.crt` land in a named volume (`certs`) mounted at `/etc/certctl/tls/` in the server container (read-only) and the agent container (read-only). The bootstrap is idempotent — if `server.crt`, `server.key`, and `ca.crt` are already present on the volume, the init container logs `TLS cert already present at …` and exits cleanly.
Single-cert design. CN is `certctl-server` to match the Docker-network hostname. The SAN list is `[certctl-server, localhost, 127.0.0.1, ::1]`, which covers both container-internal agent→server traffic and operator browser/curl access to `https://localhost:8443`. There is no separate intermediate/root chain — the server cert and the CA bundle are the same PEM. This is the whole point of a demo bootstrap.
To force regeneration (rotate the demo cert), tear the volume down: `docker compose down -v`. The next `up` re-runs the init container.
The server's Docker healthcheck and the agent both verify against `/etc/certctl/tls/ca.crt`; no `-k` / `InsecureSkipVerify` anywhere in the default stack.
## Pattern 2 — Operator-supplied `kubernetes.io/tls` Secret (Helm)
This is the default path for Helm installs. The operator provisions a Secret of type `kubernetes.io/tls` holding `tls.crt` + `tls.key` (and optionally `ca.crt` for mounting a CA bundle to clients in the same cluster) from whatever source they already trust — their internal CA, a manually-issued cert, step-ca, AWS ACM PCA exported to PEM, or the output of the self-signed bootstrap pattern above copied into a cluster Secret.
```
kubectl create secret tls certctl-server-tls \
--cert=server.crt \
--key=server.key \
--namespace certctl
```
Then:
```
helm install certctl deploy/helm/certctl \
--namespace certctl \
--set server.tls.existingSecret=certctl-server-tls
```
The Secret is mounted read-only at `/etc/certctl/tls/` in the server pod. The `CERTCTL_SERVER_TLS_CERT_PATH` and `CERTCTL_SERVER_TLS_KEY_PATH` env vars are wired to `tls.crt` and `tls.key` keys inside that mount. If `ca.crt` is absent from the Secret, clients that need a CA bundle should use `tls.crt` as the bundle (self-signed case) or mount a separate ConfigMap with the root chain (operator-CA case).
If the operator sets neither `server.tls.existingSecret` nor `server.tls.certManager.enabled=true`, `helm template` / `helm install` fails at render-time with a diagnostic pointing at this doc. The guard is implemented in `deploy/helm/certctl/templates/_helpers.tpl` under the `certctl.tls.required` helper. This is deliberate: the HTTPS-only server would crash-loop on an empty path, so we fail earlier at Helm-render time.
## Pattern 3 — cert-manager `Certificate` CR (Helm, opt-in)
For clusters that already run cert-manager, the chart can provision a `Certificate` CR that writes into the Secret the server pod reads from. This is opt-in — the default is `server.tls.certManager.enabled: false` — because not every cluster has cert-manager installed, and we refuse to ship a chart that silently depends on an external controller.
```
helm install certctl deploy/helm/certctl \
--namespace certctl \
--set server.tls.certManager.enabled=true \
--set server.tls.certManager.issuerRef.name=my-cluster-issuer \
--set server.tls.certManager.issuerRef.kind=ClusterIssuer
```
The rendered `Certificate` (see `deploy/helm/certctl/templates/server-certificate.yaml`) writes `tls.crt` + `tls.key` + `ca.crt` into the Secret named by `server.tls.certManager.secretName` (defaults to `<fullname>-tls`). The server pod reads from that same Secret; the agent DaemonSet mounts the same Secret as its CA bundle source.
cert-manager handles rotation. certctl-server handles in-place reload — see the SIGHUP section below.
The chart enforces that if `server.tls.certManager.enabled=true`, `server.tls.certManager.issuerRef.name` must also be set. An empty `issuerRef.name` makes `helm template` fail with a diagnostic naming the missing flag.
## Pattern 4 — Manually-issued from an internal CA
For operators running neither Helm nor docker-compose (bare-metal / custom orchestration), the server just needs two files on disk pointed at by `CERTCTL_SERVER_TLS_CERT_PATH` and `CERTCTL_SERVER_TLS_KEY_PATH`. Issue the cert from your internal CA with:
- CN matching the hostname your agents and operators use to dial the server (e.g., `certctl.prod.example.com`)
- SAN list covering every hostname and IP that appears in `CERTCTL_SERVER_URL` values across your agent fleet
- Key usage: digital signature + key encipherment
- Extended key usage: server auth
Store the key with mode `0600` and owner matching the UID the server runs as (`1000` in our shipped Dockerfile). The server process reads both files during `preflightServerTLS` at startup and again on every SIGHUP.
The full CA chain that signed the server cert should be distributed to agents, CLI operators, and MCP clients as their `CERTCTL_SERVER_CA_BUNDLE_PATH` — see the client section below.
## SIGHUP cert rotation
The server wraps its cert+key pair in a `*certHolder` (see `cmd/server/tls.go`) that guards the loaded `*tls.Certificate` under a `sync.Mutex`. The `*tls.Config` wires `GetCertificate` to the holder, so every new inbound TLS handshake reads whatever cert the holder currently has.
Send `SIGHUP` to the server PID and the holder re-reads both files from disk. On success, the next new connection uses the new cert; in-flight requests finish on the previous cert. A log line goes out:
```
TLS cert reloaded via SIGHUP cert_path=/etc/certctl/tls/server.crt key_path=/etc/certctl/tls/server.key
```
On failure (missing file, malformed PEM, key does not sign cert), the old cert is retained and an error logs:
```
TLS cert reload failed; continuing with previous cert cert_path=… key_path=… error=…
```
This is deliberately fail-safe on reload (as opposed to fail-loud on startup). A cert-manager renewal race, a partially-copied file, a typo in a rotation script — none of those should crash a running server and drop every agent connection. The operator sees the error in logs, fixes the underlying issue, and sends another `SIGHUP`.
Pair with cert-manager, certbot `--post-hook`, or any rotation tool that can fire a signal. For docker-compose, `docker compose kill -s HUP certctl-server` works. For Kubernetes, reload is typically handled by cert-manager updating the Secret and the mounted file changing on the next kubelet sync — no explicit SIGHUP needed if the volume mount is `subPath`-free.
Startup is a different story. If the cert is missing or malformed at process start, the server exits non-zero rather than binding plaintext or attempting a retry loop. That's the HTTPS-only contract.
## Client-side TLS: agents, CLI, MCP
Everything that talks to the server enforces HTTPS on the URL.
### Agent
`CERTCTL_SERVER_URL` must be `https://…`. `http://`, bare hostnames, `ftp://`, `ws://`, and empty strings are rejected at startup by `validateHTTPSScheme` in `cmd/agent/main.go` with a diagnostic pointing at `upgrade-to-tls.md`. There is no warning-and-proceed path.
Two additional env vars control how the agent verifies the server cert:
- `CERTCTL_SERVER_CA_BUNDLE_PATH` — filesystem path to a PEM-encoded CA bundle that signed the server cert. Loaded into `*tls.Config.RootCAs` on the agent's HTTP client. If unset, the agent falls back to the OS system trust store.
- `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` — defaults to `false`. Setting it to `true` skips verification entirely. **Dev-only escape hatch.** The agent logs a prominent warning at startup (`TLS certificate verification is disabled … never enable this in production`). Use this only when dialing a demo server whose cert you haven't bothered to mount into the agent container.
Equivalent CLI flags: `--ca-bundle <path>` and `--insecure-skip-verify`.
If both the CA bundle and `InsecureSkipVerify=true` are set, `InsecureSkipVerify` wins — it's the whole point of the flag. Don't do this in production.
### CLI (`certctl-cli`)
Same contract as the agent:
- `CERTCTL_SERVER_URL` defaults to `https://` scheme; `http://` rejected at startup
- `--ca-bundle <path>` flag or `CERTCTL_SERVER_CA_BUNDLE_PATH` env var — CA bundle for server cert verification
- `--insecure` flag or `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true` — skip verification (dev only)
- Error diagnostic on empty URL explicitly mentions both `--server` and `CERTCTL_SERVER_URL` so operators see the right knob to turn
The CLI shares the URL-scheme validation with the agent; the test pins in `cmd/cli/main_test.go:TestValidateHTTPSScheme` cover the full rejection matrix.
### MCP server (`certctl-mcp-server`)
Same three controls as CLI, env-var-driven only (no flags — MCP runs as a stdio subprocess and inherits env from the launching LLM client):
- `CERTCTL_SERVER_URL` must start with `https://`
- `CERTCTL_SERVER_CA_BUNDLE_PATH` optional CA bundle
- `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY` optional skip
Claude Desktop / other MCP client configs should set all three in the tool's env block.
## Troubleshooting: fail-loud preflight errors
Every preflight failure message ends with `(see docs/tls.md)` so this doc is the first hit when an operator searches. Common failures:
**`CERTCTL_SERVER_TLS_CERT_PATH is empty: HTTPS-only control plane refuses to start`**
Set the env var. For docker-compose this is already set to `/etc/certctl/tls/server.crt` in the shipped compose file — if you're seeing this, check the `certctl-tls-init` service logs to see why the init container didn't populate the volume. For Helm, check that `server.tls.existingSecret` or `server.tls.certManager.enabled=true` is set.
**`TLS cert file "…" unreadable: …`**
The cert path is set but `os.Stat` failed. Check filesystem permissions — the server runs as UID 1000 in our shipped Dockerfile; the cert needs to be readable by that UID. Typos in the path also land here.
**`TLS cert/key pair invalid (cert="…" key="…"): …`**
Both files exist but `tls.LoadX509KeyPair` refused them. Typical causes: the private key does not sign the certificate, the key is encrypted with a passphrase (not supported — remove the passphrase with `openssl pkey` before mounting), or one of the two is DER-encoded instead of PEM. Re-issue the pair from the same CA call and re-mount.
**Client side: `tls: failed to verify certificate: x509: certificate signed by unknown authority`**
The client did not trust the CA that signed the server cert. Either mount the CA bundle via `CERTCTL_SERVER_CA_BUNDLE_PATH`, add the CA to the system trust store on the client host, or (dev only) set `CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true`.
**Client side: `tls: first record does not look like a TLS handshake`**
The client is speaking plaintext HTTP to an HTTPS server (or vice-versa). Check that `CERTCTL_SERVER_URL` starts with `https://`. If you are upgrading from a pre-v2.2 release and your agents are old, they will surface this error until you roll the DaemonSet — see [`upgrade-to-tls.md`](upgrade-to-tls.md).
## Related docs
- [`upgrade-to-tls.md`](upgrade-to-tls.md) — one-step cutover from pre-HTTPS releases
- [`quickstart.md`](quickstart.md) — docker-compose walkthrough with HTTPS examples
- [`test-env.md`](test-env.md) — integration test environment (also HTTPS-only)
- Milestone spec: `prompts/https-everywhere-milestone.md` (authoritative source for locked decisions)
+194
View File
@@ -0,0 +1,194 @@
# Upgrading to HTTPS-Everywhere (v2.2)
certctl's control plane is HTTPS-only as of v2.2. There is no `http` mode, no `auto` mode, no dual-listener bind, no N-release migration window. The cutover is a single step. Out-of-date agents that still point at `http://…` fail at the TCP/TLS handshake layer on first connect after the upgrade and stay `Offline` in the dashboard until their env block is updated and the fleet is rolled.
This doc walks operators through the cutover for the two shipped deployment topologies — docker-compose and Helm — and documents the failure modes and rollback posture explicitly.
For the deep-dive on cert provisioning patterns, SIGHUP cert reload, and client-side CA-trust configuration, read [`tls.md`](tls.md). This doc is the narrow "how do I upgrade" procedure.
## Preconditions
Before you start, confirm:
- **Shell access** to the server host and every agent host. The cutover requires you to restart the server and update every agent's env block.
- **A cert+key source** for the server. Pick one:
- An internal CA that can issue a server cert (CN + SAN list covering every hostname / IP agents dial).
- A `cert-manager` install in the target Kubernetes cluster, plus a `ClusterIssuer` or `Issuer` you're willing to reference.
- Willingness to use the self-signed bootstrap that the shipped `deploy/docker-compose.yml` generates automatically. This is the right choice for dev and demo; it is the wrong choice for production.
- **A maintenance window.** Out-of-date agents break at the TLS handshake and stay offline until rolled. Schedule the upgrade so the agent fleet can be updated in the same window as the server.
- **Backups.** This is a one-way door (see the Rollback section below). Snapshot your PostgreSQL database before `docker compose down` or `helm upgrade`.
There is no schema migration tied to this release; the only at-rest state that changes is the `certs` named volume (docker-compose) or the `tls.crt`/`tls.key` Secret (Helm).
## Procedure — docker-compose operators
The shipped `deploy/docker-compose.yml` includes a `certctl-tls-init` init container that self-signs an ed25519 cert on first boot and drops `server.crt`, `server.key`, and `ca.crt` into a named volume mounted read-only at `/etc/certctl/tls/` on the server and agent containers. No manual cert provisioning is required for the default stack.
1. **Pull the HTTPS-everywhere release.** From the repo root:
```
git pull
```
Confirm you're on a tag or `master` that contains the `certctl-tls-init` service in `deploy/docker-compose.yml`. Grep for it: `grep certctl-tls-init deploy/docker-compose.yml` should hit.
2. **Stop the old plaintext cluster.**
```
docker compose -f deploy/docker-compose.yml down
```
Do not pass `-v`; keeping the PostgreSQL volume preserves your cert inventory, audit trail, and job history across the upgrade.
3. **Bring the cluster back up with the HTTPS build.**
```
docker compose -f deploy/docker-compose.yml up -d --build
```
The `certctl-tls-init` service runs once, generates the self-signed cert into the `certs` volume, and exits with code 0. The server container waits for `certctl-tls-init` via `depends_on: { condition: service_completed_successfully }` and only starts once the cert material is on disk. The server's Docker healthcheck now uses `curl --cacert /etc/certctl/tls/ca.crt -f https://localhost:8443/health`, so the container only becomes healthy once the HTTPS listener is up and serving the bundled cert correctly.
4. **Verify the HTTPS endpoint from the host.**
```
curl --cacert $(docker compose -f deploy/docker-compose.yml exec -T certctl-server cat /etc/certctl/tls/ca.crt) https://localhost:8443/health
```
Expect `{"status":"ok"}` with HTTP 200. If you get a TLS verification error, the CA bundle wasn't read correctly — re-run the `exec -T` command and pipe the output directly into `--cacert @-` or save it to a local file first. If you get `connection refused`, the server never finished startup — check `docker compose logs certctl-server` for a fail-loud preflight diagnostic pointing at `docs/tls.md`.
5. **Confirm the bundled agent reconnects.** Agents inside the compose stack pick up the new URL (`CERTCTL_SERVER_URL=https://certctl-server:8443`) and the bundled CA (`CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt`) from their env block automatically — no per-agent change needed. Tail the agent log:
```
docker compose -f deploy/docker-compose.yml logs -f certctl-agent
```
You should see `heartbeat sent` within 30 seconds. In the dashboard (`https://localhost:8443`), the agent should show as `Online`.
**External agents** running outside the compose network (e.g., the `install-agent.sh`-installed systemd service on a separate host) need their env block updated manually before the cutover — see the Agent env block section below.
## Procedure — Helm operators
The Helm chart does not self-sign. It refuses to render (`helm template` exits non-zero) unless you configure one of two cert sources: an operator-supplied Secret, or a cert-manager `Certificate` CR. See [`tls.md`](tls.md) for the full pattern catalog.
1. **Provision cert material.** Pick one of:
- **Operator-supplied Secret.** Issue a cert from your internal CA (or any other source) and load it into a `kubernetes.io/tls` Secret in the certctl namespace:
```
kubectl create secret tls certctl-server-tls \
--cert=server.crt --key=server.key \
--namespace certctl
```
- **cert-manager.** Set `server.tls.certManager.enabled=true` on the upgrade and reference an existing `ClusterIssuer` or `Issuer`:
```
--set server.tls.certManager.enabled=true
--set server.tls.certManager.issuerRef.name=my-cluster-issuer
--set server.tls.certManager.issuerRef.kind=ClusterIssuer
```
2. **Upgrade the release.**
```
helm upgrade certctl deploy/helm/certctl \
--namespace certctl \
--set server.tls.existingSecret=certctl-server-tls
```
(Or the `certManager` variant.) If you omit both `server.tls.existingSecret` and `server.tls.certManager.enabled`, the chart fails at render time with a diagnostic pointing at `docs/tls.md`. That guard exists precisely so you catch the missing config at `helm upgrade` time, not at pod-crash-loop time.
3. **Verify the HTTPS endpoint from inside the cluster.** Port-forward and curl with the CA bundle:
```
kubectl port-forward -n certctl svc/certctl-server 8443:8443 &
kubectl get secret -n certctl certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/certctl-ca.crt
curl --cacert /tmp/certctl-ca.crt https://localhost:8443/health
```
Expect `{"status":"ok"}`. If the Secret does not contain a `ca.crt` key (operator-supplied Secrets often don't), use `tls.crt` as the bundle instead — for a self-signed cert the two files are identical, and for a cert chained to an internal CA you should separately distribute the root CA bundle via ConfigMap or mounted file.
4. **Update every agent manifest.** Agents outside this Helm release (or in a separately-managed DaemonSet) need their env block updated:
```
- name: CERTCTL_SERVER_URL
value: "https://certctl-server.certctl.svc.cluster.local:8443"
- name: CERTCTL_SERVER_CA_BUNDLE_PATH
value: "/etc/certctl/tls/ca.crt"
```
Mount the server's Secret (or a separate CA-bundle Secret / ConfigMap) at `/etc/certctl/tls/` as a read-only volume. If you bundle the agent via the shipped Helm chart's DaemonSet, the wiring is already done — set `agent.enabled=true` and the chart mounts the same Secret.
5. **Roll the agent DaemonSet.**
```
kubectl rollout restart ds/certctl-agent -n certctl
kubectl rollout status ds/certctl-agent -n certctl
```
Every agent pod restarts with the new URL + CA bundle and reconnects on HTTPS. The dashboard shows agents flip from `Offline` to `Online` as pods finish rolling.
## Agent env block — external hosts
Agents installed on bare-metal or VM hosts via `install-agent.sh` (systemd on Linux, launchd on macOS) read config from `/etc/certctl/agent.env` (Linux) or `~/Library/Application Support/certctl/agent.env` (macOS). On cutover, append or update:
```
CERTCTL_SERVER_URL=https://certctl.example.com:8443
CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/tls/ca.crt
# CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=false # Dev only. Never set to true in production.
```
Distribute the CA bundle (the same `ca.crt` the server holds, or the root chain if you issued the server cert from an intermediate) to every agent host. The path under `CERTCTL_SERVER_CA_BUNDLE_PATH` must be readable by the UID the agent service runs as.
Restart the service after editing:
- Linux: `systemctl restart certctl-agent`
- macOS: `launchctl kickstart -k system/com.certctl.agent`
The agent refuses to start on an `http://` URL and exits with a pre-flight diagnostic that names this doc. That rejection happens before any network call — no spurious half-connected state.
## Failure mode
Out-of-date agents still configured with `CERTCTL_SERVER_URL=http://…` fail on first reconnect after the cutover. The failure surfaces as one of:
- `dial tcp …: connect: connection refused` — the server is no longer listening on a plaintext port. The new release binds only a TLS listener; attempting a plaintext `connect()` gets refused at the kernel level because nothing holds the socket.
- `tls: first record does not look like a TLS handshake` — depending on timing and proxy layers (e.g., a load balancer that accepts the TCP connection before forwarding), the client may negotiate TCP, send an HTTP request line, and have the server's TLS stack reject it.
Agents in this state surface as `Offline` in the dashboard. They stay offline until their env block is updated and the service restarts. There is no graceful 400-with-migration-URL response because there is no HTTP listener to serve one from — the entire plaintext call path is removed by design.
If you see an unexpected agent stay `Offline` past the cutover window, SSH to the host and check the agent log. On a systemd host:
```
journalctl -u certctl-agent -n 100
```
Look for `URL scheme "http" is not supported: HTTPS-only control plane refuses to start (see docs/upgrade-to-tls.md)`. That's the pre-flight rejection. Update `CERTCTL_SERVER_URL`, restart the service, and the agent reconnects.
## Rollback
**There is no rollback window.** The upgrade is a one-way door. The rationale lives in §3.7 of `prompts/https-everywhere-milestone.md`: a cert-lifecycle product that bridges back to plaintext after committing to HTTPS is advertising that its own security posture is negotiable.
If you need to revert, you have two options:
1. **Stay on the pre-HTTPS release.** Do not upgrade until you are ready to run HTTPS on the control plane. Pin your `docker-compose.yml` or `helm upgrade` command to the last pre-v2.2 tag.
2. **Rollback the release.** `helm rollback certctl <previous-revision>` or `git checkout <previous-tag> && docker compose up -d --build`. This rolls back the server, the compose topology, and the Helm chart in lockstep. Your PostgreSQL volume — cert inventory, audit trail, jobs — survives the rollback; nothing in this milestone changes the database schema.
Option 2 drops you back to the plaintext world. It should be treated as an emergency measure, not a supported migration path.
## After the cutover
Once every agent is `Online`, confirm a few invariants:
- `curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8443/health` returns `000` with `Connection refused` (no HTTP listener). Plaintext is gone.
- `openssl s_client -connect localhost:8443 -tls1_2 </dev/null` fails the handshake. TLS 1.2 is rejected.
- `openssl s_client -connect localhost:8443 -tls1_3 </dev/null` succeeds and prints the server's SAN list. TLS 1.3 is live.
- A cert rotation test: overwrite the server cert on disk, `kill -HUP` the server PID, confirm the new cert serves on the next `openssl s_client -connect … -showcerts` without a process restart. See the SIGHUP section in [`tls.md`](tls.md).
Update your runbooks. Every `http://certctl.example.com` URL in internal documentation, monitoring config, and on-call playbooks should become `https://certctl.example.com` plus a CA-trust note.
## Related docs
- [`tls.md`](tls.md) — cert provisioning patterns, SIGHUP rotation, troubleshooting
- [`quickstart.md`](quickstart.md) — docker-compose walkthrough (post-HTTPS)
- [`test-env.md`](test-env.md) — integration test environment (HTTPS-only)
- Milestone spec: `prompts/https-everywhere-milestone.md`
+1 -1
View File
@@ -107,7 +107,7 @@ The demo seeds certificates across multiple issuers, agents, and deployment targ
```bash
git clone https://github.com/shankar0123/certctl.git
cd certctl/deploy && docker compose up -d
# Dashboard at http://localhost:8443
# Dashboard at https://localhost:8443 (self-signed cert — pin deploy/test/certs/ca.crt)
```
See the [Quickstart Guide](quickstart.md) for a full walkthrough, or explore the [5 turnkey examples](../examples/) for specific scenarios (ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer).
+8 -1
View File
@@ -36,6 +36,13 @@ flowchart TD
If you don't have a real domain or can't open port 80, see [Customization Tips](#customization-tips) below.
## TLS Security
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
- Use `curl -k ...` for quick smoke tests (never in production)
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
## Quick Start
### 1. Clone or copy this example
@@ -122,7 +129,7 @@ docker compose logs -f certctl-server certctl-agent
### 5. Access the dashboard
Navigate to `http://localhost:8443` (or your `SERVER_PORT`)
Navigate to `https://localhost:8443` (or your `SERVER_PORT`)
You should see:
- An empty certificate inventory (no certs issued yet)
+1 -1
View File
@@ -61,7 +61,7 @@ services:
networks:
- certctl-network
healthcheck:
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
interval: 10s
timeout: 5s
retries: 3
@@ -9,6 +9,13 @@ This example is ideal for:
- Internal PKI with public DNS names
- Scenarios where you have programmatic access to your DNS provider's API
## TLS Security
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
- Use `curl -k ...` for quick smoke tests (never in production)
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
## Prerequisites
Before running this example, you need:
@@ -74,7 +81,7 @@ This starts:
### Step 5: Access the Dashboard
Open your browser to `http://localhost:8443`
Open your browser to `https://localhost:8443`
### Step 6: Create a Wildcard Certificate
@@ -113,7 +113,7 @@ services:
- certctl-network
healthcheck:
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
interval: 10s
timeout: 5s
retries: 3
+1 -1
View File
@@ -64,7 +64,7 @@ services:
networks:
- certctl-network
healthcheck:
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
interval: 10s
timeout: 5s
retries: 3
+8 -1
View File
@@ -45,6 +45,13 @@ flowchart TD
- **Domain for ACME** (optional) — if using real Let's Encrypt, not needed for demo
- **Internet connectivity** — to reach Let's Encrypt's API (demo can use staging directory)
## TLS Security
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
- Use `curl -k ...` for quick smoke tests (never in production)
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
## Quick Start
### 1. Clone or navigate to this directory
@@ -83,7 +90,7 @@ This spins up:
### 4. Access the dashboard
Open your browser to **http://localhost:8443** (or your configured SERVER_PORT)
Open your browser to **https://localhost:8443** (or your configured SERVER_PORT)
You should see:
- Empty cert inventory (fresh start)
@@ -77,7 +77,7 @@ services:
networks:
- certctl-network
healthcheck:
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
interval: 10s
timeout: 5s
retries: 3
@@ -29,6 +29,13 @@ flowchart TD
C -->|TLS handshakes| D
```
## TLS Security
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
- Use `curl -k ...` for quick smoke tests (never in production)
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
## Quick Start (Self-Signed CA)
The simplest way to get running in 2 minutes:
@@ -58,7 +65,7 @@ EOF
docker compose up -d
# 4. Access the dashboards
# - certctl: http://localhost:8443 (API only, use the CLI or direct HTTP calls)
# - certctl: https://localhost:8443 (API only, use the CLI or direct HTTP calls)
# - Traefik dashboard: http://localhost:8080
```
@@ -112,7 +119,7 @@ Once the stack is running:
```bash
# 1. Create a certificate profile in certctl (defines allowed key types, TTL, etc.)
curl -X POST http://localhost:8443/api/v1/profiles \
curl -X POST https://localhost:8443/api/v1/profiles \
-H "Content-Type: application/json" \
-d '{
"id": "prof-internal",
@@ -123,7 +130,7 @@ curl -X POST http://localhost:8443/api/v1/profiles \
}'
# 2. Create a renewal policy (defines issuer, renewal thresholds, etc.)
curl -X POST http://localhost:8443/api/v1/policies \
curl -X POST https://localhost:8443/api/v1/policies \
-H "Content-Type: application/json" \
-d '{
"id": "pol-internal",
@@ -135,7 +142,7 @@ curl -X POST http://localhost:8443/api/v1/policies \
}'
# 3. Create a certificate (triggers issuance immediately)
curl -X POST http://localhost:8443/api/v1/certificates \
curl -X POST https://localhost:8443/api/v1/certificates \
-H "Content-Type: application/json" \
-d '{
"common_name": "api.internal.local",
@@ -144,7 +151,7 @@ curl -X POST http://localhost:8443/api/v1/certificates \
}'
# 4. Create a Traefik target (agent will deploy to this)
curl -X POST http://localhost:8443/api/v1/targets \
curl -X POST https://localhost:8443/api/v1/targets \
-H "Content-Type: application/json" \
-d '{
"id": "target-traefik-01",
@@ -156,7 +163,7 @@ curl -X POST http://localhost:8443/api/v1/targets \
}'
# 5. Create a deployment job (agent picks this up and deploys)
curl -X POST http://localhost:8443/api/v1/certificates/{cert-id}/deploy \
curl -X POST https://localhost:8443/api/v1/certificates/{cert-id}/deploy \
-H "Content-Type: application/json" \
-d '{
"target_ids": ["target-traefik-01"]
@@ -209,16 +216,16 @@ The server provides a REST API on port 8443. Example queries:
```bash
# List all certificates
curl http://localhost:8443/api/v1/certificates
curl https://localhost:8443/api/v1/certificates
# Check certificate status
curl http://localhost:8443/api/v1/certificates/{cert-id}
curl https://localhost:8443/api/v1/certificates/{cert-id}
# View audit trail
curl http://localhost:8443/api/v1/audit
curl https://localhost:8443/api/v1/audit
# Check renewal policy compliance
curl http://localhost:8443/api/v1/policies/{policy-id}
curl https://localhost:8443/api/v1/policies/{policy-id}
```
### Traefik Dashboard
@@ -290,7 +297,7 @@ Changes are picked up automatically (file watcher enabled).
docker compose logs certctl-agent | grep heartbeat
# Check deployment job status
curl http://localhost:8443/api/v1/jobs | jq '.[] | select(.type == "Deployment")'
curl https://localhost:8443/api/v1/jobs | jq '.[] | select(.type == "Deployment")'
# Check Traefik is watching the directory
docker compose exec traefik ls -la /etc/traefik/certs/
+1 -1
View File
@@ -119,7 +119,7 @@ services:
networks:
- certctl-network
healthcheck:
test: ['CMD-SHELL', 'curl -sf http://localhost:8443/health || exit 1']
test: ['CMD-SHELL', 'curl -sfk https://localhost:8443/health || exit 1']
interval: 10s
timeout: 5s
retries: 3
+15 -8
View File
@@ -48,6 +48,13 @@ Monitor logs:
docker compose logs -f certctl-server
```
## TLS Security
certctl is HTTPS-only as of v2.2. The demo compose stack provisions a self-signed certificate. When accessing `https://localhost:8443`, you can either:
- Use `curl --cacert ./deploy/test/certs/ca.crt ...` to pin the CA certificate
- Use `curl -k ...` for quick smoke tests (never in production)
- Import the CA at `./deploy/test/certs/ca.crt` into your OS trust store for browser visits
Wait for all services to reach healthy state:
```bash
@@ -69,7 +76,7 @@ certctl-haproxy-... healthy
Open your browser to:
```
http://localhost:8443
https://localhost:8443
```
You should see an empty dashboard. This is expected — no certificates issued yet.
@@ -79,7 +86,7 @@ You should see an empty dashboard. This is expected — no certificates issued y
This defines what certificates certctl can issue (key algorithm, max TTL, allowed names).
```bash
curl -X POST http://localhost:8443/api/v1/profiles \
curl -X POST https://localhost:8443/api/v1/profiles \
-H 'Content-Type: application/json' \
-d '{
"name": "internal-web",
@@ -94,7 +101,7 @@ curl -X POST http://localhost:8443/api/v1/profiles \
This tells certctl where to deploy certificates on the HAProxy server.
```bash
curl -X POST http://localhost:8443/api/v1/targets \
curl -X POST https://localhost:8443/api/v1/targets \
-H 'Content-Type: application/json' \
-d '{
"name": "haproxy-01",
@@ -115,7 +122,7 @@ Note: In the Docker Compose environment, reload command can be `kill -HUP $(pido
This ties a certificate profile to a deployment target and sets renewal thresholds.
```bash
curl -X POST http://localhost:8443/api/v1/renewal-policies \
curl -X POST https://localhost:8443/api/v1/renewal-policies \
-H 'Content-Type: application/json' \
-d '{
"name": "haproxy-internal-web",
@@ -130,7 +137,7 @@ curl -X POST http://localhost:8443/api/v1/renewal-policies \
Get the issuer ID:
```bash
curl http://localhost:8443/api/v1/issuers | jq '.'
curl https://localhost:8443/api/v1/issuers | jq '.'
```
You should see `iss-stepca` in the list.
@@ -140,7 +147,7 @@ You should see `iss-stepca` in the list.
Request a certificate via the API. The server will sign it via step-ca.
```bash
curl -X POST http://localhost:8443/api/v1/certificates \
curl -X POST https://localhost:8443/api/v1/certificates \
-H 'Content-Type: application/json' \
-d '{
"common_name": "api.internal.example.com",
@@ -155,7 +162,7 @@ curl -X POST http://localhost:8443/api/v1/certificates \
Get the certificate ID and trigger deployment:
```bash
curl -X POST http://localhost:8443/api/v1/certificates/<cert_id>/deploy \
curl -X POST https://localhost:8443/api/v1/certificates/<cert_id>/deploy \
-H 'Content-Type: application/json' \
-d '{
"target_id": "<target_id_from_step_4>"
@@ -171,7 +178,7 @@ The agent will:
### 8. Verify in Dashboard
Refresh http://localhost:8443 and you should see:
Refresh https://localhost:8443 and you should see:
- 1 certificate (status: Active, expiry in 90 days)
- 1 deployment job (status: Completed)
- 1 agent (heartbeat: recent)
+40 -2
View File
@@ -75,6 +75,14 @@ EXAMPLES:
--server-url https://certctl.example.com \\
--api-key YOUR_API_KEY
CONTROL-PLANE TLS TRUST:
The certctl server is HTTPS-only as of v2.2. This installer does NOT copy a CA
bundle — the generated agent.env leaves TLS trust to the system root store by
default. If the server uses a private/enterprise or self-signed CA, set
CERTCTL_SERVER_CA_BUNDLE_PATH in the generated agent.env to point at the CA
bundle, or (dev only) CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true. See the
commented block in the generated agent.env for the full menu.
EOF
}
@@ -322,7 +330,7 @@ setup_linux_config() {
# Agent ID (unique identifier in the fleet)
CERTCTL_AGENT_ID=$AGENT_ID
# Control plane server URL
# Control plane server URL (HTTPS-only as of v2.2)
CERTCTL_SERVER_URL=$SERVER_URL
# API authentication key
@@ -334,6 +342,21 @@ CERTCTL_KEYGEN_MODE=agent
# Key storage directory (agent-side keygen)
CERTCTL_KEY_DIR=$key_dir
# ---- Control-plane TLS trust ----
# The certctl server is HTTPS-only (v2.2+). The agent's HTTP client MUST trust the
# server's certificate chain. Pick ONE of the approaches below:
#
# 1) Public CA (Let's Encrypt, DigiCert, etc.) — no config needed; system trust store works.
# 2) Private / enterprise CA — point the agent at the CA bundle that signed the server cert:
# CERTCTL_SERVER_CA_BUNDLE_PATH=/etc/certctl/server-ca.crt
#
# 3) Self-signed server cert (Helm/compose bootstrap) — same env var, just point at the
# extracted self-signed CA bundle (e.g. from the certctl-server-tls Kubernetes secret
# via: kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d).
#
# 4) Dev/eval only — disable verification entirely (NEVER do this in production):
# CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true
# Logging level (debug, info, warn, error)
# CERTCTL_LOG_LEVEL=info
@@ -373,7 +396,7 @@ setup_macos_config() {
# Agent ID (unique identifier in the fleet)
CERTCTL_AGENT_ID=$AGENT_ID
# Control plane server URL
# Control plane server URL (HTTPS-only as of v2.2)
CERTCTL_SERVER_URL=$SERVER_URL
# API authentication key
@@ -385,6 +408,21 @@ CERTCTL_KEYGEN_MODE=agent
# Key storage directory (agent-side keygen)
CERTCTL_KEY_DIR=$key_dir
# ---- Control-plane TLS trust ----
# The certctl server is HTTPS-only (v2.2+). The agent's HTTP client MUST trust the
# server's certificate chain. Pick ONE of the approaches below:
#
# 1) Public CA (Let's Encrypt, DigiCert, etc.) — no config needed; system trust store works.
# 2) Private / enterprise CA — point the agent at the CA bundle that signed the server cert:
# CERTCTL_SERVER_CA_BUNDLE_PATH=$HOME/.certctl/server-ca.crt
#
# 3) Self-signed server cert (Helm/compose bootstrap) — same env var, just point at the
# extracted self-signed CA bundle (e.g. from the certctl-server-tls Kubernetes secret
# via: kubectl get secret certctl-server-tls -o jsonpath='{.data.ca\.crt}' | base64 -d).
#
# 4) Dev/eval only — disable verification entirely (NEVER do this in production):
# CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY=true
# Logging level (debug, info, warn, error)
# CERTCTL_LOG_LEVEL=info
+6 -6
View File
@@ -46,7 +46,7 @@ func TestClient_RetireAgent_Success(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
// Positional arg: the agent ID. No --force, no --reason — the default
// soft-retire path. Compile-fail until client.RetireAgent exists.
if err := client.RetireAgent([]string{"ag-1"}); err != nil {
@@ -101,7 +101,7 @@ func TestClient_RetireAgent_Force_WithReason_Success(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
if err := client.RetireAgent([]string{"ag-1", "--force", "--reason", "decommissioning rack 7"}); err != nil {
t.Fatalf("RetireAgent(force+reason) err=%v want nil", err)
}
@@ -126,7 +126,7 @@ func TestClient_RetireAgent_Force_RequiresReason(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.RetireAgent([]string{"ag-1", "--force"})
if err == nil {
t.Fatalf("RetireAgent(force, no reason) err=nil want client-side error")
@@ -150,7 +150,7 @@ func TestClient_RetireAgent_MissingID(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.RetireAgent([]string{})
if err == nil {
t.Fatalf("RetireAgent([]) err=nil want missing-id error")
@@ -198,7 +198,7 @@ func TestClient_ListRetiredAgents_Success(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
if err := client.ListRetiredAgents([]string{}); err != nil {
t.Fatalf("ListRetiredAgents err=%v want nil", err)
}
@@ -220,7 +220,7 @@ func TestClient_ListRetiredAgents_ServerError(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ListRetiredAgents([]string{})
if err == nil {
t.Fatalf("ListRetiredAgents(500) err=nil want propagated error")
+32 -2
View File
@@ -2,6 +2,7 @@ package cli
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
@@ -26,15 +27,44 @@ type Client struct {
}
// NewClient creates a new CLI client.
func NewClient(baseURL, apiKey, format string) *Client {
//
// HTTPS-Everywhere (v2.2): the certctl control plane is HTTPS-only. caBundlePath,
// when non-empty, points at a PEM bundle used to verify the server cert; otherwise
// the system trust store is used. insecure skips cert verification — dev only,
// never enable in production. The TLS config is attached to *http.Transport so
// every call goes through the same verified socket.
func NewClient(baseURL, apiKey, format, 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,
format: format,
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
}
// do performs an HTTP request and returns the parsed JSON response.
+207 -15
View File
@@ -3,6 +3,7 @@ package cli
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
@@ -39,7 +40,7 @@ func TestClient_ListCertificates(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ListCertificates([]string{})
if err != nil {
t.Fatalf("ListCertificates failed: %v", err)
@@ -64,7 +65,7 @@ func TestClient_GetCertificate(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "json")
client, _ := NewClient(server.URL, "", "json", "", false)
err := client.GetCertificate("mc-1")
if err != nil {
t.Fatalf("GetCertificate failed: %v", err)
@@ -86,7 +87,7 @@ func TestClient_RenewCertificate(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.RenewCertificate("mc-1")
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
@@ -107,7 +108,7 @@ func TestClient_RevokeCertificate(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.RevokeCertificate("mc-1", "cessationOfOperation")
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
@@ -141,7 +142,7 @@ func TestClient_BulkRevokeCertificates(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.BulkRevokeCertificates([]string{
"--reason", "keyCompromise",
"--profile-id", "prof-tls",
@@ -175,7 +176,7 @@ func TestClient_ListAgents(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ListAgents([]string{})
if err != nil {
t.Fatalf("ListAgents failed: %v", err)
@@ -201,7 +202,7 @@ func TestClient_GetAgent(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "json")
client, _ := NewClient(server.URL, "", "json", "", false)
err := client.GetAgent("ag-1")
if err != nil {
t.Fatalf("GetAgent failed: %v", err)
@@ -232,7 +233,7 @@ func TestClient_ListJobs(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ListJobs([]string{})
if err != nil {
t.Fatalf("ListJobs failed: %v", err)
@@ -258,7 +259,7 @@ func TestClient_GetJob(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "json")
client, _ := NewClient(server.URL, "", "json", "", false)
err := client.GetJob("job-1")
if err != nil {
t.Fatalf("GetJob failed: %v", err)
@@ -276,7 +277,7 @@ func TestClient_CancelJob(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.CancelJob("job-1")
if err != nil {
t.Fatalf("CancelJob failed: %v", err)
@@ -308,7 +309,7 @@ func TestClient_GetStatus(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.GetStatus()
if err != nil {
t.Fatalf("GetStatus failed: %v", err)
@@ -381,7 +382,7 @@ func TestClient_AuthHeader(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "testkey123", "json")
client, _ := NewClient(server.URL, "testkey123", "json", "", false)
client.do("GET", "/api/v1/certificates", nil, nil)
if authHeader != "Bearer testkey123" {
@@ -439,7 +440,7 @@ func TestClient_ImportCertificates_MissingRequiredFlags(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ImportCertificates(tc.args)
if err == nil {
t.Fatalf("expected error for %s, got nil", tc.name)
@@ -468,7 +469,7 @@ func TestClient_ImportCertificates_MissingPositionalArgs(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ImportCertificates([]string{
"--owner-id", "o-alice",
"--team-id", "t-platform",
@@ -513,7 +514,7 @@ func TestClient_ImportCertificates_SixFieldPayload(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "", "table")
client, _ := NewClient(server.URL, "", "table", "", false)
err := client.ImportCertificates([]string{
"--owner-id", "o-alice",
"--team-id", "t-platform",
@@ -583,3 +584,194 @@ func generateTestCert() *x509.Certificate {
return cert
}
// -----------------------------------------------------------------------------
// HTTPS-Everywhere milestone (v2.2, §3.2 + §7 Phase 5):
// The CLI binary now talks HTTPS-only to the control plane. These tests pin the
// three contracts the milestone requires every client binary (agent, CLI, MCP)
// to satisfy in lock-step:
// (a) CA bundle load success — PEM loads, RootCAs + MinVersion=TLS1.3 wired
// through the injected *http.Transport so the httpClient actually uses them.
// (b) CA bundle load failure — missing file and malformed/empty PEM each fail
// loud with a pinned substring so operators get a useful diagnostic instead
// of a later TLS-handshake-error mystery.
// (c) End-to-end TLS round-trip — an httptest.NewTLSServer whose own cert is
// written out as the CA bundle validates that every TLS-config knob is
// actually reaching the wire, not just surviving into the struct.
// Each of the three client binaries pins the same three contracts against its
// own NewClient signature; drifting any of them in isolation is exactly what
// this suite is here to catch. The error-string substrings below must stay in
// sync with the fmt.Errorf messages in internal/cli/client.go:NewClient.
// -----------------------------------------------------------------------------
// writeCABundle PEM-encodes a DER cert and writes it to a temp file under the
// test's own TempDir. Returns the absolute path of the written bundle so test
// callers can pass it straight into NewClient(..., caBundlePath, ...).
func writeCABundle(t *testing.T, dir string, certDER []byte, filename string) string {
t.Helper()
path := filepath.Join(dir, filename)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
t.Fatalf("writing CA bundle to %q: %v", path, err)
}
return path
}
// TestNewClient_CABundle_Success pins the happy path: a valid PEM CA bundle
// loads, populates RootCAs on the client's TLS config, and leaves
// MinVersion=TLS1.3 intact. Regression guard: if a future edit accidentally
// swaps the transport after TLS config setup (or forgets to re-attach the
// *tls.Config to *http.Transport), this test catches it before ops does.
func TestNewClient_CABundle_Success(t *testing.T) {
cert := generateTestCert()
tmp := t.TempDir()
bundlePath := writeCABundle(t, tmp, cert.Raw, "ca.pem")
client, err := NewClient("https://certctl-server:8443", "test-key", "table", bundlePath, false)
if err != nil {
t.Fatalf("NewClient with valid CA bundle err=%v want nil", err)
}
if client == nil {
t.Fatal("NewClient returned nil client on happy path")
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("httpClient.Transport type=%T want *http.Transport (TLS config injection broke)", client.httpClient.Transport)
}
if transport.TLSClientConfig == nil {
t.Fatal("transport.TLSClientConfig is nil; TLS config must be set on every client")
}
if transport.TLSClientConfig.RootCAs == nil {
t.Fatal("transport.TLSClientConfig.RootCAs is nil; CA bundle path was ignored")
}
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion=%d want tls.VersionTLS13 (%d); HTTPS-Everywhere requires TLS1.3 floor",
transport.TLSClientConfig.MinVersion, tls.VersionTLS13)
}
if transport.TLSClientConfig.InsecureSkipVerify {
t.Error("InsecureSkipVerify=true with insecure=false arg; flag wiring crossed")
}
}
// TestNewClient_CABundle_MissingFile pins the fail-loud path for a nonexistent
// bundle path. The error surface must include "reading CA bundle" so operators
// see the right diagnostic instead of a downstream TLS-handshake-error.
func TestNewClient_CABundle_MissingFile(t *testing.T) {
_, err := NewClient("https://certctl-server:8443", "test-key", "table", "/nonexistent/path/ca.pem", false)
if err == nil {
t.Fatal("NewClient with missing CA bundle err=nil; must fail loud so operators see the right diagnostic")
}
if !containsStr(err.Error(), "reading CA bundle") {
t.Errorf("err=%q must contain %q so operators can locate the misconfigured path", err.Error(), "reading CA bundle")
}
}
// TestNewClient_CABundle_EmptyPEM pins the fail-loud path for a file whose
// contents are not valid PEM certificate data. AppendCertsFromPEM returning
// false is the signal we need to surface — otherwise the client would silently
// ship with an empty cert pool and every TLS handshake would fail downstream.
func TestNewClient_CABundle_EmptyPEM(t *testing.T) {
tmp := t.TempDir()
garbagePath := filepath.Join(tmp, "garbage.pem")
if err := os.WriteFile(garbagePath, []byte("not a pem certificate, just bytes"), 0o600); err != nil {
t.Fatalf("writing garbage file: %v", err)
}
_, err := NewClient("https://certctl-server:8443", "test-key", "table", garbagePath, false)
if err == nil {
t.Fatal("NewClient with malformed PEM err=nil; must fail loud, not silently skip")
}
if !containsStr(err.Error(), "no valid PEM-encoded certificates") {
t.Errorf("err=%q must contain %q so operators know the file parsed but held no certs",
err.Error(), "no valid PEM-encoded certificates")
}
}
// TestNewClient_TLSRoundTrip validates that the TLS config knobs we set on
// NewClient actually reach the wire. An httptest.NewTLSServer signs its own
// self-signed leaf; we PEM-encode that server cert, write it as the CA bundle,
// and issue a real HTTPS call through ListCertificates. A successful round-trip
// proves RootCAs + MinVersion are flowing through *http.Transport into the
// dialer, not just surviving into the client struct.
func TestNewClient_TLSRoundTrip(t *testing.T) {
var handlerHit int
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/api/v1/certificates" {
handlerHit++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{},
"total": 0,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
serverCert := server.Certificate()
if serverCert == nil {
t.Fatal("httptest.NewTLSServer.Certificate() returned nil; cannot build CA bundle")
}
tmp := t.TempDir()
bundlePath := writeCABundle(t, tmp, serverCert.Raw, "server-ca.pem")
client, err := NewClient(server.URL, "test-key", "table", bundlePath, false)
if err != nil {
t.Fatalf("NewClient(TLS server) err=%v want nil", err)
}
if err := client.ListCertificates([]string{}); err != nil {
t.Fatalf("ListCertificates over HTTPS err=%v; TLS config must reach the wire", err)
}
if handlerHit != 1 {
t.Errorf("handlerHit=%d want 1; request did not reach the TLS server", handlerHit)
}
}
// TestNewClient_InsecureSkipVerify pins the dev-only escape hatch: an untrusted
// TLS server (cert NOT in the client's root pool) must be reachable when
// insecure=true. This is the only path in the control plane that disables
// certificate verification; it's documented in docs/tls.md and gated by the
// CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY env var so it never slips into
// production silently.
func TestNewClient_InsecureSkipVerify(t *testing.T) {
var handlerHit int
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerHit++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []map[string]interface{}{},
"total": 0,
})
}))
defer server.Close()
// No CA bundle → system roots, which will NOT trust the self-signed
// httptest cert. insecure=true is the only thing keeping this call from
// failing with an x509-unknown-authority error.
client, err := NewClient(server.URL, "test-key", "table", "", true)
if err != nil {
t.Fatalf("NewClient(insecure=true) err=%v want nil", err)
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("httpClient.Transport type=%T want *http.Transport", client.httpClient.Transport)
}
if !transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("insecure=true arg did not set TLSClientConfig.InsecureSkipVerify; flag wiring broken")
}
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion=%d want tls.VersionTLS13 even with insecure=true (TLS1.3 floor is not optional)",
transport.TLSClientConfig.MinVersion)
}
if err := client.ListCertificates([]string{}); err != nil {
t.Fatalf("ListCertificates(insecure=true) err=%v; escape hatch must still complete the round-trip", err)
}
if handlerHit != 1 {
t.Errorf("handlerHit=%d want 1; insecure round-trip did not reach the server", handlerHit)
}
}
+60
View File
@@ -1,6 +1,7 @@
package config
import (
"crypto/tls"
"fmt"
"log/slog"
"os"
@@ -680,6 +681,27 @@ type ServerConfig struct {
Host string // Server host (default: 127.0.0.1). Set via CERTCTL_SERVER_HOST.
Port int // Server port (default: 8080). Set via CERTCTL_SERVER_PORT.
MaxBodySize int64 // Maximum request body size in bytes (default: 1MB). Set via CERTCTL_MAX_BODY_SIZE.
TLS ServerTLSConfig // HTTPS-only TLS configuration. Both CertPath and KeyPath are required.
}
// ServerTLSConfig holds the server-side TLS material.
//
// The control plane is HTTPS-only as of the HTTPS-everywhere milestone
// (§3 locked decisions: no `http` mode, no dual-listener, TLS 1.3 only).
// Both CertPath and KeyPath are required; an empty value causes
// Config.Validate() to return a fail-loud error and the server refuses
// to start. There is no plaintext HTTP fallback, no N-release migration
// bridge, and no auto-generated self-signed cert — operators either
// supply a cert on disk (docker-compose init container, operator-managed
// file, cert-manager mount) or the process exits non-zero.
type ServerTLSConfig struct {
// CertPath is the filesystem path to the server's PEM-encoded X.509
// certificate. Set via CERTCTL_SERVER_TLS_CERT_PATH. Required.
CertPath string
// KeyPath is the filesystem path to the server's PEM-encoded private
// key that signs CertPath. Set via CERTCTL_SERVER_TLS_KEY_PATH. Required.
KeyPath string
}
// DatabaseConfig contains database connection configuration.
@@ -841,6 +863,13 @@ func Load() (*Config, error) {
Host: getEnv("CERTCTL_SERVER_HOST", "127.0.0.1"),
Port: getEnvInt("CERTCTL_SERVER_PORT", 8080),
MaxBodySize: getEnvInt64("CERTCTL_MAX_BODY_SIZE", 1024*1024), // 1MB default
// HTTPS-everywhere milestone §2.1: both paths REQUIRED. Empty defaults
// are intentional so Validate() emits a fail-loud error pointing at
// docs/tls.md rather than silently binding plaintext HTTP.
TLS: ServerTLSConfig{
CertPath: getEnv("CERTCTL_SERVER_TLS_CERT_PATH", ""),
KeyPath: getEnv("CERTCTL_SERVER_TLS_KEY_PATH", ""),
},
},
Database: DatabaseConfig{
URL: getEnv("CERTCTL_DATABASE_URL", "postgres://localhost/certctl"),
@@ -1059,6 +1088,37 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
// HTTPS-everywhere milestone §2.1 + §3 locked decisions: the control plane
// is TLS-only and refuses to start without a cert. No plaintext HTTP fallback,
// no auto-generated self-signed cert, no N-release migration window. An empty
// CertPath or KeyPath is operator-visible misconfiguration, not a soft warning.
if c.Server.TLS.CertPath == "" {
return fmt.Errorf("server TLS cert path is required — refuse to start (HTTPS-only: set CERTCTL_SERVER_TLS_CERT_PATH to a PEM-encoded certificate; see docs/tls.md)")
}
if c.Server.TLS.KeyPath == "" {
return fmt.Errorf("server TLS key path is required — refuse to start (HTTPS-only: set CERTCTL_SERVER_TLS_KEY_PATH to the PEM-encoded private key matching CERTCTL_SERVER_TLS_CERT_PATH; see docs/tls.md)")
}
// Files must exist and be readable. Catches typos and missing mount paths
// up-front so the operator gets a structured error on startup instead of
// a deferred ListenAndServeTLS failure after the scheduler has already
// fanned out its goroutines.
if _, err := os.Stat(c.Server.TLS.CertPath); err != nil {
return fmt.Errorf("server TLS cert file unreadable at %q: %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, err)
}
if _, err := os.Stat(c.Server.TLS.KeyPath); err != nil {
return fmt.Errorf("server TLS key file unreadable at %q: %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.KeyPath, err)
}
// Parse the cert+key pair up-front. tls.LoadX509KeyPair verifies that the
// key signs the cert (prevents the classic footgun of shipping a pair
// whose private key doesn't match). Discard the returned Certificate — the
// server constructs its own holder from fresh reads so SIGHUP reload is
// authoritative.
if _, err := tls.LoadX509KeyPair(c.Server.TLS.CertPath, c.Server.TLS.KeyPath); err != nil {
return fmt.Errorf("server TLS cert/key pair invalid (cert=%q key=%q): %w — refuse to start (HTTPS-only; see docs/tls.md)", c.Server.TLS.CertPath, c.Server.TLS.KeyPath, err)
}
// Validate database configuration
if c.Database.URL == "" {
return fmt.Errorf("database URL is required")
+256 -13
View File
@@ -1,10 +1,18 @@
package config
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log/slog"
"math/big"
"os"
"testing"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -26,10 +34,76 @@ func clearCertctlEnv(t *testing.T) {
}
// setMinimalValidEnv sets the minimum env vars needed for Load() to succeed (Validate passes).
//
// HTTPS-everywhere milestone (§2.1 + §3 locked decisions): the control plane
// is TLS-only and Validate() refuses to pass without a readable cert/key pair
// on disk. setMinimalValidEnv therefore materializes a throwaway ECDSA P-256
// self-signed pair in t.TempDir() and points the two TLS env vars at it so
// every Load-based test inherits a valid HTTPS posture without each caller
// having to spell out cert generation. The temp dir is cleaned up by
// testing.T at end-of-test.
func setMinimalValidEnv(t *testing.T) {
t.Helper()
// api-key auth requires a secret
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret-key")
// HTTPS-only control plane requires a real cert/key pair on disk.
certPath, keyPath := generateTestTLSPair(t)
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
}
// generateTestTLSPair writes an ECDSA P-256 self-signed certificate + private
// key pair to files inside t.TempDir() and returns the paths. Same shape used
// by cmd/server/tls_test.go — this duplicates the generator rather than
// importing it so the config package tests stay independent of cmd/server.
func generateTestTLSPair(t *testing.T) (certPath, keyPath string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "certctl-config-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
dir := t.TempDir()
certPath = filepath.Join(dir, "cert.pem")
keyPath = filepath.Join(dir, "key.pem")
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("x509.MarshalECPrivateKey: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certPath, keyPath
}
// validServerConfig returns a ServerConfig with Port=8080 plus a freshly
// minted TLS cert/key pair on disk, so Validate() passes the HTTPS-only
// preflight (cert empty → stat → tls.LoadX509KeyPair round-trip). Every
// struct-based Validate test uses this so they fail for the reason they
// claim to test, not for a missing TLS pair.
func validServerConfig(t *testing.T) ServerConfig {
t.Helper()
certPath, keyPath := generateTestTLSPair(t)
return ServerConfig{
Port: 8080,
TLS: ServerTLSConfig{CertPath: certPath, KeyPath: keyPath},
}
}
func TestLoad_DefaultValues(t *testing.T) {
@@ -135,6 +209,13 @@ func TestLoad_DefaultValues(t *testing.T) {
func TestLoad_AllEnvVarsSet(t *testing.T) {
clearCertctlEnv(t)
// HTTPS-only control plane: Load() → Validate() refuses an empty cert path.
// Materialize a throwaway ECDSA P-256 pair and point the two TLS env vars
// at it before setting every other CERTCTL_* var this test cares about.
certPath, keyPath := generateTestTLSPair(t)
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
t.Setenv("CERTCTL_SERVER_HOST", "0.0.0.0")
t.Setenv("CERTCTL_SERVER_PORT", "9090")
t.Setenv("CERTCTL_MAX_BODY_SIZE", "2097152")
@@ -319,7 +400,7 @@ func TestLoad_CommaSeparatedList(t *testing.T) {
func TestValidate_ValidConfig(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
@@ -329,6 +410,7 @@ func TestValidate_ValidConfig(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
@@ -342,7 +424,7 @@ func TestValidate_ValidConfig(t *testing.T) {
func TestValidate_AuthTypeNone(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "none", Secret: ""},
@@ -352,6 +434,7 @@ func TestValidate_AuthTypeNone(t *testing.T) {
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 5 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
@@ -365,7 +448,7 @@ func TestValidate_AuthTypeNone(t *testing.T) {
func TestValidate_InvalidAuthType(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "oauth", Secret: "key"},
@@ -384,7 +467,7 @@ func TestValidate_InvalidAuthType(t *testing.T) {
func TestValidate_APIKeyAuth_MissingSecret(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: ""},
@@ -403,7 +486,7 @@ func TestValidate_APIKeyAuth_MissingSecret(t *testing.T) {
func TestValidate_JWTAuth_MissingSecret(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "jwt", Secret: ""},
@@ -422,7 +505,7 @@ func TestValidate_JWTAuth_MissingSecret(t *testing.T) {
func TestValidate_InvalidKeygenMode(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
@@ -470,9 +553,168 @@ func TestValidate_InvalidPort(t *testing.T) {
}
}
// TestValidate_TLSCertPathEmpty pins the first of the HTTPS-only fail-loud
// gates in Validate(): an empty CertPath must produce the operator-facing
// "server TLS cert path is required" error. Per §2.1 + §3 locked decisions,
// there is no plaintext HTTP fallback — missing TLS config is a hard startup
// refusal, not a warning.
func TestValidate_TLSCertPathEmpty(t *testing.T) {
_, keyPath := generateTestTLSPair(t)
cfg := &Config{
Server: ServerConfig{
Port: 8080,
TLS: ServerTLSConfig{CertPath: "", KeyPath: keyPath},
},
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() should return error for empty TLS cert path")
}
if !strings.Contains(err.Error(), "server TLS cert path is required") {
t.Errorf("error = %q, want substring %q", err.Error(), "server TLS cert path is required")
}
}
// TestValidate_TLSKeyPathEmpty pins the second HTTPS-only gate: empty KeyPath
// must produce the "server TLS key path is required" error. Runs with a valid
// CertPath so the cert-empty gate (which fires first) is cleanly bypassed —
// proves the key-empty gate is actually reached.
func TestValidate_TLSKeyPathEmpty(t *testing.T) {
certPath, _ := generateTestTLSPair(t)
cfg := &Config{
Server: ServerConfig{
Port: 8080,
TLS: ServerTLSConfig{CertPath: certPath, KeyPath: ""},
},
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() should return error for empty TLS key path")
}
if !strings.Contains(err.Error(), "server TLS key path is required") {
t.Errorf("error = %q, want substring %q", err.Error(), "server TLS key path is required")
}
}
// TestValidate_TLSCertFileMissing pins the os.Stat gate on the cert path. A
// non-existent path must surface "server TLS cert file unreadable" so the
// operator sees the bad path in the error (file=%q) instead of a deferred
// ListenAndServeTLS panic after the scheduler has already fanned out.
func TestValidate_TLSCertFileMissing(t *testing.T) {
_, keyPath := generateTestTLSPair(t)
missingCert := filepath.Join(t.TempDir(), "does-not-exist.pem")
cfg := &Config{
Server: ServerConfig{
Port: 8080,
TLS: ServerTLSConfig{CertPath: missingCert, KeyPath: keyPath},
},
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() should return error for missing TLS cert file")
}
if !strings.Contains(err.Error(), "server TLS cert file unreadable") {
t.Errorf("error = %q, want substring %q", err.Error(), "server TLS cert file unreadable")
}
}
// TestValidate_TLSKeyFileMissing pins the os.Stat gate on the key path. Uses a
// valid CertPath so the cert-missing gate does not pre-empt; proves the key
// gate is reached and reports the bad key path.
func TestValidate_TLSKeyFileMissing(t *testing.T) {
certPath, _ := generateTestTLSPair(t)
missingKey := filepath.Join(t.TempDir(), "does-not-exist.key")
cfg := &Config{
Server: ServerConfig{
Port: 8080,
TLS: ServerTLSConfig{CertPath: certPath, KeyPath: missingKey},
},
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() should return error for missing TLS key file")
}
if !strings.Contains(err.Error(), "server TLS key file unreadable") {
t.Errorf("error = %q, want substring %q", err.Error(), "server TLS key file unreadable")
}
}
// TestValidate_TLSMismatchedPair pins the tls.LoadX509KeyPair gate — the
// classic "you shipped the wrong private key" footgun. Generates two
// independent ECDSA pairs and crosses them (pair1 cert + pair2 key). Both
// files exist and parse as PEM, so os.Stat passes; only the cryptographic
// round-trip inside LoadX509KeyPair catches the mismatch.
func TestValidate_TLSMismatchedPair(t *testing.T) {
certPath1, _ := generateTestTLSPair(t)
_, keyPath2 := generateTestTLSPair(t)
cfg := &Config{
Server: ServerConfig{
Port: 8080,
TLS: ServerTLSConfig{CertPath: certPath1, KeyPath: keyPath2},
},
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
Keygen: KeygenConfig{Mode: "agent"},
Scheduler: SchedulerConfig{
RenewalCheckInterval: 1 * time.Hour,
JobProcessorInterval: 30 * time.Second,
AgentHealthCheckInterval: 2 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
},
}
err := cfg.Validate()
if err == nil {
t.Fatal("Validate() should return error for mismatched TLS cert/key pair")
}
if !strings.Contains(err.Error(), "server TLS cert/key pair invalid") {
t.Errorf("error = %q, want substring %q", err.Error(), "server TLS cert/key pair invalid")
}
}
func TestValidate_EmptyDatabaseURL(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
@@ -491,7 +733,7 @@ func TestValidate_EmptyDatabaseURL(t *testing.T) {
func TestValidate_InvalidLogLevel(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "verbose", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
@@ -510,7 +752,7 @@ func TestValidate_InvalidLogLevel(t *testing.T) {
func TestValidate_InvalidLogFormat(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "yaml"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
@@ -572,7 +814,7 @@ func TestValidate_SchedulerIntervalTooSmall(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
@@ -588,7 +830,7 @@ func TestValidate_SchedulerIntervalTooSmall(t *testing.T) {
func TestValidate_DatabaseMaxConnectionsZero(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 0},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "key"},
@@ -795,7 +1037,7 @@ func TestConfig_Scheduler_JobTimeoutValidation(t *testing.T) {
// Start from a fully valid config so the I-003 timeout checks
// are the only potential failure point.
cfg := &Config{
Server: ServerConfig{Port: 8080},
Server: validServerConfig(t),
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
Log: LogConfig{Level: "info", Format: "json"},
Auth: AuthConfig{Type: "api-key", Secret: "test-secret"},
@@ -805,6 +1047,7 @@ func TestConfig_Scheduler_JobTimeoutValidation(t *testing.T) {
JobProcessorInterval: 1 * time.Minute,
AgentHealthCheckInterval: 1 * time.Minute,
NotificationProcessInterval: 1 * time.Minute,
NotificationRetryInterval: 2 * time.Minute,
RetryInterval: 1 * time.Minute,
JobTimeoutInterval: 10 * time.Minute,
AwaitingCSRTimeout: 24 * time.Hour,
+36 -3
View File
@@ -2,11 +2,14 @@ package mcp
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
)
@@ -18,15 +21,45 @@ type Client struct {
httpClient *http.Client
}
// NewClient creates a new certctl API client.
func NewClient(baseURL, apiKey string) *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.
+248 -15
View File
@@ -1,17 +1,30 @@
package mcp
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"io"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
c := NewClient("http://localhost:8443", "test-key")
if c.baseURL != "http://localhost:8443" {
t.Errorf("expected baseURL http://localhost:8443, got %s", c.baseURL)
c, err := NewClient("https://localhost:8443", "test-key", "", false)
if err != nil {
t.Fatalf("NewClient err=%v want nil", err)
}
if c.baseURL != "https://localhost:8443" {
t.Errorf("expected baseURL https://localhost:8443, got %s", c.baseURL)
}
if c.apiKey != "test-key" {
t.Errorf("expected apiKey test-key, got %s", c.apiKey)
@@ -44,7 +57,7 @@ func TestClient_Get(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
data, err := c.Get("/api/v1/certificates", map[string][]string{"status": {"Active"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -64,7 +77,7 @@ func TestClient_Get_NoAuth(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "")
c, _ := NewClient(server.URL, "", "", false)
_, err := c.Get("/api/v1/certificates", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -95,7 +108,7 @@ func TestClient_Post(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
data, err := c.Post("/api/v1/certificates", map[string]string{"name": "test-cert"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -120,7 +133,7 @@ func TestClient_Put(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
data, err := c.Put("/api/v1/certificates/mc-test", map[string]string{"name": "updated"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -139,7 +152,7 @@ func TestClient_Delete_204(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
data, err := c.Delete("/api/v1/certificates/mc-test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -161,7 +174,7 @@ func TestClient_ErrorResponse(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
_, err := c.Get("/api/v1/certificates/nonexistent", nil)
if err == nil {
t.Fatal("expected error for 404 response")
@@ -179,7 +192,7 @@ func TestClient_ServerError(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
_, err := c.Post("/api/v1/certificates", map[string]string{"name": "test"})
if err == nil {
t.Fatal("expected error for 500 response")
@@ -202,7 +215,7 @@ func TestClient_GetRaw(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
data, contentType, err := c.GetRaw("/.well-known/pki/crl/iss-local")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -222,7 +235,7 @@ func TestClient_GetRaw_Error(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
_, _, err := c.GetRaw("/.well-known/pki/crl/nonexistent")
if err == nil {
t.Fatal("expected error for 404 response")
@@ -230,7 +243,7 @@ func TestClient_GetRaw_Error(t *testing.T) {
}
func TestClient_ConnectionRefused(t *testing.T) {
c := NewClient("http://localhost:1", "test-key")
c, _ := NewClient("https://localhost:1", "test-key", "", false)
_, err := c.Get("/api/v1/certificates", nil)
if err == nil {
t.Fatal("expected error for connection refused")
@@ -247,7 +260,7 @@ func TestClient_PostNilBody(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
data, err := c.Post("/api/v1/certificates/mc-test/renew", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -270,7 +283,7 @@ func TestClient_QueryParams(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
q := paginationQuery(2, 10)
_, err := c.Get("/api/v1/certificates", q)
if err != nil {
@@ -287,3 +300,223 @@ func containsStr(s, substr string) bool {
}
return false
}
// generateTestCert produces a short-lived self-signed RSA-2048 certificate for
// tests that need a PEM-encodable cert. Mirrors the helper used in
// internal/cli/client_test.go so the two packages pin the same HTTPS-Everywhere
// TLS-wiring contract against matching test fixtures.
func generateTestCert() *x509.Certificate {
now := time.Now()
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "test.certctl.local",
},
NotBefore: now,
NotAfter: now.Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"test.certctl.local"},
}
privateKey, _ := rsa.GenerateKey(rand.Reader, 2048)
certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
cert, _ := x509.ParseCertificate(certBytes)
return cert
}
// -----------------------------------------------------------------------------
// HTTPS-Everywhere milestone (v2.2, §3.2 + §7 Phase 5):
// The MCP server binary talks HTTPS-only to the certctl control plane. These
// tests pin the three contracts every client binary (agent, CLI, MCP) must
// satisfy in lock-step:
// (a) CA bundle load success — PEM loads, RootCAs + MinVersion=TLS1.3 wired
// through the injected *http.Transport so the httpClient actually uses
// them on the wire, not just in the struct.
// (b) CA bundle load failure — missing file and malformed/empty PEM each fail
// loud with a pinned substring so operators get a useful diagnostic.
// (c) End-to-end TLS round-trip — an httptest.NewTLSServer whose own cert is
// written out as the CA bundle validates that every TLS-config knob
// actually flows into the dialer.
// The substrings below must stay in sync with internal/mcp/client.go:NewClient;
// drifting them in isolation is exactly what this suite is here to catch.
// -----------------------------------------------------------------------------
// writeCABundle PEM-encodes a DER cert and writes it to a temp file under the
// test's own TempDir. Returns the absolute path for piping into NewClient.
func writeCABundle(t *testing.T, dir string, certDER []byte, filename string) string {
t.Helper()
path := filepath.Join(dir, filename)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
t.Fatalf("writing CA bundle to %q: %v", path, err)
}
return path
}
// TestNewClient_CABundle_Success pins the happy path: a valid PEM CA bundle
// loads, populates RootCAs on the client's TLS config, and leaves
// MinVersion=TLS1.3 intact. Regression guard for any future edit that
// accidentally swaps the transport or detaches *tls.Config from *http.Transport.
func TestNewClient_CABundle_Success(t *testing.T) {
cert := generateTestCert()
tmp := t.TempDir()
bundlePath := writeCABundle(t, tmp, cert.Raw, "ca.pem")
client, err := NewClient("https://certctl-server:8443", "test-key", bundlePath, false)
if err != nil {
t.Fatalf("NewClient with valid CA bundle err=%v want nil", err)
}
if client == nil {
t.Fatal("NewClient returned nil client on happy path")
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("httpClient.Transport type=%T want *http.Transport (TLS config injection broke)", client.httpClient.Transport)
}
if transport.TLSClientConfig == nil {
t.Fatal("transport.TLSClientConfig is nil; TLS config must be set on every client")
}
if transport.TLSClientConfig.RootCAs == nil {
t.Fatal("transport.TLSClientConfig.RootCAs is nil; CA bundle path was ignored")
}
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion=%d want tls.VersionTLS13 (%d); HTTPS-Everywhere requires TLS1.3 floor",
transport.TLSClientConfig.MinVersion, tls.VersionTLS13)
}
if transport.TLSClientConfig.InsecureSkipVerify {
t.Error("InsecureSkipVerify=true with insecure=false arg; flag wiring crossed")
}
}
// TestNewClient_CABundle_MissingFile pins the fail-loud path for a nonexistent
// bundle path. The error surface must include "reading CA bundle" so operators
// see the right diagnostic instead of a downstream TLS-handshake-error.
func TestNewClient_CABundle_MissingFile(t *testing.T) {
_, err := NewClient("https://certctl-server:8443", "test-key", "/nonexistent/path/ca.pem", false)
if err == nil {
t.Fatal("NewClient with missing CA bundle err=nil; must fail loud so operators see the right diagnostic")
}
if !containsStr(err.Error(), "reading CA bundle") {
t.Errorf("err=%q must contain %q so operators can locate the misconfigured path", err.Error(), "reading CA bundle")
}
}
// TestNewClient_CABundle_EmptyPEM pins the fail-loud path for a file whose
// contents are not valid PEM. AppendCertsFromPEM returning false is the signal
// we need to surface — otherwise the client would silently ship with an empty
// cert pool and every TLS handshake would fail downstream.
func TestNewClient_CABundle_EmptyPEM(t *testing.T) {
tmp := t.TempDir()
garbagePath := filepath.Join(tmp, "garbage.pem")
if err := os.WriteFile(garbagePath, []byte("not a pem certificate, just bytes"), 0o600); err != nil {
t.Fatalf("writing garbage file: %v", err)
}
_, err := NewClient("https://certctl-server:8443", "test-key", garbagePath, false)
if err == nil {
t.Fatal("NewClient with malformed PEM err=nil; must fail loud, not silently skip")
}
if !containsStr(err.Error(), "no valid PEM-encoded certificates") {
t.Errorf("err=%q must contain %q so operators know the file parsed but held no certs",
err.Error(), "no valid PEM-encoded certificates")
}
}
// TestNewClient_TLSRoundTrip validates that the TLS config knobs we set on
// NewClient actually reach the wire. An httptest.NewTLSServer signs its own
// self-signed leaf; we PEM-encode that server cert, write it as the CA bundle,
// and issue a real HTTPS GET via c.Get. A successful round-trip proves RootCAs
// + MinVersion are flowing through *http.Transport into the dialer, not just
// surviving into the client struct.
func TestNewClient_TLSRoundTrip(t *testing.T) {
var handlerHit int
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/certificates" {
handlerHit++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []interface{}{},
"total": 0,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
serverCert := server.Certificate()
if serverCert == nil {
t.Fatal("httptest.NewTLSServer.Certificate() returned nil; cannot build CA bundle")
}
tmp := t.TempDir()
bundlePath := writeCABundle(t, tmp, serverCert.Raw, "server-ca.pem")
client, err := NewClient(server.URL, "test-key", bundlePath, false)
if err != nil {
t.Fatalf("NewClient(TLS server) err=%v want nil", err)
}
data, err := client.Get("/api/v1/certificates", nil)
if err != nil {
t.Fatalf("Get over HTTPS err=%v; TLS config must reach the wire", err)
}
if data == nil {
t.Fatal("Get over HTTPS returned nil data; want non-empty JSON body")
}
if handlerHit != 1 {
t.Errorf("handlerHit=%d want 1; request did not reach the TLS server", handlerHit)
}
}
// TestNewClient_InsecureSkipVerify pins the dev-only escape hatch: an untrusted
// TLS server (cert NOT in the client's root pool) must be reachable when
// insecure=true. This is the only path in the control plane that disables
// certificate verification; it's documented in docs/tls.md and gated by the
// CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY env var so it never slips into
// production silently.
func TestNewClient_InsecureSkipVerify(t *testing.T) {
var handlerHit int
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerHit++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"data": []interface{}{},
"total": 0,
})
}))
defer server.Close()
// No CA bundle → system roots, which will NOT trust the self-signed
// httptest cert. insecure=true is the only thing keeping this call from
// failing with an x509-unknown-authority error.
client, err := NewClient(server.URL, "test-key", "", true)
if err != nil {
t.Fatalf("NewClient(insecure=true) err=%v want nil", err)
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("httpClient.Transport type=%T want *http.Transport", client.httpClient.Transport)
}
if !transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("insecure=true arg did not set TLSClientConfig.InsecureSkipVerify; flag wiring broken")
}
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion=%d want tls.VersionTLS13 even with insecure=true (TLS1.3 floor is not optional)",
transport.TLSClientConfig.MinVersion)
}
data, err := client.Get("/api/v1/certificates", nil)
if err != nil {
t.Fatalf("Get(insecure=true) err=%v; escape hatch must still complete the round-trip", err)
}
if data == nil {
t.Fatal("Get(insecure=true) returned nil data; want non-empty JSON body")
}
if handlerHit != 1 {
t.Errorf("handlerHit=%d want 1; insecure round-trip did not reach the server", handlerHit)
}
}
+4 -4
View File
@@ -44,7 +44,7 @@ func TestClient_DeleteWithQuery_ForceRetire(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "test-key")
c, _ := NewClient(server.URL, "test-key", "", false)
// Compile-fail until Phase 2b grows Client.DeleteWithQuery. Passing the
// query as a url.Values is the established pattern (matches Get's shape).
query := url.Values{}
@@ -87,7 +87,7 @@ func TestClient_DeleteWithQuery_NoQuery(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "")
c, _ := NewClient(server.URL, "", "", false)
if _, err := c.DeleteWithQuery("/api/v1/agents/ag-1", nil); err != nil {
t.Fatalf("DeleteWithQuery(nil query) err=%v want nil", err)
}
@@ -108,7 +108,7 @@ func TestClient_DeleteWithQuery_204ReturnsMinimalBody(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "")
c, _ := NewClient(server.URL, "", "", false)
data, err := c.DeleteWithQuery("/api/v1/agents/ag-1", nil)
if err != nil {
t.Fatalf("DeleteWithQuery(204) err=%v want nil (idempotent)", err)
@@ -141,7 +141,7 @@ func TestClient_DeleteWithQuery_409PropagatesError(t *testing.T) {
}))
defer server.Close()
c := NewClient(server.URL, "")
c, _ := NewClient(server.URL, "", "", false)
_, err := c.DeleteWithQuery("/api/v1/agents/ag-1", nil)
if err == nil {
t.Fatalf("DeleteWithQuery(409) err=nil; 409 must propagate as Go error")
+10 -10
View File
@@ -88,7 +88,7 @@ func TestRegisterTools_ToolCount(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
RegisterTools(server, client)
// The server should have tools registered — we can verify by listing them
@@ -166,7 +166,7 @@ func TestToolEndToEnd_ListCertificates(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
// Manually call the handler logic that would be registered as a tool
q := paginationQuery(1, 50)
@@ -204,7 +204,7 @@ func TestToolEndToEnd_CreateCertificate(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
input := CreateCertificateInput{
Name: "API Production",
@@ -244,7 +244,7 @@ func TestToolEndToEnd_TriggerRenewal(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
data, err := client.Post("/api/v1/certificates/mc-api-prod/renew", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -272,7 +272,7 @@ func TestToolEndToEnd_DeleteTarget(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
data, err := client.Delete("/api/v1/targets/t-platform")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -300,7 +300,7 @@ func TestToolEndToEnd_RevokeCertificate(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
input := RevokeCertificateInput{
ID: "mc-api-prod",
Reason: "keyCompromise",
@@ -327,7 +327,7 @@ func TestToolEndToEnd_AgentHeartbeat(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
_, err := client.Post("/api/v1/agents/agent-001/heartbeat", map[string]string{
"os": "linux",
"architecture": "amd64",
@@ -347,7 +347,7 @@ func TestToolEndToEnd_ListWithFilters(t *testing.T) {
api := mockCertctlAPI(log)
defer api.Close()
client := NewClient(api.URL, "test-key")
client, _ := NewClient(api.URL, "test-key", "", false)
q := paginationQuery(1, 25)
q.Set("status", "Pending")
q.Set("type", "Renewal")
@@ -377,7 +377,7 @@ func TestToolEndToEnd_GetRawBinary(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "test-key")
client, _ := NewClient(server.URL, "test-key", "", false)
data, ct, err := client.GetRaw("/.well-known/pki/crl/iss-local")
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -397,7 +397,7 @@ func TestToolEndToEnd_ErrorPropagation(t *testing.T) {
}))
defer server.Close()
client := NewClient(server.URL, "test-key")
client, _ := NewClient(server.URL, "test-key", "", false)
_, err := client.Get("/api/v1/certificates", nil)
if err == nil {
t.Fatal("expected error for 403 response")
+2 -2
View File
@@ -119,7 +119,7 @@ echo -e "\n${GREEN}=== Setup Complete ===${NC}\n"
echo "Your development environment is ready!"
echo ""
echo "Services running:"
echo " • Server: http://localhost:8443"
echo " • Server: https://localhost:8443"
echo " • Database: postgres://certctl:certctl@localhost:5432/certctl"
echo " • Agent: Connected to server"
echo ""
@@ -132,7 +132,7 @@ echo " make docker-logs-server"
echo " make docker-logs-agent"
echo ""
echo " 3. Test the API:"
echo " curl http://localhost:8443/health"
echo " curl --cacert ./deploy/test/certs/ca.crt https://localhost:8443/health"
echo ""
echo " 4. Try the quick start guide:"
echo " cat docs/quickstart.md"