diff --git a/docs/config/command-line.mdx b/docs/config/command-line.mdx
index 0b935dc8..475f6fcd 100644
--- a/docs/config/command-line.mdx
+++ b/docs/config/command-line.mdx
@@ -334,7 +334,8 @@ npx @bytebase/dbhub@latest --dsn "..." \
- `--ssh-key` / `SSH_KEY` accepts either a file path or a base64-encoded private key. DBHub automatically detects the format: it first tries to read the value as a file path, and if that fails, decodes it as base64.
-- When `--ssh-host` is a plain alias (no dots, not an IP address), DBHub automatically resolves it from `~/.ssh/config`, reading the `Hostname`, `User`, `IdentityFile`, and `ProxyJump` directives. Explicit flags always override values from the config file.
+- When `--ssh-host` is a plain alias (no dots, not an IP address), DBHub automatically resolves it from `~/.ssh/config`, reading the `HostName`, `User`, `IdentityFile`, and `ProxyJump` directives. Explicit flags always override values from the config file.
+- `ProxyJump` hops that are themselves `~/.ssh/config` aliases are resolved recursively — each hop uses its own `HostName`/`User`/`Port`/`IdentityFile` (and its own nested `ProxyJump`), matching how `ssh` connects. Cyclic `ProxyJump` chains are rejected with an error.
- ProxyCommand is not supported (requires shell execution). Use ProxyJump instead.
- Path expansion for `~/` is supported in file paths.
diff --git a/src/connectors/manager.ts b/src/connectors/manager.ts
index fa08afb7..e4f1f6dd 100644
--- a/src/connectors/manager.ts
+++ b/src/connectors/manager.ts
@@ -7,7 +7,7 @@ import { getDatabaseTypeFromDSN, getDefaultPortForType } from "../utils/dsn-obfu
import { redactDSN } from "../config/env.js";
import { SafeURL } from "../utils/safe-url.js";
import { generateRdsAuthToken } from "../utils/aws-rds-signer.js";
-import { parseSSHConfig, looksLikeSSHAlias, getDefaultSSHConfigPath } from "../utils/ssh-config-parser.js";
+import { parseSSHConfig, looksLikeSSHAlias, getDefaultSSHConfigPath, resolveJumpHosts } from "../utils/ssh-config-parser.js";
import { TUNNEL_ERROR_MARKER } from "../utils/error-classifier.js";
// Singleton instance for global access
@@ -149,16 +149,32 @@ export class ConnectorManager {
// Setup SSH tunnel if needed
let actualDSN = dsn;
if (source.ssh_host) {
+ const sshConfigPath = getDefaultSSHConfigPath();
// If ssh_host looks like an SSH config alias, resolve from ~/.ssh/config
let resolvedSSHConfig: SSHTunnelConfig | null = null;
if (looksLikeSSHAlias(source.ssh_host)) {
- const sshConfigPath = getDefaultSSHConfigPath();
console.error(` Resolving SSH config for host '${source.ssh_host}' from: ${sshConfigPath}`);
resolvedSSHConfig = parseSSHConfig(source.ssh_host, sshConfigPath);
}
// Build SSH config: explicit TOML fields override SSH config values
const username = source.ssh_user || resolvedSSHConfig?.username;
+ const proxyJump = source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump;
+
+ // Resolve the jump-host chain so alias hops (and nested ProxyJump) carry their own
+ // host/user/port/key from ~/.ssh/config, matching `ssh`. This can throw (e.g. a
+ // cyclic ProxyJump or invalid token); tag such failures as tunnel errors so they're
+ // classified consistently with tunnel.establish() failures.
+ let resolvedJumpHosts: SSHTunnelConfig["resolvedJumpHosts"];
+ try {
+ resolvedJumpHosts = proxyJump ? resolveJumpHosts(proxyJump, sshConfigPath) : undefined;
+ } catch (error) {
+ if (error && typeof error === "object") {
+ (error as Record)[TUNNEL_ERROR_MARKER] = true;
+ }
+ throw error;
+ }
+
const sshConfig: SSHTunnelConfig = {
host: resolvedSSHConfig?.host || source.ssh_host,
port: source.ssh_port || resolvedSSHConfig?.port || 22,
@@ -166,7 +182,8 @@ export class ConnectorManager {
password: source.ssh_password,
privateKey: source.ssh_key || resolvedSSHConfig?.privateKey,
passphrase: source.ssh_passphrase,
- proxyJump: source.ssh_proxy_jump || resolvedSSHConfig?.proxyJump,
+ proxyJump,
+ resolvedJumpHosts,
keepaliveInterval: source.ssh_keepalive_interval,
keepaliveCountMax: source.ssh_keepalive_count_max,
};
diff --git a/src/types/ssh.ts b/src/types/ssh.ts
index 59019abb..5d52f67b 100644
--- a/src/types/ssh.ts
+++ b/src/types/ssh.ts
@@ -34,6 +34,14 @@ export interface SSHTunnelConfig {
/** Maximum number of missed keepalive responses before disconnecting (default: 3) */
keepaliveCountMax?: number;
+
+ /**
+ * Fully-resolved jump-host chain (in connection order), produced by
+ * `resolveJumpHosts` from `proxyJump` + `~/.ssh/config`. When present, the tunnel
+ * uses these (with their per-hop credentials) instead of re-parsing `proxyJump`
+ * as literal hosts.
+ */
+ resolvedJumpHosts?: JumpHost[];
}
/**
@@ -48,6 +56,12 @@ export interface JumpHost {
/** Jump host username (inherited from target if not specified) */
username?: string;
+
+ /** Jump host private key path/content, resolved from its own `~/.ssh/config` entry */
+ privateKey?: string;
+
+ /** Passphrase for the jump host's private key */
+ passphrase?: string;
}
export interface SSHTunnelOptions {
diff --git a/src/utils/__tests__/ssh-config-parser.test.ts b/src/utils/__tests__/ssh-config-parser.test.ts
index 9022e343..5bcb3154 100644
--- a/src/utils/__tests__/ssh-config-parser.test.ts
+++ b/src/utils/__tests__/ssh-config-parser.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { parseSSHConfig, looksLikeSSHAlias, resolveSymlink, parseJumpHost, parseJumpHosts } from '../ssh-config-parser.js';
+import { parseSSHConfig, looksLikeSSHAlias, resolveSymlink, parseJumpHost, parseJumpHosts, resolveJumpHosts } from '../ssh-config-parser.js';
import { mkdtempSync, writeFileSync, rmSync, symlinkSync, mkdirSync, realpathSync, unlinkSync } from 'fs';
import { tmpdir, homedir } from 'os';
import { join } from 'path';
@@ -530,4 +530,138 @@ describe('parseJumpHosts', () => {
expect(result[1]).toEqual({ host: 'internal.company.com', port: 2222, username: 'admin' });
expect(result[2]).toEqual({ host: '10.0.0.1', port: 22, username: 'root' });
});
+
+ describe('resolveJumpHosts', () => {
+ let tempDir: string;
+ let configPath: string;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'dbhub-resolvejump-'));
+ configPath = join(tempDir, 'config');
+ });
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it("resolves a ProxyJump alias to the bastion's real host/user/port/key (issue #347)", () => {
+ const keyPath = join(tempDir, 'bastion_key');
+ writeFileSync(keyPath, '-----BEGIN OPENSSH PRIVATE KEY-----\n');
+ writeFileSync(configPath, `
+Host mybastion
+ HostName bastion.example.com
+ User ubuntu
+ Port 2200
+ IdentityFile ${keyPath}
+
+Host target-with-jump
+ HostName 10.0.0.5
+ User admin
+ ProxyJump mybastion
+`);
+ const hops = resolveJumpHosts('mybastion', configPath);
+ expect(hops).toHaveLength(1);
+ expect(hops[0].host).toBe('bastion.example.com');
+ expect(hops[0].port).toBe(2200);
+ expect(hops[0].username).toBe('ubuntu');
+ expect(hops[0].privateKey).toBe(realpathSync(keyPath));
+ });
+
+ it('resolves a jump alias that has no User (username inherited from target)', () => {
+ const keyPath = join(tempDir, 'bastion_key');
+ writeFileSync(keyPath, '-----BEGIN OPENSSH PRIVATE KEY-----\n');
+ writeFileSync(configPath, `
+Host bastion
+ HostName bastion.example.com
+ Port 2200
+ IdentityFile ${keyPath}
+`);
+ const hops = resolveJumpHosts('bastion', configPath);
+ expect(hops).toHaveLength(1);
+ expect(hops[0].host).toBe('bastion.example.com');
+ expect(hops[0].port).toBe(2200);
+ expect(hops[0].privateKey).toBe(realpathSync(keyPath));
+ // No User in the stanza → username left undefined so the tunnel inherits the target's.
+ expect(hops[0].username).toBeUndefined();
+ });
+
+ it('resolves a jump alias defining only Port/IdentityFile (HostName falls back to the alias)', () => {
+ const keyPath = join(tempDir, 'bastion_key');
+ writeFileSync(keyPath, '-----BEGIN OPENSSH PRIVATE KEY-----\n');
+ writeFileSync(configPath, `
+Host bastion
+ Port 2200
+ IdentityFile ${keyPath}
+`);
+ const hops = resolveJumpHosts('bastion', configPath);
+ expect(hops).toHaveLength(1);
+ // No HostName → OpenSSH uses the alias itself as the hostname.
+ expect(hops[0].host).toBe('bastion');
+ expect(hops[0].port).toBe(2200);
+ expect(hops[0].privateKey).toBe(realpathSync(keyPath));
+ expect(hops[0].username).toBeUndefined();
+ });
+
+ it('expands nested ProxyJump aliases in connection order (x -> a -> b)', () => {
+ writeFileSync(configPath, `
+Host x
+ HostName x.example.com
+ User xu
+Host a
+ HostName a.example.com
+ User au
+ ProxyJump x
+Host b
+ HostName b.example.com
+ User bu
+`);
+ const hops = resolveJumpHosts('a,b', configPath);
+ expect(hops.map((h) => h.host)).toEqual(['x.example.com', 'a.example.com', 'b.example.com']);
+ });
+
+ it('throws on a ProxyJump cycle', () => {
+ writeFileSync(configPath, `
+Host a
+ HostName a.example.com
+ User au
+ ProxyJump b
+Host b
+ HostName b.example.com
+ User bu
+ ProxyJump a
+`);
+ expect(() => resolveJumpHosts('a', configPath)).toThrow(/cycle/i);
+ });
+
+ it('passes through literal (non-alias) jump hosts unchanged', () => {
+ writeFileSync(configPath, `Host unused\n HostName u.example.com\n User uu\n`);
+ const hops = resolveJumpHosts('bastion.example.com:2222', configPath);
+ expect(hops).toEqual([{ host: 'bastion.example.com', port: 2222, username: undefined }]);
+ });
+
+ it('lets an explicit :port on the token override the config Port (incl. :22)', () => {
+ writeFileSync(configPath, `
+Host mybastion
+ HostName bastion.example.com
+ User ubuntu
+ Port 2200
+`);
+ // No port on the token → use the alias's Port.
+ expect(resolveJumpHosts('mybastion', configPath)[0].port).toBe(2200);
+ // Explicit port on the token wins — including an explicit :22.
+ expect(resolveJumpHosts('mybastion:2022', configPath)[0].port).toBe(2022);
+ expect(resolveJumpHosts('mybastion:22', configPath)[0].port).toBe(22);
+ });
+
+ it('lets an explicit user@ on the token override the config User', () => {
+ writeFileSync(configPath, `
+Host mybastion
+ HostName bastion.example.com
+ User ubuntu
+`);
+ const hops = resolveJumpHosts('admin@mybastion', configPath);
+ expect(hops[0].host).toBe('bastion.example.com');
+ expect(hops[0].username).toBe('admin');
+ });
+ });
});
diff --git a/src/utils/ssh-config-parser.ts b/src/utils/ssh-config-parser.ts
index 39a7c41a..b5c8e1c6 100644
--- a/src/utils/ssh-config-parser.ts
+++ b/src/utils/ssh-config-parser.ts
@@ -4,6 +4,10 @@ import { join } from 'path';
import SSHConfig from 'ssh-config';
import type { SSHTunnelConfig, JumpHost } from '../types/ssh.js';
+type SSHConfigLookupResult = Omit & {
+ username?: string;
+};
+
/**
* Default path to the user's SSH config file
*/
@@ -85,12 +89,32 @@ function findDefaultSSHKey(): string | undefined {
* Parse SSH config file and extract configuration for a specific host
* @param hostAlias The host alias to look up in the SSH config
* @param configPath Path to SSH config file
+ * @param options.requireUser When true (default), return null unless a `User` is
+ * resolved. ProxyJump alias hops pass `false` because they inherit the username
+ * from the target connection.
* @returns SSH tunnel configuration or null if not found
*/
export function parseSSHConfig(
hostAlias: string,
configPath: string
-): SSHTunnelConfig | null {
+): SSHTunnelConfig | null;
+export function parseSSHConfig(
+ hostAlias: string,
+ configPath: string,
+ options: { requireUser?: true }
+): SSHTunnelConfig | null;
+export function parseSSHConfig(
+ hostAlias: string,
+ configPath: string,
+ options: { requireUser: false }
+): SSHConfigLookupResult | null;
+export function parseSSHConfig(
+ hostAlias: string,
+ configPath: string,
+ options: { requireUser?: boolean } = {}
+): SSHConfigLookupResult | null {
+ const { requireUser = true } = options;
+
// Resolve symlinks in the config path (important for Windows where .ssh may be a junction)
const sshConfigPath = resolveSymlink(configPath);
@@ -107,13 +131,19 @@ export function parseSSHConfig(
// Find configuration for the specified host
const hostConfig = config.compute(hostAlias);
- // Check if we have a valid config (not just Include directives)
- if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
+ // Check if we have a valid config (not just Include directives). A host counts as
+ // configured if it sets any meaningful directive — not only HostName/User — since
+ // ProxyJump aliases often define just Port/IdentityFile/ProxyJump and inherit the
+ // hostname (the alias) and username (from the target).
+ if (
+ !hostConfig ||
+ (!hostConfig.HostName && !hostConfig.User && !hostConfig.Port && !hostConfig.IdentityFile && !hostConfig.ProxyJump)
+ ) {
return null;
}
// Extract SSH configuration parameters
- const sshConfig: Partial = {};
+ const sshConfig: Partial = {};
// Host (required)
if (hostConfig.HostName) {
@@ -165,12 +195,15 @@ export function parseSSHConfig(
console.error('Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.');
}
- // Validate that we have minimum required fields
- if (!sshConfig.host || !sshConfig.username) {
+ // Validate that we have minimum required fields. Top-level `ssh_host` resolution
+ // requires a username; ProxyJump alias hops can inherit it from the target.
+ if (!sshConfig.host || (requireUser && !sshConfig.username)) {
return null;
}
- return sshConfig as SSHTunnelConfig;
+ return requireUser
+ ? sshConfig as SSHTunnelConfig
+ : sshConfig as SSHConfigLookupResult;
} catch (error) {
console.error(`Error parsing SSH config: ${error instanceof Error ? error.message : String(error)}`);
return null;
@@ -310,3 +343,83 @@ export function parseJumpHosts(proxyJump: string): JumpHost[] {
.filter(s => s.length > 0)
.map(parseJumpHost);
}
+
+/**
+ * Resolve a ProxyJump string into a fully-resolved jump-host chain.
+ *
+ * Unlike {@link parseJumpHosts} (which treats every token as a literal
+ * `[user@]host[:port]`), this resolves any hop that is a `~/.ssh/config` Host
+ * alias through {@link parseSSHConfig}, so each hop carries its own real
+ * HostName/User/Port/IdentityFile — matching how OpenSSH connects. Aliases whose
+ * own stanza has a `ProxyJump` are expanded recursively and prepended, so a
+ * target with `ProxyJump a,b` where `a` has `ProxyJump x` resolves to
+ * `x -> a -> b`. An explicit `user@`/`:port` on the token overrides the config.
+ *
+ * Non-alias tokens (FQDNs/IPs) and aliases absent from the config fall back to
+ * literal parsing, preserving prior behavior for explicit `ssh_proxy_jump` specs.
+ *
+ * @param proxyJump Comma-separated ProxyJump string
+ * @param configPath Path to the SSH config file (for alias lookups)
+ * @param visited Aliases already on the current resolution path (cycle guard)
+ */
+export function resolveJumpHosts(
+ proxyJump: string,
+ configPath: string,
+ visited: Set = new Set()
+): JumpHost[] {
+ if (!proxyJump || proxyJump.trim() === '' || proxyJump.toLowerCase() === 'none') {
+ return [];
+ }
+
+ const resolved: JumpHost[] = [];
+
+ // Iterate the raw tokens (not parseJumpHosts output) so we can tell an explicit
+ // `:port` from the normalized default of 22 — needed to let a token port override
+ // a config alias's Port.
+ for (const token of proxyJump.split(',').map((s) => s.trim()).filter((s) => s.length > 0)) {
+ const hop = parseJumpHost(token);
+
+ if (!looksLikeSSHAlias(hop.host)) {
+ resolved.push(hop); // literal host — nothing to resolve
+ continue;
+ }
+
+ if (visited.has(hop.host)) {
+ throw new Error(`Cycle detected in SSH ProxyJump chain at alias "${hop.host}"`);
+ }
+
+ // Jump-host aliases may omit `User` (inherited from the target), so don't require it.
+ const aliasConfig = parseSSHConfig(hop.host, configPath, { requireUser: false });
+ if (!aliasConfig) {
+ resolved.push(hop); // alias not in config — treat the token literally
+ continue;
+ }
+
+ // 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,
+ });
+ }
+
+ return resolved;
+}
+
+/**
+ * Whether a ProxyJump token carries an explicit `:port` (vs. relying on the
+ * default). Handles an optional `user@` prefix and bracketed IPv6 (`[host]:port`).
+ */
+function tokenHasExplicitPort(token: string): boolean {
+ const atIndex = token.indexOf('@');
+ const hostPart = atIndex !== -1 ? token.slice(atIndex + 1) : token;
+ return hostPart.startsWith('[') ? /\]:\d+$/.test(hostPart) : /:\d+$/.test(hostPart);
+}
diff --git a/src/utils/ssh-tunnel.ts b/src/utils/ssh-tunnel.ts
index 4e9df610..30abccda 100644
--- a/src/utils/ssh-tunnel.ts
+++ b/src/utils/ssh-tunnel.ts
@@ -33,34 +33,13 @@ export class SSHTunnel {
this.isConnected = true;
try {
- // Parse jump hosts if ProxyJump is configured
- const jumpHosts = config.proxyJump ? parseJumpHosts(config.proxyJump) : [];
-
- // Read the private key once (shared by all connections)
- // Supports both file paths and base64-encoded key content
- let privateKeyBuffer: Buffer | undefined;
- if (config.privateKey) {
- try {
- const resolvedKeyPath = resolveSymlink(config.privateKey);
- privateKeyBuffer = readFileSync(resolvedKeyPath);
- } catch {
- // Not a readable file — try base64 decode
- try {
- const decoded = Buffer.from(config.privateKey, 'base64');
- const text = decoded.toString('utf8');
- if (text.includes('PRIVATE KEY')) {
- privateKeyBuffer = decoded;
- } else {
- throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
- }
- } catch (decodeError) {
- if (decodeError instanceof Error && decodeError.message.includes('neither a valid file path')) {
- throw decodeError;
- }
- throw new Error(`SSH key is neither a valid file path nor a base64-encoded private key`);
- }
- }
- }
+ // Use the fully-resolved jump-host chain when available (per-hop config/auth
+ // from ~/.ssh/config); otherwise fall back to literal ProxyJump parsing.
+ const jumpHosts = config.resolvedJumpHosts
+ ?? (config.proxyJump ? parseJumpHosts(config.proxyJump) : []);
+
+ // Read the target's private key once.
+ const privateKeyBuffer = config.privateKey ? this.loadPrivateKey(config.privateKey) : undefined;
// Validate authentication
if (!config.password && !privateKeyBuffer) {
@@ -78,6 +57,32 @@ export class SSHTunnel {
}
}
+ /**
+ * Load an SSH private key, supporting both a file path (with symlink resolution)
+ * and base64-encoded key content.
+ */
+ private loadPrivateKey(key: string): Buffer {
+ try {
+ const resolvedKeyPath = resolveSymlink(key);
+ return readFileSync(resolvedKeyPath);
+ } catch {
+ // Not a readable file — try base64 decode
+ try {
+ const decoded = Buffer.from(key, 'base64');
+ const text = decoded.toString('utf8');
+ if (text.includes('PRIVATE KEY')) {
+ return decoded;
+ }
+ throw new Error('SSH key is neither a valid file path nor a base64-encoded private key');
+ } catch (decodeError) {
+ if (decodeError instanceof Error && decodeError.message.includes('neither a valid file path')) {
+ throw decodeError;
+ }
+ throw new Error('SSH key is neither a valid file path nor a base64-encoded private key');
+ }
+ }
+ }
+
/**
* Establish a chain of SSH connections through jump hosts.
* @returns The final SSH client connected to the target host
@@ -96,6 +101,14 @@ export class SSHTunnel {
? jumpHosts[i + 1]
: { host: targetConfig.host, port: targetConfig.port || 22 };
+ // Per-hop credentials: use a hop's own resolved key when it has one, falling
+ // back to the target's key otherwise. The target password is always offered as
+ // a fallback (as before) — a hop may carry only a default-discovered key, so
+ // suppressing the password on "has a key" would break password auth.
+ const hopPrivateKey = jumpHost.privateKey ? this.loadPrivateKey(jumpHost.privateKey) : privateKey;
+ const hopPassword = targetConfig.password;
+ const hopPassphrase = jumpHost.passphrase ?? targetConfig.passphrase;
+
let client: Client | null = null;
let forwardStream: Duplex;
try {
@@ -105,9 +118,9 @@ export class SSHTunnel {
port: jumpHost.port,
username: jumpHost.username || targetConfig.username,
},
- targetConfig.password,
- privateKey,
- targetConfig.passphrase,
+ hopPassword,
+ hopPrivateKey,
+ hopPassphrase,
previousStream,
`jump host ${i + 1}`,
targetConfig.keepaliveInterval,