@theia/remote 1.51.0 → 1.53.0-next.55
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 +61 -61
- package/lib/electron-node/setup/remote-setup-script-service.js +11 -11
- package/package.json +5 -5
- package/src/electron-browser/port-forwarding/port-forwading-contribution.ts +33 -33
- package/src/electron-browser/port-forwarding/port-forwarding-service.ts +92 -92
- package/src/electron-browser/port-forwarding/port-forwarding-widget.tsx +140 -140
- package/src/electron-browser/remote-electron-file-dialog-service.ts +47 -47
- package/src/electron-browser/remote-frontend-contribution.ts +143 -143
- package/src/electron-browser/remote-frontend-module.ts +68 -68
- package/src/electron-browser/remote-preferences.ts +62 -62
- package/src/electron-browser/remote-registry-contribution.ts +73 -73
- package/src/electron-browser/remote-service.ts +31 -31
- package/src/electron-browser/remote-ssh-contribution.ts +102 -102
- package/src/electron-browser/style/port-forwarding-widget.css +44 -44
- package/src/electron-common/remote-port-forwarding-provider.ts +30 -30
- package/src/electron-common/remote-ssh-connection-provider.ts +29 -29
- package/src/electron-common/remote-status-service.ts +35 -35
- package/src/electron-node/backend-remote-service-impl.ts +45 -45
- package/src/electron-node/remote-backend-module.ts +87 -87
- package/src/electron-node/remote-connection-service.ts +56 -56
- package/src/electron-node/remote-connection-socket-provider.ts +34 -34
- package/src/electron-node/remote-port-forwarding-provider.ts +66 -66
- package/src/electron-node/remote-proxy-server-provider.ts +37 -37
- package/src/electron-node/remote-status-service.ts +41 -41
- package/src/electron-node/remote-types.ts +64 -64
- package/src/electron-node/setup/app-native-dependency-contribution.ts +48 -48
- package/src/electron-node/setup/main-copy-contribution.ts +28 -28
- package/src/electron-node/setup/remote-copy-contribution.ts +74 -74
- package/src/electron-node/setup/remote-copy-service.ts +116 -116
- package/src/electron-node/setup/remote-native-dependency-contribution.ts +63 -63
- package/src/electron-node/setup/remote-native-dependency-service.ts +111 -111
- package/src/electron-node/setup/remote-node-setup-service.ts +123 -123
- package/src/electron-node/setup/remote-setup-script-service.ts +146 -146
- package/src/electron-node/setup/remote-setup-service.ts +220 -220
- package/src/electron-node/ssh/remote-ssh-connection-provider.ts +358 -358
- package/src/electron-node/ssh/ssh-identity-file-collector.ts +137 -137
- package/src/package.spec.ts +29 -29
|
@@ -1,358 +1,358 @@
|
|
|
1
|
-
// *****************************************************************************
|
|
2
|
-
// Copyright (C) 2023 TypeFox and others.
|
|
3
|
-
//
|
|
4
|
-
// This program and the accompanying materials are made available under the
|
|
5
|
-
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
-
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
-
//
|
|
8
|
-
// This Source Code may also be made available under the following Secondary
|
|
9
|
-
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
-
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
-
// with the GNU Classpath Exception which is available at
|
|
12
|
-
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
-
//
|
|
14
|
-
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
-
// *****************************************************************************
|
|
16
|
-
|
|
17
|
-
import * as ssh2 from 'ssh2';
|
|
18
|
-
import * as net from 'net';
|
|
19
|
-
import * as fs from '@theia/core/shared/fs-extra';
|
|
20
|
-
import SftpClient = require('ssh2-sftp-client');
|
|
21
|
-
import { Emitter, Event, MessageService, QuickInputService } from '@theia/core';
|
|
22
|
-
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
23
|
-
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderOptions } from '../../electron-common/remote-ssh-connection-provider';
|
|
24
|
-
import { RemoteConnectionService } from '../remote-connection-service';
|
|
25
|
-
import { RemoteProxyServerProvider } from '../remote-proxy-server-provider';
|
|
26
|
-
import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '../remote-types';
|
|
27
|
-
import { Deferred, timeout } from '@theia/core/lib/common/promise-util';
|
|
28
|
-
import { SSHIdentityFileCollector, SSHKey } from './ssh-identity-file-collector';
|
|
29
|
-
import { RemoteSetupService } from '../setup/remote-setup-service';
|
|
30
|
-
import { generateUuid } from '@theia/core/lib/common/uuid';
|
|
31
|
-
|
|
32
|
-
@injectable()
|
|
33
|
-
export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvider {
|
|
34
|
-
|
|
35
|
-
@inject(RemoteConnectionService)
|
|
36
|
-
protected readonly remoteConnectionService: RemoteConnectionService;
|
|
37
|
-
|
|
38
|
-
@inject(RemoteProxyServerProvider)
|
|
39
|
-
protected readonly serverProvider: RemoteProxyServerProvider;
|
|
40
|
-
|
|
41
|
-
@inject(SSHIdentityFileCollector)
|
|
42
|
-
protected readonly identityFileCollector: SSHIdentityFileCollector;
|
|
43
|
-
|
|
44
|
-
@inject(RemoteSetupService)
|
|
45
|
-
protected readonly remoteSetup: RemoteSetupService;
|
|
46
|
-
|
|
47
|
-
@inject(QuickInputService)
|
|
48
|
-
protected readonly quickInputService: QuickInputService;
|
|
49
|
-
|
|
50
|
-
@inject(MessageService)
|
|
51
|
-
protected readonly messageService: MessageService;
|
|
52
|
-
|
|
53
|
-
protected passwordRetryCount = 3;
|
|
54
|
-
protected passphraseRetryCount = 3;
|
|
55
|
-
|
|
56
|
-
async establishConnection(options: RemoteSSHConnectionProviderOptions): Promise<string> {
|
|
57
|
-
const progress = await this.messageService.showProgress({
|
|
58
|
-
text: 'Remote SSH'
|
|
59
|
-
});
|
|
60
|
-
const report: RemoteStatusReport = message => progress.report({ message });
|
|
61
|
-
report('Connecting to remote system...');
|
|
62
|
-
try {
|
|
63
|
-
const remote = await this.establishSSHConnection(options.host, options.user);
|
|
64
|
-
await this.remoteSetup.setup({
|
|
65
|
-
connection: remote,
|
|
66
|
-
report,
|
|
67
|
-
nodeDownloadTemplate: options.nodeDownloadTemplate
|
|
68
|
-
});
|
|
69
|
-
const registration = this.remoteConnectionService.register(remote);
|
|
70
|
-
const server = await this.serverProvider.getProxyServer(socket => {
|
|
71
|
-
remote.forwardOut(socket);
|
|
72
|
-
});
|
|
73
|
-
remote.onDidDisconnect(() => {
|
|
74
|
-
server.close();
|
|
75
|
-
registration.dispose();
|
|
76
|
-
});
|
|
77
|
-
const localPort = (server.address() as net.AddressInfo).port;
|
|
78
|
-
remote.localPort = localPort;
|
|
79
|
-
return localPort.toString();
|
|
80
|
-
} finally {
|
|
81
|
-
progress.cancel();
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async establishSSHConnection(host: string, user: string): Promise<RemoteSSHConnection> {
|
|
86
|
-
const deferred = new Deferred<RemoteSSHConnection>();
|
|
87
|
-
const sshClient = new ssh2.Client();
|
|
88
|
-
const identityFiles = await this.identityFileCollector.gatherIdentityFiles();
|
|
89
|
-
const hostUrl = new URL(`ssh://${host}`);
|
|
90
|
-
const sshAuthHandler = this.getAuthHandler(user, hostUrl.hostname, identityFiles);
|
|
91
|
-
sshClient
|
|
92
|
-
.on('ready', async () => {
|
|
93
|
-
const connection = new RemoteSSHConnection({
|
|
94
|
-
client: sshClient,
|
|
95
|
-
id: generateUuid(),
|
|
96
|
-
name: hostUrl.hostname,
|
|
97
|
-
type: 'SSH'
|
|
98
|
-
});
|
|
99
|
-
try {
|
|
100
|
-
await this.testConnection(connection);
|
|
101
|
-
deferred.resolve(connection);
|
|
102
|
-
} catch (err) {
|
|
103
|
-
deferred.reject(err);
|
|
104
|
-
}
|
|
105
|
-
}).on('end', () => {
|
|
106
|
-
console.log(`Ended remote connection to host '${user}@${hostUrl.hostname}'`);
|
|
107
|
-
}).on('error', err => {
|
|
108
|
-
deferred.reject(err);
|
|
109
|
-
}).connect({
|
|
110
|
-
host: hostUrl.hostname,
|
|
111
|
-
port: hostUrl.port ? parseInt(hostUrl.port, 10) : undefined,
|
|
112
|
-
username: user,
|
|
113
|
-
authHandler: (methodsLeft, successes, callback) => (sshAuthHandler(methodsLeft, successes, callback), undefined)
|
|
114
|
-
});
|
|
115
|
-
return deferred.promise;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Sometimes, ssh2.exec will not execute and retrieve any data right after the `ready` event fired.
|
|
120
|
-
* In this method, we just perform `echo hello` in a loop to ensure that the connection is really ready.
|
|
121
|
-
* See also https://github.com/mscdex/ssh2/issues/48
|
|
122
|
-
*/
|
|
123
|
-
protected async testConnection(connection: RemoteSSHConnection): Promise<void> {
|
|
124
|
-
for (let i = 0; i < 100; i++) {
|
|
125
|
-
const result = await connection.exec('echo hello');
|
|
126
|
-
if (result.stdout.includes('hello')) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
await timeout(50);
|
|
130
|
-
}
|
|
131
|
-
throw new Error('SSH connection failed testing. Could not execute "echo"');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
protected getAuthHandler(user: string, host: string, identityKeys: SSHKey[]): ssh2.AuthHandlerMiddleware {
|
|
135
|
-
let passwordRetryCount = this.passwordRetryCount;
|
|
136
|
-
let keyboardRetryCount = this.passphraseRetryCount;
|
|
137
|
-
// `false` is a valid return value, indicating that the authentication has failed
|
|
138
|
-
const END_AUTH = false as unknown as ssh2.AuthenticationType;
|
|
139
|
-
// `null` indicates that we just want to continue with the next auth type
|
|
140
|
-
// eslint-disable-next-line no-null/no-null
|
|
141
|
-
const NEXT_AUTH = null as unknown as ssh2.AuthenticationType;
|
|
142
|
-
return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: ssh2.NextAuthHandler) => {
|
|
143
|
-
if (!methodsLeft) {
|
|
144
|
-
return callback({
|
|
145
|
-
type: 'none',
|
|
146
|
-
username: user,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
if (methodsLeft && methodsLeft.includes('publickey') && identityKeys.length) {
|
|
150
|
-
const identityKey = identityKeys.shift()!;
|
|
151
|
-
if (identityKey.isPrivate) {
|
|
152
|
-
return callback({
|
|
153
|
-
type: 'publickey',
|
|
154
|
-
username: user,
|
|
155
|
-
key: identityKey.parsedKey
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
if (!await fs.pathExists(identityKey.filename)) {
|
|
159
|
-
// Try next identity file
|
|
160
|
-
return callback(NEXT_AUTH);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const keyBuffer = await fs.promises.readFile(identityKey.filename);
|
|
164
|
-
let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase
|
|
165
|
-
if (result instanceof Error && result.message.match(/no passphrase given/)) {
|
|
166
|
-
let passphraseRetryCount = this.passphraseRetryCount;
|
|
167
|
-
while (result instanceof Error && passphraseRetryCount > 0) {
|
|
168
|
-
const passphrase = await this.quickInputService.input({
|
|
169
|
-
title: `Enter passphrase for ${identityKey.filename}`,
|
|
170
|
-
password: true
|
|
171
|
-
});
|
|
172
|
-
if (!passphrase) {
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
result = ssh2.utils.parseKey(keyBuffer, passphrase);
|
|
176
|
-
passphraseRetryCount--;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (!result || result instanceof Error) {
|
|
180
|
-
// Try next identity file
|
|
181
|
-
return callback(NEXT_AUTH);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const key = Array.isArray(result) ? result[0] : result;
|
|
185
|
-
return callback({
|
|
186
|
-
type: 'publickey',
|
|
187
|
-
username: user,
|
|
188
|
-
key
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
if (methodsLeft && methodsLeft.includes('password') && passwordRetryCount > 0) {
|
|
192
|
-
const password = await this.quickInputService.input({
|
|
193
|
-
title: `Enter password for ${user}@${host}`,
|
|
194
|
-
password: true
|
|
195
|
-
});
|
|
196
|
-
passwordRetryCount--;
|
|
197
|
-
|
|
198
|
-
return callback(password
|
|
199
|
-
? {
|
|
200
|
-
type: 'password',
|
|
201
|
-
username: user,
|
|
202
|
-
password
|
|
203
|
-
}
|
|
204
|
-
: END_AUTH);
|
|
205
|
-
}
|
|
206
|
-
if (methodsLeft && methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0) {
|
|
207
|
-
return callback({
|
|
208
|
-
type: 'keyboard-interactive',
|
|
209
|
-
username: user,
|
|
210
|
-
prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => {
|
|
211
|
-
const responses: string[] = [];
|
|
212
|
-
for (const prompt of prompts) {
|
|
213
|
-
const response = await this.quickInputService.input({
|
|
214
|
-
title: `(${user}@${host}) ${prompt.prompt}`,
|
|
215
|
-
password: !prompt.echo
|
|
216
|
-
});
|
|
217
|
-
if (response === undefined) {
|
|
218
|
-
keyboardRetryCount = 0;
|
|
219
|
-
break;
|
|
220
|
-
}
|
|
221
|
-
responses.push(response);
|
|
222
|
-
}
|
|
223
|
-
keyboardRetryCount--;
|
|
224
|
-
finish(responses);
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
callback(END_AUTH);
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export interface RemoteSSHConnectionOptions {
|
|
235
|
-
id: string;
|
|
236
|
-
name: string;
|
|
237
|
-
type: string;
|
|
238
|
-
client: ssh2.Client;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export class RemoteSSHConnection implements RemoteConnection {
|
|
242
|
-
|
|
243
|
-
id: string;
|
|
244
|
-
name: string;
|
|
245
|
-
type: string;
|
|
246
|
-
client: ssh2.Client;
|
|
247
|
-
localPort = 0;
|
|
248
|
-
remotePort = 0;
|
|
249
|
-
|
|
250
|
-
private sftpClientPromise: Promise<SftpClient>;
|
|
251
|
-
|
|
252
|
-
private readonly onDidDisconnectEmitter = new Emitter<void>();
|
|
253
|
-
|
|
254
|
-
get onDidDisconnect(): Event<void> {
|
|
255
|
-
return this.onDidDisconnectEmitter.event;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
constructor(options: RemoteSSHConnectionOptions) {
|
|
259
|
-
this.id = options.id;
|
|
260
|
-
this.type = options.type;
|
|
261
|
-
this.name = options.name;
|
|
262
|
-
this.client = options.client;
|
|
263
|
-
this.onDidDisconnect(() => this.dispose());
|
|
264
|
-
this.client.on('end', () => {
|
|
265
|
-
this.onDidDisconnectEmitter.fire();
|
|
266
|
-
});
|
|
267
|
-
this.sftpClientPromise = this.setupSftpClient();
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
protected async setupSftpClient(): Promise<SftpClient> {
|
|
271
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
-
const sftpClient = new SftpClient() as any;
|
|
273
|
-
// A hack to set the internal ssh2 client of the sftp client
|
|
274
|
-
// That way, we don't have to create a second connection
|
|
275
|
-
sftpClient.client = this.client;
|
|
276
|
-
// Calling this function establishes the sftp connection on the ssh client
|
|
277
|
-
await sftpClient.getSftpChannel();
|
|
278
|
-
return sftpClient;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
forwardOut(socket: net.Socket, port?: number): void {
|
|
282
|
-
this.client.forwardOut(socket.localAddress!, socket.localPort!, '127.0.0.1', port ?? this.remotePort, (err, stream) => {
|
|
283
|
-
if (err) {
|
|
284
|
-
console.debug('Proxy message rejected', err);
|
|
285
|
-
} else {
|
|
286
|
-
stream.pipe(socket).pipe(stream);
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async copy(localPath: string, remotePath: string): Promise<void> {
|
|
292
|
-
const sftpClient = await this.sftpClientPromise;
|
|
293
|
-
await sftpClient.put(localPath, remotePath);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
exec(cmd: string, args?: string[], options: RemoteExecOptions = {}): Promise<RemoteExecResult> {
|
|
297
|
-
const deferred = new Deferred<RemoteExecResult>();
|
|
298
|
-
cmd = this.buildCmd(cmd, args);
|
|
299
|
-
this.client.exec(cmd, options, (err, stream) => {
|
|
300
|
-
if (err) {
|
|
301
|
-
return deferred.reject(err);
|
|
302
|
-
}
|
|
303
|
-
let stdout = '';
|
|
304
|
-
let stderr = '';
|
|
305
|
-
stream.on('close', () => {
|
|
306
|
-
deferred.resolve({ stdout, stderr });
|
|
307
|
-
}).on('data', (data: Buffer | string) => {
|
|
308
|
-
stdout += data.toString();
|
|
309
|
-
}).stderr.on('data', (data: Buffer | string) => {
|
|
310
|
-
stderr += data.toString();
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
return deferred.promise;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options: RemoteExecOptions = {}): Promise<RemoteExecResult> {
|
|
317
|
-
const deferred = new Deferred<RemoteExecResult>();
|
|
318
|
-
cmd = this.buildCmd(cmd, args);
|
|
319
|
-
this.client.exec(cmd, {
|
|
320
|
-
...options,
|
|
321
|
-
// Ensure that the process on the remote ends when the connection is closed
|
|
322
|
-
pty: true
|
|
323
|
-
}, (err, stream) => {
|
|
324
|
-
if (err) {
|
|
325
|
-
return deferred.reject(err);
|
|
326
|
-
}
|
|
327
|
-
// in pty mode we only have an stdout stream
|
|
328
|
-
// return stdout as stderr as well
|
|
329
|
-
let stdout = '';
|
|
330
|
-
stream.on('close', () => {
|
|
331
|
-
if (deferred.state === 'unresolved') {
|
|
332
|
-
deferred.resolve({ stdout, stderr: stdout });
|
|
333
|
-
}
|
|
334
|
-
}).on('data', (data: Buffer | string) => {
|
|
335
|
-
if (deferred.state === 'unresolved') {
|
|
336
|
-
stdout += data.toString();
|
|
337
|
-
|
|
338
|
-
if (tester(stdout, stdout)) {
|
|
339
|
-
deferred.resolve({ stdout, stderr: stdout });
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
return deferred.promise;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
protected buildCmd(cmd: string, args?: string[]): string {
|
|
348
|
-
const escapedArgs = args?.map(arg => `"${arg.replace(/"/g, '\\"')}"`) || [];
|
|
349
|
-
const fullCmd = cmd + (escapedArgs.length > 0 ? (' ' + escapedArgs.join(' ')) : '');
|
|
350
|
-
return fullCmd;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
dispose(): void {
|
|
354
|
-
this.client.end();
|
|
355
|
-
this.client.destroy();
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
}
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2023 TypeFox and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import * as ssh2 from 'ssh2';
|
|
18
|
+
import * as net from 'net';
|
|
19
|
+
import * as fs from '@theia/core/shared/fs-extra';
|
|
20
|
+
import SftpClient = require('ssh2-sftp-client');
|
|
21
|
+
import { Emitter, Event, MessageService, QuickInputService } from '@theia/core';
|
|
22
|
+
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
23
|
+
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderOptions } from '../../electron-common/remote-ssh-connection-provider';
|
|
24
|
+
import { RemoteConnectionService } from '../remote-connection-service';
|
|
25
|
+
import { RemoteProxyServerProvider } from '../remote-proxy-server-provider';
|
|
26
|
+
import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '../remote-types';
|
|
27
|
+
import { Deferred, timeout } from '@theia/core/lib/common/promise-util';
|
|
28
|
+
import { SSHIdentityFileCollector, SSHKey } from './ssh-identity-file-collector';
|
|
29
|
+
import { RemoteSetupService } from '../setup/remote-setup-service';
|
|
30
|
+
import { generateUuid } from '@theia/core/lib/common/uuid';
|
|
31
|
+
|
|
32
|
+
@injectable()
|
|
33
|
+
export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvider {
|
|
34
|
+
|
|
35
|
+
@inject(RemoteConnectionService)
|
|
36
|
+
protected readonly remoteConnectionService: RemoteConnectionService;
|
|
37
|
+
|
|
38
|
+
@inject(RemoteProxyServerProvider)
|
|
39
|
+
protected readonly serverProvider: RemoteProxyServerProvider;
|
|
40
|
+
|
|
41
|
+
@inject(SSHIdentityFileCollector)
|
|
42
|
+
protected readonly identityFileCollector: SSHIdentityFileCollector;
|
|
43
|
+
|
|
44
|
+
@inject(RemoteSetupService)
|
|
45
|
+
protected readonly remoteSetup: RemoteSetupService;
|
|
46
|
+
|
|
47
|
+
@inject(QuickInputService)
|
|
48
|
+
protected readonly quickInputService: QuickInputService;
|
|
49
|
+
|
|
50
|
+
@inject(MessageService)
|
|
51
|
+
protected readonly messageService: MessageService;
|
|
52
|
+
|
|
53
|
+
protected passwordRetryCount = 3;
|
|
54
|
+
protected passphraseRetryCount = 3;
|
|
55
|
+
|
|
56
|
+
async establishConnection(options: RemoteSSHConnectionProviderOptions): Promise<string> {
|
|
57
|
+
const progress = await this.messageService.showProgress({
|
|
58
|
+
text: 'Remote SSH'
|
|
59
|
+
});
|
|
60
|
+
const report: RemoteStatusReport = message => progress.report({ message });
|
|
61
|
+
report('Connecting to remote system...');
|
|
62
|
+
try {
|
|
63
|
+
const remote = await this.establishSSHConnection(options.host, options.user);
|
|
64
|
+
await this.remoteSetup.setup({
|
|
65
|
+
connection: remote,
|
|
66
|
+
report,
|
|
67
|
+
nodeDownloadTemplate: options.nodeDownloadTemplate
|
|
68
|
+
});
|
|
69
|
+
const registration = this.remoteConnectionService.register(remote);
|
|
70
|
+
const server = await this.serverProvider.getProxyServer(socket => {
|
|
71
|
+
remote.forwardOut(socket);
|
|
72
|
+
});
|
|
73
|
+
remote.onDidDisconnect(() => {
|
|
74
|
+
server.close();
|
|
75
|
+
registration.dispose();
|
|
76
|
+
});
|
|
77
|
+
const localPort = (server.address() as net.AddressInfo).port;
|
|
78
|
+
remote.localPort = localPort;
|
|
79
|
+
return localPort.toString();
|
|
80
|
+
} finally {
|
|
81
|
+
progress.cancel();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async establishSSHConnection(host: string, user: string): Promise<RemoteSSHConnection> {
|
|
86
|
+
const deferred = new Deferred<RemoteSSHConnection>();
|
|
87
|
+
const sshClient = new ssh2.Client();
|
|
88
|
+
const identityFiles = await this.identityFileCollector.gatherIdentityFiles();
|
|
89
|
+
const hostUrl = new URL(`ssh://${host}`);
|
|
90
|
+
const sshAuthHandler = this.getAuthHandler(user, hostUrl.hostname, identityFiles);
|
|
91
|
+
sshClient
|
|
92
|
+
.on('ready', async () => {
|
|
93
|
+
const connection = new RemoteSSHConnection({
|
|
94
|
+
client: sshClient,
|
|
95
|
+
id: generateUuid(),
|
|
96
|
+
name: hostUrl.hostname,
|
|
97
|
+
type: 'SSH'
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
await this.testConnection(connection);
|
|
101
|
+
deferred.resolve(connection);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
deferred.reject(err);
|
|
104
|
+
}
|
|
105
|
+
}).on('end', () => {
|
|
106
|
+
console.log(`Ended remote connection to host '${user}@${hostUrl.hostname}'`);
|
|
107
|
+
}).on('error', err => {
|
|
108
|
+
deferred.reject(err);
|
|
109
|
+
}).connect({
|
|
110
|
+
host: hostUrl.hostname,
|
|
111
|
+
port: hostUrl.port ? parseInt(hostUrl.port, 10) : undefined,
|
|
112
|
+
username: user,
|
|
113
|
+
authHandler: (methodsLeft, successes, callback) => (sshAuthHandler(methodsLeft, successes, callback), undefined)
|
|
114
|
+
});
|
|
115
|
+
return deferred.promise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sometimes, ssh2.exec will not execute and retrieve any data right after the `ready` event fired.
|
|
120
|
+
* In this method, we just perform `echo hello` in a loop to ensure that the connection is really ready.
|
|
121
|
+
* See also https://github.com/mscdex/ssh2/issues/48
|
|
122
|
+
*/
|
|
123
|
+
protected async testConnection(connection: RemoteSSHConnection): Promise<void> {
|
|
124
|
+
for (let i = 0; i < 100; i++) {
|
|
125
|
+
const result = await connection.exec('echo hello');
|
|
126
|
+
if (result.stdout.includes('hello')) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await timeout(50);
|
|
130
|
+
}
|
|
131
|
+
throw new Error('SSH connection failed testing. Could not execute "echo"');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
protected getAuthHandler(user: string, host: string, identityKeys: SSHKey[]): ssh2.AuthHandlerMiddleware {
|
|
135
|
+
let passwordRetryCount = this.passwordRetryCount;
|
|
136
|
+
let keyboardRetryCount = this.passphraseRetryCount;
|
|
137
|
+
// `false` is a valid return value, indicating that the authentication has failed
|
|
138
|
+
const END_AUTH = false as unknown as ssh2.AuthenticationType;
|
|
139
|
+
// `null` indicates that we just want to continue with the next auth type
|
|
140
|
+
// eslint-disable-next-line no-null/no-null
|
|
141
|
+
const NEXT_AUTH = null as unknown as ssh2.AuthenticationType;
|
|
142
|
+
return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: ssh2.NextAuthHandler) => {
|
|
143
|
+
if (!methodsLeft) {
|
|
144
|
+
return callback({
|
|
145
|
+
type: 'none',
|
|
146
|
+
username: user,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (methodsLeft && methodsLeft.includes('publickey') && identityKeys.length) {
|
|
150
|
+
const identityKey = identityKeys.shift()!;
|
|
151
|
+
if (identityKey.isPrivate) {
|
|
152
|
+
return callback({
|
|
153
|
+
type: 'publickey',
|
|
154
|
+
username: user,
|
|
155
|
+
key: identityKey.parsedKey
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!await fs.pathExists(identityKey.filename)) {
|
|
159
|
+
// Try next identity file
|
|
160
|
+
return callback(NEXT_AUTH);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const keyBuffer = await fs.promises.readFile(identityKey.filename);
|
|
164
|
+
let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase
|
|
165
|
+
if (result instanceof Error && result.message.match(/no passphrase given/)) {
|
|
166
|
+
let passphraseRetryCount = this.passphraseRetryCount;
|
|
167
|
+
while (result instanceof Error && passphraseRetryCount > 0) {
|
|
168
|
+
const passphrase = await this.quickInputService.input({
|
|
169
|
+
title: `Enter passphrase for ${identityKey.filename}`,
|
|
170
|
+
password: true
|
|
171
|
+
});
|
|
172
|
+
if (!passphrase) {
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
result = ssh2.utils.parseKey(keyBuffer, passphrase);
|
|
176
|
+
passphraseRetryCount--;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!result || result instanceof Error) {
|
|
180
|
+
// Try next identity file
|
|
181
|
+
return callback(NEXT_AUTH);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const key = Array.isArray(result) ? result[0] : result;
|
|
185
|
+
return callback({
|
|
186
|
+
type: 'publickey',
|
|
187
|
+
username: user,
|
|
188
|
+
key
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (methodsLeft && methodsLeft.includes('password') && passwordRetryCount > 0) {
|
|
192
|
+
const password = await this.quickInputService.input({
|
|
193
|
+
title: `Enter password for ${user}@${host}`,
|
|
194
|
+
password: true
|
|
195
|
+
});
|
|
196
|
+
passwordRetryCount--;
|
|
197
|
+
|
|
198
|
+
return callback(password
|
|
199
|
+
? {
|
|
200
|
+
type: 'password',
|
|
201
|
+
username: user,
|
|
202
|
+
password
|
|
203
|
+
}
|
|
204
|
+
: END_AUTH);
|
|
205
|
+
}
|
|
206
|
+
if (methodsLeft && methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0) {
|
|
207
|
+
return callback({
|
|
208
|
+
type: 'keyboard-interactive',
|
|
209
|
+
username: user,
|
|
210
|
+
prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => {
|
|
211
|
+
const responses: string[] = [];
|
|
212
|
+
for (const prompt of prompts) {
|
|
213
|
+
const response = await this.quickInputService.input({
|
|
214
|
+
title: `(${user}@${host}) ${prompt.prompt}`,
|
|
215
|
+
password: !prompt.echo
|
|
216
|
+
});
|
|
217
|
+
if (response === undefined) {
|
|
218
|
+
keyboardRetryCount = 0;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
responses.push(response);
|
|
222
|
+
}
|
|
223
|
+
keyboardRetryCount--;
|
|
224
|
+
finish(responses);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
callback(END_AUTH);
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface RemoteSSHConnectionOptions {
|
|
235
|
+
id: string;
|
|
236
|
+
name: string;
|
|
237
|
+
type: string;
|
|
238
|
+
client: ssh2.Client;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class RemoteSSHConnection implements RemoteConnection {
|
|
242
|
+
|
|
243
|
+
id: string;
|
|
244
|
+
name: string;
|
|
245
|
+
type: string;
|
|
246
|
+
client: ssh2.Client;
|
|
247
|
+
localPort = 0;
|
|
248
|
+
remotePort = 0;
|
|
249
|
+
|
|
250
|
+
private sftpClientPromise: Promise<SftpClient>;
|
|
251
|
+
|
|
252
|
+
private readonly onDidDisconnectEmitter = new Emitter<void>();
|
|
253
|
+
|
|
254
|
+
get onDidDisconnect(): Event<void> {
|
|
255
|
+
return this.onDidDisconnectEmitter.event;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
constructor(options: RemoteSSHConnectionOptions) {
|
|
259
|
+
this.id = options.id;
|
|
260
|
+
this.type = options.type;
|
|
261
|
+
this.name = options.name;
|
|
262
|
+
this.client = options.client;
|
|
263
|
+
this.onDidDisconnect(() => this.dispose());
|
|
264
|
+
this.client.on('end', () => {
|
|
265
|
+
this.onDidDisconnectEmitter.fire();
|
|
266
|
+
});
|
|
267
|
+
this.sftpClientPromise = this.setupSftpClient();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
protected async setupSftpClient(): Promise<SftpClient> {
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
+
const sftpClient = new SftpClient() as any;
|
|
273
|
+
// A hack to set the internal ssh2 client of the sftp client
|
|
274
|
+
// That way, we don't have to create a second connection
|
|
275
|
+
sftpClient.client = this.client;
|
|
276
|
+
// Calling this function establishes the sftp connection on the ssh client
|
|
277
|
+
await sftpClient.getSftpChannel();
|
|
278
|
+
return sftpClient;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
forwardOut(socket: net.Socket, port?: number): void {
|
|
282
|
+
this.client.forwardOut(socket.localAddress!, socket.localPort!, '127.0.0.1', port ?? this.remotePort, (err, stream) => {
|
|
283
|
+
if (err) {
|
|
284
|
+
console.debug('Proxy message rejected', err);
|
|
285
|
+
} else {
|
|
286
|
+
stream.pipe(socket).pipe(stream);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async copy(localPath: string, remotePath: string): Promise<void> {
|
|
292
|
+
const sftpClient = await this.sftpClientPromise;
|
|
293
|
+
await sftpClient.put(localPath, remotePath);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
exec(cmd: string, args?: string[], options: RemoteExecOptions = {}): Promise<RemoteExecResult> {
|
|
297
|
+
const deferred = new Deferred<RemoteExecResult>();
|
|
298
|
+
cmd = this.buildCmd(cmd, args);
|
|
299
|
+
this.client.exec(cmd, options, (err, stream) => {
|
|
300
|
+
if (err) {
|
|
301
|
+
return deferred.reject(err);
|
|
302
|
+
}
|
|
303
|
+
let stdout = '';
|
|
304
|
+
let stderr = '';
|
|
305
|
+
stream.on('close', () => {
|
|
306
|
+
deferred.resolve({ stdout, stderr });
|
|
307
|
+
}).on('data', (data: Buffer | string) => {
|
|
308
|
+
stdout += data.toString();
|
|
309
|
+
}).stderr.on('data', (data: Buffer | string) => {
|
|
310
|
+
stderr += data.toString();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
return deferred.promise;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
execPartial(cmd: string, tester: RemoteExecTester, args?: string[], options: RemoteExecOptions = {}): Promise<RemoteExecResult> {
|
|
317
|
+
const deferred = new Deferred<RemoteExecResult>();
|
|
318
|
+
cmd = this.buildCmd(cmd, args);
|
|
319
|
+
this.client.exec(cmd, {
|
|
320
|
+
...options,
|
|
321
|
+
// Ensure that the process on the remote ends when the connection is closed
|
|
322
|
+
pty: true
|
|
323
|
+
}, (err, stream) => {
|
|
324
|
+
if (err) {
|
|
325
|
+
return deferred.reject(err);
|
|
326
|
+
}
|
|
327
|
+
// in pty mode we only have an stdout stream
|
|
328
|
+
// return stdout as stderr as well
|
|
329
|
+
let stdout = '';
|
|
330
|
+
stream.on('close', () => {
|
|
331
|
+
if (deferred.state === 'unresolved') {
|
|
332
|
+
deferred.resolve({ stdout, stderr: stdout });
|
|
333
|
+
}
|
|
334
|
+
}).on('data', (data: Buffer | string) => {
|
|
335
|
+
if (deferred.state === 'unresolved') {
|
|
336
|
+
stdout += data.toString();
|
|
337
|
+
|
|
338
|
+
if (tester(stdout, stdout)) {
|
|
339
|
+
deferred.resolve({ stdout, stderr: stdout });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
return deferred.promise;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
protected buildCmd(cmd: string, args?: string[]): string {
|
|
348
|
+
const escapedArgs = args?.map(arg => `"${arg.replace(/"/g, '\\"')}"`) || [];
|
|
349
|
+
const fullCmd = cmd + (escapedArgs.length > 0 ? (' ' + escapedArgs.join(' ')) : '');
|
|
350
|
+
return fullCmd;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
dispose(): void {
|
|
354
|
+
this.client.end();
|
|
355
|
+
this.client.destroy();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
}
|