From 0fbb180e3ddf499d1ada60383fbf5a94b7bd5612 Mon Sep 17 00:00:00 2001 From: Lucas Hartmann Date: Thu, 1 Oct 2026 12:23:10 -0300 Subject: [PATCH] fix(Postgres Node): Fail fast when SSH port forwarding fails When the shared SSH client has been closed, `forwardOut` throws synchronously inside the TCP proxy's `connection` listener. The throw escaped to the global uncaughtException handler, the accepted socket was never piped or destroyed, and the pool was never aborted, so queries hung until the workflow timeout. The error-callback path had the same leak. Catch the throw, destroy the accepted socket and abort the pool in both paths, so the query rejects immediately and the next execution builds a fresh pool and SSH client. Co-Authored-By: Claude Opus 5.5 --- .../test/transport/ssh-tunnel.test.ts | 69 +++++++++++++++++++ .../nodes/Postgres/transport/index.ts | 43 ++++++++---- 2 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 packages/nodes-base/nodes/Postgres/test/transport/ssh-tunnel.test.ts diff --git a/packages/nodes-base/nodes/Postgres/test/transport/ssh-tunnel.test.ts b/packages/nodes-base/nodes/Postgres/test/transport/ssh-tunnel.test.ts new file mode 100644 index 00000000000..f0dacc4dfcf --- /dev/null +++ b/packages/nodes-base/nodes/Postgres/test/transport/ssh-tunnel.test.ts @@ -0,0 +1,69 @@ +import type { IExecuteFunctions, Logger } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import { ConnectionPoolManager } from '@utils/connection-pool-manager'; + +import { configurePostgres } from '../../transport'; +import type { PostgresNodeCredentials } from '../../v2/helpers/interfaces'; + +type SSHClient = Awaited>; + +const credentials = (): PostgresNodeCredentials => ({ + host: 'db.example.com', + port: 5432, + database: 'postgres', + user: 'postgres', + password: 'password', + maxConnections: 10, + ssl: 'disable', + sshTunnel: true, + sshAuthenticateWith: 'password', + sshHost: 'bastion.example.com', + sshPort: 22, + sshUser: 'tunnel', + sshPassword: 'password', +}); + +const createContext = (sshClient: SSHClient) => + mock({ + logger: mock(), + helpers: mock({ + getSSHClient: vi.fn().mockResolvedValue(sshClient), + updateLastUsed: vi.fn(), + }), + }); + +describe('configurePostgres with an SSH tunnel', () => { + afterEach(() => { + ConnectionPoolManager.getInstance(mock()).purgeConnections(); + }); + + it('should reject the query and drop the pool when forwardOut throws', async () => { + const sshClient = mock(); + sshClient.forwardOut.mockImplementation(() => { + throw new Error('Not connected'); + }); + const context = createContext(sshClient); + + const { db } = await configurePostgres.call(context, credentials(), { nodeVersion: 2.6 }); + + await expect(db.one('SELECT 1')).rejects.toThrow(); + + // The pool was dropped, so the next call builds a new one with a fresh SSH client + await configurePostgres.call(context, credentials(), { nodeVersion: 2.6 }); + expect(context.helpers.getSSHClient).toHaveBeenCalledTimes(2); + }); + + it('should reject the query when the SSH server refuses the forward', async () => { + const sshClient = mock(); + sshClient.forwardOut.mockImplementation((_srcIP, _srcPort, _dstIP, _dstPort, callback) => { + callback?.(new Error('(SSH) Channel open failure: open failed'), undefined as never); + return sshClient; + }); + const context = createContext(sshClient); + + const { db } = await configurePostgres.call(context, credentials(), { nodeVersion: 2.6 }); + + await expect(db.one('SELECT 1')).rejects.toThrow(); + }); +}); diff --git a/packages/nodes-base/nodes/Postgres/transport/index.ts b/packages/nodes-base/nodes/Postgres/transport/index.ts index 7dd2b504aad..28295eab17c 100644 --- a/packages/nodes-base/nodes/Postgres/transport/index.ts +++ b/packages/nodes-base/nodes/Postgres/transport/index.ts @@ -160,21 +160,34 @@ export async function configurePostgres( }); proxy.on('connection', (localSocket) => { - sshClient.forwardOut( - LOCALHOST, - localSocket.remotePort!, - credentials.host, - credentials.port, - (error, clientChannel) => { - if (error) { - this.logger.error('SSH Client: Port forwarding encountered an error', { error }); - abortController.abort(); - } else { - localSocket.pipe(clientChannel); - clientChannel.pipe(localSocket); - } - }, - ); + // Closing the proxy server does not close sockets it already accepted, so + // destroy this one too. Otherwise pg waits on it forever. + const onForwardError = (error: unknown) => { + this.logger.error('SSH Client: Port forwarding encountered an error', { error }); + localSocket.destroy(); + abortController.abort(); + }; + + try { + sshClient.forwardOut( + LOCALHOST, + localSocket.remotePort!, + credentials.host, + credentials.port, + (error, clientChannel) => { + if (error) { + onForwardError(error); + } else { + localSocket.pipe(clientChannel); + clientChannel.pipe(localSocket); + } + }, + ); + } catch (error) { + // `forwardOut` throws synchronously once the SSH client has been closed, + // e.g. when another pool sharing it was cleaned up. + onForwardError(error); + } }); const db = pgp({