You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/10/16 04:03:06 UTC

(superset) branch master updated: fix(websocket): support Sentinel and full TLS options for Redis/Valkey (#45355)

This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 1aaabd8e435 fix(websocket): support Sentinel and full TLS options for Redis/Valkey (#45355)
1aaabd8e435 is described below

commit 1aaabd8e4352cfcad448b5734831075e22a96b9d
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Thu Oct 15 21:02:59 2026 -0700

    fix(websocket): support Sentinel and full TLS options for Redis/Valkey (#45355)
---
 superset-websocket/README.md           |  25 +++++
 superset-websocket/config.example.json |  10 +-
 superset-websocket/spec/config.test.ts |  42 +++++++++
 superset-websocket/spec/index.test.ts  | 161 ++++++++++++++++++++++++++-------
 superset-websocket/src/config.ts       |  75 +++++++++++++++
 superset-websocket/src/index.ts        |  93 +++++++++++++++----
 6 files changed, 352 insertions(+), 54 deletions(-)

diff --git a/superset-websocket/README.md b/superset-websocket/README.md
index 844becec080..bf1dafc4d52 100644
--- a/superset-websocket/README.md
+++ b/superset-websocket/README.md
@@ -258,6 +258,31 @@ when `REALTIME_CHANNEL_PREFIX=tenant-a:`. Set a per-deployment prefix to keep
 deployments that share one Redis/Valkey from cross-delivering realtime nudges
 (Redis pub/sub is not scoped by DB number).
 
+### Redis / Valkey connection modes
+
+The `redis` object (or the `REDIS_*` environment variables) supports the same
+modes as the Python coordination backend: a standalone server, or Redis/Sentinel,
+each optionally over TLS. Redis Cluster is not supported by either side.
+
+| Setting                          | Environment variable       | Notes                                                                 |
+| -------------------------------- | -------------------------- | --------------------------------------------------------------------- |
+| `host`, `port`, `db`             | `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB` | Standalone server (ignored in Sentinel mode)               |
+| `username`, `password`           | `REDIS_USERNAME`, `REDIS_PASSWORD`     | Authenticate against the (resolved) master                 |
+| `ssl`                            | `REDIS_SSL`                | Enable TLS to the server                                              |
+| `sslCaCerts`                     | `REDIS_SSL_CA_CERTS`       | Path to a PEM CA bundle, trusted **in addition to** the public CAs    |
+| `sslCertfile`, `sslKeyfile`      | `REDIS_SSL_CERTFILE`, `REDIS_SSL_KEYFILE` | Client certificate and key (mutual TLS)             |
+| `sslCertReqs`                    | `REDIS_SSL_CERT_REQS`      | `required` (default), `optional` or `none`; only `none` skips verification |
+| `validateHostname`               | `REDIS_VALIDATE_HOSTNAME`  | Check the server certificate's hostname (default `true`)              |
+| `sentinels`                      | `REDIS_SENTINELS`          | Enables Sentinel mode. JSON: `[{"host": "s1", "port": 26379}]`; env: `s1:26379,s2:26379` |
+| `sentinelMaster`                 | `REDIS_SENTINEL_MASTER`    | Master name; required when `sentinels` is set                         |
+| `sentinelPassword`               | `REDIS_SENTINEL_PASSWORD`  | Password for the sentinel nodes themselves                            |
+
+With no `sslCaCerts`, the server certificate is validated against the public CA
+roots bundled with Node, so a certificate from a public CA works with just
+`ssl: true`. In Sentinel mode TLS applies to the resolved master; the sentinel
+nodes are contacted in plaintext, matching the Python backend. The server follows
+failovers by re-resolving the master through the sentinels.
+
 ## StatsD monitoring
 
 The application is tracking a couple of metrics with `statsd` using the [hot-shots](https://www.npmjs.com/package/hot-shots) library, such as the number of connected clients and the number of failed attempts to send a message to a client.
diff --git a/superset-websocket/config.example.json b/superset-websocket/config.example.json
index bab9f6c6d59..aeb961f4a7e 100644
--- a/superset-websocket/config.example.json
+++ b/superset-websocket/config.example.json
@@ -13,7 +13,15 @@
     "host": "127.0.0.1",
     "password": "",
     "db": 0,
-    "ssl": false
+    "ssl": false,
+    "validateHostname": true,
+    "sslCaCerts": "",
+    "sslCertfile": "",
+    "sslKeyfile": "",
+    "sslCertReqs": "required",
+    "sentinels": [],
+    "sentinelMaster": "",
+    "sentinelPassword": ""
   },
   "jwtAlgorithms": ["HS256"],
   "jwtSecret": "CHANGE-ME",
diff --git a/superset-websocket/spec/config.test.ts b/superset-websocket/spec/config.test.ts
index 40e161da73c..c1de1d77b5b 100644
--- a/superset-websocket/spec/config.test.ts
+++ b/superset-websocket/spec/config.test.ts
@@ -33,6 +33,10 @@ test('buildConfig() builds configuration and applies env var overrides', () => {
   expect(config.redis.password).toEqual('some pwd');
   expect(config.redis.db).toEqual(10);
   expect(config.redis.ssl).toEqual(false);
+  expect(config.redis.validateHostname).toEqual(true);
+  expect(config.redis.sslCertReqs).toEqual('required');
+  expect(config.redis.sslCaCerts).toEqual('');
+  expect(config.redis.sentinels).toEqual([]);
   expect(config.statsd.host).toEqual('127.0.0.1');
   expect(config.statsd.port).toEqual(8125);
   expect(config.statsd.globalTags).toEqual([]);
@@ -45,6 +49,15 @@ test('buildConfig() builds configuration and applies env var overrides', () => {
   process.env.REDIS_PASSWORD = 'admin';
   process.env.REDIS_DB = '4';
   process.env.REDIS_SSL = 'true';
+  process.env.REDIS_VALIDATE_HOSTNAME = 'false';
+  process.env.REDIS_SSL_CA_CERTS = '/certs/ca.pem';
+  process.env.REDIS_SSL_CERTFILE = '/certs/client.pem';
+  process.env.REDIS_SSL_KEYFILE = '/certs/client.key';
+  process.env.REDIS_SSL_CERT_REQS = 'NONE';
+  process.env.REDIS_SENTINELS =
+    's1:26380, s2 ,[::1]:26381,bad:port,toobig:70000';
+  process.env.REDIS_SENTINEL_MASTER = 'mymaster';
+  process.env.REDIS_SENTINEL_PASSWORD = 'sentinel-pwd';
   process.env.STATSD_HOST = '15.15.15.15';
   process.env.STATSD_PORT = '8000';
   process.env.STATSD_GLOBAL_TAGS = 'tag-1,tag-2';
@@ -59,6 +72,18 @@ test('buildConfig() builds configuration and applies env var overrides', () => {
   expect(config.redis.password).toEqual('admin');
   expect(config.redis.db).toEqual(4);
   expect(config.redis.ssl).toEqual(true);
+  expect(config.redis.validateHostname).toEqual(false);
+  expect(config.redis.sslCaCerts).toEqual('/certs/ca.pem');
+  expect(config.redis.sslCertfile).toEqual('/certs/client.pem');
+  expect(config.redis.sslKeyfile).toEqual('/certs/client.key');
+  expect(config.redis.sslCertReqs).toEqual('none');
+  expect(config.redis.sentinels).toEqual([
+    { host: 's1', port: 26380 },
+    { host: 's2', port: 26379 },
+    { host: '::1', port: 26381 },
+  ]);
+  expect(config.redis.sentinelMaster).toEqual('mymaster');
+  expect(config.redis.sentinelPassword).toEqual('sentinel-pwd');
   expect(config.statsd.host).toEqual('15.15.15.15');
   expect(config.statsd.port).toEqual(8000);
   expect(config.statsd.globalTags).toEqual(['tag-1', 'tag-2']);
@@ -71,6 +96,14 @@ test('buildConfig() builds configuration and applies env var overrides', () => {
   delete process.env.REDIS_PASSWORD;
   delete process.env.REDIS_DB;
   delete process.env.REDIS_SSL;
+  delete process.env.REDIS_VALIDATE_HOSTNAME;
+  delete process.env.REDIS_SSL_CA_CERTS;
+  delete process.env.REDIS_SSL_CERTFILE;
+  delete process.env.REDIS_SSL_KEYFILE;
+  delete process.env.REDIS_SSL_CERT_REQS;
+  delete process.env.REDIS_SENTINELS;
+  delete process.env.REDIS_SENTINEL_MASTER;
+  delete process.env.REDIS_SENTINEL_PASSWORD;
   delete process.env.STATSD_HOST;
   delete process.env.STATSD_PORT;
   delete process.env.STATSD_GLOBAL_TAGS;
@@ -104,3 +137,12 @@ test('buildConfig() falls back to the default for a malformed numeric override',
   delete process.env.SOCKET_RESPONSE_TIMEOUT_MS;
   delete process.env.MAX_CONNECTIONS_PER_CHANNEL;
 });
+
+test('buildConfig() falls back to the default for an invalid REDIS_SSL_CERT_REQS', () => {
+  process.env.REDIS_SSL_CERT_REQS = 'sometimes';
+
+  // A typo must not silently weaken verification.
+  expect(buildConfig().redis.sslCertReqs).toEqual('required');
+
+  delete process.env.REDIS_SSL_CERT_REQS;
+});
diff --git a/superset-websocket/spec/index.test.ts b/superset-websocket/spec/index.test.ts
index f45c878123d..db376996638 100644
--- a/superset-websocket/spec/index.test.ts
+++ b/superset-websocket/spec/index.test.ts
@@ -30,8 +30,13 @@ import {
 } from 'vitest';
 import * as http from 'http';
 import * as net from 'net';
+import { mkdtempSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { ConnectionOptions, rootCertificates } from 'tls';
 import { WebSocket } from 'ws';
 import * as server from '../src/index';
+import type { RedisConfig } from '../src/config';
 import { statsd } from '../src/index';
 
 const { mockSubscribe, mockOn } = vi.hoisted(() => {
@@ -226,31 +231,44 @@ describe('server', () => {
     });
   });
 
-  describe('redisUrlFromConfig', () => {
-    test('it builds a valid Redis URL from defaults', () => {
-      expect(
-        server.buildRedisOpts({
-          port: 6379,
-          host: '127.0.0.1',
-          username: 'test-user',
-          password: '',
-          db: 0,
-          ssl: false,
-          validateHostname: false,
-        }),
-      ).toEqual({ db: 0, host: '127.0.0.1', port: 6379 });
+  describe('buildRedisOpts', () => {
+    const redisConfig = (
+      overrides: Partial<RedisConfig> = {},
+    ): RedisConfig => ({
+      port: 6379,
+      host: '127.0.0.1',
+      username: 'cool-user',
+      password: '',
+      db: 0,
+      ssl: false,
+      validateHostname: false,
+      sslCaCerts: '',
+      sslCertfile: '',
+      sslKeyfile: '',
+      sslCertReqs: 'required',
+      sentinels: [],
+      sentinelMaster: '',
+      sentinelPassword: '',
+      ...overrides,
+    });
+
+    test('it builds standalone options from defaults', () => {
+      expect(server.buildRedisOpts(redisConfig())).toEqual({
+        db: 0,
+        host: '127.0.0.1',
+        port: 6379,
+      });
     });
-    test('it builds a valid Redis URL with a password', () => {
+    test('it builds standalone options with a password', () => {
       expect(
-        server.buildRedisOpts({
-          port: 6380,
-          host: 'redis.local',
-          username: 'cool-user',
-          password: 'foo',
-          db: 1,
-          ssl: false,
-          validateHostname: false,
-        }),
+        server.buildRedisOpts(
+          redisConfig({
+            port: 6380,
+            host: 'redis.local',
+            password: 'foo',
+            db: 1,
+          }),
+        ),
       ).toEqual({
         db: 1,
         host: 'redis.local',
@@ -259,24 +277,97 @@ describe('server', () => {
         username: 'cool-user',
       });
     });
-    test('it builds a valid Redis URL with SSL', () => {
-      expect(
-        server.buildRedisOpts({
-          port: 6379,
-          host: '127.0.0.1',
-          password: '',
-          username: 'cool-user',
-          db: 0,
-          ssl: true,
-          validateHostname: false,
-        }),
-      ).toEqual({
+    test('it relies on the default trust store for TLS', () => {
+      expect(server.buildRedisOpts(redisConfig({ ssl: true }))).toEqual({
         db: 0,
         host: '127.0.0.1',
         port: 6379,
         tls: { checkServerIdentity: expect.anything() },
       });
     });
+    test('it disables verification only for ssl cert reqs "none"', () => {
+      const tlsFor = (sslCertReqs: RedisConfig['sslCertReqs']) =>
+        server.buildRedisOpts(redisConfig({ ssl: true, sslCertReqs })).tls;
+      expect(tlsFor('none')).toMatchObject({ rejectUnauthorized: false });
+      expect(tlsFor('required')).not.toHaveProperty('rejectUnauthorized');
+      expect(tlsFor('optional')).not.toHaveProperty('rejectUnauthorized');
+    });
+    test('it trusts a custom CA alongside the public roots, and loads client certs', () => {
+      const dir = mkdtempSync(join(tmpdir(), 'ws-redis-tls-'));
+      try {
+        const write = (name: string, body: string) => {
+          const path = join(dir, name);
+          writeFileSync(path, body);
+          return path;
+        };
+        const opts = server.buildRedisOpts(
+          redisConfig({
+            ssl: true,
+            sslCaCerts: write('ca.pem', 'CUSTOM-CA'),
+            sslCertfile: write('client.pem', 'CLIENT-CERT'),
+            sslKeyfile: write('client.key', 'CLIENT-KEY'),
+          }),
+        );
+        const tls = opts.tls as ConnectionOptions;
+        expect(tls.ca).toEqual([...rootCertificates, 'CUSTOM-CA']);
+        expect(tls.cert).toEqual('CLIENT-CERT');
+        expect(tls.key).toEqual('CLIENT-KEY');
+      } finally {
+        rmSync(dir, { recursive: true, force: true });
+      }
+    });
+    test('it fails fast when a configured cert file is missing', () => {
+      expect(() =>
+        server.buildRedisOpts(
+          redisConfig({ ssl: true, sslCaCerts: '/nonexistent/ca.pem' }),
+        ),
+      ).toThrow(/ca\.pem/);
+    });
+    test('it builds Sentinel options in place of host/port', () => {
+      expect(
+        server.buildRedisOpts(
+          redisConfig({
+            password: 'master-pwd',
+            sentinels: [
+              { host: 's1', port: 26379 },
+              { host: 's2', port: 26380 },
+            ],
+            sentinelMaster: 'mymaster',
+            sentinelPassword: 'sentinel-pwd',
+          }),
+        ),
+      ).toEqual({
+        db: 0,
+        sentinels: [
+          { host: 's1', port: 26379 },
+          { host: 's2', port: 26380 },
+        ],
+        name: 'mymaster',
+        failoverDetector: true,
+        sentinelPassword: 'sentinel-pwd',
+        username: 'cool-user',
+        password: 'master-pwd',
+      });
+    });
+    test('it enables TLS for the Sentinel-resolved master', () => {
+      const opts = server.buildRedisOpts(
+        redisConfig({
+          ssl: true,
+          sentinels: [{ host: 's1', port: 26379 }],
+          sentinelMaster: 'mymaster',
+        }),
+      );
+      expect(opts.tls).toEqual({ checkServerIdentity: expect.anything() });
+      // Without this flag ioredis connects to the master in plaintext.
+      expect(opts.enableTLSForSentinelMode).toBe(true);
+    });
+    test('it requires a master name in Sentinel mode', () => {
+      expect(() =>
+        server.buildRedisOpts(
+          redisConfig({ sentinels: [{ host: 's1', port: 26379 }] }),
+        ),
+      ).toThrow(/sentinelMaster/);
+    });
   });
 
   describe('subscribeToChannels', () => {
diff --git a/superset-websocket/src/config.ts b/superset-websocket/src/config.ts
index d9146b9b61d..c6c59618ca3 100644
--- a/superset-websocket/src/config.ts
+++ b/superset-websocket/src/config.ts
@@ -21,6 +21,14 @@ import { readFileSync } from 'fs';
 import { merge as _merge } from 'lodash-es';
 import { resolve } from 'path';
 
+export interface SentinelNode {
+  host: string;
+  port: number;
+}
+
+/** Mirrors the Python ``CACHE_REDIS_SSL_CERT_REQS`` values. */
+export type SslCertReqs = 'required' | 'optional' | 'none';
+
 export interface RedisConfig {
   port: number;
   host: string;
@@ -29,6 +37,21 @@ export interface RedisConfig {
   db: number;
   ssl: boolean;
   validateHostname: boolean;
+  // Paths to PEM files. The CA bundle is trusted in addition to the system's
+  // public CAs, so a private CA and public CAs can coexist.
+  sslCaCerts: string;
+  sslCertfile: string;
+  sslKeyfile: string;
+  // Only 'none' disables certificate verification: for a TLS client Python's
+  // 'optional' is equivalent to 'required', so both verify the server.
+  sslCertReqs: SslCertReqs;
+  // A non-empty list selects Sentinel mode: `host`/`port` are ignored and the
+  // master named `sentinelMaster` is resolved through the sentinels.
+  sentinels: SentinelNode[];
+  sentinelMaster: string;
+  // Credentials for the sentinel nodes themselves (`password`/`username`
+  // authenticate against the resolved master).
+  sentinelPassword: string;
 }
 
 type ConfigType = {
@@ -90,6 +113,13 @@ function defaultConfig(): ConfigType {
       db: 0,
       ssl: false,
       validateHostname: true,
+      sslCaCerts: '',
+      sslCertfile: '',
+      sslKeyfile: '',
+      sslCertReqs: 'required',
+      sentinels: [],
+      sentinelMaster: '',
+      sentinelPassword: '',
     },
   };
 }
@@ -134,6 +164,41 @@ const toStringArray = (s: string) =>
     .map(entry => entry.trim())
     .filter(entry => entry.length > 0);
 
+// Parse "host:port,host:port" (IPv6 hosts as "[::1]:26379"). A missing port
+// defaults to the Sentinel port; an unparseable entry is skipped with a warning.
+function toSentinelNodes(s: string): SentinelNode[] {
+  return toStringArray(s).flatMap(entry => {
+    const sep = entry.lastIndexOf(':');
+    const hasPort = sep > 0 && !entry.endsWith(']');
+    const host = (hasPort ? entry.slice(0, sep) : entry).replace(
+      /^\[|\]$/g,
+      '',
+    );
+    const port = hasPort ? Number(entry.slice(sep + 1)) : 26379;
+    if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
+      console.warn(`Ignoring invalid sentinel address "${entry}".`);
+      return [];
+    }
+    return [{ host, port }];
+  });
+}
+
+function toSslCertReqs(val: string, fallback: SslCertReqs): SslCertReqs {
+  const normalized = val.trim().toLowerCase();
+  if (
+    normalized === 'required' ||
+    normalized === 'optional' ||
+    normalized === 'none'
+  ) {
+    return normalized;
+  }
+  console.warn(
+    `Invalid REDIS_SSL_CERT_REQS "${val}"; expected required, optional or ` +
+      `none. Falling back to ${fallback}.`,
+  );
+  return fallback;
+}
+
 function applyEnvOverrides(config: ConfigType): ConfigType {
   // Every numeric override goes through `toNonNegativeNumber` so a typo'd value
   // falls back to the default with a warning rather than silently becoming NaN
@@ -186,6 +251,16 @@ function applyEnvOverrides(config: ConfigType): ConfigType {
     REDIS_DB: val =>
       (config.redis.db = toNonNegativeNumber(val, config.redis.db)),
     REDIS_SSL: val => (config.redis.ssl = toBoolean(val)),
+    REDIS_VALIDATE_HOSTNAME: val =>
+      (config.redis.validateHostname = toBoolean(val)),
+    REDIS_SSL_CA_CERTS: val => (config.redis.sslCaCerts = val),
+    REDIS_SSL_CERTFILE: val => (config.redis.sslCertfile = val),
+    REDIS_SSL_KEYFILE: val => (config.redis.sslKeyfile = val),
+    REDIS_SSL_CERT_REQS: val =>
+      (config.redis.sslCertReqs = toSslCertReqs(val, config.redis.sslCertReqs)),
+    REDIS_SENTINELS: val => (config.redis.sentinels = toSentinelNodes(val)),
+    REDIS_SENTINEL_MASTER: val => (config.redis.sentinelMaster = val),
+    REDIS_SENTINEL_PASSWORD: val => (config.redis.sentinelPassword = val),
     STATSD_HOST: val => (config.statsd.host = val),
     STATSD_PORT: val =>
       (config.statsd.port = toNonNegativeNumber(val, config.statsd.port)),
diff --git a/superset-websocket/src/index.ts b/superset-websocket/src/index.ts
index d72f12d4833..289e68cc035 100644
--- a/superset-websocket/src/index.ts
+++ b/superset-websocket/src/index.ts
@@ -18,6 +18,7 @@
  */
 import * as http from 'http';
 import * as net from 'net';
+import { readFileSync } from 'fs';
 import { inspect } from 'util';
 import { WebSocket, WebSocketServer } from 'ws';
 import { randomUUID } from 'crypto';
@@ -31,7 +32,12 @@ import StatsD from 'hot-shots';
 
 import { createLogger } from './logger.js';
 import { buildConfig, RedisConfig } from './config.js';
-import { checkServerIdentity, PeerCertificate } from 'tls';
+import {
+  checkServerIdentity,
+  ConnectionOptions,
+  PeerCertificate,
+  rootCertificates,
+} from 'tls';
 
 const REALTIME_JWT_AUDIENCE = 'superset-websocket';
 const REALTIME_JWT_ISSUER = 'superset';
@@ -146,12 +152,69 @@ if (startServer) {
   validateConfiguredJwtSecret(opts.previousJwtSecret, 'previous JWT secret');
 }
 
-export const buildRedisOpts = (baseConfig: RedisConfig) => {
-  const redisOpts: RedisOptions = {
-    port: baseConfig.port,
-    host: baseConfig.host,
-    db: baseConfig.db,
+const readPem = (path: string): string => readFileSync(path, 'utf8');
+
+const buildTlsOptions = (baseConfig: RedisConfig): ConnectionOptions => {
+  const tlsOpts: ConnectionOptions = {
+    checkServerIdentity: (
+      hostname: string,
+      cert: PeerCertificate,
+    ): Error | undefined => {
+      // Note, the cert chain will have been verified already. the role of this method is to
+      // validate that at least one of the SAN's (or subject) of the server's cert matches the provided hostname
+      if (baseConfig.validateHostname) {
+        return checkServerIdentity(hostname, cert);
+      }
+    },
   };
+  if (baseConfig.sslCertReqs === 'none') {
+    tlsOpts.rejectUnauthorized = false;
+  }
+  if (baseConfig.sslCaCerts) {
+    // Supplying `ca` replaces Node's default trust store, so the public roots
+    // are re-added to keep public and private CAs usable together.
+    tlsOpts.ca = [...rootCertificates, readPem(baseConfig.sslCaCerts)];
+  }
+  if (baseConfig.sslCertfile) {
+    tlsOpts.cert = readPem(baseConfig.sslCertfile);
+  }
+  if (baseConfig.sslKeyfile) {
+    tlsOpts.key = readPem(baseConfig.sslKeyfile);
+  }
+  return tlsOpts;
+};
+
+/**
+ * Translate the `redis` config into ioredis options, covering the same modes as
+ * the Python coordination backend: standalone or Sentinel (`sentinels` set),
+ * each optionally over TLS. In Sentinel mode TLS applies to the resolved master
+ * only; the sentinel nodes are reached in plaintext, as on the Python side.
+ */
+export const buildRedisOpts = (baseConfig: RedisConfig) => {
+  const redisOpts: RedisOptions = { db: baseConfig.db };
+
+  if (baseConfig.sentinels.length > 0) {
+    if (!baseConfig.sentinelMaster) {
+      throw new Error(
+        'redis.sentinelMaster (REDIS_SENTINEL_MASTER) is required when ' +
+          'redis.sentinels (REDIS_SENTINELS) is set',
+      );
+    }
+    redisOpts.sentinels = baseConfig.sentinels.map(({ host, port }) => ({
+      host,
+      port,
+    }));
+    redisOpts.name = baseConfig.sentinelMaster;
+    // Drop the connection to a master that has been demoted by a failover so
+    // the subscriber re-resolves the new master and keeps receiving messages.
+    redisOpts.failoverDetector = true;
+    if (baseConfig.sentinelPassword !== '') {
+      redisOpts.sentinelPassword = baseConfig.sentinelPassword;
+    }
+  } else {
+    redisOpts.host = baseConfig.host;
+    redisOpts.port = baseConfig.port;
+  }
 
   const passwd = baseConfig.password;
   if (passwd !== '') {
@@ -160,18 +223,12 @@ export const buildRedisOpts = (baseConfig: RedisConfig) => {
   }
 
   if (baseConfig.ssl) {
-    redisOpts.tls = {
-      checkServerIdentity: (
-        hostname: string,
-        cert: PeerCertificate,
-      ): Error | undefined => {
-        // Note, the cert chain will have been verified already. the role of this method is to
-        // validate that at least one of the SAN's (or subject) of the server's cert matches the provided hostname
-        if (baseConfig.validateHostname) {
-          return checkServerIdentity(hostname, cert);
-        }
-      },
-    };
+    redisOpts.tls = buildTlsOptions(baseConfig);
+    // ioredis ignores `tls` for the Sentinel-resolved master (connecting in
+    // plaintext) unless this flag is set.
+    if (redisOpts.sentinels) {
+      redisOpts.enableTLSForSentinelMode = true;
+    }
   }
 
   return redisOpts;