From 1b095d50be53514f613c7d9a2ad93fb00b56fcf7 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Tue, 31 Mar 2026 18:18:24 +0000 Subject: [PATCH] Feat v0.6.7 native mTLS (#42) 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) --- CHANGELOG.md | 41 ++ ROADMAP.md | 16 +- VERSION | 2 +- docs/DESIGN-native-mtls.md | 12 +- scripts/switchboard-ca.sh | 237 ++++++++++ server/auth/mtls.go | 180 -------- server/auth/mtls_helpers.go | 108 +++++ server/auth/mtls_native.go | 72 +++ server/auth/mtls_native_test.go | 410 ++++++++++++++++++ server/auth/mtls_proxy.go | 94 ++++ .../auth/{mtls_test.go => mtls_proxy_test.go} | 8 +- server/config/config.go | 17 +- server/config/tls.go | 98 +++++ server/main.go | 52 ++- 14 files changed, 1138 insertions(+), 209 deletions(-) create mode 100755 scripts/switchboard-ca.sh delete mode 100644 server/auth/mtls.go create mode 100644 server/auth/mtls_helpers.go create mode 100644 server/auth/mtls_native.go create mode 100644 server/auth/mtls_native_test.go create mode 100644 server/auth/mtls_proxy.go rename server/auth/{mtls_test.go => mtls_proxy_test.go} (93%) create mode 100644 server/config/tls.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 941de7d..9d53d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to Switchboard Core are documented here. +## v0.6.7 — Native mTLS + +End-to-end mutual TLS without a reverse proxy. Targets systemd+podman +deployments where the Go binary terminates TLS itself. + +### Added + +- **`TLS_MODE` config**: Three values — `none` (default, plain HTTP), `server` + (TLS, no client cert), `mtls` (mutual TLS, client cert required). Independent + of `AUTH_MODE`. Combinations: `server`+`builtin` for HTTPS with password auth, + `mtls`+`mtls` for full mTLS identity, `none`+`mtls` for proxy-terminated + (existing behavior). +- **TLS server mode**: Binary calls `ListenAndServeTLS` directly when + `TLS_MODE` is `server` or `mtls`. TLS 1.3 minimum, no cipher suite + configuration. Config: `TLS_CERT`, `TLS_KEY`, `TLS_CA` path env vars. +- **`MTLSNativeProvider`**: New auth provider that reads + `r.TLS.PeerCertificates[0]` directly — no header trust. `Subject.CommonName` + becomes username, `sha256(cert.Raw)` becomes `external_id`. Auto-provisions + users identically to the proxy provider. Selected when `AUTH_MODE=mtls` and + `TLS_MODE=mtls`. +- **Shared mTLS helpers**: `ParseDN()`, `FingerprintCert()`, and + `resolveOrProvision()` extracted to `mtls_helpers.go`. Both proxy and native + providers consume the same user resolution logic. +- **Peer TLS config**: `BuildPeerTLSConfig()` constructs a `*tls.Config` for + outbound node-to-node connections (forward-looking — cluster registry is + currently DB-backed with no HTTP peer calls). +- **`switchboard-ca.sh`**: Shell wrapper around openssl for cert provisioning. + Three commands: `init` (CA keypair), `issue-node` (365d, ServerAuth + + ClientAuth EKU), `issue-user` (90d, ClientAuth only). All ECDSA P-256, PEM + output. +- **12 new tests**: `FingerprintCert` determinism, native provider unit tests + (nil TLS, empty certs, no CN, email extraction), TLS integration tests (no + cert rejected, wrong CA rejected, expired cert rejected, valid cert accepted, + peer certificate visibility). + +### Changed + +- **`MTLSProvider` → `MTLSProxyProvider`**: Renamed for clarity. Config type + `MTLSConfig` → `MTLSProxyConfig`. Constructor `NewMTLSProvider` → + `NewMTLSProxyProvider`. File `mtls.go` → `mtls_proxy.go`. + ## v0.6.6 — Final Hardening Final pass before public release. Security, correctness, and developer diff --git a/ROADMAP.md b/ROADMAP.md index 24ed8e5..677b1b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Switchboard Core — Roadmap -## Current: v0.6.6 — Final Hardening +## Current: v0.6.7 — Native mTLS Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, storage, realtime, and ops are kernel primitives. Everything else is an extension. @@ -131,13 +131,13 @@ End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployment | Step | Status | Description | |------|--------|-------------| -| `TLS_MODE` config | ☐ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. | -| TLS server mode | ☐ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. | -| `MTLSNativeProvider` | ☐ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Rename existing `mtls.go` → `mtls_proxy.go`. Extract shared helpers to `mtls_helpers.go`. | -| Node-to-node mTLS | ☐ | Cluster heartbeat over mTLS. Node presents its cert when dialing peers. Single cert per node (ServerAuth + ClientAuth EKU). SAN includes hostname + IP. Peer verification against cluster CA pool. | -| `armature-ca.sh` | ☐ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name --san ` (365d) · `issue-user --cn ` (90d). All output PEM. | -| Unit tests | ☐ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · same fingerprint → idempotent. | -| Integration tests | ☐ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200, user created. Node-to-node: two in-process servers, distinct certs, same CA. Wrong-CA node rejected. | +| `TLS_MODE` config | ✅ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. | +| TLS server mode | ✅ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. | +| `MTLSNativeProvider` | ✅ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Renamed existing `mtls.go` → `mtls_proxy.go`. Shared helpers in `mtls_helpers.go`. | +| Node-to-node mTLS | ✅ | `BuildPeerTLSConfig()` constructs outbound TLS config with node cert + CA pool. Forward-looking — cluster registry is DB-backed (PG LISTEN/NOTIFY), no HTTP peer calls yet. | +| `switchboard-ca.sh` | ✅ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name --san ` (365d) · `issue-user --cn ` (90d). All output PEM. | +| Unit tests | ✅ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · no CN → `ErrInvalidCreds`. | +| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. | ### v0.6.6 — Final Hardening diff --git a/VERSION b/VERSION index 05e8a45..2228cad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.6 +0.6.7 diff --git a/docs/DESIGN-native-mtls.md b/docs/DESIGN-native-mtls.md index 3982be7..febc932 100644 --- a/docs/DESIGN-native-mtls.md +++ b/docs/DESIGN-native-mtls.md @@ -1,7 +1,7 @@ # DESIGN — Native mTLS **Version:** v0.6.7 -**Status:** Proposed +**Status:** Implemented **Author:** Jeff / Claude session 2026-03-31 --- @@ -172,17 +172,17 @@ their own `node-N.key`. ### Cert Provisioning Tooling -A shell script (`scripts/armature-ca.sh`) wrapping `openssl` is the +A shell script (`scripts/switchboard-ca.sh`) wrapping `openssl` is the KISS path. No new binary, no new dependency. Three commands: ``` -armature-ca init +switchboard-ca init → generates cluster-ca.crt + cluster-ca.key in ./ca/ -armature-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1" +switchboard-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1" → generates node-1.crt + node-1.key in ./nodes/ -armature-ca issue-user --cn jeff [--email jeff@example.com] +switchboard-ca issue-user --cn jeff [--email jeff@example.com] → generates jeff.crt + jeff.key in ./users/ ``` @@ -280,7 +280,7 @@ The new code is: | `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` | | `server/auth/mtls_native_test.go` | Unit + integration tests | | `server/config/tls.go` | `TLSConfig` struct, loader, validation | -| `scripts/armature-ca.sh` | Cert provisioning wrapper | +| `scripts/switchboard-ca.sh` | Cert provisioning wrapper | `server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for clarity. No behavioral changes to the proxy provider. diff --git a/scripts/switchboard-ca.sh b/scripts/switchboard-ca.sh new file mode 100755 index 0000000..a361f66 --- /dev/null +++ b/scripts/switchboard-ca.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# +# switchboard-ca.sh — Certificate provisioning for Switchboard Core mTLS. +# +# Wraps openssl to generate a cluster CA, node certificates (ServerAuth + +# ClientAuth), and user certificates (ClientAuth only). All output is PEM. +# +# Usage: +# switchboard-ca init +# switchboard-ca issue-node --name --san +# switchboard-ca issue-user --cn [--email ] +# +# Files are written to the current directory under ca/, nodes/, users/. + +set -euo pipefail + +CA_DIR="./ca" +NODES_DIR="./nodes" +USERS_DIR="./users" +CA_DAYS=3650 # 10 years for CA +NODE_DAYS=365 # 1 year for nodes +USER_DAYS=90 # 90 days for users + +# ── Helpers ────────────────────────────────────────────────────────── + +die() { echo "error: $*" >&2; exit 1; } + +require_openssl() { + command -v openssl >/dev/null 2>&1 || die "openssl not found in PATH" +} + +require_ca() { + [ -f "$CA_DIR/cluster-ca.key" ] || die "CA not initialized. Run: $0 init" + [ -f "$CA_DIR/cluster-ca.crt" ] || die "CA not initialized. Run: $0 init" +} + +# ── init ───────────────────────────────────────────────────────────── + +cmd_init() { + require_openssl + mkdir -p "$CA_DIR" + + if [ -f "$CA_DIR/cluster-ca.key" ]; then + die "CA already exists at $CA_DIR/cluster-ca.key — remove it first to reinitialize" + fi + + # Generate ECDSA P-256 CA key + openssl ecparam -genkey -name prime256v1 -noout -out "$CA_DIR/cluster-ca.key" 2>/dev/null + + # Self-signed CA certificate + openssl req -new -x509 \ + -key "$CA_DIR/cluster-ca.key" \ + -out "$CA_DIR/cluster-ca.crt" \ + -days "$CA_DAYS" \ + -subj "/CN=Switchboard Cluster CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + 2>/dev/null + + echo "CA initialized:" + echo " $CA_DIR/cluster-ca.crt" + echo " $CA_DIR/cluster-ca.key" + echo "" + echo "Keep cluster-ca.key secure. It never needs to be on a running node." +} + +# ── issue-node ─────────────────────────────────────────────────────── + +cmd_issue_node() { + require_openssl + require_ca + + local name="" + local san="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --san) san="$2"; shift 2 ;; + *) die "unknown option: $1" ;; + esac + done + + [ -n "$name" ] || die "usage: $0 issue-node --name --san " + [ -n "$san" ] || die "usage: $0 issue-node --name --san " + + mkdir -p "$NODES_DIR" + + # Generate node key + openssl ecparam -genkey -name prime256v1 -noout -out "$NODES_DIR/$name.key" 2>/dev/null + + # Build SAN entries from comma-separated list + local san_entries="" + IFS=',' read -ra ADDRS <<< "$san" + for addr in "${ADDRS[@]}"; do + addr=$(echo "$addr" | xargs) # trim whitespace + if [[ "$addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + san_entries="${san_entries}IP:${addr}," + else + san_entries="${san_entries}DNS:${addr}," + fi + done + san_entries="${san_entries%,}" # remove trailing comma + + # Create CSR + openssl req -new \ + -key "$NODES_DIR/$name.key" \ + -out "$NODES_DIR/$name.csr" \ + -subj "/CN=$name" \ + 2>/dev/null + + # Create extensions config + local ext_file + ext_file=$(mktemp) + cat > "$ext_file" </dev/null + + rm -f "$NODES_DIR/$name.csr" "$ext_file" + + echo "Node cert issued ($NODE_DAYS days):" + echo " $NODES_DIR/$name.crt" + echo " $NODES_DIR/$name.key" + echo "" + echo "Deploy to the node along with $CA_DIR/cluster-ca.crt" +} + +# ── issue-user ─────────────────────────────────────────────────────── + +cmd_issue_user() { + require_openssl + require_ca + + local cn="" + local email="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --cn) cn="$2"; shift 2 ;; + --email) email="$2"; shift 2 ;; + *) die "unknown option: $1" ;; + esac + done + + [ -n "$cn" ] || die "usage: $0 issue-user --cn [--email ]" + + mkdir -p "$USERS_DIR" + + # Generate user key + openssl ecparam -genkey -name prime256v1 -noout -out "$USERS_DIR/$cn.key" 2>/dev/null + + # Build subject + local subj="/CN=$cn" + local san_line="" + if [ -n "$email" ]; then + san_line="email:${email}" + fi + + # Create CSR + openssl req -new \ + -key "$USERS_DIR/$cn.key" \ + -out "$USERS_DIR/$cn.csr" \ + -subj "$subj" \ + 2>/dev/null + + # Create extensions config + local ext_file + ext_file=$(mktemp) + cat > "$ext_file" <> "$ext_file" + fi + + # Sign with CA + openssl x509 -req \ + -in "$USERS_DIR/$cn.csr" \ + -CA "$CA_DIR/cluster-ca.crt" \ + -CAkey "$CA_DIR/cluster-ca.key" \ + -CAcreateserial \ + -out "$USERS_DIR/$cn.crt" \ + -days "$USER_DAYS" \ + -extfile "$ext_file" \ + 2>/dev/null + + rm -f "$USERS_DIR/$cn.csr" "$ext_file" + + echo "User cert issued ($USER_DAYS days):" + echo " $USERS_DIR/$cn.crt" + echo " $USERS_DIR/$cn.key" + echo "" + echo "Import both files into the user's browser or CLI tool." +} + +# ── Main dispatch ──────────────────────────────────────────────────── + +case "${1:-}" in + init) + shift + cmd_init "$@" + ;; + issue-node) + shift + cmd_issue_node "$@" + ;; + issue-user) + shift + cmd_issue_user "$@" + ;; + *) + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " init Initialize cluster CA" + echo " issue-node --name --san Issue node cert (server+client auth)" + echo " issue-user --cn [--email ] Issue user cert (client auth only)" + exit 1 + ;; +esac diff --git a/server/auth/mtls.go b/server/auth/mtls.go deleted file mode 100644 index 9fa4092..0000000 --- a/server/auth/mtls.go +++ /dev/null @@ -1,180 +0,0 @@ -package auth - -import ( - "context" - "fmt" - "log" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// MTLSConfig holds mTLS-specific configuration. -type MTLSConfig struct { - HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN") - HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify") - HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint") - AutoActivate bool // auto-activate new users (default true) - DefaultTeam string // team ID for auto-provisioned users (optional) -} - -// MTLSProvider authenticates via client certificate headers injected -// by the TLS-terminating reverse proxy (nginx, Traefik, Istio). -// -// The backend never sees the actual TLS handshake — it trusts headers -// injected by the proxy after cert validation. After reading and parsing -// the DN, the provider resolves an existing user or auto-provisions a -// new one, then returns a Result. The auth handler issues an internal JWT. -// -// Header flow: -// -// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN, -// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers. -type MTLSProvider struct { - cfg MTLSConfig -} - -func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider { - if cfg.HeaderDN == "" { - cfg.HeaderDN = "X-SSL-Client-DN" - } - if cfg.HeaderVerify == "" { - cfg.HeaderVerify = "X-SSL-Client-Verify" - } - if cfg.HeaderFingerprint == "" { - cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint" - } - return &MTLSProvider{cfg: cfg} -} - -func (p *MTLSProvider) Mode() Mode { return ModeMTLS } - -func (p *MTLSProvider) SupportsRegistration() bool { return false } - -func (p *MTLSProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) { - return nil, ErrNotSupported -} - -// Authenticate reads the cert DN and verify status from proxy headers, -// parses the DN fields, and resolves or auto-provisions the user. -// -// Returns ErrInvalidCreds when headers are missing or verify fails. -// Returns ErrInactive when user exists but is deactivated. -func (p *MTLSProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { - // ── Validate headers ─────────────────────────────────────────── - verify := c.GetHeader(p.cfg.HeaderVerify) - if verify == "" { - return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify) - } - // nginx: "SUCCESS", Traefik: "0" (both mean valid cert) - if verify != "SUCCESS" && verify != "0" { - return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify) - } - - dn := c.GetHeader(p.cfg.HeaderDN) - if dn == "" { - return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN) - } - - fields := ParseDN(dn) - cn := fields["CN"] - if cn == "" { - return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds) - } - - // Stable external identity: fingerprint if available, else full DN - fingerprint := c.GetHeader(p.cfg.HeaderFingerprint) - if fingerprint == "" { - fingerprint = dn - } - - ctx := c.Request.Context() - - // ── Look up existing user ────────────────────────────────────── - user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint) - if err == nil && user != nil { - if !user.IsActive { - return nil, ErrInactive - } - log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn) - return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil - } - - // ── Auto-provision ───────────────────────────────────────────── - if !p.cfg.AutoActivate { - return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds) - } - - return p.autoProvision(ctx, fields, fingerprint, stores) -} - -func (p *MTLSProvider) autoProvision( - ctx context.Context, - dn map[string]string, - fingerprint string, - stores store.Stores, -) (*Result, error) { - cn := dn["CN"] - - email := dn["emailAddress"] - if email == "" { - email = models.HandleFromName(cn) + "@mtls.local" - } - - handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn)) - - user := &models.User{ - Username: handle, - Email: email, - DisplayName: cn, - IsActive: true, - AuthSource: string(ModeMTLS), - ExternalID: &fingerprint, - Handle: handle, - } - - if err := stores.Users.Create(ctx, user); err != nil { - return nil, fmt.Errorf("auto-provision failed: %w", err) - } - - log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn) - EnsureEveryoneGroup(ctx, stores, user.ID) - - // Auto-add to default team if configured - if p.cfg.DefaultTeam != "" { - if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil { - log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err) - } - } - - return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil -} - -// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs. -// -// Examples: -// -// "CN=Jeff Smith,O=Acme Corp,OU=Engineering" -// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp" -// -// Handles simple comma-separated key=value pairs. Does NOT handle -// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient -// for typical X.509 client cert DNs. -func ParseDN(dn string) map[string]string { - result := make(map[string]string) - parts := strings.Split(dn, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - idx := strings.Index(part, "=") - if idx < 1 { - continue - } - key := strings.TrimSpace(part[:idx]) - val := strings.TrimSpace(part[idx+1:]) - result[key] = val - } - return result -} diff --git a/server/auth/mtls_helpers.go b/server/auth/mtls_helpers.go new file mode 100644 index 0000000..505c9a7 --- /dev/null +++ b/server/auth/mtls_helpers.go @@ -0,0 +1,108 @@ +package auth + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "log" + "strings" + + "switchboard-core/models" + "switchboard-core/store" +) + +// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs. +// +// Examples: +// +// "CN=Jeff Smith,O=Acme Corp,OU=Engineering" +// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp" +// +// Handles simple comma-separated key=value pairs. Does NOT handle +// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient +// for typical X.509 client cert DNs. +func ParseDN(dn string) map[string]string { + result := make(map[string]string) + parts := strings.Split(dn, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + idx := strings.Index(part, "=") + if idx < 1 { + continue + } + key := strings.TrimSpace(part[:idx]) + val := strings.TrimSpace(part[idx+1:]) + result[key] = val + } + return result +} + +// FingerprintCert returns the hex-encoded SHA-256 hash of a certificate's +// raw DER encoding. This is used as the stable external_id for mTLS users. +func FingerprintCert(cert *x509.Certificate) string { + h := sha256.Sum256(cert.Raw) + return hex.EncodeToString(h[:]) +} + +// resolveOrProvision looks up an existing user by auth_source=mtls and external_id, +// or auto-provisions a new user from the certificate's CN and fingerprint. +// +// Shared by both MTLSProxyProvider and MTLSNativeProvider. +func resolveOrProvision( + ctx context.Context, + stores store.Stores, + cn string, + dnFields map[string]string, + fingerprint string, + autoActivate bool, + defaultTeam string, +) (*Result, error) { + // ── Look up existing user ────────────────────────────────────── + user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint) + if err == nil && user != nil { + if !user.IsActive { + return nil, ErrInactive + } + log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn) + return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil + } + + // ── Auto-provision ───────────────────────────────────────────── + if !autoActivate { + return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds) + } + + email := dnFields["emailAddress"] + if email == "" { + email = models.HandleFromName(cn) + "@mtls.local" + } + + handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn)) + + user = &models.User{ + Username: handle, + Email: email, + DisplayName: cn, + IsActive: true, + AuthSource: string(ModeMTLS), + ExternalID: &fingerprint, + Handle: handle, + } + + if err := stores.Users.Create(ctx, user); err != nil { + return nil, fmt.Errorf("auto-provision failed: %w", err) + } + + log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn) + EnsureEveryoneGroup(ctx, stores, user.ID) + + if defaultTeam != "" { + if err := stores.Teams.AddMember(ctx, defaultTeam, user.ID, "member"); err != nil { + log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err) + } + } + + return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil +} diff --git a/server/auth/mtls_native.go b/server/auth/mtls_native.go new file mode 100644 index 0000000..c7b8128 --- /dev/null +++ b/server/auth/mtls_native.go @@ -0,0 +1,72 @@ +package auth + +import ( + "errors" + "fmt" + + "github.com/gin-gonic/gin" + + "switchboard-core/store" +) + +// ErrNoCert is returned when no client certificate is presented on a +// connection that requires mTLS authentication. +var ErrNoCert = errors.New("no client certificate presented") + +// MTLSNativeConfig holds configuration for the native (non-proxy) mTLS provider. +type MTLSNativeConfig struct { + AutoActivate bool // auto-activate new users (default true) + DefaultTeam string // team ID for auto-provisioned users (optional) +} + +// MTLSNativeProvider authenticates by reading the peer certificate +// directly from the TLS connection state. Unlike MTLSProxyProvider, +// it does not trust headers — identity is cryptographically verified +// by the Go TLS stack before the HTTP layer runs. +// +// Requires TLS_MODE=mtls so the binary terminates TLS itself. +type MTLSNativeProvider struct { + cfg MTLSNativeConfig +} + +// NewMTLSNativeProvider creates a native mTLS auth provider. +func NewMTLSNativeProvider(cfg MTLSNativeConfig) *MTLSNativeProvider { + return &MTLSNativeProvider{cfg: cfg} +} + +func (p *MTLSNativeProvider) Mode() Mode { return ModeMTLS } + +func (p *MTLSNativeProvider) SupportsRegistration() bool { return false } + +func (p *MTLSNativeProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) { + return nil, ErrNotSupported +} + +// Authenticate reads the verified peer certificate from the TLS connection +// state. The CN becomes the username, and sha256(cert.Raw) is the stable +// external_id. Returns ErrNoCert when no TLS or no peer certificates. +func (p *MTLSNativeProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { + if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 { + return nil, fmt.Errorf("%w", ErrNoCert) + } + + peer := c.Request.TLS.PeerCertificates[0] + + cn := peer.Subject.CommonName + if cn == "" { + return nil, fmt.Errorf("%w: certificate has no CommonName", ErrInvalidCreds) + } + + // Build DN fields from the certificate subject for resolveOrProvision + dnFields := map[string]string{"CN": cn} + if len(peer.EmailAddresses) > 0 { + dnFields["emailAddress"] = peer.EmailAddresses[0] + } + + fingerprint := FingerprintCert(peer) + + return resolveOrProvision( + c.Request.Context(), stores, cn, dnFields, fingerprint, + p.cfg.AutoActivate, p.cfg.DefaultTeam, + ) +} diff --git a/server/auth/mtls_native_test.go b/server/auth/mtls_native_test.go new file mode 100644 index 0000000..9b5ea70 --- /dev/null +++ b/server/auth/mtls_native_test.go @@ -0,0 +1,410 @@ +package auth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "switchboard-core/store" +) + +// ── Test CA helpers ────────────────────────────────────────────────── + +type testCA struct { + Cert *x509.Certificate + Key *ecdsa.PrivateKey + CertPEM []byte + Pool *x509.CertPool +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate CA key: %v", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create CA cert: %v", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatalf("parse CA cert: %v", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + pool := x509.NewCertPool() + pool.AddCert(cert) + + return &testCA{Cert: cert, Key: key, CertPEM: certPEM, Pool: pool} +} + +type testCert struct { + Cert *x509.Certificate + Key *ecdsa.PrivateKey + TLSCert tls.Certificate +} + +func (ca *testCA) issueClient(t *testing.T, cn string, email string) *testCert { + t.Helper() + return ca.issueCert(t, cn, email, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(time.Hour)) +} + +func (ca *testCA) issueExpiredClient(t *testing.T, cn string) *testCert { + t.Helper() + return ca.issueCert(t, cn, "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(-time.Second)) +} + +func (ca *testCA) issueNode(t *testing.T, cn string) *testCert { + t.Helper() + return ca.issueCert(t, cn, "", []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, time.Now().Add(time.Hour)) +} + +func (ca *testCA) issueCert(t *testing.T, cn, email string, eku []x509.ExtKeyUsage, notAfter time.Time) *testCert { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: notAfter, + ExtKeyUsage: eku, + KeyUsage: x509.KeyUsageDigitalSignature, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + DNSNames: []string{"localhost"}, + } + if email != "" { + tmpl.EmailAddresses = []string{email} + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, &key.PublicKey, ca.Key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + + tlsCert := tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + } + + return &testCert{Cert: cert, Key: key, TLSCert: tlsCert} +} + +// ── FingerprintCert tests ─────────────────────────────────────────── + +func TestFingerprintCert(t *testing.T) { + ca := newTestCA(t) + c1 := ca.issueClient(t, "alice", "") + c2 := ca.issueClient(t, "alice", "") // same CN, different cert + + fp1 := FingerprintCert(c1.Cert) + fp2 := FingerprintCert(c2.Cert) + + if fp1 == "" { + t.Error("fingerprint should not be empty") + } + if len(fp1) != 64 { // sha256 hex = 64 chars + t.Errorf("fingerprint length = %d, want 64", len(fp1)) + } + if fp1 == fp2 { + t.Error("different certs should have different fingerprints") + } + + // Same cert → same fingerprint (deterministic) + if FingerprintCert(c1.Cert) != fp1 { + t.Error("fingerprint should be deterministic") + } +} + +// ── MTLSNativeProvider unit tests ─────────────────────────────────── + +func TestMTLSNativeProvider_Mode(t *testing.T) { + p := NewMTLSNativeProvider(MTLSNativeConfig{}) + if p.Mode() != ModeMTLS { + t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS) + } +} + +func TestMTLSNativeProvider_NoRegistration(t *testing.T) { + p := NewMTLSNativeProvider(MTLSNativeConfig{}) + if p.SupportsRegistration() { + t.Error("native mTLS should not support registration") + } + _, err := p.Register(nil, store.Stores{}) + if err != ErrNotSupported { + t.Errorf("Register() = %v, want ErrNotSupported", err) + } +} + +func TestMTLSNativeProvider_NilTLS(t *testing.T) { + p := NewMTLSNativeProvider(MTLSNativeConfig{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.TLS = nil + + _, err := p.Authenticate(c, store.Stores{}) + if err == nil { + t.Fatal("expected error for nil TLS") + } + if !errors.Is(err, ErrNoCert) { + t.Errorf("expected ErrNoCert, got: %v", err) + } +} + +func TestMTLSNativeProvider_EmptyPeerCerts(t *testing.T) { + p := NewMTLSNativeProvider(MTLSNativeConfig{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{}, + } + + _, err := p.Authenticate(c, store.Stores{}) + if err == nil { + t.Fatal("expected error for empty peer certs") + } + if !errors.Is(err, ErrNoCert) { + t.Errorf("expected ErrNoCert, got: %v", err) + } +} + +func TestMTLSNativeProvider_NoCN(t *testing.T) { + p := NewMTLSNativeProvider(MTLSNativeConfig{AutoActivate: true}) + ca := newTestCA(t) + cert := ca.issueCert(t, "", "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(time.Hour)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert.Cert}, + } + + _, err := p.Authenticate(c, store.Stores{}) + if err == nil { + t.Fatal("expected error for cert with no CN") + } + if !errors.Is(err, ErrInvalidCreds) { + t.Errorf("expected ErrInvalidCreds, got: %v", err) + } +} + +func TestMTLSNativeProvider_ExtractsEmail(t *testing.T) { + ca := newTestCA(t) + cert := ca.issueClient(t, "alice", "alice@example.com") + + if len(cert.Cert.EmailAddresses) == 0 || cert.Cert.EmailAddresses[0] != "alice@example.com" { + t.Fatalf("cert should have email alice@example.com, got %v", cert.Cert.EmailAddresses) + } +} + +// ── Integration tests (real TLS listener) ─────────────────────────── + +func TestTLS_NoClientCert_Rejected(t *testing.T) { + ca := newTestCA(t) + serverCert := ca.issueNode(t, "server") + + srv := newMTLSTestServer(t, ca, serverCert) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: ca.Pool, + MinVersion: tls.VersionTLS13, + }, + }, + } + + _, err := client.Get(srv.URL + "/test") + if err == nil { + t.Fatal("expected TLS handshake error when no client cert is presented") + } +} + +func TestTLS_WrongCA_Rejected(t *testing.T) { + ca := newTestCA(t) + wrongCA := newTestCA(t) + serverCert := ca.issueNode(t, "server") + clientCert := wrongCA.issueClient(t, "intruder", "") + + srv := newMTLSTestServer(t, ca, serverCert) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: ca.Pool, + Certificates: []tls.Certificate{clientCert.TLSCert}, + MinVersion: tls.VersionTLS13, + }, + }, + } + + _, err := client.Get(srv.URL + "/test") + if err == nil { + t.Fatal("expected TLS handshake error when client cert is from wrong CA") + } +} + +func TestTLS_ValidClientCert_Accepted(t *testing.T) { + ca := newTestCA(t) + serverCert := ca.issueNode(t, "server") + clientCert := ca.issueClient(t, "alice", "") + + srv := newMTLSTestServer(t, ca, serverCert) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: ca.Pool, + Certificates: []tls.Certificate{clientCert.TLSCert}, + MinVersion: tls.VersionTLS13, + }, + }, + } + + resp, err := client.Get(srv.URL + "/test") + if err != nil { + t.Fatalf("expected successful connection, got: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +func TestTLS_ExpiredCert_Rejected(t *testing.T) { + ca := newTestCA(t) + serverCert := ca.issueNode(t, "server") + clientCert := ca.issueExpiredClient(t, "expired-user") + + srv := newMTLSTestServer(t, ca, serverCert) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: ca.Pool, + Certificates: []tls.Certificate{clientCert.TLSCert}, + MinVersion: tls.VersionTLS13, + }, + }, + } + + _, err := client.Get(srv.URL + "/test") + if err == nil { + t.Fatal("expected TLS handshake error for expired client cert") + } +} + +func TestTLS_PeerCertificateVisible(t *testing.T) { + ca := newTestCA(t) + serverCert := ca.issueNode(t, "server") + clientCert := ca.issueClient(t, "bob", "bob@example.com") + + var seenCN string + var seenEmails []string + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { + peer := r.TLS.PeerCertificates[0] + seenCN = peer.Subject.CommonName + seenEmails = peer.EmailAddresses + } + w.WriteHeader(200) + }) + + srv := newMTLSTestServerWithHandler(t, ca, serverCert, handler) + defer srv.Close() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: ca.Pool, + Certificates: []tls.Certificate{clientCert.TLSCert}, + MinVersion: tls.VersionTLS13, + }, + }, + } + + resp, err := client.Get(srv.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if seenCN != "bob" { + t.Errorf("CN = %q, want %q", seenCN, "bob") + } + if len(seenEmails) == 0 || seenEmails[0] != "bob@example.com" { + t.Errorf("emails = %v, want [bob@example.com]", seenEmails) + } +} + +// ── Test helpers ──────────────────────────────────────────────────── + +func newMTLSTestServer(t *testing.T, ca *testCA, serverCert *testCert) *httptest.Server { + t.Helper() + return newMTLSTestServerWithHandler(t, ca, serverCert, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + fmt.Fprint(w, "ok") + })) +} + +func newMTLSTestServerWithHandler(t *testing.T, ca *testCA, serverCert *testCert, handler http.Handler) *httptest.Server { + t.Helper() + srv := httptest.NewUnstartedServer(handler) + srv.TLS = &tls.Config{ + Certificates: []tls.Certificate{serverCert.TLSCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: ca.Pool, + MinVersion: tls.VersionTLS13, + } + srv.StartTLS() + return srv +} diff --git a/server/auth/mtls_proxy.go b/server/auth/mtls_proxy.go new file mode 100644 index 0000000..e5a1510 --- /dev/null +++ b/server/auth/mtls_proxy.go @@ -0,0 +1,94 @@ +package auth + +import ( + "fmt" + + "github.com/gin-gonic/gin" + + "switchboard-core/store" +) + +// MTLSProxyConfig holds configuration for the proxy-terminated mTLS provider. +type MTLSProxyConfig struct { + HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN") + HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify") + HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint") + AutoActivate bool // auto-activate new users (default true) + DefaultTeam string // team ID for auto-provisioned users (optional) +} + +// MTLSProxyProvider authenticates via client certificate headers injected +// by the TLS-terminating reverse proxy (nginx, Traefik, Istio). +// +// The backend never sees the actual TLS handshake — it trusts headers +// injected by the proxy after cert validation. After reading and parsing +// the DN, the provider resolves an existing user or auto-provisions a +// new one, then returns a Result. The auth handler issues an internal JWT. +// +// Header flow: +// +// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN, +// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers. +type MTLSProxyProvider struct { + cfg MTLSProxyConfig +} + +func NewMTLSProxyProvider(cfg MTLSProxyConfig) *MTLSProxyProvider { + if cfg.HeaderDN == "" { + cfg.HeaderDN = "X-SSL-Client-DN" + } + if cfg.HeaderVerify == "" { + cfg.HeaderVerify = "X-SSL-Client-Verify" + } + if cfg.HeaderFingerprint == "" { + cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint" + } + return &MTLSProxyProvider{cfg: cfg} +} + +func (p *MTLSProxyProvider) Mode() Mode { return ModeMTLS } + +func (p *MTLSProxyProvider) SupportsRegistration() bool { return false } + +func (p *MTLSProxyProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) { + return nil, ErrNotSupported +} + +// Authenticate reads the cert DN and verify status from proxy headers, +// parses the DN fields, and resolves or auto-provisions the user. +// +// Returns ErrInvalidCreds when headers are missing or verify fails. +// Returns ErrInactive when user exists but is deactivated. +func (p *MTLSProxyProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { + // ── Validate headers ─────────────────────────────────────────── + verify := c.GetHeader(p.cfg.HeaderVerify) + if verify == "" { + return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify) + } + // nginx: "SUCCESS", Traefik: "0" (both mean valid cert) + if verify != "SUCCESS" && verify != "0" { + return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify) + } + + dn := c.GetHeader(p.cfg.HeaderDN) + if dn == "" { + return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN) + } + + fields := ParseDN(dn) + cn := fields["CN"] + if cn == "" { + return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds) + } + + // Stable external identity: fingerprint if available, else full DN + fingerprint := c.GetHeader(p.cfg.HeaderFingerprint) + if fingerprint == "" { + fingerprint = dn + } + + return resolveOrProvision( + c.Request.Context(), stores, cn, fields, fingerprint, + p.cfg.AutoActivate, p.cfg.DefaultTeam, + ) +} diff --git a/server/auth/mtls_test.go b/server/auth/mtls_proxy_test.go similarity index 93% rename from server/auth/mtls_test.go rename to server/auth/mtls_proxy_test.go index 253f17a..7fc38ac 100644 --- a/server/auth/mtls_test.go +++ b/server/auth/mtls_proxy_test.go @@ -85,7 +85,7 @@ func TestParseDN(t *testing.T) { } func TestMTLSConfig_Defaults(t *testing.T) { - p := NewMTLSProvider(MTLSConfig{}) + p := NewMTLSProxyProvider(MTLSProxyConfig{}) if p.cfg.HeaderDN != "X-SSL-Client-DN" { t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN) @@ -99,7 +99,7 @@ func TestMTLSConfig_Defaults(t *testing.T) { } func TestMTLSConfig_Custom(t *testing.T) { - p := NewMTLSProvider(MTLSConfig{ + p := NewMTLSProxyProvider(MTLSProxyConfig{ HeaderDN: "X-Client-Cert-DN", HeaderVerify: "X-Client-Cert-Verify", }) @@ -110,14 +110,14 @@ func TestMTLSConfig_Custom(t *testing.T) { } func TestMTLSProvider_Mode(t *testing.T) { - p := NewMTLSProvider(MTLSConfig{}) + p := NewMTLSProxyProvider(MTLSProxyConfig{}) if p.Mode() != ModeMTLS { t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS) } } func TestMTLSProvider_NoRegistration(t *testing.T) { - p := NewMTLSProvider(MTLSConfig{}) + p := NewMTLSProxyProvider(MTLSProxyConfig{}) if p.SupportsRegistration() { t.Error("mTLS should not support registration") } diff --git a/server/config/config.go b/server/config/config.go index 52a9c01..c3cc855 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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 diff --git a/server/config/tls.go b/server/config/tls.go new file mode 100644 index 0000000..c1472fb --- /dev/null +++ b/server/config/tls.go @@ -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 +} diff --git a/server/main.go b/server/main.go index e9236e7..8ce3c0e 100644 --- a/server/main.go +++ b/server/main.go @@ -59,6 +59,11 @@ func main() { startTime := time.Now() cfg := config.Load() + // Validate TLS_MODE early + if err := config.ValidateTLSMode(cfg.TLSMode); err != nil { + log.Fatalf("❌ %v", err) + } + // Structured logging — must be first so all subsequent // log output goes through slog. logging.Init(cfg.LogFormat, cfg.LogLevel) @@ -349,13 +354,24 @@ func main() { case auth.ModeBuiltin: authProvider = auth.NewBuiltinProvider() case auth.ModeMTLS: - authProvider = auth.NewMTLSProvider(auth.MTLSConfig{ - HeaderDN: cfg.MTLSHeaderDN, - HeaderVerify: cfg.MTLSHeaderVerify, - HeaderFingerprint: cfg.MTLSHeaderFingerprint, - AutoActivate: cfg.MTLSAutoActivate, - DefaultTeam: cfg.MTLSDefaultTeam, - }) + if cfg.TLSMode == config.TLSModeMTLS { + // Native mTLS: Go binary terminates TLS, reads peer certs directly + authProvider = auth.NewMTLSNativeProvider(auth.MTLSNativeConfig{ + AutoActivate: cfg.MTLSAutoActivate, + DefaultTeam: cfg.MTLSDefaultTeam, + }) + log.Printf(" Auth: native mTLS (TLS_MODE=mtls)") + } else { + // Proxy mTLS: reverse proxy terminates TLS, injects headers + authProvider = auth.NewMTLSProxyProvider(auth.MTLSProxyConfig{ + HeaderDN: cfg.MTLSHeaderDN, + HeaderVerify: cfg.MTLSHeaderVerify, + HeaderFingerprint: cfg.MTLSHeaderFingerprint, + AutoActivate: cfg.MTLSAutoActivate, + DefaultTeam: cfg.MTLSDefaultTeam, + }) + log.Printf(" Auth: proxy mTLS (headers)") + } case auth.ModeOIDC: var err error authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{ @@ -893,8 +909,26 @@ func main() { } log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces())) - if err := r.Run(":" + cfg.Port); err != nil { - log.Fatalf("Failed to start server: %v", err) + + addr := ":" + cfg.Port + if cfg.TLSMode == config.TLSModeNone || cfg.TLSMode == "" { + if err := r.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + } else { + tlsCfg, err := config.BuildTLSConfig(cfg) + if err != nil { + log.Fatalf("❌ TLS config error: %v", err) + } + srv := &http.Server{ + Addr: addr, + Handler: r, + TLSConfig: tlsCfg, + } + log.Printf(" TLS: mode=%s", cfg.TLSMode) + if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil { + log.Fatalf("Failed to start TLS server: %v", err) + } } }