Feat v0.6.7 native mtls (#42)
All checks were successful
All checks were successful
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:
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
16
ROADMAP.md
16
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Roadmap
|
# Switchboard Core — Roadmap
|
||||||
|
|
||||||
## Current: v0.6.6 — Final Hardening
|
## Current: v0.6.7 — Native 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
237
scripts/switchboard-ca.sh
Executable 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
|
||||||
@@ -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
108
server/auth/mtls_helpers.go
Normal 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
|
||||||
|
}
|
||||||
72
server/auth/mtls_native.go
Normal file
72
server/auth/mtls_native.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
410
server/auth/mtls_native_test.go
Normal file
410
server/auth/mtls_native_test.go
Normal 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
94
server/auth/mtls_proxy.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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
98
server/config/tls.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user