-
Notifications
You must be signed in to change notification settings - Fork 169
Description
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 2Expected 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 connOn 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.