@lightdash/warehouses 2.164.1 → 2.165.1
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/dist/.tsbuildinfo +1 -1
- package/dist/ssh/sshTunnel.d.ts +6 -1
- package/dist/ssh/sshTunnel.d.ts.map +1 -1
- package/dist/ssh/sshTunnel.js +131 -17
- package/dist/ssh/sshTunnelFailure.d.ts +24 -0
- package/dist/ssh/sshTunnelFailure.d.ts.map +1 -0
- package/dist/ssh/sshTunnelFailure.js +56 -0
- package/dist/ssh/sshTunnelFailure.test.d.ts +2 -0
- package/dist/ssh/sshTunnelFailure.test.d.ts.map +1 -0
- package/dist/ssh/sshTunnelFailure.test.js +90 -0
- package/package.json +2 -2
package/dist/ssh/sshTunnel.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { CreateWarehouseCredentials } from '@lightdash/common';
|
|
2
|
+
export type SshTunnelOptions = {
|
|
3
|
+
staticIp: string | null;
|
|
4
|
+
probeForward: boolean;
|
|
5
|
+
};
|
|
2
6
|
export declare class SshTunnel<T extends CreateWarehouseCredentials> {
|
|
3
7
|
readonly originalCredentials: T;
|
|
4
8
|
overrideCredentials: T;
|
|
5
9
|
localPort: number | undefined;
|
|
6
10
|
private sshConnection;
|
|
7
|
-
|
|
11
|
+
private readonly options;
|
|
12
|
+
constructor(credentials: T, options?: SshTunnelOptions);
|
|
8
13
|
connect: () => Promise<T>;
|
|
9
14
|
disconnect: () => Promise<void>;
|
|
10
15
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sshTunnel.d.ts","sourceRoot":"","sources":["../../src/ssh/sshTunnel.ts"],"names":[],"mappings":"AACA,OAAO,EAEH,0BAA0B,
|
|
1
|
+
{"version":3,"file":"sshTunnel.d.ts","sourceRoot":"","sources":["../../src/ssh/sshTunnel.ts"],"names":[],"mappings":"AACA,OAAO,EAEH,0BAA0B,EAK7B,MAAM,mBAAmB,CAAC;AAwW3B,MAAM,MAAM,gBAAgB,GAAG;IAG3B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAIxB,YAAY,EAAE,OAAO,CAAC;CACzB,CAAC;AAOF,qBAAa,SAAS,CAAC,CAAC,SAAS,0BAA0B;IACvD,QAAQ,CAAC,mBAAmB,EAAE,CAAC,CAAC;IAEhC,mBAAmB,EAAE,CAAC,CAAC;IAEvB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAE9B,OAAO,CAAC,aAAa,CAAyB;IAE9C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAE3C,YACI,WAAW,EAAE,CAAC,EACd,OAAO,GAAE,gBAA6C,EAOzD;IAED,OAAO,QAAa,OAAO,CAAC,CAAC,CAAC,CA4E5B;IAEF,UAAU,QAAa,OAAO,CAAC,IAAI,CAAC,CAIlC;CACL"}
|
package/dist/ssh/sshTunnel.js
CHANGED
|
@@ -39,13 +39,31 @@ const common_1 = require("@lightdash/common");
|
|
|
39
39
|
const crypto = __importStar(require("crypto"));
|
|
40
40
|
const net = __importStar(require("net"));
|
|
41
41
|
const ssh2 = __importStar(require("ssh2"));
|
|
42
|
+
const sshTunnelFailure_1 = require("./sshTunnelFailure");
|
|
43
|
+
// The TCP connect is done here, not by ssh2, so a bastion that silently drops
|
|
44
|
+
// packets fails as a tcp stage with ETIMEDOUT instead of ssh2's generic
|
|
45
|
+
// handshake timeout. ssh2's ready timeout then only covers handshake and auth.
|
|
46
|
+
const TCP_CONNECT_TIMEOUT_MS = 10000;
|
|
47
|
+
const SSH_READY_TIMEOUT_MS = 15000;
|
|
48
|
+
class SshTunnelStageFailure extends Error {
|
|
49
|
+
constructor(stage, cause) {
|
|
50
|
+
super(cause);
|
|
51
|
+
this.name = 'SshTunnelStageFailure';
|
|
52
|
+
this.stage = stage;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
42
55
|
class SSH2Tunnel {
|
|
43
56
|
constructor(args) {
|
|
44
57
|
this.error = undefined;
|
|
58
|
+
this.tcpConnected = false;
|
|
59
|
+
this.handshakeCompleted = false;
|
|
45
60
|
// Wall-clock (ms) when this tunnel was constructed. Used only for logging:
|
|
46
61
|
// how long a connection survived before it was closed / dropped / timed out.
|
|
47
62
|
this.openedAt = Date.now();
|
|
48
63
|
this.id = crypto.randomBytes(8).toString('hex');
|
|
64
|
+
this.probeForwardOnConnect = args.probeForward;
|
|
65
|
+
this.sshHost = args.sshHost;
|
|
66
|
+
this.sshPort = args.sshPort;
|
|
49
67
|
this.databaseHostOnRemote = args.databaseHostOnRemote;
|
|
50
68
|
this.databasePortOnRemote = args.databasePortOnRemote;
|
|
51
69
|
this.sshConnectConfig = {
|
|
@@ -53,6 +71,7 @@ class SSH2Tunnel {
|
|
|
53
71
|
privateKey: args.sshPrivateKey,
|
|
54
72
|
host: args.sshHost,
|
|
55
73
|
port: args.sshPort,
|
|
74
|
+
readyTimeout: SSH_READY_TIMEOUT_MS,
|
|
56
75
|
};
|
|
57
76
|
this.sshClient = new ssh2.Client();
|
|
58
77
|
this.localTcpServer = net.createServer();
|
|
@@ -74,6 +93,7 @@ class SSH2Tunnel {
|
|
|
74
93
|
console.log(`SSH tunnel ${this.id} - ssh client received "end"`);
|
|
75
94
|
});
|
|
76
95
|
this.sshClient.on('handshake', () => {
|
|
96
|
+
this.handshakeCompleted = true;
|
|
77
97
|
console.log(`SSH tunnel ${this.id} - ssh client handshake success`);
|
|
78
98
|
});
|
|
79
99
|
this.localTcpServer.on('close', () => {
|
|
@@ -155,53 +175,126 @@ class SSH2Tunnel {
|
|
|
155
175
|
this.sshClient.end();
|
|
156
176
|
this.localTcpServer.close(onClose);
|
|
157
177
|
}
|
|
178
|
+
// Opens one forward to the database host and closes it straight away, so
|
|
179
|
+
// a bastion that cannot reach the database fails here as a "forward"
|
|
180
|
+
// stage instead of later as an opaque database error.
|
|
181
|
+
probeForward() {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
this.sshClient.forwardOut('127.0.0.1', 0, this.databaseHostOnRemote, this.databasePortOnRemote, (err, stream) => {
|
|
184
|
+
if (err) {
|
|
185
|
+
console.error(`SSH tunnel ${this.id} - forward probe to ${this.databaseHostOnRemote}:${this.databasePortOnRemote} failed: ${err.message}`);
|
|
186
|
+
reject(new SshTunnelStageFailure('forward', err.message));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.log(`SSH tunnel ${this.id} - forward probe to ${this.databaseHostOnRemote}:${this.databasePortOnRemote} succeeded`);
|
|
190
|
+
stream.close();
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
158
195
|
async connect() {
|
|
159
196
|
return new Promise((resolve, reject) => {
|
|
197
|
+
let settled = false;
|
|
198
|
+
const fail = (stage, cause) => {
|
|
199
|
+
if (settled)
|
|
200
|
+
return;
|
|
201
|
+
settled = true;
|
|
202
|
+
reject(new SshTunnelStageFailure(stage, cause));
|
|
203
|
+
};
|
|
160
204
|
if (this.error) {
|
|
161
|
-
|
|
205
|
+
fail('tcp', this.error.message);
|
|
206
|
+
return;
|
|
162
207
|
}
|
|
163
208
|
this.localTcpServer.on('error', (e) => {
|
|
164
|
-
|
|
209
|
+
fail('forward', e.message);
|
|
165
210
|
});
|
|
166
211
|
this.sshClient.on('error', (e) => {
|
|
167
|
-
|
|
212
|
+
fail((0, sshTunnelFailure_1.classifySshClientError)(e, {
|
|
213
|
+
tcpConnected: this.tcpConnected,
|
|
214
|
+
handshakeCompleted: this.handshakeCompleted,
|
|
215
|
+
}), (0, sshTunnelFailure_1.sshClientErrorMessage)(e));
|
|
216
|
+
});
|
|
217
|
+
const sock = net.connect({
|
|
218
|
+
host: this.sshHost,
|
|
219
|
+
port: this.sshPort,
|
|
220
|
+
});
|
|
221
|
+
sock.setTimeout(TCP_CONNECT_TIMEOUT_MS);
|
|
222
|
+
const onConnectError = (e) => {
|
|
223
|
+
sock.destroy();
|
|
224
|
+
fail((0, sshTunnelFailure_1.classifySshClientError)(e, {
|
|
225
|
+
tcpConnected: false,
|
|
226
|
+
handshakeCompleted: false,
|
|
227
|
+
}), (0, sshTunnelFailure_1.sshClientErrorMessage)(e));
|
|
228
|
+
};
|
|
229
|
+
const onConnectTimeout = () => {
|
|
230
|
+
const e = Object.assign(new Error(`connect ETIMEDOUT ${this.sshHost}:${this.sshPort} after ${TCP_CONNECT_TIMEOUT_MS}ms`), { code: 'ETIMEDOUT' });
|
|
231
|
+
onConnectError(e);
|
|
232
|
+
};
|
|
233
|
+
sock.once('error', onConnectError);
|
|
234
|
+
sock.once('timeout', onConnectTimeout);
|
|
235
|
+
sock.once('connect', () => {
|
|
236
|
+
this.tcpConnected = true;
|
|
237
|
+
sock.setTimeout(0);
|
|
238
|
+
sock.off('error', onConnectError);
|
|
239
|
+
sock.off('timeout', onConnectTimeout);
|
|
240
|
+
console.log(`SSH tunnel ${this.id} - tcp connected to ${this.sshHost}:${this.sshPort}`);
|
|
241
|
+
this.sshClient.connect({ ...this.sshConnectConfig, sock });
|
|
168
242
|
});
|
|
169
|
-
|
|
170
|
-
//
|
|
243
|
+
// When SSH Client has connected and the forward works - start
|
|
244
|
+
// listening on local tcp server
|
|
171
245
|
this.sshClient.on('ready', () => {
|
|
172
246
|
console.log(`SSH tunnel ${this.id} - ssh client ready`);
|
|
173
|
-
|
|
247
|
+
const probe = this.probeForwardOnConnect
|
|
248
|
+
? this.probeForward()
|
|
249
|
+
: Promise.resolve();
|
|
250
|
+
probe
|
|
251
|
+
.then(() => {
|
|
252
|
+
this.localTcpServer.listen(0); // random port
|
|
253
|
+
})
|
|
254
|
+
.catch((e) => {
|
|
255
|
+
this.close();
|
|
256
|
+
if (e instanceof SshTunnelStageFailure) {
|
|
257
|
+
fail(e.stage, e.message);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
fail('forward', (0, common_1.getErrorMessage)(e));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
174
263
|
});
|
|
175
264
|
// When local tcp server is listening - resolve
|
|
176
265
|
this.localTcpServer.on('listening', () => {
|
|
177
266
|
const address = this.localTcpServer.address();
|
|
178
267
|
if (address === null || typeof address === 'string') {
|
|
179
|
-
|
|
268
|
+
fail('forward', 'local tcp server address has no port');
|
|
180
269
|
return;
|
|
181
270
|
}
|
|
182
271
|
console.log(`SSH tunnel ${this.id} - local tcp server listening on ${address.port}`);
|
|
272
|
+
settled = true;
|
|
183
273
|
resolve(address.port);
|
|
184
274
|
});
|
|
185
275
|
});
|
|
186
276
|
}
|
|
187
277
|
}
|
|
278
|
+
const DEFAULT_SSH_TUNNEL_OPTIONS = {
|
|
279
|
+
staticIp: null,
|
|
280
|
+
probeForward: false,
|
|
281
|
+
};
|
|
188
282
|
class SshTunnel {
|
|
189
|
-
constructor(credentials) {
|
|
283
|
+
constructor(credentials, options = DEFAULT_SSH_TUNNEL_OPTIONS) {
|
|
190
284
|
this.connect = async () => {
|
|
191
285
|
const { type } = this.originalCredentials;
|
|
192
286
|
switch (type) {
|
|
193
287
|
case common_1.WarehouseTypes.POSTGRES:
|
|
194
288
|
case common_1.WarehouseTypes.REDSHIFT:
|
|
195
289
|
if (this.originalCredentials.useSshTunnel) {
|
|
290
|
+
const remoteHostConfig = {
|
|
291
|
+
host: this.originalCredentials.sshTunnelHost || '',
|
|
292
|
+
port: this.originalCredentials.sshTunnelPort || 22,
|
|
293
|
+
username: this.originalCredentials.sshTunnelUser || '',
|
|
294
|
+
privateKey: this.originalCredentials.sshTunnelPrivateKey || '',
|
|
295
|
+
reconnect: false,
|
|
296
|
+
};
|
|
196
297
|
try {
|
|
197
|
-
const remoteHostConfig = {
|
|
198
|
-
host: this.originalCredentials.sshTunnelHost || '',
|
|
199
|
-
port: this.originalCredentials.sshTunnelPort || 22,
|
|
200
|
-
username: this.originalCredentials.sshTunnelUser || '',
|
|
201
|
-
privateKey: this.originalCredentials.sshTunnelPrivateKey ||
|
|
202
|
-
'',
|
|
203
|
-
reconnect: false,
|
|
204
|
-
};
|
|
205
298
|
this.sshConnection = new SSH2Tunnel({
|
|
206
299
|
sshHost: remoteHostConfig.host,
|
|
207
300
|
sshPort: remoteHostConfig.port,
|
|
@@ -209,6 +302,7 @@ class SshTunnel {
|
|
|
209
302
|
sshPrivateKey: remoteHostConfig.privateKey,
|
|
210
303
|
databaseHostOnRemote: this.originalCredentials.host,
|
|
211
304
|
databasePortOnRemote: this.originalCredentials.port,
|
|
305
|
+
probeForward: this.options.probeForward,
|
|
212
306
|
});
|
|
213
307
|
console.info(`Opening SSH tunnel to remote host: ${this.originalCredentials.host}:${this.originalCredentials.port}`);
|
|
214
308
|
this.localPort = await this.sshConnection.connect();
|
|
@@ -220,7 +314,26 @@ class SshTunnel {
|
|
|
220
314
|
}
|
|
221
315
|
catch (e) {
|
|
222
316
|
console.error(`Failed to connect to remote host: ${this.originalCredentials.host}:${this.originalCredentials.port}`);
|
|
223
|
-
|
|
317
|
+
const stage = e instanceof SshTunnelStageFailure
|
|
318
|
+
? e.stage
|
|
319
|
+
: 'tcp';
|
|
320
|
+
const cause = (0, common_1.getErrorMessage)(e);
|
|
321
|
+
throw new common_1.SshTunnelError((0, sshTunnelFailure_1.describeSshTunnelFailure)({
|
|
322
|
+
stage,
|
|
323
|
+
sshHost: remoteHostConfig.host,
|
|
324
|
+
sshPort: remoteHostConfig.port,
|
|
325
|
+
sshUser: remoteHostConfig.username,
|
|
326
|
+
databaseHost: this.originalCredentials.host,
|
|
327
|
+
databasePort: this.originalCredentials.port,
|
|
328
|
+
staticIp: this.options.staticIp,
|
|
329
|
+
cause,
|
|
330
|
+
}), {
|
|
331
|
+
stage,
|
|
332
|
+
sshHost: remoteHostConfig.host,
|
|
333
|
+
sshPort: remoteHostConfig.port,
|
|
334
|
+
sshUser: remoteHostConfig.username,
|
|
335
|
+
cause,
|
|
336
|
+
});
|
|
224
337
|
}
|
|
225
338
|
}
|
|
226
339
|
break;
|
|
@@ -246,6 +359,7 @@ class SshTunnel {
|
|
|
246
359
|
this.overrideCredentials = credentials;
|
|
247
360
|
this.localPort = undefined;
|
|
248
361
|
this.sshConnection = undefined;
|
|
362
|
+
this.options = options;
|
|
249
363
|
}
|
|
250
364
|
}
|
|
251
365
|
exports.SshTunnel = SshTunnel;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type SshTunnelStage } from '@lightdash/common';
|
|
2
|
+
export type SshClientError = Error & {
|
|
3
|
+
level?: string;
|
|
4
|
+
code?: string;
|
|
5
|
+
errors?: Error[];
|
|
6
|
+
};
|
|
7
|
+
export declare const sshClientErrorMessage: (error: SshClientError) => string;
|
|
8
|
+
export type SshConnectionProgress = {
|
|
9
|
+
tcpConnected: boolean;
|
|
10
|
+
handshakeCompleted: boolean;
|
|
11
|
+
};
|
|
12
|
+
export declare const classifySshClientError: (error: SshClientError, { tcpConnected, handshakeCompleted }: SshConnectionProgress) => SshTunnelStage;
|
|
13
|
+
export type SshTunnelFailureContext = {
|
|
14
|
+
stage: SshTunnelStage;
|
|
15
|
+
sshHost: string;
|
|
16
|
+
sshPort: number;
|
|
17
|
+
sshUser: string;
|
|
18
|
+
databaseHost: string;
|
|
19
|
+
databasePort: number;
|
|
20
|
+
staticIp: string | null;
|
|
21
|
+
cause: string;
|
|
22
|
+
};
|
|
23
|
+
export declare const describeSshTunnelFailure: ({ stage, sshHost, sshPort, sshUser, databaseHost, databasePort, staticIp, cause, }: SshTunnelFailureContext) => string;
|
|
24
|
+
//# sourceMappingURL=sshTunnelFailure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sshTunnelFailure.d.ts","sourceRoot":"","sources":["../../src/ssh/sshTunnelFailure.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAE3E,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;CACpB,CAAC;AAIF,eAAO,MAAM,qBAAqB,UAAW,cAAc,KAAG,MAO7D,CAAC;AAIF,MAAM,MAAM,qBAAqB,GAAG;IAChC,YAAY,EAAE,OAAO,CAAC;IACtB,kBAAkB,EAAE,OAAO,CAAC;CAC/B,CAAC;AAKF,eAAO,MAAM,sBAAsB,UACxB,cAAc,wCACiB,qBAAqB,KAC5D,cAOF,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IAClC,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,eAAO,MAAM,wBAAwB,uFASlC,uBAAuB,KAAG,MAmB5B,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.describeSshTunnelFailure = exports.classifySshClientError = exports.sshClientErrorMessage = void 0;
|
|
4
|
+
const common_1 = require("@lightdash/common");
|
|
5
|
+
// Node's happy-eyeballs connect surfaces a refused port as an AggregateError
|
|
6
|
+
// with an empty message, so fall back to the inner errors or the code.
|
|
7
|
+
const sshClientErrorMessage = (error) => {
|
|
8
|
+
if (error.message)
|
|
9
|
+
return error.message;
|
|
10
|
+
const inner = (error.errors ?? [])
|
|
11
|
+
.map((e) => e.message)
|
|
12
|
+
.filter((m) => m.length > 0);
|
|
13
|
+
if (inner.length > 0)
|
|
14
|
+
return inner.join('; ');
|
|
15
|
+
return error.code ?? 'connection failed';
|
|
16
|
+
};
|
|
17
|
+
exports.sshClientErrorMessage = sshClientErrorMessage;
|
|
18
|
+
const RESOLVE_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'EAI_FAIL']);
|
|
19
|
+
// The TCP connect is ours, so anything before it is resolve or tcp. After it,
|
|
20
|
+
// ssh2 tags errors with a level, and whether the handshake event fired
|
|
21
|
+
// separates handshake from auth.
|
|
22
|
+
const classifySshClientError = (error, { tcpConnected, handshakeCompleted }) => {
|
|
23
|
+
if (error.code && RESOLVE_CODES.has(error.code))
|
|
24
|
+
return 'resolve';
|
|
25
|
+
if (!tcpConnected)
|
|
26
|
+
return 'tcp';
|
|
27
|
+
if (handshakeCompleted)
|
|
28
|
+
return 'auth';
|
|
29
|
+
if (error.level === 'client-authentication')
|
|
30
|
+
return 'auth';
|
|
31
|
+
if (/authentication methods failed/i.test(error.message))
|
|
32
|
+
return 'auth';
|
|
33
|
+
return 'handshake';
|
|
34
|
+
};
|
|
35
|
+
exports.classifySshClientError = classifySshClientError;
|
|
36
|
+
const describeSshTunnelFailure = ({ stage, sshHost, sshPort, sshUser, databaseHost, databasePort, staticIp, cause, }) => {
|
|
37
|
+
const from = staticIp ? ` from Lightdash (${staticIp})` : '';
|
|
38
|
+
const allow = staticIp
|
|
39
|
+
? `Allow inbound SSH from ${staticIp} in the bastion's security group or firewall.`
|
|
40
|
+
: "Allow inbound SSH from Lightdash's IP in the bastion's security group or firewall.";
|
|
41
|
+
switch (stage) {
|
|
42
|
+
case 'resolve':
|
|
43
|
+
return `The SSH host "${sshHost}" does not resolve to an address. Check the SSH Remote Host for typos and make sure it is a public hostname or IP. (${cause})`;
|
|
44
|
+
case 'tcp':
|
|
45
|
+
return `Could not reach ${sshHost} on port ${sshPort}${from}. ${allow} (${cause})`;
|
|
46
|
+
case 'handshake':
|
|
47
|
+
return `Reached ${sshHost}:${sshPort} but nothing answered as an SSH server. Check that sshd is listening on that port and that a firewall is not dropping the session after connect. (${cause})`;
|
|
48
|
+
case 'auth':
|
|
49
|
+
return `The bastion ${sshHost} rejected the key for user "${sshUser}". Add the public key shown above to ~/.ssh/authorized_keys for that user on the bastion, and check the user exists and the file is mode 600. (${cause})`;
|
|
50
|
+
case 'forward':
|
|
51
|
+
return `Connected to the bastion, but it could not open a connection to ${databaseHost}:${databasePort}. Either AllowTcpForwarding is off in the bastion's sshd_config, or the bastion cannot reach the database endpoint. Check the database's security group allows the bastion on port ${databasePort}. (${cause})`;
|
|
52
|
+
default:
|
|
53
|
+
return (0, common_1.assertUnreachable)(stage, 'Unknown SSH tunnel stage');
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
exports.describeSshTunnelFailure = describeSshTunnelFailure;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sshTunnelFailure.test.d.ts","sourceRoot":"","sources":["../../src/ssh/sshTunnelFailure.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const sshTunnelFailure_1 = require("./sshTunnelFailure");
|
|
5
|
+
const err = (message, extra = {}) => Object.assign(new Error(message), extra);
|
|
6
|
+
const before = { tcpConnected: false, handshakeCompleted: false };
|
|
7
|
+
const afterTcp = { tcpConnected: true, handshakeCompleted: false };
|
|
8
|
+
const afterHandshake = { tcpConnected: true, handshakeCompleted: true };
|
|
9
|
+
(0, vitest_1.describe)('classifySshClientError', () => {
|
|
10
|
+
(0, vitest_1.it)('maps DNS failures to resolve', () => {
|
|
11
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('getaddrinfo ENOTFOUND bastion.example', {
|
|
12
|
+
code: 'ENOTFOUND',
|
|
13
|
+
}), before)).toBe('resolve');
|
|
14
|
+
});
|
|
15
|
+
(0, vitest_1.it)('maps anything before the TCP connect to tcp, including a dropped SYN', () => {
|
|
16
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('connect ETIMEDOUT 34.195.79.184:22 after 10000ms', {
|
|
17
|
+
code: 'ETIMEDOUT',
|
|
18
|
+
}), before)).toBe('tcp');
|
|
19
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('connect ECONNREFUSED 127.0.0.1:2222', {
|
|
20
|
+
code: 'ECONNREFUSED',
|
|
21
|
+
}), before)).toBe('tcp');
|
|
22
|
+
});
|
|
23
|
+
(0, vitest_1.it)('maps a ready timeout after the TCP connect to handshake', () => {
|
|
24
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('Timed out while waiting for handshake', {
|
|
25
|
+
level: 'client-timeout',
|
|
26
|
+
}), afterTcp)).toBe('handshake');
|
|
27
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('Connection lost before handshake', {
|
|
28
|
+
level: 'client-socket',
|
|
29
|
+
}), afterTcp)).toBe('handshake');
|
|
30
|
+
});
|
|
31
|
+
(0, vitest_1.it)('maps rejected keys to auth', () => {
|
|
32
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('All configured authentication methods failed', {
|
|
33
|
+
level: 'client-authentication',
|
|
34
|
+
}), afterHandshake)).toBe('auth');
|
|
35
|
+
});
|
|
36
|
+
(0, vitest_1.it)('treats a socket reset after the handshake as auth, not tcp', () => {
|
|
37
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.classifySshClientError)(err('read ECONNRESET', {
|
|
38
|
+
code: 'ECONNRESET',
|
|
39
|
+
level: 'client-socket',
|
|
40
|
+
}), afterHandshake)).toBe('auth');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
(0, vitest_1.describe)('describeSshTunnelFailure', () => {
|
|
44
|
+
const base = {
|
|
45
|
+
sshHost: '34.195.79.184',
|
|
46
|
+
sshPort: 22,
|
|
47
|
+
sshUser: 'lightdash',
|
|
48
|
+
databaseHost: 'redshift.internal',
|
|
49
|
+
databasePort: 5439,
|
|
50
|
+
staticIp: '35.1.2.3',
|
|
51
|
+
cause: 'connect ETIMEDOUT',
|
|
52
|
+
};
|
|
53
|
+
(0, vitest_1.it)('tells the bastion admin which IP to allow on tcp failures', () => {
|
|
54
|
+
const message = (0, sshTunnelFailure_1.describeSshTunnelFailure)({ ...base, stage: 'tcp' });
|
|
55
|
+
(0, vitest_1.expect)(message).toContain('Could not reach 34.195.79.184 on port 22');
|
|
56
|
+
(0, vitest_1.expect)(message).toContain('from Lightdash (35.1.2.3)');
|
|
57
|
+
(0, vitest_1.expect)(message).toContain('Allow inbound SSH from 35.1.2.3');
|
|
58
|
+
});
|
|
59
|
+
(0, vitest_1.it)('omits the IP when the instance has none', () => {
|
|
60
|
+
const message = (0, sshTunnelFailure_1.describeSshTunnelFailure)({
|
|
61
|
+
...base,
|
|
62
|
+
staticIp: null,
|
|
63
|
+
stage: 'tcp',
|
|
64
|
+
});
|
|
65
|
+
(0, vitest_1.expect)(message).not.toContain('35.1.2.3');
|
|
66
|
+
(0, vitest_1.expect)(message).toContain("Lightdash's IP");
|
|
67
|
+
});
|
|
68
|
+
(0, vitest_1.it)('names the user on auth failures', () => {
|
|
69
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.describeSshTunnelFailure)({ ...base, stage: 'auth' })).toContain('rejected the key for user "lightdash"');
|
|
70
|
+
});
|
|
71
|
+
(0, vitest_1.it)('names the database endpoint on forward failures', () => {
|
|
72
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.describeSshTunnelFailure)({ ...base, stage: 'forward' })).toContain('redshift.internal:5439');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
(0, vitest_1.describe)('sshClientErrorMessage', () => {
|
|
76
|
+
(0, vitest_1.it)('unpacks the empty-message AggregateError Node emits for a refused port', () => {
|
|
77
|
+
const aggregate = err('', {
|
|
78
|
+
code: 'ECONNREFUSED',
|
|
79
|
+
errors: [
|
|
80
|
+
new Error('connect ECONNREFUSED ::1:2299'),
|
|
81
|
+
new Error('connect ECONNREFUSED 127.0.0.1:2299'),
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.sshClientErrorMessage)(aggregate)).toBe('connect ECONNREFUSED ::1:2299; connect ECONNREFUSED 127.0.0.1:2299');
|
|
85
|
+
});
|
|
86
|
+
(0, vitest_1.it)('falls back to the code, then a fixed string', () => {
|
|
87
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.sshClientErrorMessage)(err('', { code: 'ETIMEDOUT' }))).toBe('ETIMEDOUT');
|
|
88
|
+
(0, vitest_1.expect)((0, sshTunnelFailure_1.sshClientErrorMessage)(err(''))).toBe('connection failed');
|
|
89
|
+
});
|
|
90
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightdash/warehouses",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.165.1",
|
|
4
4
|
"description": "Warehouse connectors for Lightdash",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"snowflake-sdk": "2.3.4",
|
|
29
29
|
"ssh2": "1.14.0",
|
|
30
30
|
"trino-client": "0.2.9",
|
|
31
|
-
"@lightdash/common": "2.
|
|
31
|
+
"@lightdash/common": "2.165.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"oxlint": "1.75.0",
|