Guide for configuring TLS certificates for HTTP/3 connections in Spooky.
Overview
HTTP/3 uses QUIC as its transport protocol, which requires TLS 1.3 for encryption and authentication. Spooky requires valid TLS certificates to establish secure connections with clients.
Requirements
Protocol Requirements
- TLS 1.3 (required for QUIC/HTTP3)
- ALPN (Application-Layer Protocol Negotiation) support
- SNI (Server Name Indication) support
Supported Formats
- Certificates: PEM-encoded X.509 certificates
- Private Keys: PEM-encoded PKCS#8 format (recommended) or traditional RSA/ECDSA formats
- Key Types: RSA (2048-bit minimum) or ECDSA (P-256, P-384)
Certificate Generation
Development: Self-Signed Certificates with mkcert
For local development, mkcert generates locally-trusted certificates:
# Install mkcert
# Ubuntu/Debian
sudo apt install mkcert
# macOS
brew install mkcert
# Install local CA
mkcert -install
# Generate certificate for localhost
mkdir -p certs
cd certs
mkcert -key-file server.key -cert-file server.crt localhost 127.0.0.1 ::1
# Verify generation
ls -lh server.crt server.key
Configuration:
listen:
protocol: http3
port: 9889
address: "127.0.0.1"
tls:
cert: "certs/server.crt"
key: "certs/server.key"
Development: Self-Signed Certificates with OpenSSL
For environments where mkcert is not available:
# Create certificate directory
mkdir -p certs
cd certs
# Generate private key (RSA 2048-bit)
openssl genrsa -out server.key 2048
# Generate certificate signing request
openssl req -new -key server.key -out server.csr \
-subj "/C=US/ST=State/L=City/O=Development/CN=localhost"
# Generate self-signed certificate (valid 365 days)
openssl x509 -req -in server.csr -signkey server.key \
-out server.crt -days 365 -sha256
# Convert key to PKCS#8 format (recommended)
openssl pkcs8 -topk8 -nocrypt -in server.key -out server-pkcs8.key
# Verify certificate
openssl x509 -in server.crt -text -noout
# Clean up CSR
rm server.csr
Configuration:
listen:
protocol: http3
port: 9889
address: "127.0.0.1"
tls:
cert: "certs/server.crt"
key: "certs/server-pkcs8.key"
Production: Let's Encrypt
For production deployments with public domains:
# Install certbot
sudo apt update
sudo apt install certbot
# Option 1: Standalone mode (requires port 80 available)
sudo certbot certonly --standalone \
-d example.com \
-d www.example.com
# Option 2: DNS challenge (no port requirements)
sudo certbot certonly --manual \
--preferred-challenges dns \
-d example.com
# Certificates are saved to:
# Certificate: /etc/letsencrypt/live/example.com/fullchain.pem
# Private Key: /etc/letsencrypt/live/example.com/privkey.pem
Configuration:
listen:
protocol: http3
port: 9889
address: "0.0.0.0"
tls:
cert: "/etc/letsencrypt/live/example.com/fullchain.pem"
key: "/etc/letsencrypt/live/example.com/privkey.pem"
Production: ECDSA Certificates
ECDSA certificates offer better performance than RSA:
# Generate ECDSA private key (P-256)
openssl ecparam -genkey -name prime256v1 -out server-ec.key
# Convert to PKCS#8 format
openssl pkcs8 -topk8 -nocrypt -in server-ec.key -out server-ec-pkcs8.key
# Generate CSR
openssl req -new -key server-ec-pkcs8.key -out server-ec.csr \
-subj "/C=US/ST=State/L=City/O=Organization/CN=example.com"
# Generate self-signed certificate (or send CSR to CA)
openssl x509 -req -in server-ec.csr -signkey server-ec-pkcs8.key \
-out server-ec.crt -days 365 -sha256
Certificate Configuration
Basic Configuration
Minimal TLS configuration for HTTP/3:
listen:
protocol: http3
port: 9889
address: "0.0.0.0"
tls:
cert: "/path/to/certificate.pem"
key: "/path/to/private-key.pem"
Path Specifications
Paths can be absolute or relative:
# Absolute paths (recommended for production)
tls:
cert: "/etc/spooky/certs/fullchain.pem"
key: "/etc/spooky/certs/privkey.pem"
# Relative paths (relative to working directory)
tls:
cert: "certs/server.crt"
key: "certs/server.key"
Multi-Domain Certificates
For certificates covering multiple domains (SAN certificates):
# Generate certificate with Subject Alternative Names
openssl req -new -x509 -key server.key -out server.crt -days 365 \
-subj "/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:www.example.com,DNS:api.example.com"
Configuration remains the same:
tls:
cert: "/etc/spooky/certs/multi-domain.crt"
key: "/etc/spooky/certs/multi-domain.key"
Multi-Certificate SNI Configuration
Use listen.tls.certificates when one listener must present different certificates by hostname:
listen:
protocol: http3
address: "0.0.0.0"
port: 9889
tls:
cert: "/etc/spooky/certs/default-fullchain.pem"
key: "/etc/spooky/certs/default-privkey.pem"
certificates:
- server_name: "api.example.com"
cert: "/etc/spooky/certs/api-fullchain.pem"
key: "/etc/spooky/certs/api-privkey.pem"
- server_name: "www.example.com"
cert: "/etc/spooky/certs/www-fullchain.pem"
key: "/etc/spooky/certs/www-privkey.pem"
Selection order:
- Exact
server_namematch inlisten.tls.certificates - Fallback
listen.tls.cert+listen.tls.key - If no legacy fallback pair is configured, the first
certificates[]entry becomes the default identity
Fallback behavior details:
- Both the native QUIC/HTTP/3 listener and the bootstrap TLS listener use the same selection order.
server_namematching is exact after hostname normalization. There is no wildcard SNI certificate lookup here; wildcard behavior must come from the certificate SANs themselves, not from the listener map.- If the client sends no SNI, Spooky always serves the default identity.
- If the client sends an SNI hostname that is not present in
listen.tls.certificates, Spooky serves the default identity rather than rejecting the handshake. - Startup rejects any
listen.tls.certificates[].server_namemapping whose configured certificate SANs do not cover that hostname.
File Permissions and Security
Recommended Permissions
Restrict access to certificate files:
# Create dedicated certificate directory
sudo mkdir -p /etc/spooky/certs
sudo chown spooky:spooky /etc/spooky/certs
sudo chmod 700 /etc/spooky/certs
# Set certificate permissions
sudo chmod 644 /etc/spooky/certs/server.crt
sudo chmod 600 /etc/spooky/certs/server.key
# Verify permissions
ls -l /etc/spooky/certs/
Expected output:
drwx------ 2 spooky spooky 4096 Dec 15 10:00 .
-rw-r--r-- 1 spooky spooky 1234 Dec 15 10:00 server.crt
-rw------- 1 spooky spooky 1704 Dec 15 10:00 server.key
Security Best Practices
- Private Key Protection
- Never commit private keys to version control
- Use restrictive file permissions (600)
- Store keys on encrypted filesystems
-
Consider using hardware security modules (HSM) for production
-
Certificate Chain Validation
- Use complete certificate chains (fullchain.pem with Let's Encrypt)
- Include intermediate certificates
-
Verify chain with
openssl verify -
Certificate Monitoring
- Monitor expiration dates
- Set up renewal automation for Let's Encrypt
- Implement alerting for certificates expiring within 30 days
- Scrape:
spooky_downstream_tls_certificate_not_after_secondsspooky_downstream_tls_certificate_days_remaining
Certificate Validation
Verify Certificate and Key Match
Ensure certificate and private key are paired correctly:
# Extract modulus from certificate
cert_modulus=$(openssl x509 -noout -modulus -in server.crt | md5sum)
# Extract modulus from private key
key_modulus=$(openssl rsa -noout -modulus -in server.key | md5sum)
# Compare (should be identical)
echo "Certificate: $cert_modulus"
echo "Private Key: $key_modulus"
For ECDSA keys:
# Verify ECDSA private key
openssl ec -in server-ec.key -check
# Verify certificate
openssl x509 -in server-ec.crt -text -noout
Verify Certificate Properties
Check certificate details:
# Display certificate information
openssl x509 -in server.crt -text -noout
# Check expiration date
openssl x509 -in server.crt -noout -enddate
# Check subject and issuer
openssl x509 -in server.crt -noout -subject -issuer
# Verify certificate chain
openssl verify -CAfile ca.crt server.crt
Test Configuration
Verify Spooky can load certificates:
# Test configuration validity
spooky --config config.yaml
# Run in debug mode to see TLS initialization
# Set log level in config.yaml (log.level) or via RUST_LOG=debug
spooky --config config.yaml
Certificate Rotation and Renewal
Let's Encrypt Automatic Renewal
Let's Encrypt certificates are valid for 90 days. Set up automatic renewal:
# Test renewal process
sudo certbot renew --dry-run
# Enable automatic renewal (certbot installs systemd timer)
sudo systemctl status certbot.timer
# Manually renew certificates
sudo certbot renew
# Reload listener certificates for new handshakes
curl -X POST \
-H "Authorization: Bearer ${SPOOKY_CONTROL_API_TOKEN}" \
https://127.0.0.1:9902/admin/runtime/reload-certs
Manual Certificate Rotation
For manually-managed certificates:
# Backup current certificates
sudo cp /etc/spooky/certs/server.crt /etc/spooky/certs/server.crt.backup
sudo cp /etc/spooky/certs/server.key /etc/spooky/certs/server.key.backup
# Install new certificates
sudo cp new-server.crt /etc/spooky/certs/server.crt
sudo cp new-server.key /etc/spooky/certs/server.key
# Set permissions
sudo chmod 644 /etc/spooky/certs/server.crt
sudo chmod 600 /etc/spooky/certs/server.key
# Reload listener certificates for new handshakes
curl -X POST \
-H "Authorization: Bearer ${SPOOKY_CONTROL_API_TOKEN}" \
https://127.0.0.1:9902/admin/runtime/reload-certs
# Verify new certificates are loaded
openssl s_client -connect localhost:9889 -servername localhost < /dev/null 2>/dev/null | openssl x509 -noout -dates
Reload behavior:
- New QUIC and bootstrap TLS handshakes use the updated certificate material immediately after reload succeeds.
- Existing connections are not interrupted or re-handshaken.
- Existing QUIC connections keep the certificate and client-auth policy that were negotiated when their handshake completed. The new certificate material is only visible to later QUIC Initial packets and later bootstrap TCP+TLS accepts.
- Existing HTTP/2 streams multiplexed over an already-established bootstrap TLS session are also unaffected. Only brand-new bootstrap TLS sessions observe the reloaded certificate set.
Downstream TLS Metrics
Spooky exposes downstream TLS observability through Prometheus:
spooky_downstream_tls_handshake_failure_total{listener,reason}spooky_downstream_tls_certificate_selection_total{listener,selection}spooky_downstream_tls_alpn_total{listener,protocol}spooky_downstream_tls_certificate_not_after_seconds{listener,server_name}spooky_downstream_tls_certificate_days_remaining{listener,server_name}
Important label values:
reason=missing_client_cert: mTLS listener required a client cert and none was presentedreason=invalid_client_cert: client cert was present but rejected for a generic certificate validation reasonreason=expired_client_cert: client cert was expired or not yet validreason=unknown_issuer: client cert chain was not rooted in the configured CA setreason=alpn: handshake failed because no acceptable application protocol could be negotiatedreason=handshake: fallback bucket for other downstream TLS handshake failures
Certificate-selection labels:
selection=exact_sni: exact SNI match inlisten.tls.certificatesselection=fallback_unmatched_sni: client sent SNI, but no configured mapping matched, so Spooky served the default identityselection=fallback_no_sni: client sent no SNI and Spooky served the default identity while additional SNI identities existedselection=default_only: listener had only one effective identity, so that certificate was always served
Monitoring Certificate Expiry
Check certificate expiration:
# Check days until expiry
openssl x509 -in /etc/spooky/certs/server.crt -noout -enddate
# Calculate days remaining
days_left=$(( ($(date -d "$(openssl x509 -in /etc/spooky/certs/server.crt -noout -enddate | cut -d= -f2)" +%s) - $(date +%s)) / 86400 ))
echo "Certificate expires in $days_left days"
# Alert if less than 30 days
if [ $days_left -lt 30 ]; then
echo "WARNING: Certificate expires soon!"
fi
Troubleshooting
Common Issues
Certificate File Not Found
Error: failed to read certificate file: No such file or directory
Solution:
# Verify file exists
ls -l /etc/spooky/certs/server.crt
# Check path in configuration
cat config.yaml | grep -A2 tls
# Use absolute paths
realpath certs/server.crt
Permission Denied
Error: failed to read certificate file: Permission denied
Solution:
# Check file permissions
ls -l /etc/spooky/certs/
# Fix permissions
sudo chown spooky:spooky /etc/spooky/certs/server.{crt,key}
sudo chmod 644 /etc/spooky/certs/server.crt
sudo chmod 600 /etc/spooky/certs/server.key
# Verify Spooky user can read files
sudo -u spooky cat /etc/spooky/certs/server.crt > /dev/null
Invalid Certificate Format
Error: failed to parse certificate: invalid PEM format
Solution:
# Verify PEM format
openssl x509 -in server.crt -text -noout
# Check file encoding
file server.crt
# Convert DER to PEM if needed
openssl x509 -inform DER -in server.der -out server.pem
Certificate and Key Mismatch
Error: certificate and private key do not match
Solution:
# Verify certificate and key match (RSA)
openssl x509 -noout -modulus -in server.crt | md5sum
openssl rsa -noout -modulus -in server.key | md5sum
# Verify ECDSA key
openssl ec -in server.key -pubout -out server-pub.pem
openssl x509 -in server.crt -pubkey -noout -out cert-pub.pem
diff server-pub.pem cert-pub.pem
PKCS#8 Format Required
Some systems require PKCS#8 format:
# Convert traditional RSA to PKCS#8
openssl pkcs8 -topk8 -nocrypt -in server.key -out server-pkcs8.key
# Update configuration to use PKCS#8 key
Testing TLS Connections
Test with OpenSSL
# Test TLS 1.3 connection
echo -e "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" | \
openssl s_client -connect localhost:9889 -servername localhost -tls1_3
# Display certificate chain
openssl s_client -connect localhost:9889 -servername localhost -showcerts < /dev/null
# Check ALPN negotiation
openssl s_client -connect localhost:9889 -servername localhost -alpn h3 < /dev/null
Test with cURL (HTTP/3 Support)
If curl is built with HTTP/3 support:
# Test HTTP/3 connection
curl --http3 https://localhost:9889/
# Verbose output for debugging
curl --http3 -v https://localhost:9889/
# Test with self-signed certificate
curl --http3 -k https://localhost:9889/
Debug Logging
Enable debug logging to troubleshoot TLS issues:
log:
level: debug
Look for log entries related to:
- Certificate loading
- TLS handshake
- QUIC connection establishment
- ALPN negotiation
Common Error Messages
| Error | Cause | Solution |
|---|---|---|
certificate has expired |
Certificate validity period ended | Renew certificate |
certificate is not yet valid |
System clock incorrect or certificate future-dated | Check system time |
unable to get local issuer certificate |
Missing intermediate certificate | Use fullchain.pem |
self signed certificate |
Client doesn't trust self-signed cert | Use CA-signed cert or add to client trust store |
wrong signature type |
Key algorithm mismatch | Ensure certificate and key use same algorithm |
Reference
Configuration Schema
listen:
tls:
cert: string # Path to PEM certificate file (required)
key: string # Path to PEM private key file (required)
Supported Key Algorithms
- RSA 2048-bit (minimum)
- RSA 4096-bit (recommended for long-term use)
- ECDSA P-256 (secp256r1)
- ECDSA P-384 (secp384r1)
Certificate Requirements
- PEM encoding
- X.509 format
- Valid date range (not expired, not future-dated)
- Subject Alternative Names (SAN) for multi-domain support
- Complete certificate chain (including intermediates)