diff --git a/src/connect.ts b/src/connect.ts index aab6c78..a7c58d0 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -6,9 +6,9 @@ import { SFTPStream } from 'ssh2-streams'; import * as vscode from 'vscode'; import { getConfig, getFlag, getFlagBoolean } from './config'; import type { FileSystemConfig } from './fileSystemConfig'; -import { censorConfig, Logging } from './logging'; +import { censorConfig, Logger, Logging, LOGGING_NO_STACKTRACE } from './logging'; import type { PuttySession } from './putty'; -import { toPromise } from './toPromise'; +import { reduceAsync, toPromise } from './toPromise'; // tslint:disable-next-line:variable-name const SFTPWrapper = require('ssh2/lib/SFTPWrapper') as (new (stream: SFTPStream) => SFTPWrapperReal); @@ -199,30 +199,35 @@ export async function createSocket(config: FileSystemConfig): Promise { + let hop: FileSystemConfig | undefined | null = getConfig(str); + if (!hop) throw new Error(`Could not get config for hop '${str}'`); + hop = await calculateActualConfig(hop); + if (!hop) return null; + if (!hop.host) throw new Error(`Hop '${str}' converted to '${hop.name}' but lacks the 'host' field`); + return hop; + })); + if (calculatedHops.includes(null)) return null; + const hopConfigs = calculatedHops as FileSystemConfig[]; + logging.debug(`\tHop configs: client -> ${hopConfigs.map((c, i) => `[${i + 1}] ${c.name}`).join(' -> ')} -> server`); + const stream = await reduceAsync(hopConfigs, async (sock: NodeJS.ReadableStream | null | undefined, hop, index) => { + if (sock === null) return null; + const logger = logging.scope(`Hop#${index + 1}`); + logger.debug(`Connecting to hop '${hop.name}'`); + const ssh = await createSSH(hop, { logger, sock }); + if (ssh == null) return null; + const target = hopConfigs[index + 1] || config; + const channel = await toPromise(cb => ssh.forwardOut('localhost', 0, target.host!, target.port || 22, cb)).catch((e: Error) => e); + if ('write' in channel) return channel; + logger.error('Could not create forwarded socket over SSH connection', LOGGING_NO_STACKTRACE); + logger.error(channel); return null; - } - return new Promise((resolve, reject) => { - ssh.forwardOut('localhost', 0, config.host!, config.port || 22, (err, channel) => { - if (err) { - logging.debug(`\tError connecting to hop ${config.hop} for ${config.name}: ${err}`); - err.message = `Couldn't connect through the hop:\n${err.message}`; - return reject(err); - } else if (!channel) { - err = new Error('Did not receive a channel'); - logging.debug(`\tGot no channel when connecting to hop ${config.hop} for ${config.name}`); - return reject(err); - } - channel.once('close', () => ssh.destroy()); - resolve(channel); - }); - }); + }, undefined); + if (stream === undefined) throw new Error('Unexpected undefined'); + return stream; } switch (config.proxy && config.proxy.type) { case null: @@ -244,12 +249,16 @@ export async function createSocket(config: FileSystemConfig): Promise { +export interface CreateSSHOptions { + sock?: NodeJS.ReadableStream; + logger?: Logger; +} +export async function createSSH(config: FileSystemConfig, options: CreateSSHOptions = {}): Promise { config = (await calculateActualConfig(config))!; if (!config) return null; - sock = sock || (await createSocket(config))!; + const sock = options.sock || (await createSocket(config))!; if (!sock) return null; - const logging = Logging.scope(`createSSH(${config.name})`); + const logging = options.logger || Logging.scope(`createSSH(${config.name})`); return new Promise((resolve, reject) => { const client = new Client(); client.once('ready', () => resolve(client)); diff --git a/src/fileSystemConfig.ts b/src/fileSystemConfig.ts index b7bba31..c837b16 100644 --- a/src/fileSystemConfig.ts +++ b/src/fileSystemConfig.ts @@ -92,8 +92,8 @@ export interface FileSystemConfig extends ConnectConfig { proxy?: ProxyConfig; /** Optional path to a private keyfile to authenticate with */ privateKeyPath?: string; - /** A name of another config to use as a hop */ - hop?: string; + /** Names of other config (or connection strings) to use as hops */ + hops?: string | string[]; /** The command to run on the remote SSH session to start a SFTP session (defaults to sftp subsystem) */ sftpCommand?: string; /** Whether to use a sudo shell (and for which user) to run the sftpCommand in (sftpCommand defaults to /usr/lib/openssh/sftp-server if missing) */ diff --git a/src/toPromise.ts b/src/toPromise.ts index 086d78b..6b890fc 100644 --- a/src/toPromise.ts +++ b/src/toPromise.ts @@ -22,3 +22,11 @@ export async function catchingPromise(executor: (resolve: (value?: T | Promis } }); } + +export async function reduceAsync(array: T[], reducer: (prev: R, current: T, index: number, array: T[]) => R | PromiseLike, initial: R | PromiseLike): Promise { + return array.reduce>((prev, curr, index) => prev.then(p => reducer(p, curr, index, array)), Promise.resolve(initial)); +} + +export async function reduceRightAsync(array: T[], reducer: (prev: R, current: T, index: number, array: T[]) => R | PromiseLike, initial: R | PromiseLike): Promise { + return array.reduceRight>((prev, curr, index) => prev.then(p => reducer(p, curr, index, array)), Promise.resolve(initial)); +}