@bytebase/dbhub 0.13.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -30
- package/dist/{chunk-KBVJEDZF.js → chunk-TPHNNFR5.js} +464 -217
- package/dist/index.js +264 -164
- package/dist/public/assets/index-BJ-1UrcV.css +1 -0
- package/dist/public/assets/index-DBYlgGks.js +147 -0
- package/dist/public/index.html +2 -2
- package/dist/{registry-AWAIN6WO.js → registry-XXEL5IXH.js} +1 -1
- package/package.json +3 -3
- package/dist/public/assets/index-gVrYRID4.css +0 -1
- package/dist/public/assets/index-hd88eD9m.js +0 -51
|
@@ -62,161 +62,11 @@ var ConnectorRegistry = _ConnectorRegistry;
|
|
|
62
62
|
|
|
63
63
|
// src/utils/ssh-tunnel.ts
|
|
64
64
|
import { Client } from "ssh2";
|
|
65
|
-
import { readFileSync } from "fs";
|
|
65
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
66
66
|
import { createServer } from "net";
|
|
67
|
-
var SSHTunnel = class {
|
|
68
|
-
constructor() {
|
|
69
|
-
this.sshClient = null;
|
|
70
|
-
this.localServer = null;
|
|
71
|
-
this.tunnelInfo = null;
|
|
72
|
-
this.isConnected = false;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Establish an SSH tunnel
|
|
76
|
-
* @param config SSH connection configuration
|
|
77
|
-
* @param options Tunnel options including target host and port
|
|
78
|
-
* @returns Promise resolving to tunnel information including local port
|
|
79
|
-
*/
|
|
80
|
-
async establish(config, options) {
|
|
81
|
-
if (this.isConnected) {
|
|
82
|
-
throw new Error("SSH tunnel is already established");
|
|
83
|
-
}
|
|
84
|
-
return new Promise((resolve, reject) => {
|
|
85
|
-
this.sshClient = new Client();
|
|
86
|
-
const sshConfig = {
|
|
87
|
-
host: config.host,
|
|
88
|
-
port: config.port || 22,
|
|
89
|
-
username: config.username
|
|
90
|
-
};
|
|
91
|
-
if (config.password) {
|
|
92
|
-
sshConfig.password = config.password;
|
|
93
|
-
} else if (config.privateKey) {
|
|
94
|
-
try {
|
|
95
|
-
const privateKey = readFileSync(config.privateKey);
|
|
96
|
-
sshConfig.privateKey = privateKey;
|
|
97
|
-
if (config.passphrase) {
|
|
98
|
-
sshConfig.passphrase = config.passphrase;
|
|
99
|
-
}
|
|
100
|
-
} catch (error) {
|
|
101
|
-
reject(new Error(`Failed to read private key file: ${error instanceof Error ? error.message : String(error)}`));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
reject(new Error("Either password or privateKey must be provided for SSH authentication"));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
this.sshClient.on("error", (err) => {
|
|
109
|
-
this.cleanup();
|
|
110
|
-
reject(new Error(`SSH connection error: ${err.message}`));
|
|
111
|
-
});
|
|
112
|
-
this.sshClient.on("ready", () => {
|
|
113
|
-
console.error("SSH connection established");
|
|
114
|
-
this.localServer = createServer((localSocket) => {
|
|
115
|
-
this.sshClient.forwardOut(
|
|
116
|
-
"127.0.0.1",
|
|
117
|
-
0,
|
|
118
|
-
options.targetHost,
|
|
119
|
-
options.targetPort,
|
|
120
|
-
(err, stream) => {
|
|
121
|
-
if (err) {
|
|
122
|
-
console.error("SSH forward error:", err);
|
|
123
|
-
localSocket.end();
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
localSocket.pipe(stream).pipe(localSocket);
|
|
127
|
-
stream.on("error", (err2) => {
|
|
128
|
-
console.error("SSH stream error:", err2);
|
|
129
|
-
localSocket.end();
|
|
130
|
-
});
|
|
131
|
-
localSocket.on("error", (err2) => {
|
|
132
|
-
console.error("Local socket error:", err2);
|
|
133
|
-
stream.end();
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
);
|
|
137
|
-
});
|
|
138
|
-
const localPort = options.localPort || 0;
|
|
139
|
-
this.localServer.listen(localPort, "127.0.0.1", () => {
|
|
140
|
-
const address = this.localServer.address();
|
|
141
|
-
if (!address || typeof address === "string") {
|
|
142
|
-
this.cleanup();
|
|
143
|
-
reject(new Error("Failed to get local server address"));
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
this.tunnelInfo = {
|
|
147
|
-
localPort: address.port,
|
|
148
|
-
targetHost: options.targetHost,
|
|
149
|
-
targetPort: options.targetPort
|
|
150
|
-
};
|
|
151
|
-
this.isConnected = true;
|
|
152
|
-
console.error(`SSH tunnel established: localhost:${address.port} -> ${options.targetHost}:${options.targetPort}`);
|
|
153
|
-
resolve(this.tunnelInfo);
|
|
154
|
-
});
|
|
155
|
-
this.localServer.on("error", (err) => {
|
|
156
|
-
this.cleanup();
|
|
157
|
-
reject(new Error(`Local server error: ${err.message}`));
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
this.sshClient.connect(sshConfig);
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Close the SSH tunnel and clean up resources
|
|
165
|
-
*/
|
|
166
|
-
async close() {
|
|
167
|
-
if (!this.isConnected) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
return new Promise((resolve) => {
|
|
171
|
-
this.cleanup();
|
|
172
|
-
this.isConnected = false;
|
|
173
|
-
console.error("SSH tunnel closed");
|
|
174
|
-
resolve();
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Clean up resources
|
|
179
|
-
*/
|
|
180
|
-
cleanup() {
|
|
181
|
-
if (this.localServer) {
|
|
182
|
-
this.localServer.close();
|
|
183
|
-
this.localServer = null;
|
|
184
|
-
}
|
|
185
|
-
if (this.sshClient) {
|
|
186
|
-
this.sshClient.end();
|
|
187
|
-
this.sshClient = null;
|
|
188
|
-
}
|
|
189
|
-
this.tunnelInfo = null;
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Get current tunnel information
|
|
193
|
-
*/
|
|
194
|
-
getTunnelInfo() {
|
|
195
|
-
return this.tunnelInfo;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Check if tunnel is connected
|
|
199
|
-
*/
|
|
200
|
-
getIsConnected() {
|
|
201
|
-
return this.isConnected;
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
// src/config/toml-loader.ts
|
|
206
|
-
import fs2 from "fs";
|
|
207
|
-
import path2 from "path";
|
|
208
|
-
import { homedir as homedir3 } from "os";
|
|
209
|
-
import toml from "@iarna/toml";
|
|
210
|
-
|
|
211
|
-
// src/config/env.ts
|
|
212
|
-
import dotenv from "dotenv";
|
|
213
|
-
import path from "path";
|
|
214
|
-
import fs from "fs";
|
|
215
|
-
import { fileURLToPath } from "url";
|
|
216
|
-
import { homedir as homedir2 } from "os";
|
|
217
67
|
|
|
218
68
|
// src/utils/ssh-config-parser.ts
|
|
219
|
-
import { readFileSync
|
|
69
|
+
import { readFileSync, realpathSync, statSync } from "fs";
|
|
220
70
|
import { homedir } from "os";
|
|
221
71
|
import { join } from "path";
|
|
222
72
|
import SSHConfig from "ssh-config";
|
|
@@ -232,28 +82,38 @@ function expandTilde(filePath) {
|
|
|
232
82
|
}
|
|
233
83
|
return filePath;
|
|
234
84
|
}
|
|
235
|
-
function
|
|
85
|
+
function resolveSymlink(filePath) {
|
|
86
|
+
const expandedPath = expandTilde(filePath);
|
|
236
87
|
try {
|
|
237
|
-
return
|
|
88
|
+
return realpathSync(expandedPath);
|
|
89
|
+
} catch {
|
|
90
|
+
return expandedPath;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isFile(filePath) {
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(filePath);
|
|
96
|
+
return stat.isFile();
|
|
238
97
|
} catch {
|
|
239
98
|
return false;
|
|
240
99
|
}
|
|
241
100
|
}
|
|
242
101
|
function findDefaultSSHKey() {
|
|
243
102
|
for (const keyPath of DEFAULT_SSH_KEYS) {
|
|
244
|
-
|
|
245
|
-
|
|
103
|
+
const resolvedPath = resolveSymlink(keyPath);
|
|
104
|
+
if (isFile(resolvedPath)) {
|
|
105
|
+
return resolvedPath;
|
|
246
106
|
}
|
|
247
107
|
}
|
|
248
108
|
return void 0;
|
|
249
109
|
}
|
|
250
110
|
function parseSSHConfig(hostAlias, configPath) {
|
|
251
|
-
const sshConfigPath = configPath;
|
|
252
|
-
if (!
|
|
111
|
+
const sshConfigPath = resolveSymlink(configPath);
|
|
112
|
+
if (!isFile(sshConfigPath)) {
|
|
253
113
|
return null;
|
|
254
114
|
}
|
|
255
115
|
try {
|
|
256
|
-
const configContent =
|
|
116
|
+
const configContent = readFileSync(sshConfigPath, "utf8");
|
|
257
117
|
const config = SSHConfig.parse(configContent);
|
|
258
118
|
const hostConfig = config.compute(hostAlias);
|
|
259
119
|
if (!hostConfig || !hostConfig.HostName && !hostConfig.User) {
|
|
@@ -273,9 +133,9 @@ function parseSSHConfig(hostAlias, configPath) {
|
|
|
273
133
|
}
|
|
274
134
|
if (hostConfig.IdentityFile) {
|
|
275
135
|
const identityFile = Array.isArray(hostConfig.IdentityFile) ? hostConfig.IdentityFile[0] : hostConfig.IdentityFile;
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
sshConfig.privateKey =
|
|
136
|
+
const resolvedPath = resolveSymlink(identityFile);
|
|
137
|
+
if (isFile(resolvedPath)) {
|
|
138
|
+
sshConfig.privateKey = resolvedPath;
|
|
279
139
|
}
|
|
280
140
|
}
|
|
281
141
|
if (!sshConfig.privateKey) {
|
|
@@ -284,8 +144,11 @@ function parseSSHConfig(hostAlias, configPath) {
|
|
|
284
144
|
sshConfig.privateKey = defaultKey;
|
|
285
145
|
}
|
|
286
146
|
}
|
|
287
|
-
if (hostConfig.ProxyJump
|
|
288
|
-
|
|
147
|
+
if (hostConfig.ProxyJump) {
|
|
148
|
+
sshConfig.proxyJump = hostConfig.ProxyJump;
|
|
149
|
+
}
|
|
150
|
+
if (hostConfig.ProxyCommand) {
|
|
151
|
+
console.error("Warning: ProxyCommand in SSH config is not supported by DBHub. Use ProxyJump instead.");
|
|
289
152
|
}
|
|
290
153
|
if (!sshConfig.host || !sshConfig.username) {
|
|
291
154
|
return null;
|
|
@@ -308,6 +171,332 @@ function looksLikeSSHAlias(host) {
|
|
|
308
171
|
}
|
|
309
172
|
return true;
|
|
310
173
|
}
|
|
174
|
+
function validatePort(port, jumpHostStr) {
|
|
175
|
+
if (isNaN(port) || port <= 0 || port > 65535) {
|
|
176
|
+
throw new Error(`Invalid port number in "${jumpHostStr}": port must be between 1 and 65535`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function parseJumpHost(jumpHostStr) {
|
|
180
|
+
let username;
|
|
181
|
+
let host;
|
|
182
|
+
let port = 22;
|
|
183
|
+
let remaining = jumpHostStr.trim();
|
|
184
|
+
if (!remaining) {
|
|
185
|
+
throw new Error("Jump host string cannot be empty");
|
|
186
|
+
}
|
|
187
|
+
const atIndex = remaining.indexOf("@");
|
|
188
|
+
if (atIndex !== -1) {
|
|
189
|
+
const extractedUsername = remaining.substring(0, atIndex).trim();
|
|
190
|
+
if (extractedUsername) {
|
|
191
|
+
username = extractedUsername;
|
|
192
|
+
}
|
|
193
|
+
remaining = remaining.substring(atIndex + 1);
|
|
194
|
+
}
|
|
195
|
+
if (remaining.startsWith("[")) {
|
|
196
|
+
const closeBracket = remaining.indexOf("]");
|
|
197
|
+
if (closeBracket !== -1) {
|
|
198
|
+
host = remaining.substring(1, closeBracket);
|
|
199
|
+
const afterBracket = remaining.substring(closeBracket + 1);
|
|
200
|
+
if (afterBracket.startsWith(":")) {
|
|
201
|
+
const parsedPort = parseInt(afterBracket.substring(1), 10);
|
|
202
|
+
validatePort(parsedPort, jumpHostStr);
|
|
203
|
+
port = parsedPort;
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
throw new Error(`Invalid ProxyJump host "${jumpHostStr}": missing closing bracket in IPv6 address`);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const lastColon = remaining.lastIndexOf(":");
|
|
210
|
+
if (lastColon !== -1) {
|
|
211
|
+
const potentialPort = remaining.substring(lastColon + 1);
|
|
212
|
+
if (/^\d+$/.test(potentialPort)) {
|
|
213
|
+
host = remaining.substring(0, lastColon);
|
|
214
|
+
const parsedPort = parseInt(potentialPort, 10);
|
|
215
|
+
validatePort(parsedPort, jumpHostStr);
|
|
216
|
+
port = parsedPort;
|
|
217
|
+
} else {
|
|
218
|
+
host = remaining;
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
host = remaining;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!host) {
|
|
225
|
+
throw new Error(`Invalid jump host format: "${jumpHostStr}" - host cannot be empty`);
|
|
226
|
+
}
|
|
227
|
+
return { host, port, username };
|
|
228
|
+
}
|
|
229
|
+
function parseJumpHosts(proxyJump) {
|
|
230
|
+
if (!proxyJump || proxyJump.trim() === "" || proxyJump.toLowerCase() === "none") {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
return proxyJump.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(parseJumpHost);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/utils/ssh-tunnel.ts
|
|
237
|
+
var SSHTunnel = class {
|
|
238
|
+
constructor() {
|
|
239
|
+
this.sshClients = [];
|
|
240
|
+
// All SSH clients in the chain
|
|
241
|
+
this.localServer = null;
|
|
242
|
+
this.tunnelInfo = null;
|
|
243
|
+
this.isConnected = false;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Establish an SSH tunnel, optionally through jump hosts (ProxyJump).
|
|
247
|
+
* @param config SSH connection configuration
|
|
248
|
+
* @param options Tunnel options including target host and port
|
|
249
|
+
* @returns Promise resolving to tunnel information including local port
|
|
250
|
+
*/
|
|
251
|
+
async establish(config, options) {
|
|
252
|
+
if (this.isConnected) {
|
|
253
|
+
throw new Error("SSH tunnel is already established");
|
|
254
|
+
}
|
|
255
|
+
this.isConnected = true;
|
|
256
|
+
try {
|
|
257
|
+
const jumpHosts = config.proxyJump ? parseJumpHosts(config.proxyJump) : [];
|
|
258
|
+
let privateKeyBuffer;
|
|
259
|
+
if (config.privateKey) {
|
|
260
|
+
try {
|
|
261
|
+
const resolvedKeyPath = resolveSymlink(config.privateKey);
|
|
262
|
+
privateKeyBuffer = readFileSync2(resolvedKeyPath);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
throw new Error(`Failed to read private key file: ${error instanceof Error ? error.message : String(error)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!config.password && !privateKeyBuffer) {
|
|
268
|
+
throw new Error("Either password or privateKey must be provided for SSH authentication");
|
|
269
|
+
}
|
|
270
|
+
const finalClient = await this.establishChain(jumpHosts, config, privateKeyBuffer);
|
|
271
|
+
return await this.createLocalTunnel(finalClient, options);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.cleanup();
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Establish a chain of SSH connections through jump hosts.
|
|
279
|
+
* @returns The final SSH client connected to the target host
|
|
280
|
+
*/
|
|
281
|
+
async establishChain(jumpHosts, targetConfig, privateKey) {
|
|
282
|
+
let previousStream;
|
|
283
|
+
for (let i = 0; i < jumpHosts.length; i++) {
|
|
284
|
+
const jumpHost = jumpHosts[i];
|
|
285
|
+
const nextHost = i + 1 < jumpHosts.length ? jumpHosts[i + 1] : { host: targetConfig.host, port: targetConfig.port || 22 };
|
|
286
|
+
let client = null;
|
|
287
|
+
let forwardStream;
|
|
288
|
+
try {
|
|
289
|
+
client = await this.connectToHost(
|
|
290
|
+
{
|
|
291
|
+
host: jumpHost.host,
|
|
292
|
+
port: jumpHost.port,
|
|
293
|
+
username: jumpHost.username || targetConfig.username
|
|
294
|
+
},
|
|
295
|
+
targetConfig.password,
|
|
296
|
+
privateKey,
|
|
297
|
+
targetConfig.passphrase,
|
|
298
|
+
previousStream,
|
|
299
|
+
`jump host ${i + 1}`
|
|
300
|
+
);
|
|
301
|
+
console.error(` \u2192 Forwarding through ${jumpHost.host}:${jumpHost.port} to ${nextHost.host}:${nextHost.port}`);
|
|
302
|
+
forwardStream = await this.forwardTo(client, nextHost.host, nextHost.port);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (client) {
|
|
305
|
+
try {
|
|
306
|
+
client.end();
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
this.sshClients.push(client);
|
|
313
|
+
previousStream = forwardStream;
|
|
314
|
+
}
|
|
315
|
+
const finalClient = await this.connectToHost(
|
|
316
|
+
{
|
|
317
|
+
host: targetConfig.host,
|
|
318
|
+
port: targetConfig.port || 22,
|
|
319
|
+
username: targetConfig.username
|
|
320
|
+
},
|
|
321
|
+
targetConfig.password,
|
|
322
|
+
privateKey,
|
|
323
|
+
targetConfig.passphrase,
|
|
324
|
+
previousStream,
|
|
325
|
+
jumpHosts.length > 0 ? "target host" : void 0
|
|
326
|
+
);
|
|
327
|
+
this.sshClients.push(finalClient);
|
|
328
|
+
return finalClient;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Connect to a single SSH host.
|
|
332
|
+
*/
|
|
333
|
+
connectToHost(hostInfo, password, privateKey, passphrase, sock, label) {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
const client = new Client();
|
|
336
|
+
const sshConfig = {
|
|
337
|
+
host: hostInfo.host,
|
|
338
|
+
port: hostInfo.port,
|
|
339
|
+
username: hostInfo.username
|
|
340
|
+
};
|
|
341
|
+
if (password) {
|
|
342
|
+
sshConfig.password = password;
|
|
343
|
+
}
|
|
344
|
+
if (privateKey) {
|
|
345
|
+
sshConfig.privateKey = privateKey;
|
|
346
|
+
if (passphrase) {
|
|
347
|
+
sshConfig.passphrase = passphrase;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (sock) {
|
|
351
|
+
sshConfig.sock = sock;
|
|
352
|
+
}
|
|
353
|
+
const onError = (err) => {
|
|
354
|
+
client.removeListener("ready", onReady);
|
|
355
|
+
client.destroy();
|
|
356
|
+
reject(new Error(`SSH connection error${label ? ` (${label})` : ""}: ${err.message}`));
|
|
357
|
+
};
|
|
358
|
+
const onReady = () => {
|
|
359
|
+
client.removeListener("error", onError);
|
|
360
|
+
const desc = label || `${hostInfo.host}:${hostInfo.port}`;
|
|
361
|
+
console.error(`SSH connection established: ${desc}`);
|
|
362
|
+
resolve(client);
|
|
363
|
+
};
|
|
364
|
+
client.on("error", onError);
|
|
365
|
+
client.on("ready", onReady);
|
|
366
|
+
client.connect(sshConfig);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Forward a connection through an SSH client to a target host.
|
|
371
|
+
*/
|
|
372
|
+
forwardTo(client, targetHost, targetPort) {
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
|
|
375
|
+
if (err) {
|
|
376
|
+
reject(new Error(`SSH forward error: ${err.message}`));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
resolve(stream);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Create the local server that tunnels connections to the database.
|
|
385
|
+
*/
|
|
386
|
+
createLocalTunnel(sshClient, options) {
|
|
387
|
+
return new Promise((resolve, reject) => {
|
|
388
|
+
let settled = false;
|
|
389
|
+
this.localServer = createServer((localSocket) => {
|
|
390
|
+
sshClient.forwardOut(
|
|
391
|
+
"127.0.0.1",
|
|
392
|
+
0,
|
|
393
|
+
options.targetHost,
|
|
394
|
+
options.targetPort,
|
|
395
|
+
(err, stream) => {
|
|
396
|
+
if (err) {
|
|
397
|
+
console.error("SSH forward error:", err);
|
|
398
|
+
localSocket.end();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
localSocket.pipe(stream).pipe(localSocket);
|
|
402
|
+
stream.on("error", (err2) => {
|
|
403
|
+
console.error("SSH stream error:", err2);
|
|
404
|
+
localSocket.end();
|
|
405
|
+
});
|
|
406
|
+
localSocket.on("error", (err2) => {
|
|
407
|
+
console.error("Local socket error:", err2);
|
|
408
|
+
stream.end();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
this.localServer.on("error", (err) => {
|
|
414
|
+
if (!settled) {
|
|
415
|
+
settled = true;
|
|
416
|
+
reject(new Error(`Local server error: ${err.message}`));
|
|
417
|
+
} else {
|
|
418
|
+
console.error("Local server error after tunnel established:", err);
|
|
419
|
+
this.cleanup();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
const localPort = options.localPort || 0;
|
|
423
|
+
this.localServer.listen(localPort, "127.0.0.1", () => {
|
|
424
|
+
const address = this.localServer.address();
|
|
425
|
+
if (!address || typeof address === "string") {
|
|
426
|
+
if (!settled) {
|
|
427
|
+
settled = true;
|
|
428
|
+
reject(new Error("Failed to get local server address"));
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
this.tunnelInfo = {
|
|
433
|
+
localPort: address.port,
|
|
434
|
+
targetHost: options.targetHost,
|
|
435
|
+
targetPort: options.targetPort
|
|
436
|
+
};
|
|
437
|
+
console.error(`SSH tunnel established: localhost:${address.port} \u2192 ${options.targetHost}:${options.targetPort}`);
|
|
438
|
+
settled = true;
|
|
439
|
+
resolve(this.tunnelInfo);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Close the SSH tunnel and clean up resources
|
|
445
|
+
*/
|
|
446
|
+
async close() {
|
|
447
|
+
if (!this.isConnected) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
return new Promise((resolve) => {
|
|
451
|
+
this.cleanup();
|
|
452
|
+
console.error("SSH tunnel closed");
|
|
453
|
+
resolve();
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Clean up resources. Closes all SSH clients in reverse order (innermost first).
|
|
458
|
+
*/
|
|
459
|
+
cleanup() {
|
|
460
|
+
if (this.localServer) {
|
|
461
|
+
this.localServer.close();
|
|
462
|
+
this.localServer = null;
|
|
463
|
+
}
|
|
464
|
+
for (let i = this.sshClients.length - 1; i >= 0; i--) {
|
|
465
|
+
try {
|
|
466
|
+
this.sshClients[i].end();
|
|
467
|
+
} catch {
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
this.sshClients = [];
|
|
471
|
+
this.tunnelInfo = null;
|
|
472
|
+
this.isConnected = false;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Get current tunnel information
|
|
476
|
+
*/
|
|
477
|
+
getTunnelInfo() {
|
|
478
|
+
return this.tunnelInfo;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Check if tunnel is connected
|
|
482
|
+
*/
|
|
483
|
+
getIsConnected() {
|
|
484
|
+
return this.isConnected;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// src/config/toml-loader.ts
|
|
489
|
+
import fs2 from "fs";
|
|
490
|
+
import path2 from "path";
|
|
491
|
+
import { homedir as homedir3 } from "os";
|
|
492
|
+
import toml from "@iarna/toml";
|
|
493
|
+
|
|
494
|
+
// src/config/env.ts
|
|
495
|
+
import dotenv from "dotenv";
|
|
496
|
+
import path from "path";
|
|
497
|
+
import fs from "fs";
|
|
498
|
+
import { fileURLToPath } from "url";
|
|
499
|
+
import { homedir as homedir2 } from "os";
|
|
311
500
|
|
|
312
501
|
// src/utils/safe-url.ts
|
|
313
502
|
var SafeURL = class {
|
|
@@ -815,6 +1004,13 @@ function resolveSSHConfig() {
|
|
|
815
1004
|
config.passphrase = process.env.SSH_PASSPHRASE;
|
|
816
1005
|
sources.push("SSH_PASSPHRASE from environment");
|
|
817
1006
|
}
|
|
1007
|
+
if (args["ssh-proxy-jump"]) {
|
|
1008
|
+
config.proxyJump = args["ssh-proxy-jump"];
|
|
1009
|
+
sources.push("ssh-proxy-jump from command line");
|
|
1010
|
+
} else if (process.env.SSH_PROXY_JUMP) {
|
|
1011
|
+
config.proxyJump = process.env.SSH_PROXY_JUMP;
|
|
1012
|
+
sources.push("SSH_PROXY_JUMP from environment");
|
|
1013
|
+
}
|
|
818
1014
|
if (!config.host || !config.username) {
|
|
819
1015
|
throw new Error("SSH tunnel configuration requires at least --ssh-host and --ssh-user");
|
|
820
1016
|
}
|
|
@@ -917,8 +1113,13 @@ function loadTomlConfig() {
|
|
|
917
1113
|
try {
|
|
918
1114
|
const fileContent = fs2.readFileSync(configPath, "utf-8");
|
|
919
1115
|
const parsedToml = toml.parse(fileContent);
|
|
920
|
-
|
|
1116
|
+
if (!Array.isArray(parsedToml.sources)) {
|
|
1117
|
+
throw new Error(
|
|
1118
|
+
`Configuration file ${configPath}: must contain a [[sources]] array. Use [[sources]] syntax for array of tables in TOML.`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
921
1121
|
const sources = processSourceConfigs(parsedToml.sources, configPath);
|
|
1122
|
+
validateTomlConfig({ ...parsedToml, sources }, configPath);
|
|
922
1123
|
return {
|
|
923
1124
|
sources,
|
|
924
1125
|
tools: parsedToml.tools,
|
|
@@ -960,11 +1161,6 @@ id = "my_db"
|
|
|
960
1161
|
dsn = "postgres://..."`
|
|
961
1162
|
);
|
|
962
1163
|
}
|
|
963
|
-
if (!Array.isArray(config.sources)) {
|
|
964
|
-
throw new Error(
|
|
965
|
-
`Configuration file ${configPath}: 'sources' must be an array. Use [[sources]] syntax for array of tables in TOML.`
|
|
966
|
-
);
|
|
967
|
-
}
|
|
968
1164
|
if (config.sources.length === 0) {
|
|
969
1165
|
throw new Error(
|
|
970
1166
|
`Configuration file ${configPath}: sources array cannot be empty. Please define at least one source with [[sources]].`
|
|
@@ -1041,11 +1237,6 @@ function validateToolsConfig(tools, sources, configPath) {
|
|
|
1041
1237
|
`Configuration file ${configPath}: custom tool '${tool.name}' must have 'description' and 'statement' fields`
|
|
1042
1238
|
);
|
|
1043
1239
|
}
|
|
1044
|
-
if (tool.readonly !== void 0 || tool.max_rows !== void 0) {
|
|
1045
|
-
throw new Error(
|
|
1046
|
-
`Configuration file ${configPath}: custom tool '${tool.name}' cannot have readonly or max_rows fields (these are only valid for ${BUILTIN_TOOL_EXECUTE_SQL} tool)`
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
1240
|
}
|
|
1050
1241
|
if (tool.max_rows !== void 0) {
|
|
1051
1242
|
if (typeof tool.max_rows !== "number" || tool.max_rows <= 0) {
|
|
@@ -1054,6 +1245,11 @@ function validateToolsConfig(tools, sources, configPath) {
|
|
|
1054
1245
|
);
|
|
1055
1246
|
}
|
|
1056
1247
|
}
|
|
1248
|
+
if (tool.readonly !== void 0 && typeof tool.readonly !== "boolean") {
|
|
1249
|
+
throw new Error(
|
|
1250
|
+
`Configuration file ${configPath}: tool '${tool.name}' has invalid readonly. Must be a boolean (true or false).`
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1057
1253
|
}
|
|
1058
1254
|
}
|
|
1059
1255
|
function validateSourceConfig(source, configPath) {
|
|
@@ -1081,10 +1277,10 @@ function validateSourceConfig(source, configPath) {
|
|
|
1081
1277
|
);
|
|
1082
1278
|
}
|
|
1083
1279
|
}
|
|
1084
|
-
if (source.
|
|
1085
|
-
if (typeof source.
|
|
1280
|
+
if (source.query_timeout !== void 0) {
|
|
1281
|
+
if (typeof source.query_timeout !== "number" || source.query_timeout <= 0) {
|
|
1086
1282
|
throw new Error(
|
|
1087
|
-
`Configuration file ${configPath}: source '${source.id}' has invalid
|
|
1283
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid query_timeout. Must be a positive number (in seconds).`
|
|
1088
1284
|
);
|
|
1089
1285
|
}
|
|
1090
1286
|
}
|
|
@@ -1095,6 +1291,64 @@ function validateSourceConfig(source, configPath) {
|
|
|
1095
1291
|
);
|
|
1096
1292
|
}
|
|
1097
1293
|
}
|
|
1294
|
+
if (source.sslmode !== void 0) {
|
|
1295
|
+
if (source.type === "sqlite") {
|
|
1296
|
+
throw new Error(
|
|
1297
|
+
`Configuration file ${configPath}: source '${source.id}' has sslmode but SQLite does not support SSL. Remove the sslmode field for SQLite sources.`
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
const validSslModes = ["disable", "require"];
|
|
1301
|
+
if (!validSslModes.includes(source.sslmode)) {
|
|
1302
|
+
throw new Error(
|
|
1303
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid sslmode '${source.sslmode}'. Valid values: ${validSslModes.join(", ")}`
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (source.authentication !== void 0) {
|
|
1308
|
+
if (source.type !== "sqlserver") {
|
|
1309
|
+
throw new Error(
|
|
1310
|
+
`Configuration file ${configPath}: source '${source.id}' has authentication but it is only supported for SQL Server.`
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
const validAuthMethods = ["ntlm", "azure-active-directory-access-token"];
|
|
1314
|
+
if (!validAuthMethods.includes(source.authentication)) {
|
|
1315
|
+
throw new Error(
|
|
1316
|
+
`Configuration file ${configPath}: source '${source.id}' has invalid authentication '${source.authentication}'. Valid values: ${validAuthMethods.join(", ")}`
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
if (source.authentication === "ntlm" && !source.domain) {
|
|
1320
|
+
throw new Error(
|
|
1321
|
+
`Configuration file ${configPath}: source '${source.id}' uses NTLM authentication but 'domain' is not specified.`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (source.domain !== void 0) {
|
|
1326
|
+
if (source.type !== "sqlserver") {
|
|
1327
|
+
throw new Error(
|
|
1328
|
+
`Configuration file ${configPath}: source '${source.id}' has domain but it is only supported for SQL Server.`
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
if (source.authentication === void 0) {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`Configuration file ${configPath}: source '${source.id}' has domain but authentication is not set. Add authentication = "ntlm" to use Windows domain authentication.`
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
if (source.authentication !== "ntlm") {
|
|
1337
|
+
throw new Error(
|
|
1338
|
+
`Configuration file ${configPath}: source '${source.id}' has domain but authentication is set to '${source.authentication}'. Domain is only valid with authentication = "ntlm".`
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (source.readonly !== void 0) {
|
|
1343
|
+
throw new Error(
|
|
1344
|
+
`Configuration file ${configPath}: source '${source.id}' has 'readonly' field, but readonly must be configured per-tool, not per-source. Move 'readonly' to [[tools]] configuration instead.`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
if (source.max_rows !== void 0) {
|
|
1348
|
+
throw new Error(
|
|
1349
|
+
`Configuration file ${configPath}: source '${source.id}' has 'max_rows' field, but max_rows must be configured per-tool, not per-source. Move 'max_rows' to [[tools]] configuration instead.`
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1098
1352
|
}
|
|
1099
1353
|
function processSourceConfigs(sources, configPath) {
|
|
1100
1354
|
return sources.map((source) => {
|
|
@@ -1154,9 +1408,15 @@ function buildDSNFromSource(source) {
|
|
|
1154
1408
|
}
|
|
1155
1409
|
return `sqlite:///${source.database}`;
|
|
1156
1410
|
}
|
|
1157
|
-
|
|
1411
|
+
const passwordRequired = source.authentication !== "azure-active-directory-access-token";
|
|
1412
|
+
if (!source.host || !source.user || !source.database) {
|
|
1413
|
+
throw new Error(
|
|
1414
|
+
`Source '${source.id}': missing required connection parameters. Required: type, host, user, database`
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
if (passwordRequired && !source.password) {
|
|
1158
1418
|
throw new Error(
|
|
1159
|
-
`Source '${source.id}':
|
|
1419
|
+
`Source '${source.id}': password is required. (Password is optional only for azure-active-directory-access-token authentication)`
|
|
1160
1420
|
);
|
|
1161
1421
|
}
|
|
1162
1422
|
const port = source.port || getDefaultPortForType(source.type);
|
|
@@ -1164,11 +1424,26 @@ function buildDSNFromSource(source) {
|
|
|
1164
1424
|
throw new Error(`Source '${source.id}': unable to determine port`);
|
|
1165
1425
|
}
|
|
1166
1426
|
const encodedUser = encodeURIComponent(source.user);
|
|
1167
|
-
const encodedPassword = encodeURIComponent(source.password);
|
|
1427
|
+
const encodedPassword = source.password ? encodeURIComponent(source.password) : "";
|
|
1168
1428
|
const encodedDatabase = encodeURIComponent(source.database);
|
|
1169
1429
|
let dsn = `${source.type}://${encodedUser}:${encodedPassword}@${source.host}:${port}/${encodedDatabase}`;
|
|
1170
|
-
|
|
1171
|
-
|
|
1430
|
+
const queryParams = [];
|
|
1431
|
+
if (source.type === "sqlserver") {
|
|
1432
|
+
if (source.instanceName) {
|
|
1433
|
+
queryParams.push(`instanceName=${encodeURIComponent(source.instanceName)}`);
|
|
1434
|
+
}
|
|
1435
|
+
if (source.authentication) {
|
|
1436
|
+
queryParams.push(`authentication=${encodeURIComponent(source.authentication)}`);
|
|
1437
|
+
}
|
|
1438
|
+
if (source.domain) {
|
|
1439
|
+
queryParams.push(`domain=${encodeURIComponent(source.domain)}`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (source.sslmode && source.type !== "sqlite") {
|
|
1443
|
+
queryParams.push(`sslmode=${source.sslmode}`);
|
|
1444
|
+
}
|
|
1445
|
+
if (queryParams.length > 0) {
|
|
1446
|
+
dsn += `?${queryParams.join("&")}`;
|
|
1172
1447
|
}
|
|
1173
1448
|
return dsn;
|
|
1174
1449
|
}
|
|
@@ -1181,7 +1456,6 @@ var ConnectorManager = class {
|
|
|
1181
1456
|
// Maps for multi-source support
|
|
1182
1457
|
this.connectors = /* @__PURE__ */ new Map();
|
|
1183
1458
|
this.sshTunnels = /* @__PURE__ */ new Map();
|
|
1184
|
-
this.executeOptions = /* @__PURE__ */ new Map();
|
|
1185
1459
|
this.sourceConfigs = /* @__PURE__ */ new Map();
|
|
1186
1460
|
// Store original source configs
|
|
1187
1461
|
this.sourceIds = [];
|
|
@@ -1197,6 +1471,7 @@ var ConnectorManager = class {
|
|
|
1197
1471
|
if (sources.length === 0) {
|
|
1198
1472
|
throw new Error("No sources provided");
|
|
1199
1473
|
}
|
|
1474
|
+
console.error(`Connecting to ${sources.length} database source(s)...`);
|
|
1200
1475
|
for (const source of sources) {
|
|
1201
1476
|
await this.connectSource(source);
|
|
1202
1477
|
}
|
|
@@ -1207,6 +1482,7 @@ var ConnectorManager = class {
|
|
|
1207
1482
|
async connectSource(source) {
|
|
1208
1483
|
const sourceId = source.id;
|
|
1209
1484
|
const dsn = buildDSNFromSource(source);
|
|
1485
|
+
console.error(` - ${sourceId}: ${redactDSN(dsn)}`);
|
|
1210
1486
|
let actualDSN = dsn;
|
|
1211
1487
|
if (source.ssh_host) {
|
|
1212
1488
|
if (!source.ssh_user) {
|
|
@@ -1220,7 +1496,8 @@ var ConnectorManager = class {
|
|
|
1220
1496
|
username: source.ssh_user,
|
|
1221
1497
|
password: source.ssh_password,
|
|
1222
1498
|
privateKey: source.ssh_key,
|
|
1223
|
-
passphrase: source.ssh_passphrase
|
|
1499
|
+
passphrase: source.ssh_passphrase,
|
|
1500
|
+
proxyJump: source.ssh_proxy_jump
|
|
1224
1501
|
};
|
|
1225
1502
|
if (!sshConfig.password && !sshConfig.privateKey) {
|
|
1226
1503
|
throw new Error(
|
|
@@ -1255,8 +1532,8 @@ var ConnectorManager = class {
|
|
|
1255
1532
|
if (source.connection_timeout !== void 0) {
|
|
1256
1533
|
config.connectionTimeoutSeconds = source.connection_timeout;
|
|
1257
1534
|
}
|
|
1258
|
-
if (
|
|
1259
|
-
config.
|
|
1535
|
+
if (source.query_timeout !== void 0 && connector.id !== "sqlite") {
|
|
1536
|
+
config.queryTimeoutSeconds = source.query_timeout;
|
|
1260
1537
|
}
|
|
1261
1538
|
if (source.readonly !== void 0) {
|
|
1262
1539
|
config.readonly = source.readonly;
|
|
@@ -1265,14 +1542,6 @@ var ConnectorManager = class {
|
|
|
1265
1542
|
this.connectors.set(sourceId, connector);
|
|
1266
1543
|
this.sourceIds.push(sourceId);
|
|
1267
1544
|
this.sourceConfigs.set(sourceId, source);
|
|
1268
|
-
const options = {};
|
|
1269
|
-
if (source.max_rows !== void 0) {
|
|
1270
|
-
options.maxRows = source.max_rows;
|
|
1271
|
-
}
|
|
1272
|
-
if (source.readonly !== void 0) {
|
|
1273
|
-
options.readonly = source.readonly;
|
|
1274
|
-
}
|
|
1275
|
-
this.executeOptions.set(sourceId, options);
|
|
1276
1545
|
}
|
|
1277
1546
|
/**
|
|
1278
1547
|
* Close all database connections
|
|
@@ -1295,7 +1564,6 @@ var ConnectorManager = class {
|
|
|
1295
1564
|
}
|
|
1296
1565
|
this.connectors.clear();
|
|
1297
1566
|
this.sshTunnels.clear();
|
|
1298
|
-
this.executeOptions.clear();
|
|
1299
1567
|
this.sourceConfigs.clear();
|
|
1300
1568
|
this.sourceIds = [];
|
|
1301
1569
|
}
|
|
@@ -1340,25 +1608,6 @@ var ConnectorManager = class {
|
|
|
1340
1608
|
}
|
|
1341
1609
|
return managerInstance.getConnector(sourceId);
|
|
1342
1610
|
}
|
|
1343
|
-
/**
|
|
1344
|
-
* Get execute options for SQL execution
|
|
1345
|
-
* @param sourceId - Optional source ID. If not provided, returns default options
|
|
1346
|
-
*/
|
|
1347
|
-
getExecuteOptions(sourceId) {
|
|
1348
|
-
const id = sourceId || this.sourceIds[0];
|
|
1349
|
-
return this.executeOptions.get(id) || {};
|
|
1350
|
-
}
|
|
1351
|
-
/**
|
|
1352
|
-
* Get the current execute options
|
|
1353
|
-
* This is used by tool handlers
|
|
1354
|
-
* @param sourceId - Optional source ID. If not provided, returns default options
|
|
1355
|
-
*/
|
|
1356
|
-
static getCurrentExecuteOptions(sourceId) {
|
|
1357
|
-
if (!managerInstance) {
|
|
1358
|
-
throw new Error("ConnectorManager not initialized");
|
|
1359
|
-
}
|
|
1360
|
-
return managerInstance.getExecuteOptions(sourceId);
|
|
1361
|
-
}
|
|
1362
1611
|
/**
|
|
1363
1612
|
* Get all available source IDs
|
|
1364
1613
|
*/
|
|
@@ -1812,11 +2061,9 @@ export {
|
|
|
1812
2061
|
isDemoMode,
|
|
1813
2062
|
resolveTransport,
|
|
1814
2063
|
resolvePort,
|
|
1815
|
-
redactDSN,
|
|
1816
2064
|
resolveSourceConfigs,
|
|
1817
2065
|
BUILTIN_TOOL_EXECUTE_SQL,
|
|
1818
2066
|
BUILTIN_TOOL_SEARCH_OBJECTS,
|
|
1819
|
-
buildDSNFromSource,
|
|
1820
2067
|
ConnectorManager,
|
|
1821
2068
|
mapArgumentsToArray,
|
|
1822
2069
|
ToolRegistry,
|