Skip to content

Implement OpenSSH-style ProxyJump transport - #1165

Open
KaliszAd wants to merge 15 commits into
mwiede:masterfrom
KaliszAd:master
Open

KaliszAd wants to merge 15 commits into
mwiede:masterfrom
KaliszAd:master

Conversation

@KaliszAd

@KaliszAd KaliszAd commented Sep 25, 2026 •

Copy link
Copy Markdown

ProxyJump: reach hosts behind a bastion the way ssh -J does

This adds OpenSSH-compatible ProxyJump to JSch. The typical setup it is
written for: an internal network where only the bastion's SSH port is
reachable, and everything behind it is a pinhole away. With this change a
JSch application reaches those hosts with the same ssh_config an
administrator already uses with OpenSSH, and no code changes beyond
loading that config.

Host bastion
  HostName bastion.example.org
  User jump
  IdentityFile ~/.ssh/bastion_ed25519

Host db-1
  HostName 10.0.0.5
  User dba
  ProxyJump bastion
JSch jsch = new JSch();
jsch.setConfigRepository(OpenSSHConfig.parseFile("~/.ssh/config"));
Session session = jsch.getSession("db-1");   // routed through bastion
session.connect();

What is supported

  • ProxyJump host, user@host:port, comma-separated chains
    (ProxyJump a,b,c), the ssh://user@host:port form including bracketed
    IPv6, and ProxyJump none.
  • Each hop is an ordinary Session built from its own Host block, so a
    hop's HostName, User, IdentityFile, UserKnownHostsFile,
    StrictHostKeyChecking and ConnectTimeout all apply. As with ssh -J,
    only the first hop honours a ProxyJump of its own; later hops are
    reached through the previous one.
  • Each hop carries the next connection in a direct-tcpip channel with a
    2 MiB window. Data passes through a bounded buffer owned by the proxy,
    not through a PipedInputStream, so read timeouts, EOF and back-pressure
    behave like a socket. SFTP through a hop runs at wire speed on a LAN.

Security properties

  • Hop host keys are verified with the hop's own configuration. Host-key
    constraints an application sets explicitly on the target session (a
    HostKeyRepository, a stricter StrictHostKeyChecking, or
    server_host_key) are also applied to the hops, and a hop's own
    StrictHostKeyChecking is never weakened by the target's.
  • Hops never receive the target's UserInfo or password. Prompts from a
    hop, for a bastion password or an unknown host key, go to a separate
    Session.setProxyJumpUserInfo(...) if the application sets one, and every
    prompt names the hop it is for. A stored password therefore cannot reach
    a bastion by accident.
  • A password embedded in an ssh://user:password@host hop is rejected, and
    it is redacted in the error message.
  • Configuration loops (a jumps via b, b via a) are detected by
    walking the config before any socket is opened, and the message names the
    loop. No thread-local or static state is involved.
  • The whole chain shares one connect deadline, the largest ConnectTimeout
    on the path, so a silent hop cannot stretch the connect to the sum of all
    timeouts.

Programmatic use and hop sharing

  • ProxyJump.through(Session hop) tunnels a session through a hop the
    application has connected itself; the hop stays open when the tunnelled
    session closes.
  • ProxyJump.share(factory) returns a SharedHop that behaves like
    OpenSSH's ControlMaster without ControlPersist: the first session to
    connect opens the bastion, later sessions reuse it, the last one to
    disconnect closes it, and a later session opens a fresh one. close()
    shuts it at once. It is safe to use from several threads; the hop is
    connected outside the lock so close() never waits on a slow bastion.
ProxyJump.SharedHop bastion = ProxyJump.share(() -> jsch.getSession("bastion"));
Session a = jsch.getSession("dba", "10.0.0.5", 22);
a.setProxy(bastion.proxy());
Session b = jsch.getSession("dba", "10.0.0.6", 22);
b.setProxy(bastion.proxy());

Testing

  • Unit tests cover parsing, loop detection, host-key policy propagation,
    credential isolation, the connect budget, the transport buffer
    (timeouts, EOF ordering, growth, a blocked writer released by close) and
    the shared hop's reference counting under concurrent use.
  • ProxyJumpIT runs against the repository's sshd image with
    testcontainers: one- and two-hop chains from config, the shared hop
    opening and closing with its first and last session, and through().
  • Beyond the repository: chains through OpenSSH 10.0 sshds, a Proxmox host
    as bastion in front of OpenSSH for Windows 9.5, a Windows host as a
    password-authenticated bastion, three-hop chains, 64 MB SFTP transfers
    through a hop, 20 concurrent sessions through one bastion, and soak runs
    of several hundred connects with no leaked threads, sockets or heap.
  • The buffer and shared-hop concurrency had an independent adversarial
    review; its findings are folded into the last commits.

Notes for reviewers

  • The earlier ThreadLocal for loop detection is gone, which addresses
    the concerns raised in the review threads.
  • OpenSSH gives each hop its own ConnectTimeout; this implementation
    bounds the whole chain by the largest one. The Javadoc says so.
  • Reconnecting the same Session object after disconnect() is not
    supported by JSch today; SharedHop therefore creates a fresh hop
    session when it reopens.
  • Include and Match support for ssh_config is in a separate PR.
  • Remaining SonarCloud hints ask for Java 16 instanceof patterns; the
    main code targets Java 8.

Development

  • The initial implementation was heavily assisted by OpenAI ChatGPT 6.0 Sol with medium effort.
  • Work continued using Anthropic Claude Opus 5.5 with medium effort and Fable 5.1 with high effort.
  • Second opinions used mostly ChatGPT 6.0 Sol with high effort.

Followup PRs

  • Support configuration Include
  • Support tokens such as %d for the home directory in paths in the configuration


/** Carries an SSH connection through one or more direct-tcpip channels. */
public final class ProxyJump implements Proxy {
private static final ThreadLocal<Set<String>> CONNECTING = ThreadLocal.withInitial(HashSet::new);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't using a ThreadLocal mean that a single thread can't open multiple independent JSch sessions that happen to go thru the same proxy host?
Or am I misunderstanding something?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not super deep into the mechanics of Java, this is all ChatGPT 6.0-Sol/ medium assisted to scratch my itch and be able to use the library with epiccastle/bbssh or epiccastle/clojuressh for some configuration management work.

My understanding is the ThreadLocal tracks the destination alias target.org_host, not the jump host. Each connect() removes its entry in finally, so one thread can connect multiple sessions sequentially and keep them all open, including sessions using the same jump host.

The edge case is a nested, re-entrant connect() to the same destination alias before the first call returns. It's currently rejected as a possible ProxyJump cycle, even if the nested connection is independent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be aware that with using ThreadLocals you potentially leak memory, especially when you don't cleanup correctly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think using a ThreadLocal is the correct mechanism to use for this.
As @theit points out it can be a memory leak.
Additionally, even if org_host tracks destination hosts, I think it would also prevent the same thread from opening multiple independent sessions to the same destination host.

Adam Kalisz added 9 commits September 25, 2026 03:40
The chain now shares one connect deadline: the largest ConnectTimeout of
the target and its hops, instead of the full timeout per hop. A silent
hop with ConnectTimeout no longer hangs a target connect(0).

The timed hop stream waits on the pipe monitor and wakes a writer after
reading. PipedInputStream only wakes a writer when a read finds the pipe
empty, so downloads with any read timeout set were limited to one 32 KiB
pipe per second. Hop channels use a 2 MiB window like OpenSSH.

An explicit StrictHostKeyChecking on the target is only carried to a hop
when it is stricter, so "no" never disables a hop's own checking. Hops
inherit the target's daemon flag, thread factory and logger.

ProxyJump parsing follows OpenSSH's URI rules, rejects ssh:// passwords
and redacts user:password values in errors.
A static ThreadLocal tracked the aliases being connected on the current
thread. Besides the class-loader and thread-pool leak risk of static
ThreadLocals, it rejected an independent connect to the same alias
started on the connecting thread, e.g. from a UserInfo or SocketFactory
callback, as a cycle.

Chains only recurse through the first hop's own ProxyJump, which
createHop() constructs, so pass the aliases that lead to it down
explicitly. A loop is now rejected when that hop is created, before any
socket is opened, and no thread or static state remains.
Cycle detection no longer keeps state on the proxies. Chains only nest
through the first hop's own ProxyJump setting, which its Session takes
from the config repository, so connect() follows those settings from
alias to alias before creating any session. A loop is reported with the
aliases that form it. An explicit ProxyJump set on a session is not part
of the walk, so jumping through a host whose config jumps back to an
alias without a ProxyJump is correctly allowed.

The hop channel now writes into a bounded buffer that ProxyJump owns
instead of the JDK pipe. PipedInputStream ties itself to the threads
that last used it and reports "Read end dead" when one exits, which the
handshake's hand-off from the connecting thread to the session thread
exposes, and its writer only wakes on a one-second timer. The buffer
starts at 32 KiB, grows to the channel window before it throttles the
hop, and bounds reads by the session's timeout.

ProxyJump.through(Session) tunnels a session through a hop the caller
has already connected, so several sessions can share one hop like
OpenSSH's ControlMaster. The caller owns the hop; the proxy only closes
its own channel.
ProxyJump.share(factory) returns a SharedHop that behaves like OpenSSH's
ControlMaster without ControlPersist: the first session to connect
through one of its proxies opens the hop, every further session reuses
it, and the last session to disconnect closes it. A later session opens
a fresh hop, and a hop that died is replaced on the next connect, so the
hop Session never has to be reconnected. close() shuts it at once.

Tunnels now take their hop from a HopSource and give it back on close,
which is where the reference count lives. through(Session) keeps its
meaning: the application owns that hop.
Hops received no UserInfo, so a bastion that authenticates by password
or keyboard-interactive could not be used, and a hop with an unknown
host key could not be confirmed. Like ssh -J, hops now share the
target's UserInfo; every prompt names the hop it is for. A password set
with Session.setPassword still applies to the target only.

Verified against a Windows bastion authenticating by password in front
of a key-authenticated Linux target, a password bastion in front of a
password target, and a wrong bastion password, which fails at the hop
without leaking threads.

The shared-hop concurrency test now completes all acquires before any
release; a release racing a queued acquire may close and reopen the hop,
which is the documented behaviour rather than a defect.
@KaliszAd
KaliszAd marked this pull request as draft September 25, 2026 19:18
Wait on the tunnel buffer's monitor from inside the loops that hold it,
so the condition is rechecked after every wake-up and the monitor is
visibly held. The channel's side of the buffer is a named inner class
that owns its write and grow logic. The hop address parser is split into
bracketed and plain forms. Session names its host-key config keys once
and declares the exceptions setReadTimeout can throw.

Tests no longer sleep: the concurrent shared-hop test releases the first
connect through a latch once every thread has started, and the blocked
writer test yields while it waits for the writer to park.
Adam Kalisz added 2 commits September 25, 2026 22:02
Hops no longer receive the target's UserInfo. Prompts from hops go to
the ProxyJump UserInfo set with Session.setProxyJumpUserInfo, so a
UserInfo that answers every prompt with one stored password cannot send
it to a bastion by accident. OpenSSH asks for each hop in turn; this is
the same, but the application chooses which prompts hops may reach.

An independent concurrency review of TunnelBuffer and SharedHop found:
SharedHop held its monitor while connecting the hop, so close() and
other users waited on a slow bastion or an unanswered prompt; a hop that
was closing could return a null channel outside the cleanup, stranding
a reference; a timed read rounded to whole milliseconds and could time
out early; a zero-sized buffer would spin under the monitor. The hop now
connects outside the monitor and close() abandons a connect in progress,
channel setup is inside the cleanup, the deadline is compared in
nanoseconds, and the buffer size is validated.

ProxyJumpIT replaces the property-gated live test. It runs against the
existing sshd image, which serves as hop and as target reached through
the hop, and covers config chains, the shared hop and through().
Caught exceptions are kept as causes instead of being dropped, the
integration test follows JUnit 5 visibility and naming rules and reads
the command output to EOF instead of polling, an unneeded throws clause
is gone, and the local variable touched in Session follows the naming
rule. The remaining suggestions to use unnamed catch parameters and
instanceof patterns need Java 22 and 16; the main code targets Java 8.
@KaliszAd
KaliszAd marked this pull request as ready for review September 25, 2026 20:34
The two constants introduced for the duplicated-literal rule did not
match the file's convention; the same string in a few places is not a
problem.
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants