mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +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:
+38
-6
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user