Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
fail-fast: false
matrix:
node-version: ["18", "20", "22"]
redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "8.4.0"]
redis-version: ["rs-7.4.0-v1", "8.0.2", "8.2", "custom-21183968220-debian-amd64"]
steps:
- uses: actions/checkout@v4
with:
Expand Down
2 changes: 1 addition & 1 deletion packages/bloom/lib/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import RedisBloomModules from '.';
export default TestUtils.createFromConfig({
dockerImageName: 'redislabs/client-libs-test',
dockerImageVersionArgument: 'redis-version',
defaultDockerVersion: '8.4.0'
defaultDockerVersion: 'custom-21183968220-debian-amd64'
});

export const GLOBAL = {
Expand Down
69 changes: 69 additions & 0 deletions packages/client/lib/client/tls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { strict as assert } from "node:assert";
import testUtils, { GLOBAL } from "../test-utils";
import { createClient } from "../..";

describe("TLS", () => {
describe("Basic TLS connection", () => {
testUtils.testWithTlsClient(
"should connect with valid certificates",
async (tlsClient, { tlsPort }) => {
// Verify valid TLS connection works
const pong = await tlsClient.ping();
assert.equal(pong, "PONG");

// Verify that connecting with wrong CA fails (proves TLS is enforced)
const clientWithWrongCA = createClient({
socket: {
port: tlsPort,
tls: true,
ca: "wrong-ca-certificate",
reconnectStrategy: false,
},
});
clientWithWrongCA.on("error", () => {});

await assert.rejects(clientWithWrongCA.connect());
},
{
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [6, 0],
},
);
});

describe("CN-based authentication", () => {
const CLIENT_CN = "node-redis-test-client";

testUtils.testWithTlsClient(
"should authenticate TLS client using certificate CN",
async (client) => {
// Verify we're connected as default user
const initialWhoami = await client.aclWhoAmI();
assert.equal(initialWhoami, "default");

// Set up ACL user matching the certificate CN
await client.aclSetUser(CLIENT_CN, [
"on",
">clientpass",
"allcommands",
"allkeys",
]);

// Disconnect the client
client.destroy();

// Reconnect the client
await client.connect();

// Verify the TLS client is authenticated as the CN user
const whoami = await client.aclWhoAmI();
assert.equal(whoami, CLIENT_CN);
},
{
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [8, 6],
tls: { clientCertCN: CLIENT_CN },
},
);
});
});
2 changes: 1 addition & 1 deletion packages/client/lib/sentinel/test-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export class SentinelFramework extends DockerBase {
this.#testUtils = TestUtils.createFromConfig({
dockerImageName: 'redislabs/client-libs-test',
dockerImageVersionArgument: 'redis-version',
defaultDockerVersion: '8.4.0'
defaultDockerVersion: 'custom-21183968220-debian-amd64'
});
this.#nodeMap = new Map<string, ArrayElement<Awaited<ReturnType<SentinelFramework['spawnRedisSentinelNodes']>>>>();
this.#sentinelMap = new Map<string, ArrayElement<Awaited<ReturnType<SentinelFramework['spawnRedisSentinelSentinels']>>>>();
Expand Down
2 changes: 1 addition & 1 deletion packages/client/lib/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import RedisBloomModules from '@redis/bloom';
const utils = TestUtils.createFromConfig({
dockerImageName: 'redislabs/client-libs-test',
dockerImageVersionArgument: 'redis-version',
defaultDockerVersion: '8.4.0'
defaultDockerVersion: 'custom-21183968220-debian-amd64'
});

export default utils;
Expand Down
2 changes: 1 addition & 1 deletion packages/entraid/lib/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { EntraidCredentialsProvider } from './entraid-credentials-provider';
export const testUtils = TestUtils.createFromConfig({
dockerImageName: 'redislabs/client-libs-test',
dockerImageVersionArgument: 'redis-version',
defaultDockerVersion: '8.4.0'
defaultDockerVersion: 'custom-21183968220-debian-amd64'
});

const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ?
Expand Down
2 changes: 1 addition & 1 deletion packages/json/lib/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import RedisJSON from '.';
export default TestUtils.createFromConfig({
dockerImageName: 'redislabs/client-libs-test',
dockerImageVersionArgument: 'redis-version',
defaultDockerVersion: '8.4.0'
defaultDockerVersion: 'custom-21183968220-debian-amd64'
});

export const GLOBAL = {
Expand Down
2 changes: 1 addition & 1 deletion packages/search/lib/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { RespVersions } from '@redis/client';
export default TestUtils.createFromConfig({
dockerImageName: 'redislabs/client-libs-test',
dockerImageVersionArgument: 'redis-version',
defaultDockerVersion: '8.4.0'
defaultDockerVersion: 'custom-21183968220-debian-amd64'
});

export const GLOBAL = {
Expand Down
226 changes: 226 additions & 0 deletions packages/test-utils/lib/dockers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,38 @@ export interface RedisServerDocker {
dockerId: string;
}

export interface TlsCertificates {
ca: Buffer;
cert: Buffer;
key: Buffer;
}

export interface TlsRedisServerDocker extends RedisServerDocker {
/** The TLS port */
tlsPort: number;
/** TLS certificates for client connection */
certs: TlsCertificates;
}

export interface TlsConfig {
/**
* If provided, enables client certificate CN-based authentication.
* Sets TLS_CLIENT_CNS env var and uses this CN for the client certificate.
* If not provided, uses the default 'client' certificate without CN-based auth.
*/
clientCertCN?: string;
}

/**
* Default certificate name used for client authentication
*/
const DEFAULT_CLIENT_CERT_NAME = "client";

/**
* Default path where TLS certificates are stored in the Docker container
*/
const DEFAULT_TLS_PATH = "/redis/work/tls";

export async function spawnRedisServerDocker(
options: RedisServerDockerOptions, serverArguments: Array<string>): Promise<RedisServerDocker> {
let port;
Expand Down Expand Up @@ -142,6 +174,200 @@ after(() => {
);
});

/**
* Reads a file from a Docker container directly into memory
* @param dockerId - The Docker container ID
* @param filePath - Path to the file inside the container
* @returns Buffer containing the file contents
*/
async function readFileFromContainer(
dockerId: string,
filePath: string,
): Promise<Buffer> {
const { stdout, stderr } = await execAsync("docker", [
"exec",
dockerId,
"cat",
filePath,
]);
if (stderr) {
throw new Error(`Failed to read ${filePath} from container: ${stderr}`);
}
return Buffer.from(stdout);
}

/**
* Loads TLS certificates from a running Docker container into memory
* @param dockerId - The Docker container ID
* @param certName - The certificate name (used for client cert/key naming)
* @returns TlsCertificates object with ca, cert, and key buffers
*/
async function loadTlsCertificates(
dockerId: string,
certName: string,
): Promise<TlsCertificates> {
const [ca, cert, key] = await Promise.all([
readFileFromContainer(dockerId, `${DEFAULT_TLS_PATH}/ca.crt`),
readFileFromContainer(dockerId, `${DEFAULT_TLS_PATH}/${certName}.crt`),
readFileFromContainer(dockerId, `${DEFAULT_TLS_PATH}/${certName}.key`),
]);

return { ca, cert, key };
}

/**
* Waits for TLS certificates to be available in the container
* @param dockerId - The Docker container ID
* @param certName - The certificate name
* @param maxWaitMs - Maximum time to wait in milliseconds
*/
async function waitForTlsCertificates(
dockerId: string,
certName: string,
maxWaitMs: number = 30000,
): Promise<void> {
const startTime = Date.now();
const certFiles = [
`${DEFAULT_TLS_PATH}/ca.crt`,
`${DEFAULT_TLS_PATH}/${certName}.crt`,
`${DEFAULT_TLS_PATH}/${certName}.key`,
];

while (Date.now() - startTime < maxWaitMs) {
try {
await Promise.all(
certFiles.map(file =>
execAsync("docker", ["exec", dockerId, "test", "-f", file]),
),
);
// All files exist
return;
} catch {
// Not all files exist yet, wait and retry
await setTimeout(100);
}
}

throw new Error(`TLS certificates not available after ${maxWaitMs}ms`);
}

/**
* Spawns a TLS-enabled Redis server Docker container with both TLS and non-TLS ports
*/
export async function spawnTlsRedisServerDocker(
options: RedisServerDockerOptions,
serverArguments: Array<string> = [],
tlsConfig?: TlsConfig,
): Promise<TlsRedisServerDocker> {
const port = (await portIterator.next()).value;
const tlsPort = (await portIterator.next()).value;
const clientCertCN = tlsConfig?.clientCertCN;

// Use provided CN for cert name, otherwise use default 'client' cert
const certName = clientCertCN ?? DEFAULT_CLIENT_CERT_NAME;

const dockerArgs = [
"run",
"--init",
"-e",
"TLS_ENABLED=yes",
"-e",
"NODES=1",
"-e",
`PORT=${port}`,
"-e",
`TLS_PORT=${tlsPort}`,
];

// Only add client CN auth if specified
if (clientCertCN) {
dockerArgs.push("-e", `TLS_CLIENT_CNS=${clientCertCN}`);
}

dockerArgs.push(
"-d",
"--network",
"host",
`${options.image}:${options.version}`,
);

for (const arg of serverArguments) {
dockerArgs.push(arg);
}

console.log(
`[Docker] Spawning TLS Redis container - Image: ${options.image}:${options.version}, Port: ${port}, TLS Port: ${tlsPort}, CertName: ${certName}`,
);

const { stdout, stderr } = await execAsync("docker", dockerArgs);

if (!stdout) {
throw new Error(`docker run error - ${stderr}`);
}

const dockerId = stdout.trim();

// Wait for both ports to be available
while ((await isPortAvailable(port)) || (await isPortAvailable(tlsPort))) {
await setTimeout(50);
}

// Wait for TLS certificates to be generated
await waitForTlsCertificates(dockerId, certName);

// Load certificates directly into memory from the container
const certs = await loadTlsCertificates(dockerId, certName);

return {
port,
tlsPort,
dockerId,
certs,
};
}

const RUNNING_TLS_SERVERS = new Map<
string,
ReturnType<typeof spawnTlsRedisServerDocker>
>();

/**
* Spawns a TLS-enabled Redis server, reusing existing containers when possible
*/
export function spawnTlsRedisServer(
dockerConfig: RedisServerDockerOptions,
serverArguments: Array<string>,
tlsConfig?: TlsConfig,
): Promise<TlsRedisServerDocker> {
const clientCertCN = tlsConfig?.clientCertCN;
const cacheKey = JSON.stringify({ serverArguments, clientCertCN });

const runningServer = RUNNING_TLS_SERVERS.get(cacheKey);
if (runningServer) {
return runningServer;
}

const dockerPromise = spawnTlsRedisServerDocker(
dockerConfig,
serverArguments,
tlsConfig,
);
RUNNING_TLS_SERVERS.set(cacheKey, dockerPromise);
return dockerPromise;
}

/**
* Cleanup function for TLS servers - removes containers
*/
after(() => {
return Promise.all(
[...RUNNING_TLS_SERVERS.values()].map(async (dockerPromise) => {
const docker = await dockerPromise;
await dockerRemove(docker.dockerId);
}),
);
});

export type RedisClusterDockersConfig = RedisServerDockerOptions & {
numberOfMasters?: number;
numberOfReplicas?: number;
Expand Down
Loading
Loading