mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 16:48:57 +00:00
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:
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -677,9 +678,30 @@ type VerificationConfig struct {
|
||||
|
||||
// ServerConfig contains HTTP server configuration.
|
||||
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.
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user