Compare commits

...

27 Commits

Author SHA1 Message Date
Kelvin Schoofs edcd323774 Improve UI for port forwarding (#254)
2 years ago
Kelvin Schoofs 5e52da7620 Merge branch 'master' into feature/forwarding
2 years ago
Kelvin Schoofs 573f2e4bfd Improve port forwarding for wildcard addresses
3 years ago
Kelvin Schoofs f03e1754ba Improve parsePortForwarding
3 years ago
Kelvin Schoofs b7a68da3ec Merge branch 'master' into feature/forwarding
3 years ago
Kelvin Schoofs 90ca8f0308 Fix issue with loading/resolving .pnp.cjs for debugging
3 years ago
Kelvin Schoofs 12317c2fe9 Remove "stale workspace" fix
3 years ago
Kelvin Schoofs 0e52e477f4 Slightly improve webpack config/plugin
3 years ago
Kelvin Schoofs c9b6c3d4de Make proxy hop field in webview a properly populated dropdown
3 years ago
Kelvin Schoofs 284e02f763 Improve webpack configs (deterministic builds across devices)
3 years ago
Kelvin Schoofs c217261eab Add `forwardings` to FileSystemConfig and auto-forward on connection creation
3 years ago
Kelvin Schoofs cf941c73c6 Improve/fix port forwarding
3 years ago
Kelvin Schoofs b7dd83dd70 Add initial auto-reconnection logic
3 years ago
Kelvin Schoofs 7bde31c3b2 Add SOCKS proxy support to RemoteForward (closes #252)
3 years ago
Kelvin Schoofs 3ad7bae30b Add support for DynamicForward (closes #251)
3 years ago
Kelvin Schoofs 13357c8133 Remove "stale workspace" fix
3 years ago
Kelvin Schoofs 14e26a791b Improve how file systems are created + other small fixes/improvements
3 years ago
Kelvin Schoofs 7dc40bd285 Make `REMOTE_COMMANDS` flag support multiple users
3 years ago
Kelvin Schoofs d68bed01cf Lazy import socks library
3 years ago
Kelvin Schoofs 0de0b7d6ab Add node-socksv5@^1.0.3
3 years ago
Kelvin Schoofs eae8e9d6fc Update socks from ^2.2.0 to ^2.6.1
3 years ago
Kelvin Schoofs 2a784a7db6 Merge branch master into feature/forwarding
3 years ago
Kelvin Schoofs 0a4c8a21a3 Fix bug with forwarding local unix socket / windows pipe
4 years ago
Kelvin Schoofs b10c6ee8af Add port forwarding things to Connections view
4 years ago
Kelvin Schoofs 5aafdf5ba0 Add forwardPort/unforwardPort commands
4 years ago
Kelvin Schoofs c707d56895 Add initial support for port forwarding
4 years ago
Kelvin Schoofs 03241aa420 Add dependency ip-matching
4 years ago

@ -5827,6 +5827,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ip-matching@npm:^2.0.0":
version: 2.1.2
resolution: "ip-matching@npm:2.1.2"
checksum: 60356b123e6b2991d9faa57dfe56813a17c55a55fd0ab1d1e2b0df2f944bd3834a0e76530f5a7a20e098770df4ee709f43af7817843ffdef3c0fe8d37b8660a2
languageName: node
linkType: hard
"ip@npm:^2.0.0": "ip@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "ip@npm:2.0.0" resolution: "ip@npm:2.0.0"
@ -6967,6 +6974,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-socksv5@npm:^1.0.3":
version: 1.0.3
resolution: "node-socksv5@npm:1.0.3"
checksum: 9f78ef0e212ade0b1ca7f081509e485100a6daa02b6aad06ab45999a87b4ef89773ca07c463f8877c71f5432816968590d98022ce0df5316004f43486900c339
languageName: node
linkType: hard
"nopt@npm:^6.0.0": "nopt@npm:^6.0.0":
version: 6.0.0 version: 6.0.0
resolution: "nopt@npm:6.0.0" resolution: "nopt@npm:6.0.0"
@ -9560,7 +9574,9 @@ __metadata:
"@vscode/vsce": ^2.18.0 "@vscode/vsce": ^2.18.0
common: "workspace:*" common: "workspace:*"
event-stream: ^4.0.1 event-stream: ^4.0.1
ip-matching: ^2.0.0
jsonc-parser: ^3.2.0 jsonc-parser: ^3.2.0
node-socksv5: ^1.0.3
prettier: ^2.6.2 prettier: ^2.6.2
semver: ^7.3.5 semver: ^7.3.5
socks: ^2.2.0 socks: ^2.2.0

@ -117,6 +117,8 @@ export interface FileSystemConfig extends ConnectConfig {
instantConnection?: boolean; instantConnection?: boolean;
/** List of special flags to enable/disable certain fixes/features. Flags are usually used for issues or beta testing. Flags can disappear/change anytime! */ /** List of special flags to enable/disable certain fixes/features. Flags are usually used for issues or beta testing. Flags can disappear/change anytime! */
flags?: string[]; flags?: string[];
/** List of port forwardings to (attempt to) establish when the connection gets created */
forwardings?: string[];
/** Internal property saying where this config comes from. Undefined if this config is merged or something */ /** Internal property saying where this config comes from. Undefined if this config is merged or something */
_location?: ConfigLocation; _location?: ConfigLocation;
/** Internal property keeping track of where this config comes from (including merges) */ /** Internal property keeping track of where this config comes from (including merges) */

@ -294,25 +294,25 @@ declare module 'ssh2' {
* - `localhost` to accept connections on all loopback addresses (any protocol family) * - `localhost` to accept connections on all loopback addresses (any protocol family)
* - `127.0.0.1` or `::1` for a specific IPv4 or IPv6 loopback addresses * - `127.0.0.1` or `::1` for a specific IPv4 or IPv6 loopback addresses
*/ */
forwardIn(remoteAddr: string, remotePort: number, callback: (error: Error | undefined, port: number) => void): void; forwardIn(remoteAddr: string, remotePort: number, callback?: (error: Error | undefined, port: number) => void): void;
/** Method to revert {@link forwardIn}. Use the actual bound port, i.e. not `0` */ /** Method to revert {@link forwardIn}. Use the actual bound port, i.e. not `0` */
unforwardIn(remoteAddr: string, remotePort: number, callback: ErrorCallback): void; unforwardIn(remoteAddr: string, remotePort: number, callback?: ErrorCallback): void;
/** Opens a connection from the given address/port to the given address/port */ /** Opens a connection from the given address/port to the given address/port */
forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: ClientChannelCallback): void; forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback?: ClientChannelCallback): void;
/** OpenSSH extension to listen on UNIX domain sockets, similar to {@link forwardIn} */ /** OpenSSH extension to listen on UNIX domain sockets, similar to {@link forwardIn} */
openssh_forwardInStreamLocal(socketPath: string, callback: (error?: Error) => void): void; openssh_forwardInStreamLocal(socketPath: string, callback?: (error?: Error) => void): void;
/** OpenSSh extension to revert {@link openssh_forwardInStreamLocal} */ /** OpenSSh extension to revert {@link openssh_forwardInStreamLocal} */
openssh_unforwardInStreamLocal(socketPath: string, callback: (error?: Error) => void): void; openssh_unforwardInStreamLocal(socketPath: string, callback?: (error?: Error) => void): void;
/** OpenSSH extension to make a connection to a UNIX domain sockets, similar to {@link forwardOut} */ /** OpenSSH extension to make a connection to a UNIX domain sockets, similar to {@link forwardOut} */
openssh_forwardOutStreamLocal(socketPath: string, callback: ClientChannelCallback): void; openssh_forwardOutStreamLocal(socketPath: string, callback?: ClientChannelCallback): void;
/* OpenSSH extension that sends a request to reject any new sessions */ /* OpenSSH extension that sends a request to reject any new sessions */
openssh_noMoreSessions(callback: (error?: Error) => void): void; openssh_noMoreSessions(callback?: (error?: Error) => void): void;
/** Initiates a rekey with the server */ /** Initiates a rekey with the server */
rekey(callback?: () => void): void; rekey(callback?: () => void): void;

@ -22,6 +22,8 @@
"onCommand:sshfs.terminal", "onCommand:sshfs.terminal",
"onCommand:sshfs.focusTerminal", "onCommand:sshfs.focusTerminal",
"onCommand:sshfs.closeTerminal", "onCommand:sshfs.closeTerminal",
"onCommand:sshfs.forwardPort",
"onCommand:sshfs.unforwardPort",
"onCommand:sshfs.configure", "onCommand:sshfs.configure",
"onCommand:sshfs.reload", "onCommand:sshfs.reload",
"onCommand:sshfs.settings", "onCommand:sshfs.settings",
@ -172,6 +174,18 @@
"title": "Close terminal", "title": "Close terminal",
"category": "SSH FS", "category": "SSH FS",
"icon": "$(close)" "icon": "$(close)"
},
{
"command": "sshfs.forwardPort",
"title": "Start forwarding a port (BETA)",
"category": "SSH FS",
"icon": "$(ports-forward-icon)"
},
{
"command": "sshfs.unforwardPort",
"title": "Stop forwarding a port (BETA)",
"category": "SSH FS",
"icon": "$(ports-stop-forward-icon)"
} }
], ],
"menus": { "menus": {
@ -275,14 +289,24 @@
"group": "inline@1" "group": "inline@1"
}, },
{ {
"command": "sshfs.disconnect", "command": "sshfs.forwardPort",
"when": "view == 'sshfs-connections' && viewItem == connection", "when": "view == 'sshfs-connections' && viewItem == connection",
"group": "inline@2" "group": "inline@2"
}, },
{
"command": "sshfs.disconnect",
"when": "view == 'sshfs-connections' && viewItem == connection",
"group": "inline@3"
},
{ {
"command": "sshfs.closeTerminal", "command": "sshfs.closeTerminal",
"when": "view == 'sshfs-connections' && viewItem == terminal", "when": "view == 'sshfs-connections' && viewItem == terminal",
"group": "inline@1" "group": "inline@1"
},
{
"command": "sshfs.unforwardPort",
"when": "view == 'sshfs-connections' && viewItem == forwarding",
"group": "inline@1"
} }
], ],
"explorer/context": [ "explorer/context": [
@ -302,17 +326,21 @@
"group": "remote_11_ssh_sshfs@2" "group": "remote_11_ssh_sshfs@2"
}, },
{ {
"command": "sshfs.settings", "command": "sshfs.forwardPort",
"group": "remote_11_ssh_sshfs@3" "group": "remote_11_ssh_sshfs@3"
}, },
{
"command": "sshfs.settings",
"group": "remote_11_ssh_sshfs@4"
},
{ {
"command": "sshfs.disconnect", "command": "sshfs.disconnect",
"group": "remote_11_ssh_sshfs@4", "group": "remote_11_ssh_sshfs@5",
"when": "sshfs.openConnections > 0" "when": "sshfs.openConnections > 0"
}, },
{ {
"command": "sshfs.disconnectAll", "command": "sshfs.disconnectAll",
"group": "remote_11_ssh_sshfs@5", "group": "remote_11_ssh_sshfs@6",
"when": "sshfs.openConnections > 0" "when": "sshfs.openConnections > 0"
} }
] ]
@ -429,7 +457,9 @@
"dependencies": { "dependencies": {
"common": "workspace:*", "common": "workspace:*",
"event-stream": "^4.0.1", "event-stream": "^4.0.1",
"ip-matching": "^2.0.0",
"jsonc-parser": "^3.2.0", "jsonc-parser": "^3.2.0",
"node-socksv5": "^1.0.3",
"semver": "^7.3.5", "semver": "^7.3.5",
"socks": "^2.2.0", "socks": "^2.2.0",
"ssh2": "^1.11.0", "ssh2": "^1.11.0",

@ -253,10 +253,10 @@ function makeAuthHandler(config: FileSystemConfig, logging: Logger): AuthHandler
return () => authsAllowed.shift() || false; return () => authsAllowed.shift() || false;
} }
export async function createSSH(config: FileSystemConfig, sock?: NodeJS.ReadableStream): Promise<Client | null> { export async function createSSH(config: FileSystemConfig): Promise<Client | null> {
config = (await calculateActualConfig(config))!; config = (await calculateActualConfig(config))!;
if (!config) return null; if (!config) return null;
sock = sock || (await createSocket(config))!; const sock = await createSocket(config);
if (!sock) return null; if (!sock) return null;
const logging = Logging.scope(`createSSH(${config.name})`); const logging = Logging.scope(`createSSH(${config.name})`);
return new Promise<Client>((resolve, reject) => { return new Promise<Client>((resolve, reject) => {

@ -5,9 +5,11 @@ import type { Client, ClientChannel } from 'ssh2';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { configMatches, loadConfigs } from './config'; import { configMatches, loadConfigs } from './config';
import { getFlag, getFlagBoolean } from './flags'; import { getFlag, getFlagBoolean } from './flags';
import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { LOGGING_NO_STACKTRACE, Logging } from './logging';
import type { Manager } from './manager';
import { ActivePortForwarding, addForwarding, formatPortForwarding, parsePortForwarding } from './portForwarding';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import { calculateShellConfig, KNOWN_SHELL_CONFIGS, ShellConfig, tryCommand, tryEcho } from './shellConfig'; import { KNOWN_SHELL_CONFIGS, ShellConfig, calculateShellConfig, tryCommand, tryEcho } from './shellConfig';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
import { mergeEnvironment, toPromise } from './utils'; import { mergeEnvironment, toPromise } from './utils';
@ -21,6 +23,7 @@ export interface Connection {
terminals: SSHPseudoTerminal[]; terminals: SSHPseudoTerminal[];
filesystems: SSHFileSystem[]; filesystems: SSHFileSystem[];
cache: Record<string, any>; cache: Record<string, any>;
forwardings: ActivePortForwarding[];
pendingUserCount: number; pendingUserCount: number;
idleTimer: NodeJS.Timeout; idleTimer: NodeJS.Timeout;
} }
@ -40,6 +43,7 @@ export class ConnectionManager {
public readonly onConnectionUpdated = this.onConnectionUpdatedEmitter.event; public readonly onConnectionUpdated = this.onConnectionUpdatedEmitter.event;
/** Fired when a pending connection gets added/removed */ /** Fired when a pending connection gets added/removed */
public readonly onPendingChanged = this.onPendingChangedEmitter.event; public readonly onPendingChanged = this.onPendingChangedEmitter.event;
public constructor(public readonly manager: Manager) { }
public getActiveConnection(name: string, config?: FileSystemConfig): Connection | undefined { public getActiveConnection(name: string, config?: FileSystemConfig): Connection | undefined {
if (config) return this.connections.find(con => configMatches(con.config, config)); if (config) return this.connections.find(con => configMatches(con.config, config));
name = name.toLowerCase(); name = name.toLowerCase();
@ -138,6 +142,8 @@ export class ConnectionManager {
} else { } else {
shellConfig = await calculateShellConfig(client, logging); shellConfig = await calculateShellConfig(client, logging);
} }
// Complains about ssh2 library connecting a 'drain' event for every channel
client.setMaxListeners(0);
// Query home directory // Query home directory
let home: string | Error | null; let home: string | Error | null;
if (shellConfig.isWindows) { if (shellConfig.isWindows) {
@ -187,6 +193,7 @@ export class ConnectionManager {
terminals: [], terminals: [],
filesystems: [], filesystems: [],
cache: {}, cache: {},
forwardings: [],
pendingUserCount: 0, pendingUserCount: 0,
idleTimer: setInterval(() => { // Automatically close connection when idle for a while idleTimer: setInterval(() => { // Automatically close connection when idle for a while
timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0; timeoutCounter = timeoutCounter ? timeoutCounter - 1 : 0;
@ -194,11 +201,52 @@ export class ConnectionManager {
con.filesystems = con.filesystems.filter(fs => !fs.closed && !fs.closing); con.filesystems = con.filesystems.filter(fs => !fs.closed && !fs.closing);
if (con.filesystems.length) return; // Still got active filesystems on this connection if (con.filesystems.length) return; // Still got active filesystems on this connection
if (con.terminals.length) return; // Still got active terminals on this connection if (con.terminals.length) return; // Still got active terminals on this connection
if (con.forwardings.length) return; // Still got active port forwardings on this connection
if (timeoutCounter !== 1) return timeoutCounter = 2; if (timeoutCounter !== 1) return timeoutCounter = 2;
// timeoutCounter === 1, so it's been inactive for at least 5 seconds, close it! // timeoutCounter === 1, so it's been inactive for at least 5 seconds, close it!
this.closeConnection(con, 'Idle with no active filesystems/terminals'); this.closeConnection(con, 'Idle with no active filesystems/terminals');
}, 5e3), }, 5e3),
}; };
// Setup auto-reconnecting (hacky but better than nothing)
let hadError = false;
client.on('error', e => {
logging.error`Client encountered an error: ${e}`;
hadError = true;
});
(client.once as typeof client.on)('close', async () => {
if (!hadError) return;
logging.warning('Connection closed due to an error');
this.closeConnection(con, 'Connection closed due to an error');
const choice = await vscode.window.showErrorMessage(`Connection to ${actualConfig.label || actualConfig.name} closed due to an error`, 'Ignore', 'Reconnect');
if (choice === 'Ignore') return;
// The "Reconnect" is actually just "create brand new connection" without even trying to e.g. reforward ports
this.createConnection(name, config).catch(e => {
logging.error('Reconnect failed', LOGGING_NO_STACKTRACE);
logging.error(e);
vscode.window.showErrorMessage(`Reconnect failed: ${e.message || e}`);
});
});
// Setup initial port forwardings
setImmediate(async () => {
const forwards = (actualConfig.forwardings || []).map(f => parsePortForwarding(f, 'report'));
const badForwards = forwards.reduce((tot, f) => f ? tot : tot + 1, 0);
if (badForwards) vscode.window.showWarningMessage(`Could not parse ${badForwards} of ${forwards.length} port forwarding from the config, ignoring them`);
let failed = 0;
console.log(forwards);
for (const forward of forwards) {
logging.debug(`forward: ${forward}`);
if (!forward) continue;
logging.info(`Adding forwarding ${formatPortForwarding(forward)}`);
try {
await addForwarding(this.manager, con, forward);
} catch (e) {
logging.error(`Error during forwarding ${formatPortForwarding(forward)}:`, LOGGING_NO_STACKTRACE);
logging.error(e);
failed++;
}
}
if (failed) vscode.window.showWarningMessage(`Failed ${failed} of ${forwards.length - badForwards} port forwardings from the config`);
});
this.connections.push(con); this.connections.push(con);
this.onConnectionAddedEmitter.fire(con); this.onConnectionAddedEmitter.fire(con);
return con; return con;
@ -226,6 +274,8 @@ export class ConnectionManager {
clearInterval(connection.idleTimer); clearInterval(connection.idleTimer);
this.onConnectionRemovedEmitter.fire(connection); this.onConnectionRemovedEmitter.fire(connection);
connection.client.end(); connection.client.end();
connection.forwardings.forEach(f => f[2]());
connection.forwardings = [];
} }
// Without making createConnection return a Proxy, or making Connection a class with // Without making createConnection return a Proxy, or making Connection a class with
// getters and setters informing the manager that created it, we don't know if it updated. // getters and setters informing the manager that created it, we don't know if it updated.

@ -6,6 +6,7 @@ import type { Connection } from './connection';
import { FileSystemRouter } from './fileSystemRouter'; import { FileSystemRouter } from './fileSystemRouter';
import { Logging, setDebug } from './logging'; import { Logging, setDebug } from './logging';
import { Manager } from './manager'; import { Manager } from './manager';
import { ActivePortForwarding, isActivePortForwarding } from './portForwarding';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import { ConfigTreeProvider, ConnectionTreeProvider } from './treeViewManager'; import { ConfigTreeProvider, ConnectionTreeProvider } from './treeViewManager';
import { pickComplex, PickComplexOptions, pickConnection, setAsAbsolutePath, setupWhenClauseContexts } from './ui-utils'; import { pickComplex, PickComplexOptions, pickConnection, setAsAbsolutePath, setupWhenClauseContexts } from './ui-utils';
@ -24,6 +25,7 @@ interface CommandHandler {
handleConfig?(config: FileSystemConfig): void; handleConfig?(config: FileSystemConfig): void;
handleConnection?(connection: Connection): void; handleConnection?(connection: Connection): void;
handleTerminal?(terminal: SSHPseudoTerminal): void; handleTerminal?(terminal: SSHPseudoTerminal): void;
handleActivePortForwarding?(forwarding: ActivePortForwarding): void;
} }
/** `findConfigs` in config.ts ignores URIs for still-connecting connections */ /** `findConfigs` in config.ts ignores URIs for still-connecting connections */
@ -66,7 +68,7 @@ export function activate(context: vscode.ExtensionContext) {
setupWhenClauseContexts(manager.connectionManager); setupWhenClauseContexts(manager.connectionManager);
function registerCommandHandler(name: string, handler: CommandHandler) { function registerCommandHandler(name: string, handler: CommandHandler) {
const callback = async (arg?: string | FileSystemConfig | Connection | SSHPseudoTerminal | vscode.Uri) => { const callback = async (arg?: string | FileSystemConfig | Connection | SSHPseudoTerminal | ActivePortForwarding | vscode.Uri) => {
if (handler.promptOptions && (!arg || typeof arg === 'string')) { if (handler.promptOptions && (!arg || typeof arg === 'string')) {
arg = await pickComplex(manager, { ...handler.promptOptions, nameFilter: arg }); arg = await pickComplex(manager, { ...handler.promptOptions, nameFilter: arg });
} }
@ -80,6 +82,8 @@ export function activate(context: vscode.ExtensionContext) {
return handler.handleConnection?.(arg); return handler.handleConnection?.(arg);
} else if ('name' in arg) { } else if ('name' in arg) {
return handler.handleConfig?.(arg); return handler.handleConfig?.(arg);
} else if (isActivePortForwarding(arg)) {
return handler.handleActivePortForwarding?.(arg);
} }
Logging.warning(`CommandHandler for '${name}' could not handle input '${arg}'`); Logging.warning(`CommandHandler for '${name}' could not handle input '${arg}'`);
}; };
@ -133,6 +137,23 @@ export function activate(context: vscode.ExtensionContext) {
handleTerminal: terminal => terminal.close(), handleTerminal: terminal => terminal.close(),
}); });
// sshfs.forwardPort(target?: FileSystemConfig | Connection, pf?: PortForwarding)
registerCommandHandler('sshfs.forwardPort', {
promptOptions: { promptConfigs: true, promptConnections: true, promptInstantConnection: true },
handleConfig: config => manager.commandPortForward(config),
handleConnection: con => manager.commandPortForward(con),
});
// sshfs.unforwardPort(target: ActivePortForwarding)
registerCommandHandler('sshfs.unforwardPort', {
promptOptions: { promptActivePortForwardings: true },
handleActivePortForwarding(forwarding) {
manager.connectionManager.update(forwarding[1], c =>
c.forwardings = c.forwardings.filter(f => f !== forwarding));
forwarding[2]();
},
});
// sshfs.configure(target?: string | FileSystemConfig) // sshfs.configure(target?: string | FileSystemConfig)
registerCommandHandler('sshfs.configure', { registerCommandHandler('sshfs.configure', {
promptOptions: { promptConfigs: true }, promptOptions: { promptConfigs: true },

@ -3,9 +3,10 @@ import type { FileSystemConfig } from 'common/fileSystemConfig';
import type { Navigation } from 'common/webviewMessages'; import type { Navigation } from 'common/webviewMessages';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { getConfig, loadConfigs, LOADING_CONFIGS } from './config'; import { getConfig, loadConfigs, LOADING_CONFIGS } from './config';
import { getFlagBoolean } from './flags';
import { Connection, ConnectionManager } from './connection'; import { Connection, ConnectionManager } from './connection';
import { getFlagBoolean } from './flags';
import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { Logging, LOGGING_NO_STACKTRACE } from './logging';
import { addForwarding, PortForwarding, promptPortForwarding } from './portForwarding';
import { isSSHPseudoTerminal, replaceVariables, replaceVariablesRecursive } from './pseudoTerminal'; import { isSSHPseudoTerminal, replaceVariables, replaceVariablesRecursive } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
import { catchingPromise, joinCommands } from './utils'; import { catchingPromise, joinCommands } from './utils';
@ -30,7 +31,7 @@ interface TerminalLinkUri extends vscode.TerminalLink {
export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider<TerminalLinkUri> { export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider<TerminalLinkUri> {
protected fileSystems: SSHFileSystem[] = []; protected fileSystems: SSHFileSystem[] = [];
protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {}; protected creatingFileSystems: { [name: string]: Promise<SSHFileSystem> } = {};
public readonly connectionManager = new ConnectionManager(); public readonly connectionManager = new ConnectionManager(this);
constructor(public readonly context: vscode.ExtensionContext) { constructor(public readonly context: vscode.ExtensionContext) {
// In a multi-workspace environment, when the non-main folder gets removed, // In a multi-workspace environment, when the non-main folder gets removed,
// it might be one of ours, which we should then disconnect if it's // it might be one of ours, which we should then disconnect if it's
@ -54,9 +55,8 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
return this.creatingFileSystems[name] ||= catchingPromise<SSHFileSystem>(async (resolve, reject) => { return this.creatingFileSystems[name] ||= catchingPromise<SSHFileSystem>(async (resolve, reject) => {
config ||= getConfig(name); config ||= getConfig(name);
if (!config) throw new Error(`Couldn't find a configuration with the name '${name}'`); if (!config) throw new Error(`Couldn't find a configuration with the name '${name}'`);
const con = await this.connectionManager.createConnection(name, config); con = await this.connectionManager.createConnection(name, config);
this.connectionManager.update(con, con => con.pendingUserCount++); this.connectionManager.update(con, con => con.pendingUserCount++);
config = con.actualConfig;
const { getSFTP } = await import('./connect'); const { getSFTP } = await import('./connect');
const { SSHFileSystem } = await import('./sshFileSystem'); const { SSHFileSystem } = await import('./sshFileSystem');
// Create the actual SFTP session (using the connection's actualConfig, otherwise it'll reprompt for passwords etc) // Create the actual SFTP session (using the connection's actualConfig, otherwise it'll reprompt for passwords etc)
@ -68,13 +68,11 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
delete this.creatingFileSystems[name]; delete this.creatingFileSystems[name];
fs.onClose(() => { fs.onClose(() => {
this.fileSystems = this.fileSystems.filter(f => f !== fs); this.fileSystems = this.fileSystems.filter(f => f !== fs);
this.connectionManager.update(con, con => con.filesystems = con.filesystems.filter(f => f !== fs)); this.connectionManager.update(con!, con => con.filesystems = con.filesystems.filter(f => f !== fs));
}); });
vscode.commands.executeCommand('workbench.files.action.refreshFilesExplorer');
// con.client.once('close', hadError => !fs.closing && this.promptReconnect(name));
this.connectionManager.update(con, con => con.pendingUserCount--); this.connectionManager.update(con, con => con.pendingUserCount--);
// Sanity check that we can access the home directory // Sanity check that we can access the home directory
const [flagCH] = getFlagBoolean('CHECK_HOME', true, config.flags); const [flagCH] = getFlagBoolean('CHECK_HOME', true, con.actualConfig.flags);
if (flagCH) try { if (flagCH) try {
const homeUri = vscode.Uri.parse(`ssh://${name}/${con.home}`); const homeUri = vscode.Uri.parse(`ssh://${name}/${con.home}`);
const stat = await fs.stat(homeUri); const stat = await fs.stat(homeUri);
@ -91,8 +89,8 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
if (answer === 'Okay') return reject(new Error('User stopped filesystem creation after unaccessible home directory error')); if (answer === 'Okay') return reject(new Error('User stopped filesystem creation after unaccessible home directory error'));
} }
return resolve(fs); return resolve(fs);
}).catch((e) => { }).catch(e => {
if (con) this.connectionManager.update(con, con => con.pendingUserCount--); // I highly doubt resolve(fs) will error if (con) this.connectionManager.update(con, con => con.pendingUserCount--);
if (!e) { if (!e) {
delete this.creatingFileSystems[name]; delete this.creatingFileSystems[name];
this.commandDisconnect(name); this.commandDisconnect(name);
@ -122,7 +120,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty))); pty.onDidClose(() => this.connectionManager.update(con, con => con.terminals = con.terminals.filter(t => t !== pty)));
this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--)); this.connectionManager.update(con, con => (con.terminals.push(pty), con.pendingUserCount--));
// Create and show the graphical representation // Create and show the graphical representation
const terminal = vscode.window.createTerminal({ name, pty }); const terminal = vscode.window.createTerminal({ name: con.actualConfig.label || con.actualConfig.name, pty });
pty.terminal = terminal; pty.terminal = terminal;
terminal.show(); terminal.show();
} }
@ -260,7 +258,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
Logging.info`Command received to open a terminal for ${commandArgumentToName(target)}${uri ? ` in ${uri}` : ''}`; Logging.info`Command received to open a terminal for ${commandArgumentToName(target)}${uri ? ` in ${uri}` : ''}`;
const config = 'client' in target ? target.actualConfig : target; const config = 'client' in target ? target.actualConfig : target;
try { try {
await this.createTerminal(config.label || config.name, target, uri); await this.createTerminal(config.name, target, uri);
} catch (e) { } catch (e) {
Logging.error`Error while creating terminal:\n${e}`; Logging.error`Error while creating terminal:\n${e}`;
const choice = await vscode.window.showErrorMessage<vscode.MessageItem>( const choice = await vscode.window.showErrorMessage<vscode.MessageItem>(
@ -269,6 +267,21 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
if (choice && choice.title === 'Retry') return this.commandTerminal(target, uri); if (choice && choice.title === 'Retry') return this.commandTerminal(target, uri);
} }
} }
public async commandPortForward(target: FileSystemConfig | Connection, pf?: PortForwarding) {
const conn = 'config' in target ? target : await this.connectionManager.createConnection(target.name, target);
this.connectionManager.update(conn, c => c.pendingUserCount++);
pf ||= await promptPortForwarding(conn.actualConfig);
this.connectionManager.update(conn, c => c.pendingUserCount--);
if (!pf) return;
try {
await addForwarding(this, conn, pf);
} catch (e) {
const choice = await vscode.window.showErrorMessage<vscode.MessageItem>(
`Couldn't forward the ${pf.type} port for ${conn.actualConfig.name}: ${e.message || e}`,
{ title: 'Retry' }, { title: 'Ignore', isCloseAffordance: true });
if (choice && choice.title === 'Retry') return this.commandPortForward(conn, pf);
}
}
public async commandConfigure(target: string | FileSystemConfig) { public async commandConfigure(target: string | FileSystemConfig) {
Logging.info`Command received to configure ${typeof target === 'string' ? target : target.name}`; Logging.info`Command received to configure ${typeof target === 'string' ? target : target.name}`;
if (typeof target === 'object') { if (typeof target === 'object') {

@ -0,0 +1,562 @@
import type { FileSystemConfig } from 'common/fileSystemConfig';
import { getIP } from 'ip-matching';
import * as net from 'net';
import type { ClientChannel, TcpConnectionDetails, UnixConnectionDetails } from 'ssh2';
import type { Duplex } from 'stream';
import * as vscode from 'vscode';
import type { Connection } from "./connection";
import { LOGGING_NO_STACKTRACE, Logging } from './logging';
import type { Manager } from './manager';
import { FormattedItem, promptQuickPick } from './ui-utils';
import { toPromise } from './utils';
/** Represents a dynamic port forwarding (DynamicForward) */
export interface PortForwardingDynamic {
type: 'dynamic';
port: number;
address?: string;
}
/** Represents a local (LocalForward) or remote (RemoteForward) port forwarding */
export interface PortForwardingLocalRemote {
type: 'local' | 'remote';
/** Represents the local port to use, or undefined for a Unix socket */
localPort?: number;
/** Represents the (optional) local bind address, or the Unix socket path if `localPort` is undefined */
localAddress?: string;
/** Represents the remote port to use, or undefined for a Unix socket */
remotePort?: number;
/** Represents the (optional) remote bind address, or the Unix socket path if `remotePort` is undefined */
remoteAddress?: string;
}
export type PortForwarding = PortForwardingDynamic | PortForwardingLocalRemote;
function tryParseInt(input?: string): number | undefined {
const parsed = input ? parseInt(input) : undefined;
return Number.isNaN(parsed) ? undefined : parsed;
}
// https://regexr.com/61quq
const PORT_FORWARD_REGEX = /^(?<type>\w+)\s*?(?:(?:\s+(?:(?<localAddress>[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):))?(?<localPort>\d+)|(?<localPath>[/\\][/\\.\w?\-]+))(?:\s+(?:(?<remoteAddress>[^\s:]+|[\da-zA-Z:]+|\[[\da-zA-Z:]+\]):)?(?<remotePort>\d+)|\s+(?<remotePath>[/\\][/\\.\w?\-]+))?$/i;
const PORT_FORWARD_TYPES = ['remote', 'local', 'dynamic'];
export function parsePortForwarding(input: string, mode: 'throw'): PortForwarding;
export function parsePortForwarding(input: string, mode: 'report' | 'ignore'): PortForwarding | undefined;
export function parsePortForwarding(input: string, mode: 'report' | 'throw' | 'ignore'): PortForwarding | undefined {
try {
const match = input.match(PORT_FORWARD_REGEX);
if (!match) throw new Error(`Could not infer PortForwarding from '${input}'`);
let type = match.groups?.type.toLowerCase();
if (!type) throw new Error(`Could not infer PortForwarding from '${input}'`);
if (type.endsWith('forward')) type = type.substring(0, type.length - 7);
if (type.length === 1) type = PORT_FORWARD_TYPES.find(t => t[0] === type);
if (!type || !PORT_FORWARD_TYPES.includes(type))
throw new Error(`Could not recognize PortForwarding type '${match.groups!.type}'`);
let {
localPath, localAddress = localPath, localPort,
remotePath, remoteAddress = remotePath, remotePort,
} = match.groups as Partial<Record<string, string>>;
if (localAddress?.[0] === '[' && localAddress.endsWith(']'))
localAddress = localAddress.substring(1, localAddress.length - 1);
if (remoteAddress?.[0] === '[' && remoteAddress.endsWith(']'))
remoteAddress = remoteAddress.substring(1, remoteAddress.length - 1);
let pf: PortForwarding;
if (type === 'remote' && !remoteAddress && !remotePort) {
pf = { type, remoteAddress: localAddress, remotePort: tryParseInt(localPort) };
} else if (type === 'local' || type === 'remote') {
pf = {
type,
localAddress, localPort: tryParseInt(localPort),
remoteAddress, remotePort: tryParseInt(remotePort),
};
} else {
pf = { type: 'dynamic', address: localAddress, port: tryParseInt(localPort)! };
}
validatePortForwarding(pf);
return pf;
} catch (e) {
if (mode === 'ignore') return undefined;
if (mode === 'throw') throw e;
Logging.error(`Parsing port forwarding '${input}' failed:\n${e.message || e}`, LOGGING_NO_STACKTRACE);
return undefined;
}
}
export function getPortForwardingIcon(forwarding: PortForwarding): string {
if (forwarding.type === 'dynamic') return 'globe';
if (forwarding.type === 'local') return 'arrow-small-left';
if (!forwarding.localAddress && forwarding.localPort === undefined) return 'globe';
return 'arrow-small-right';
}
const SINGLE_WORD_PATH_REGEX = /^[/\\.\w?]+$/;
const formatAddrPortPath = (addr?: string, port?: number): string => {
if (port === undefined) {
if (!addr) return 'N/A';
if (SINGLE_WORD_PATH_REGEX.test(addr)) return addr;
return `'${addr}'`;
}
if (addr) try {
const ip = getIP(addr);
if (ip?.type === 'IPv6') return `[${addr.toString()}]:${port}`;
} catch (e) { }
return `${addr || '*'}:${port}`;
};
export function formatPortForwarding(forwarding: PortForwarding): string {
if (forwarding.type === 'local' || forwarding.type === 'remote') {
const local = (forwarding.localPort !== undefined || forwarding.localAddress)
? formatAddrPortPath(forwarding.localAddress, forwarding.localPort) : 'SOCKSv5';
return `${local} ${forwarding.type === 'local' ? ' → ' : ' ← '} ${formatAddrPortPath(forwarding.remoteAddress, forwarding.remotePort)}`;
} else if (forwarding.type === 'dynamic') {
return `${formatAddrPortPath(forwarding.address, forwarding.port)} → SOCKSv5`;
}
// Shouldn't happen but might as well catch it this way
return JSON.stringify(forwarding);
}
export function formatPortForwardingConfig(forwarding: PortForwarding): string {
if (forwarding.type === 'local') {
const { localAddress, localPort, remoteAddress, remotePort } = forwarding;
return `LocalForward ${formatAddrPortPath(localAddress, localPort)} ${formatAddrPortPath(remoteAddress, remotePort)}`;
} else if (forwarding.type === 'remote') {
const { localAddress, localPort, remoteAddress, remotePort } = forwarding;
if (!localAddress && localPort === undefined) {
return `RemoteForward ${formatAddrPortPath(remoteAddress, remotePort)}`;
}
return `RemoteForward ${formatAddrPortPath(localAddress, localPort)} ${formatAddrPortPath(remoteAddress, remotePort)}`;
} else if (forwarding.type === 'dynamic') {
return `DynamicForward ${formatAddrPortPath(forwarding.address, forwarding.port)}`;
}
throw new Error(`Unrecognized forwarding type '${forwarding.type}'`);
}
type Disconnect = () => void;
export type ActivePortForwarding = [data: PortForwarding, connection: Connection, disconnect: Disconnect];
export function isActivePortForwarding(apf: any): apf is ActivePortForwarding {
return Array.isArray(apf) && apf.length === 3 && 'type' in apf[0];
}
function validateLocalRemoteForwarding(forwarding: PortForwardingLocalRemote) {
if (forwarding.type === 'local') {
// Requires `localAddress:localPort` or `localAddress` or `localPort`
if (!forwarding.localAddress && forwarding.localPort === undefined)
throw new Error(`Expected 'localAddress' and/or 'localPort' fields for LocalForward`);
// Requires `remoteAddress:remotePort` or `remoteAddress`
if (!forwarding.remoteAddress)
throw new Error(`Expected 'remoteAddress' field for LocalForward`);
} else if (forwarding.type === 'remote') {
// Requires `remoteAddress:remotePort` or `remoteAddress` or `remotePort`
if (!forwarding.remoteAddress && forwarding.remotePort === undefined)
throw new Error(`Expected 'remoteAddress' and/or 'remotePort' fields for RemoteForward`);
if (forwarding.localAddress || forwarding.localPort !== undefined) {
// Regular forward, so validate the local stuff
// Requires `localAddress:localPort` or `localAddress`
if (!forwarding.localAddress)
throw new Error(`Expected 'localAddress' field for RemoteForward`);
}
}
// Validate ports if given
if (forwarding.localPort !== undefined) {
if (!Number.isInteger(forwarding.localPort) || forwarding.localPort < 0 || forwarding.localPort > 65565)
throw new Error(`Expected 'localPort' field to be an integer 0-65565 for RemoteForward`);
}
if (forwarding.remotePort !== undefined) {
if (!Number.isInteger(forwarding.remotePort) || forwarding.remotePort < 0 || forwarding.remotePort > 65565)
throw new Error(`Expected 'remotePort' field to be an integer 0-65565 for RemoteForward`);
}
}
function validateDynamicForwarding(forwarding: PortForwardingDynamic) {
// Requires `address:port` (or only `address` if we allowed Unix socket paths, but OpenSSH doesn't and neither do we)
if (!forwarding.address)
throw new Error(`Missing 'address' field for DynamicForward`);
if (forwarding.port === undefined)
throw new Error(`Missing 'port' field for DynamicForward`);
if (!Number.isInteger(forwarding.port) || forwarding.port < 0 || forwarding.port > 65565) {
throw new Error(`Expected 'port' field to be an integer 0-65565 for DynamicForward`);
}
}
export function validatePortForwarding(forwarding: PortForwarding): PortForwarding {
switch (forwarding.type) {
case 'dynamic':
validateDynamicForwarding(forwarding);
return forwarding;
case 'local':
case 'remote':
validateLocalRemoteForwarding(forwarding);
return forwarding;
default:
throw new Error(`Unknown PortForwarding type '${(forwarding as any).type}'`);
}
}
async function createLocalForwarding(connection: Connection, forwarding: PortForwardingLocalRemote): Promise<ActivePortForwarding> {
validateLocalRemoteForwarding(forwarding);
if (forwarding.localAddress === '') forwarding = { ...forwarding, localAddress: undefined };
if (forwarding.localAddress === '*') forwarding = { ...forwarding, localAddress: undefined };
const { localAddress, localPort, remoteAddress, remotePort } = forwarding;
const logging = Logging.scope(formatPortForwarding(forwarding));
logging.info(`Setting up local forwarding`);
const { client } = connection;
const sockets = new Set<net.Socket>();
const server = net.createServer(socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
if (remotePort === undefined) {
client.openssh_forwardOutStreamLocal(remoteAddress!, (err, channel) => {
if (err) return socket.destroy(err);
socket.pipe(channel).pipe(socket);
});
} else {
client.forwardOut('localhost', 0, remoteAddress || '', remotePort, (err, channel) => {
if (err) return socket.destroy(err);
socket.pipe(channel).pipe(socket);
});
}
});
if (localPort === undefined) {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(localAddress, resolve);
});
logging.info(`Listening on local socket path: ${localAddress}`);
} else {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
if (localAddress) server.listen(localPort, localAddress, resolve)
else server.listen(localPort, resolve);
});
if (localPort === 0) {
forwarding = { ...forwarding, localPort: (server.address() as net.AddressInfo).port };
}
logging.info(`Listening on remote port ${remoteAddress || '*'}:${localPort}`);
}
return [forwarding, connection, () => server.close(() => sockets.forEach(s => s.destroy()))];
}
async function createRemoteForwarding(connection: Connection, forwarding: PortForwardingLocalRemote): Promise<ActivePortForwarding> {
validateLocalRemoteForwarding(forwarding);
const { localAddress, localPort, remoteAddress, remotePort } = forwarding;
const logging = Logging.scope(formatPortForwarding(forwarding));
let socksServer: import('node-socksv5').Server | undefined;
if (localPort === undefined && !localPort) {
logging.info(`Setting up remote SOCKSv5 proxy with no authentication`);
const { Server, Auth } = await import('node-socksv5');
socksServer = new Server({ auths: [Auth.none()] });
} else {
logging.info(`Setting up remote port forwarding for local address ${localAddress || '*'}:${localPort}`);
}
const channels = new Set<Duplex>();
const onSocket = (channel: Duplex) => {
channels.add(channel);
channel.on('close', () => channels.delete(channel));
let socket: net.Socket;
if (socksServer) {
socket = new net.Socket();
((socksServer as any).onConnection as (typeof socksServer)['onConnection'])(channel as net.Socket);
} else if (localPort === undefined) {
socket = net.createConnection(localAddress!);
socket.on('connect', () => socket.pipe(channel).pipe(socket));
} else {
socket = net.createConnection(localPort, localAddress!);
socket.on('connect', () => socket.pipe(channel).pipe(socket));
}
};
let unlisten: () => void;
if (remotePort === undefined) {
await toPromise(cb => connection.client.openssh_forwardInStreamLocal(remoteAddress!, cb));
const listener = (details: UnixConnectionDetails, accept: () => ClientChannel) => {
if (details.socketPath !== remoteAddress) return;
onSocket(accept());
};
connection.client.on('unix connection', listener);
unlisten = () => connection.client.off('unix connection', listener);
logging.info(`Listening on remote socket path: ${remoteAddress}`);
} else {
const rAddr = remoteAddress === '*' ? '' : remoteAddress || '';
const actualPort = await toPromise<number>(cb => connection.client.forwardIn(rAddr, remotePort!, cb));
forwarding = { ...forwarding, remotePort: actualPort };
const listener = (details: TcpConnectionDetails, accept: () => ClientChannel) => {
if (details.destPort !== actualPort) return;
if (details.destIP !== rAddr) return;
onSocket(accept());
};
connection.client.on('tcp connection', listener);
unlisten = () => connection.client.off('tcp connection', listener);
logging.info(`Listening on remote port ${remoteAddress || '*'}:${actualPort}`);
}
return [forwarding, connection, () => {
unlisten();
if (socksServer) ((socksServer as any).serverSocket as net.Server).close();
try {
if (forwarding.remotePort === undefined) {
connection.client.openssh_unforwardInStreamLocal(forwarding.remoteAddress!);
} else {
connection.client.unforwardIn(forwarding.remoteAddress!, forwarding.remotePort!);
}
} catch (e) {
// Unforwarding when the client is already disconnected throw an error
}
channels.forEach(s => s.destroy());
}];
}
async function createDynamicForwarding(connection: Connection, forwarding: PortForwardingDynamic): Promise<ActivePortForwarding> {
validateDynamicForwarding(forwarding);
// Default is localhost, so transform `undefined` to 'localhost'
if (!forwarding.address) forwarding = { ...forwarding, address: 'localhost' };
// But `undefined` in the net API means "any interface", so transform '*' into `undefined`
if (forwarding.address === '*') forwarding = { ...forwarding, address: undefined };
const logging = Logging.scope(formatPortForwarding(forwarding));
logging.info(`Setting up dynamic forwarding on ${forwarding.address || '*'}:${forwarding.port}`);
const channels = new Set<Duplex>();
let closed = false;
const { Server, Command, Auth } = await import('node-socksv5');
const server = new Server({ auths: [Auth.none()] }, async (info, accept, deny) => {
if (closed) return deny();
if (info.command !== Command.CONNECT) {
logging.error(`Received unsupported ${Command[info.command]} command from ${info.source.ip}:${info.source.port} to ${info.destination.host}:${info.destination.port}`);
return deny();
}
let channel: ClientChannel | undefined;
try {
channel = await toPromise<ClientChannel>(cb => connection.client.forwardOut(info.source.ip, info.source.port, info.destination.host, info.destination.port, cb));
const socket = await accept();
channel.pipe(socket).pipe(channel);
} catch (e) {
if (channel) channel.destroy();
logging.error(`Error connecting from ${info.source.ip}:${info.source.port} to ${info.destination.host}:${info.destination.port}:`, LOGGING_NO_STACKTRACE);
logging.error(e);
return deny();
}
channels.add(channel);
});
// The library does some weird thing where it creates a connection to the destination
// and then makes accept() return that connection? Very weird and bad for our use
// case, so we overwrite this internal method so accept() returns the original socket.
const processConnection: (typeof server)['processConnection'] = async function (this: typeof server, socket, destination) {
await this.sendSuccessConnection(socket, destination);
return socket;
};
(server as any).processConnection = processConnection;
const err = await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
server.listen(forwarding.port, forwarding.address);
}).then(() => undefined, (e: NodeJS.ErrnoException) => e);
if (err) {
if (err.code === 'EADDRINUSE') {
throw new Error(`Port ${forwarding.port} for interface ${forwarding.address} already in use`);
}
throw err;
}
const serverSocket = (server as any).serverSocket as net.Server;
const aInfo = serverSocket.address();
if (!aInfo || typeof aInfo !== 'object' || !('port' in aInfo))
throw new Error(`Could not get bound address for SOCKSv5 server`);
logging.info(`Server listening on ${aInfo.family === 'IPv6' ? `[${aInfo.address}]` : aInfo.address}:${aInfo.port}`);
forwarding = { ...forwarding, port: aInfo.port, address: aInfo.address };
return [forwarding, connection, () => {
closed = true;
serverSocket.close();
channels.forEach(s => s.destroy());
}];
}
function getFactory(type: PortForwarding['type']): (conn: Connection, pf: PortForwarding) => Promise<ActivePortForwarding> {
switch (type) {
case 'local': return createLocalForwarding;
case 'remote': return createRemoteForwarding;
case 'dynamic': return createDynamicForwarding;
default: throw new Error(`Forwarding type '${type}' is not recognized`);
}
}
export async function addForwarding(manager: Manager, connection: Connection, forwarding: PortForwarding): Promise<void> {
const factory = getFactory(forwarding.type);
manager.connectionManager.update(connection, c => c.pendingUserCount++);
try {
const active = await factory(connection, forwarding);
manager.connectionManager.update(connection, c => {
c.forwardings.push(active);
c.pendingUserCount--;
});
} catch (e) {
manager.connectionManager.update(connection, c => c.pendingUserCount--);
throw e;
}
}
/** Far from correct, but it's more of a simple validation against obvious mistakes */
const DOMAIN_REGEX = /^\S{2,63}(\.\S{2,63})*$/;
function validateHost(str: string): string | undefined {
if (DOMAIN_REGEX.test(str)) return undefined;
try {
const ip = getIP(str);
if (!ip || !ip.exact()) return 'Invalid IP / domain';
} catch (e) {
return e.message || 'Invalid IP / domain';
}
}
const PIPE_REGEX = /^\\\\[\?\.]\\pipe\\.*$/;
function validatePipe(str: string): string | undefined {
if (str.match(PIPE_REGEX)) return undefined;
return 'Windows pipe path should start with \\\\?\\pipe\\ or \\\\.\\pipe\\';
}
function validatePort(str: string, allowRandom: boolean): string | undefined {
try {
const port = parseInt(str);
if (!allowRandom && port === 0) return undefined;
if (port >= 0 && port < 2 ** 16) return undefined;
return `Port has to be in the range ${allowRandom ? 0 : 1}-65535`;
} catch (e) {
return 'Invalid port';
}
}
const SOCKET_REGEX = /^[/\\][^\0]+$/;
function validateSocketPath(str: string): string | undefined {
if (str.match(SOCKET_REGEX)) return undefined;
return 'Unix domain socket path should be a proper absolute file path';
}
async function promptAddressOrPath(location: 'local' | 'remote', allowWildcard: boolean): Promise<[port?: number, address?: string] | undefined> {
const A = 'address:port';
const B = 'socket path / pipe';
const type = await promptQuickPick(`Use a ${location} address:port or Unix domain socket path / Windows pipe?`, [A, B] as const);
if (!type) return undefined;
if (type === A) {
const placeHolder = allowWildcard ? 'IPv4 / IPv6 / domain / *' : 'IPv4 / IPv6 / domain';
let validateInput = allowWildcard ? (input: string) => input === '*' ? undefined : validateHost(input) : validateHost;
const addr = await vscode.window.showInputBox({ prompt: 'Address to use', validateInput, placeHolder });
validateInput = (input: string) => validatePort(input, allowWildcard);
const port = await vscode.window.showInputBox({ prompt: 'Port to use', validateInput, placeHolder: `${allowWildcard ? 0 : 1}-65535` });
return port === undefined ? undefined : [parseInt(port), addr];
} else if (location === 'local' && process.platform === 'win32') {
const pipe = await vscode.window.showInputBox({ prompt: 'Pipe to use', validateInput: validatePipe, placeHolder: '\\\\?\\pipe\\...' });
return pipe ? [, pipe] : undefined;
} else {
const path = await vscode.window.showInputBox({ prompt: 'Socket path to use', validateInput: validateSocketPath, placeHolder: '/tmp/socket' });
return path ? [, path] : undefined;
}
}
async function promptFullLocalRemoteForwarding(type: 'local' | 'remote'): Promise<PortForwarding | undefined> {
const local = await promptAddressOrPath('local', type === 'local');
const remote = local && await promptAddressOrPath('remote', type === 'remote');
if (!remote) return undefined;
const [localPort, localAddress] = local;
const [remotePort, remoteAddress] = remote;
return { type, localPort, localAddress, remotePort, remoteAddress };
}
async function promptRemoteProxyForwarding(): Promise<PortForwarding | undefined> {
const remote = await promptAddressOrPath('remote', true);
if (!remote) return undefined;
const [remotePort, remoteAddress] = remote;
return { type: 'remote', remotePort, remoteAddress };
}
async function promptDynamicForwarding(): Promise<PortForwarding | undefined> {
const local = await promptAddressOrPath('local', true);
if (!local) return undefined;
const [port, address] = local;
return { type: 'dynamic', port: port!, address };
}
export async function promptPortForwarding(config: FileSystemConfig): Promise<PortForwarding | undefined> {
const picker = vscode.window.createQuickPick<FormattedItem>();
picker.title = `Port forwarding to ${config.label || config.name}`;
picker.ignoreFocusOut = true;
picker.matchOnDetail = true;
picker.matchOnDescription = true;
const ITEMS: FormattedItem[] = [
{ item: 'local', label: '→ Local forward' },
{ item: 'remote', label: '← Remote forward' },
{ item: 'remoteProxy', label: '$(globe) Remote proxy (client SOCKSv5 ← server)', description: '(omit local address/port)' },
{ item: 'dynamic', label: '$(globe) Dynamic forward (client → server SOCKSv5)' },
{ item: 'examples', label: '$(list-unordered) Show examples' },
];
const formatPF = (forward: PortForwarding, description?: string, alwaysShow?: boolean): FormattedItem => ({
item: forward, alwaysShow, description,
label: `$(${getPortForwardingIcon(forward)}) ${formatPortForwarding(forward)}`,
detail: formatPortForwardingConfig(forward),
});
let examples = false;
const updateItems = () => {
let items: FormattedItem[] = [];
let suggested: FormattedItem[] = [];
if (picker.value === 'examples' || picker.value.startsWith('examples ')) {
examples = true;
picker.value = picker.value.slice(9);
}
if (examples) {
suggested = [{ item: 'return', label: '$(quick-input-back) Return', alwaysShow: true }];
items = [
formatPF({ type: 'local', localPort: 0, remoteAddress: 'localhost', remotePort: 8080 }, 'Port 0 will pick a free port'),
formatPF({ type: 'local', localPort: 8080, remoteAddress: 'localhost', remotePort: 8080 }, 'No address or "*" binds to all interfaces'),
formatPF({ type: 'local', localAddress: '\\\\?\\pipe\\windows\\named\\pipe', remoteAddress: '/tmp/unix/socket' }, 'Supports Unix sockets'),
formatPF({ type: 'remote', localPort: 8080, remotePort: 8080 }, 'No address or "*" binds to all interfaces'),
formatPF({ type: 'remote', localAddress: 'example.com', localPort: 80, remoteAddress: '0::1', remotePort: 8080 }, 'Supports hostnames and IPv6'),
formatPF({ type: 'remote', remoteAddress: 'localhost', remotePort: 1234 }, 'Bind remotely to proxy through client'),
formatPF({ type: 'dynamic', address: 'localhost', port: 1234 }, 'Bind locally to proxy through server'),
];
} else if (picker.value) {
const type = picker.value.toLowerCase().trimLeft().match(/^[a-zA-Z]*/)![0].replace(/Forward$/, '');
let detail: string;
if (type === 'l' || type === 'local') {
detail = 'Local [localAddress]:localPort remoteAddress:remotePort';
} else if (type === 'r' || type === 'remote') {
detail = 'Remote [localAddress:localPort] [remoteAddress]:remotePort';
} else if (type === 'd' || type === 'dynamic') {
detail = 'Dynamic localAddress:localPort';
} else {
detail = 'Select or type a port forwarding type';
items = [...ITEMS];
}
try {
const forward = parsePortForwarding(picker.value, 'throw');
suggested.unshift(formatPF(forward, undefined, true));
detail = `Current syntax: ${detail}`;
} catch (e) {
const label = (e.message as string).replace(/from '.*'$/, '');
items.unshift({ item: undefined, label, detail, alwaysShow: true });
}
items.push({ item: 'return', label: '$(quick-input-back) Pick type', detail, alwaysShow: true });
} else {
items = ITEMS;
}
// If you set items first, onDidAccept will be triggered (even though it shouldn't)
picker.selectedItems = picker.activeItems = suggested;
picker.items = items.length ? [...suggested, ...items] : suggested;
};
updateItems();
picker.onDidChangeValue(updateItems);
return new Promise<PortForwarding | undefined>((resolve) => {
picker.onDidAccept(() => {
if (!picker.selectedItems.length) return;
const [{ item }] = picker.selectedItems;
if (!item) return;
if (item === 'examples') {
examples = true;
picker.value = '';
} else if (item === 'return') {
examples = false;
picker.value = '';
} else if (item === 'local' || item === 'remote') {
return resolve(promptFullLocalRemoteForwarding(item));
} else if (item === 'remoteProxy') {
return resolve(promptRemoteProxyForwarding());
} else if (item === 'dynamic') {
return resolve(promptDynamicForwarding());
} else if (examples) {
// Looking at examples, don't actually accept but copy the value
examples = false;
picker.value = formatPortForwardingConfig(item);
} else {
return resolve(item);
}
updateItems();
});
picker.onDidHide(() => resolve(undefined));
picker.show();
}).finally(() => picker.dispose());
}

@ -2,9 +2,9 @@ import type { EnvironmentVariable, FileSystemConfig } from 'common/fileSystemCon
import * as path from 'path'; import * as path from 'path';
import type { ClientChannel, PseudoTtyOptions } from 'ssh2'; import type { ClientChannel, PseudoTtyOptions } from 'ssh2';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { getFlagBoolean } from './flags';
import type { Connection } from './connection'; import type { Connection } from './connection';
import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { getFlagBoolean } from './flags';
import { LOGGING_NO_STACKTRACE, Logging } from './logging';
import { environmentToExportString, joinCommands, mergeEnvironment, toPromise } from './utils'; import { environmentToExportString, joinCommands, mergeEnvironment, toPromise } from './utils';
const [HEIGHT, WIDTH] = [480, 640]; const [HEIGHT, WIDTH] = [480, 640];

@ -1,14 +1,15 @@
import { FileSystemConfig, getGroups } from 'common/fileSystemConfig'; import { FileSystemConfig, getGroups } from 'common/fileSystemConfig';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { getConfigs, UPDATE_LISTENERS } from './config'; import { UPDATE_LISTENERS, getConfigs } from './config';
import type { Connection, ConnectionManager } from './connection'; import type { Connection, ConnectionManager } from './connection';
import { ActivePortForwarding, isActivePortForwarding } from './portForwarding';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
import { formatItem } from './ui-utils'; import { formatItem } from './ui-utils';
type PendingConnection = [string, FileSystemConfig | undefined]; type PendingConnection = [string, FileSystemConfig | undefined];
type TreeData = Connection | PendingConnection | SSHFileSystem | SSHPseudoTerminal; type TreeData = Connection | PendingConnection | SSHFileSystem | SSHPseudoTerminal | ActivePortForwarding;
export class ConnectionTreeProvider implements vscode.TreeDataProvider<TreeData> { export class ConnectionTreeProvider implements vscode.TreeDataProvider<TreeData> {
protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<TreeData | void>(); protected onDidChangeTreeDataEmitter = new vscode.EventEmitter<TreeData | void>();
public onDidChangeTreeData: vscode.Event<TreeData | void> = this.onDidChangeTreeDataEmitter.event; public onDidChangeTreeData: vscode.Event<TreeData | void> = this.onDidChangeTreeDataEmitter.event;
@ -21,7 +22,8 @@ export class ConnectionTreeProvider implements vscode.TreeDataProvider<TreeData>
this.onDidChangeTreeDataEmitter.fire(); this.onDidChangeTreeDataEmitter.fire();
} }
public getTreeItem(element: TreeData): vscode.TreeItem | Thenable<vscode.TreeItem> { public getTreeItem(element: TreeData): vscode.TreeItem | Thenable<vscode.TreeItem> {
if ('onDidChangeFile' in element || 'handleInput' in element) { // SSHFileSystem | SSHPseudoTerminal if ('onDidChangeFile' in element || 'handleInput' in element || isActivePortForwarding(element)) {
// SSHFileSystem | SSHPseudoTerminal | ActivePortForwarding
return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.None } return { ...formatItem(element), collapsibleState: vscode.TreeItemCollapsibleState.None }
} else if (Array.isArray(element)) { // PendingConnection } else if (Array.isArray(element)) { // PendingConnection
const [name, config] = element; const [name, config] = element;
@ -45,7 +47,7 @@ export class ConnectionTreeProvider implements vscode.TreeDataProvider<TreeData>
if ('onDidChangeFile' in element) return []; // SSHFileSystem if ('onDidChangeFile' in element) return []; // SSHFileSystem
if ('handleInput' in element) return []; // SSHPseudoTerminal if ('handleInput' in element) return []; // SSHPseudoTerminal
if (Array.isArray(element)) return []; // PendingConnection if (Array.isArray(element)) return []; // PendingConnection
return [...element.terminals, ...element.filesystems]; // Connection return [...element.terminals, ...element.filesystems, ...element.forwardings]; // Connection
} }
} }

@ -4,8 +4,10 @@ import * as vscode from 'vscode';
import { getConfigs } from './config'; import { getConfigs } from './config';
import type { Connection, ConnectionManager } from './connection'; import type { Connection, ConnectionManager } from './connection';
import type { Manager } from './manager'; import type { Manager } from './manager';
import { ActivePortForwarding, getPortForwardingIcon, isActivePortForwarding } from './portForwarding';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
import { toPromise } from './utils';
export interface FormattedItem extends vscode.QuickPickItem, vscode.TreeItem { export interface FormattedItem extends vscode.QuickPickItem, vscode.TreeItem {
item: any; item: any;
@ -18,6 +20,8 @@ export function formatAddress(config: FileSystemConfig): string {
return `${username ? `${username}@` : ''}${host}${port ? `:${port}` : ''}`; return `${username ? `${username}@` : ''}${host}${port ? `:${port}` : ''}`;
} }
export const capitalize = (str: string) => str.substring(0, 1).toUpperCase() + str.substring(1);
export function setWhenClauseContext(key: string, value: any) { export function setWhenClauseContext(key: string, value: any) {
return vscode.commands.executeCommand('setContext', `sshfs.${key}`, value); return vscode.commands.executeCommand('setContext', `sshfs.${key}`, value);
} }
@ -41,7 +45,7 @@ export let asAbsolutePath: vscode.ExtensionContext['asAbsolutePath'] | undefined
export const setAsAbsolutePath = (value: typeof asAbsolutePath) => asAbsolutePath = value; export const setAsAbsolutePath = (value: typeof asAbsolutePath) => asAbsolutePath = value;
/** Converts the supported types to something basically ready-to-use as vscode.QuickPickItem and vscode.TreeItem */ /** Converts the supported types to something basically ready-to-use as vscode.QuickPickItem and vscode.TreeItem */
export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | SSHPseudoTerminal, iconInLabel = false): FormattedItem { export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem | SSHPseudoTerminal | ActivePortForwarding, iconInLabel = false): FormattedItem {
if ('handleInput' in item) { // SSHPseudoTerminal if ('handleInput' in item) { // SSHPseudoTerminal
return { return {
item, contextValue: 'terminal', item, contextValue: 'terminal',
@ -72,6 +76,32 @@ export function formatItem(item: FileSystemConfig | Connection | SSHFileSystem |
label: `${iconInLabel ? '$(root-folder) ' : ''}ssh://${item.authority}/`, label: `${iconInLabel ? '$(root-folder) ' : ''}ssh://${item.authority}/`,
iconPath: asAbsolutePath?.('resources/icon.svg'), iconPath: asAbsolutePath?.('resources/icon.svg'),
} }
} else if (isActivePortForwarding(item)) { // ActivePortForwarding
let label = '';
const [forw] = item;
if (forw.type === 'local' || forw.type === 'remote') {
if (forw.localPort != undefined || forw.localAddress) {
label += forw.localPort === undefined ? forw.localAddress : `${forw.localAddress || '*'}:${forw.localPort}` || '?';
} else {
label += 'SOCKSv5';
}
label += forw.type === 'local' ? ' → ' : ' ← ';
label += forw.remotePort === undefined ? forw.remoteAddress : `${forw.remoteAddress || '*'}:${forw.remotePort}` || '?';
} else if (forw.type === 'dynamic') {
label += `${forw.address || '?'}:${forw.port} → SOCKSv5`;
} else {
label += ' <unrecognized type>';
}
const connLabel = item[1].actualConfig.label || item[1].actualConfig.name;
const detail = `${capitalize(forw.type)} port forwarding ${forw.type === 'remote' ? 'from' : 'to'} ${connLabel}`;
const icon = getPortForwardingIcon(forw);
if (iconInLabel) label = `$(${icon}) ${label}`;
return {
item, label, contextValue: 'forwarding',
detail, tooltip: detail,
collapsibleState: vscode.TreeItemCollapsibleState.Expanded,
iconPath: new vscode.ThemeIcon(icon),
};
} }
// FileSystemConfig // FileSystemConfig
const { label, name, group, putty } = item; const { label, name, group, putty } = item;
@ -98,6 +128,8 @@ export interface PickComplexOptions {
promptConfigs?: boolean | string; promptConfigs?: boolean | string;
/** If true, add all terminals. If this is a string, filter by config name first */ /** If true, add all terminals. If this is a string, filter by config name first */
promptTerminals?: boolean | string; promptTerminals?: boolean | string;
/** If true, add all active port forwardings. If this is a string, filter by address/port first */
promptActivePortForwardings?: boolean | string;
/** If set, filter the connections/configs by (config) name first */ /** If set, filter the connections/configs by (config) name first */
nameFilter?: string; nameFilter?: string;
} }
@ -147,6 +179,14 @@ export async function pickComplex(manager: Manager, options: PickComplexOptions)
items.push(...terminals.map(config => formatItem(config, true))); items.push(...terminals.map(config => formatItem(config, true)));
toSelect.push('terminal'); toSelect.push('terminal');
} }
if (options.promptActivePortForwardings) {
let cons = manager.connectionManager.getActiveConnections();
if (typeof promptConnections === 'string') cons = cons.filter(con => con.actualConfig.name === promptConnections);
if (nameFilter) cons = cons.filter(con => con.actualConfig.name === nameFilter);
const forwardings = cons.reduce((all, con) => [...all, ...con.forwardings], []);
items.push(...forwardings.map(config => formatItem(config, true)));
toSelect.push('forwarded port');
}
if (options.promptInstantConnection) { if (options.promptInstantConnection) {
items.unshift({ items.unshift({
label: '$(terminal) Create instant connection', label: '$(terminal) Create instant connection',
@ -176,3 +216,16 @@ export const pickConfig = (manager: Manager) => pickComplex(manager, { promptCon
export const pickConnection = (manager: Manager, name?: string) => export const pickConnection = (manager: Manager, name?: string) =>
pickComplex(manager, { promptConnections: name || true, immediateReturn: !!name }) as Promise<Connection | undefined>; pickComplex(manager, { promptConnections: name || true, immediateReturn: !!name }) as Promise<Connection | undefined>;
export const pickTerminal = (manager: Manager) => pickComplex(manager, { promptTerminals: true }) as Promise<SSHPseudoTerminal | undefined>; export const pickTerminal = (manager: Manager) => pickComplex(manager, { promptTerminals: true }) as Promise<SSHPseudoTerminal | undefined>;
export async function promptQuickPick<T>(title: string, items: readonly T[], toString?: (item: T) => string): Promise<T | undefined> {
const picker = vscode.window.createQuickPick<FormattedItem>();
picker.title = title;
picker.items = items.map(item => ({ item, label: toString?.(item) || `${item}` }));
picker.show();
const accepted = await Promise.race([
toPromise(cb => picker.onDidAccept(cb)).then(() => true),
toPromise(cb => picker.onDidHide(cb)).then(() => false),
]);
if (!accepted) return undefined;
return picker.selectedItems[0]?.item;
}

Loading…
Cancel
Save