Skip to content

fix(ssh): resolve ProxyJump aliases recursively with per-hop auth (#347)#348

Merged
tianzhou merged 7 commits into
mainfrom
fix/347-ssh-proxyjump-alias-resolution
Jun 26, 2026
Merged

fix(ssh): resolve ProxyJump aliases recursively with per-hop auth (#347)#348
tianzhou merged 7 commits into
mainfrom
fix/347-ssh-proxyjump-alias-resolution

Conversation

@tianzhou

@tianzhou tianzhou commented Jun 26, 2026

Copy link
Copy Markdown
Member

Fixes #347.

Problem

When ssh_host (or a resolved alias) has ProxyJump <alias>, DBHub diverged from native ssh in two ways, so configs that work with ssh target-with-jump failed:

  1. ProxyJump aliases weren't resolved. parseSSHConfig copied ProxyJump as a raw string and parseJumpHosts parsed each token as a literal [user@]host[:port] — so ProxyJump mybastion used mybastion as a literal hostname instead of resolving its real HostName/User/Port/IdentityFile.
  2. Per-hop auth was wrong. establishChain reused the target's key/password/passphrase for every hop.

Fix (self-contained — no new dependency)

  • ssh-config-parser: new resolveJumpHosts() — for each ProxyJump hop that is a config alias, resolve it via parseSSHConfig (own HostName/User/Port/IdentityFile); expand nested ProxyJump chains in connection order (x → a → b); reject cycles. Non-alias tokens (FQDN/IP) and aliases absent from config fall back to literal parsing, preserving explicit ssh_proxy_jump behavior. Explicit user@/:port on a token overrides the config.
  • ssh-tunnel: establishChain uses per-hop credentials — each hop loads its own resolved key (extracted a loadPrivateKey helper) and each hop prefers its own resolved key but the target password is always offered as a fallback.
  • manager: populates SSHTunnelConfig.resolvedJumpHosts from the resolved chain.
  • types: JumpHost gains privateKey/passphrase; SSHTunnelConfig gains resolvedJumpHosts.
  • docs: note recursive alias resolution + per-hop auth + cycle rejection.

Scope / non-goals (documented)

ProxyCommand, Match exec, agent forwarding, and known_hosts policy are out of scope — a system-ssh (or ssh -G) delegation mode for that exotic residue is a separate decision, kept out of this PR.

Verification

  • pnpm build:backend — passes (DTS clean).
  • New resolveJumpHosts tests pass: alias resolution (the issue's exact example), nested-chain ordering, cycle detection, literal passthrough, token-user override.
  • Full unit suite: my changes add 5 passing tests and introduce no new failures. (Note: 5 tests fail both on main and this branch on a dev machine that has real ~/.ssh keys / DSN env vars — findDefaultSSHKey adds a privateKey the old exact-match assertions don't expect, and an env DSN test — these are pre-existing env-dependent fragilities, not caused by this PR.)

🤖 Generated with Claude Code

When `ssh_host` (or a resolved alias) had `ProxyJump <alias>`, DBHub treated
the jump alias as a literal hostname and reused the target's credentials for
every hop — so `~/.ssh/config` setups that work with `ssh target` failed.

- ssh-config-parser: add `resolveJumpHosts()` — resolve each ProxyJump hop
  that is a config alias through `parseSSHConfig` (its own HostName/User/Port/
  IdentityFile), expand nested `ProxyJump` chains in connection order
  (`x -> a -> b`), and reject cycles. Non-alias tokens fall back to literal
  parsing (preserves explicit `ssh_proxy_jump` behavior).
- ssh-tunnel: carry per-hop credentials in `establishChain` — each hop uses
  its own resolved key (extracted `loadPrivateKey` helper) instead of the
  target's, and a hop with its own key no longer inherits the target password.
- manager: populate `SSHTunnelConfig.resolvedJumpHosts` from the resolved chain.
- types: `JumpHost` gains `privateKey`/`passphrase`; `SSHTunnelConfig` gains
  `resolvedJumpHosts`.
- tests: alias resolution (the issue's example), nested-chain ordering, cycle
  detection, literal passthrough, and token-user override.

Out of scope (documented): ProxyCommand, `Match exec`, agent forwarding, and
known_hosts policy — a system-`ssh` delegation mode for that residue is a
separate decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 26, 2026 09:12

Copilot AI left a comment

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.

Pull request overview

This PR updates DBHub’s SSH tunnel setup to better match OpenSSH behavior when ssh_host (or its ProxyJump hops) are ~/.ssh/config aliases, by resolving jump-host aliases recursively and applying per-hop authentication data.

Changes:

  • Add resolveJumpHosts() to expand ProxyJump tokens that are SSH config aliases into a fully-resolved, ordered hop chain (with cycle detection).
  • Update tunnel establishment to use resolvedJumpHosts (when provided) and to apply per-hop private keys rather than reusing target credentials for all hops.
  • Plumb resolved jump-host chains through types, connector manager setup, and docs; add unit tests for jump-host resolution.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/utils/ssh-tunnel.ts Uses resolved jump-host chains and applies per-hop private key handling via loadPrivateKey().
src/utils/ssh-config-parser.ts Adds resolveJumpHosts() for recursive ProxyJump alias resolution and cycle detection.
src/utils/tests/ssh-config-parser.test.ts Adds tests covering alias resolution, nested chains, cycle detection, and overrides.
src/types/ssh.ts Extends JumpHost and SSHTunnelConfig to carry resolved per-hop auth details.
src/connectors/manager.ts Computes resolvedJumpHosts during SSH tunnel config construction.
docs/config/command-line.mdx Documents recursive ProxyJump alias resolution and per-hop auth behavior.

Comment thread src/utils/ssh-config-parser.ts
Comment thread src/utils/ssh-tunnel.ts
Comment thread docs/config/command-line.mdx Outdated
tianzhou and others added 3 commits June 26, 2026 02:23
…t hop port

- establishChain: always offer the target password as a fallback for jump hops.
  Previously a hop was treated as "has its own key" whenever `privateKey` was
  set — but `findDefaultSSHKey` fills that even for an alias with only
  HostName/User, so password auth broke once the client had a default key.
- resolveJumpHosts: detect an explicit `:port` from the raw ProxyJump token
  (incl. `:22`) so a token port overrides the alias's configured Port, instead
  of the `!== 22` heuristic that couldn't distinguish "no port" from ":22".
- test: explicit `:port` (and `:22`) override the config Port; bare token uses it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review on #348: the directive is `HostName` (capital N) in OpenSSH;
fix the doc so copy/paste and search match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/utils/ssh-config-parser.ts Outdated
Comment thread src/utils/ssh-tunnel.ts
parseSSHConfig() required a username, so a jump-host alias defining only
HostName/Port/IdentityFile (relying on inheriting the user) returned null and
fell back to literal-hostname handling — diverging from OpenSSH.

- parseSSHConfig: add `{ requireUser = true }` option; top-level ssh_host
  resolution still requires a user, but ProxyJump alias resolution passes
  `requireUser: false` and lets the username be inherited from the target
  (via `jumpHost.username || targetConfig.username` in establishChain).
- test: a jump alias with HostName/Port/IdentityFile but no User resolves
  (host/port/key set, username undefined).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/connectors/manager.ts Outdated
Comment thread src/utils/ssh-config-parser.ts
…348 review)

- manager: wrap resolveJumpHosts() in the TUNNEL_ERROR_MARKER handling so a
  failure during jump-host resolution (e.g. cyclic ProxyJump) is classified as
  a tunnel error, consistent with tunnel.establish() failures.
- ssh-config-parser: a host counts as "configured" if it sets any meaningful
  directive (HostName/User/Port/IdentityFile/ProxyJump), not only HostName/User.
  A jump alias defining just Port/IdentityFile/ProxyJump now resolves (HostName
  falls back to the alias) instead of being dropped to a literal hostname.
- tests: jump alias with only Port/IdentityFile resolves with host = alias name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/utils/ssh-config-parser.ts Outdated
Comment thread src/utils/ssh-config-parser.ts

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +398 to +411
// Expand this alias's own jump chain first so it connects before the alias.
if (aliasConfig.proxyJump) {
resolved.push(...resolveJumpHosts(aliasConfig.proxyJump, configPath, new Set(visited).add(hop.host)));
}

resolved.push({
host: aliasConfig.host,
// An explicit `:port` on the token wins; otherwise use the alias's Port (default 22).
port: tokenHasExplicitPort(token) ? hop.port : aliasConfig.port ?? 22,
// An explicit `user@` on the token wins; otherwise the alias's User.
username: hop.username ?? aliasConfig.username,
privateKey: aliasConfig.privateKey,
passphrase: aliasConfig.passphrase,
});
@tianzhou tianzhou merged commit 4c390d5 into main Jun 26, 2026
4 checks passed
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.

Use system ssh client when ssh_host is a ~/.ssh/config alias

2 participants