diff --git a/.env.example b/.env.example index cc197b652..351c72c0d 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,9 @@ ENABLE_SECURE_COOKIES=true # - Fail Open 策略:Redis 不可用时自动降级,不影响服务可用性 ENABLE_RATE_LIMIT=true # 是否启用限流功能(默认:true) REDIS_URL=redis://localhost:6379 # Redis 连接地址(Docker 部署使用 redis://redis:6379,支持 rediss:// TLS) +REDIS_TLS_REJECT_UNAUTHORIZED=true # 是否验证 Redis TLS 证书(默认:true) + # 设置为 false 可跳过证书验证,用于自签证书或共享证书场景 + # 仅在 rediss:// 协议时生效 # Session 配置 SESSION_TTL=300 # Session 过期时间(秒,默认 300 = 5 分钟) diff --git a/README.en.md b/README.en.md index 6ff2bf4fd..9adf7b919 100644 --- a/README.en.md +++ b/README.en.md @@ -246,6 +246,7 @@ Docker Compose is the **preferred deployment method** — it automatically provi | `DSN` | - | PostgreSQL connection string, e.g., `postgres://user:pass@host:5432/db`. | | `AUTO_MIGRATE` | `true` | Executes Drizzle migrations on startup; consider disabling in production for manual control. | | `REDIS_URL` | `redis://localhost:6379` | Redis endpoint, supports `rediss://` for TLS providers. | +| `REDIS_TLS_REJECT_UNAUTHORIZED` | `true` | Validate Redis TLS certificates; set `false` to skip (for self-signed/shared certs). | | `ENABLE_RATE_LIMIT` | `true` | Toggles multi-dimensional rate limiting; Fail-Open handles Redis outages gracefully. | | `SESSION_TTL` | `300` | Session cache window (seconds) that drives vendor reuse. | | `ENABLE_SECURE_COOKIES` | `true` | Browsers require HTTPS for Secure cookies; set to `false` when serving plain HTTP outside localhost. | diff --git a/README.md b/README.md index 7eb309f73..53b4cebb3 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ Docker Compose 是**首选部署方式**,自动配置数据库、Redis 和应 | `DSN` | - | PostgreSQL 连接串,如 `postgres://user:pass@host:5432/db`. | | `AUTO_MIGRATE` | `true` | 启动时自动执行 Drizzle 迁移;生产环境可关闭以人工控制。 | | `REDIS_URL` | `redis://localhost:6379` | Redis 地址,支持 `rediss://` 用于 TLS。 | +| `REDIS_TLS_REJECT_UNAUTHORIZED` | `true` | 是否验证 Redis TLS 证书;设为 `false` 可跳过验证(用于自签/共享证书)。 | | `ENABLE_RATE_LIMIT` | `true` | 控制多维限流开关;Fail-Open 策略在 Redis 不可用时自动降级。 | | `SESSION_TTL` | `300` | Session 缓存时间(秒),影响供应商复用策略。 | | `ENABLE_SECURE_COOKIES` | `true` | 仅 HTTPS 场景能设置 Secure Cookie;HTTP 访问(非 localhost)需改为 `false`。 | diff --git a/scripts/clear-session-bindings.ts b/scripts/clear-session-bindings.ts index dad8e566f..71e6dc9fc 100644 --- a/scripts/clear-session-bindings.ts +++ b/scripts/clear-session-bindings.ts @@ -274,11 +274,16 @@ async function createRedisClient(redisUrl: string): Promise { }; if (redisUrl.startsWith("rediss://")) { + const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false"; try { const url = new URL(redisUrl); - options.tls = { host: url.hostname }; + options.tls = { + host: url.hostname, + servername: url.hostname, // SNI support for cloud Redis providers + rejectUnauthorized, + }; } catch { - options.tls = {}; + options.tls = { rejectUnauthorized }; } } diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index 7ccd43547..72cb6dd80 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -32,6 +32,7 @@ export const EnvSchema = z.object({ AUTO_MIGRATE: z.string().default("true").transform(booleanTransform), PORT: z.coerce.number().default(23000), REDIS_URL: z.string().optional(), + REDIS_TLS_REJECT_UNAUTHORIZED: z.string().default("true").transform(booleanTransform), ENABLE_RATE_LIMIT: z.string().default("true").transform(booleanTransform), ENABLE_SECURE_COOKIES: z.string().default("true").transform(booleanTransform), SESSION_TTL: z.coerce.number().default(300), diff --git a/src/lib/log-cleanup/cleanup-queue.ts b/src/lib/log-cleanup/cleanup-queue.ts index f97222ca2..8c87dd3be 100644 --- a/src/lib/log-cleanup/cleanup-queue.ts +++ b/src/lib/log-cleanup/cleanup-queue.ts @@ -50,9 +50,12 @@ function getCleanupQueue(): Queue.Queue { redisQueueOptions.username = url.username; // 传递用户名 if (useTls) { - logger.info("[CleanupQueue] Using TLS connection (rediss://)"); + const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false"; + logger.info("[CleanupQueue] Using TLS connection (rediss://)", { rejectUnauthorized }); redisQueueOptions.tls = { - host: url.hostname, // 显式 SNI 修复 + host: url.hostname, + servername: url.hostname, // SNI support for cloud Redis providers + rejectUnauthorized, }; } } catch (e) { diff --git a/src/lib/notification/notification-queue.ts b/src/lib/notification/notification-queue.ts index 64961be60..ae51139ca 100644 --- a/src/lib/notification/notification-queue.ts +++ b/src/lib/notification/notification-queue.ts @@ -70,9 +70,12 @@ function getNotificationQueue(): Queue.Queue { redisQueueOptions.username = url.username; // 传递用户名 if (useTls) { - logger.info("[NotificationQueue] Using TLS connection (rediss://)"); + const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false"; + logger.info("[NotificationQueue] Using TLS connection (rediss://)", { rejectUnauthorized }); redisQueueOptions.tls = { - host: url.hostname, // 显式 SNI 修复 + host: url.hostname, + servername: url.hostname, // SNI support for cloud Redis providers + rejectUnauthorized, }; } } catch (e) { diff --git a/src/lib/redis/client.ts b/src/lib/redis/client.ts index d82130465..0ec54f2f3 100644 --- a/src/lib/redis/client.ts +++ b/src/lib/redis/client.ts @@ -15,10 +15,31 @@ function maskRedisUrl(redisUrl: string) { } } +/** + * Build TLS configuration for Redis connection. + * Supports skipping certificate verification via REDIS_TLS_REJECT_UNAUTHORIZED env. + * Includes servername for SNI (Server Name Indication) support. + */ +function buildTlsConfig(redisUrl: string): Record { + const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false"; + + try { + const url = new URL(redisUrl); + return { + host: url.hostname, + servername: url.hostname, // SNI support for cloud Redis providers + rejectUnauthorized, + }; + } catch { + return { rejectUnauthorized }; + } +} + /** * Build ioredis connection options with protocol-based TLS detection. * - When `rediss://` is used, explicitly enable TLS via `tls: {}` * - When `redis://` is used, keep plaintext TCP (no TLS option) + * - Supports REDIS_TLS_REJECT_UNAUTHORIZED env to skip certificate verification */ export function buildRedisOptionsForUrl(redisUrl: string) { const isTLS = (() => { @@ -46,7 +67,7 @@ export function buildRedisOptionsForUrl(redisUrl: string) { } as const; // Explicit TLS config for Upstash and other managed Redis providers - const tlsOptions = isTLS ? { tls: {} as Record } : {}; + const tlsOptions = isTLS ? { tls: buildTlsConfig(redisUrl) } : {}; return { isTLS, options: { ...baseOptions, ...tlsOptions } }; } @@ -89,20 +110,14 @@ export function getRedisClient(): Redis | null { }, }; - // 2. 如果使用 rediss://,则添加显式的 TLS 和 SNI (host) 配置 + // 2. 如果使用 rediss://,则添加显式的 TLS 配置(支持跳过证书验证) if (useTls) { - logger.info("[Redis] Using TLS connection (rediss://)", { redisUrl: safeRedisUrl }); - try { - // 从 URL 中解析 hostname,用于 SNI - const url = new URL(redisUrl); - redisOptions.tls = { - host: url.hostname, - }; - } catch (e) { - logger.error("[Redis] Failed to parse REDIS_URL for TLS host:", e); - // 如果 URL 解析失败,回退 - redisOptions.tls = {}; - } + const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false"; + logger.info("[Redis] Using TLS connection (rediss://)", { + redisUrl: safeRedisUrl, + rejectUnauthorized, + }); + redisOptions.tls = buildTlsConfig(redisUrl); } // 3. 使用组合后的配置创建客户端