Skip to content

Commit 332ed98

Browse files
KnowZerojonah-iden
andauthored
Basic ssh_config support (#15499)
--------- Signed-off-by: KnowZero <[email protected]> Signed-off-by: Jonah Iden <[email protected]> Co-authored-by: Jonah Iden <[email protected]>
1 parent 9795e1a commit 332ed98

File tree

7 files changed

+173
-8
lines changed

7 files changed

+173
-8
lines changed

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/remote/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"glob": "^8.1.0",
1616
"socket.io": "^4.5.3",
1717
"socket.io-client": "^4.5.3",
18+
"ssh-config": "^5.0.3",
1819
"ssh2": "^1.15.0",
1920
"ssh2-sftp-client": "^9.1.0",
2021
"tslib": "^2.6.2"

packages/remote/src/electron-browser/remote-preferences.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
// *****************************************************************************
1616

17+
import { OS } from '@theia/core';
1718
import { interfaces } from '@theia/core/shared/inversify';
1819
import {
1920
PreferenceProxy,
@@ -41,11 +42,20 @@ export const RemotePreferenceSchema: PreferenceSchema = {
4142
'Controls the template used to download the node.js binaries for the remote backend. Points to the official node.js website by default. Uses multiple placeholders:'
4243
) + '\n- ' + nodeDownloadTemplateParts.join('\n- ')
4344
},
45+
'remote.ssh.configFile': {
46+
type: 'string',
47+
default: OS.backend.isWindows ? '${env:USERPROFILE}\\.ssh\\config' : '${env:HOME}/.ssh/config',
48+
markdownDescription: nls.localize(
49+
'theia/remote/ssh/configFile',
50+
'Remote SSH Config file'
51+
)
52+
},
4453
}
4554
};
4655

4756
export interface RemoteConfiguration {
4857
'remote.nodeDownloadTemplate': string;
58+
'remote.ssh.configFile': string;
4959
}
5060

5161
export const RemotePreferenceContribution = Symbol('RemotePreferenceContribution');

packages/remote/src/electron-browser/remote-ssh-contribution.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
// *****************************************************************************
1616

17-
import { Command, MessageService, nls, QuickInputService } from '@theia/core';
17+
import { Command, MessageService, nls, QuickInputService, QuickPickInput } from '@theia/core';
1818
import { inject, injectable } from '@theia/core/shared/inversify';
1919
import { RemoteSSHConnectionProvider } from '../electron-common/remote-ssh-connection-provider';
2020
import { AbstractRemoteRegistryContribution, RemoteRegistry } from './remote-registry-contribution';
2121
import { RemotePreferences } from './remote-preferences';
22+
import SSHConfig, { Directive } from 'ssh-config';
2223

2324
export namespace RemoteSSHCommands {
2425
export const CONNECT: Command = Command.toLocalizedCommand({
@@ -31,6 +32,11 @@ export namespace RemoteSSHCommands {
3132
category: 'SSH',
3233
label: 'Connect Current Window to Host...',
3334
}, 'theia/remoteSSH/connect');
35+
export const CONNECT_CURRENT_WINDOW_TO_CONFIG_HOST: Command = Command.toLocalizedCommand({
36+
id: 'remote.ssh.connectToConfigHost',
37+
category: 'SSH',
38+
label: 'Connect Current Window to Host in Config File...',
39+
}, 'theia/remoteSSH/connectToConfigHost');
3440
}
3541

3642
@injectable()
@@ -55,6 +61,58 @@ export class RemoteSSHContribution extends AbstractRemoteRegistryContribution {
5561
registry.registerCommand(RemoteSSHCommands.CONNECT_CURRENT_WINDOW, {
5662
execute: () => this.connect(false)
5763
});
64+
registry.registerCommand(RemoteSSHCommands.CONNECT_CURRENT_WINDOW_TO_CONFIG_HOST, {
65+
execute: () => this.connectToConfigHost()
66+
});
67+
}
68+
69+
async getConfigFilePath(): Promise<string | undefined> {
70+
return this.remotePreferences['remote.ssh.configFile'];
71+
}
72+
73+
async connectToConfigHost(): Promise<void> {
74+
const quickPicks: QuickPickInput[] = [];
75+
const sshConfig = await this.sshConnectionProvider.getSSHConfig(await this.getConfigFilePath());
76+
77+
const wildcardCheck = /[\?\*\%]/;
78+
79+
for (const record of sshConfig) {
80+
// check if its a section and if it has a single value
81+
if (!('config' in record) || !(typeof record.value === 'string')) {
82+
continue;
83+
}
84+
if (record.param.toLowerCase() === 'host' && !wildcardCheck.test(record.value)) {
85+
const rec: Record<string, string | string[]> = ((record.config)
86+
.filter((entry): entry is Directive => entry.type === SSHConfig.DIRECTIVE))
87+
.reduce(
88+
(pv, item) => ({ ...pv, [item.param.toLowerCase()]: item.value }), { 'host': record.value }
89+
);
90+
const host = (rec.hostname || rec.host) + ':' + (rec.port || '22');
91+
const user = rec.user || 'root';
92+
93+
quickPicks.push({
94+
label: <string>rec.host,
95+
id: user + '@' + host
96+
});
97+
98+
}
99+
100+
}
101+
102+
const selection = await this.quickInputService?.showQuickPick(quickPicks, {
103+
placeholder: nls.localizeByDefault('Select an option to open a Remote Window')
104+
});
105+
if (selection && selection.id) {
106+
try {
107+
let [user, host] = selection.id!.split('@', 2);
108+
host = selection.label;
109+
110+
const remotePort = await this.sendSSHConnect(host, user);
111+
this.openRemote(remotePort, false);
112+
} catch (err) {
113+
this.messageService.error(`${nls.localize('theia/remote/sshFailure', 'Could not open SSH connection to remote.')} ${err.message ?? String(err)}`);
114+
}
115+
}
58116
}
59117

60118
async connect(newWindow: boolean): Promise<void> {
@@ -73,6 +131,15 @@ export class RemoteSSHContribution extends AbstractRemoteRegistryContribution {
73131
user = split[0];
74132
host = split[1];
75133
}
134+
if (!user) {
135+
const configHost = await this.sshConnectionProvider.matchSSHConfigHost(host, undefined, await this.getConfigFilePath());
136+
137+
if (configHost) {
138+
if (!user && configHost.user) {
139+
user = <string>configHost.user;
140+
}
141+
}
142+
}
76143
if (!user) {
77144
user = await this.quickInputService.input({
78145
title: nls.localize('theia/remote/enterUser', 'Enter SSH user name'),
@@ -96,7 +163,8 @@ export class RemoteSSHContribution extends AbstractRemoteRegistryContribution {
96163
return this.sshConnectionProvider.establishConnection({
97164
host,
98165
user,
99-
nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate']
166+
nodeDownloadTemplate: this.remotePreferences['remote.nodeDownloadTemplate'],
167+
customConfigFile: await this.getConfigFilePath()
100168
});
101169
}
102170
}

packages/remote/src/electron-common/remote-ssh-connection-provider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
// *****************************************************************************
1616

17+
import * as SshConfig from 'ssh-config';
18+
1719
export const RemoteSSHConnectionProviderPath = '/remote/ssh';
1820

1921
export const RemoteSSHConnectionProvider = Symbol('RemoteSSHConnectionProvider');
@@ -22,8 +24,15 @@ export interface RemoteSSHConnectionProviderOptions {
2224
user: string;
2325
host: string;
2426
nodeDownloadTemplate?: string;
27+
customConfigFile?: string;
28+
}
29+
30+
export interface SSHConfig extends Array<SshConfig.Line> {
31+
compute(opts: string | SshConfig.MatchOptions): Record<string, string | string[]>;
2532
}
2633

2734
export interface RemoteSSHConnectionProvider {
2835
establishConnection(options: RemoteSSHConnectionProviderOptions): Promise<string>;
36+
getSSHConfig(customConfigFile?: string): Promise<SSHConfig>;
37+
matchSSHConfigHost(host: string, user?: string, customConfigFile?: string): Promise<Record<string, string | string[]> | undefined>;
2938
}

packages/remote/src/electron-node/ssh/remote-ssh-connection-provider.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,18 @@ import * as ssh2 from 'ssh2';
1818
import * as net from 'net';
1919
import * as fs from '@theia/core/shared/fs-extra';
2020
import SftpClient = require('ssh2-sftp-client');
21+
import * as SshConfig from 'ssh-config';
2122
import { Emitter, Event, MessageService, QuickInputService } from '@theia/core';
2223
import { inject, injectable } from '@theia/core/shared/inversify';
23-
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderOptions } from '../../electron-common/remote-ssh-connection-provider';
24+
import { RemoteSSHConnectionProvider, RemoteSSHConnectionProviderOptions, SSHConfig } from '../../electron-common/remote-ssh-connection-provider';
2425
import { RemoteConnectionService } from '../remote-connection-service';
2526
import { RemoteProxyServerProvider } from '../remote-proxy-server-provider';
2627
import { RemoteConnection, RemoteExecOptions, RemoteExecResult, RemoteExecTester, RemoteStatusReport } from '../remote-types';
2728
import { Deferred, timeout } from '@theia/core/lib/common/promise-util';
2829
import { SSHIdentityFileCollector, SSHKey } from './ssh-identity-file-collector';
2930
import { RemoteSetupService } from '../setup/remote-setup-service';
3031
import { generateUuid } from '@theia/core/lib/common/uuid';
32+
import { EnvVariablesServer, EnvVariable } from '@theia/core/lib/common/env-variables';
3133

3234
@injectable()
3335
export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvider {
@@ -50,17 +52,68 @@ export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvi
5052
@inject(MessageService)
5153
protected readonly messageService: MessageService;
5254

55+
@inject(EnvVariablesServer)
56+
protected readonly envVariablesServer: EnvVariablesServer;
57+
5358
protected passwordRetryCount = 3;
5459
protected passphraseRetryCount = 3;
5560

61+
async matchSSHConfigHost(host: string, user?: string, customConfigFile?: string): Promise<Record<string, string | string[]> | undefined> {
62+
const sshConfig = await this.getSSHConfig(customConfigFile);
63+
const host2 = host.trim().split(':');
64+
65+
const record = Object.fromEntries(
66+
Object.entries(sshConfig.compute(host2[0])).map(([k, v]) => [k.toLowerCase(), v])
67+
);
68+
69+
// Generate a regexp to find wildcards and process the hostname with the wildcards
70+
if (record.host) {
71+
const checkHost = new RegExp('^' + (<string>record.host)
72+
.replace(/([^\w\*\?])/g, '\\$1')
73+
.replace(/([\?]+)/g, (...m) => '(' + '.'.repeat(m[1].length) + ')')
74+
.replace(/\*/g, '(.+)') + '$');
75+
76+
const match = host2[0].match(checkHost);
77+
if (match) {
78+
if (record.hostname) {
79+
record.hostname = (<string>record.hostname).replace('%h', match[1]);
80+
}
81+
}
82+
83+
if (host2[1]) {
84+
record.port = host2[1];
85+
}
86+
}
87+
88+
return record;
89+
}
90+
91+
async getSSHConfig(customConfigFile?: string): Promise<SSHConfig> {
92+
const reg = /\$\{env:(.+?)\}/;
93+
const promises: Promise<EnvVariable | undefined>[] = [];
94+
customConfigFile?.replace(reg, (...m) => {
95+
promises.push(this.envVariablesServer.getValue(m[1]));
96+
return m[0];
97+
});
98+
99+
const repVal = await Promise.all(promises);
100+
const sshConfigFilePath = customConfigFile!.replace(reg, () => repVal.shift()?.value || '');
101+
102+
const buff: Buffer = await fs.promises.readFile(sshConfigFilePath);
103+
104+
const sshConfig = SshConfig.parse(buff.toString());
105+
106+
return sshConfig;
107+
}
108+
56109
async establishConnection(options: RemoteSSHConnectionProviderOptions): Promise<string> {
57110
const progress = await this.messageService.showProgress({
58111
text: 'Remote SSH'
59112
});
60113
const report: RemoteStatusReport = message => progress.report({ message });
61114
report('Connecting to remote system...');
62115
try {
63-
const remote = await this.establishSSHConnection(options.host, options.user);
116+
const remote = await this.establishSSHConnection(options.host, options.user, options.customConfigFile);
64117
await this.remoteSetup.setup({
65118
connection: remote,
66119
report,
@@ -82,10 +135,26 @@ export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvi
82135
}
83136
}
84137

85-
async establishSSHConnection(host: string, user: string): Promise<RemoteSSHConnection> {
138+
async establishSSHConnection(host: string, user: string, customConfigFile?: string): Promise<RemoteSSHConnection> {
86139
const deferred = new Deferred<RemoteSSHConnection>();
87140
const sshClient = new ssh2.Client();
88-
const identityFiles = await this.identityFileCollector.gatherIdentityFiles();
141+
const sshHostConfig = await this.matchSSHConfigHost(host, user, customConfigFile);
142+
const identityFiles = await this.identityFileCollector.gatherIdentityFiles(undefined, <string[]>sshHostConfig?.identityfile);
143+
144+
let algorithms: ssh2.Algorithms | undefined = undefined;
145+
if (sshHostConfig) {
146+
if (!user && sshHostConfig.user) {
147+
user = <string>sshHostConfig.user;
148+
}
149+
if (sshHostConfig.hostname) {
150+
host = sshHostConfig.hostname + ':' + (sshHostConfig.port || '22');
151+
} else if (sshHostConfig.port) {
152+
host = sshHostConfig.host + ':' + (sshHostConfig.port || '22');
153+
}
154+
if (sshHostConfig.compression && (<string>sshHostConfig.compression).toLowerCase() === 'yes') {
155+
algorithms = { compress: ['[email protected]', 'zlib'] };
156+
}
157+
}
89158
const hostUrl = new URL(`ssh://${host}`);
90159
const sshAuthHandler = this.getAuthHandler(user, hostUrl.hostname, identityFiles);
91160
sshClient
@@ -110,6 +179,7 @@ export class RemoteSSHConnectionProviderImpl implements RemoteSSHConnectionProvi
110179
host: hostUrl.hostname,
111180
port: hostUrl.port ? parseInt(hostUrl.port, 10) : undefined,
112181
username: user,
182+
algorithms: algorithms,
113183
authHandler: (methodsLeft, successes, callback) => (sshAuthHandler(methodsLeft, successes, callback), undefined)
114184
});
115185
return deferred.promise;

packages/remote/src/electron-node/ssh/ssh-identity-file-collector.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export class SSHIdentityFileCollector {
5454
];
5555
}
5656

57-
async gatherIdentityFiles(sshAgentSock?: string): Promise<SSHKey[]> {
58-
const identityFiles = this.getDefaultIdentityFiles();
57+
async gatherIdentityFiles(sshAgentSock?: string, overrideIdentityFiles?: string[]): Promise<SSHKey[]> {
58+
const identityFiles = overrideIdentityFiles || this.getDefaultIdentityFiles();
5959

6060
const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => {
6161
keyPath = await fs.pathExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath;

0 commit comments

Comments
 (0)