Feat v0.6.7 native mtls #42

Merged
xcaliber merged 1 commits from feat/v0.6.7-native-mtls into main 2026-03-31 18:36:13 +00:00
14 changed files with 1138 additions and 209 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap
## Current: v0.6.6Final Hardening
## Current: v0.6.7Native 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 <n> --san <addrs>` (365d) · `issue-user --cn <name>` (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 <n> --san <addrs>` (365d) · `issue-user --cn <name>` (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

View File

@@ -1 +1 @@
0.6.6
0.6.7

View File

@@ -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.

237
scripts/switchboard-ca.sh Executable file
View File

@@ -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 <node-name> --san <dns1,ip1,...>
# switchboard-ca issue-user --cn <username> [--email <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 <node-name> --san <dns1,ip1,...>"
[ -n "$san" ] || die "usage: $0 issue-node --name <node-name> --san <dns1,ip1,...>"
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" <<EOF
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = ${san_entries}
EOF
# Sign with CA
openssl x509 -req \
-in "$NODES_DIR/$name.csr" \
-CA "$CA_DIR/cluster-ca.crt" \
-CAkey "$CA_DIR/cluster-ca.key" \
-CAcreateserial \
-out "$NODES_DIR/$name.crt" \
-days "$NODE_DAYS" \
-extfile "$ext_file" \
2>/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 <username> [--email <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" <<EOF
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
EOF
if [ -n "$san_line" ]; then
echo "subjectAltName = ${san_line}" >> "$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 <command>"
echo ""
echo "Commands:"
echo " init Initialize cluster CA"
echo " issue-node --name <n> --san <addrs> Issue node cert (server+client auth)"
echo " issue-user --cn <name> [--email <e>] Issue user cert (client auth only)"
exit 1
;;
esac

View File

@@ -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
}

108
server/auth/mtls_helpers.go Normal file
View File

@@ -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
}

View File

@@ -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,
)
}

View File

@@ -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
}

94
server/auth/mtls_proxy.go Normal file
View File

@@ -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,
)
}

View File

@@ -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")
}

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
}

View File

@@ -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)
}
}
}