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,