Skip to content

Commit 22342dd

Browse files
committed
feat: read ssh setting from .ssh/config
1 parent a85a14c commit 22342dd

File tree

9 files changed

+636
-2
lines changed

9 files changed

+636
-2
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,12 @@ DBHub supports SSH tunnels for secure database connections through bastion hosts
9191

9292
- Configuration via command-line options: `--ssh-host`, `--ssh-port`, `--ssh-user`, `--ssh-password`, `--ssh-key`, `--ssh-passphrase`
9393
- Configuration via environment variables: `SSH_HOST`, `SSH_PORT`, `SSH_USER`, `SSH_PASSWORD`, `SSH_KEY`, `SSH_PASSPHRASE`
94+
- SSH config file support: Automatically reads from `~/.ssh/config` when using host aliases
9495
- Implementation in `src/utils/ssh-tunnel.ts` using the `ssh2` library
96+
- SSH config parsing in `src/utils/ssh-config-parser.ts` using the `ssh-config` library
9597
- Automatic tunnel establishment when SSH config is detected
9698
- Support for both password and key-based authentication
99+
- Default SSH key detection (tries `~/.ssh/id_rsa`, `~/.ssh/id_ed25519`, etc.)
97100
- Tunnel lifecycle managed by `ConnectorManager`
98101

99102
## Code Style

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,24 @@ postgres://user:password@localhost:5432/dbname
217217

218218
DBHub supports connecting to databases through SSH tunnels, enabling secure access to databases in private networks or behind firewalls.
219219

220+
#### Using SSH Config File (Recommended)
221+
222+
DBHub can read SSH connection settings from your `~/.ssh/config` file. Simply use the host alias from your SSH config:
223+
224+
```bash
225+
# If you have this in ~/.ssh/config:
226+
# Host mybastion
227+
# HostName bastion.example.com
228+
# User ubuntu
229+
# IdentityFile ~/.ssh/id_rsa
230+
231+
npx @bytebase/dbhub \
232+
--dsn "postgres://dbuser:dbpass@database.internal:5432/mydb" \
233+
--ssh-host mybastion
234+
```
235+
236+
DBHub will automatically use the settings from your SSH config, including hostname, user, port, and identity file. If no identity file is specified in the config, DBHub will try common default locations (`~/.ssh/id_rsa`, `~/.ssh/id_ed25519`, etc.).
237+
220238
#### SSH with Password Authentication
221239

222240
```bash

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"mssql": "^11.0.1",
3737
"mysql2": "^3.13.0",
3838
"pg": "^8.13.3",
39+
"ssh-config": "^5.0.3",
3940
"ssh2": "^1.16.0",
4041
"zod": "^3.24.2"
4142
},

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import { resolveSSHConfig } from '../env.js';
3+
import * as sshConfigParser from '../../utils/ssh-config-parser.js';
4+
5+
// Mock the ssh-config-parser module
6+
vi.mock('../../utils/ssh-config-parser.js', () => ({
7+
parseSSHConfig: vi.fn(),
8+
looksLikeSSHAlias: vi.fn()
9+
}));
10+
11+
describe('SSH Config Integration', () => {
12+
let originalArgs: string[];
13+
14+
beforeEach(() => {
15+
// Save original values
16+
originalArgs = process.argv;
17+
18+
// Clear mocks
19+
vi.clearAllMocks();
20+
});
21+
22+
afterEach(() => {
23+
// Restore original values
24+
process.argv = originalArgs;
25+
26+
// Clear any environment variables
27+
delete process.env.SSH_HOST;
28+
delete process.env.SSH_USER;
29+
delete process.env.SSH_PORT;
30+
delete process.env.SSH_KEY;
31+
delete process.env.SSH_PASSWORD;
32+
});
33+
34+
it('should resolve SSH config from host alias', () => {
35+
// Mock the SSH config parser
36+
vi.mocked(sshConfigParser.looksLikeSSHAlias).mockReturnValue(true);
37+
vi.mocked(sshConfigParser.parseSSHConfig).mockReturnValue({
38+
host: 'bastion.example.com',
39+
username: 'ubuntu',
40+
port: 2222,
41+
privateKey: '/home/user/.ssh/id_rsa'
42+
});
43+
44+
// Simulate command line args
45+
process.argv = ['node', 'index.js', '--ssh-host=mybastion'];
46+
47+
const result = resolveSSHConfig();
48+
49+
expect(result).not.toBeNull();
50+
expect(result?.config).toMatchObject({
51+
host: 'bastion.example.com',
52+
username: 'ubuntu',
53+
port: 2222,
54+
privateKey: '/home/user/.ssh/id_rsa'
55+
});
56+
expect(result?.source).toContain('SSH config for host \'mybastion\'');
57+
});
58+
59+
it('should allow command line to override SSH config values', () => {
60+
// Mock the SSH config parser
61+
vi.mocked(sshConfigParser.looksLikeSSHAlias).mockReturnValue(true);
62+
vi.mocked(sshConfigParser.parseSSHConfig).mockReturnValue({
63+
host: 'bastion.example.com',
64+
username: 'ubuntu',
65+
port: 2222,
66+
privateKey: '/home/user/.ssh/id_rsa'
67+
});
68+
69+
// Simulate command line args with override
70+
process.argv = ['node', 'index.js', '--ssh-host=mybastion', '--ssh-user=override-user'];
71+
72+
const result = resolveSSHConfig();
73+
74+
expect(result).not.toBeNull();
75+
expect(result?.config).toMatchObject({
76+
host: 'bastion.example.com',
77+
username: 'override-user', // Command line overrides config
78+
port: 2222,
79+
privateKey: '/home/user/.ssh/id_rsa'
80+
});
81+
});
82+
83+
it('should work with environment variables', () => {
84+
// Mock the SSH config parser
85+
vi.mocked(sshConfigParser.looksLikeSSHAlias).mockReturnValue(true);
86+
vi.mocked(sshConfigParser.parseSSHConfig).mockReturnValue({
87+
host: 'bastion.example.com',
88+
username: 'ubuntu',
89+
port: 2222,
90+
privateKey: '/home/user/.ssh/id_rsa'
91+
});
92+
93+
process.env.SSH_HOST = 'mybastion';
94+
95+
const result = resolveSSHConfig();
96+
97+
expect(result).not.toBeNull();
98+
expect(result?.config).toMatchObject({
99+
host: 'bastion.example.com',
100+
username: 'ubuntu',
101+
port: 2222,
102+
privateKey: '/home/user/.ssh/id_rsa'
103+
});
104+
});
105+
106+
it('should not use SSH config for direct hostnames', () => {
107+
// Mock the SSH config parser
108+
vi.mocked(sshConfigParser.looksLikeSSHAlias).mockReturnValue(false);
109+
110+
process.argv = ['node', 'index.js', '--ssh-host=direct.example.com', '--ssh-user=myuser', '--ssh-password=mypass'];
111+
112+
const result = resolveSSHConfig();
113+
114+
expect(result).not.toBeNull();
115+
expect(result?.config).toMatchObject({
116+
host: 'direct.example.com',
117+
username: 'myuser',
118+
password: 'mypass'
119+
});
120+
expect(result?.source).not.toContain('SSH config');
121+
expect(sshConfigParser.parseSSHConfig).not.toHaveBeenCalled();
122+
});
123+
124+
it('should require SSH user when only host is provided', () => {
125+
// Mock the SSH config parser to return null (no config found)
126+
vi.mocked(sshConfigParser.looksLikeSSHAlias).mockReturnValue(true);
127+
vi.mocked(sshConfigParser.parseSSHConfig).mockReturnValue(null);
128+
129+
process.argv = ['node', 'index.js', '--ssh-host=unknown-host'];
130+
131+
expect(() => resolveSSHConfig()).toThrow('SSH tunnel configuration requires at least --ssh-host and --ssh-user');
132+
});
133+
});

src/config/env.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "path";
33
import fs from "fs";
44
import { fileURLToPath } from "url";
55
import type { SSHTunnelConfig } from "../types/ssh.js";
6+
import { parseSSHConfig, looksLikeSSHAlias } from "../utils/ssh-config-parser.js";
67

78
// Create __dirname equivalent for ES modules
89
const __filename = fileURLToPath(import.meta.url);
@@ -229,18 +230,34 @@ export function resolveSSHConfig(): { config: SSHTunnelConfig; source: string }
229230
}
230231

231232
// Build SSH config from command line and environment variables
232-
const config: Partial<SSHTunnelConfig> = {};
233+
let config: Partial<SSHTunnelConfig> = {};
233234
let sources: string[] = [];
235+
let sshConfigHost: string | undefined;
234236

235237
// SSH Host (required)
236238
if (args["ssh-host"]) {
239+
sshConfigHost = args["ssh-host"];
237240
config.host = args["ssh-host"];
238241
sources.push("ssh-host from command line");
239242
} else if (process.env.SSH_HOST) {
243+
sshConfigHost = process.env.SSH_HOST;
240244
config.host = process.env.SSH_HOST;
241245
sources.push("SSH_HOST from environment");
242246
}
243247

248+
// Check if the host looks like an SSH config alias
249+
if (sshConfigHost && looksLikeSSHAlias(sshConfigHost)) {
250+
// Try to parse SSH config for this host
251+
const sshConfigData = parseSSHConfig(sshConfigHost);
252+
if (sshConfigData) {
253+
// Use SSH config as base, but allow command line/env to override
254+
config = { ...sshConfigData };
255+
sources.push(`SSH config for host '${sshConfigHost}'`);
256+
257+
// The host from SSH config has already been set, no need to override
258+
}
259+
}
260+
244261
// SSH Port (optional, default: 22)
245262
if (args["ssh-port"]) {
246263
config.port = parseInt(args["ssh-port"], 10);

src/connectors/__tests__/postgres-ssh.integration.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
1+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
22
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
33
import { PostgresConnector } from '../postgres/index.js';
44
import { ConnectorManager } from '../manager.js';
55
import { ConnectorRegistry } from '../interface.js';
66
import { SSHTunnel } from '../../utils/ssh-tunnel.js';
77
import type { SSHTunnelConfig } from '../../types/ssh.js';
8+
import * as sshConfigParser from '../../utils/ssh-config-parser.js';
89

910
describe('PostgreSQL SSH Tunnel Simple Integration Tests', () => {
1011
let postgresContainer: StartedPostgreSqlContainer;
@@ -104,5 +105,117 @@ describe('PostgreSQL SSH Tunnel Simple Integration Tests', () => {
104105
delete process.env.SSH_USER;
105106
}
106107
});
108+
109+
it('should handle SSH config file resolution', async () => {
110+
const manager = new ConnectorManager();
111+
112+
// Mock the SSH config parser functions
113+
const mockParseSSHConfig = vi.spyOn(sshConfigParser, 'parseSSHConfig');
114+
const mockLooksLikeSSHAlias = vi.spyOn(sshConfigParser, 'looksLikeSSHAlias');
115+
116+
// Spy on the SSH tunnel establish method to verify the config values
117+
const mockSSHTunnelEstablish = vi.spyOn(SSHTunnel.prototype, 'establish');
118+
119+
try {
120+
// Configure mocks to simulate SSH config file lookup with specific values
121+
mockLooksLikeSSHAlias.mockReturnValue(true);
122+
mockParseSSHConfig.mockReturnValue({
123+
host: 'bastion.example.com',
124+
username: 'sshuser',
125+
port: 2222,
126+
privateKey: '/home/user/.ssh/id_rsa'
127+
});
128+
129+
// Mock SSH tunnel establish to capture the config and prevent actual connection
130+
mockSSHTunnelEstablish.mockRejectedValue(new Error('SSH connection failed (expected in test)'));
131+
132+
// Set SSH host alias (would normally come from command line)
133+
process.env.SSH_HOST = 'mybastion';
134+
135+
const dsn = postgresContainer.getConnectionUri();
136+
137+
// This should fail during SSH connection (expected), but we can verify the config parsing
138+
await expect(manager.connectWithDSN(dsn)).rejects.toThrow();
139+
140+
// Verify that SSH config parsing functions were called correctly
141+
expect(mockLooksLikeSSHAlias).toHaveBeenCalledWith('mybastion');
142+
expect(mockParseSSHConfig).toHaveBeenCalledWith('mybastion');
143+
144+
// Verify that SSH tunnel was attempted with the correct config values from SSH config
145+
expect(mockSSHTunnelEstablish).toHaveBeenCalledTimes(1);
146+
const sshTunnelCall = mockSSHTunnelEstablish.mock.calls[0];
147+
const [sshConfig, tunnelOptions] = sshTunnelCall;
148+
149+
// Debug: Log the actual values being passed (for verification)
150+
// SSH Config should contain the values from our mocked SSH config file
151+
// Tunnel Options should contain database connection details from the container DSN
152+
153+
// Verify SSH config values were properly resolved from the SSH config file
154+
expect(sshConfig).toMatchObject({
155+
host: 'bastion.example.com', // Should use HostName from SSH config
156+
username: 'sshuser', // Should use User from SSH config
157+
port: 2222, // Should use Port from SSH config
158+
privateKey: '/home/user/.ssh/id_rsa' // Should use IdentityFile from SSH config
159+
});
160+
161+
// Verify tunnel options are correctly set up for the database connection
162+
expect(tunnelOptions).toMatchObject({
163+
targetHost: expect.any(String), // Database host from DSN
164+
targetPort: expect.any(Number) // Database port from DSN
165+
});
166+
167+
// The localPort might be undefined for dynamic allocation, so check separately if it exists
168+
if (tunnelOptions.localPort !== undefined) {
169+
expect(typeof tunnelOptions.localPort).toBe('number');
170+
}
171+
172+
// Verify that the target database details from the DSN are preserved
173+
const originalDsnUrl = new URL(dsn);
174+
expect(tunnelOptions.targetHost).toBe(originalDsnUrl.hostname);
175+
expect(tunnelOptions.targetPort).toBe(parseInt(originalDsnUrl.port));
176+
177+
} finally {
178+
// Clean up
179+
delete process.env.SSH_HOST;
180+
mockParseSSHConfig.mockRestore();
181+
mockLooksLikeSSHAlias.mockRestore();
182+
mockSSHTunnelEstablish.mockRestore();
183+
}
184+
});
185+
186+
it('should skip SSH config lookup for direct hostnames', async () => {
187+
const manager = new ConnectorManager();
188+
189+
// Mock the SSH config parser functions
190+
const mockParseSSHConfig = vi.spyOn(sshConfigParser, 'parseSSHConfig');
191+
const mockLooksLikeSSHAlias = vi.spyOn(sshConfigParser, 'looksLikeSSHAlias');
192+
193+
try {
194+
// Configure mocks - direct hostname should not trigger SSH config lookup
195+
mockLooksLikeSSHAlias.mockReturnValue(false);
196+
197+
// Set a direct hostname with required SSH credentials
198+
process.env.SSH_HOST = 'ssh.example.com';
199+
process.env.SSH_USER = 'sshuser';
200+
process.env.SSH_PASSWORD = 'sshpass';
201+
202+
const dsn = postgresContainer.getConnectionUri();
203+
204+
// This should fail during actual SSH connection, but we can verify the parsing behavior
205+
await expect(manager.connectWithDSN(dsn)).rejects.toThrow();
206+
207+
// Verify that SSH config parsing was checked but not executed
208+
expect(mockLooksLikeSSHAlias).toHaveBeenCalledWith('ssh.example.com');
209+
expect(mockParseSSHConfig).not.toHaveBeenCalled();
210+
211+
} finally {
212+
// Clean up
213+
delete process.env.SSH_HOST;
214+
delete process.env.SSH_USER;
215+
delete process.env.SSH_PASSWORD;
216+
mockParseSSHConfig.mockRestore();
217+
mockLooksLikeSSHAlias.mockRestore();
218+
}
219+
});
107220
});
108221
});

0 commit comments

Comments
 (0)