diff --git a/README.md b/README.md index e948f1c..76d04f6 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,12 @@ This extension makes use of the new FileSystemProvider, added in version 1.23.0 of Visual Studio Code. +## Features +* Use a remote directory (over SSH) as workspace folder +* Use agents, including Pageant for Windows +* Easily create configurations that mirror a PuTTY session +* Have multiple SSH workspace folders at once + ## Usage Add SSH FS configs to "sshfs.configs" in your User Settings: ```js @@ -20,17 +26,52 @@ Add SSH FS configs to "sshfs.configs" in your User Settings: // Username to login with "username": "root", - // Path to ssh-agent's UNIX socket + + // Path to ssh-agent's UNIX socket (cygwin ones should work too) // or 'pageant' when using Pageant on Windows "agent": "pageant" // Instead of using an agent, we can also just use a password "password": "CorrectHorseBatteryStaple" + // Or a private key (raw key, OpenSSH format) + // (can also be a public key for host-based authentication) "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnN...", // Should the private key be encrypted "passphrase": "CorrectHorseBatteryStaple" + }, + { + // If you're on Windows and have PuTTY installed + "name": "media-server", + "root": "/data/media/", + + + // Either set this to a session name + "putty": "My media server", + + // Or let it find one using the host (and username) + "putty": true, + // Can also be a session name, e.g. "My media server" + "host": "my.media.me", + // (Optional) Filter the session by username + // (This only works if host is NOT a name of a session) + "username": "media", + + + // If the session has an encrypted key + "passphrase": "CorrectHorseBatteryStaple" + + // Note: If the session doesn't specify a username, but + // has "Use system username" enabled, it'll use process.env.USER + + // Note: The "agent" option will be set to "pageant" if the + // session has "Attempt authentication using Pageant" set + }, + { + // With PuTTY, this can be a complete configuration (with / as root) + "name": "quick-putty", + "putty": "My PuTTY session" } ], } @@ -49,8 +90,11 @@ This will add a Workspace folder linked to a SSH (SFTP) session: ## TO DO *(in order of most likely to implement first)* * ~~Fix bug where saving a file resets the permissions (when owner/root at least)~~ **DONE** +* ~~Allow loading PuTTY sessions when on windows~~ **DONE** + * Also have a command to directly use a PuTTY session (**TODO**) * Fix bug where the Explorer shows a loading bar forever * Fix bug where VSCode shows an error message about `no provider for ssh://NAME/` +* Allow loading (or automatically use) sessions from .ssh/config * An icon for the extension * Configuring a deleted (but active) configuration should show the old config * Currently it'll open a new default configuration file for it diff --git a/package-lock.json b/package-lock.json index ef0a984..e188931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "vscode-sshfs", - "version": "0.0.1", + "version": "1.0.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -29,6 +29,12 @@ "@types/node": "*" } }, + "@types/winreg": { + "version": "1.2.30", + "resolved": "https://registry.npmjs.org/@types/winreg/-/winreg-1.2.30.tgz", + "integrity": "sha1-kdZxDlNtNFucmwF8V0z2qNpkxRg=", + "dev": true + }, "ajv": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", @@ -2423,6 +2429,11 @@ "vinyl-source-stream": "^1.1.0" } }, + "winreg": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz", + "integrity": "sha1-ugZWKbepJRMOFXeRCM9UCZDpjRs=" + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index a00b25c..20d71e7 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,10 @@ "passphrase": { "type": "string", "description": "For an encrypted private key, this is the passphrase used to decrypt it" + }, + "putty": { + "type": ["string", "boolean"], + "description": "(Windows only) Use the settings from the given PuTTY session (set this to true to find one using the other settings)" } } } @@ -224,10 +228,12 @@ "devDependencies": { "@types/node": "^7.0.43", "@types/ssh2": "^0.5.35", + "@types/winreg": "^1.2.30", "typescript": "^2.5.2", "vscode": "^1.1.17" }, "dependencies": { - "ssh2": "^0.6.0" + "ssh2": "^0.6.0", + "winreg": "^1.2.4" } -} \ No newline at end of file +} diff --git a/src/manager.ts b/src/manager.ts index 6b3ffcc..5879308 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -1,6 +1,9 @@ +import { readFile } from 'fs'; import { Client, ConnectConfig } from 'ssh2'; import * as vscode from 'vscode'; + +import { getSession as getPuttySession, PuttySession } from './putty'; import SSHFileSystem, { EMPTY_FILE_SYSTEM } from './sshFileSystem'; import { toPromise } from './toPromise'; @@ -14,6 +17,7 @@ async function assertFs(man: Manager, uri: vscode.Uri) { export interface FileSystemConfig extends ConnectConfig { name: string; root?: string; + putty?: string | boolean; } function createTreeItem(manager: Manager, name: string): vscode.TreeItem { @@ -126,11 +130,38 @@ export class Manager implements vscode.FileSystemProvider, vscode.TreeDataProvid if (promise) return promise; // config = config || this.memento.get(`fs.config.${name}`); config = config || (await this.loadConfigs()).find(c => c.name === name); - promise = new Promise((resolve, reject) => { + promise = new Promise(async (resolve, reject) => { if (!config) { throw new Error(`A SSH filesystem with the name '${name}' doesn't exist`); } this.registerFileSystem(name, config); + if (config.putty) { + let nameOnly = true; + if (config.putty === true) { + if (!config.host) return reject(new Error(`'putty' was true but 'host' is empty/missing`)); + config.putty = config.host; + nameOnly = false; + } + const session = await getPuttySession(config.putty, config.host, config.username, nameOnly); + if (!session) return reject(new Error(`Couldn't find the requested PuTTY session`)); + if (session.protocol !== 'ssh') return reject(new Error(`The requested PuTTY session isn't a SSH session`)); + config.username = session.username; + config.host = session.hostname; + config.port = session.portnumber; + config.agent = session.tryagent ? 'pageant' : undefined; + if (session.usernamefromenvironment) { + session.username = process.env.USERNAME; + if (!session.username) return reject(new Error(`No username specified in the session (nor is using the system username enabled)`)); + } + if (session.publickeyfile) { + try { + const key = await toPromise(cb => readFile(session.publickeyfile, cb)); + config.privateKey = key; + } catch (e) { + return reject(new Error(`Error while reading the keyfile at:\n${session.publickeyfile}`)); + } + } + } const client = new Client(); client.on('ready', () => { client.sftp((err, sftp) => { diff --git a/src/putty.ts b/src/putty.ts new file mode 100644 index 0000000..548d69a --- /dev/null +++ b/src/putty.ts @@ -0,0 +1,61 @@ + +import * as Winreg from 'winreg'; + +import { toPromise } from './toPromise'; + +const winreg = new Winreg({ + hive: Winreg.HKCU, + key: `\\Software\\SimonTatham\\PuTTY\\Sessions\\`, +}); + +export type NumberAsBoolean = 0 | 1; +export interface PuttySession { + [key: string]: string | number; + name: string; + hostname: string; + protocol: string; + portnumber: number; + username: string; + usernamefromenvironment: NumberAsBoolean; + tryagent: NumberAsBoolean; + publickeyfile: string; +} + +function valueFromItem(item: Winreg.RegistryItem) { + switch (item.type) { + case 'REG_DWORD': + return parseInt(item.value, 16); + case 'REG_SZ': + return item.value; + } + console.log(item.name, item.value); + throw new Error(`Unknown RegistryItem type: '${item.type}'`); +} + +export async function getSessions() { + const values = await toPromise(cb => winreg.keys(cb)); + const sessions: PuttySession[] = []; + await Promise.all(values.map(regSession => (async (res, rej) => { + const name = decodeURIComponent(regSession.key.substr(winreg.key.length)); + const props = await toPromise(cb => regSession.values(cb)); + const properties: { [key: string]: string | number } = {}; + props.forEach(prop => properties[prop.name.toLowerCase()] = valueFromItem(prop)); + sessions.push({ name, ...(properties as any) }); + })())); + return sessions; +} + +export async function getSession(name?: string, host?: string, username?: string, nameOnly = false): Promise { + const sessions = await getSessions(); + if (name) { + name = name.toLowerCase(); + const session = sessions.find(s => s.name.toLowerCase() === name) || null; + if (nameOnly || session) return session; + } + if (!host) return null; + host = host.toLowerCase(); + const hosts = sessions.filter(s => s.hostname.toLowerCase() === host); + if (!username) return hosts[0] || null; + username = username.toLowerCase(); + return hosts.find(s => s.username.toLowerCase() === username) || null; +}