Feat v0.6.7 native mTLS (#42)
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>
This commit is contained in:
2026-03-31 18:18:24 +00:00
parent 7915d84c8b
commit 1b095d50be
14 changed files with 1138 additions and 209 deletions

View File

@@ -53,10 +53,19 @@ type Config struct {
LogFormat string
LogLevel string
// TLS termination
// TLS_MODE: "none" (default) | "server" (TLS, no client cert) | "mtls" (mutual TLS)
// When server or mtls: binary calls ListenAndServeTLS directly.
// TLS_MODE and AUTH_MODE are independent knobs.
TLSMode string
TLSCert string // path to server certificate PEM
TLSKey string // path to server private key PEM
TLSCA string // path to CA certificate PEM (required for mtls)
// Auth mode: "builtin" (default) | "mtls" | "oidc"
AuthMode string
// mTLS
// mTLS (proxy mode)
// Headers injected by TLS-terminating reverse proxy.
MTLSHeaderDN string // default "X-SSL-Client-DN"
MTLSHeaderVerify string // default "X-SSL-Client-Verify"
@@ -141,6 +150,12 @@ func Load() *Config {
LogFormat: getEnv("LOG_FORMAT", "text"),
LogLevel: getEnv("LOG_LEVEL", "info"),
// TLS
TLSMode: getEnv("TLS_MODE", "none"),
TLSCert: getEnv("TLS_CERT", ""),
TLSKey: getEnv("TLS_KEY", ""),
TLSCA: getEnv("TLS_CA", ""),
AuthMode: getEnv("AUTH_MODE", "builtin"),
// mTLS

98
server/config/tls.go Normal file
View File

@@ -0,0 +1,98 @@
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
}