All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m44s
CI/CD / test-sqlite (pull_request) Successful in 2m50s
CI/CD / build-and-deploy (pull_request) Successful in 1m4s
End-to-end mutual TLS without a reverse proxy. Go binary terminates TLS itself via ListenAndServeTLS. TLS_MODE config (none/server/mtls) is independent of AUTH_MODE. - MTLSNativeProvider reads PeerCertificates directly (no header trust) - Shared helpers extracted to mtls_helpers.go (ParseDN, FingerprintCert) - MTLSProvider renamed to MTLSProxyProvider for clarity - BuildPeerTLSConfig for future node-to-node mTLS - switchboard-ca.sh: CA init, issue-node, issue-user (ECDSA P-256) - 12 new tests (unit + TLS integration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
99 lines
2.7 KiB
Go
99 lines
2.7 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
)
|
|
|
|
// TLS mode values for TLS_MODE env var.
|
|
const (
|
|
TLSModeNone = "none" // Plain HTTP (default). Use behind a proxy.
|
|
TLSModeServer = "server" // Server-side TLS only. No client cert required.
|
|
TLSModeMTLS = "mtls" // Mutual TLS. Client cert required and verified.
|
|
)
|
|
|
|
var (
|
|
ErrTLSCertRequired = errors.New("TLS_CERT and TLS_KEY required when TLS_MODE is server or mtls")
|
|
ErrTLSCARequired = errors.New("TLS_CA required when TLS_MODE is mtls")
|
|
ErrTLSCALoad = errors.New("failed to load TLS CA certificate")
|
|
ErrTLSModeInvalid = errors.New("TLS_MODE must be none, server, or mtls")
|
|
)
|
|
|
|
// ValidateTLSMode returns an error if the TLS_MODE value is not recognized.
|
|
func ValidateTLSMode(mode string) error {
|
|
switch mode {
|
|
case TLSModeNone, TLSModeServer, TLSModeMTLS, "":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%w: %q", ErrTLSModeInvalid, mode)
|
|
}
|
|
}
|
|
|
|
// BuildTLSConfig constructs a *tls.Config from the application config.
|
|
// Returns nil when TLS_MODE is "none" (or empty).
|
|
// Returns an error if required cert/key/CA files are missing or unreadable.
|
|
func BuildTLSConfig(cfg *Config) (*tls.Config, error) {
|
|
mode := cfg.TLSMode
|
|
if mode == "" || mode == TLSModeNone {
|
|
return nil, nil
|
|
}
|
|
|
|
if cfg.TLSCert == "" || cfg.TLSKey == "" {
|
|
return nil, ErrTLSCertRequired
|
|
}
|
|
|
|
tlsCfg := &tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
}
|
|
|
|
if mode == TLSModeMTLS {
|
|
if cfg.TLSCA == "" {
|
|
return nil, ErrTLSCARequired
|
|
}
|
|
caCert, err := os.ReadFile(cfg.TLSCA)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTLSCALoad, err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caCert) {
|
|
return nil, fmt.Errorf("%w: no valid certificates in %s", ErrTLSCALoad, cfg.TLSCA)
|
|
}
|
|
tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert
|
|
tlsCfg.ClientCAs = pool
|
|
}
|
|
|
|
return tlsCfg, nil
|
|
}
|
|
|
|
// BuildPeerTLSConfig constructs a *tls.Config for outbound node-to-node
|
|
// connections. The node presents its own cert and verifies peers against
|
|
// the cluster CA. Returns nil when TLS_MODE is not "mtls".
|
|
func BuildPeerTLSConfig(cfg *Config) (*tls.Config, error) {
|
|
if cfg.TLSMode != TLSModeMTLS {
|
|
return nil, nil
|
|
}
|
|
|
|
cert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load node keypair: %w", err)
|
|
}
|
|
|
|
caCert, err := os.ReadFile(cfg.TLSCA)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrTLSCALoad, err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caCert) {
|
|
return nil, fmt.Errorf("%w: no valid certificates in %s", ErrTLSCALoad, cfg.TLSCA)
|
|
}
|
|
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
RootCAs: pool,
|
|
MinVersion: tls.VersionTLS13,
|
|
}, nil
|
|
}
|