Skip to content
3 changes: 2 additions & 1 deletion docs/config/command-line.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ npx @bytebase/dbhub@latest --dsn "..." \

<Note>
- `--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.
</Note>
Expand Down
23 changes: 20 additions & 3 deletions src/connectors/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -149,24 +149,41 @@ 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<string, unknown>)[TUNNEL_ERROR_MARKER] = true;
}
throw error;
}

const sshConfig: SSHTunnelConfig = {
host: resolvedSSHConfig?.host || source.ssh_host,
port: source.ssh_port || resolvedSSHConfig?.port || 22,
username: username || '',
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,
};
Expand Down
14 changes: 14 additions & 0 deletions src/types/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand All @@ -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 {
Expand Down
136 changes: 135 additions & 1 deletion src/utils/__tests__/ssh-config-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
});
Loading
Loading