Skip to content

SSH tunnel client shared between credentials is destroyed when the first credential's pool expires #37911

Description

@cabrilo

Bug Description

When several credentials use an SSH tunnel with identical SSH settings (same host, port, user and key), n8n shares one ssh2 client between them via SSHClientsManager. That shared client is destroyed as soon as the first credential that opened it has its connection pool expire, even while other credentials are still actively using it. Those other credentials then hang on "Executing node…" and fail, and keep failing until their own pool is dropped or another credential rebuilds the client.

Root cause, from the current master:

  • packages/core/src/execution-engine/ssh-clients-manager.ts, getClient(): clients are cached by sha1(JSON.stringify(sshConfig)). On a cache hit the method only does existing.lastUsed = new Date() and returns the client; the caller's abortController is not registered. Only the first caller's AbortController is stored with the registration, and its abort listener calls cleanupClient(key), which runs registration.client.end().
  • packages/nodes-base/nodes/Postgres/transport/index.ts: every Postgres credential gets its own pool via ConnectionPoolManager and passes its own abortController to this.helpers.getSSHClient(credentials, abortController). The local TCP proxy calls sshClient.forwardOut(...) on whatever client it was handed.
  • packages/nodes-base/utils/connection-pool-manager.ts: pools have a 5 minute TTL (5 * 60 * 1000, cleanup every 60 s). When a pool is reaped, registration.abortController.abort() is called.

So: credential A's pool creates the shared client and becomes its "owner". Credential B attaches to the same client (cache hit, controller ignored). Five minutes after A's last use, A's pool is reaped → A's controller aborts → cleanupClientclient.end(). B's proxy still points at the ended client; its next forwardOut fails, B's execution hangs and then errors. B keeps failing on retries until its pool is dropped. A, or any other credential, opens a fresh client on its next run and works.

This is different from #12807 / #16054 (single-credential pool not being recreated). That fix is present in the version we run; the failure here only happens with two or more credentials sharing one SSH client.

To Reproduce

Setup: two Postgres credentials pointing at different databases, both with "SSH Tunnel" enabled and identical SSH host, port, user and private key.

  1. Run a workflow using credential A once.
  2. Within a minute, start running a workflow using credential B once per minute. It works.
  3. About five minutes after A's run (A's pool TTL), B hangs on "Executing node…" and then fails.
  4. Run B again: fails again.
  5. Run A: works (opens a new SSH connection). B works again only after its own pool has been reaped or n8n is restarted.

We confirmed the mechanism from the SSH server side (Amazon Linux 2023, OpenSSH 9.9). All times UTC, 2026-09-05. Exact times are from the SSH server's log; times marked ~ are the manual runs as triggered in the n8n UI, and for the failing runs the point is precisely that nothing reached the server:

 15:01:09  A runs -> new SSH login (shared client created, owned by A's pool)
~15:05:00  B runs through the same SSH connection -> works
 15:06:37  n8n sends SSH disconnect: A's pool TTL (5 min after A's last use)
           rounded up to the next 60 s cleanup tick. B had refreshed the SSH
           client's lastUsed ~90 s earlier, so this is not the SSH idle timeout.
~15:07:00  B runs -> hangs on "Executing node…"; NO connection attempt reaches the server
~15:08:30  B runs again -> fails; still no connection attempt
 15:09:08  A runs -> new SSH login -> works

Confirming test: after giving every credential a different SSH port (so the cache key differs), the server held 8 concurrent SSH sessions, one per credential, each expiring independently 5 minutes after its own last use, and no credential failed.

Expected behavior

A shared SSH client should not be ended while other dependents are still using it. Either:

  • register every caller's AbortController on a cache hit and only end the client when the last dependent has aborted (reference counting), or
  • when the client is ended for any reason, abort all dependents' controllers so their proxies and pools are torn down and recreated on next use, instead of leaving them pointing at a dead client.

Debug Info

# Debug info

## core

- n8nVersion: 2.37.7
- platform: docker (cloud)
- nodeJsVersion: 26.5.1
- nodeEnv: production
- database: sqlite
- executionMode: regular
- concurrency: 5
- license: enterprise (sandbox)
- consumerId: 00000000-0000-0000-0000-000000000000

## storage

- success: all
- error: all
- progress: false
- manual: true
- binaryMode: filesystem

## pruning

- enabled: true
- maxAge: 168 hours
- maxCount: 2500 executions

## client

- userAgent: mozilla/5.0 (macintosh; intel mac os x 10_15_7) applewebkit/537.36 (khtml, like gecko) chrome/152.0.0.0 safari/537.36
- isTouchDevice: false

Generated at: 2026-09-05T16:55:31.450Z

Operating System

n8n Cloud

n8n Version

2.37.7 (n8n Cloud)

Node.js Version

26.5.1

Database

SQLite (default)

Execution mode

main (default)

Hosting

n8n cloud

Workaround

Give each credential a distinct SSH port (the SSH server can listen on, or redirect, a small range of ports). Different port → different cache key → one SSH client per credential.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    status:in-linearIssue or PR is now in Linearstatus:team-assignedA team has been assigned the issue or PRteam:catsIssue is with the Cats team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions