diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 46b322349..92cf86e66 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -15,8 +15,8 @@ import { mockSmtpServer } from "./mocks/smtp"; import { initDbConnection } from "@app/db"; import { queueServiceFactory } from "@app/queue"; import { keyStoreFactory } from "@app/keystore/keystore"; -import { Redis } from "ioredis"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { buildRedisFromConfig } from "@app/lib/config/redis"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -30,7 +30,7 @@ export default { dbRootCert: envConfig.DB_ROOT_CERT }); - const redis = new Redis(envConfig.REDIS_URL); + const redis = buildRedisFromConfig(envConfig); await redis.flushdb("SYNC"); try { @@ -55,8 +55,8 @@ export default { }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(envConfig.REDIS_URL, { dbConnectionUrl: envConfig.DB_CONNECTION_URI }); - const keyStore = keyStoreFactory(envConfig.REDIS_URL); + const queue = queueServiceFactory(envConfig, { dbConnectionUrl: envConfig.DB_CONNECTION_URI }); + const keyStore = keyStoreFactory(envConfig); const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 49128b18a..ad58a2a7e 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,5 +1,4 @@ -import { Redis } from "ioredis"; - +import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext"; import { applyJitter } from "@app/lib/dates"; import { delay as delayMs } from "@app/lib/delay"; @@ -68,8 +67,8 @@ type TWaitTillReady = { jitter?: number; }; -export const keyStoreFactory = (redisUrl: string) => { - const redis = new Redis(redisUrl); +export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys) => { + const redis = buildRedisFromConfig(redisConfigKeys); const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index ae5af701e..198ccb9bf 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -30,7 +30,19 @@ const envSchema = z .enum(["true", "false"]) .default("false") .transform((el) => el === "true"), - REDIS_URL: zpStr(z.string()), + REDIS_URL: zpStr(z.string().optional()), + REDIS_SENTINEL_HOSTS: zpStr( + z + .string() + .optional() + .describe("Comma-separated list of Sentinel host:port pairs. Eg: 192.168.65.254:26379,192.168.65.254:26380") + ), + REDIS_SENTINEL_MASTER_NAME: zpStr( + z.string().optional().default("mymaster").describe("The name of the Redis master set monitored by Sentinel") + ), + REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"), + REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")), + REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")), HOST: zpStr(z.string().default("localhost")), DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` @@ -259,26 +271,34 @@ const envSchema = z (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." ) + .refine( + (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS), + "Either REDIS_URL or REDIS_SENTINEL_HOSTS must be defined." + ) .transform((data) => ({ ...data, - DB_READ_REPLICAS: data.DB_READ_REPLICAS ? databaseReadReplicaSchema.parse(JSON.parse(data.DB_READ_REPLICAS)) : undefined, isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), - isRedisConfigured: Boolean(data.REDIS_URL), + isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), isDevelopmentMode: data.NODE_ENV === "development", isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, - + isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), + REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() + ?.split(",") + .map((el) => { + const [host, port] = el.trim().split(":"); + return { host: host.trim(), port: Number(port.trim()) }; + }), isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), isHsmConfigured: Boolean(data.HSM_LIB_PATH) && Boolean(data.HSM_PIN) && Boolean(data.HSM_KEY_LABEL) && data.HSM_SLOT !== undefined, - samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, SECRET_SCANNING_ORG_WHITELIST: data.SECRET_SCANNING_ORG_WHITELIST?.split(",") })); diff --git a/backend/src/lib/config/redis.ts b/backend/src/lib/config/redis.ts new file mode 100644 index 000000000..987518dd5 --- /dev/null +++ b/backend/src/lib/config/redis.ts @@ -0,0 +1,24 @@ +import { Redis } from "ioredis"; + +export type TRedisConfigKeys = Partial<{ + REDIS_URL: string; + REDIS_SENTINEL_HOSTS: { host: string; port: number }[]; + REDIS_SENTINEL_MASTER_NAME: string; + REDIS_SENTINEL_ENABLE_TLS: boolean; + REDIS_SENTINEL_USERNAME: string; + REDIS_SENTINEL_PASSWORD: string; +}>; + +export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { + if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null }); + + return new Redis({ + // refine at tope will catch this case + sentinels: cfg.REDIS_SENTINEL_HOSTS!, + name: cfg.REDIS_SENTINEL_MASTER_NAME!, + maxRetriesPerRequest: null, + sentinelUsername: cfg.REDIS_SENTINEL_USERNAME, + sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD, + enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS + }); +}; diff --git a/backend/src/main.ts b/backend/src/main.ts index c3b5a0900..d141b62d5 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,7 +1,6 @@ import "./lib/telemetry/instrumentation"; import dotenv from "dotenv"; -import { Redis } from "ioredis"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; @@ -9,6 +8,7 @@ import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; import { keyStoreFactory } from "./keystore/keystore"; import { formatSmtpConfig, initEnvConfig } from "./lib/config/env"; +import { buildRedisFromConfig } from "./lib/config/redis"; import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; @@ -44,15 +44,15 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(envConfig.REDIS_URL, { + const queue = queueServiceFactory(envConfig, { dbConnectionUrl: envConfig.DB_CONNECTION_URI, dbRootCert: envConfig.DB_ROOT_CERT }); await queue.initialize(); - const keyStore = keyStoreFactory(envConfig.REDIS_URL); - const redis = new Redis(envConfig.REDIS_URL); + const keyStore = keyStoreFactory(envConfig); + const redis = buildRedisFromConfig(envConfig); const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 345c00278..c4aae9aaf 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -1,5 +1,4 @@ import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq"; -import Redis from "ioredis"; import PgBoss, { WorkOptions } from "pg-boss"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; @@ -13,6 +12,7 @@ import { TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; import { getConfig } from "@app/lib/config/env"; +import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { @@ -265,10 +265,10 @@ export type TQueueJobTypes = { export type TQueueServiceFactory = ReturnType; export const queueServiceFactory = ( - redisUrl: string, + redisCfg: TRedisConfigKeys, { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } ) => { - const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); + const connection = buildRedisFromConfig(redisCfg); const queueContainer = {} as Record< QueueName, Queue diff --git a/backend/src/server/boot-strap-check.ts b/backend/src/server/boot-strap-check.ts index 7db2a71e8..91c52d871 100644 --- a/backend/src/server/boot-strap-check.ts +++ b/backend/src/server/boot-strap-check.ts @@ -1,9 +1,9 @@ /* eslint-disable no-console */ -import { Redis } from "ioredis"; import { Knex } from "knex"; import { createTransport } from "nodemailer"; import { formatSmtpConfig, getConfig } from "@app/lib/config/env"; +import { buildRedisFromConfig } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -65,12 +65,15 @@ export const bootstrapCheck = async ({ db }: BootstrapOpt) => { }); console.log("Testing redis connection"); - const redis = new Redis(appCfg.REDIS_URL); + const redis = buildRedisFromConfig(appCfg); const redisPing = await redis?.ping(); if (!redisPing) { console.error("Redis - Failed to connect"); } else { - console.error("Redis successfully connected"); + console.log("Redis successfully connected"); + if (appCfg.isRedisSentinelMode) { + console.log("Redis Sentinel Mode"); + } redis.disconnect(); } diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index b12c9b0d3..7b4b9a99b 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -1,14 +1,12 @@ import type { RateLimitOptions, RateLimitPluginOptions } from "@fastify/rate-limit"; -import { Redis } from "ioredis"; import { getConfig } from "@app/lib/config/env"; +import { buildRedisFromConfig } from "@app/lib/config/redis"; import { RateLimitError } from "@app/lib/errors"; export const globalRateLimiterCfg = (): RateLimitPluginOptions => { const appCfg = getConfig(); - const redis = appCfg.isRedisConfigured - ? new Redis(appCfg.REDIS_URL, { connectTimeout: 500, maxRetriesPerRequest: 1 }) - : null; + const redis = appCfg.isRedisConfigured ? buildRedisFromConfig(appCfg) : null; return { errorResponseBuilder: (_, context) => { diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8da732d6d..cb374071f 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -1,5 +1,5 @@ --- -title: "Configurations" +title: "Environment Variables" description: "Read how to configure environment variables for self-hosted Infisical." --- @@ -34,12 +34,14 @@ Used to configure platform-specific security and operational settings By default, Infisical binds to `localhost`, which restricts access to connections from the same machine. - To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces. +To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces. + +Example values: + +- `localhost` (default, same as `127.0.0.1`) +- `0.0.0.0` (all interfaces, accessible externally) +- `192.168.1.100` (specific interface IP) - Example values: - - `localhost` (default, same as `127.0.0.1`) - - `0.0.0.0` (all interfaces, accessible externally) - - `192.168.1.100` (specific interface IP) @@ -86,8 +88,9 @@ The platform utilizes Postgres to persist all of its data and Redis for caching ### PostgreSQL - Please note that the database user you create must be granted all privileges on the Infisical database. - This includes the ability to create new schemas, create, update, delete, modify tables and indexes, etc. + Please note that the database user you create must be granted all privileges + on the Infisical database. This includes the ability to create new schemas, + create, update, delete, modify tables and indexes, etc. @@ -119,10 +122,42 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] ### Redis +Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup. - - Redis connection string. - + + + + Redis connection string. + + + + + Comma-separated list of Sentinel host:port pairs. ``` + 192.168.65.254:26379,192.168.65.254:26380 ``` + + + The name of the Redis master set monitored by Sentinel + + + Whether to use TLS/SSL for Redis Sentinel connection + + + Authentication username for Redis Sentinel + + + Authentication password for Redis Sentinel + + + ## Email Service @@ -640,13 +675,27 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I The App ID of your GitHub App. - - The slug of your GitHub App. - +{" "} - - A private key for your GitHub App. - + + The slug of your GitHub App. + + +{" "} + + + A private key for your GitHub App. + The webhook secret of your GitHub App. diff --git a/sink/redis-sentinel/.env.example b/sink/redis-sentinel/.env.example new file mode 100644 index 000000000..89dea292d --- /dev/null +++ b/sink/redis-sentinel/.env.example @@ -0,0 +1 @@ +HOST_IP= diff --git a/sink/redis-sentinel/README.md b/sink/redis-sentinel/README.md new file mode 100644 index 000000000..91d5949c6 --- /dev/null +++ b/sink/redis-sentinel/README.md @@ -0,0 +1,20 @@ +## Sink for Redis Sentinel + +1. Create a `.env` from `.env.example` +2. The HOST_IP value must be your host ip, so that redis inside the containers can connect to it and switch over as needed. + +To test Sentinel is working correctly + +1. Run + +``` +docker exec -it sentinel-2 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster +``` + +2. Run + +``` +docker-compose -f docker-compose.sentinel.yml stop redis-master +``` + +3. Again running step 1 should show the other replica port. diff --git a/sink/redis-sentinel/docker-compose.sentinel.yml b/sink/redis-sentinel/docker-compose.sentinel.yml new file mode 100644 index 000000000..df30c152b --- /dev/null +++ b/sink/redis-sentinel/docker-compose.sentinel.yml @@ -0,0 +1,166 @@ +version: "3.8" + +services: + redis-master: + image: redis:latest + container_name: redis-master + hostname: redis-master + ports: + - "6380:6379" + volumes: + - ./data/master:/data + command: + [ + "redis-server", + "--appendonly", + "yes", + "--repl-diskless-load", + "on-empty-db", + "--replica-announce-ip", + "${HOST_IP}", + "--replica-announce-port", + "6380", + "--protected-mode", + "no", + ] + networks: + redis-net: + ipv4_address: 172.21.0.3 + + redis-slave-1: + image: redis:latest + container_name: redis-slave-1 + hostname: redis-slave-1 + depends_on: + - redis-master + ports: + - "6381:6379" + volumes: + - ./data/slave1:/data + command: + [ + "redis-server", + "--appendonly", + "yes", + "--replicaof", + "redis-master", + "6379", + "--repl-diskless-load", + "on-empty-db", + "--replica-announce-ip", + "${HOST_IP}", + "--replica-announce-port", + "6381", + "--protected-mode", + "no", + ] + networks: + redis-net: + ipv4_address: 172.21.0.4 + + redis-slave-2: + image: redis:latest + container_name: redis-slave-2 + hostname: redis-slave-2 + depends_on: + - redis-master + ports: + - "6382:6379" + volumes: + - ./data/slave2:/data + command: + [ + "redis-server", + "--appendonly", + "yes", + "--replicaof", + "redis-master", + "6379", + "--repl-diskless-load", + "on-empty-db", + "--replica-announce-ip", + "${HOST_IP}", + "--replica-announce-port", + "6382", + "--protected-mode", + "no", + ] + networks: + redis-net: + ipv4_address: 172.21.0.5 + + sentinel-1: + image: redis:latest + container_name: sentinel-1 + hostname: sentinel-1 + depends_on: + - redis-master + ports: + - "26379:26379" + command: > + sh -c 'echo "bind 0.0.0.0" > /etc/sentinel.conf && + echo "sentinel monitor mymaster ${HOST_IP} 6380 2" >> /etc/sentinel.conf && + echo "sentinel resolve-hostnames yes" >> /etc/sentinel.conf && + echo "sentinel down-after-milliseconds mymaster 10000" >> /etc/sentinel.conf && + echo "sentinel failover-timeout mymaster 10000" >> /etc/sentinel.conf && + echo "sentinel parallel-syncs mymaster 1" >> /etc/sentinel.conf && + redis-sentinel /etc/sentinel.conf' + networks: + redis-net: + ipv4_address: 172.21.0.6 + + sentinel-2: + image: redis:latest + container_name: sentinel-2 + hostname: sentinel-2 + depends_on: + - redis-master + ports: + - "26380:26379" + command: > + sh -c 'echo "bind 0.0.0.0" > /etc/sentinel.conf && + echo "sentinel monitor mymaster ${HOST_IP} 6380 2" >> /etc/sentinel.conf && + echo "sentinel resolve-hostnames yes" >> /etc/sentinel.conf && + echo "sentinel down-after-milliseconds mymaster 10000" >> /etc/sentinel.conf && + echo "sentinel failover-timeout mymaster 10000" >> /etc/sentinel.conf && + echo "sentinel parallel-syncs mymaster 1" >> /etc/sentinel.conf && + redis-sentinel /etc/sentinel.conf' + networks: + redis-net: + ipv4_address: 172.21.0.7 + + sentinel-3: + image: redis:latest + container_name: sentinel-3 + hostname: sentinel-3 + depends_on: + - redis-master + ports: + - "26381:26379" + command: > + sh -c 'echo "bind 0.0.0.0" > /etc/sentinel.conf && + echo "sentinel monitor mymaster ${HOST_IP} 6380 2" >> /etc/sentinel.conf && + echo "sentinel resolve-hostnames yes" >> /etc/sentinel.conf && + echo "sentinel down-after-milliseconds mymaster 10000" >> /etc/sentinel.conf && + echo "sentinel failover-timeout mymaster 10000" >> /etc/sentinel.conf && + echo "sentinel parallel-syncs mymaster 1" >> /etc/sentinel.conf && + redis-sentinel /etc/sentinel.conf' + networks: + redis-net: + ipv4_address: 172.21.0.8 + + redisinsight: + image: redis/redisinsight:latest + container_name: redisinsight + ports: + - "5540:5540" + networks: + redis-net: + ipv4_address: 172.21.0.9 + +networks: + redis-net: + driver: bridge + ipam: + config: + - subnet: 172.21.0.0/16