Skip to content

Commit 83bbee7

Browse files
takemi-ohamaclaude
andcommitted
feat: add SSH keepalive configuration options
Add ssh_keepalive_interval and ssh_keepalive_count_max options to prevent SSH tunnel connections from being dropped due to idle timeouts caused by NAT gateways, firewalls, or server-side sshd settings. These options map to the ssh2 library's keepaliveInterval and keepaliveCountMax settings, allowing users to configure keepalive behavior per data source via TOML config, CLI arguments, or environment variables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6223cf2 commit 83bbee7

File tree

6 files changed

+49
-3
lines changed

6 files changed

+49
-3
lines changed

dbhub.toml.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
5656
# ssh_user = "deploy"
5757
# ssh_key = "~/.ssh/id_ed25519"
5858
# # ssh_passphrase = "your_key_passphrase" # If key is encrypted
59+
# # ssh_keepalive_interval = 60 # Send keepalive every 60 seconds to prevent idle disconnects
60+
# # ssh_keepalive_count_max = 3 # Disconnect after 3 missed responses
5961

6062
# Production PostgreSQL (multi-hop SSH through multiple jump hosts)
6163
# [[sources]]
@@ -279,6 +281,8 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
279281
# ssh_key (path to private key) OR ssh_password
280282
# ssh_passphrase (if key is encrypted)
281283
# ssh_proxy_jump (ProxyJump for multi-hop: "jump1.com,user@jump2.com:2222")
284+
# ssh_keepalive_interval (seconds between keepalive packets, default: 0 = disabled)
285+
# ssh_keepalive_count_max (max missed keepalive responses, default: 3)
282286
#
283287
# SSL Mode (for network databases, not SQLite):
284288
# sslmode = "disable" # No SSL

src/config/env.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,24 @@ export function resolveSSHConfig(): { config: SSHTunnelConfig; source: string }
485485
sources.push("SSH_PROXY_JUMP from environment");
486486
}
487487

488+
// SSH Keepalive Interval (optional) - seconds between keepalive packets
489+
if (args["ssh-keepalive-interval"]) {
490+
config.keepaliveInterval = parseInt(args["ssh-keepalive-interval"], 10);
491+
sources.push("ssh-keepalive-interval from command line");
492+
} else if (process.env.SSH_KEEPALIVE_INTERVAL) {
493+
config.keepaliveInterval = parseInt(process.env.SSH_KEEPALIVE_INTERVAL, 10);
494+
sources.push("SSH_KEEPALIVE_INTERVAL from environment");
495+
}
496+
497+
// SSH Keepalive Count Max (optional) - max missed keepalive responses
498+
if (args["ssh-keepalive-count-max"]) {
499+
config.keepaliveCountMax = parseInt(args["ssh-keepalive-count-max"], 10);
500+
sources.push("ssh-keepalive-count-max from command line");
501+
} else if (process.env.SSH_KEEPALIVE_COUNT_MAX) {
502+
config.keepaliveCountMax = parseInt(process.env.SSH_KEEPALIVE_COUNT_MAX, 10);
503+
sources.push("SSH_KEEPALIVE_COUNT_MAX from environment");
504+
}
505+
488506
// Validate required fields
489507
if (!config.host || !config.username) {
490508
throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
@@ -595,6 +613,8 @@ export async function resolveSourceConfigs(): Promise<{ sources: SourceConfig[];
595613
source.ssh_password = sshResult.config.password;
596614
source.ssh_key = sshResult.config.privateKey;
597615
source.ssh_passphrase = sshResult.config.passphrase;
616+
source.ssh_keepalive_interval = sshResult.config.keepaliveInterval;
617+
source.ssh_keepalive_count_max = sshResult.config.keepaliveCountMax;
598618
}
599619

600620
// Add init script for demo mode

src/connectors/manager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ export class ConnectorManager {
157157
privateKey: source.ssh_key,
158158
passphrase: source.ssh_passphrase,
159159
proxyJump: source.ssh_proxy_jump,
160+
keepaliveInterval: source.ssh_keepalive_interval,
161+
keepaliveCountMax: source.ssh_keepalive_count_max,
160162
};
161163

162164
// Validate SSH auth

src/types/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export interface SSHConfig {
1717
* Comma-separated list of jump hosts: "jump1.example.com,user@jump2.example.com:2222"
1818
*/
1919
ssh_proxy_jump?: string;
20+
/** Interval in seconds between keepalive packets (default: 0 = disabled) */
21+
ssh_keepalive_interval?: number;
22+
/** Maximum number of missed keepalive responses before disconnecting (default: 3) */
23+
ssh_keepalive_count_max?: number;
2024
}
2125

2226
/**

src/types/ssh.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export interface SSHTunnelConfig {
2828
* Each host can include optional user and port: "user@host:port"
2929
*/
3030
proxyJump?: string;
31+
32+
/** Interval in seconds between keepalive packets sent to the SSH server (default: 0 = disabled) */
33+
keepaliveInterval?: number;
34+
35+
/** Maximum number of missed keepalive responses before disconnecting (default: 3) */
36+
keepaliveCountMax?: number;
3137
}
3238

3339
/**

src/utils/ssh-tunnel.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ export class SSHTunnel {
9494
privateKey,
9595
targetConfig.passphrase,
9696
previousStream,
97-
`jump host ${i + 1}`
97+
`jump host ${i + 1}`,
98+
targetConfig.keepaliveInterval,
99+
targetConfig.keepaliveCountMax
98100
);
99101

100102
// Forward to the next host
@@ -126,7 +128,9 @@ export class SSHTunnel {
126128
privateKey,
127129
targetConfig.passphrase,
128130
previousStream,
129-
jumpHosts.length > 0 ? 'target host' : undefined
131+
jumpHosts.length > 0 ? 'target host' : undefined,
132+
targetConfig.keepaliveInterval,
133+
targetConfig.keepaliveCountMax
130134
);
131135

132136
this.sshClients.push(finalClient);
@@ -142,7 +146,9 @@ export class SSHTunnel {
142146
privateKey: Buffer | undefined,
143147
passphrase: string | undefined,
144148
sock: Duplex | undefined,
145-
label: string | undefined
149+
label: string | undefined,
150+
keepaliveInterval?: number,
151+
keepaliveCountMax?: number
146152
): Promise<Client> {
147153
return new Promise((resolve, reject) => {
148154
const client = new Client();
@@ -165,6 +171,10 @@ export class SSHTunnel {
165171
if (sock) {
166172
sshConfig.sock = sock;
167173
}
174+
if (keepaliveInterval !== undefined && keepaliveInterval > 0) {
175+
sshConfig.keepaliveInterval = keepaliveInterval * 1000; // Convert seconds to milliseconds
176+
sshConfig.keepaliveCountMax = keepaliveCountMax ?? 3;
177+
}
168178

169179
const onError = (err: Error) => {
170180
client.removeListener('ready', onReady);

0 commit comments

Comments
 (0)