mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
68c150eba4
The redis.enableTLS / redis.tlsSkipVerify settings were accepted by the config layer but silently dropped before reaching the connection pool, so the plugin always dialed Redis in plaintext. This blocked TLS-only Redis deployments such as AWS ElastiCache with in-transit encryption. - Add EnableTLS, TLSSkipVerify, TLSServerName to backends.Config and PoolConfig and forward them through universal_cache_singleton -> backends.Config -> PoolConfig. - In the connection pool, dial via tls.Dialer.DialContext (TLS 1.2 minimum) with SNI defaulting to the host part of the configured Address when TLSServerName is empty, so ElastiCache cluster endpoints validate out of the box. Plain dial path now also propagates ctx. - Add regression tests covering successful TLS negotiation with skip- verify, rejection of self-signed certs without skip-verify, rejection of plain TCP servers when EnableTLS=true, and unaffected plaintext behavior. - Document maxRefreshTokenAgeSeconds (added in1b6c861) and the implicit SSE / WebSocket auth bypass (added in684a990) in README.md, docs/CONFIGURATION.md and docs/index.html. - Add the missing redis.tlsSkipVerify row to docs/index.html and clarify the redis.enableTLS description. patch-release
570 lines
13 KiB
Go
570 lines
13 KiB
Go
package backends
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Pure-Go Redis client implementation
|
|
// Compatible with Yaegi interpreter (no unsafe package)
|
|
// Implements RESP protocol for basic Redis operations
|
|
|
|
var (
|
|
ErrPoolExhausted = errors.New("connection pool exhausted")
|
|
)
|
|
|
|
// RedisBackend implements a Redis-based cache backend using pure Go
|
|
type RedisBackend struct {
|
|
config *Config
|
|
pool *ConnectionPool
|
|
healthMonitor *HealthMonitor
|
|
|
|
// Metrics
|
|
hits atomic.Int64
|
|
misses atomic.Int64
|
|
|
|
// Lifecycle
|
|
closed atomic.Bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewRedisBackend creates a new Redis cache backend with pure-Go implementation
|
|
func NewRedisBackend(config *Config) (*RedisBackend, error) {
|
|
if config == nil {
|
|
return nil, fmt.Errorf("config is required")
|
|
}
|
|
|
|
if config.RedisAddr == "" {
|
|
return nil, fmt.Errorf("redis address is required")
|
|
}
|
|
|
|
// Create connection pool with health checks enabled
|
|
// Timeouts are kept short to prevent request pileup when Redis is slow/stalled.
|
|
// The UniversalCache uses 200ms context timeout, so socket timeouts should be
|
|
// shorter to allow proper context cancellation handling.
|
|
poolConfig := &PoolConfig{
|
|
Address: config.RedisAddr,
|
|
Password: config.RedisPassword,
|
|
TLSServerName: config.TLSServerName,
|
|
DB: config.RedisDB,
|
|
MaxConnections: config.PoolSize,
|
|
ConnectTimeout: 2 * time.Second,
|
|
ReadTimeout: 500 * time.Millisecond,
|
|
WriteTimeout: 500 * time.Millisecond,
|
|
EnableHealthCheck: true,
|
|
MaxRetries: 3,
|
|
RetryDelay: 100 * time.Millisecond,
|
|
EnableTLS: config.EnableTLS,
|
|
TLSSkipVerify: config.TLSSkipVerify,
|
|
}
|
|
|
|
pool, err := NewConnectionPool(poolConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
|
}
|
|
|
|
// Create health monitor
|
|
healthConfig := DefaultHealthMonitorConfig()
|
|
healthMonitor := NewHealthMonitor(pool, healthConfig)
|
|
|
|
backend := &RedisBackend{
|
|
config: config,
|
|
pool: pool,
|
|
healthMonitor: healthMonitor,
|
|
}
|
|
|
|
// Test connectivity
|
|
if err := backend.Ping(context.Background()); err != nil {
|
|
_ = pool.Close()
|
|
return nil, fmt.Errorf("failed to ping Redis: %w", err)
|
|
}
|
|
|
|
// Start health monitoring
|
|
healthMonitor.Start()
|
|
|
|
return backend, nil
|
|
}
|
|
|
|
// Set stores a value in Redis with TTL
|
|
func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
|
if r.closed.Load() {
|
|
return ErrBackendClosed
|
|
}
|
|
|
|
prefixedKey := r.prefixKey(key)
|
|
|
|
// Execute with retry logic
|
|
return r.executeWithRetry(ctx, func(conn *RedisConn) error {
|
|
var err error
|
|
|
|
// Use PSETEX for millisecond precision, SETEX for second precision
|
|
if ttl > 0 {
|
|
ttlMillis := ttl.Milliseconds()
|
|
if ttlMillis < 1000 {
|
|
// Use PSETEX for sub-second TTLs (millisecond precision)
|
|
_, err = conn.Do("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
|
|
} else {
|
|
// Use SETEX for larger TTLs (second precision)
|
|
ttlSeconds := int(ttl.Seconds())
|
|
_, err = conn.Do("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
|
|
}
|
|
} else {
|
|
_, err = conn.Do("SET", prefixedKey, string(value))
|
|
}
|
|
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Get retrieves a value from Redis
|
|
func (r *RedisBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
|
|
if r.closed.Load() {
|
|
return nil, 0, false, ErrBackendClosed
|
|
}
|
|
|
|
prefixedKey := r.prefixKey(key)
|
|
var resultValue []byte
|
|
var resultTTL time.Duration
|
|
var resultExists bool
|
|
|
|
// Execute with retry logic
|
|
err := r.executeWithRetry(ctx, func(conn *RedisConn) error {
|
|
// Get value
|
|
resp, err := conn.Do("GET", prefixedKey)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNilResponse) {
|
|
r.misses.Add(1)
|
|
resultExists = false
|
|
return nil // Not an error, key just doesn't exist
|
|
}
|
|
return err
|
|
}
|
|
|
|
value, err := RESPString(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get TTL
|
|
ttlResp, err := conn.Do("TTL", prefixedKey)
|
|
if err != nil {
|
|
// If TTL fails, still return the value
|
|
r.hits.Add(1)
|
|
resultValue = []byte(value)
|
|
resultTTL = 0
|
|
resultExists = true
|
|
return nil
|
|
}
|
|
|
|
ttlSeconds, _ := RESPInt(ttlResp)
|
|
var ttl time.Duration
|
|
if ttlSeconds > 0 {
|
|
ttl = time.Duration(ttlSeconds) * time.Second
|
|
}
|
|
|
|
r.hits.Add(1)
|
|
resultValue = []byte(value)
|
|
resultTTL = ttl
|
|
resultExists = true
|
|
return nil
|
|
})
|
|
|
|
return resultValue, resultTTL, resultExists, err
|
|
}
|
|
|
|
// Delete removes a key from Redis
|
|
func (r *RedisBackend) Delete(ctx context.Context, key string) (bool, error) {
|
|
if r.closed.Load() {
|
|
return false, ErrBackendClosed
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer r.pool.Put(conn)
|
|
|
|
prefixedKey := r.prefixKey(key)
|
|
resp, err := conn.Do("DEL", prefixedKey)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
count, err := RESPInt(resp)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
// Exists checks if a key exists in Redis
|
|
func (r *RedisBackend) Exists(ctx context.Context, key string) (bool, error) {
|
|
if r.closed.Load() {
|
|
return false, ErrBackendClosed
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer r.pool.Put(conn)
|
|
|
|
prefixedKey := r.prefixKey(key)
|
|
resp, err := conn.Do("EXISTS", prefixedKey)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
count, err := RESPInt(resp)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
// Clear removes all keys with the configured prefix
|
|
func (r *RedisBackend) Clear(ctx context.Context) error {
|
|
if r.closed.Load() {
|
|
return ErrBackendClosed
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.pool.Put(conn)
|
|
|
|
// Use FLUSHDB if no prefix (clear entire DB)
|
|
if r.config.RedisPrefix == "" {
|
|
_, err := conn.Do("FLUSHDB")
|
|
return err
|
|
}
|
|
|
|
// With prefix, we need to scan and delete keys
|
|
// For simplicity in this implementation, we'll use KEYS pattern (not recommended for production at scale)
|
|
pattern := r.config.RedisPrefix + "*"
|
|
resp, err := conn.Do("KEYS", pattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Extract keys from array response
|
|
keys, ok := resp.([]interface{})
|
|
if !ok || len(keys) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Delete each key
|
|
for _, keyInterface := range keys {
|
|
key, err := RESPString(keyInterface)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
_, _ = conn.Do("DEL", key) // Best effort, ignore errors
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetStats returns backend statistics
|
|
func (r *RedisBackend) GetStats() map[string]interface{} {
|
|
hits := r.hits.Load()
|
|
misses := r.misses.Load()
|
|
total := hits + misses
|
|
|
|
hitRate := float64(0)
|
|
if total > 0 {
|
|
hitRate = float64(hits) / float64(total)
|
|
}
|
|
|
|
stats := map[string]interface{}{
|
|
"backend": "redis-pure-go",
|
|
"address": r.config.RedisAddr,
|
|
"hits": hits,
|
|
"misses": misses,
|
|
"hit_rate": hitRate,
|
|
"pool": r.pool.Stats(),
|
|
}
|
|
|
|
// Add health monitor stats if available
|
|
if r.healthMonitor != nil {
|
|
stats["health"] = r.healthMonitor.GetStats()
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// Ping checks Redis connectivity
|
|
func (r *RedisBackend) Ping(ctx context.Context) error {
|
|
if r.closed.Load() {
|
|
return ErrBackendClosed
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.pool.Put(conn)
|
|
|
|
_, err = conn.Do("PING")
|
|
return err
|
|
}
|
|
|
|
// Close closes the Redis backend and all connections
|
|
func (r *RedisBackend) Close() error {
|
|
if r.closed.Swap(true) {
|
|
return nil // Already closed
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
// Stop health monitor
|
|
if r.healthMonitor != nil {
|
|
r.healthMonitor.Stop()
|
|
}
|
|
|
|
// Close connection pool
|
|
if r.pool != nil {
|
|
return r.pool.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// prefixKey adds the configured prefix to a key
|
|
func (r *RedisBackend) prefixKey(key string) string {
|
|
if r.config.RedisPrefix == "" {
|
|
return key
|
|
}
|
|
return r.config.RedisPrefix + key
|
|
}
|
|
|
|
// executeWithRetry executes a Redis operation with exponential backoff retry logic.
|
|
// It checks context cancellation at multiple points to ensure fast abort when the
|
|
// caller's context is canceled (e.g., due to request timeout).
|
|
func (r *RedisBackend) executeWithRetry(ctx context.Context, operation func(*RedisConn) error) error {
|
|
maxRetries := 3
|
|
baseDelay := 50 * time.Millisecond // Reduced from 100ms to fail faster
|
|
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
// Check context before each attempt to fail fast
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
// If we can't get a connection and this is the last attempt, fail
|
|
if attempt == maxRetries-1 {
|
|
return fmt.Errorf("failed to get connection after %d attempts: %w", maxRetries, err)
|
|
}
|
|
|
|
// Wait with exponential backoff before retrying
|
|
delay := baseDelay * time.Duration(1<<uint(attempt))
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(delay):
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Execute the operation
|
|
err = operation(conn)
|
|
r.pool.Put(conn)
|
|
|
|
// Check context after operation - if canceled, don't bother retrying
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
// If successful, return
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// If error is not retryable or last attempt, fail
|
|
if attempt == maxRetries-1 || !isRetryableError(err) {
|
|
return err
|
|
}
|
|
|
|
// Wait with exponential backoff before retrying
|
|
delay := baseDelay * time.Duration(1<<uint(attempt))
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(delay):
|
|
continue
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("operation failed after %d attempts", maxRetries)
|
|
}
|
|
|
|
// isRetryableError determines if an error is worth retrying
|
|
func isRetryableError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
// Retry on connection errors, timeouts, etc.
|
|
// Don't retry on application-level errors like wrong type
|
|
errMsg := err.Error()
|
|
retryablePatterns := []string{
|
|
"connection",
|
|
"timeout",
|
|
"EOF",
|
|
"broken pipe",
|
|
"reset by peer",
|
|
}
|
|
|
|
for _, pattern := range retryablePatterns {
|
|
if contains(errMsg, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// SetMany stores multiple values in Redis using pipelining for efficiency
|
|
// This reduces N round-trips to a single round-trip
|
|
func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
|
|
if r.closed.Load() {
|
|
return ErrBackendClosed
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// For single items, use regular Set
|
|
if len(items) == 1 {
|
|
for key, value := range items {
|
|
return r.Set(ctx, key, value, ttl)
|
|
}
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.pool.Put(conn)
|
|
|
|
pipeline := conn.NewPipeline()
|
|
|
|
// Queue all SET commands
|
|
ttlSeconds := int(ttl.Seconds())
|
|
ttlMillis := ttl.Milliseconds()
|
|
|
|
for key, value := range items {
|
|
prefixedKey := r.prefixKey(key)
|
|
|
|
if ttl > 0 {
|
|
if ttlMillis < 1000 {
|
|
// Use PSETEX for sub-second TTLs
|
|
pipeline.Queue("PSETEX", prefixedKey, fmt.Sprintf("%d", ttlMillis), string(value))
|
|
} else {
|
|
// Use SETEX for larger TTLs
|
|
pipeline.Queue("SETEX", prefixedKey, fmt.Sprintf("%d", ttlSeconds), string(value))
|
|
}
|
|
} else {
|
|
pipeline.Queue("SET", prefixedKey, string(value))
|
|
}
|
|
}
|
|
|
|
// Execute pipeline
|
|
responses, err := pipeline.Execute()
|
|
if err != nil {
|
|
return fmt.Errorf("pipeline SetMany failed: %w", err)
|
|
}
|
|
|
|
// Check responses for errors (each should be "OK")
|
|
for i, resp := range responses {
|
|
if resp == nil {
|
|
continue
|
|
}
|
|
if str, ok := resp.(string); ok && str == "OK" {
|
|
continue
|
|
}
|
|
return fmt.Errorf("SetMany: unexpected response at index %d: %v", i, resp)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetMany retrieves multiple values from Redis using pipelining for efficiency
|
|
// This reduces N round-trips to a single round-trip
|
|
func (r *RedisBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
|
|
if r.closed.Load() {
|
|
return nil, ErrBackendClosed
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
return make(map[string][]byte), nil
|
|
}
|
|
|
|
// For single key, use regular Get
|
|
if len(keys) == 1 {
|
|
result := make(map[string][]byte)
|
|
value, _, exists, err := r.Get(ctx, keys[0])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exists {
|
|
result[keys[0]] = value
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
conn, err := r.pool.Get(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer r.pool.Put(conn)
|
|
|
|
pipeline := conn.NewPipeline()
|
|
|
|
// Queue all GET commands
|
|
prefixedKeys := make([]string, len(keys))
|
|
for i, key := range keys {
|
|
prefixedKeys[i] = r.prefixKey(key)
|
|
pipeline.Queue("GET", prefixedKeys[i])
|
|
}
|
|
|
|
// Execute pipeline
|
|
responses, err := pipeline.Execute()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pipeline GetMany failed: %w", err)
|
|
}
|
|
|
|
// Process responses
|
|
result := make(map[string][]byte)
|
|
for i, resp := range responses {
|
|
if resp == nil {
|
|
// Key doesn't exist
|
|
r.misses.Add(1)
|
|
continue
|
|
}
|
|
|
|
value, err := RESPString(resp)
|
|
if err != nil {
|
|
// Invalid response, skip this key
|
|
r.misses.Add(1)
|
|
continue
|
|
}
|
|
|
|
r.hits.Add(1)
|
|
result[keys[i]] = []byte(value)
|
|
}
|
|
|
|
return result, nil
|
|
}
|