diff --git a/packages/nodes-base/credentials/Redis.credentials.ts b/packages/nodes-base/credentials/Redis.credentials.ts index 9d9bbab394..6525f5750a 100644 --- a/packages/nodes-base/credentials/Redis.credentials.ts +++ b/packages/nodes-base/credentials/Redis.credentials.ts @@ -2,11 +2,8 @@ import type { ICredentialType, INodeProperties } from 'n8n-workflow'; export class Redis implements ICredentialType { name = 'redis'; - displayName = 'Redis'; - documentationUrl = 'redis'; - properties: INodeProperties[] = [ { displayName: 'Password', @@ -48,5 +45,18 @@ export class Redis implements ICredentialType { type: 'boolean', default: false, }, + { + displayName: 'Disable TLS Verification (insecure)', + name: 'disableTlsVerification', + type: 'boolean', + displayOptions: { + show: { + ssl: [true], + }, + }, + default: false, + description: + 'Whether to disable TLS certificate verification. Enable this to use self-signed certificates. WARNING: This makes the connection less secure.', + }, ]; } diff --git a/packages/nodes-base/nodes/Redis/Redis.node.ts b/packages/nodes-base/nodes/Redis/Redis.node.ts index b9384dc7b3..1e3580721d 100644 --- a/packages/nodes-base/nodes/Redis/Redis.node.ts +++ b/packages/nodes-base/nodes/Redis/Redis.node.ts @@ -515,147 +515,159 @@ export class Redis implements INodeType { const credentials = await this.getCredentials('redis'); const client = setupRedisClient(credentials); - await client.connect(); - await client.ping(); - const operation = this.getNodeParameter('operation', 0); - const returnItems: INodeExecutionData[] = []; + try { + await client.connect(); + await client.ping(); - if (operation === 'info') { - try { - const result = await client.info(); - returnItems.push({ json: convertInfoToObject(result) }); - } catch (error) { - if (this.continueOnFail()) { - returnItems.push({ - json: { - error: error.message, - }, - }); - } else { - await client.quit(); - throw new NodeOperationError(this.getNode(), error); - } - } - } else if ( - ['delete', 'get', 'keys', 'set', 'incr', 'publish', 'push', 'pop'].includes(operation) - ) { - const items = this.getInputData(); + const operation = this.getNodeParameter('operation', 0); + const returnItems: INodeExecutionData[] = []; - let item: INodeExecutionData; - for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + if (operation === 'info') { try { - item = { json: {}, pairedItem: { item: itemIndex } }; - - if (operation === 'delete') { - const keyDelete = this.getNodeParameter('key', itemIndex) as string; - - await client.del(keyDelete); - returnItems.push(items[itemIndex]); - } else if (operation === 'get') { - const propertyName = this.getNodeParameter('propertyName', itemIndex) as string; - const keyGet = this.getNodeParameter('key', itemIndex) as string; - const keyType = this.getNodeParameter('keyType', itemIndex) as string; - - const value = (await getValue(client, keyGet, keyType)) ?? null; - - const options = this.getNodeParameter('options', itemIndex, {}); - - if (options.dotNotation === false) { - item.json[propertyName] = value; - } else { - set(item.json, propertyName, value); - } - - returnItems.push(item); - } else if (operation === 'keys') { - const keyPattern = this.getNodeParameter('keyPattern', itemIndex) as string; - const getValues = this.getNodeParameter('getValues', itemIndex, true) as boolean; - - const keys = await client.keys(keyPattern); - - if (!getValues) { - returnItems.push({ json: { keys } }); - continue; - } - - for (const keyName of keys) { - item.json[keyName] = await getValue(client, keyName); - } - returnItems.push(item); - } else if (operation === 'set') { - const keySet = this.getNodeParameter('key', itemIndex) as string; - const value = this.getNodeParameter('value', itemIndex) as string; - const keyType = this.getNodeParameter('keyType', itemIndex) as string; - const valueIsJSON = this.getNodeParameter('valueIsJSON', itemIndex, true) as boolean; - const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; - const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; - - await setValue.call(this, client, keySet, value, expire, ttl, keyType, valueIsJSON); - returnItems.push(items[itemIndex]); - } else if (operation === 'incr') { - const keyIncr = this.getNodeParameter('key', itemIndex) as string; - const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; - const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; - const incrementVal = await client.incr(keyIncr); - if (expire && ttl > 0) { - await client.expire(keyIncr, ttl); - } - returnItems.push({ json: { [keyIncr]: incrementVal } }); - } else if (operation === 'publish') { - const channel = this.getNodeParameter('channel', itemIndex) as string; - const messageData = this.getNodeParameter('messageData', itemIndex) as string; - await client.publish(channel, messageData); - returnItems.push(items[itemIndex]); - } else if (operation === 'push') { - const redisList = this.getNodeParameter('list', itemIndex) as string; - const messageData = this.getNodeParameter('messageData', itemIndex) as string; - const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; - await client[tail ? 'rPush' : 'lPush'](redisList, messageData); - returnItems.push(items[itemIndex]); - } else if (operation === 'pop') { - const redisList = this.getNodeParameter('list', itemIndex) as string; - const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; - const propertyName = this.getNodeParameter( - 'propertyName', - itemIndex, - 'propertyName', - ) as string; - - const value = await client[tail ? 'rPop' : 'lPop'](redisList); - - let outputValue; - try { - outputValue = value && JSON.parse(value); - } catch { - outputValue = value; - } - const options = this.getNodeParameter('options', itemIndex, {}); - if (options.dotNotation === false) { - item.json[propertyName] = outputValue; - } else { - set(item.json, propertyName, outputValue); - } - returnItems.push(item); - } + const result = await client.info(); + returnItems.push({ json: convertInfoToObject(result) }); } catch (error) { if (this.continueOnFail()) { returnItems.push({ json: { error: error.message, }, - pairedItem: { - item: itemIndex, - }, }); - continue; + } else { + throw new NodeOperationError(this.getNode(), error); } - await client.quit(); - throw new NodeOperationError(this.getNode(), error, { itemIndex }); + } + } else if ( + ['delete', 'get', 'keys', 'set', 'incr', 'publish', 'push', 'pop'].includes(operation) + ) { + const items = this.getInputData(); + + let item: INodeExecutionData; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + try { + item = { json: {}, pairedItem: { item: itemIndex } }; + + if (operation === 'delete') { + const keyDelete = this.getNodeParameter('key', itemIndex) as string; + + await client.del(keyDelete); + returnItems.push(items[itemIndex]); + } else if (operation === 'get') { + const propertyName = this.getNodeParameter('propertyName', itemIndex) as string; + const keyGet = this.getNodeParameter('key', itemIndex) as string; + const keyType = this.getNodeParameter('keyType', itemIndex) as string; + + const value = (await getValue(client, keyGet, keyType)) ?? null; + + const options = this.getNodeParameter('options', itemIndex, {}); + + if (options.dotNotation === false) { + item.json[propertyName] = value; + } else { + set(item.json, propertyName, value); + } + + returnItems.push(item); + } else if (operation === 'keys') { + const keyPattern = this.getNodeParameter('keyPattern', itemIndex) as string; + const getValues = this.getNodeParameter('getValues', itemIndex, true) as boolean; + + const keys = await client.keys(keyPattern); + + if (!getValues) { + returnItems.push({ json: { keys } }); + continue; + } + + for (const keyName of keys) { + item.json[keyName] = await getValue(client, keyName); + } + returnItems.push(item); + } else if (operation === 'set') { + const keySet = this.getNodeParameter('key', itemIndex) as string; + const value = this.getNodeParameter('value', itemIndex) as string; + const keyType = this.getNodeParameter('keyType', itemIndex) as string; + const valueIsJSON = this.getNodeParameter('valueIsJSON', itemIndex, true) as boolean; + const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; + const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; + + await setValue.call(this, client, keySet, value, expire, ttl, keyType, valueIsJSON); + returnItems.push(items[itemIndex]); + } else if (operation === 'incr') { + const keyIncr = this.getNodeParameter('key', itemIndex) as string; + const expire = this.getNodeParameter('expire', itemIndex, false) as boolean; + const ttl = this.getNodeParameter('ttl', itemIndex, -1) as number; + const incrementVal = await client.incr(keyIncr); + if (expire && ttl > 0) { + await client.expire(keyIncr, ttl); + } + returnItems.push({ json: { [keyIncr]: incrementVal } }); + } else if (operation === 'publish') { + const channel = this.getNodeParameter('channel', itemIndex) as string; + const messageData = this.getNodeParameter('messageData', itemIndex) as string; + await client.publish(channel, messageData); + returnItems.push(items[itemIndex]); + } else if (operation === 'push') { + const redisList = this.getNodeParameter('list', itemIndex) as string; + const messageData = this.getNodeParameter('messageData', itemIndex) as string; + const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; + await client[tail ? 'rPush' : 'lPush'](redisList, messageData); + returnItems.push(items[itemIndex]); + } else if (operation === 'pop') { + const redisList = this.getNodeParameter('list', itemIndex) as string; + const tail = this.getNodeParameter('tail', itemIndex, false) as boolean; + const propertyName = this.getNodeParameter( + 'propertyName', + itemIndex, + 'propertyName', + ) as string; + + const value = await client[tail ? 'rPop' : 'lPop'](redisList); + + let outputValue; + try { + outputValue = value && JSON.parse(value); + } catch { + outputValue = value; + } + const options = this.getNodeParameter('options', itemIndex, {}); + if (options.dotNotation === false) { + item.json[propertyName] = outputValue; + } else { + set(item.json, propertyName, outputValue); + } + returnItems.push(item); + } + } catch (error) { + if (this.continueOnFail()) { + returnItems.push({ + json: { + error: error.message, + }, + pairedItem: { + item: itemIndex, + }, + }); + continue; + } + throw new NodeOperationError(this.getNode(), error, { itemIndex }); + } + } + } + return [returnItems]; + } finally { + // Ensure the Redis client is always closed to prevent leaked connections + try { + await client.quit(); + } catch { + // If quit fails, forcefully disconnect + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors in cleanup } } } - await client.quit(); - return [returnItems]; } } diff --git a/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts b/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts index c676c27c12..4ea2c04d0d 100644 --- a/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts +++ b/packages/nodes-base/nodes/Redis/__tests__/Redis.node.test.ts @@ -37,6 +37,8 @@ describe('Redis Node', () => { host: 'redis.domain', port: 1234, tls: false, + connectTimeout: 10000, + reconnectStrategy: undefined, }, }); }); @@ -54,6 +56,69 @@ describe('Redis Node', () => { host: 'redis.domain', port: 1234, tls: true, + connectTimeout: 10000, + reconnectStrategy: undefined, + }, + }); + }); + + it('should configure TLS with verification disabled for self-signed certificates', () => { + setupRedisClient({ + host: 'redis.domain', + port: 1234, + database: 0, + ssl: true, + disableTlsVerification: true, + }); + expect(createClient).toHaveBeenCalledWith({ + database: 0, + socket: { + host: 'redis.domain', + port: 1234, + tls: true, + rejectUnauthorized: false, + connectTimeout: 10000, + reconnectStrategy: undefined, + }, + }); + }); + + it('should not set rejectUnauthorized when TLS verification is enabled', () => { + setupRedisClient({ + host: 'redis.domain', + port: 1234, + database: 0, + ssl: true, + disableTlsVerification: false, + }); + expect(createClient).toHaveBeenCalledWith({ + database: 0, + socket: { + host: 'redis.domain', + port: 1234, + tls: true, + connectTimeout: 10000, + reconnectStrategy: undefined, + }, + }); + }); + + it('should not set rejectUnauthorized when SSL is disabled', () => { + setupRedisClient({ + host: 'redis.domain', + port: 1234, + database: 0, + ssl: false, + disableTlsVerification: true, + }); + expect(createClient).toHaveBeenCalledWith({ + database: 0, + socket: { + host: 'redis.domain', + port: 1234, + tls: false, + connectTimeout: 10000, + reconnectStrategy: undefined, }, }); }); @@ -74,6 +139,33 @@ describe('Redis Node', () => { host: 'redis.domain', port: 1234, tls: false, + connectTimeout: 10000, + reconnectStrategy: undefined, + }, + }); + }); + + it('should configure TLS with disabled verification and auth', () => { + setupRedisClient({ + host: 'redis.domain', + port: 1234, + database: 0, + ssl: true, + disableTlsVerification: true, + user: 'test_user', + password: 'test_password', + }); + expect(createClient).toHaveBeenCalledWith({ + database: 0, + username: 'test_user', + password: 'test_password', + socket: { + host: 'redis.domain', + port: 1234, + tls: true, + rejectUnauthorized: false, + connectTimeout: 10000, + reconnectStrategy: undefined, }, }); }); @@ -95,10 +187,14 @@ describe('Redis Node', () => { host: 'localhost', port: 6379, tls: false, + connectTimeout: 10000, + reconnectStrategy: false, }, database: 0, username: 'username', password: 'password', + disableOfflineQueue: true, + enableOfflineQueue: false, }; it('should return success when connection is established', async () => { @@ -126,6 +222,44 @@ describe('Redis Node', () => { expect(mockClient.connect).toHaveBeenCalled(); expect(mockClient.ping).not.toHaveBeenCalled(); }); + + it('should return success when connection is established with disabled TLS verification', async () => { + const credentialsWithTls = mock({ + data: { + host: 'localhost', + port: 6379, + ssl: true, + disableTlsVerification: true, + user: 'username', + password: 'password', + database: 0, + }, + }); + + const result = await redisConnectionTest.call(thisArg, credentialsWithTls); + + expect(result).toEqual({ + status: 'OK', + message: 'Connection successful!', + }); + expect(createClient).toHaveBeenCalledWith({ + socket: { + host: 'localhost', + port: 6379, + tls: true, + rejectUnauthorized: false, + connectTimeout: 10000, + reconnectStrategy: false, + }, + database: 0, + username: 'username', + password: 'password', + disableOfflineQueue: true, + enableOfflineQueue: false, + }); + expect(mockClient.connect).toHaveBeenCalled(); + expect(mockClient.ping).toHaveBeenCalled(); + }); }); describe('operations', () => { diff --git a/packages/nodes-base/nodes/Redis/types.ts b/packages/nodes-base/nodes/Redis/types.ts index 63e873acce..ca1f2f5298 100644 --- a/packages/nodes-base/nodes/Redis/types.ts +++ b/packages/nodes-base/nodes/Redis/types.ts @@ -6,6 +6,7 @@ export type RedisCredential = { host: string; port: number; ssl?: boolean; + disableTlsVerification?: boolean; database: number; user?: string; password?: string; diff --git a/packages/nodes-base/nodes/Redis/utils.ts b/packages/nodes-base/nodes/Redis/utils.ts index 4364bab892..26c51a8e7d 100644 --- a/packages/nodes-base/nodes/Redis/utils.ts +++ b/packages/nodes-base/nodes/Redis/utils.ts @@ -10,16 +10,31 @@ import { createClient } from 'redis'; import type { RedisCredential, RedisClient } from './types'; -export function setupRedisClient(credentials: RedisCredential): RedisClient { +export function setupRedisClient(credentials: RedisCredential, isTest = false): RedisClient { + const socketConfig: any = { + host: credentials.host, + port: credentials.port, + tls: credentials.ssl === true, + connectTimeout: 10000, + // Disable reconnection for tests to prevent hanging + reconnectStrategy: isTest ? false : undefined, + }; + + // If SSL is enabled and TLS verification should be disabled + if (credentials.ssl === true && credentials.disableTlsVerification === true) { + socketConfig.rejectUnauthorized = false; + } + return createClient({ - socket: { - host: credentials.host, - port: credentials.port, - tls: credentials.ssl === true, - }, + socket: socketConfig, database: credentials.database, - username: credentials.user || undefined, - password: credentials.password || undefined, + username: credentials.user ?? undefined, + password: credentials.password ?? undefined, + // Disable automatic error retry for tests + ...(isTest && { + disableOfflineQueue: true, + enableOfflineQueue: false, + }), }); } @@ -28,26 +43,68 @@ export async function redisConnectionTest( credential: ICredentialsDecrypted, ): Promise { const credentials = credential.data as RedisCredential; + let client: RedisClient | undefined; try { - const client = setupRedisClient(credentials); - await client.connect(); + client = setupRedisClient(credentials, true); + + // Add error event handler to catch connection errors + const errorPromise = new Promise((_, reject) => { + client!.on('error', (err) => { + reject(err); + }); + }); + + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Connection timeout: Unable to connect to Redis server')); + }, 10000); // 10 seconds timeout + }); + + // Race between connecting and error/timeout + await Promise.race([client.connect(), errorPromise, timeoutPromise]); + await client.ping(); return { status: 'OK', message: 'Connection successful!', }; } catch (error) { + // Handle specific error types for better user feedback + let errorMessage = error.message; + if (error.code === 'ECONNRESET') { + errorMessage = + 'Connection reset: The Redis server rejected the connection. This often happens when trying to connect without SSL to an SSL-only server.'; + } else if (error.code === 'ECONNREFUSED') { + errorMessage = + 'Connection refused: Unable to connect to the Redis server. Please check the host and port.'; + } + return { status: 'Error', - message: error.message, + message: errorMessage, }; + } finally { + // Ensure the Redis client is always closed to prevent leaked connections + if (client) { + try { + await client.quit(); + } catch { + // If quit fails, forcefully disconnect + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors in cleanup + } + } + } } } /** Parses the given value in a number if it is one else returns a string */ function getParsedValue(value: string): string | number { - if (value.match(/^[\d\.]+$/) === null) { + if (value.match(/^[\d.]+$/) === null) { // Is a string return value; } else {