diff --git a/.env.example b/.env.example index c39ad4125..396dcdf21 100644 --- a/.env.example +++ b/.env.example @@ -601,11 +601,31 @@ HELP_AND_FAQ_URL=https://librechat.ai # REDIS Options # #===============# -# REDIS_URI=10.10.10.10:6379 +# Enable Redis for caching and session storage # USE_REDIS=true -# USE_REDIS_CLUSTER=true -# REDIS_CA=/path/to/ca.crt +# Single Redis instance +# REDIS_URI=redis://127.0.0.1:6379 + +# Redis cluster (multiple nodes) +# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 + +# Redis with TLS/SSL encryption and CA certificate +# REDIS_URI=rediss://127.0.0.1:6380 +# REDIS_CA=/path/to/ca-cert.pem + +# Redis authentication (if required) +# REDIS_USERNAME=your_redis_username +# REDIS_PASSWORD=your_redis_password + +# Redis key prefix configuration +# Use environment variable name for dynamic prefix (recommended for cloud deployments) +# REDIS_KEY_PREFIX_VAR=K_REVISION +# Or use static prefix directly +# REDIS_KEY_PREFIX=librechat + +# Redis connection limits +# REDIS_MAX_LISTENERS=40 #==================================================# # Others # diff --git a/.gitignore b/.gitignore index c9658f17e..461eef9d2 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,12 @@ helm/**/.values.yaml # SAML Idp cert *.cert + +# AI Assistants +/.claude/ +/.cursor/ +/.copilot/ +/.aider/ +/.openai/ +/.tabnine/ +/.codeium diff --git a/api/cache/cacheConfig.js b/api/cache/cacheConfig.js new file mode 100644 index 000000000..534b3f4b3 --- /dev/null +++ b/api/cache/cacheConfig.js @@ -0,0 +1,33 @@ +const fs = require('fs'); +const { math, isEnabled } = require('@librechat/api'); + +// To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. +// This prefix is usually the deployment ID, which is often passed to the container or pod as an env var. +// Set REDIS_KEY_PREFIX_VAR to the env var that contains the deployment ID. +const REDIS_KEY_PREFIX_VAR = process.env.REDIS_KEY_PREFIX_VAR; +const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX; +if (REDIS_KEY_PREFIX_VAR && REDIS_KEY_PREFIX) { + throw new Error('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); +} + +const USE_REDIS = isEnabled(process.env.USE_REDIS); +if (USE_REDIS && !process.env.REDIS_URI) { + throw new Error('USE_REDIS is enabled but REDIS_URI is not set.'); +} + +const cacheConfig = { + USE_REDIS, + REDIS_URI: process.env.REDIS_URI, + REDIS_USERNAME: process.env.REDIS_USERNAME, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + REDIS_CA: process.env.REDIS_CA ? fs.readFileSync(process.env.REDIS_CA, 'utf8') : null, + REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '', + REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40), + + CI: isEnabled(process.env.CI), + DEBUG_MEMORY_CACHE: isEnabled(process.env.DEBUG_MEMORY_CACHE), + + BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours +}; + +module.exports = { cacheConfig }; diff --git a/api/cache/cacheConfig.spec.js b/api/cache/cacheConfig.spec.js new file mode 100644 index 000000000..d931bf0ce --- /dev/null +++ b/api/cache/cacheConfig.spec.js @@ -0,0 +1,108 @@ +const fs = require('fs'); + +describe('cacheConfig', () => { + let originalEnv; + let originalReadFileSync; + + beforeEach(() => { + originalEnv = { ...process.env }; + originalReadFileSync = fs.readFileSync; + + // Clear all related env vars first + delete process.env.REDIS_URI; + delete process.env.REDIS_CA; + delete process.env.REDIS_KEY_PREFIX_VAR; + delete process.env.REDIS_KEY_PREFIX; + delete process.env.USE_REDIS; + + // Clear require cache + jest.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + fs.readFileSync = originalReadFileSync; + jest.resetModules(); + }); + + describe('REDIS_KEY_PREFIX validation and resolution', () => { + test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; + process.env.REDIS_KEY_PREFIX = 'manual-prefix'; + + expect(() => { + require('./cacheConfig'); + }).toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); + }); + + test('should resolve REDIS_KEY_PREFIX from variable reference', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; + process.env.DEPLOYMENT_ID = 'test-deployment-123'; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe('test-deployment-123'); + }); + + test('should use direct REDIS_KEY_PREFIX value', () => { + process.env.REDIS_KEY_PREFIX = 'direct-prefix'; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe('direct-prefix'); + }); + + test('should default to empty string when no prefix is configured', () => { + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); + }); + + test('should handle empty variable reference', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'EMPTY_VAR'; + process.env.EMPTY_VAR = ''; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); + }); + + test('should handle undefined variable reference', () => { + process.env.REDIS_KEY_PREFIX_VAR = 'UNDEFINED_VAR'; + + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); + }); + }); + + describe('USE_REDIS and REDIS_URI validation', () => { + test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', () => { + process.env.USE_REDIS = 'true'; + + expect(() => { + require('./cacheConfig'); + }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + }); + + test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', () => { + process.env.USE_REDIS = 'true'; + process.env.REDIS_URI = 'redis://localhost:6379'; + + expect(() => { + require('./cacheConfig'); + }).not.toThrow(); + }); + + test('should handle empty REDIS_URI when USE_REDIS is enabled', () => { + process.env.USE_REDIS = 'true'; + process.env.REDIS_URI = ''; + + expect(() => { + require('./cacheConfig'); + }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + }); + }); + + describe('REDIS_CA file reading', () => { + test('should be null when REDIS_CA is not set', () => { + const { cacheConfig } = require('./cacheConfig'); + expect(cacheConfig.REDIS_CA).toBeNull(); + }); + }); +}); diff --git a/api/cache/cacheFactory.js b/api/cache/cacheFactory.js new file mode 100644 index 000000000..f4147f89b --- /dev/null +++ b/api/cache/cacheFactory.js @@ -0,0 +1,66 @@ +const KeyvRedis = require('@keyv/redis').default; +const { Keyv } = require('keyv'); +const { cacheConfig } = require('./cacheConfig'); +const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients'); +const { Time } = require('librechat-data-provider'); +const ConnectRedis = require('connect-redis').default; +const MemoryStore = require('memorystore')(require('express-session')); +const { violationFile } = require('./keyvFiles'); +const { RedisStore } = require('rate-limit-redis'); + +/** + * Creates a cache instance using Redis or a fallback store. Suitable for general caching needs. + * @param {string} namespace - The cache namespace. + * @param {number} [ttl] - Time to live for cache entries. + * @param {object} [fallbackStore] - Optional fallback store if Redis is not used. + * @returns {Keyv} Cache instance. + */ +const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => { + if (cacheConfig.USE_REDIS) { + const keyvRedis = new KeyvRedis(keyvRedisClient); + const cache = new Keyv(keyvRedis, { namespace, ttl }); + keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; + keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR; + return cache; + } + if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl }); + return new Keyv({ namespace, ttl }); +}; + +/** + * Creates a cache instance for storing violation data. + * Uses a file-based fallback store if Redis is not enabled. + * @param {string} namespace - The cache namespace for violations. + * @param {number} [ttl] - Time to live for cache entries. + * @returns {Keyv} Cache instance for violations. + */ +const violationCache = (namespace, ttl = undefined) => { + return standardCache(`violations:${namespace}`, ttl, violationFile); +}; + +/** + * Creates a session cache instance using Redis or in-memory store. + * @param {string} namespace - The session namespace. + * @param {number} [ttl] - Time to live for session entries. + * @returns {MemoryStore | ConnectRedis} Session store instance. + */ +const sessionCache = (namespace, ttl = undefined) => { + namespace = namespace.endsWith(':') ? namespace : `${namespace}:`; + if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY }); + return new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace }); +}; + +/** + * Creates a rate limiter cache using Redis. + * @param {string} prefix - The key prefix for rate limiting. + * @returns {RedisStore|undefined} RedisStore instance or undefined if Redis is not used. + */ +const limiterCache = (prefix) => { + if (!prefix) throw new Error('prefix is required'); + if (!cacheConfig.USE_REDIS) return undefined; + prefix = prefix.endsWith(':') ? prefix : `${prefix}:`; + return new RedisStore({ sendCommand, prefix }); +}; +const sendCommand = (...args) => ioredisClient?.call(...args); + +module.exports = { standardCache, sessionCache, violationCache, limiterCache }; diff --git a/api/cache/cacheFactory.spec.js b/api/cache/cacheFactory.spec.js new file mode 100644 index 000000000..6270a08a1 --- /dev/null +++ b/api/cache/cacheFactory.spec.js @@ -0,0 +1,272 @@ +const { Time } = require('librechat-data-provider'); + +// Mock dependencies first +const mockKeyvRedis = { + namespace: '', + keyPrefixSeparator: '', +}; + +const mockKeyv = jest.fn().mockReturnValue({ mock: 'keyv' }); +const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' }); +const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' }); +const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' }); + +const mockIoredisClient = { + call: jest.fn(), +}; + +const mockKeyvRedisClient = {}; +const mockViolationFile = {}; + +// Mock modules before requiring the main module +jest.mock('@keyv/redis', () => ({ + default: jest.fn().mockImplementation(() => mockKeyvRedis), +})); + +jest.mock('keyv', () => ({ + Keyv: mockKeyv, +})); + +jest.mock('./cacheConfig', () => ({ + cacheConfig: { + USE_REDIS: false, + REDIS_KEY_PREFIX: 'test', + }, +})); + +jest.mock('./redisClients', () => ({ + keyvRedisClient: mockKeyvRedisClient, + ioredisClient: mockIoredisClient, + GLOBAL_PREFIX_SEPARATOR: '::', +})); + +jest.mock('./keyvFiles', () => ({ + violationFile: mockViolationFile, +})); + +jest.mock('connect-redis', () => ({ + default: mockConnectRedis, +})); + +jest.mock('memorystore', () => jest.fn(() => mockMemoryStore)); + +jest.mock('rate-limit-redis', () => ({ + RedisStore: mockRedisStore, +})); + +// Import after mocking +const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory'); +const { cacheConfig } = require('./cacheConfig'); + +describe('cacheFactory', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset cache config mock + cacheConfig.USE_REDIS = false; + cacheConfig.REDIS_KEY_PREFIX = 'test'; + }); + + describe('redisCache', () => { + it('should create Redis cache when USE_REDIS is true', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'test-namespace'; + const ttl = 3600; + + standardCache(namespace, ttl); + + expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); + expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); + expect(mockKeyvRedis.namespace).toBe(cacheConfig.REDIS_KEY_PREFIX); + expect(mockKeyvRedis.keyPrefixSeparator).toBe('::'); + }); + + it('should create Redis cache with undefined ttl when not provided', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'test-namespace'; + + standardCache(namespace); + + expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl: undefined }); + }); + + it('should use fallback store when USE_REDIS is false and fallbackStore is provided', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'test-namespace'; + const ttl = 3600; + const fallbackStore = { some: 'store' }; + + standardCache(namespace, ttl, fallbackStore); + + expect(mockKeyv).toHaveBeenCalledWith({ store: fallbackStore, namespace, ttl }); + }); + + it('should create default Keyv instance when USE_REDIS is false and no fallbackStore', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'test-namespace'; + const ttl = 3600; + + standardCache(namespace, ttl); + + expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); + }); + + it('should handle namespace and ttl as undefined', () => { + cacheConfig.USE_REDIS = false; + + standardCache(); + + expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined }); + }); + }); + + describe('violationCache', () => { + it('should create violation cache with prefixed namespace', () => { + const namespace = 'test-violations'; + const ttl = 7200; + + // We can't easily mock the internal redisCache call since it's in the same module + // But we can test that the function executes without throwing + expect(() => violationCache(namespace, ttl)).not.toThrow(); + }); + + it('should create violation cache with undefined ttl', () => { + const namespace = 'test-violations'; + + violationCache(namespace); + + // The function should call redisCache with violations: prefixed namespace + // Since we can't easily mock the internal redisCache call, we test the behavior + expect(() => violationCache(namespace)).not.toThrow(); + }); + + it('should handle undefined namespace', () => { + expect(() => violationCache(undefined)).not.toThrow(); + }); + }); + + describe('sessionCache', () => { + it('should return MemoryStore when USE_REDIS is false', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'sessions'; + const ttl = 86400; + + const result = sessionCache(namespace, ttl); + + expect(mockMemoryStore).toHaveBeenCalledWith({ ttl, checkPeriod: Time.ONE_DAY }); + expect(result).toBe(mockMemoryStore()); + }); + + it('should return ConnectRedis when USE_REDIS is true', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'sessions'; + const ttl = 86400; + + const result = sessionCache(namespace, ttl); + + expect(mockConnectRedis).toHaveBeenCalledWith({ + client: mockIoredisClient, + ttl, + prefix: `${namespace}:`, + }); + expect(result).toBe(mockConnectRedis()); + }); + + it('should add colon to namespace if not present', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'sessions'; + + sessionCache(namespace); + + expect(mockConnectRedis).toHaveBeenCalledWith({ + client: mockIoredisClient, + ttl: undefined, + prefix: 'sessions:', + }); + }); + + it('should not add colon to namespace if already present', () => { + cacheConfig.USE_REDIS = true; + const namespace = 'sessions:'; + + sessionCache(namespace); + + expect(mockConnectRedis).toHaveBeenCalledWith({ + client: mockIoredisClient, + ttl: undefined, + prefix: 'sessions:', + }); + }); + + it('should handle undefined ttl', () => { + cacheConfig.USE_REDIS = false; + const namespace = 'sessions'; + + sessionCache(namespace); + + expect(mockMemoryStore).toHaveBeenCalledWith({ + ttl: undefined, + checkPeriod: Time.ONE_DAY, + }); + }); + }); + + describe('limiterCache', () => { + it('should return undefined when USE_REDIS is false', () => { + cacheConfig.USE_REDIS = false; + const result = limiterCache('prefix'); + + expect(result).toBeUndefined(); + }); + + it('should return RedisStore when USE_REDIS is true', () => { + cacheConfig.USE_REDIS = true; + const result = limiterCache('rate-limit'); + + expect(mockRedisStore).toHaveBeenCalledWith({ + sendCommand: expect.any(Function), + prefix: `rate-limit:`, + }); + expect(result).toBe(mockRedisStore()); + }); + + it('should add colon to prefix if not present', () => { + cacheConfig.USE_REDIS = true; + limiterCache('rate-limit'); + + expect(mockRedisStore).toHaveBeenCalledWith({ + sendCommand: expect.any(Function), + prefix: 'rate-limit:', + }); + }); + + it('should not add colon to prefix if already present', () => { + cacheConfig.USE_REDIS = true; + limiterCache('rate-limit:'); + + expect(mockRedisStore).toHaveBeenCalledWith({ + sendCommand: expect.any(Function), + prefix: 'rate-limit:', + }); + }); + + it('should pass sendCommand function that calls ioredisClient.call', () => { + cacheConfig.USE_REDIS = true; + limiterCache('rate-limit'); + + const sendCommandCall = mockRedisStore.mock.calls[0][0]; + const sendCommand = sendCommandCall.sendCommand; + + // Test that sendCommand properly delegates to ioredisClient.call + const args = ['GET', 'test-key']; + sendCommand(...args); + + expect(mockIoredisClient.call).toHaveBeenCalledWith(...args); + }); + + it('should handle undefined prefix', () => { + cacheConfig.USE_REDIS = true; + expect(() => limiterCache()).toThrow('prefix is required'); + }); + }); +}); diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 0eef7d3fb..aca53fcfc 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -1,113 +1,52 @@ +const { cacheConfig } = require('./cacheConfig'); const { Keyv } = require('keyv'); -const { isEnabled, math } = require('@librechat/api'); const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider'); -const { logFile, violationFile } = require('./keyvFiles'); -const keyvRedis = require('./keyvRedis'); +const { logFile } = require('./keyvFiles'); const keyvMongo = require('./keyvMongo'); - -const { BAN_DURATION, USE_REDIS, DEBUG_MEMORY_CACHE, CI } = process.env ?? {}; - -const duration = math(BAN_DURATION, 7200000); -const isRedisEnabled = isEnabled(USE_REDIS); -const debugMemoryCache = isEnabled(DEBUG_MEMORY_CACHE); - -const createViolationInstance = (namespace) => { - const config = isRedisEnabled ? { store: keyvRedis } : { store: violationFile, namespace }; - return new Keyv(config); -}; - -// Serve cache from memory so no need to clear it on startup/exit -const pending_req = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.PENDING_REQ }); - -const config = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.CONFIG_STORE }); - -const roles = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.ROLES }); - -const mcpTools = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.MCP_TOOLS }); - -const audioRuns = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES }) - : new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES }); - -const messages = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.ONE_MINUTE }) - : new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.ONE_MINUTE }); - -const flows = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES }) - : new Keyv({ namespace: CacheKeys.FLOWS, ttl: Time.ONE_MINUTE * 3 }); - -const tokenConfig = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES }) - : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES }); - -const genTitle = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES }) - : new Keyv({ namespace: CacheKeys.GEN_TITLE, ttl: Time.TWO_MINUTES }); - -const s3ExpiryInterval = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES }) - : new Keyv({ namespace: CacheKeys.S3_EXPIRY_INTERVAL, ttl: Time.THIRTY_MINUTES }); - -const modelQueries = isEnabled(process.env.USE_REDIS) - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.MODEL_QUERIES }); - -const abortKeys = isRedisEnabled - ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: CacheKeys.ABORT_KEYS, ttl: Time.TEN_MINUTES }); - -const openIdExchangedTokensCache = isRedisEnabled - ? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES }) - : new Keyv({ namespace: CacheKeys.OPENID_EXCHANGED_TOKENS, ttl: Time.TEN_MINUTES }); +const { standardCache, sessionCache, violationCache } = require('./cacheFactory'); const namespaces = { - [CacheKeys.ROLES]: roles, - [CacheKeys.MCP_TOOLS]: mcpTools, - [CacheKeys.CONFIG_STORE]: config, - [CacheKeys.PENDING_REQ]: pending_req, - [ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }), - [CacheKeys.ENCODED_DOMAINS]: new Keyv({ + [ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }), + [ViolationTypes.LOGINS]: violationCache(ViolationTypes.LOGINS), + [ViolationTypes.CONCURRENT]: violationCache(ViolationTypes.CONCURRENT), + [ViolationTypes.NON_BROWSER]: violationCache(ViolationTypes.NON_BROWSER), + [ViolationTypes.MESSAGE_LIMIT]: violationCache(ViolationTypes.MESSAGE_LIMIT), + [ViolationTypes.REGISTRATIONS]: violationCache(ViolationTypes.REGISTRATIONS), + [ViolationTypes.TOKEN_BALANCE]: violationCache(ViolationTypes.TOKEN_BALANCE), + [ViolationTypes.TTS_LIMIT]: violationCache(ViolationTypes.TTS_LIMIT), + [ViolationTypes.STT_LIMIT]: violationCache(ViolationTypes.STT_LIMIT), + [ViolationTypes.CONVO_ACCESS]: violationCache(ViolationTypes.CONVO_ACCESS), + [ViolationTypes.TOOL_CALL_LIMIT]: violationCache(ViolationTypes.TOOL_CALL_LIMIT), + [ViolationTypes.FILE_UPLOAD_LIMIT]: violationCache(ViolationTypes.FILE_UPLOAD_LIMIT), + [ViolationTypes.VERIFY_EMAIL_LIMIT]: violationCache(ViolationTypes.VERIFY_EMAIL_LIMIT), + [ViolationTypes.RESET_PASSWORD_LIMIT]: violationCache(ViolationTypes.RESET_PASSWORD_LIMIT), + [ViolationTypes.ILLEGAL_MODEL_REQUEST]: violationCache(ViolationTypes.ILLEGAL_MODEL_REQUEST), + [ViolationTypes.BAN]: new Keyv({ store: keyvMongo, - namespace: CacheKeys.ENCODED_DOMAINS, - ttl: 0, + namespace: CacheKeys.BANS, + ttl: cacheConfig.BAN_DURATION, }), - general: new Keyv({ store: logFile, namespace: 'violations' }), - concurrent: createViolationInstance('concurrent'), - non_browser: createViolationInstance('non_browser'), - message_limit: createViolationInstance('message_limit'), - token_balance: createViolationInstance(ViolationTypes.TOKEN_BALANCE), - registrations: createViolationInstance('registrations'), - [ViolationTypes.TTS_LIMIT]: createViolationInstance(ViolationTypes.TTS_LIMIT), - [ViolationTypes.STT_LIMIT]: createViolationInstance(ViolationTypes.STT_LIMIT), - [ViolationTypes.CONVO_ACCESS]: createViolationInstance(ViolationTypes.CONVO_ACCESS), - [ViolationTypes.TOOL_CALL_LIMIT]: createViolationInstance(ViolationTypes.TOOL_CALL_LIMIT), - [ViolationTypes.FILE_UPLOAD_LIMIT]: createViolationInstance(ViolationTypes.FILE_UPLOAD_LIMIT), - [ViolationTypes.VERIFY_EMAIL_LIMIT]: createViolationInstance(ViolationTypes.VERIFY_EMAIL_LIMIT), - [ViolationTypes.RESET_PASSWORD_LIMIT]: createViolationInstance( - ViolationTypes.RESET_PASSWORD_LIMIT, + + [CacheKeys.OPENID_SESSION]: sessionCache(CacheKeys.OPENID_SESSION), + [CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION), + + [CacheKeys.ROLES]: standardCache(CacheKeys.ROLES), + [CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS), + [CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE), + [CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ), + [CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }), + [CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES), + [CacheKeys.TOKEN_CONFIG]: standardCache(CacheKeys.TOKEN_CONFIG, Time.THIRTY_MINUTES), + [CacheKeys.GEN_TITLE]: standardCache(CacheKeys.GEN_TITLE, Time.TWO_MINUTES), + [CacheKeys.S3_EXPIRY_INTERVAL]: standardCache(CacheKeys.S3_EXPIRY_INTERVAL, Time.THIRTY_MINUTES), + [CacheKeys.MODEL_QUERIES]: standardCache(CacheKeys.MODEL_QUERIES), + [CacheKeys.AUDIO_RUNS]: standardCache(CacheKeys.AUDIO_RUNS, Time.TEN_MINUTES), + [CacheKeys.MESSAGES]: standardCache(CacheKeys.MESSAGES, Time.ONE_MINUTE), + [CacheKeys.FLOWS]: standardCache(CacheKeys.FLOWS, Time.ONE_MINUTE * 3), + [CacheKeys.OPENID_EXCHANGED_TOKENS]: standardCache( + CacheKeys.OPENID_EXCHANGED_TOKENS, + Time.TEN_MINUTES, ), - [ViolationTypes.ILLEGAL_MODEL_REQUEST]: createViolationInstance( - ViolationTypes.ILLEGAL_MODEL_REQUEST, - ), - logins: createViolationInstance('logins'), - [CacheKeys.ABORT_KEYS]: abortKeys, - [CacheKeys.TOKEN_CONFIG]: tokenConfig, - [CacheKeys.GEN_TITLE]: genTitle, - [CacheKeys.S3_EXPIRY_INTERVAL]: s3ExpiryInterval, - [CacheKeys.MODEL_QUERIES]: modelQueries, - [CacheKeys.AUDIO_RUNS]: audioRuns, - [CacheKeys.MESSAGES]: messages, - [CacheKeys.FLOWS]: flows, - [CacheKeys.OPENID_EXCHANGED_TOKENS]: openIdExchangedTokensCache, }; /** @@ -116,7 +55,10 @@ const namespaces = { */ function getTTLStores() { return Object.values(namespaces).filter( - (store) => store instanceof Keyv && typeof store.opts?.ttl === 'number' && store.opts.ttl > 0, + (store) => + store instanceof Keyv && + parseInt(store.opts?.ttl ?? '0') > 0 && + !store.opts?.store?.constructor?.name?.includes('Redis'), // Only include non-Redis stores ); } @@ -152,18 +94,18 @@ async function clearExpiredFromCache(cache) { if (data?.expires && data.expires <= expiryTime) { const deleted = await cache.opts.store.delete(key); if (!deleted) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`); continue; } cleared++; } } catch (error) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.log(`[Cache] Error processing entry from ${cache.opts.namespace}:`, error); const deleted = await cache.opts.store.delete(key); if (!deleted) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.warn(`[Cache] Error deleting entry: ${key} from ${cache.opts.namespace}`); continue; } @@ -172,7 +114,7 @@ async function clearExpiredFromCache(cache) { } if (cleared > 0) { - debugMemoryCache && + cacheConfig.DEBUG_MEMORY_CACHE && console.log( `[Cache] Cleared ${cleared} entries older than ${ttl}ms from ${cache.opts.namespace}`, ); @@ -213,7 +155,7 @@ async function clearAllExpiredFromCache() { } } -if (!isRedisEnabled && !isEnabled(CI)) { +if (!cacheConfig.USE_REDIS && !cacheConfig.CI) { /** @type {Set} */ const cleanupIntervals = new Set(); @@ -224,7 +166,7 @@ if (!isRedisEnabled && !isEnabled(CI)) { cleanupIntervals.add(cleanup); - if (debugMemoryCache) { + if (cacheConfig.DEBUG_MEMORY_CACHE) { const monitor = setInterval(() => { const ttlStores = getTTLStores(); const memory = process.memoryUsage(); @@ -245,13 +187,13 @@ if (!isRedisEnabled && !isEnabled(CI)) { } const dispose = () => { - debugMemoryCache && console.log('[Cache] Cleaning up and shutting down...'); + cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Cleaning up and shutting down...'); cleanupIntervals.forEach((interval) => clearInterval(interval)); cleanupIntervals.clear(); // One final cleanup before exit clearAllExpiredFromCache().then(() => { - debugMemoryCache && console.log('[Cache] Final cleanup completed'); + cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed'); process.exit(0); }); }; diff --git a/api/cache/ioredisClient.js b/api/cache/ioredisClient.js deleted file mode 100644 index cd48459ab..000000000 --- a/api/cache/ioredisClient.js +++ /dev/null @@ -1,92 +0,0 @@ -const fs = require('fs'); -const Redis = require('ioredis'); -const { isEnabled } = require('~/server/utils'); -const logger = require('~/config/winston'); - -const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_MAX_LISTENERS } = process.env; - -/** @type {import('ioredis').Redis | import('ioredis').Cluster} */ -let ioredisClient; -const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40; - -function mapURI(uri) { - const regex = - /^(?:(?\w+):\/\/)?(?:(?[^:@]+)(?::(?[^@]+))?@)?(?[\w.-]+)(?::(?\d{1,5}))?$/; - const match = uri.match(regex); - - if (match) { - const { scheme, user, password, host, port } = match.groups; - - return { - scheme: scheme || 'none', - user: user || null, - password: password || null, - host: host || null, - port: port || null, - }; - } else { - const parts = uri.split(':'); - if (parts.length === 2) { - return { - scheme: 'none', - user: null, - password: null, - host: parts[0], - port: parts[1], - }; - } - - return { - scheme: 'none', - user: null, - password: null, - host: uri, - port: null, - }; - } -} - -if (REDIS_URI && isEnabled(USE_REDIS)) { - let redisOptions = null; - - if (REDIS_CA) { - const ca = fs.readFileSync(REDIS_CA); - redisOptions = { tls: { ca } }; - } - - if (isEnabled(USE_REDIS_CLUSTER)) { - const hosts = REDIS_URI.split(',').map((item) => { - var value = mapURI(item); - - return { - host: value.host, - port: value.port, - }; - }); - ioredisClient = new Redis.Cluster(hosts, { redisOptions }); - } else { - ioredisClient = new Redis(REDIS_URI, redisOptions); - } - - ioredisClient.on('ready', () => { - logger.info('IoRedis connection ready'); - }); - ioredisClient.on('reconnecting', () => { - logger.info('IoRedis connection reconnecting'); - }); - ioredisClient.on('end', () => { - logger.info('IoRedis connection ended'); - }); - ioredisClient.on('close', () => { - logger.info('IoRedis connection closed'); - }); - ioredisClient.on('error', (err) => logger.error('IoRedis connection error:', err)); - ioredisClient.setMaxListeners(redis_max_listeners); - logger.info( - '[Optional] IoRedis initialized for rate limiters. If you have issues, disable Redis or restart the server.', - ); -} else { - logger.info('[Optional] IoRedis not initialized for rate limiters.'); -} - -module.exports = ioredisClient; diff --git a/api/cache/keyvRedis.js b/api/cache/keyvRedis.js deleted file mode 100644 index 0c3878b34..000000000 --- a/api/cache/keyvRedis.js +++ /dev/null @@ -1,109 +0,0 @@ -const fs = require('fs'); -const ioredis = require('ioredis'); -const KeyvRedis = require('@keyv/redis').default; -const { isEnabled } = require('~/server/utils'); -const logger = require('~/config/winston'); - -const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_KEY_PREFIX, REDIS_MAX_LISTENERS } = - process.env; - -let keyvRedis; -const redis_prefix = REDIS_KEY_PREFIX || ''; -const redis_max_listeners = Number(REDIS_MAX_LISTENERS) || 40; - -function mapURI(uri) { - const regex = - /^(?:(?\w+):\/\/)?(?:(?[^:@]+)(?::(?[^@]+))?@)?(?[\w.-]+)(?::(?\d{1,5}))?$/; - const match = uri.match(regex); - - if (match) { - const { scheme, user, password, host, port } = match.groups; - - return { - scheme: scheme || 'none', - user: user || null, - password: password || null, - host: host || null, - port: port || null, - }; - } else { - const parts = uri.split(':'); - if (parts.length === 2) { - return { - scheme: 'none', - user: null, - password: null, - host: parts[0], - port: parts[1], - }; - } - - return { - scheme: 'none', - user: null, - password: null, - host: uri, - port: null, - }; - } -} - -if (REDIS_URI && isEnabled(USE_REDIS)) { - let redisOptions = null; - /** @type {import('@keyv/redis').KeyvRedisOptions} */ - let keyvOpts = { - useRedisSets: false, - keyPrefix: redis_prefix, - }; - - if (REDIS_CA) { - const ca = fs.readFileSync(REDIS_CA); - redisOptions = { tls: { ca } }; - } - - if (isEnabled(USE_REDIS_CLUSTER)) { - const hosts = REDIS_URI.split(',').map((item) => { - var value = mapURI(item); - - return { - host: value.host, - port: value.port, - }; - }); - const cluster = new ioredis.Cluster(hosts, { redisOptions }); - keyvRedis = new KeyvRedis(cluster, keyvOpts); - } else { - keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts); - } - - const pingInterval = setInterval( - () => { - logger.debug('KeyvRedis ping'); - keyvRedis.client.ping().catch((err) => logger.error('Redis keep-alive ping failed:', err)); - }, - 5 * 60 * 1000, - ); - - keyvRedis.on('ready', () => { - logger.info('KeyvRedis connection ready'); - }); - keyvRedis.on('reconnecting', () => { - logger.info('KeyvRedis connection reconnecting'); - }); - keyvRedis.on('end', () => { - logger.info('KeyvRedis connection ended'); - }); - keyvRedis.on('close', () => { - clearInterval(pingInterval); - logger.info('KeyvRedis connection closed'); - }); - keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err)); - keyvRedis.setMaxListeners(redis_max_listeners); - logger.info( - '[Optional] Redis initialized. If you have issues, or seeing older values, disable it or flush cache to refresh values.', - ); -} else { - logger.info('[Optional] Redis not initialized.'); -} - -module.exports = keyvRedis; diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js index 5d785480b..16dc2e4ea 100644 --- a/api/cache/logViolation.js +++ b/api/cache/logViolation.js @@ -1,4 +1,5 @@ const { isEnabled } = require('~/server/utils'); +const { ViolationTypes } = require('librechat-data-provider'); const getLogStores = require('./getLogStores'); const banViolation = require('./banViolation'); @@ -16,7 +17,7 @@ const logViolation = async (req, res, type, errorMessage, score = 1) => { if (!userId) { return; } - const logs = getLogStores('general'); + const logs = getLogStores(ViolationTypes.GENERAL); const violationLogs = getLogStores(type); const key = isEnabled(process.env.USE_REDIS) ? `${type}:${userId}` : userId; diff --git a/api/cache/redisClients.js b/api/cache/redisClients.js new file mode 100644 index 000000000..1a653ba13 --- /dev/null +++ b/api/cache/redisClients.js @@ -0,0 +1,57 @@ +const IoRedis = require('ioredis'); +const { cacheConfig } = require('./cacheConfig'); +const { createClient, createCluster } = require('@keyv/redis'); + +const GLOBAL_PREFIX_SEPARATOR = '::'; + +const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)); +const username = urls?.[0].username || cacheConfig.REDIS_USERNAME; +const password = urls?.[0].password || cacheConfig.REDIS_PASSWORD; +const ca = cacheConfig.REDIS_CA; + +/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */ +let ioredisClient = null; +if (cacheConfig.USE_REDIS) { + const redisOptions = { + username: username, + password: password, + tls: ca ? { ca } : undefined, + keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`, + maxListeners: cacheConfig.REDIS_MAX_LISTENERS, + }; + + ioredisClient = + urls.length === 1 + ? new IoRedis(cacheConfig.REDIS_URI, redisOptions) + : new IoRedis.Cluster(cacheConfig.REDIS_URI, { redisOptions }); + + // Pinging the Redis server every 5 minutes to keep the connection alive + const pingInterval = setInterval(() => ioredisClient.ping(), 5 * 60 * 1000); + ioredisClient.on('close', () => clearInterval(pingInterval)); + ioredisClient.on('end', () => clearInterval(pingInterval)); +} + +/** @type {import('@keyv/redis').RedisClient | import('@keyv/redis').RedisCluster | null} */ +let keyvRedisClient = null; +if (cacheConfig.USE_REDIS) { + // ** WARNING ** Keyv Redis client does not support Prefix like ioredis above. + // The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js + const redisOptions = { username, password, socket: { tls: ca != null, ca } }; + + keyvRedisClient = + urls.length === 1 + ? createClient({ url: cacheConfig.REDIS_URI, ...redisOptions }) + : createCluster({ + rootNodes: cacheConfig.REDIS_URI.split(',').map((url) => ({ url })), + defaults: redisOptions, + }); + + keyvRedisClient.setMaxListeners(cacheConfig.REDIS_MAX_LISTENERS); + + // Pinging the Redis server every 5 minutes to keep the connection alive + const keyvPingInterval = setInterval(() => keyvRedisClient.ping(), 5 * 60 * 1000); + keyvRedisClient.on('disconnect', () => clearInterval(keyvPingInterval)); + keyvRedisClient.on('end', () => clearInterval(keyvPingInterval)); +} + +module.exports = { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR }; diff --git a/api/server/middleware/concurrentLimiter.js b/api/server/middleware/concurrentLimiter.js index 73de65dd2..79de88609 100644 --- a/api/server/middleware/concurrentLimiter.js +++ b/api/server/middleware/concurrentLimiter.js @@ -1,4 +1,4 @@ -const { Time, CacheKeys } = require('librechat-data-provider'); +const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider'); const clearPendingReq = require('~/cache/clearPendingReq'); const { logViolation, getLogStores } = require('~/cache'); const { isEnabled } = require('~/server/utils'); @@ -37,7 +37,7 @@ const concurrentLimiter = async (req, res, next) => { const userId = req.user?.id ?? req.user?._id ?? ''; const limit = Math.max(CONCURRENT_MESSAGE_MAX, 1); - const type = 'concurrent'; + const type = ViolationTypes.CONCURRENT; const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}`; const pendingRequests = +((await cache.get(key)) ?? 0); diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js index 8a07cefab..35fda10e9 100644 --- a/api/server/middleware/limiters/forkLimiters.js +++ b/api/server/middleware/limiters/forkLimiters.js @@ -1,9 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { isEnabled } = require('@librechat/api'); -const { RedisStore } = require('rate-limit-redis'); -const { logger } = require('@librechat/data-schemas'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -62,6 +59,7 @@ const createForkLimiters = () => { windowMs: forkIpWindowMs, max: forkIpMax, handler: createForkHandler(), + store: limiterCache('fork_ip_limiter'), }; const userLimiterOptions = { windowMs: forkUserWindowMs, @@ -70,23 +68,9 @@ const createForkLimiters = () => { keyGenerator: function (req) { return req.user?.id; }, + store: limiterCache('fork_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for fork rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'fork_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'fork_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const forkIpLimiter = rateLimit(ipLimiterOptions); const forkUserLimiter = rateLimit(userLimiterOptions); return { forkIpLimiter, forkUserLimiter }; diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js index 7ff48af5e..0d8204393 100644 --- a/api/server/middleware/limiters/importLimiters.js +++ b/api/server/middleware/limiters/importLimiters.js @@ -1,9 +1,6 @@ const rateLimit = require('express-rate-limit'); -const { isEnabled } = require('@librechat/api'); -const { RedisStore } = require('rate-limit-redis'); -const { logger } = require('@librechat/data-schemas'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { @@ -63,6 +60,7 @@ const createImportLimiters = () => { windowMs: importIpWindowMs, max: importIpMax, handler: createImportHandler(), + store: limiterCache('import_ip_limiter'), }; const userLimiterOptions = { windowMs: importUserWindowMs, @@ -71,23 +69,9 @@ const createImportLimiters = () => { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('import_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for import rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'import_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'import_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const importIpLimiter = rateLimit(ipLimiterOptions); const importUserLimiter = rateLimit(userLimiterOptions); return { importIpLimiter, importUserLimiter }; diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js index d57af2941..cc21f6879 100644 --- a/api/server/middleware/limiters/loginLimiter.js +++ b/api/server/middleware/limiters/loginLimiter.js @@ -1,9 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { ViolationTypes } = require('librechat-data-provider'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env; const windowMs = LOGIN_WINDOW * 60 * 1000; @@ -12,7 +11,7 @@ const windowInMinutes = windowMs / 60000; const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`; const handler = async (req, res) => { - const type = 'logins'; + const type = ViolationTypes.LOGINS; const errorMessage = { type, max, @@ -28,17 +27,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('login_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for login rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'login_limiter:', - }); - limiterOptions.store = store; -} - const loginLimiter = rateLimit(limiterOptions); module.exports = loginLimiter; diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js index cd409fa52..553d39959 100644 --- a/api/server/middleware/limiters/messageLimiters.js +++ b/api/server/middleware/limiters/messageLimiters.js @@ -1,10 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); +const { ViolationTypes } = require('librechat-data-provider'); const denyRequest = require('~/server/middleware/denyRequest'); -const ioredisClient = require('~/cache/ioredisClient'); -const { isEnabled } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { MESSAGE_IP_MAX = 40, @@ -32,7 +30,7 @@ const userWindowInMinutes = userWindowMs / 60000; */ const createHandler = (ip = true) => { return async (req, res) => { - const type = 'message_limit'; + const type = ViolationTypes.MESSAGE_LIMIT; const errorMessage = { type, max: ip ? ipMax : userMax, @@ -52,6 +50,7 @@ const ipLimiterOptions = { windowMs: ipWindowMs, max: ipMax, handler: createHandler(), + store: limiterCache('message_ip_limiter'), }; const userLimiterOptions = { @@ -61,23 +60,9 @@ const userLimiterOptions = { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('message_user_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for message rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'message_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'message_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; -} - /** * Message request rate limiter by IP */ diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js index 7d38b3044..15c91eba3 100644 --- a/api/server/middleware/limiters/registerLimiter.js +++ b/api/server/middleware/limiters/registerLimiter.js @@ -1,9 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { ViolationTypes } = require('librechat-data-provider'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env; const windowMs = REGISTER_WINDOW * 60 * 1000; @@ -12,7 +11,7 @@ const windowInMinutes = windowMs / 60000; const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`; const handler = async (req, res) => { - const type = 'registrations'; + const type = ViolationTypes.REGISTRATIONS; const errorMessage = { type, max, @@ -28,17 +27,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('register_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for register rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'register_limiter:', - }); - limiterOptions.store = store; -} - const registerLimiter = rateLimit(limiterOptions); module.exports = registerLimiter; diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js index 673b23e8e..1905d5f2b 100644 --- a/api/server/middleware/limiters/resetPasswordLimiter.js +++ b/api/server/middleware/limiters/resetPasswordLimiter.js @@ -1,10 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { RESET_PASSWORD_WINDOW = 2, @@ -33,17 +31,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('reset_password_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for reset password rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'reset_password_limiter:', - }); - limiterOptions.store = store; -} - const resetPasswordLimiter = rateLimit(limiterOptions); module.exports = resetPasswordLimiter; diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js index 79305bf5d..138e68caa 100644 --- a/api/server/middleware/limiters/sttLimiters.js +++ b/api/server/middleware/limiters/sttLimiters.js @@ -1,10 +1,7 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); const getEnvironmentVariables = () => { const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100; @@ -57,6 +54,7 @@ const createSTTLimiters = () => { windowMs: sttIpWindowMs, max: sttIpMax, handler: createSTTHandler(), + store: limiterCache('stt_ip_limiter'), }; const userLimiterOptions = { @@ -66,23 +64,9 @@ const createSTTLimiters = () => { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('stt_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for STT rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'stt_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'stt_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const sttIpLimiter = rateLimit(ipLimiterOptions); const sttUserLimiter = rateLimit(userLimiterOptions); diff --git a/api/server/middleware/limiters/toolCallLimiter.js b/api/server/middleware/limiters/toolCallLimiter.js index b14ca55d8..28c1f7891 100644 --- a/api/server/middleware/limiters/toolCallLimiter.js +++ b/api/server/middleware/limiters/toolCallLimiter.js @@ -1,10 +1,7 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); const { TOOL_CALL_VIOLATION_SCORE: score } = process.env; @@ -28,17 +25,9 @@ const limiterOptions = { keyGenerator: function (req) { return req.user?.id; }, + store: limiterCache('tool_call_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for tool call rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'tool_call_limiter:', - }); - limiterOptions.store = store; -} - const toolCallLimiter = rateLimit(limiterOptions); module.exports = toolCallLimiter; diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js index 93dd6eb99..89742c88a 100644 --- a/api/server/middleware/limiters/ttsLimiters.js +++ b/api/server/middleware/limiters/ttsLimiters.js @@ -1,10 +1,7 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); +const { limiterCache } = require('~/cache/cacheFactory'); const getEnvironmentVariables = () => { const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100; @@ -57,32 +54,19 @@ const createTTSLimiters = () => { windowMs: ttsIpWindowMs, max: ttsIpMax, handler: createTTSHandler(), + store: limiterCache('tts_ip_limiter'), }; const userLimiterOptions = { windowMs: ttsUserWindowMs, max: ttsUserMax, handler: createTTSHandler(false), + store: limiterCache('tts_user_limiter'), keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for TTS rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'tts_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'tts_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const ttsIpLimiter = rateLimit(ipLimiterOptions); const ttsUserLimiter = rateLimit(userLimiterOptions); diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js index 84eb6c071..0ec4bde8d 100644 --- a/api/server/middleware/limiters/uploadLimiters.js +++ b/api/server/middleware/limiters/uploadLimiters.js @@ -1,10 +1,7 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const ioredisClient = require('~/cache/ioredisClient'); +const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); const getEnvironmentVariables = () => { const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100; @@ -63,6 +60,7 @@ const createFileLimiters = () => { windowMs: fileUploadIpWindowMs, max: fileUploadIpMax, handler: createFileUploadHandler(), + store: limiterCache('file_upload_ip_limiter'), }; const userLimiterOptions = { @@ -72,23 +70,9 @@ const createFileLimiters = () => { keyGenerator: function (req) { return req.user?.id; // Use the user ID or NULL if not available }, + store: limiterCache('file_upload_user_limiter'), }; - if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for file upload rate limiters.'); - const sendCommand = (...args) => ioredisClient.call(...args); - const ipStore = new RedisStore({ - sendCommand, - prefix: 'file_upload_ip_limiter:', - }); - const userStore = new RedisStore({ - sendCommand, - prefix: 'file_upload_user_limiter:', - }); - ipLimiterOptions.store = ipStore; - userLimiterOptions.store = userStore; - } - const fileUploadIpLimiter = rateLimit(ipLimiterOptions); const fileUploadUserLimiter = rateLimit(userLimiterOptions); diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js index 73bfa2daf..0025c041f 100644 --- a/api/server/middleware/limiters/verifyEmailLimiter.js +++ b/api/server/middleware/limiters/verifyEmailLimiter.js @@ -1,10 +1,8 @@ const rateLimit = require('express-rate-limit'); -const { RedisStore } = require('rate-limit-redis'); const { ViolationTypes } = require('librechat-data-provider'); -const { removePorts, isEnabled } = require('~/server/utils'); -const ioredisClient = require('~/cache/ioredisClient'); +const { removePorts } = require('~/server/utils'); +const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); -const { logger } = require('~/config'); const { VERIFY_EMAIL_WINDOW = 2, @@ -33,17 +31,9 @@ const limiterOptions = { max, handler, keyGenerator: removePorts, + store: limiterCache('verify_email_limiter'), }; -if (isEnabled(process.env.USE_REDIS) && ioredisClient) { - logger.debug('Using Redis for verify email rate limiter.'); - const store = new RedisStore({ - sendCommand: (...args) => ioredisClient.call(...args), - prefix: 'verify_email_limiter:', - }); - limiterOptions.store = store; -} - const verifyEmailLimiter = rateLimit(limiterOptions); module.exports = verifyEmailLimiter; diff --git a/api/server/middleware/uaParser.js b/api/server/middleware/uaParser.js index 7fa75f0f8..d7d38f102 100644 --- a/api/server/middleware/uaParser.js +++ b/api/server/middleware/uaParser.js @@ -1,4 +1,5 @@ const uap = require('ua-parser-js'); +const { ViolationTypes } = require('librechat-data-provider'); const { handleError } = require('@librechat/api'); const { logViolation } = require('../../cache'); @@ -21,7 +22,7 @@ async function uaParser(req, res, next) { const ua = uap(req.headers['user-agent']); if (!ua.browser.name) { - const type = 'non_browser'; + const type = ViolationTypes.NON_BROWSER; await logViolation(req, res, type, { type }, score); return handleError(res, { message: 'Illegal request' }); } diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 9b9541cdc..938d97e04 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,8 +1,5 @@ -const { Keyv } = require('keyv'); const passport = require('passport'); const session = require('express-session'); -const MemoryStore = require('memorystore')(session); -const RedisStore = require('connect-redis').default; const { setupOpenId, googleLogin, @@ -14,8 +11,9 @@ const { openIdJwtLogin, } = require('~/strategies'); const { isEnabled } = require('~/server/utils'); -const keyvRedis = require('~/cache/keyvRedis'); const { logger } = require('~/config'); +const { getLogStores } = require('~/cache'); +const { CacheKeys } = require('librechat-data-provider'); /** * @@ -51,17 +49,8 @@ const configureSocialLogins = async (app) => { secret: process.env.OPENID_SESSION_SECRET, resave: false, saveUninitialized: false, + store: getLogStores(CacheKeys.OPENID_SESSION), }; - if (isEnabled(process.env.USE_REDIS)) { - logger.debug('Using Redis for session storage in OpenID...'); - const keyv = new Keyv({ store: keyvRedis }); - const client = keyv.opts.store.client; - sessionOptions.store = new RedisStore({ client, prefix: 'openid_session' }); - } else { - sessionOptions.store = new MemoryStore({ - checkPeriod: 86400000, // prune expired entries every 24h - }); - } app.use(session(sessionOptions)); app.use(passport.session()); const config = await setupOpenId(); @@ -82,17 +71,8 @@ const configureSocialLogins = async (app) => { secret: process.env.SAML_SESSION_SECRET, resave: false, saveUninitialized: false, + store: getLogStores(CacheKeys.SAML_SESSION), }; - if (isEnabled(process.env.USE_REDIS)) { - logger.debug('Using Redis for session storage in SAML...'); - const keyv = new Keyv({ store: keyvRedis }); - const client = keyv.opts.store.client; - sessionOptions.store = new RedisStore({ client, prefix: 'saml_session' }); - } else { - sessionOptions.store = new MemoryStore({ - checkPeriod: 86400000, // prune expired entries every 24h - }); - } app.use(session(sessionOptions)); app.use(passport.session()); setupSaml(); diff --git a/config/ban-user.js b/config/ban-user.js index 9612318ae..485dc8b41 100644 --- a/config/ban-user.js +++ b/config/ban-user.js @@ -1,6 +1,7 @@ const path = require('path'); const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose')); const { User } = require('@librechat/data-schemas').createModels(mongoose); +const { ViolationTypes } = require('librechat-data-provider'); require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { askQuestion, silentExit } = require('./helpers'); const banViolation = require('~/cache/banViolation'); @@ -65,7 +66,7 @@ const connect = require('./connect'); }; const errorMessage = { - type: 'concurrent', + type: ViolationTypes.CONCURRENT, violation_count: 20, user_id: user._id, prev_count: 0, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 2e042e268..a6ac8b3ec 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1064,10 +1064,12 @@ export enum InfiniteCollections { * Enum for time intervals */ export enum Time { + ONE_DAY = 86400000, ONE_HOUR = 3600000, THIRTY_MINUTES = 1800000, TEN_MINUTES = 600000, FIVE_MINUTES = 300000, + THREE_MINUTES = 180000, TWO_MINUTES = 120000, ONE_MINUTE = 60000, THIRTY_SECONDS = 30000, @@ -1167,6 +1169,14 @@ export enum CacheKeys { * key for open id exchanged tokens */ OPENID_EXCHANGED_TOKENS = 'OPENID_EXCHANGED_TOKENS', + /** + * Key for OpenID session. + */ + OPENID_SESSION = 'openid_session', + /** + * Key for SAML session. + */ + SAML_SESSION = 'saml_session', } /** @@ -1213,6 +1223,30 @@ export enum ViolationTypes { * Tool Call Limit Violation. */ TOOL_CALL_LIMIT = 'tool_call_limit', + /** + * General violation (catch-all). + */ + GENERAL = 'general', + /** + * Login attempt violations. + */ + LOGINS = 'logins', + /** + * Concurrent request violations. + */ + CONCURRENT = 'concurrent', + /** + * Non-browser access violations. + */ + NON_BROWSER = 'non_browser', + /** + * Message limit violations. + */ + MESSAGE_LIMIT = 'message_limit', + /** + * Registration violations. + */ + REGISTRATIONS = 'registrations', } /** diff --git a/redis-config/README.md b/redis-config/README.md new file mode 100644 index 000000000..024d0b168 --- /dev/null +++ b/redis-config/README.md @@ -0,0 +1,399 @@ +# Redis Configuration and Setup + +This directory contains comprehensive Redis configuration files and scripts for LibreChat development and testing, supporting both cluster and single-node setups with optional TLS encryption. + +## Supported Configurations + +### 1. Redis Cluster (3 Nodes) +- **3 Redis nodes** running on ports 7001, 7002, and 7003 +- **No replicas** (each node is a master) +- **Automatic hash slot distribution** across all nodes + +### 2. Single Redis with TLS Encryption +- **Single Redis instance** on port 6380 with TLS encryption +- **CA certificate validation** for secure connections +- **Self-signed certificates** with proper Subject Alternative Names + +### 3. Standard Single Redis +- **Basic Redis instance** on port 6379 (default) +- **No encryption** - suitable for local development + +All configurations are designed for **local development and testing**. + +## Prerequisites + +1. **Redis** must be installed on your system: + ```bash + # macOS + brew install redis + + # Ubuntu/Debian + sudo apt-get install redis-server + + # CentOS/RHEL + sudo yum install redis + ``` + +2. **Redis CLI** should be available (usually included with Redis) + +## Quick Start + +### Option 1: Redis Cluster (3 Nodes) + +```bash +# Navigate to the redis-config directory +cd redis-config + +# Start and initialize the cluster +./start-cluster.sh +``` + +### Option 2: Single Redis with TLS + +```bash +# Start Redis with TLS encryption on port 6380 +./start-redis-tls.sh +``` + +### Option 3: Standard Redis + +```bash +# Use system Redis on default port 6379 +redis-server +``` + +## Testing Your Setup + +### Test Cluster +```bash +# Connect to the cluster +redis-cli -c -p 7001 + +# Test basic operations +SET test_key "Hello World" +GET test_key +``` + +### Test TLS Redis +```bash +# Test with CA certificate validation +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 ping +``` + +### Test Standard Redis +```bash +# Connect to default Redis +redis-cli ping +``` + +## Stopping Services + +### Stop Cluster +```bash +./stop-cluster.sh +``` + +### Stop TLS Redis +```bash +# Find and stop TLS Redis process +ps aux | grep "redis-server.*6380" +kill +``` + +## Configuration Files + +- `redis-7001.conf` - Configuration for node 1 (port 7001) +- `redis-7002.conf` - Configuration for node 2 (port 7002) +- `redis-7003.conf` - Configuration for node 3 (port 7003) + +## Scripts + +- `start-cluster.sh` - Starts and initializes the Redis cluster +- `stop-cluster.sh` - Stops all Redis nodes and cleans up +- `start-redis-tls.sh` - Starts Redis with TLS encryption and CA certificate validation +- `redis-tls.conf` - TLS Redis configuration file + +## Directory Structure + +``` +redis-config/ +├── README.md +├── redis-7001.conf # Cluster node 1 configuration +├── redis-7002.conf # Cluster node 2 configuration +├── redis-7003.conf # Cluster node 3 configuration +├── redis-tls.conf # TLS Redis configuration +├── start-cluster.sh # Start cluster script +├── stop-cluster.sh # Stop cluster script +├── start-redis-tls.sh # Start TLS Redis script +├── certs/ # TLS certificates (created automatically) +│ ├── ca-cert.pem # Certificate Authority certificate +│ ├── ca-key.pem # CA private key +│ ├── server-cert.pem # Server certificate with SAN +│ ├── server-key.pem # Server private key +│ ├── redis.dh # Diffie-Hellman parameters +│ └── server.conf # OpenSSL certificate configuration +├── data/ # Data files (created automatically) +│ ├── 7001/ # Cluster node 1 data +│ ├── 7002/ # Cluster node 2 data +│ └── 7003/ # Cluster node 3 data +└── logs/ # Log directory (created automatically) + # Note: By default, Redis logs to stdout/stderr + # Log files would be created here if enabled in config +``` + +## Using with LibreChat + +Update your `.env` file based on your chosen Redis configuration: + +### For Redis Cluster +```bash +USE_REDIS=true +REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 +``` + +### For TLS Redis +```bash +USE_REDIS=true +REDIS_URI=rediss://127.0.0.1:6380 +REDIS_CA=/path/to/LibreChat/redis-config/certs/ca-cert.pem +``` + +### For Standard Redis +```bash +USE_REDIS=true +REDIS_URI=redis://127.0.0.1:6379 +``` + +### Optional Configuration +```bash +# Use environment variable for dynamic key prefixing +REDIS_KEY_PREFIX_VAR=K_REVISION + +# Or set static prefix +REDIS_KEY_PREFIX=librechat + +# Connection limits +REDIS_MAX_LISTENERS=40 +``` + +## TLS/SSL Redis Setup + +For secure Redis connections using TLS encryption with CA certificate validation: + +### 1. Start Redis with TLS + +```bash +# Start Redis with TLS on port 6380 +./start-redis-tls.sh +``` + +### 2. Configure LibreChat for TLS + +Update your `.env` file: + +```bash +# .env file - TLS Redis with CA certificate validation +USE_REDIS=true +REDIS_URI=rediss://127.0.0.1:6380 +REDIS_CA=/path/to/LibreChat/redis-config/certs/ca-cert.pem +``` + +### 3. Test TLS Connection + +```bash +# Test Redis TLS connection with CA certificate +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 ping + +# Should return: PONG + +# Test basic operations +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 set test_tls "TLS Working" +redis-cli --tls --cacert certs/ca-cert.pem -p 6380 get test_tls +``` + +### 4. Test Backend Integration + +```bash +# Start LibreChat backend +npm run backend + +# Look for these success indicators in logs: +# ✅ "No changes needed for 'USER' role permissions" +# ✅ "No changes needed for 'ADMIN' role permissions" +# ✅ "Server listening at http://localhost:3080" +# ✅ No "IoRedis connection error" messages +``` + +### TLS Certificate Details + +The TLS setup includes: + +- **CA Certificate**: Self-signed Certificate Authority for validation +- **Server Certificate**: Contains Subject Alternative Names (SAN) for: + - `DNS: localhost` + - `IP: 127.0.0.1` +- **TLS Configuration**: + - TLS v1.2 and v1.3 support + - No client certificate authentication required + - Strong cipher suites (AES-256-GCM, ChaCha20-Poly1305) + +### Troubleshooting TLS + +#### Certificate Validation Errors + +```bash +# If you see "Hostname/IP does not match certificate's altnames" +# Check certificate SAN entries: +openssl x509 -in certs/server-cert.pem -text -noout | grep -A3 "Subject Alternative Name" + +# Should show: DNS:localhost, IP Address:127.0.0.1 +``` + +#### Connection Refused + +```bash +# Check if Redis TLS is running +lsof -i :6380 + +# Check Redis TLS server logs +ps aux | grep redis-server +``` + +#### Backend Connection Issues + +```bash +# Verify CA certificate path in .env +ls -la /path/to/LibreChat/redis-config/certs/ca-cert.pem + +# Test LibreChat Redis configuration +cd /path/to/LibreChat +npm run backend +# Look for Redis connection errors in output +``` + +## Common Operations + +### Check Cluster Status + +```bash +# Cluster information +redis-cli -p 7001 cluster info + +# Node information +redis-cli -p 7001 cluster nodes + +# Check specific node +redis-cli -p 7002 info replication +``` + +### Monitor Cluster + +```bash +# Monitor all operations +redis-cli -p 7001 monitor + +# Check memory usage +redis-cli -p 7001 info memory +redis-cli -p 7002 info memory +redis-cli -p 7003 info memory +``` + +### Troubleshooting + +#### Cluster Won't Start + +1. Check if Redis is installed: `redis-server --version` +2. Check for port conflicts: `netstat -tlnp | grep :700` +3. Check Redis processes: `ps aux | grep redis-server` +4. Check if nodes are responding: `redis-cli -p 7001 ping` + +#### Cluster Initialization Fails + +1. Ensure all nodes are running: `./start-cluster.sh` +2. Check cluster configuration: `redis-cli -p 7001 cluster nodes` +3. Reset if needed: `redis-cli -p 7001 CLUSTER RESET` + +#### Performance Issues + +1. Monitor memory usage: `redis-cli -p 7001 info memory` +2. Check slow queries: `redis-cli -p 7001 slowlog get 10` +3. Adjust `maxmemory` settings in configuration files + +## Configuration Details + +### Node Configuration + +Each node is configured with: +- **Memory limit**: 256MB with LRU eviction +- **Persistence**: AOF + RDB snapshots +- **Clustering**: Enabled with 15-second timeout +- **Logging**: Notice level (logs to stdout/stderr by default) + +### Hash Slot Distribution + +With 3 nodes and no replicas: +- Node 1 (7001): Hash slots 0-5460 +- Node 2 (7002): Hash slots 5461-10922 +- Node 3 (7003): Hash slots 10923-16383 + +## Security Note + +### Development Setup +The basic Redis cluster setup is designed for **local development only**. + +### TLS Setup +The TLS Redis configuration provides: +- ✅ **TLS encryption** with CA certificate validation +- ✅ **Server certificate** with proper Subject Alternative Names +- ✅ **Strong cipher suites** (AES-256-GCM, ChaCha20-Poly1305) +- ✅ **Certificate validation** via self-signed CA + +### Production Considerations +For production use, consider: +- Authentication (`requirepass` or `AUTH` commands) +- Client certificate authentication (`tls-auth-clients yes`) +- Firewall configuration +- Replica nodes for high availability +- Proper certificate management (not self-signed) +- Key rotation policies + +## Backup and Recovery + +### Backup + +```bash +# Backup all nodes +mkdir -p backup +redis-cli -p 7001 BGSAVE +redis-cli -p 7002 BGSAVE +redis-cli -p 7003 BGSAVE + +# Copy backup files +cp data/7001/dump.rdb backup/dump-7001.rdb +cp data/7002/dump.rdb backup/dump-7002.rdb +cp data/7003/dump.rdb backup/dump-7003.rdb +``` + +### Recovery + +```bash +# Stop cluster +./stop-cluster.sh + +# Restore backup files +cp backup/dump-7001.rdb data/7001/dump.rdb +cp backup/dump-7002.rdb data/7002/dump.rdb +cp backup/dump-7003.rdb data/7003/dump.rdb + +# Start cluster +./start-cluster.sh +``` + +## Support + +For Redis-specific issues: +- [Redis Documentation](https://redis.io/docs/) +- [Redis Cluster Tutorial](https://redis.io/docs/manual/scaling/) + +For LibreChat integration: +- [LibreChat Documentation](https://github.com/danny-avila/LibreChat) \ No newline at end of file diff --git a/redis-config/certs/ca-cert.srl b/redis-config/certs/ca-cert.srl new file mode 100644 index 000000000..d38dda3b6 --- /dev/null +++ b/redis-config/certs/ca-cert.srl @@ -0,0 +1 @@ +54E48A77C8FF4781A554C80BF6EFC0401A6ACE8A diff --git a/redis-config/certs/dump.rdb b/redis-config/certs/dump.rdb new file mode 100644 index 000000000..292e946f6 Binary files /dev/null and b/redis-config/certs/dump.rdb differ diff --git a/redis-config/certs/redis.dh b/redis-config/certs/redis.dh new file mode 100644 index 000000000..c87e70095 --- /dev/null +++ b/redis-config/certs/redis.dh @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBDAKCAQEAmPCtZIwB9l9LqyFewdGKxxk3HEcQdHswM3IHbhE+GqZOaD8KwxB+ +rfPH54pDSW42WLWM+y7/eC2ufPdw6ZDBjCYFC8rGkWUPwguDl90INuzCCCAgwBVw +tpKfcZ92T8ek1qR6UgZa4zPq4FjQm09ZcVmMzaUeIkiRGv0/t2GjswZDVuLhKRp5 +eSH7pByYCNYj3X9HyMqcCDfGhTkg8azcWJiEOCTCpsYgXcW1tz2PQsaJZpERnffk +px8mLuDPMVxTRWpXcIBmzs/Nwv8bGigyI4ADocM3jmQ8c6b9ZYUmyvled/1LEBzC +10g2R67op+dGOxk40lwrLmN6bzYFt/YKbwIBAgICAOE= +-----END DH PARAMETERS----- diff --git a/redis-config/certs/server.conf b/redis-config/certs/server.conf new file mode 100644 index 000000000..ec9a8dc25 --- /dev/null +++ b/redis-config/certs/server.conf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = localhost + +[v3_req] +keyUsage = keyEncipherment, dataEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 \ No newline at end of file diff --git a/redis-config/redis-7001.conf b/redis-config/redis-7001.conf new file mode 100644 index 000000000..1f0869437 --- /dev/null +++ b/redis-config/redis-7001.conf @@ -0,0 +1,27 @@ +# Redis Cluster Node 1 Configuration +port 7001 +cluster-enabled yes +cluster-config-file nodes-7001.conf +cluster-node-timeout 15000 +appendonly yes +appendfilename "appendonly-7001.aof" + +# Data directory +dir ./data/7001 + +# Logging +# logfile ./logs/redis-7001.log +loglevel notice + +# Network +bind 127.0.0.1 +protected-mode no + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# Persistence +save 900 1 +save 300 10 +save 60 10000 \ No newline at end of file diff --git a/redis-config/redis-7002.conf b/redis-config/redis-7002.conf new file mode 100644 index 000000000..e16ac2c6b --- /dev/null +++ b/redis-config/redis-7002.conf @@ -0,0 +1,27 @@ +# Redis Cluster Node 2 Configuration +port 7002 +cluster-enabled yes +cluster-config-file nodes-7002.conf +cluster-node-timeout 15000 +appendonly yes +appendfilename "appendonly-7002.aof" + +# Data directory +dir ./data/7002 + +# Logging +# logfile ./logs/redis-7002.log +loglevel notice + +# Network +bind 127.0.0.1 +protected-mode no + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# Persistence +save 900 1 +save 300 10 +save 60 10000 \ No newline at end of file diff --git a/redis-config/redis-7003.conf b/redis-config/redis-7003.conf new file mode 100644 index 000000000..216f161d9 --- /dev/null +++ b/redis-config/redis-7003.conf @@ -0,0 +1,27 @@ +# Redis Cluster Node 3 Configuration +port 7003 +cluster-enabled yes +cluster-config-file nodes-7003.conf +cluster-node-timeout 15000 +appendonly yes +appendfilename "appendonly-7003.aof" + +# Data directory +dir ./data/7003 + +# Logging +# logfile ./logs/redis-7003.log +loglevel notice + +# Network +bind 127.0.0.1 +protected-mode no + +# Memory management +maxmemory 256mb +maxmemory-policy allkeys-lru + +# Persistence +save 900 1 +save 300 10 +save 60 10000 \ No newline at end of file diff --git a/redis-config/redis-tls.conf b/redis-config/redis-tls.conf new file mode 100644 index 000000000..29ebe2e18 --- /dev/null +++ b/redis-config/redis-tls.conf @@ -0,0 +1,31 @@ +port 0 +tls-port 6380 +tls-cert-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/server-cert.pem +tls-key-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/server-key.pem +tls-ca-cert-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/ca-cert.pem +tls-auth-clients no +tls-dh-params-file /Users/theotr/WebstormProjects/LibreChat/redis-cluster/certs/redis.dh +tls-protocols "TLSv1.2 TLSv1.3" +tls-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256 +tls-prefer-server-ciphers yes +tls-session-caching no +tls-session-cache-size 5000 +tls-session-cache-timeout 60 +bind 127.0.0.1 +protected-mode yes +timeout 0 +tcp-keepalive 300 +daemonize no +pidfile /var/run/redis_6379.pid +loglevel notice +logfile "" +databases 16 +always-show-logo no +save 900 1 +save 300 10 +save 60 10000 +stop-writes-on-bgsave-error yes +rdbcompression yes +rdbchecksum yes +dbfilename dump.rdb +dir ./ \ No newline at end of file diff --git a/redis-config/start-cluster.sh b/redis-config/start-cluster.sh new file mode 100755 index 000000000..d46227c34 --- /dev/null +++ b/redis-config/start-cluster.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# Redis Cluster Startup Script +# This script starts and initializes a 3-node Redis cluster with no replicas + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "🚀 Starting Redis Cluster..." + +# Create necessary directories +mkdir -p data/7001 data/7002 data/7003 +mkdir -p logs + +# Check if Redis is installed +if ! command -v redis-server &> /dev/null; then + echo "❌ Redis is not installed. Please install Redis first:" + echo " macOS: brew install redis" + echo " Ubuntu: sudo apt-get install redis-server" + echo " CentOS: sudo yum install redis" + exit 1 +fi + +# Check if Redis CLI is available +if ! command -v redis-cli &> /dev/null; then + echo "❌ Redis CLI is not available. Please install Redis CLI." + exit 1 +fi + +# Start Redis instances +redis-server redis-7001.conf --daemonize yes +redis-server redis-7002.conf --daemonize yes +redis-server redis-7003.conf --daemonize yes + +# Wait for nodes to start +sleep 3 + +# Check if all nodes are running +NODES_RUNNING=0 +for port in 7001 7002 7003; do + if redis-cli -p $port ping &> /dev/null; then + NODES_RUNNING=$((NODES_RUNNING + 1)) + else + echo "❌ Node on port $port failed to start" + fi +done + +if [ $NODES_RUNNING -ne 3 ]; then + echo "❌ Not all Redis nodes started successfully." + exit 1 +fi + +echo "✅ All Redis nodes started" + +# Check if cluster is already initialized +if redis-cli -p 7001 cluster info 2>/dev/null | grep -q "cluster_state:ok"; then + echo "✅ Cluster already initialized" + echo "" + echo "📋 Usage:" + echo " Connect: redis-cli -c -p 7001" + echo " Stop: ./stop-cluster.sh" + exit 0 +fi + +# Initialize the cluster +echo "🔧 Initializing cluster..." +echo "yes" | redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 > /dev/null + +# Wait for cluster to stabilize +sleep 3 + +# Verify cluster status +if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then + echo "✅ Redis cluster ready!" + echo "" + echo "📋 Usage:" + echo " Connect: redis-cli -c -p 7001" + echo " Stop: ./stop-cluster.sh" +else + echo "❌ Cluster initialization failed!" + exit 1 +fi \ No newline at end of file diff --git a/redis-config/start-redis-tls.sh b/redis-config/start-redis-tls.sh new file mode 100755 index 000000000..b9a6a7a47 --- /dev/null +++ b/redis-config/start-redis-tls.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Start Redis with TLS configuration +echo "Starting Redis with TLS on port 6379..." + +# Check if Redis is already running +if pgrep -f "redis-server.*tls" > /dev/null; then + echo "Redis with TLS is already running" + exit 1 +fi + +# Start Redis with TLS config +redis-server /Users/theotr/WebstormProjects/LibreChat/redis-cluster/redis-tls.conf \ No newline at end of file diff --git a/redis-config/stop-cluster.sh b/redis-config/stop-cluster.sh new file mode 100755 index 000000000..7f4d47e78 --- /dev/null +++ b/redis-config/stop-cluster.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# Redis Cluster Shutdown Script +# This script stops all Redis cluster nodes + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "🛑 Stopping Redis Cluster..." + +# Function to stop a Redis node +stop_node() { + local port=$1 + + if redis-cli -p $port ping &> /dev/null; then + # Try graceful shutdown first + redis-cli -p $port SHUTDOWN NOSAVE 2>/dev/null || true + sleep 2 + + # Check if still running and force kill if needed + if redis-cli -p $port ping &> /dev/null; then + PID=$(ps aux | grep "[r]edis-server.*:$port" | awk '{print $2}') + if [ -n "$PID" ]; then + kill -TERM $PID 2>/dev/null || true + sleep 2 + if kill -0 $PID 2>/dev/null; then + kill -KILL $PID 2>/dev/null || true + fi + fi + fi + + # Final check + if redis-cli -p $port ping &> /dev/null; then + echo "❌ Failed to stop Redis node on port $port" + return 1 + else + return 0 + fi + else + return 0 + fi +} + +# Stop all nodes +NODES_STOPPED=0 +for port in 7001 7002 7003; do + if stop_node $port; then + NODES_STOPPED=$((NODES_STOPPED + 1)) + fi +done + +# Clean up cluster configuration files +rm -f nodes-7001.conf nodes-7002.conf nodes-7003.conf + +if [ $NODES_STOPPED -eq 3 ]; then + echo "✅ Redis cluster stopped" +else + echo "⚠️ Some nodes may not have stopped properly" + echo "Check running processes: ps aux | grep redis-server" +fi + +# Check for remaining processes +REMAINING_PROCESSES=$(ps aux | grep "[r]edis-server" | grep -E ":(7001|7002|7003)" | wc -l) +if [ $REMAINING_PROCESSES -gt 0 ]; then + echo "⚠️ Found $REMAINING_PROCESSES remaining Redis processes" + echo "Kill with: pkill -f 'redis-server.*:700[1-3]'" +fi \ No newline at end of file