diff --git a/build/gulpfile.extensions.js b/build/gulpfile.extensions.js index 4f745aeb..65ea5daf 100644 --- a/build/gulpfile.extensions.js +++ b/build/gulpfile.extensions.js @@ -55,6 +55,7 @@ const compilations = [ 'extensions/microsoft-authentication/tsconfig.json', 'extensions/notebook-renderers/tsconfig.json', 'extensions/npm/tsconfig.json', + 'extensions/open-remote-ssh/tsconfig.json', 'extensions/php-language-features/tsconfig.json', 'extensions/references-view/tsconfig.json', 'extensions/search-result/tsconfig.json', diff --git a/extensions/open-remote-ssh/CHANGELOG.md b/extensions/open-remote-ssh/CHANGELOG.md new file mode 100644 index 00000000..71a79fd2 --- /dev/null +++ b/extensions/open-remote-ssh/CHANGELOG.md @@ -0,0 +1,74 @@ +## 0.0.48 +- Support `%n` in ProxyCommand +- fix: add missing direct @types/ssh2-stream dependency (#177) +- fix Win32 internal error (#178) + +## 0.0.47 +- Add support for loong64 (#175) +- Add s390x support (#174) +- Support vscodium alpine reh (#142) + +## 0.0.46 +- Add riscv64 support (#147) + +## 0.0.45 +- Use windows-x64 server on windows-arm64 + +## 0.0.44 +- Update ssh2 lib +- Properly set extensionHost env variables + +## 0.0.43 +- Fix parsing multiple include directives + +## 0.0.42 +- Fix remote label to show port when connecting to a port other than 22 + +## 0.0.41 +- Take into account parsed port from ssh destination. Fixes (#110) + +## 0.0.40 +- Update ssh-config package + +## 0.0.39 + +- output error messages when downloading vscode server (#39) +- Add PreferredAuthentications support (#97) + +## 0.0.38 + +- Enable remote support for ppc64le (#93) + +## 0.0.37 + +- Default to Current OS User in Connection String if No User Provided (#91) +- Add support for (unofficial) DragonFly reh (#86) + +## 0.0.36 + +- Make wget support continue download (#85) + +## 0.0.35 + +- Fixes hardcoded agentsock for windows breaks pageant compatibility (#81) + +## 0.0.34 + +- Add remote.SSH.connectTimeout setting +- adding %r username replacement to proxycommand (#77) + +## 0.0.33 + +- feat: support %r user substitution in proxycommand + +## 0.0.32 + +- feat: use serverDownloadUrlTemplate from product.json (#59) + +## 0.0.31 + +- feat: support glob patterns in SSH include directives + +## 0.0.30 + +- feat: support file patterns in SSH include directives diff --git a/extensions/open-remote-ssh/README.md b/extensions/open-remote-ssh/README.md new file mode 100644 index 00000000..2a208f68 --- /dev/null +++ b/extensions/open-remote-ssh/README.md @@ -0,0 +1,48 @@ +# Open Remote - SSH + +## SSH Host Requirements + +You can connect to a running SSH server on the following platforms. + +**Supported**: + +- x86_64 Debian 8+, Ubuntu 16.04+, CentOS / RHEL 7+ Linux. +- ARMv7l (AArch32) Raspbian Stretch/9+ (32-bit). +- ARMv8l (AArch64) Ubuntu 18.04+ (64-bit). +- macOS 10.14+ (Mojave) +- Windows 10+ +- FreeBSD 13 (Requires manual remote-extension-host installation) +- DragonFlyBSD (Requires manual remote-extension-host installation) + +## Requirements + +**Activation** + +Enable the extension in your `argv.json` + +```json +{ + ... + "enable-proposed-api": [ + ..., + "jeanp413.open-remote-ssh", + ] + ... +} +``` + +which you can open by running the `Preferences: Configure Runtime Arguments` command. +The file is located in `~/.vscode-oss/argv.json`. + +**Alpine linux** + +When running on alpine linux, the packages `libstdc++` and `bash` are necessary and can be installed via +running + +```bash +sudo apk add bash libstdc++ +``` + +## SSH configuration file + +[OpenSSH](https://www.openssh.com/) supports using a [configuration file](https://linuxize.com/post/using-the-ssh-config-file/) to store all your different SSH connections. To use an SSH config file, run the `Remote-SSH: Open SSH Configuration File...` command. diff --git a/extensions/open-remote-ssh/extension-browser.webpack.config.js b/extensions/open-remote-ssh/extension-browser.webpack.config.js new file mode 100644 index 00000000..7fcc53a7 --- /dev/null +++ b/extensions/open-remote-ssh/extension-browser.webpack.config.js @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withBrowserDefaults = require('../shared.webpack.config').browser; + +module.exports = withBrowserDefaults({ + context: __dirname, + entry: { + extension: './src/extension.ts' + } +}); diff --git a/extensions/open-remote-ssh/extension.webpack.config.js b/extensions/open-remote-ssh/extension.webpack.config.js new file mode 100644 index 00000000..e124b7f4 --- /dev/null +++ b/extensions/open-remote-ssh/extension.webpack.config.js @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check + +'use strict'; + +const withDefaults = require('../shared.webpack.config'); +const { IgnorePlugin } = require('webpack'); + +module.exports = withDefaults({ + context: __dirname, + resolve: { + mainFields: ['module', 'main'] + }, + entry: { + extension: './src/extension.ts', + }, + externals: { + vscode: "commonjs vscode", + bufferutil: "commonjs bufferutil", + "utf-8-validate": "commonjs utf-8-validate", + }, + plugins: [ + new IgnorePlugin({ + resourceRegExp: /crypto\/build\/Release\/sshcrypto\.node$/, + }), + new IgnorePlugin({ + resourceRegExp: /cpu-features/, + }) + ] +}); diff --git a/extensions/open-remote-ssh/package-lock.json b/extensions/open-remote-ssh/package-lock.json new file mode 100644 index 00000000..8b9b7d82 --- /dev/null +++ b/extensions/open-remote-ssh/package-lock.json @@ -0,0 +1,370 @@ +{ + "name": "open-remote-ssh", + "version": "0.0.48", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-remote-ssh", + "version": "0.0.48", + "dependencies": { + "@jeanp413/ssh-config": "^4.3.1", + "glob": "^9.3.1", + "simple-socks": "git+https://github.com/jeanp413/simple-socks#main", + "socks": "^2.5.0", + "ssh2": "git+https://github.com/jeanp413/ssh2#master" + }, + "devDependencies": { + "@types/ssh2": "^0.5.52", + "@types/ssh2-streams": "0.1.12" + }, + "engines": { + "vscode": "^1.70.2" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.26.0.tgz", + "integrity": "sha512-YXHu5lN8kJCb1LOb9PgV6pvak43X2h4HvRApcN5SdWeaItQOzfn1hgP6jasD6KWQyJDBxrVmA9o9OivlnNJK/w==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jeanp413/ssh-config": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@jeanp413/ssh-config/-/ssh-config-4.3.1.tgz", + "integrity": "sha512-x0EaWRdjs5sPDNmYr11wVB1GdwWQgRekc7SbueuO5FK7YZUav98qZKtZZU5iSDKyxJkooCs3rgVizB1wIWrF7g==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.12.tgz", + "integrity": "sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/core-js-pure": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.40.0.tgz", + "integrity": "sha512-AtDzVIgRrmRKQai62yuSIN5vNiQjcJakJb4fbhVw3ehxx7Lohphvw9SGNWKhLFqSxC4ilD0g/L1huAYFQU3Q6A==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", + "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/simple-socks": { + "version": "2.2.2", + "resolved": "git+ssh://git@github.com/jeanp413/simple-socks.git#2ac739301a82d6baff04804ed494436a026acb60", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.16.8", + "binary": "^0.3.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/ssh2": { + "version": "1.14.0", + "resolved": "git+ssh://git@github.com/jeanp413/ssh2.git#a169f627213aa663e0aa2fd2f0ef5c8931890c26", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.9", + "nan": "^2.17.0" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/extensions/open-remote-ssh/package.json b/extensions/open-remote-ssh/package.json new file mode 100644 index 00000000..f6fc75a8 --- /dev/null +++ b/extensions/open-remote-ssh/package.json @@ -0,0 +1,351 @@ +{ + "name": "open-remote-ssh", + "displayName": "Open Remote - SSH", + "publisher": "voideditor", + "description": "Use any remote machine with a SSH server as your development environment.", + "version": "0.0.48", + "icon": "resources/icon.png", + "engines": { + "vscode": "^1.70.2" + }, + "extensionKind": [ + "ui" + ], + "enabledApiProposals": [ + "resolvers", + "contribViewsRemote" + ], + "keywords": [ + "remote development", + "remote", + "ssh" + ], + "api": "none", + "activationEvents": [ + "onCommand:openremotessh.openEmptyWindow", + "onCommand:openremotessh.openEmptyWindowInCurrentWindow", + "onCommand:openremotessh.openConfigFile", + "onCommand:openremotessh.showLog", + "onResolveRemoteAuthority:ssh-remote", + "onView:sshHosts" + ], + "main": "./out/extension.js", + "contributes": { + "configuration": { + "title": "Remote - SSH", + "properties": { + "remote.SSH.configFile": { + "type": "string", + "description": "The absolute file path to a custom SSH config file.", + "default": "", + "scope": "application" + }, + "remote.SSH.connectTimeout": { + "type": "number", + "description": "Specifies the timeout in seconds used for the SSH command that connects to the remote.", + "default": 60, + "scope": "application", + "minimum": 1 + }, + "remote.SSH.defaultExtensions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of extensions that should be installed automatically on all SSH hosts.", + "scope": "application" + }, + "remote.SSH.enableDynamicForwarding": { + "type": "boolean", + "description": "Whether to use SSH dynamic forwarding to allow setting up new port tunnels over an existing SSH connection.", + "scope": "application", + "default": true + }, + "remote.SSH.enableAgentForwarding": { + "type": "boolean", + "markdownDescription": "Enable fixing the remote environment so that the SSH config option `ForwardAgent` will take effect as expected from VS Code's remote extension host.", + "scope": "application", + "default": true + }, + "remote.SSH.serverDownloadUrlTemplate": { + "type": "string", + "description": "The URL from where the vscode server will be downloaded. You can use the following variables and they will be replaced dynamically:\n- ${quality}: vscode server quality, e.g. stable or insiders\n- ${version}: vscode server version, e.g. 1.69.0\n- ${commit}: vscode server release commit\n- ${arch}: vscode server arch, e.g. x64, armhf, arm64\n- ${release}: release number", + "scope": "application", + "default": "https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz" + }, + "remote.SSH.remotePlatform": { + "type": "object", + "description": "A map of the remote hostname to the platform for that remote. Valid values: linux, macos, windows.", + "scope": "application", + "default": {}, + "additionalProperties": { + "type": "string", + "enum": [ + "linux", + "macos", + "windows" + ] + } + }, + "remote.SSH.remoteServerListenOnSocket": { + "type": "boolean", + "description": "When true, the remote vscode server will listen on a socket path instead of opening a port. Only valid for Linux and macOS remotes. Requires `AllowStreamLocalForwarding` to be enabled for the SSH server.", + "default": false + }, + "remote.SSH.experimental.serverBinaryName": { + "type": "string", + "description": "**Experimental:** The name of the server binary, use this **only if** you are using a client without a corresponding server release", + "scope": "application", + "default": "" + } + } + }, + "views": { + "remote": [ + { + "id": "sshHosts", + "name": "SSH Targets", + "group": "targets@1", + "remoteName": "ssh-remote" + } + ] + }, + "commands": [ + { + "command": "openremotessh.openEmptyWindow", + "title": "Connect to Host...", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "title": "Connect Current Window to Host...", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.openConfigFile", + "title": "Open SSH Configuration File...", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.showLog", + "title": "Show Log", + "category": "Remote-SSH" + }, + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "title": "Connect to Host in New Window", + "icon": "$(empty-window)" + }, + { + "command": "openremotessh.explorer.emptyWindowInCurrentWindow", + "title": "Connect to Host in Current Window" + }, + { + "command": "openremotessh.explorer.reopenFolderInCurrentWindow", + "title": "Open on SSH Host in Current Window" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "title": "Open on SSH Host in New Window", + "icon": "$(folder-opened)" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "title": "Remove From Recent List", + "icon": "$(x)" + }, + { + "command": "openremotessh.explorer.refresh", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "openremotessh.explorer.configure", + "title": "Configure", + "icon": "$(gear)" + }, + { + "command": "openremotessh.explorer.add", + "title": "Add New", + "icon": "$(plus)" + } + ], + "resourceLabelFormatters": [ + { + "scheme": "vscode-remote", + "authority": "ssh-remote+*", + "formatting": { + "label": "${path}", + "separator": "/", + "tildify": true, + "workspaceSuffix": "SSH" + } + } + ], + "menus": { + "statusBar/remoteIndicator": [ + { + "command": "openremotessh.openEmptyWindow", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@1" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@2" + }, + { + "command": "openremotessh.openConfigFile", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@3" + }, + { + "command": "openremotessh.showLog", + "when": "remoteName =~ /^ssh-remote$/ && remoteConnectionState == connected", + "group": "remote_20_ssh_1general@4" + }, + { + "command": "openremotessh.openEmptyWindow", + "when": "remoteConnectionState == disconnected", + "group": "remote_20_ssh_3local@1" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "when": "remoteConnectionState == disconnected", + "group": "remote_20_ssh_3local@2" + }, + { + "command": "openremotessh.openConfigFile", + "when": "remoteConnectionState == disconnected", + "group": "remote_20_ssh_3local@3" + }, + { + "command": "openremotessh.openEmptyWindow", + "when": "!remoteName && !virtualWorkspace", + "group": "remote_20_ssh_3local@5" + }, + { + "command": "openremotessh.openEmptyWindowInCurrentWindow", + "when": "!remoteName && !virtualWorkspace", + "group": "remote_20_ssh_3local@6" + }, + { + "command": "openremotessh.openConfigFile", + "when": "!remoteName && !virtualWorkspace", + "group": "remote_20_ssh_3local@7" + } + ], + "commandPalette": [ + { + "command": "openremotessh.explorer.refresh", + "when": "false" + }, + { + "command": "openremotessh.explorer.configure", + "when": "false" + }, + { + "command": "openremotessh.explorer.add", + "when": "false" + }, + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.emptyWindowInCurrentWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.reopenFolderInCurrentWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "when": "false" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "when": "false" + } + ], + "view/title": [ + { + "command": "openremotessh.explorer.add", + "when": "view == sshHosts", + "group": "navigation" + }, + { + "command": "openremotessh.explorer.configure", + "when": "view == sshHosts", + "group": "navigation" + }, + { + "command": "openremotessh.explorer.refresh", + "when": "view == sshHosts", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotessh.explorer.host$/", + "group": "inline@1" + }, + { + "command": "openremotessh.explorer.emptyWindowInNewWindow", + "when": "viewItem =~ /^openremotessh.explorer.host$/", + "group": "navigation@2" + }, + { + "command": "openremotessh.explorer.emptyWindowInCurrentWindow", + "when": "viewItem =~ /^openremotessh.explorer.host$/", + "group": "navigation@1" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotessh.explorer.folder", + "group": "inline@1" + }, + { + "command": "openremotessh.explorer.reopenFolderInNewWindow", + "when": "viewItem == openremotessh.explorer.folder", + "group": "navigation@2" + }, + { + "command": "openremotessh.explorer.reopenFolderInCurrentWindow", + "when": "viewItem == openremotessh.explorer.folder", + "group": "navigation@1" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotessh.explorer.folder/", + "group": "navigation@3" + }, + { + "command": "openremotessh.explorer.deleteFolderHistoryItem", + "when": "viewItem =~ /^openremotessh.explorer.folder/", + "group": "inline@2" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "gulp compile-extension:open-remote-ssh", + "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", + "watch": "gulp watch-extension:open-remote-ssh", + "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" + }, + "devDependencies": { + "@types/ssh2": "^0.5.52", + "@types/ssh2-streams": "0.1.12" + }, + "dependencies": { + "glob": "^9.3.1", + "simple-socks": "git+https://github.com/jeanp413/simple-socks#main", + "socks": "^2.5.0", + "@jeanp413/ssh-config": "^4.3.1", + "ssh2": "git+https://github.com/jeanp413/ssh2#master" + } +} diff --git a/extensions/open-remote-ssh/resources/icon.png b/extensions/open-remote-ssh/resources/icon.png new file mode 100644 index 00000000..aa5a7b9f Binary files /dev/null and b/extensions/open-remote-ssh/resources/icon.png differ diff --git a/extensions/open-remote-ssh/src/authResolver.ts b/extensions/open-remote-ssh/src/authResolver.ts new file mode 100644 index 00000000..16db657f --- /dev/null +++ b/extensions/open-remote-ssh/src/authResolver.ts @@ -0,0 +1,464 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as net from 'net'; +import * as stream from 'stream'; +import { SocksClient, SocksClientOptions } from 'socks'; +import * as vscode from 'vscode'; +import * as ssh2 from 'ssh2'; +import type { ParsedKey } from 'ssh2-streams'; +import Log from './common/logger'; +import SSHDestination from './ssh/sshDestination'; +import SSHConnection, { SSHTunnelConfig } from './ssh/sshConnection'; +import SSHConfiguration from './ssh/sshConfig'; +import { gatherIdentityFiles } from './ssh/identityFiles'; +import { untildify, exists as fileExists } from './common/files'; +import { findRandomPort } from './common/ports'; +import { disposeAll } from './common/disposable'; +import { installCodeServer, ServerInstallError } from './serverSetup'; +import { isWindows } from './common/platform'; +import * as os from 'os'; + +const PASSWORD_RETRY_COUNT = 3; +const PASSPHRASE_RETRY_COUNT = 3; + +export const REMOTE_SSH_AUTHORITY = 'ssh-remote'; + +export function getRemoteAuthority(host: string) { + return `${REMOTE_SSH_AUTHORITY}+${host}`; +} + +class TunnelInfo implements vscode.Disposable { + constructor( + readonly localPort: number, + readonly remotePortOrSocketPath: number | string, + private disposables: vscode.Disposable[] + ) { + } + + dispose() { + disposeAll(this.disposables); + } +} + +interface SSHKey { + filename: string; + parsedKey: ParsedKey; + fingerprint: string; + agentSupport?: boolean; + isPrivate?: boolean; +} + +export class RemoteSSHResolver implements vscode.RemoteAuthorityResolver, vscode.Disposable { + + private proxyConnections: SSHConnection[] = []; + private sshConnection: SSHConnection | undefined; + private sshAgentSock: string | undefined; + private proxyCommandProcess: cp.ChildProcessWithoutNullStreams | undefined; + + private socksTunnel: SSHTunnelConfig | undefined; + private tunnels: TunnelInfo[] = []; + + private labelFormatterDisposable: vscode.Disposable | undefined; + + constructor( + readonly context: vscode.ExtensionContext, + readonly logger: Log + ) { + } + + resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { + const [type, dest] = authority.split('+'); + if (type !== REMOTE_SSH_AUTHORITY) { + throw new Error(`Invalid authority type for SSH resolver: ${type}`); + } + + this.logger.info(`Resolving ssh remote authority '${authority}' (attemp #${context.resolveAttempt})`); + + const sshDest = SSHDestination.parseEncoded(dest); + + // It looks like default values are not loaded yet when resolving a remote, + // so let's hardcode the default values here + const remoteSSHconfig = vscode.workspace.getConfiguration('remote.SSH'); + const enableDynamicForwarding = remoteSSHconfig.get('enableDynamicForwarding', true)!; + const enableAgentForwarding = remoteSSHconfig.get('enableAgentForwarding', true)!; + const serverDownloadUrlTemplate = remoteSSHconfig.get('serverDownloadUrlTemplate'); + const defaultExtensions = remoteSSHconfig.get('defaultExtensions', []); + const remotePlatformMap = remoteSSHconfig.get>('remotePlatform', {}); + const remoteServerListenOnSocket = remoteSSHconfig.get('remoteServerListenOnSocket', false)!; + const connectTimeout = remoteSSHconfig.get('connectTimeout', 60)!; + + return vscode.window.withProgress({ + title: `Setting up SSH Host ${sshDest.hostname}`, + location: vscode.ProgressLocation.Notification, + cancellable: false + }, async () => { + try { + const sshconfig = await SSHConfiguration.loadFromFS(); + const sshHostConfig = sshconfig.getHostConfiguration(sshDest.hostname); + const sshHostName = sshHostConfig['HostName'] ? sshHostConfig['HostName'].replace('%h', sshDest.hostname) : sshDest.hostname; + const sshUser = sshHostConfig['User'] || sshDest.user || os.userInfo().username || ''; // https://github.com/openssh/openssh-portable/blob/5ec5504f1d328d5bfa64280cd617c3efec4f78f3/sshconnect.c#L1561-L1562 + const sshPort = sshHostConfig['Port'] ? parseInt(sshHostConfig['Port'], 10) : (sshDest.port || 22); + + this.sshAgentSock = sshHostConfig['IdentityAgent'] || process.env['SSH_AUTH_SOCK'] || (isWindows ? '\\\\.\\pipe\\openssh-ssh-agent' : undefined); + this.sshAgentSock = this.sshAgentSock ? untildify(this.sshAgentSock) : undefined; + const agentForward = enableAgentForwarding && (sshHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes'; + const agent = agentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; + + const preferredAuthentications = sshHostConfig['PreferredAuthentications'] ? sshHostConfig['PreferredAuthentications'].split(',').map(s => s.trim()) : ['publickey', 'password', 'keyboard-interactive']; + + const identityFiles: string[] = (sshHostConfig['IdentityFile'] as unknown as string[]) || []; + const identitiesOnly = (sshHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; + const identityKeys = await gatherIdentityFiles(identityFiles, this.sshAgentSock, identitiesOnly, this.logger); + + // Create proxy jump connections if any + let proxyStream: ssh2.ClientChannel | stream.Duplex | undefined; + if (sshHostConfig['ProxyJump']) { + const proxyJumps = sshHostConfig['ProxyJump'].split(',').filter(i => !!i.trim()) + .map(i => { + const proxy = SSHDestination.parse(i); + const proxyHostConfig = sshconfig.getHostConfiguration(proxy.hostname); + return [proxy, proxyHostConfig] as [SSHDestination, Record]; + }); + for (let i = 0; i < proxyJumps.length; i++) { + const [proxy, proxyHostConfig] = proxyJumps[i]; + const proxyHostName = proxyHostConfig['HostName'] || proxy.hostname; + const proxyUser = proxyHostConfig['User'] || proxy.user || sshUser; + const proxyPort = proxyHostConfig['Port'] ? parseInt(proxyHostConfig['Port'], 10) : (proxy.port || sshPort); + + const proxyAgentForward = enableAgentForwarding && (proxyHostConfig['ForwardAgent'] || 'no').toLowerCase() === 'yes'; + const proxyAgent = proxyAgentForward && this.sshAgentSock ? new ssh2.OpenSSHAgent(this.sshAgentSock) : undefined; + + const proxyIdentityFiles: string[] = (proxyHostConfig['IdentityFile'] as unknown as string[]) || []; + const proxyIdentitiesOnly = (proxyHostConfig['IdentitiesOnly'] || 'no').toLowerCase() === 'yes'; + const proxyIdentityKeys = await gatherIdentityFiles(proxyIdentityFiles, this.sshAgentSock, proxyIdentitiesOnly, this.logger); + + const proxyAuthHandler = this.getSSHAuthHandler(proxyUser, proxyHostName, proxyIdentityKeys, preferredAuthentications); + const proxyConnection = new SSHConnection({ + host: !proxyStream ? proxyHostName : undefined, + port: !proxyStream ? proxyPort : undefined, + sock: proxyStream, + username: proxyUser, + readyTimeout: connectTimeout * 1000, + strictVendor: false, + agentForward: proxyAgentForward, + agent: proxyAgent, + authHandler: (arg0, arg1, arg2) => (proxyAuthHandler(arg0, arg1, arg2), undefined) + }); + this.proxyConnections.push(proxyConnection); + + const nextProxyJump = i < proxyJumps.length - 1 ? proxyJumps[i + 1] : undefined; + const destIP = nextProxyJump ? (nextProxyJump[1]['HostName'] || nextProxyJump[0].hostname) : sshHostName; + const destPort = nextProxyJump ? ((nextProxyJump[1]['Port'] && parseInt(nextProxyJump[1]['Port'], 10)) || nextProxyJump[0].port || 22) : sshPort; + proxyStream = await proxyConnection.forwardOut('127.0.0.1', 0, destIP, destPort); + } + } else if (sshHostConfig['ProxyCommand']) { + let proxyArgs = (sshHostConfig['ProxyCommand'] as unknown as string[]) + .map((arg) => arg.replace('%h', sshHostName).replace('%n', sshDest.hostname).replace('%p', sshPort.toString()).replace('%r', sshUser)); + let proxyCommand = proxyArgs.shift()!; + + let options = {}; + if (isWindows && /\.(bat|cmd)$/.test(proxyCommand)) { + proxyCommand = `"${proxyCommand}"`; + proxyArgs = proxyArgs.map((arg) => arg.includes(' ') ? `"${arg}"` : arg); + options = { shell: true, windowsHide: true, windowsVerbatimArguments: true }; + } + + this.logger.trace(`Spawning ProxyCommand: ${proxyCommand} ${proxyArgs.join(' ')}`); + + const child = cp.spawn(proxyCommand, proxyArgs, options); + proxyStream = stream.Duplex.from({ readable: child.stdout, writable: child.stdin }); + this.proxyCommandProcess = child; + } + + // Create final shh connection + const sshAuthHandler = this.getSSHAuthHandler(sshUser, sshHostName, identityKeys, preferredAuthentications); + + this.sshConnection = new SSHConnection({ + host: !proxyStream ? sshHostName : undefined, + port: !proxyStream ? sshPort : undefined, + sock: proxyStream, + username: sshUser, + readyTimeout: connectTimeout * 1000, + strictVendor: false, + agentForward, + agent, + authHandler: (arg0, arg1, arg2) => (sshAuthHandler(arg0, arg1, arg2), undefined), + }); + await this.sshConnection.connect(); + + const envVariables: Record = {}; + if (agentForward) { + envVariables['SSH_AUTH_SOCK'] = null; + } + + const installResult = await installCodeServer(this.sshConnection, serverDownloadUrlTemplate, defaultExtensions, Object.keys(envVariables), remotePlatformMap[sshDest.hostname], remoteServerListenOnSocket, this.logger); + + for (const key of Object.keys(envVariables)) { + if (installResult[key] !== undefined) { + envVariables[key] = installResult[key]; + } + } + + // Update terminal env variables + this.context.environmentVariableCollection.persistent = false; + for (const [key, value] of Object.entries(envVariables)) { + if (value) { + this.context.environmentVariableCollection.replace(key, value); + } + } + + if (enableDynamicForwarding) { + const socksPort = await findRandomPort(); + this.socksTunnel = await this.sshConnection!.addTunnel({ + name: `ssh_tunnel_socks_${socksPort}`, + localPort: socksPort, + socks: true + }); + } + + const tunnelConfig = await this.openTunnel(0, installResult.listeningOn); + this.tunnels.push(tunnelConfig); + + // Enable ports view + vscode.commands.executeCommand('setContext', 'forwardedPortsViewEnabled', true); + + this.labelFormatterDisposable?.dispose(); + this.labelFormatterDisposable = vscode.workspace.registerResourceLabelFormatter({ + scheme: 'vscode-remote', + authority: `${REMOTE_SSH_AUTHORITY}+*`, + formatting: { + label: '${path}', + separator: '/', + tildify: true, + workspaceSuffix: `SSH: ${sshDest.hostname}` + (sshDest.port && sshDest.port !== 22 ? `:${sshDest.port}` : '') + } + }); + + const resolvedResult: vscode.ResolverResult = new vscode.ResolvedAuthority('127.0.0.1', tunnelConfig.localPort, installResult.connectionToken); + resolvedResult.extensionHostEnv = envVariables; + return resolvedResult; + } catch (e: unknown) { + this.logger.error(`Error resolving authority`, e); + + // Initial connection + if (context.resolveAttempt === 1) { + this.logger.show(); + + const closeRemote = 'Close Remote'; + const retry = 'Retry'; + const result = await vscode.window.showErrorMessage(`Could not establish connection to "${sshDest.hostname}"`, { modal: true }, closeRemote, retry); + if (result === closeRemote) { + await vscode.commands.executeCommand('workbench.action.remote.close'); + } else if (result === retry) { + await vscode.commands.executeCommand('workbench.action.reloadWindow'); + } + } + + if (e instanceof ServerInstallError || !(e instanceof Error)) { + throw vscode.RemoteAuthorityResolverError.NotAvailable(e instanceof Error ? e.message : String(e)); + } else { + throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable(e.message); + } + } + }); + } + + private async openTunnel(localPort: number, remotePortOrSocketPath: number | string) { + localPort = localPort > 0 ? localPort : await findRandomPort(); + + const disposables: vscode.Disposable[] = []; + const remotePort = typeof remotePortOrSocketPath === 'number' ? remotePortOrSocketPath : undefined; + const remoteSocketPath = typeof remotePortOrSocketPath === 'string' ? remotePortOrSocketPath : undefined; + if (this.socksTunnel && remotePort) { + const forwardingServer = await new Promise((resolve, reject) => { + this.logger.trace(`Creating forwarding server ${localPort}(local) => ${this.socksTunnel!.localPort!}(socks) => ${remotePort}(remote)`); + const socksOptions: SocksClientOptions = { + proxy: { + host: '127.0.0.1', + port: this.socksTunnel!.localPort!, + type: 5 + }, + command: 'connect', + destination: { + host: '127.0.0.1', + port: remotePort + } + }; + const server: net.Server = net.createServer() + .on('error', reject) + .on('connection', async (socket: net.Socket) => { + try { + const socksConn = await SocksClient.createConnection(socksOptions); + socket.pipe(socksConn.socket); + socksConn.socket.pipe(socket); + } catch (error) { + this.logger.error(`Error while creating SOCKS connection`, error); + } + }) + .on('listening', () => resolve(server)) + .listen(localPort); + }); + disposables.push({ + dispose: () => forwardingServer.close(() => { + this.logger.trace(`SOCKS forwading server closed`); + }), + }); + } else { + this.logger.trace(`Opening tunnel ${localPort}(local) => ${remotePortOrSocketPath}(remote)`); + const tunnelConfig = await this.sshConnection!.addTunnel({ + name: `ssh_tunnel_${localPort}_${remotePortOrSocketPath}`, + remoteAddr: '127.0.0.1', + remotePort, + remoteSocketPath, + localPort + }); + disposables.push({ + dispose: () => { + this.sshConnection?.closeTunnel(tunnelConfig.name); + this.logger.trace(`Tunnel ${tunnelConfig.name} closed`); + } + }); + } + + return new TunnelInfo(localPort, remotePortOrSocketPath, disposables); + } + + private getSSHAuthHandler(sshUser: string, sshHostName: string, identityKeys: SSHKey[], preferredAuthentications: string[]) { + let passwordRetryCount = PASSWORD_RETRY_COUNT; + let keyboardRetryCount = PASSWORD_RETRY_COUNT; + identityKeys = identityKeys.slice(); + return async (methodsLeft: string[] | null, _partialSuccess: boolean | null, callback: (nextAuth: ssh2.AuthHandlerResult) => void) => { + if (methodsLeft === null) { + this.logger.info(`Trying no-auth authentication`); + + return callback({ + type: 'none', + username: sshUser, + }); + } + if (methodsLeft.includes('publickey') && identityKeys.length && preferredAuthentications.includes('publickey')) { + const identityKey = identityKeys.shift()!; + + this.logger.info(`Trying publickey authentication: ${identityKey.filename} ${identityKey.parsedKey.type} SHA256:${identityKey.fingerprint}`); + + if (identityKey.agentSupport) { + return callback({ + type: 'agent', + username: sshUser, + agent: new class extends ssh2.OpenSSHAgent { + // Only return the current key + override getIdentities(callback: (err: Error | undefined, publicKeys?: ParsedKey[]) => void): void { + callback(undefined, [identityKey.parsedKey]); + } + }(this.sshAgentSock!) + }); + } + if (identityKey.isPrivate) { + return callback({ + type: 'publickey', + username: sshUser, + key: identityKey.parsedKey + }); + } + if (!await fileExists(identityKey.filename)) { + // Try next identity file + return callback(null as any); + } + + const keyBuffer = await fs.promises.readFile(identityKey.filename); + let result = ssh2.utils.parseKey(keyBuffer); // First try without passphrase + if (result instanceof Error && result.message === 'Encrypted private OpenSSH key detected, but no passphrase given') { + let passphraseRetryCount = PASSPHRASE_RETRY_COUNT; + while (result instanceof Error && passphraseRetryCount > 0) { + const passphrase = await vscode.window.showInputBox({ + title: `Enter passphrase for ${identityKey.filename}`, + password: true, + ignoreFocusOut: true + }); + if (!passphrase) { + break; + } + result = ssh2.utils.parseKey(keyBuffer, passphrase); + passphraseRetryCount--; + } + } + if (!result || result instanceof Error) { + // Try next identity file + return callback(null as any); + } + + const key = Array.isArray(result) ? result[0] : result; + return callback({ + type: 'publickey', + username: sshUser, + key + }); + } + if (methodsLeft.includes('password') && passwordRetryCount > 0 && preferredAuthentications.includes('password')) { + if (passwordRetryCount === PASSWORD_RETRY_COUNT) { + this.logger.info(`Trying password authentication`); + } + + const password = await vscode.window.showInputBox({ + title: `Enter password for ${sshUser}@${sshHostName}`, + password: true, + ignoreFocusOut: true + }); + passwordRetryCount--; + + return callback(password + ? { + type: 'password', + username: sshUser, + password + } + : false); + } + if (methodsLeft.includes('keyboard-interactive') && keyboardRetryCount > 0 && preferredAuthentications.includes('keyboard-interactive')) { + if (keyboardRetryCount === PASSWORD_RETRY_COUNT) { + this.logger.info(`Trying keyboard-interactive authentication`); + } + + return callback({ + type: 'keyboard-interactive', + username: sshUser, + prompt: async (_name, _instructions, _instructionsLang, prompts, finish) => { + const responses: string[] = []; + for (const prompt of prompts) { + const response = await vscode.window.showInputBox({ + title: `(${sshUser}@${sshHostName}) ${prompt.prompt}`, + password: !prompt.echo, + ignoreFocusOut: true + }); + if (response === undefined) { + keyboardRetryCount = 0; + break; + } + responses.push(response); + } + keyboardRetryCount--; + finish(responses); + } + }); + } + + callback(false); + }; + } + + dispose() { + disposeAll(this.tunnels); + // If there's proxy connections then just close the parent connection + if (this.proxyConnections.length) { + this.proxyConnections[0].close(); + } else { + this.sshConnection?.close(); + } + this.proxyCommandProcess?.kill(); + this.labelFormatterDisposable?.dispose(); + } +} diff --git a/extensions/open-remote-ssh/src/commands.ts b/extensions/open-remote-ssh/src/commands.ts new file mode 100644 index 00000000..ccc9c7ff --- /dev/null +++ b/extensions/open-remote-ssh/src/commands.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { getRemoteAuthority } from './authResolver'; +import { getSSHConfigPath } from './ssh/sshConfig'; +import { exists as fileExists } from './common/files'; +import SSHDestination from './ssh/sshDestination'; + +export async function promptOpenRemoteSSHWindow(reuseWindow: boolean) { + const host = await vscode.window.showInputBox({ + title: 'Enter [user@]hostname[:port]' + }); + + if (!host) { + return; + } + + const sshDest = new SSHDestination(host); + openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow); +} + +export function openRemoteSSHWindow(host: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.newWindow', { remoteAuthority: getRemoteAuthority(host), reuseWindow }); +} + +export function openRemoteSSHLocationWindow(host: string, path: string, reuseWindow: boolean) { + vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.from({ scheme: 'vscode-remote', authority: getRemoteAuthority(host), path }), { forceNewWindow: !reuseWindow }); +} + +export async function addNewHost() { + const sshConfigPath = getSSHConfigPath(); + if (!await fileExists(sshConfigPath)) { + await fs.promises.appendFile(sshConfigPath, ''); + } + + await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath), { preview: false }); + + const textEditor = vscode.window.activeTextEditor; + if (textEditor?.document.uri.fsPath !== sshConfigPath) { + return; + } + + const textDocument = textEditor.document; + const lastLine = textDocument.lineAt(textDocument.lineCount - 1); + + if (!lastLine.isEmptyOrWhitespace) { + await textEditor.edit((editBuilder: vscode.TextEditorEdit) => { + editBuilder.insert(lastLine.range.end, '\n'); + }); + } + + const snippet = '\nHost ${1:dev}\n\tHostName ${2:dev.example.com}\n\tUser ${3:john}'; + await textEditor.insertSnippet( + new vscode.SnippetString(snippet), + new vscode.Position(textDocument.lineCount, 0) + ); +} + +export async function openSSHConfigFile() { + const sshConfigPath = getSSHConfigPath(); + if (!await fileExists(sshConfigPath)) { + await fs.promises.appendFile(sshConfigPath, ''); + } + vscode.commands.executeCommand('vscode.open', vscode.Uri.file(sshConfigPath)); +} diff --git a/extensions/open-remote-ssh/src/common/disposable.ts b/extensions/open-remote-ssh/src/common/disposable.ts new file mode 100644 index 00000000..c81eb6cd --- /dev/null +++ b/extensions/open-remote-ssh/src/common/disposable.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +export function disposeAll(disposables: vscode.Disposable[]): void { + while (disposables.length) { + const item = disposables.pop(); + if (item) { + item.dispose(); + } + } +} + +export abstract class Disposable { + private _isDisposed = false; + + protected _disposables: vscode.Disposable[] = []; + + public dispose(): any { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + disposeAll(this._disposables); + } + + protected _register(value: T): T { + if (this._isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed(): boolean { + return this._isDisposed; + } +} diff --git a/extensions/open-remote-ssh/src/common/files.ts b/extensions/open-remote-ssh/src/common/files.ts new file mode 100644 index 00000000..6d9ff46f --- /dev/null +++ b/extensions/open-remote-ssh/src/common/files.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs'; +import * as os from 'os'; + +const homeDir = os.homedir(); + +export async function exists(path: string) { + try { + await fs.promises.access(path); + return true; + } catch { + return false; + } +} + +export function untildify(path: string) { + return path.replace(/^~(?=$|\/|\\)/, homeDir); +} + +export function normalizeToSlash(path: string) { + return path.replace(/\\/g, '/'); +} diff --git a/extensions/open-remote-ssh/src/common/logger.ts b/extensions/open-remote-ssh/src/common/logger.ts new file mode 100644 index 00000000..6489d0c0 --- /dev/null +++ b/extensions/open-remote-ssh/src/common/logger.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; + +type LogLevel = 'Trace' | 'Info' | 'Error'; + +export default class Log { + private output: vscode.OutputChannel; + + constructor(name: string) { + this.output = vscode.window.createOutputChannel(name); + } + + private data2String(data: any): string { + if (data instanceof Error) { + return data.stack || data.message; + } + if (data.success === false && data.message) { + return data.message; + } + return data.toString(); + } + + public trace(message: string, data?: any): void { + this.logLevel('Trace', message, data); + } + + public info(message: string, data?: any): void { + this.logLevel('Info', message, data); + } + + public error(message: string, data?: any): void { + this.logLevel('Error', message, data); + } + + public logLevel(level: LogLevel, message: string, data?: any): void { + this.output.appendLine(`[${level} - ${this.now()}] ${message}`); + if (data) { + this.output.appendLine(this.data2String(data)); + } + } + + private now(): string { + const now = new Date(); + return padLeft(now.getUTCHours() + '', 2, '0') + + ':' + padLeft(now.getMinutes() + '', 2, '0') + + ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds(); + } + + public show() { + this.output.show(); + } + + public dispose() { + this.output.dispose(); + } +} + +function padLeft(s: string, n: number, pad = ' ') { + return pad.repeat(Math.max(0, n - s.length)) + s; +} diff --git a/extensions/open-remote-ssh/src/common/platform.ts b/extensions/open-remote-ssh/src/common/platform.ts new file mode 100644 index 00000000..73c65ca9 --- /dev/null +++ b/extensions/open-remote-ssh/src/common/platform.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export const isWindows = process.platform === 'win32'; +export const isMacintosh = process.platform === 'darwin'; +export const isLinux = process.platform === 'linux'; diff --git a/extensions/open-remote-ssh/src/common/ports.ts b/extensions/open-remote-ssh/src/common/ports.ts new file mode 100644 index 00000000..e135441b --- /dev/null +++ b/extensions/open-remote-ssh/src/common/ports.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as net from 'net'; + +/** + * Finds a random unused port assigned by the operating system. Will reject in case no free port can be found. + */ +export function findRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer({ pauseOnConnect: true }); + server.on('error', reject); + server.on('listening', () => { + const port = (server.address() as net.AddressInfo).port; + server.close(() => resolve(port)); + }); + server.listen(0, '127.0.0.1'); + }); +} + +/** + * Given a start point and a max number of retries, will find a port that + * is openable. Will return 0 in case no free port can be found. + */ +export function findFreePort(startPort: number, giveUpAfter: number, timeout: number, stride = 1): Promise { + let done = false; + + return new Promise(resolve => { + const timeoutHandle = setTimeout(() => { + if (!done) { + done = true; + return resolve(0); + } + }, timeout); + + doFindFreePort(startPort, giveUpAfter, stride, (port) => { + if (!done) { + done = true; + clearTimeout(timeoutHandle); + return resolve(port); + } + }); + }); +} + +function doFindFreePort(startPort: number, giveUpAfter: number, stride: number, clb: (port: number) => void): void { + if (giveUpAfter === 0) { + return clb(0); + } + + const client = new net.Socket(); + + // If we can connect to the port it means the port is already taken so we continue searching + client.once('connect', () => { + dispose(client); + + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + }); + + client.once('data', () => { + // this listener is required since node.js 8.x + }); + + client.once('error', (err: Error & { code?: string }) => { + dispose(client); + + // If we receive any non ECONNREFUSED error, it means the port is used but we cannot connect + if (err.code !== 'ECONNREFUSED') { + return doFindFreePort(startPort + stride, giveUpAfter - 1, stride, clb); + } + + // Otherwise it means the port is free to use! + return clb(startPort); + }); + + client.connect(startPort, '127.0.0.1'); +} + +/** + * Uses listen instead of connect. Is faster, but if there is another listener on 0.0.0.0 then this will take 127.0.0.1 from that listener. + */ +export function findFreePortFaster(startPort: number, giveUpAfter: number, timeout: number): Promise { + let resolved = false; + let timeoutHandle: NodeJS.Timeout | undefined = undefined; + let countTried = 1; + const server = net.createServer({ pauseOnConnect: true }); + function doResolve(port: number, resolve: (port: number) => void) { + if (!resolved) { + resolved = true; + server.removeAllListeners(); + server.close(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + resolve(port); + } + } + return new Promise(resolve => { + timeoutHandle = setTimeout(() => { + doResolve(0, resolve); + }, timeout); + + server.on('listening', () => { + doResolve(startPort, resolve); + }); + server.on('error', err => { + if (err && ((err).code === 'EADDRINUSE' || (err).code === 'EACCES') && (countTried < giveUpAfter)) { + startPort++; + countTried++; + server.listen(startPort, '127.0.0.1'); + } else { + doResolve(0, resolve); + } + }); + server.on('close', () => { + doResolve(0, resolve); + }); + server.listen(startPort, '127.0.0.1'); + }); +} + +function dispose(socket: net.Socket): void { + try { + socket.removeAllListeners('connect'); + socket.removeAllListeners('error'); + socket.end(); + socket.destroy(); + socket.unref(); + } catch (error) { + console.error(error); // otherwise this error would get lost in the callback chain + } +} diff --git a/extensions/open-remote-ssh/src/extension.ts b/extensions/open-remote-ssh/src/extension.ts new file mode 100644 index 00000000..e950f545 --- /dev/null +++ b/extensions/open-remote-ssh/src/extension.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import Log from './common/logger'; +import { RemoteSSHResolver, REMOTE_SSH_AUTHORITY } from './authResolver'; +import { openSSHConfigFile, promptOpenRemoteSSHWindow } from './commands'; +import { HostTreeDataProvider } from './hostTreeView'; +import { getRemoteWorkspaceLocationData, RemoteLocationHistory } from './remoteLocationHistory'; + +export async function activate(context: vscode.ExtensionContext) { + const logger = new Log('Remote - SSH'); + context.subscriptions.push(logger); + + const remoteSSHResolver = new RemoteSSHResolver(context, logger); + context.subscriptions.push(vscode.workspace.registerRemoteAuthorityResolver(REMOTE_SSH_AUTHORITY, remoteSSHResolver)); + context.subscriptions.push(remoteSSHResolver); + + const locationHistory = new RemoteLocationHistory(context); + const locationData = getRemoteWorkspaceLocationData(); + if (locationData) { + await locationHistory.addLocation(locationData[0], locationData[1]); + } + + const hostTreeDataProvider = new HostTreeDataProvider(locationHistory); + context.subscriptions.push(vscode.window.createTreeView('sshHosts', { treeDataProvider: hostTreeDataProvider })); + context.subscriptions.push(hostTreeDataProvider); + + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindow', () => promptOpenRemoteSSHWindow(false))); + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openEmptyWindowInCurrentWindow', () => promptOpenRemoteSSHWindow(true))); + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.openConfigFile', () => openSSHConfigFile())); + context.subscriptions.push(vscode.commands.registerCommand('openremotessh.showLog', () => logger.show())); +} + +export function deactivate() { +} diff --git a/extensions/open-remote-ssh/src/hostTreeView.ts b/extensions/open-remote-ssh/src/hostTreeView.ts new file mode 100644 index 00000000..5874c53e --- /dev/null +++ b/extensions/open-remote-ssh/src/hostTreeView.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as path from 'path'; +import SSHConfiguration, { getSSHConfigPath } from './ssh/sshConfig'; +import { RemoteLocationHistory } from './remoteLocationHistory'; +import { Disposable } from './common/disposable'; +import { addNewHost, openRemoteSSHLocationWindow, openRemoteSSHWindow, openSSHConfigFile } from './commands'; +import SSHDestination from './ssh/sshDestination'; + +class HostItem { + constructor( + public hostname: string, + public locations: string[] + ) { + } +} + +class HostLocationItem { + constructor( + public path: string, + public hostname: string + ) { + } +} + +type DataTreeItem = HostItem | HostLocationItem; + +export class HostTreeDataProvider extends Disposable implements vscode.TreeDataProvider { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private locationHistory: RemoteLocationHistory + ) { + super(); + + this._register(vscode.commands.registerCommand('openremotessh.explorer.add', () => addNewHost())); + this._register(vscode.commands.registerCommand('openremotessh.explorer.configure', () => openSSHConfigFile())); + this._register(vscode.commands.registerCommand('openremotessh.explorer.refresh', () => this.refresh())); + this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInNewWindow', e => this.openRemoteSSHWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.emptyWindowInCurrentWindow', e => this.openRemoteSSHWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInNewWindow', e => this.openRemoteSSHLocationWindow(e, false))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.reopenFolderInCurrentWindow', e => this.openRemoteSSHLocationWindow(e, true))); + this._register(vscode.commands.registerCommand('openremotessh.explorer.deleteFolderHistoryItem', e => this.deleteHostLocation(e))); + + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('remote.SSH.configFile')) { + this.refresh(); + } + })); + this._register(vscode.workspace.onDidSaveTextDocument(e => { + if (e.uri.fsPath === getSSHConfigPath()) { + this.refresh(); + } + })); + } + + getTreeItem(element: DataTreeItem): vscode.TreeItem { + if (element instanceof HostLocationItem) { + const label = path.posix.basename(element.path).replace(/\.code-workspace$/, ' (Workspace)'); + const treeItem = new vscode.TreeItem(label); + treeItem.description = path.posix.dirname(element.path); + treeItem.iconPath = new vscode.ThemeIcon('folder'); + treeItem.contextValue = 'openremotessh.explorer.folder'; + return treeItem; + } + + const treeItem = new vscode.TreeItem(element.hostname); + treeItem.collapsibleState = element.locations.length ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon('vm'); + treeItem.contextValue = 'openremotessh.explorer.host'; + return treeItem; + } + + async getChildren(element?: HostItem): Promise { + if (!element) { + const sshConfigFile = await SSHConfiguration.loadFromFS(); + const hosts = sshConfigFile.getAllConfiguredHosts(); + return hosts.map(hostname => new HostItem(hostname, this.locationHistory.getHistory(hostname))); + } + if (element instanceof HostItem) { + return element.locations.map(location => new HostLocationItem(location, element.hostname)); + } + return []; + } + + private refresh() { + this._onDidChangeTreeData.fire(); + } + + private async deleteHostLocation(element: HostLocationItem) { + await this.locationHistory.removeLocation(element.hostname, element.path); + this.refresh(); + } + + private async openRemoteSSHWindow(element: HostItem, reuseWindow: boolean) { + const sshDest = new SSHDestination(element.hostname); + openRemoteSSHWindow(sshDest.toEncodedString(), reuseWindow); + } + + private async openRemoteSSHLocationWindow(element: HostLocationItem, reuseWindow: boolean) { + const sshDest = new SSHDestination(element.hostname); + openRemoteSSHLocationWindow(sshDest.toEncodedString(), element.path, reuseWindow); + } +} diff --git a/extensions/open-remote-ssh/src/remoteLocationHistory.ts b/extensions/open-remote-ssh/src/remoteLocationHistory.ts new file mode 100644 index 00000000..6e6b7777 --- /dev/null +++ b/extensions/open-remote-ssh/src/remoteLocationHistory.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import { REMOTE_SSH_AUTHORITY } from './authResolver'; +import SSHDestination from './ssh/sshDestination'; + +export class RemoteLocationHistory { + private static STORAGE_KEY = 'remoteLocationHistory_v0'; + + private remoteLocationHistory: Record = {}; + + constructor(private context: vscode.ExtensionContext) { + // context.globalState.update(RemoteLocationHistory.STORAGE_KEY, undefined); + this.remoteLocationHistory = context.globalState.get(RemoteLocationHistory.STORAGE_KEY) || {}; + } + + getHistory(host: string): string[] { + return this.remoteLocationHistory[host] || []; + } + + async addLocation(host: string, path: string) { + const hostLocations = this.remoteLocationHistory[host] || []; + if (!hostLocations.includes(path)) { + hostLocations.unshift(path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } + } + + async removeLocation(host: string, path: string) { + let hostLocations = this.remoteLocationHistory[host] || []; + hostLocations = hostLocations.filter(l => l !== path); + this.remoteLocationHistory[host] = hostLocations; + + await this.context.globalState.update(RemoteLocationHistory.STORAGE_KEY, this.remoteLocationHistory); + } +} + +export function getRemoteWorkspaceLocationData(): [string, string] | undefined { + let location = vscode.workspace.workspaceFile; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY) && location.path.endsWith('.code-workspace')) { + const [, host] = location.authority.split('+'); + const sshDest = SSHDestination.parseEncoded(host); + return [sshDest.hostname, location.path]; + } + + location = vscode.workspace.workspaceFolders?.[0].uri; + if (location && location.scheme === 'vscode-remote' && location.authority.startsWith(REMOTE_SSH_AUTHORITY)) { + const [, host] = location.authority.split('+'); + const sshDest = SSHDestination.parseEncoded(host); + return [sshDest.hostname, location.path]; + } + + return undefined; +} diff --git a/extensions/open-remote-ssh/src/serverConfig.ts b/extensions/open-remote-ssh/src/serverConfig.ts new file mode 100644 index 00000000..3f2758fa --- /dev/null +++ b/extensions/open-remote-ssh/src/serverConfig.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +let vscodeProductJson: any; +async function getVSCodeProductJson() { + if (!vscodeProductJson) { + const productJsonStr = await fs.promises.readFile(path.join(vscode.env.appRoot, 'product.json'), 'utf8'); + vscodeProductJson = JSON.parse(productJsonStr); + } + + return vscodeProductJson; +} + +export interface IServerConfig { + version: string; + commit: string; + quality: string; + release?: string; // void-like specific + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate?: string; // void-like specific +} + +export async function getVSCodeServerConfig(): Promise { + const productJson = await getVSCodeProductJson(); + + const customServerBinaryName = vscode.workspace.getConfiguration('remote.SSH.experimental').get('serverBinaryName', ''); + + return { + version: vscode.version.replace('-insider', ''), + commit: productJson.commit, + quality: productJson.quality, + release: productJson.release, + serverApplicationName: customServerBinaryName || productJson.serverApplicationName, + serverDataFolderName: productJson.serverDataFolderName, + serverDownloadUrlTemplate: productJson.serverDownloadUrlTemplate + }; +} diff --git a/extensions/open-remote-ssh/src/serverSetup.ts b/extensions/open-remote-ssh/src/serverSetup.ts new file mode 100644 index 00000000..d851988a --- /dev/null +++ b/extensions/open-remote-ssh/src/serverSetup.ts @@ -0,0 +1,626 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as crypto from 'crypto'; +import Log from './common/logger'; +import { getVSCodeServerConfig } from './serverConfig'; +import SSHConnection from './ssh/sshConnection'; + +export interface ServerInstallOptions { + id: string; + quality: string; + commit: string; + version: string; + release?: string; // void specific + extensionIds: string[]; + envVariables: string[]; + useSocketPath: boolean; + serverApplicationName: string; + serverDataFolderName: string; + serverDownloadUrlTemplate: string; +} + +export interface ServerInstallResult { + exitCode: number; + listeningOn: number | string; + connectionToken: string; + logFile: string; + osReleaseId: string; + arch: string; + platform: string; + tmpDir: string; + [key: string]: any; +} + +export class ServerInstallError extends Error { + constructor(message: string) { + super(message); + } +} + +const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/${NAME_OF_REPO}/releases/download/${version}.${release}/void-server-${os}-${arch}-${version}.${release}.tar.gz'; + +export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise { + let shell = 'powershell'; + + // detect platform and shell for windows + if (!platform || platform === 'windows') { + const result = await conn.exec('uname -s'); + + if (result.stdout) { + if (result.stdout.includes('windows32')) { + platform = 'windows'; + } else if (result.stdout.includes('MINGW64')) { + platform = 'windows'; + shell = 'bash'; + } + } else if (result.stderr) { + if (result.stderr.includes('FullyQualifiedErrorId : CommandNotFoundException')) { + platform = 'windows'; + } + + if (result.stderr.includes('is not recognized as an internal or external command')) { + platform = 'windows'; + shell = 'cmd'; + } + } + + if (platform) { + logger.trace(`Detected platform: ${platform}, ${shell}`); + } + } + + const scriptId = crypto.randomBytes(12).toString('hex'); + + const vscodeServerConfig = await getVSCodeServerConfig(); + const installOptions: ServerInstallOptions = { + id: scriptId, + version: vscodeServerConfig.version, + commit: vscodeServerConfig.commit, + quality: vscodeServerConfig.quality, + release: vscodeServerConfig.release, + extensionIds, + envVariables, + useSocketPath, + serverApplicationName: vscodeServerConfig.serverApplicationName, + serverDataFolderName: vscodeServerConfig.serverDataFolderName, + serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE, + }; + + let commandOutput: { stdout: string; stderr: string }; + if (platform === 'windows') { + const installServerScript = generatePowerShellInstallScript(installOptions); + + logger.trace('Server install command:', installServerScript); + + const installDir = `$HOME\\${vscodeServerConfig.serverDataFolderName}\\install`; + const installScript = `${installDir}\\${vscodeServerConfig.commit}.ps1`; + const endRegex = new RegExp(`${scriptId}: end`); + // investigate if it's possible to use `-EncodedCommand` flag + // https://devblogs.microsoft.com/powershell/invoking-powershell-with-complex-expressions-using-scriptblocks/ + let command = ''; + if (shell === 'powershell') { + command = `md -Force ${installDir}; echo @'\n${installServerScript}\n'@ | Set-Content ${installScript}; powershell -ExecutionPolicy ByPass -File "${installScript}"`; + } else if (shell === 'bash') { + command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`; + } else if (shell === 'cmd') { + const script = installServerScript.trim() + // remove comments + .replace(/^#.*$/gm, '') + // remove empty lines + .replace(/\n{2,}/gm, '\n') + // remove leading spaces + .replace(/^\s*/gm, '') + // escape double quotes (from powershell/cmd) + .replace(/"/g, '"""') + // escape single quotes (from cmd) + .replace(/'/g, `''`) + // escape redirect (from cmd) + .replace(/>/g, `^>`) + // escape new lines (from powershell/cmd) + .replace(/\n/g, '\'`n\''); + + command = `powershell "md -Force ${installDir}" && powershell "echo '${script}'" > ${installScript.replace('$HOME', '%USERPROFILE%')} && powershell -ExecutionPolicy ByPass -File "${installScript.replace('$HOME', '%USERPROFILE%')}"`; + + logger.trace('Command length (8191 max):', command.length); + + if (command.length > 8191) { + throw new ServerInstallError(`Command line too long`); + } + } else { + throw new ServerInstallError(`Not supported shell: ${shell}`); + } + + commandOutput = await conn.execPartial(command, (stdout: string) => endRegex.test(stdout)); + } else { + const installServerScript = generateBashInstallScript(installOptions); + + logger.trace('Server install command:', installServerScript); + // Fish shell does not support heredoc so let's workaround it using -c option, + // also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676 + commandOutput = await conn.exec(`bash -c '${installServerScript.replace(/'/g, `'\\''`)}'`); + } + + if (commandOutput.stderr) { + logger.trace('Server install command stderr:', commandOutput.stderr); + } + logger.trace('Server install command stdout:', commandOutput.stdout); + + const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId); + if (!resultMap) { + throw new ServerInstallError(`Failed parsing install script output`); + } + + const exitCode = parseInt(resultMap.exitCode, 10); + if (exitCode !== 0) { + throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`); + } + + const listeningOn = resultMap.listeningOn.match(/^\d+$/) + ? parseInt(resultMap.listeningOn, 10) + : resultMap.listeningOn; + + const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key))); + + return { + exitCode, + listeningOn, + connectionToken: resultMap.connectionToken, + logFile: resultMap.logFile, + osReleaseId: resultMap.osReleaseId, + arch: resultMap.arch, + platform: resultMap.platform, + tmpDir: resultMap.tmpDir, + ...remoteEnvVars + }; +} + +function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { + const startResultStr = `${scriptId}: start`; + const endResultStr = `${scriptId}: end`; + + const startResultIdx = str.indexOf(startResultStr); + if (startResultIdx < 0) { + return undefined; + } + + const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length); + if (endResultIdx < 0) { + return undefined; + } + + const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx); + + const resultMap: { [k: string]: string } = {}; + const resultArr = installResult.split(/\r?\n/); + for (const line of resultArr) { + const [key, value] = line.split('=='); + resultMap[key] = value; + } + + return resultMap; +} + +function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { + const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); + return ` +# Server installation script + +TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}" + +DISTRO_VERSION="${version}" +DISTRO_COMMIT="${commit}" +DISTRO_QUALITY="${quality}" +DISTRO_VOID_RELEASE="${release ?? ''}" + +SERVER_APP_NAME="${serverApplicationName}" +SERVER_INITIAL_EXTENSIONS="${extensions}" +SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}" +SERVER_DATA_DIR="$HOME/${serverDataFolderName}" +SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT" +SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME" +SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log" +SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid" +SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token" +SERVER_ARCH= +SERVER_CONNECTION_TOKEN= +SERVER_DOWNLOAD_URL= + +LISTENING_ON= +OS_RELEASE_ID= +ARCH= +PLATFORM= + +# Mimic output from logs of remote-ssh extension +print_install_results_and_exit() { + echo "${id}: start" + echo "exitCode==$1==" + echo "listeningOn==$LISTENING_ON==" + echo "connectionToken==$SERVER_CONNECTION_TOKEN==" + echo "logFile==$SERVER_LOGFILE==" + echo "osReleaseId==$OS_RELEASE_ID==" + echo "arch==$ARCH==" + echo "platform==$PLATFORM==" + echo "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} + echo "${id}: end" + exit 0 +} + +# Check if platform is supported +KERNEL="$(uname -s)" +case $KERNEL in + Darwin) + PLATFORM="darwin" + ;; + Linux) + PLATFORM="linux" + ;; + FreeBSD) + PLATFORM="freebsd" + ;; + DragonFly) + PLATFORM="dragonfly" + ;; + *) + echo "Error platform not supported: $KERNEL" + print_install_results_and_exit 1 + ;; +esac + +# Check machine architecture +ARCH="$(uname -m)" +case $ARCH in + x86_64 | amd64) + SERVER_ARCH="x64" + ;; + armv7l | armv8l) + SERVER_ARCH="armhf" + ;; + arm64 | aarch64) + SERVER_ARCH="arm64" + ;; + ppc64le) + SERVER_ARCH="ppc64le" + ;; + riscv64) + SERVER_ARCH="riscv64" + ;; + loongarch64) + SERVER_ARCH="loong64" + ;; + s390x) + SERVER_ARCH="s390x" + ;; + *) + echo "Error architecture not supported: $ARCH" + print_install_results_and_exit 1 + ;; +esac + +# https://www.freedesktop.org/software/systemd/man/os-release.html +OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" +if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" + if [[ -z $OS_RELEASE_ID ]]; then + OS_RELEASE_ID="unknown" + fi +fi + +# Create installation folder +if [[ ! -d $SERVER_DIR ]]; then + mkdir -p $SERVER_DIR + if (( $? > 0 )); then + echo "Error creating server install directory" + print_install_results_and_exit 1 + fi +fi + +# adjust platform for void download, if needed +if [[ $OS_RELEASE_ID = alpine ]]; then + PLATFORM=$OS_RELEASE_ID +fi + +SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$PLATFORM/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VOID_RELEASE/g")" + +# Check if server script is already installed +if [[ ! -f $SERVER_SCRIPT ]]; then + case "$PLATFORM" in + darwin | linux | alpine ) + ;; + *) + echo "Error '$PLATFORM' needs manual installation of remote extension host" + print_install_results_and_exit 1 + ;; + esac + + pushd $SERVER_DIR > /dev/null + + if [[ ! -z $(which wget) ]]; then + wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL + elif [[ ! -z $(which curl) ]]; then + curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL + else + echo "Error no tool to download server binary" + print_install_results_and_exit 1 + fi + + if (( $? > 0 )); then + echo "Error downloading server from $SERVER_DOWNLOAD_URL" + print_install_results_and_exit 1 + fi + + tar -xf vscode-server.tar.gz --strip-components 1 + if (( $? > 0 )); then + echo "Error while extracting server contents" + print_install_results_and_exit 1 + fi + + if [[ ! -f $SERVER_SCRIPT ]]; then + echo "Error server contents are corrupted" + print_install_results_and_exit 1 + fi + + rm -f vscode-server.tar.gz + + popd > /dev/null +else + echo "Server script already installed in $SERVER_SCRIPT" +fi + +# Try to find if server is already running +if [[ -f $SERVER_PIDFILE ]]; then + SERVER_PID="$(cat $SERVER_PIDFILE)" + SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" +else + SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)" +fi + +if [[ -z $SERVER_RUNNING_PROCESS ]]; then + if [[ -f $SERVER_LOGFILE ]]; then + rm $SERVER_LOGFILE + fi + if [[ -f $SERVER_TOKENFILE ]]; then + rm $SERVER_TOKENFILE + fi + + touch $SERVER_TOKENFILE + chmod 600 $SERVER_TOKENFILE + SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" + echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE + + $SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE & + echo $! > $SERVER_PIDFILE +else + echo "Server script is already running $SERVER_SCRIPT" +fi + +if [[ -f $SERVER_TOKENFILE ]]; then + SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" +else + echo "Error server token file not found $SERVER_TOKENFILE" + print_install_results_and_exit 1 +fi + +if [[ -f $SERVER_LOGFILE ]]; then + for i in {1..5}; do + LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')" + if [[ -n $LISTENING_ON ]]; then + break + fi + sleep 0.5 + done + + if [[ -z $LISTENING_ON ]]; then + echo "Error server did not start successfully" + print_install_results_and_exit 1 + fi +else + echo "Error server log file not found $SERVER_LOGFILE" + print_install_results_and_exit 1 +fi + +# Finish server setup +print_install_results_and_exit 0 +`; +} + +function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { + const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); + const downloadUrl = serverDownloadUrlTemplate + .replace(/\$\{quality\}/g, quality) + .replace(/\$\{version\}/g, version) + .replace(/\$\{commit\}/g, commit) + .replace(/\$\{os\}/g, 'win32') + .replace(/\$\{arch\}/g, 'x64') + .replace(/\$\{release\}/g, release ?? ''); + + return ` +# Server installation script + +$TMP_DIR="$env:TEMP\\$([System.IO.Path]::GetRandomFileName())" +$ProgressPreference = "SilentlyContinue" + +$DISTRO_VERSION="${version}" +$DISTRO_COMMIT="${commit}" +$DISTRO_QUALITY="${quality}" +$DISTRO_VOID_RELEASE="${release ?? ''}" + +$SERVER_APP_NAME="${serverApplicationName}" +$SERVER_INITIAL_EXTENSIONS="${extensions}" +$SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}" +$SERVER_DATA_DIR="$(Resolve-Path ~)\\${serverDataFolderName}" +$SERVER_DIR="$SERVER_DATA_DIR\\bin\\$DISTRO_COMMIT" +$SERVER_SCRIPT="$SERVER_DIR\\bin\\$SERVER_APP_NAME.cmd" +$SERVER_LOGFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.log" +$SERVER_PIDFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.pid" +$SERVER_TOKENFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.token" +$SERVER_ARCH= +$SERVER_CONNECTION_TOKEN= +$SERVER_DOWNLOAD_URL= + +$LISTENING_ON= +$OS_RELEASE_ID= +$ARCH= +$PLATFORM="win32" + +function printInstallResults($code) { + "${id}: start" + "exitCode==$code==" + "listeningOn==$LISTENING_ON==" + "connectionToken==$SERVER_CONNECTION_TOKEN==" + "logFile==$SERVER_LOGFILE==" + "osReleaseId==$OS_RELEASE_ID==" + "arch==$ARCH==" + "platform==$PLATFORM==" + "tmpDir==$TMP_DIR==" + ${envVariables.map(envVar => `"${envVar}==$${envVar}=="`).join('\n')} + "${id}: end" +} + +# Check machine architecture +$ARCH=$env:PROCESSOR_ARCHITECTURE +# Use x64 version for ARM64, as it's not yet available. +if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) { + $SERVER_ARCH="x64" +} +else { + "Error architecture not supported: $ARCH" + printInstallResults 1 + exit 0 +} + +# Create installation folder +if(!(Test-Path $SERVER_DIR)) { + try { + ni -it d $SERVER_DIR -f -ea si + } catch { + "Error creating server install directory - $($_.ToString())" + exit 1 + } + + if(!(Test-Path $SERVER_DIR)) { + "Error creating server install directory" + exit 1 + } +} + +cd $SERVER_DIR + +# Check if server script is already installed +if(!(Test-Path $SERVER_SCRIPT)) { + del vscode-server.tar.gz + + $REQUEST_ARGUMENTS = @{ + Uri="${downloadUrl}" + TimeoutSec=20 + OutFile="vscode-server.tar.gz" + UseBasicParsing=$True + } + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + Invoke-RestMethod @REQUEST_ARGUMENTS + + if(Test-Path "vscode-server.tar.gz") { + tar -xf vscode-server.tar.gz --strip-components 1 + + del vscode-server.tar.gz + } + + if(!(Test-Path $SERVER_SCRIPT)) { + "Error while installing the server binary" + exit 1 + } +} +else { + "Server script already installed in $SERVER_SCRIPT" +} + +# Try to find if server is already running +if(Get-Process node -ErrorAction SilentlyContinue | Where-Object Path -Like "$SERVER_DIR\\*") { + echo "Server script is already running $SERVER_SCRIPT" +} +else { + if(Test-Path $SERVER_LOGFILE) { + del $SERVER_LOGFILE + } + if(Test-Path $SERVER_PIDFILE) { + del $SERVER_PIDFILE + } + if(Test-Path $SERVER_TOKENFILE) { + del $SERVER_TOKENFILE + } + + $SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" + [System.IO.File]::WriteAllLines($SERVER_TOKENFILE, $SERVER_CONNECTION_TOKEN) + + $SCRIPT_ARGUMENTS="--start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms *> '$SERVER_LOGFILE'" + + $START_ARGUMENTS = @{ + FilePath = "powershell.exe" + WindowStyle = "hidden" + ArgumentList = @( + "-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NoProfile", "-NonInteractive", "-c", "$SERVER_SCRIPT $SCRIPT_ARGUMENTS" + ) + PassThru = $True + } + + $SERVER_ID = (start @START_ARGUMENTS).ID + + if($SERVER_ID) { + [System.IO.File]::WriteAllLines($SERVER_PIDFILE, $SERVER_ID) + } +} + +if(Test-Path $SERVER_TOKENFILE) { + $SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" +} +else { + "Error server token file not found $SERVER_TOKENFILE" + printInstallResults 1 + exit 0 +} + +sleep -Milliseconds 500 + +$SELECT_ARGUMENTS = @{ + Path = $SERVER_LOGFILE + Pattern = "Extension host agent listening on (\\d+)" +} + +for($I = 1; $I -le 5; $I++) { + if(Test-Path $SERVER_LOGFILE) { + $GROUPS = (Select-String @SELECT_ARGUMENTS).Matches.Groups + + if($GROUPS) { + $LISTENING_ON = $GROUPS[1].Value + break + } + } + + sleep -Milliseconds 500 +} + +if(!(Test-Path $SERVER_LOGFILE)) { + "Error server log file not found $SERVER_LOGFILE" + printInstallResults 1 + exit 0 +} + +# Finish server setup +printInstallResults 0 + +if($SERVER_ID) { + while($True) { + if(!(gps -Id $SERVER_ID)) { + "server died, exit" + exit 0 + } + + sleep 30 + } +} +`; +} diff --git a/extensions/open-remote-ssh/src/ssh/hostfile.ts b/extensions/open-remote-ssh/src/ssh/hostfile.ts new file mode 100644 index 00000000..51d34a9e --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/hostfile.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { exists as folderExists } from '../common/files'; + +const PATH_SSH_USER_DIR = path.join(os.homedir(), '.ssh'); +const KNOW_HOST_FILE = path.join(PATH_SSH_USER_DIR, 'known_hosts'); +const HASH_MAGIC = '|1|'; +const HASH_DELIM = '|'; + +export async function checkNewHostInHostkeys(host: string): Promise { + const fileContent = await fs.promises.readFile(KNOW_HOST_FILE, { encoding: 'utf8' }); + const lines = fileContent.split(/\r?\n/); + for (let line of lines) { + line = line.trim(); + if (!line.startsWith(HASH_MAGIC)) { + continue; + } + + const [hostEncripted_] = line.split(' '); + const [salt_, hostHash_] = hostEncripted_.substring(HASH_MAGIC.length).split(HASH_DELIM); + const hostHash = crypto.createHmac('sha1', Buffer.from(salt_, 'base64')).update(host).digest(); + if (hostHash.toString('base64') === hostHash_) { + return false; + } + } + + return true; +} + +export async function addHostToHostFile(host: string, hostKey: Buffer, type: string): Promise { + if (!folderExists(PATH_SSH_USER_DIR)) { + await fs.promises.mkdir(PATH_SSH_USER_DIR, 0o700); + } + + const salt = crypto.randomBytes(20); + const hostHash = crypto.createHmac('sha1', salt).update(host).digest(); + + const entry = `${HASH_MAGIC}${salt.toString('base64')}${HASH_DELIM}${hostHash.toString('base64')} ${type} ${hostKey.toString('base64')}\n`; + await fs.promises.appendFile(KNOW_HOST_FILE, entry); +} diff --git a/extensions/open-remote-ssh/src/ssh/identityFiles.ts b/extensions/open-remote-ssh/src/ssh/identityFiles.ts new file mode 100644 index 00000000..ffb33161 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/identityFiles.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import type { ParsedKey } from 'ssh2-streams'; +import * as ssh2 from 'ssh2'; +import { untildify, exists as fileExists } from '../common/files'; +import Log from '../common/logger'; + +const homeDir = os.homedir(); +const PATH_SSH_CLIENT_ID_DSA = path.join(homeDir, '.ssh', '/id_dsa'); +const PATH_SSH_CLIENT_ID_ECDSA = path.join(homeDir, '.ssh', '/id_ecdsa'); +const PATH_SSH_CLIENT_ID_RSA = path.join(homeDir, '.ssh', '/id_rsa'); +const PATH_SSH_CLIENT_ID_ED25519 = path.join(homeDir, '.ssh', '/id_ed25519'); +const PATH_SSH_CLIENT_ID_XMSS = path.join(homeDir, '.ssh', '/id_xmss'); +const PATH_SSH_CLIENT_ID_ECDSA_SK = path.join(homeDir, '.ssh', '/id_ecdsa_sk'); +const PATH_SSH_CLIENT_ID_ED25519_SK = path.join(homeDir, '.ssh', '/id_ed25519_sk'); + +const DEFAULT_IDENTITY_FILES: string[] = [ + PATH_SSH_CLIENT_ID_RSA, + PATH_SSH_CLIENT_ID_ECDSA, + PATH_SSH_CLIENT_ID_ECDSA_SK, + PATH_SSH_CLIENT_ID_ED25519, + PATH_SSH_CLIENT_ID_ED25519_SK, + PATH_SSH_CLIENT_ID_XMSS, + PATH_SSH_CLIENT_ID_DSA, +]; + +export interface SSHKey { + filename: string; + parsedKey: ParsedKey; + fingerprint: string; + agentSupport?: boolean; + isPrivate?: boolean; +} + +// From https://github.com/openssh/openssh-portable/blob/acb2059febaddd71ee06c2ebf63dcf211d9ab9f2/sshconnect2.c#L1689-L1690 +export async function gatherIdentityFiles(identityFiles: string[], sshAgentSock: string | undefined, identitiesOnly: boolean, logger: Log) { + identityFiles = identityFiles.map(untildify).map(i => i.replace(/\.pub$/, '')); + if (identityFiles.length === 0) { + identityFiles.push(...DEFAULT_IDENTITY_FILES); + } + + const identityFileContentsResult = await Promise.allSettled(identityFiles.map(async keyPath => { + keyPath = await fileExists(keyPath + '.pub') ? keyPath + '.pub' : keyPath; + return fs.promises.readFile(keyPath); + })); + const fileKeys: SSHKey[] = identityFileContentsResult.map((result, i) => { + if (result.status === 'rejected') { + return undefined; + } + + const parsedResult = ssh2.utils.parseKey(result.value); + if (parsedResult instanceof Error || !parsedResult) { + logger.error(`Error while parsing SSH public key ${identityFiles[i]}:`, parsedResult); + return undefined; + } + + const parsedKey = Array.isArray(parsedResult) ? parsedResult[0] : parsedResult; + const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64'); + + return { + filename: identityFiles[i], + parsedKey, + fingerprint + }; + }).filter((v: T | undefined): v is T => !!v); + + let sshAgentParsedKeys: ParsedKey[] = []; + try { + if (!sshAgentSock) { + throw new Error(`SSH_AUTH_SOCK environment variable not defined`); + } + + sshAgentParsedKeys = await new Promise((resolve, reject) => { + const sshAgent = new ssh2.OpenSSHAgent(sshAgentSock); + sshAgent.getIdentities((err, publicKeys) => { + if (err) { + reject(err); + } else { + resolve(publicKeys || []); + } + }); + }); + } catch (e) { + logger.error(`Couldn't get identities from OpenSSH agent`, e); + } + + const sshAgentKeys: SSHKey[] = sshAgentParsedKeys.map(parsedKey => { + const fingerprint = crypto.createHash('sha256').update(parsedKey.getPublicSSH()).digest('base64'); + return { + filename: parsedKey.comment, + parsedKey, + fingerprint, + agentSupport: true + }; + }); + + const agentKeys: SSHKey[] = []; + const preferredIdentityKeys: SSHKey[] = []; + for (const agentKey of sshAgentKeys) { + const foundIdx = fileKeys.findIndex(k => agentKey.parsedKey.type === k.parsedKey.type && agentKey.fingerprint === k.fingerprint); + if (foundIdx >= 0) { + preferredIdentityKeys.push({ ...fileKeys[foundIdx], agentSupport: true }); + fileKeys.splice(foundIdx, 1); + } else if (!identitiesOnly) { + agentKeys.push(agentKey); + } + } + preferredIdentityKeys.push(...agentKeys); + preferredIdentityKeys.push(...fileKeys); + + logger.trace(`Identity keys:`, preferredIdentityKeys.length ? preferredIdentityKeys.map(k => `${k.filename} ${k.parsedKey.type} SHA256:${k.fingerprint}`).join('\n') : 'None'); + + return preferredIdentityKeys; +} diff --git a/extensions/open-remote-ssh/src/ssh/sshConfig.ts b/extensions/open-remote-ssh/src/ssh/sshConfig.ts new file mode 100644 index 00000000..6ce6ed32 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshConfig.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import SSHConfig, { Directive, Line, Section } from '@jeanp413/ssh-config'; +import * as vscode from 'vscode'; +import { exists as fileExists, normalizeToSlash, untildify } from '../common/files'; +import { isWindows } from '../common/platform'; +import { glob } from 'glob'; + +const systemSSHConfig = isWindows ? path.resolve(process.env.ALLUSERSPROFILE || 'C:\\ProgramData', 'ssh\\ssh_config') : '/etc/ssh/ssh_config'; +const defaultSSHConfigPath = path.resolve(os.homedir(), '.ssh/config'); + +export function getSSHConfigPath() { + const sshConfigPath = vscode.workspace.getConfiguration('remote.SSH').get('configFile'); + return sshConfigPath ? untildify(sshConfigPath) : defaultSSHConfigPath; +} + +function isDirective(line: Line): line is Directive { + return line.type === SSHConfig.DIRECTIVE; +} + +function isHostSection(line: Line): line is Section { + return isDirective(line) && line.param === 'Host' && !!line.value && !!(line as Section).config; +} + +function isIncludeDirective(line: Line): line is Section { + return isDirective(line) && line.param === 'Include' && !!line.value; +} + +const SSH_CONFIG_PROPERTIES: Record = { + 'host': 'Host', + 'hostname': 'HostName', + 'user': 'User', + 'port': 'Port', + 'identityagent': 'IdentityAgent', + 'identitiesonly': 'IdentitiesOnly', + 'identityfile': 'IdentityFile', + 'forwardagent': 'ForwardAgent', + 'preferredauthentications': 'PreferredAuthentications', + 'proxyjump': 'ProxyJump', + 'proxycommand': 'ProxyCommand', + 'include': 'Include', +}; + +function normalizeProp(prop: Directive) { + prop.param = SSH_CONFIG_PROPERTIES[prop.param.toLowerCase()] || prop.param; +} + +function normalizeSSHConfig(config: SSHConfig) { + for (const line of config) { + if (isDirective(line)) { + normalizeProp(line); + } + if (isHostSection(line)) { + normalizeSSHConfig(line.config); + } + } + return config; +} + +async function parseSSHConfigFromFile(filePath: string, userConfig: boolean) { + let content = ''; + if (await fileExists(filePath)) { + content = (await fs.promises.readFile(filePath, 'utf8')).trim(); + } + const config = normalizeSSHConfig(SSHConfig.parse(content)); + + const includedConfigs: [number, SSHConfig[]][] = []; + for (let i = 0; i < config.length; i++) { + const line = config[i]; + if (isIncludeDirective(line)) { + const values = (line.value as string).split(',').map(s => s.trim()); + const configs: SSHConfig[] = []; + for (const value of values) { + const includePaths = await glob(normalizeToSlash(untildify(value)), { + absolute: true, + cwd: normalizeToSlash(path.dirname(userConfig ? defaultSSHConfigPath : systemSSHConfig)) + }); + for (const p of includePaths) { + configs.push(await parseSSHConfigFromFile(p, userConfig)); + } + } + includedConfigs.push([i, configs]); + } + } + for (const [idx, includeConfigs] of includedConfigs.reverse()) { + config.splice(idx, 1, ...includeConfigs.flat()); + } + + return config; +} + +export default class SSHConfiguration { + + static async loadFromFS(): Promise { + const config = await parseSSHConfigFromFile(getSSHConfigPath(), true); + config.push(...await parseSSHConfigFromFile(systemSSHConfig, false)); + + return new SSHConfiguration(config); + } + + constructor(private sshConfig: SSHConfig) { + } + + getAllConfiguredHosts(): string[] { + const hosts = new Set(); + for (const line of this.sshConfig) { + if (isHostSection(line)) { + const value = Array.isArray(line.value) ? line.value[0] : line.value; + const isPattern = /^!/.test(value) || /[?*]/.test(value); + if (!isPattern) { + hosts.add(value); + } + } + } + + return [...hosts.keys()]; + } + + getHostConfiguration(host: string): Record { + // Only a few directives return an array + // https://github.com/jeanp413/ssh-config/blob/8d187bb8f1d83a51ff2b1d127e6b6269d24092b5/src/ssh-config.ts#L9C1-L9C118 + return this.sshConfig.compute(host) as Record; + } +} diff --git a/extensions/open-remote-ssh/src/ssh/sshConnection.ts b/extensions/open-remote-ssh/src/ssh/sshConnection.ts new file mode 100644 index 00000000..abaf1471 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshConnection.ts @@ -0,0 +1,367 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter } from 'events'; +import * as net from 'net'; +import * as fs from 'fs'; +import * as stream from 'stream'; +import { Client, ClientChannel, ClientErrorExtensions, ExecOptions, ShellOptions, ConnectConfig } from 'ssh2'; +import { Server } from 'net'; +import { SocksConnectionInfo, createServer as createSocksServer } from 'simple-socks'; + +export interface SSHConnectConfig extends ConnectConfig { + /** Optional Unique ID attached to ssh connection. */ + uniqueId?: string; + /** Automatic retry to connect, after disconnect. Default true */ + reconnect?: boolean; + /** Number of reconnect retry, after disconnect. Default 10 */ + reconnectTries?: number; + /** Delay after which reconnect should be done. Default 5000ms */ + reconnectDelay?: number; + /** Path to private key */ + identity?: string | Buffer; +} + +export interface SSHTunnelConfig { + /** Remote Address to connect */ + remoteAddr?: string; + /** Local port to bind to. By default, it will bind to a random port, if not passed */ + localPort?: number; + /** Remote Port to connect */ + remotePort?: number; + /** Remote socket path to connect */ + remoteSocketPath?: string; + socks?: boolean; + /** Unique name */ + name?: string; +} + +const defaultOptions: Partial = { + reconnect: false, + port: 22, + reconnectTries: 3, + reconnectDelay: 5000 +}; + +const SSHConstants = { + 'CHANNEL': { + SSH: 'ssh', + TUNNEL: 'tunnel', + X11: 'x11' + }, + 'STATUS': { + BEFORECONNECT: 'beforeconnect', + CONNECT: 'connect', + BEFOREDISCONNECT: 'beforedisconnect', + DISCONNECT: 'disconnect' + } +}; + +export default class SSHConnection extends EventEmitter { + public config: SSHConnectConfig; + + private activeTunnels: { [index: string]: SSHTunnelConfig & { server: Server } } = {}; + private __$connectPromise: Promise | null = null; + private __retries: number = 0; + private __err: Error & ClientErrorExtensions & { code?: string } | null = null; + private sshConnection: Client | null = null; + + constructor(options: SSHConnectConfig) { + super(); + this.config = Object.assign({}, defaultOptions, options); + this.config.uniqueId = this.config.uniqueId || `${this.config.username}@${this.config.host}`; + } + + /** + * Emit message on this channel + */ + override emit(channel: string, status: string, payload?: any): boolean { + super.emit(channel, status, this, payload); + return super.emit(`${channel}:${status}`, this, payload); + } + + /** + * Get shell socket + */ + shell(options: ShellOptions = {}): Promise { + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.shell(options, (err, stream) => err ? reject(err) : resolve(stream)); + }); + }); + } + + /** + * Exec a command + */ + exec(cmd: string, params?: Array, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> { + cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : ''); + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.exec(cmd, options, (err, stream) => { + if (err) { + return reject(err); + } + let stdout = ''; + let stderr = ''; + stream.on('close', function () { + return resolve({ stdout, stderr }); + }).on('data', function (data: Buffer | string) { + stdout += data.toString(); + }).stderr.on('data', function (data: Buffer | string) { + stderr += data.toString(); + }); + }); + }); + }); + } + + /** + * Exec a command + */ + execPartial(cmd: string, tester: (stdout: string, stderr: string) => boolean, params?: Array, options: ExecOptions = {}): Promise<{ stdout: string; stderr: string }> { + cmd += (Array.isArray(params) ? (' ' + params.join(' ')) : ''); + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.exec(cmd, options, (err, stream) => { + if (err) { + return reject(err); + } + let stdout = ''; + let stderr = ''; + let resolved = false; + stream.on('close', function () { + if (!resolved) { + return resolve({ stdout, stderr }); + } + }).on('data', function (data: Buffer | string) { + stdout += data.toString(); + + if (tester(stdout, stderr)) { + resolved = true; + + return resolve({ stdout, stderr }); + } + }).stderr.on('data', function (data: Buffer | string) { + stderr += data.toString(); + + if (tester(stdout, stderr)) { + resolved = true; + + return resolve({ stdout, stderr }); + } + }); + }); + }); + }); + } + + /** + * Forward out + */ + forwardOut(srcIP: string, srcPort: number, destIP: string, destPort: number): Promise { + return this.connect().then(() => { + return new Promise((resolve, reject) => { + this.sshConnection!.forwardOut(srcIP, srcPort, destIP, destPort, (err, stream) => { + if (err) { + return reject(err); + } + resolve(stream); + }); + }); + }); + } + + /** + * Get a Socks Port + */ + getSocksPort(localPort: number): Promise { + return this.addTunnel({ name: '__socksServer', socks: true, localPort: localPort }).then((tunnel) => { + return tunnel.localPort!; + }); + } + + /** + * Close SSH Connection + */ + close(): Promise { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFOREDISCONNECT); + return this.closeTunnel().then(() => { + if (this.sshConnection) { + this.sshConnection.end(); + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT); + } + }); + } + + /** + * Connect the SSH Connection + */ + connect(c?: SSHConnectConfig): Promise { + this.config = Object.assign(this.config, c); + ++this.__retries; + + if (this.__$connectPromise) { + return this.__$connectPromise; + } + + this.__$connectPromise = new Promise((resolve, reject) => { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.BEFORECONNECT); + if (!this.config || typeof this.config === 'function' || !(this.config.host || this.config.sock) || !this.config.username) { + reject(`Invalid SSH connection configuration host/username can't be empty`); + this.__$connectPromise = null; + return; + } + + if (this.config.identity) { + if (fs.existsSync(this.config.identity)) { + this.config.privateKey = fs.readFileSync(this.config.identity); + } + delete this.config.identity; + } + + //Start ssh server connection + this.sshConnection = new Client(); + this.sshConnection.on('ready', (err: Error & ClientErrorExtensions) => { + if (err) { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err }); + this.__$connectPromise = null; + return reject(err); + } + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.CONNECT); + this.__retries = 0; + this.__err = null; + resolve(this); + }).on('error', (err) => { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: err }); + this.__err = err; + }).on('close', () => { + this.emit(SSHConstants.CHANNEL.SSH, SSHConstants.STATUS.DISCONNECT, { err: this.__err }); + if (this.config.reconnect && this.__retries <= this.config.reconnectTries! && this.__err && this.__err.level !== 'client-authentication' && this.__err.code !== 'ENOTFOUND') { + setTimeout(() => { + this.__$connectPromise = null; + resolve(this.connect()); + }, this.config.reconnectDelay); + } else { + reject(this.__err); + } + }).connect(this.config); + }); + return this.__$connectPromise; + } + + /** + * Get existing tunnel by name + */ + getTunnel(name: string) { + return this.activeTunnels[name]; + } + + /** + * Add new tunnel if not exist + */ + addTunnel(SSHTunnelConfig: SSHTunnelConfig): Promise { + SSHTunnelConfig.name = SSHTunnelConfig.name || `${SSHTunnelConfig.remoteAddr}@${SSHTunnelConfig.remotePort || SSHTunnelConfig.remoteSocketPath}`; + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.BEFORECONNECT, { SSHTunnelConfig: SSHTunnelConfig }); + if (this.getTunnel(SSHTunnelConfig.name)) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig }); + return Promise.resolve(this.getTunnel(SSHTunnelConfig.name)); + } else { + return new Promise((resolve, reject) => { + let server: net.Server; + if (SSHTunnelConfig.socks) { + server = createSocksServer({ + connectionFilter: (destination: SocksConnectionInfo, origin: SocksConnectionInfo, callback: (err?: any, dest?: stream.Duplex) => void) => { + this.connect().then(() => { + this.sshConnection!.forwardOut( + origin.address, + origin.port, + destination.address, + destination.port, + (err, stream) => { + if (err) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + return callback(err); + } + return callback(null, stream); + }); + }); + } + }).on('proxyError', (err: any) => { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + }); + } else { + server = net.createServer() + .on('connection', (socket) => { + this.connect().then(() => { + if (SSHTunnelConfig.remotePort) { + this.sshConnection!.forwardOut('127.0.0.1', 0, SSHTunnelConfig.remoteAddr!, SSHTunnelConfig.remotePort!, (err, stream) => { + if (err) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + return; + } + stream.pipe(socket); + socket.pipe(stream); + }); + } else { + this.sshConnection!.openssh_forwardOutStreamLocal(SSHTunnelConfig.remoteSocketPath!, (err, stream) => { + if (err) { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + return; + } + stream.pipe(socket); + socket.pipe(stream); + }); + } + }); + }); + } + + SSHTunnelConfig.localPort = SSHTunnelConfig.localPort || 0; + server.on('listening', () => { + SSHTunnelConfig.localPort = (server.address() as net.AddressInfo).port; + this.activeTunnels[SSHTunnelConfig.name!] = Object.assign({}, { server }, SSHTunnelConfig); + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.CONNECT, { SSHTunnelConfig: SSHTunnelConfig }); + resolve(this.activeTunnels[SSHTunnelConfig.name!]); + }).on('error', (err: any) => { + this.emit(SSHConstants.CHANNEL.TUNNEL, SSHConstants.STATUS.DISCONNECT, { SSHTunnelConfig: SSHTunnelConfig, err: err }); + server.close(); + reject(err); + delete this.activeTunnels[SSHTunnelConfig.name!]; + }).listen(SSHTunnelConfig.localPort); + }); + } + } + + /** + * Close the tunnel + */ + closeTunnel(name?: string): Promise { + if (name && this.activeTunnels[name]) { + return new Promise((resolve) => { + const tunnel = this.activeTunnels[name]; + this.emit( + SSHConstants.CHANNEL.TUNNEL, + SSHConstants.STATUS.BEFOREDISCONNECT, + { SSHTunnelConfig: tunnel } + ); + tunnel.server.close(() => { + this.emit( + SSHConstants.CHANNEL.TUNNEL, + SSHConstants.STATUS.DISCONNECT, + { SSHTunnelConfig: this.activeTunnels[name] } + ); + delete this.activeTunnels[name]; + resolve(); + }); + }); + } else if (!name) { + const tunnels = Object.keys(this.activeTunnels).map((key) => this.closeTunnel(key)); + return Promise.all(tunnels).then(() => { }); + } + + return Promise.resolve(); + } +} diff --git a/extensions/open-remote-ssh/src/ssh/sshDestination.ts b/extensions/open-remote-ssh/src/ssh/sshDestination.ts new file mode 100644 index 00000000..db0b4406 --- /dev/null +++ b/extensions/open-remote-ssh/src/ssh/sshDestination.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export default class SSHDestination { + constructor( + public readonly hostname: string, + public readonly user?: string, + public readonly port?: number + ) { + } + + static parse(dest: string): SSHDestination { + let user: string | undefined; + const atPos = dest.lastIndexOf('@'); + if (atPos !== -1) { + user = dest.substring(0, atPos); + } + + let port: number | undefined; + const colonPos = dest.lastIndexOf(':'); + if (colonPos !== -1) { + port = parseInt(dest.substring(colonPos + 1), 10); + } + + const start = atPos !== -1 ? atPos + 1 : 0; + const end = colonPos !== -1 ? colonPos : dest.length; + const hostname = dest.substring(start, end); + + return new SSHDestination(hostname, user, port); + } + + toString(): string { + let result = this.hostname; + if (this.user) { + result = `${this.user}@` + result; + } + if (this.port) { + result = result + `:${this.port}`; + } + return result; + } + + // vscode.uri implementation lowercases the authority, so when reopen or restore + // a remote session from the recently openend list the connection fails + static parseEncoded(dest: string): SSHDestination { + try { + const data = JSON.parse(Buffer.from(dest, 'hex').toString()); + return new SSHDestination(data.hostName, data.user, data.port); + } catch { + } + return SSHDestination.parse(dest.replace(/\\x([0-9a-f]{2})/g, (_, charCode) => String.fromCharCode(parseInt(charCode, 16)))); + } + + toEncodedString(): string { + return this.toString().replace(/[A-Z]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).toLowerCase()}`); + } +} diff --git a/extensions/open-remote-ssh/tsconfig.json b/extensions/open-remote-ssh/tsconfig.json new file mode 100644 index 00000000..2e08e0d2 --- /dev/null +++ b/extensions/open-remote-ssh/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out", + }, + "include": [ + "src/**/*", + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts", + "../../src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts", + ] +}