@lightdash/warehouses 2.164.0 → 2.165.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.
@@ -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
- constructor(credentials: T);
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,EAI7B,MAAM,mBAAmB,CAAC;AAoO3B,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,YAAY,WAAW,EAAE,CAAC,EAKzB;IAED,OAAO,QAAa,OAAO,CAAC,CAAC,CAAC,CA0D5B;IAEF,UAAU,QAAa,OAAO,CAAC,IAAI,CAAC,CAIlC;CACL"}
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"}
@@ -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
- reject(this.error);
205
+ fail('tcp', this.error.message);
206
+ return;
162
207
  }
163
208
  this.localTcpServer.on('error', (e) => {
164
- reject(e);
209
+ fail('forward', e.message);
165
210
  });
166
211
  this.sshClient.on('error', (e) => {
167
- reject(e);
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
- this.sshClient.connect(this.sshConnectConfig);
170
- // When SSH Client has connected - start listening on local tcp server
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
- this.localTcpServer.listen(0); // random port
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
- reject(new Error('local tcp server address has no port'));
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
- throw new common_1.SshTunnelError(`Could not open SSH tunnel: ${(0, common_1.getErrorMessage)(e)}`);
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sshTunnelFailure.test.d.ts.map
@@ -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.164.0",
3
+ "version": "2.165.0",
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.164.0"
31
+ "@lightdash/common": "2.165.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "oxlint": "1.75.0",