@milaboratories/pl-deployments 1.1.4 → 1.1.5

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/src/ssh/ssh.ts CHANGED
@@ -5,7 +5,8 @@ import dns from 'dns';
5
5
  import fs from 'fs';
6
6
  import { readFile } from 'fs/promises';
7
7
  import upath from 'upath';
8
- import type { MiLogger } from '@milaboratories/ts-helpers';
8
+ import { RetryablePromise, type MiLogger } from '@milaboratories/ts-helpers';
9
+ import { randomBytes } from 'crypto';
9
10
 
10
11
  const defaultConfig: ConnectConfig = {
11
12
  keepaliveInterval: 60000,
@@ -60,16 +61,7 @@ export class SshClient {
60
61
  */
61
62
  public async connect(config: ConnectConfig) {
62
63
  this.config = config;
63
- return new Promise((resolve, reject) => {
64
- this.client.on('ready', () => {
65
- resolve(undefined);
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
- }).on('error', (err: any) => {
68
- reject(new Error(`ssh.connect: error occurred: ${err}`));
69
- }).on('timeout', () => {
70
- reject(new Error(`timeout was occurred while waiting for SSH connection.`));
71
- }).connect(config);
72
- });
64
+ return await connect(this.client, config, () => {}, () => {});
73
65
  }
74
66
 
75
67
  /**
@@ -154,61 +146,95 @@ export class SshClient {
154
146
  * @returns { server: net.Server } A promise resolving with the created server instance.
155
147
  */
156
148
  public async forwardPort(ports: { remotePort: number; localPort: number; localHost?: string }, config?: ConnectConfig): Promise<{ server: net.Server }> {
149
+ const log = `ssh.forward:${ports.localPort}:${ports.remotePort}.id_${randomBytes(1).toString('hex')}`;
157
150
  config = config ?? this.config;
158
- return new Promise((resolve, reject) => {
159
- if (!config) {
160
- reject('No config defined');
161
- return;
162
- }
163
- const conn = new Client();
164
- let server: net.Server;
165
- conn.on('ready', () => {
166
- this.logger.info(`[SSH] Connection to ${config.host}. Remote port ${ports.remotePort} will be available locally on the ${ports.localPort}`);
167
- server = net.createServer({ pauseOnConnect: true }, (localSocket) => {
168
- conn.forwardOut('127.0.0.1', 0, '127.0.0.1', ports.remotePort,
169
- (err, stream) => {
170
- if (err) {
171
- console.error('Error opening SSH channel:', err.message);
172
- localSocket.end();
173
- return;
174
- }
175
- localSocket.pipe(stream);
176
- stream.pipe(localSocket);
177
- localSocket.resume();
178
- },
179
- );
151
+
152
+ // we make this thing persistent so that if the connection
153
+ // drops (it happened in the past because of lots of errors and forwardOut opened channels),
154
+ // we'll recreate it here.
155
+ const persistentClient = new RetryablePromise((p: RetryablePromise<Client>) => {
156
+ return new Promise<Client>((resolve, reject) => {
157
+ const client = new Client();
158
+
159
+ client.on('ready', () => {
160
+ this.logger.info(`${log}.client.ready`);
161
+ resolve(client);
180
162
  });
181
- server.listen(ports.localPort, '127.0.0.1', () => {
182
- this.logger.info(`[+] Port local ${ports.localPort} available locally for remote port → :${ports.remotePort}`);
183
- resolve({ server });
163
+
164
+ client.on('error', (err) => {
165
+ this.logger.info(`${log}.client.error: ${err}`);
166
+ p.reset();
167
+ reject(err);
184
168
  });
185
169
 
186
- server.on('error', (err) => {
187
- conn.end();
188
- server.close();
189
- reject(new Error(`ssh.forwardPort: server error: ${err}`));
170
+ client.on('close', () => {
171
+ this.logger.info(`${log}.client.closed`);
172
+ p.reset();
190
173
  });
191
174
 
192
- server.on('close', () => {
193
- this.logger.info(`Server closed ${JSON.stringify(ports)}`);
194
- if (conn) {
195
- this.logger.info(`End SSH connection`);
196
- conn.end();
197
- }
175
+ client.connect(config!);
176
+ });
177
+ });
178
+
179
+ await persistentClient.ensure(); // warm up a connection
180
+
181
+ return new Promise((resolve, reject) => {
182
+ const server = net.createServer({ pauseOnConnect: true }, async (localSocket) => {
183
+ const sockLog = `${log}.sock_${randomBytes(1).toString('hex')}`;
184
+ // this.logger.info(`${sockLog}.localSocket: start connection`);
185
+ let conn: Client;
186
+ try {
187
+ conn = await persistentClient.ensure();
188
+ } catch (e: unknown) {
189
+ this.logger.info(`${sockLog}.persistentClient.catch: ${e}`);
190
+ localSocket.end();
191
+ return;
192
+ }
193
+
194
+ let stream: ClientChannel;
195
+ try {
196
+ stream = await forwardOut(this.logger, conn, '127.0.0.1', 0, '127.0.0.1', ports.remotePort);
197
+ } catch (e: unknown) {
198
+ this.logger.error(`${sockLog}.forwardOut.err: ${e}`);
199
+ localSocket.end();
200
+ return;
201
+ }
202
+
203
+ localSocket.pipe(stream);
204
+ stream.pipe(localSocket);
205
+ localSocket.resume();
206
+ // this.logger.info(`${sockLog}.forwardOut: connected`);
207
+
208
+ stream.on('error', (err: unknown) => {
209
+ this.logger.error(`${sockLog}.stream.error: ${err}`);
210
+ localSocket.end();
211
+ stream.end();
212
+ });
213
+ stream.on('close', () => {
214
+ // this.logger.info(`${sockLog}.stream.close: closed`);
215
+ localSocket.end();
216
+ stream.end();
217
+ });
218
+ localSocket.on('close', () => {
219
+ this.logger.info(`${sockLog}.localSocket: closed`);
220
+ localSocket.end();
221
+ stream.end();
198
222
  });
199
223
  });
200
224
 
201
- conn.on('error', (err) => {
202
- this.logger.error(`[SSH] SSH connection error, ports: ${JSON.stringify(ports)}, err: ${err.message}`);
203
- server?.close();
204
- reject(`ssh.forwardPort: conn.err: ${err}`);
225
+ server.listen(ports.localPort, '127.0.0.1', () => {
226
+ this.logger.info(`${log}.server: started listening`);
227
+ resolve({ server });
205
228
  });
206
229
 
207
- conn.on('close', () => {
208
- this.logger.info(`[SSH] Connection closed, ports: ${JSON.stringify(ports)}`);
230
+ server.on('error', (err) => {
231
+ server.close();
232
+ reject(new Error(`${log}.server: error: ${JSON.stringify(err)}`));
209
233
  });
210
234
 
211
- conn.connect(config);
235
+ server.on('close', () => {
236
+ this.logger.info(`${log}.server: closed ${JSON.stringify(ports)}`);
237
+ });
212
238
  });
213
239
  }
214
240
 
@@ -440,7 +466,7 @@ export class SshClient {
440
466
 
441
467
  public uploadFileUsingExistingSftp(sftp: SFTPWrapper, localPath: string, remotePath: string, mode: number = 0o660) {
442
468
  return new Promise((resolve, reject) => {
443
- readFile(localPath).then(async (result) => {
469
+ readFile(localPath).then(async (result: Buffer) => {
444
470
  this.writeFile(sftp, remotePath, result, mode)
445
471
  .then(() => {
446
472
  resolve(undefined);
@@ -598,3 +624,40 @@ export class SshClient {
598
624
  }
599
625
 
600
626
  export type SshExecResult = { stdout: string; stderr: string };
627
+
628
+ async function connect(
629
+ client: Client,
630
+ config: ConnectConfig,
631
+ onError: (e: unknown) => void,
632
+ onClose: () => void,
633
+ ): Promise<Client> {
634
+ return new Promise((resolve, reject) => {
635
+ client.on('ready', () => {
636
+ resolve(client);
637
+ });
638
+
639
+ client.on('error', (err: unknown) => {
640
+ onError(err);
641
+ reject(new Error(`ssh.connect: error occurred: ${err}`));
642
+ });
643
+
644
+ client.on('close', () => {
645
+ onClose();
646
+ });
647
+
648
+ client.connect(config);
649
+ });
650
+ }
651
+
652
+ async function forwardOut(logger: MiLogger, conn: Client, localHost: string, localPort: number, remoteHost: string, remotePort: number): Promise<ClientChannel> {
653
+ return new Promise((resolve, reject) => {
654
+ conn.forwardOut(localHost, localPort, remoteHost, remotePort, (err, stream) => {
655
+ if (err) {
656
+ logger.error(`forwardOut.error: ${err}`);
657
+ return reject(err);
658
+ }
659
+
660
+ return resolve(stream);
661
+ });
662
+ });
663
+ }