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
+92 -8
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")
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")
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)
}
+53 -9
View File
@@ -19,16 +19,29 @@
//
// Environment overrides:
//
// CERTCTL_QA_SERVER_URL (default: http://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_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")
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("..", ".."))
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
}