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
+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")