Version: 2.22.0
Python: 3.13+ (Windows and Linux)
Component: connection.py — _open_tunnel()
Summary
When an SSH Host entry in ~/.ssh/config has a ProxyJump that itself requires another ProxyJump (i.e., a chain of ≥ 2 hops), asyncssh silently drops everything after the first jump host. The final target is reached by connecting to the first jump host directly rather than through the full chain. This produces an OSError (connection refused / semaphore timeout) instead of a successful connection.
SSH Config (minimal reproduction)
Host dev
HostName 3.64.218.166
User ubuntu
IdentityFile ~/.ssh/dev_key
Host jump
HostName 10.8.0.8
User ubuntu
IdentityFile ~/.ssh/twin_key
ProxyJump dev # <-- hop 2 requires hop 1
Host device-A
HostName 192.168.89.117
User ubuntu
IdentityFile ~/.ssh/device_key
ProxyJump jump # <-- hop 3 requires hop 2
Expected chain: local → dev (3.64.218.166) → jump (10.8.0.8) → device-A (192.168.89.117)
Reproducer
import asyncio
import asyncssh
async def main():
# This silently connects local → jump (direct), skipping dev
async with asyncssh.connect("device-A", known_hosts=None) as conn:
result = await conn.run("hostname")
print(result.stdout)
asyncio.run(main())
Actual behavior: OSError: [WinError 121] The semaphore timeout period has expired (or Connection refused) because jump (10.8.0.8) is unreachable without going through dev first.
Expected behavior: The full three-hop chain is established, matching ssh device-A from the terminal.
Root Cause
The bug is in _open_tunnel() in connection.py (≈ line 415).
_open_tunnel() is responsible for establishing the tunnel for a ProxyJump value. It receives a comma-separated string of jump hosts, iterates over them, and accumulates connections. The key variable is conn, which tracks the already-open tunnel to pass to the next hop as its own tunnel= argument:
# asyncssh/connection.py (line 415–447, asyncssh 2.22.0)
async def _open_tunnel(tunnels: object, options: _Options,
config: DefTuple[ConfigPaths]) -> Optional['SSHClientConnection']:
if isinstance(tunnels, str):
conn: Optional[SSHClientConnection] = None # ← starts as None
for tunnel in tunnels.split(','):
# ... parse username / host / port ...
last_conn = conn
conn = await connect(host, port, username=username,
passphrase=options.passphrase,
tunnel=conn, # ← None on first iteration
config=config)
conn.set_tunnel(last_conn)
return conn
On the first iteration conn is None, so connect(host, tunnel=None, ...) is called for the first jump host (e.g., jump).
Deep inside connect(), the _Options object is constructed and at line 7376 the tunnel for this hop is resolved:
# asyncssh/connection.py (line 7376)
self.tunnel = tunnel if tunnel != () else config.get('ProxyJump')
asyncssh uses () (empty tuple) as the sentinel meaning "caller did not specify a tunnel — please read from SSH config". When tunnel=None is passed explicitly, the SSH config ProxyJump for jump is never read. Since jump needs ProxyJump dev to be reachable, the connection to jump is attempted directly.
Impact
Any multi-hop ProxyJump chain of depth ≥ 2 is silently broken. Only the first ProxyJump directive is honored. This affects any asyncssh caller that relies on SSH configs with nested proxy chains, which is the standard pattern for accessing hosts behind multiple bastion layers (e.g., AWS VPC → internal jump → target device).
The native ssh CLI handles this correctly for the same ~/.ssh/config file.
Version: 2.22.0
Python: 3.13+ (Windows and Linux)
Component: connection.py —
_open_tunnel()Summary
When an SSH
Hostentry in~/.ssh/confighas aProxyJumpthat itself requires anotherProxyJump(i.e., a chain of ≥ 2 hops), asyncssh silently drops everything after the first jump host. The final target is reached by connecting to the first jump host directly rather than through the full chain. This produces anOSError(connection refused / semaphore timeout) instead of a successful connection.SSH Config (minimal reproduction)
Expected chain:
local → dev (3.64.218.166) → jump (10.8.0.8) → device-A (192.168.89.117)Reproducer
Actual behavior:
OSError: [WinError 121] The semaphore timeout period has expired(orConnection refused) becausejump(10.8.0.8) is unreachable without going throughdevfirst.Expected behavior: The full three-hop chain is established, matching
ssh device-Afrom the terminal.Root Cause
The bug is in
_open_tunnel()in connection.py (≈ line 415)._open_tunnel()is responsible for establishing the tunnel for aProxyJumpvalue. It receives a comma-separated string of jump hosts, iterates over them, and accumulates connections. The key variable isconn, which tracks the already-open tunnel to pass to the next hop as its owntunnel=argument:On the first iteration
connisNone, soconnect(host, tunnel=None, ...)is called for the first jump host (e.g.,jump).Deep inside
connect(), the_Optionsobject is constructed and at line 7376 the tunnel for this hop is resolved:asyncssh uses
()(empty tuple) as the sentinel meaning "caller did not specify a tunnel — please read from SSH config". Whentunnel=Noneis passed explicitly, the SSH configProxyJumpforjumpis never read. SincejumpneedsProxyJump devto be reachable, the connection tojumpis attempted directly.Impact
Any multi-hop
ProxyJumpchain of depth ≥ 2 is silently broken. Only the firstProxyJumpdirective is honored. This affects any asyncssh caller that relies on SSH configs with nested proxy chains, which is the standard pattern for accessing hosts behind multiple bastion layers (e.g., AWS VPC → internal jump → target device).The native
sshCLI handles this correctly for the same~/.ssh/configfile.