Skip to content

Nested ProxyJump chains silently stop at the first hop #796

@AltarBeastiful

Description

@AltarBeastiful

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions