Feat v0.6.7 native mtls (#42)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m37s
CI/CD / test-sqlite (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 52s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #42.
This commit is contained in:
2026-03-31 18:36:12 +00:00
committed by xcaliber
parent 7915d84c8b
commit fb5284f667
14 changed files with 1138 additions and 209 deletions

View File

@@ -2,6 +2,47 @@
All notable changes to Switchboard Core are documented here. 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 ## v0.6.6 — Final Hardening
Final pass before public release. Security, correctness, and developer Final pass before public release. Security, correctness, and developer

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap # Switchboard Core — Roadmap
## Current: v0.6.6Final Hardening ## Current: v0.6.7Native mTLS
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension. 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 | | 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_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. | | 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`. | | `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 | | 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. | | 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. |
| `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. | | `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` · same fingerprint → idempotent. | | 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, user created. Node-to-node: two in-process servers, distinct certs, same CA. Wrong-CA node rejected. | | 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 ### v0.6.6 — Final Hardening

View File

@@ -1 +1 @@
0.6.6 0.6.7

View File

@@ -1,7 +1,7 @@
# DESIGN — Native mTLS # DESIGN — Native mTLS
**Version:** v0.6.7 **Version:** v0.6.7
**Status:** Proposed **Status:** Implemented
**Author:** Jeff / Claude session 2026-03-31 **Author:** Jeff / Claude session 2026-03-31
--- ---
@@ -172,17 +172,17 @@ their own `node-N.key`.
### Cert Provisioning Tooling ### 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: 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/ → 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/ → 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/ → 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_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
| `server/auth/mtls_native_test.go` | Unit + integration tests | | `server/auth/mtls_native_test.go` | Unit + integration tests |
| `server/config/tls.go` | `TLSConfig` struct, loader, validation | | `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 `server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
clarity. No behavioral changes to the proxy provider. 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) { func TestMTLSConfig_Defaults(t *testing.T) {
p := NewMTLSProvider(MTLSConfig{}) p := NewMTLSProxyProvider(MTLSProxyConfig{})
if p.cfg.HeaderDN != "X-SSL-Client-DN" { if p.cfg.HeaderDN != "X-SSL-Client-DN" {
t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN) 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) { func TestMTLSConfig_Custom(t *testing.T) {
p := NewMTLSProvider(MTLSConfig{ p := NewMTLSProxyProvider(MTLSProxyConfig{
HeaderDN: "X-Client-Cert-DN", HeaderDN: "X-Client-Cert-DN",
HeaderVerify: "X-Client-Cert-Verify", HeaderVerify: "X-Client-Cert-Verify",
}) })
@@ -110,14 +110,14 @@ func TestMTLSConfig_Custom(t *testing.T) {
} }
func TestMTLSProvider_Mode(t *testing.T) { func TestMTLSProvider_Mode(t *testing.T) {
p := NewMTLSProvider(MTLSConfig{}) p := NewMTLSProxyProvider(MTLSProxyConfig{})
if p.Mode() != ModeMTLS { if p.Mode() != ModeMTLS {
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS) t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
} }
} }
func TestMTLSProvider_NoRegistration(t *testing.T) { func TestMTLSProvider_NoRegistration(t *testing.T) {
p := NewMTLSProvider(MTLSConfig{}) p := NewMTLSProxyProvider(MTLSProxyConfig{})
if p.SupportsRegistration() { if p.SupportsRegistration() {
t.Error("mTLS should not support registration") t.Error("mTLS should not support registration")
} }

View File

@@ -53,10 +53,19 @@ type Config struct {
LogFormat string LogFormat string
LogLevel 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" // Auth mode: "builtin" (default) | "mtls" | "oidc"
AuthMode string AuthMode string
// mTLS // mTLS (proxy mode)
// Headers injected by TLS-terminating reverse proxy. // Headers injected by TLS-terminating reverse proxy.
MTLSHeaderDN string // default "X-SSL-Client-DN" MTLSHeaderDN string // default "X-SSL-Client-DN"
MTLSHeaderVerify string // default "X-SSL-Client-Verify" MTLSHeaderVerify string // default "X-SSL-Client-Verify"
@@ -141,6 +150,12 @@ func Load() *Config {
LogFormat: getEnv("LOG_FORMAT", "text"), LogFormat: getEnv("LOG_FORMAT", "text"),
LogLevel: getEnv("LOG_LEVEL", "info"), 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"), AuthMode: getEnv("AUTH_MODE", "builtin"),
// mTLS // 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() startTime := time.Now()
cfg := config.Load() 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 // Structured logging — must be first so all subsequent
// log output goes through slog. // log output goes through slog.
logging.Init(cfg.LogFormat, cfg.LogLevel) logging.Init(cfg.LogFormat, cfg.LogLevel)
@@ -349,13 +354,24 @@ func main() {
case auth.ModeBuiltin: case auth.ModeBuiltin:
authProvider = auth.NewBuiltinProvider() authProvider = auth.NewBuiltinProvider()
case auth.ModeMTLS: case auth.ModeMTLS:
authProvider = auth.NewMTLSProvider(auth.MTLSConfig{ 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, HeaderDN: cfg.MTLSHeaderDN,
HeaderVerify: cfg.MTLSHeaderVerify, HeaderVerify: cfg.MTLSHeaderVerify,
HeaderFingerprint: cfg.MTLSHeaderFingerprint, HeaderFingerprint: cfg.MTLSHeaderFingerprint,
AutoActivate: cfg.MTLSAutoActivate, AutoActivate: cfg.MTLSAutoActivate,
DefaultTeam: cfg.MTLSDefaultTeam, DefaultTeam: cfg.MTLSDefaultTeam,
}) })
log.Printf(" Auth: proxy mTLS (headers)")
}
case auth.ModeOIDC: case auth.ModeOIDC:
var err error var err error
authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{ authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{
@@ -893,9 +909,27 @@ func main() {
} }
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces())) log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces()))
if err := r.Run(":" + cfg.Port); err != nil {
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) 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)
}
}
} }
// appendClusterHealth adds cluster info to a health response if the registry is active. // appendClusterHealth adds cluster info to a health response if the registry is active.