Compare commits

...

13 Commits

@ -300,6 +300,16 @@
"items": "string",
"default": []
},
"sshfs.paths.ssh": {
"title": "A list of file locations to load ssh_config (OpenSSH) config files from",
"description": "Location of ssh_config files to load configs from",
"type": "array",
"items": "string",
"default": [
"~/.ssh/config",
"/etc/ssh/ssh_config"
]
},
"sshfs.configs": {
"title": "A list of SSH FS configurations",
"description": "Use the Settings UI to edit configurations (run command SSH FS: Open settings and edit configurations)",

@ -413,7 +413,7 @@ export function getFlags(): Record<string, FlagCombo> { return cachedFlags; }
* @param target The name of the flag to look for
*/
export function getFlag(target: string): FlagCombo | undefined {
return calculateFlags()[target.toLowerCase()];
return getFlags()[target.toLowerCase()];
}
/**

@ -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);
@ -19,9 +19,13 @@ const DEFAULT_CONFIG: ConnectConfig = {
keepaliveInterval: 30e3,
};
function replaceVariables(string?: string) {
function replaceVariables(string: string): string;
function replaceVariables(string?: string): string | undefined;
function replaceVariables(string?: string): string | undefined {
if (typeof string !== 'string') return string;
return string.replace(/\$\w+/g, key => process.env[key.substr(1)] || '');
return string
.replace(/\${(\w+)}/g, (_, key) => process.env[key] || '')
.replace(/\$(\w+)/g, (_, key) => process.env[key] || '');
}
const PROMPT_FIELDS: Partial<Record<keyof FileSystemConfig, [
@ -138,6 +142,22 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise<F
}
// If the username is (still) `$USER` at this point, use the local user's username
if (config.username === '$USER') config.username = userInfo().username;
if (config.sshConfig) {
await promptFields(config, 'host');
let paths = vscode.workspace.getConfiguration('sshfs').get<string[]>('paths.ssh');
if (!paths) {
logging.debug('No value defined for sshfs.paths.ssh setting?');
paths = [];
}
if (!paths.length) {
logging.error('Option \'sshConfig\' is set but the \'sshfs.paths.ssh\' setting has no paths');
}
paths = paths.map(p => p.startsWith('~') ? `${userInfo().homedir}${p.substr(1)}` : p);
paths = paths.map<string>(replaceVariables);
const { buildHolder, fillFileSystemConfig } = await import('./ssh-config');
const holder = await buildHolder(paths);
await fillFileSystemConfig(config, holder);
}
if (config.privateKeyPath) {
try {
const key = await toPromise<Buffer>(cb => readFile(config.privateKeyPath!, cb));
@ -174,35 +194,40 @@ export async function calculateActualConfig(config: FileSystemConfig): Promise<F
return config;
}
export async function createSocket(config: FileSystemConfig): Promise<NodeJS.ReadableStream | null> {
export async function createSocket(config: FileSystemConfig): Promise<NodeJS.ReadWriteStream | null> {
config = (await calculateActualConfig(config))!;
if (!config) return null;
const logging = Logging.scope(`createSocket(${config.name})`);
logging.info(`Creating socket`);
if (config.hop) {
logging.debug(`\tHopping through ${config.hop}`);
const hop = getConfig(config.hop);
if (!hop) throw new Error(`A SSH FS configuration with the name '${config.hop}' doesn't exist`);
const ssh = await createSSH(hop);
if (!ssh) {
logging.debug(`\tFailed in connecting to hop ${config.hop}`);
if (config.hops && config.hops.length) {
const hops = Array.isArray(config.hops) ? config.hops : [config.hops];
logging.debug(`\tHops: ${hops.join(', ')}`);
const calculatedHops = await Promise.all(hops.map(async str => {
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.ReadWriteStream | 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<ClientChannel>(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<NodeJS.ReadableStream>((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:
@ -213,23 +238,29 @@ export async function createSocket(config: FileSystemConfig): Promise<NodeJS.Rea
return await (await import('./proxy')).socks(config);
case 'http':
return await (await import('./proxy')).http(config);
case 'command':
return await (await import('./proxy')).command(config);
default:
throw new Error(`Unknown proxy method`);
}
return new Promise<NodeJS.ReadableStream>((resolve, reject) => {
return new Promise<NodeJS.ReadWriteStream>((resolve, reject) => {
logging.debug(`Connecting to ${config.host}:${config.port || 22}`);
const socket = new Socket();
socket.connect(config.port || 22, config.host!, () => resolve(socket as NodeJS.ReadableStream));
socket.connect(config.port || 22, config.host!, () => resolve(socket as NodeJS.ReadWriteStream));
socket.once('error', reject);
});
}
export async function createSSH(config: FileSystemConfig, sock?: NodeJS.ReadableStream): Promise<Client | null> {
export interface CreateSSHOptions {
sock?: NodeJS.ReadWriteStream;
logger?: Logger;
}
export async function createSSH(config: FileSystemConfig, options: CreateSSHOptions = {}): Promise<Client | null> {
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<Client>((resolve, reject) => {
const client = new Client();
client.once('ready', () => resolve(client));
@ -253,7 +284,7 @@ export async function createSSH(config: FileSystemConfig, sock?: NodeJS.Readable
});
try {
const finalConfig: ConnectConfig = { ...config, sock, ...DEFAULT_CONFIG };
if (config.debug || getFlag('DEBUG_SSH2') !== undefined) {
if (config.debug || getFlagBoolean('DEBUG_SSH2', false)[0]) {
const scope = Logging.scope(`ssh2(${config.name})`);
finalConfig.debug = (msg: string) => scope.debug(msg);
}

@ -5,6 +5,10 @@ export interface ProxyConfig {
host: string;
port: number;
}
export interface ProxyCommand {
type: 'command';
command: string;
};
export type ConfigLocation = number | string;
@ -86,12 +90,14 @@ export interface FileSystemConfig extends ConnectConfig {
root?: string;
/** A name of a PuTTY session, or `true` to find the PuTTY session from the host address */
putty?: string | boolean;
/** Whether to parse ssh_config files (listed by the VS Code setting `sshfs.paths.ssh`) for extra parameters, e.g. Port */
sshConfig?: boolean;
/** Optional object defining a proxy to use */
proxy?: ProxyConfig;
proxy?: ProxyConfig | ProxyCommand;
/** 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) */
@ -133,9 +139,14 @@ export function invalidConfigName(name: string) {
*/
const CONNECTION_REGEX = /^((?<user>\w+)?(;[\w-]+=[\w\d-]+(,[\w\d-]+=[\w\d-]+)*)?@)?(?<host>[^@\\/:,=]+)(:(?<port>\d+))?(?<path>\/.*)?$/;
const PREFIX_REGEX = /^(ssh|scp|sftp):\/\/(?<input>.*)$/;
/** Based on CONNECTION_REGEX, but also strips leading `ssh://`, `scp://` or `sftp://` if present. */
export function parseConnectionString(input: string): [config: FileSystemConfig, path?: string] | string {
input = input.trim();
const match = input.match(CONNECTION_REGEX);
let match = input.match(PREFIX_REGEX);
if (match) input = match.groups!.input;
match = input.match(CONNECTION_REGEX);
if (!match) return 'Invalid format, expected something like "user@example.com:22/some/path"';
const { user, host, path } = match.groups!;
const portStr = match.groups!.port;

@ -1,7 +1,9 @@
import { spawn } from 'child_process';
import * as dns from 'dns';
import { request } from 'http';
import { SocksClient } from 'socks';
import { Readable, Writable } from 'node:stream';
import { Duplex } from 'stream';
import type { FileSystemConfig } from './fileSystemConfig';
import { Logging } from './logging';
import { toPromise } from './toPromise';
@ -15,22 +17,34 @@ async function resolveHostname(hostname: string): Promise<string> {
function validateConfig(config: FileSystemConfig) {
if (!config.proxy) throw new Error(`Missing field 'config.proxy'`);
if (!config.proxy.host) throw new Error(`Missing field 'config.proxy.host'`);
if (!config.proxy.port) throw new Error(`Missing field 'config.proxy.port'`);
if (!config.proxy.type) throw new Error(`Missing field 'config.proxy.type'`);
switch (config.proxy.type) {
case 'http':
case 'socks4':
case 'socks5':
if (!config.proxy.host) throw new Error(`Missing field 'config.proxy.host'`);
if (!config.proxy.port) throw new Error(`Missing field 'config.proxy.port'`);
break;
case 'command':
if (!config.proxy.command) throw new Error(`Missing field 'config.proxy.command'`);
break;
default:
throw new Error(`Unrecognized proxy type '${config.proxy!.type}'`);
}
}
export async function socks(config: FileSystemConfig): Promise<NodeJS.ReadableStream> {
export async function socks(config: FileSystemConfig): Promise<NodeJS.ReadWriteStream> {
Logging.info(`Creating socks proxy connection for ${config.name}`);
validateConfig(config);
if (config.proxy!.type !== 'socks4' && config.proxy!.type !== 'socks5') {
const proxy = config.proxy!;
if (proxy!.type !== 'socks4' && proxy!.type !== 'socks5') {
throw new Error(`Expected 'config.proxy.type' to be 'socks4' or 'socks5'`);
}
try {
const ipaddress = (await resolveHostname(config.proxy!.host));
if (!ipaddress) throw new Error(`Couldn't resolve '${config.proxy!.host}'`);
Logging.debug(`\tConnecting to ${config.host}:${config.port} over ${config.proxy!.type} proxy at ${ipaddress}:${config.proxy!.port}`);
const con = await SocksClient.createConnection({
const ipaddress = (await resolveHostname(proxy!.host));
if (!ipaddress) throw new Error(`Couldn't resolve '${proxy!.host}'`);
Logging.debug(`\tConnecting to ${config.host}:${config.port} over ${proxy!.type} proxy at ${ipaddress}:${proxy!.port}`);
const con = await (await import('socks')).SocksClient.createConnection({
command: 'connect',
destination: {
host: config.host!,
@ -38,37 +52,70 @@ export async function socks(config: FileSystemConfig): Promise<NodeJS.ReadableSt
},
proxy: {
ipaddress,
port: config.proxy!.port,
type: config.proxy!.type === 'socks4' ? 4 : 5,
port: proxy!.port,
type: proxy!.type === 'socks4' ? 4 : 5,
},
});
return con.socket as NodeJS.ReadableStream;
return con.socket as NodeJS.ReadWriteStream;
} catch (e) {
throw new Error(`Error while connecting to the the proxy: ${e.message}`);
}
}
export function http(config: FileSystemConfig): Promise<NodeJS.ReadableStream> {
export function http(config: FileSystemConfig): Promise<NodeJS.ReadWriteStream> {
Logging.info(`Creating http proxy connection for ${config.name}`);
validateConfig(config);
return new Promise<NodeJS.ReadableStream>((resolve, reject) => {
if (config.proxy!.type !== 'http') {
reject(new Error(`Expected config.proxy.type' to be 'http'`));
return new Promise<NodeJS.ReadWriteStream>((resolve, reject) => {
const proxy = config.proxy!;
if (proxy!.type !== 'http') {
return reject(new Error(`Expected config.proxy.type' to be 'http'`));
}
try {
Logging.debug(`\tConnecting to ${config.host}:${config.port} over http proxy at ${config.proxy!.host}:${config.proxy!.port}`);
Logging.debug(`\tConnecting to ${config.host}:${config.port} over http proxy at ${proxy!.host}:${proxy!.port}`);
const req = request({
port: config.proxy!.port,
hostname: config.proxy!.host,
port: proxy!.port,
hostname: proxy!.host,
method: 'CONNECT',
path: `${config.host}:${config.port}`,
});
req.end();
req.on('connect', (res, socket) => {
resolve(socket as NodeJS.ReadableStream);
resolve(socket as NodeJS.ReadWriteStream);
});
} catch (e) {
reject(new Error(`Error while connecting to the the proxy: ${e.message}`));
}
});
}
class ReadWriteWrapper extends Duplex {
constructor(protected _readable: Readable, protected _writable: Writable) {
super();
_readable.once('finish', () => this.end());
_writable.once('end', () => this.push(null));
_readable.once('error', e => this.emit('error', e));
_writable.once('error', e => this.emit('error', e));
this.once('close', () => _writable.end());
}
_destroy() { this._readable.destroy(); this._writable.destroy(); }
_write(chunk: any, encoding: string, callback: any) {
return this._writable._write(chunk, encoding, callback);
}
_read(size: number) {
const chunk = this._readable.read();
if (chunk) this.push(chunk);
else this._readable.once('readable', () => this._read(size));
}
}
export async function command(config: FileSystemConfig): Promise<NodeJS.ReadWriteStream> {
Logging.info(`Creating ProxyCommand connection for ${config.name}`);
validateConfig(config);
const proxy = config.proxy!;
if (proxy.type !== 'command') throw new Error(`Expected config.proxy.type' to be 'command'`);
Logging.debug('\tcommand: ' + proxy.command);
const proc = spawn(proxy.command, { shell: true, stdio: ['pipe', 'pipe', 'inherit'] });
if (proc.killed) throw new Error(`ProxyCommand process died with exit code ${proc.exitCode}`);
if (!proc.pid) throw new Error(`ProxyCommand process did not spawn, possible exit code: ${proc.exitCode}`);
return new ReadWriteWrapper(proc.stdout, proc.stdin);
}

@ -0,0 +1,315 @@
import * as fs from 'fs';
import { userInfo } from 'os';
import type { FileSystemConfig } from './fileSystemConfig';
import { censorConfig, Logging, LOGGING_NO_STACKTRACE } from './logging';
import { toPromise } from './toPromise';
export enum SSHConfigType {
GLOBAL = 'GLOBAL',
HOST = 'HOST',
MATCH = 'MATCH',
COMPUTED = 'COMPUTED',
}
const PAIR_REGEX = /^(\w+)\s*(?:=|\s)\s*(.+)$/;
const QUOTE_REGEX = /^"(.*)"$/;
const unquote = (str: string): string => str.replace(QUOTE_REGEX, (_, v) => v);
const replacePatternChar = (ch: string): string => ch == '?' ? '.' : '.*';
function checkHostname(hostname: string, target: string): boolean | undefined {
if (target === '*') return true;
const negate = target.startsWith('!');
if (negate) target = target.substring(1);
const regex = new RegExp(`^${target.trim().replace(/[*?]/g, replacePatternChar)}$`);
return regex.test(hostname) ? !negate : undefined;
}
function checkPatternList(input: string, pattern: string): boolean {
for (const pat of pattern.split(',')) {
const result = checkHostname(input, pat);
if (result !== undefined) return result;
}
return false;
}
export interface MatchContext {
hostname: string;
originalHostname: string;
user: string;
localUser?: string;
isFinalOrCanonical?: boolean;
}
export type MatchResult = [result: boolean, errors: LineError[]];
export class SSHConfig implements Iterable<[string, string[]]> {
protected entries = new Map<string, string[]>();
constructor(
public readonly type: SSHConfigType,
public readonly source: string,
public readonly line: number) { }
public get(key: string): string {
return this.entries.get(key.toLowerCase())?.[0] || '';
}
public getAll(key: string): string[] {
return this.entries.get(key.toLowerCase())?.slice() || [];
}
public set(key: string, value: string | string[]): void {
if (!Array.isArray(value)) value = [value];
this.entries.set(key.toLowerCase(), value);
}
public add(key: string, value: string | string[]): void {
if (!Array.isArray(value)) value = [value];
this.entries.set(key.toLowerCase(), [...this.getAll(key), ...value]);
}
public [Symbol.iterator](): IterableIterator<[string, string[]]> {
return this.entries[Symbol.iterator]();
}
public merge(other: SSHConfig): void {
for (const [key, values] of other) this.add(key, values);
}
protected checkHost(hostname: string): boolean {
let allowed = false;
for (const pattern of this.get('host').split(/\s+/)) {
const result = checkHostname(hostname, pattern);
if (result === true) allowed = true;
if (result === false) return false;
}
return allowed;
}
protected checkMatch(context: MatchContext): MatchResult {
const wrapResult = (result: boolean): MatchResult => [result, []];
const wrapResultWithError = (result: boolean, error: string): MatchResult => [result, [[this.source, this.line, error, Severity.ERROR]]];
const split = this.get('match').split(/\s+/);
let prev = '';
for (let i = 0, curr: string; curr = split[i]; prev = curr, i++) {
const lower = curr.toLowerCase();
if (lower === 'all') {
if (split.length === 1) return wrapResult(true);
if (split.length === 2 && i === 1) {
if (prev.toLowerCase() === 'final') return wrapResult(!!context.isFinalOrCanonical);
if (prev.toLowerCase() === 'canonical') return wrapResult(!!context.isFinalOrCanonical);
}
return wrapResultWithError(false, '\'all\' cannot be combined with other Match attributes');
} else if (lower === 'final' || lower === 'canonical') {
if (!context.isFinalOrCanonical) return wrapResult(false);
continue;
}
const next = split[++i];
if (!next) return wrapResultWithError(false, `Match keyword '${lower}' requires argument`);
if (lower === 'exec') {
return wrapResultWithError(false, '\'exec\' is not supported for now');
} else if (lower === 'host') {
if (!checkPatternList(context.hostname, next)) return wrapResult(false);
} else if (lower === 'originalhost') {
if (!checkPatternList(context.originalHostname, next)) return wrapResult(false);
} else if (lower === 'user') {
if (!checkPatternList(context.user, next)) return wrapResult(false);
} else if (lower === 'localuser' && context.localUser) {
if (!checkPatternList(context.localUser, next)) return wrapResult(false);
} else {
return wrapResultWithError(false, `Unknown argument '${curr}' for Match keyword`);
}
}
return wrapResult(true);
}
public matches(context: MatchContext): MatchResult {
if (this.type === SSHConfigType.GLOBAL) return [true, []];
if (this.type === SSHConfigType.HOST) return [this.checkHost(context.hostname), []];
if (this.type === SSHConfigType.MATCH) return this.checkMatch(context);
if (this.type === SSHConfigType.COMPUTED) return [false, [[this.source, 0, 'Cannot match a computed config', Severity.ERROR]]];
throw new Error(`Unrecognized config type '${this.type}'`);
}
public toString(): string {
if (this.type === SSHConfigType.GLOBAL) return `SSHConfig(GLOBAL,${this.source}:${this.line})`;
if (this.type === SSHConfigType.HOST) return `SSHConfig(HOST,${this.source}:${this.line},"${this.get('Host')}")`;
if (this.type === SSHConfigType.MATCH) return `SSHConfig(MATCH,${this.source}:${this.line},"${this.get('Match')}")`;
if (this.type === SSHConfigType.COMPUTED) return `SSHConfig(COMPUTED,${this.source}:${this.line})`;
throw new Error(`Unrecognized config type '${this.type}'`);
}
}
export enum Severity {
INFO = 1,
WARN = 2,
ERROR = 3,
}
const SEVERITY_TO_STRING = [, 'info', 'warning', 'error'] as const;
export type LineError = [source: string, line: number, message: string, severity: Severity];
export function formatLineError(error: LineError): string {
return `[${SEVERITY_TO_STRING[error[3]].toUpperCase()}] (${error[0]}:${error[1]}) ${error[2]}`;
}
function mergeConfigIntoContext(config: SSHConfig, context: MatchContext): void {
context.hostname = config.get('hostname') || context.hostname;
context.user = config.get('user') || context.user;
}
export class SSHConfigHolder {
public readonly errors: readonly LineError[] = [];
public readonly configs: SSHConfig[] = [];
constructor(public readonly source: string) { }
public reportError(line: number, message: string, severity = Severity.ERROR): void {
(this.errors as LineError[]).push([this.source, line, message, severity]);
}
public add(config: SSHConfig): void {
(this.configs as SSHConfig[]).push(config);
}
public getHighestSeverity(): Severity | undefined {
return this.errors.reduceRight((a, b) => a[3] > b[3] ? a : b)?.[3];
}
public buildConfig(context: MatchContext): SSHConfig {
context = { ...context };
const result = new SSHConfig(SSHConfigType.COMPUTED, this.source, 0);
for (const config of this.configs) {
if (!config.matches(context)) {
Logging.debug(`Config ${config} does not match context ${JSON.stringify(context)}, ignoring`);
continue;
}
Logging.debug(`Config ${config} matches context ${JSON.stringify(context)}, merging`);
result.merge(config);
mergeConfigIntoContext(result, context);
Logging.debug(` New context: ${JSON.stringify(context)}`);
}
return result;
}
public merge(other: SSHConfigHolder): void {
this.configs.push(...other.configs);
(this.errors as LineError[]).push(...other.errors);
}
}
const ERR_NO_MATCH = 'Incorrect comment or key-value pair syntax';
const ERR_UNSUPPORTED_FINAL = 'Unsupported Match keyword \'final\'';
const ERR_UNSUPPORTED_CANONICAL = 'Unsupported Match keyword \'canonical\'';
const ERR_MULTIPLE_IDENTITY_FILE = 'Multiple IdentityFiles given, the extension only tries the first one';
export function parseContents(content: string, source: string): SSHConfigHolder {
const holder = new SSHConfigHolder(source);
let current = new SSHConfig(SSHConfigType.GLOBAL, source, 0);
holder.add(current);
content.split('\n').forEach((line, lineNumber) => {
line = line.trim();
if (!line || line.startsWith('#')) return;
const mat = line.match(PAIR_REGEX);
if (!mat) return holder.reportError(lineNumber, ERR_NO_MATCH);
let [, key, value] = mat;
key = key.toLowerCase();
value = unquote(value);
// TODO: "Include ..."
switch (key) {
case 'host':
holder.add(current = new SSHConfig(SSHConfigType.HOST, source, lineNumber));
break;
case 'match':
holder.add(current = new SSHConfig(SSHConfigType.MATCH, source, lineNumber));
const split = value.split(/\s+/).map(v => unquote(v).toLowerCase());
if (split.includes('final')) holder.reportError(lineNumber, ERR_UNSUPPORTED_FINAL, Severity.WARN);
if (split.includes('canonical')) holder.reportError(lineNumber, ERR_UNSUPPORTED_CANONICAL, Severity.WARN);
break;
case 'identityfile':
if (current.get('IdentityFile')) {
holder.reportError(lineNumber, ERR_MULTIPLE_IDENTITY_FILE, Severity.WARN);
}
break;
}
current.add(key, value.trim());
});
return holder;
}
export async function buildHolder(paths: string[]): Promise<SSHConfigHolder> {
Logging.info(`Building ssh_config holder for ${paths.length} paths`);
const holder = new SSHConfigHolder('<root>');
for (let i = 0, path: string; path = paths[i]; i++) {
try {
const content = await toPromise<Buffer>(cb => fs.readFile(path, cb));
const subholder = parseContents(content.toString(), path);
holder.merge(subholder);
} catch (e) {
if (e instanceof Error && (e as NodeJS.ErrnoException).code === 'ENOENT') {
const msg = `No ssh_config file found at '${path}', skipping`;
Logging.info(msg);
holder.reportError(i, msg, Severity.INFO);
continue;
}
const msg = `Error during building SSHConfigHolder, current path: '${path}'`;
Logging.error(msg, LOGGING_NO_STACKTRACE);
Logging.error(e);
holder.reportError(i, msg);
}
}
const sev = holder.getHighestSeverity();
if (!sev) return holder;
const key = SEVERITY_TO_STRING[sev];
Logging[key](`Building ssh_config holder produced ${key} messages:`, LOGGING_NO_STACKTRACE);
for (const error of holder.errors) Logging[key]('- ' + formatLineError(error));
Logging[key]('End of ssh_config holder messages', LOGGING_NO_STACKTRACE);
return holder;
}
const toBoolean = (str: string): boolean | undefined => str === 'yes' ? true : str === 'no' ? false : undefined;
const toList = (str: string): string[] | undefined => str ? str.split(',') : undefined;
export async function fillFileSystemConfig(config: FileSystemConfig, holder: SSHConfigHolder): Promise<void> {
const localUser = userInfo().username;
const context: MatchContext = {
hostname: config.host!,
originalHostname: config.host!,
user: config.username || localUser,
localUser,
};
const result = holder.buildConfig(context);
const overrides: Partial<FileSystemConfig> = {
host: result.get('Hostname') || config.host,
compress: toBoolean(result.get('Compression')),
// TODO: port forwarding: DynamicForward, LocalForward, RemoteForward, ExitOnForwardFailure, GatewayPorts
// StreamLocalBindMask, StreamLocalBindUnlink
agentForward: toBoolean(result.get('ForwardAgent')),
// TODO: ForwardX11, ForwardX11Timeout, ForwardX11Trusted, XAuthLocation (maybe?)
// TODO: host key checking: CheckHostIP, GlobalKnownHostsFile, HashKnownHosts,
// KnownHostsCommand, RevokedHostKeys, StrictHostKeyChecking, UserKnownHostsFile
agent: result.get('IdentityAgent') || config.agent,
privateKeyPath: result.get('IdentityFile'),
tryKeyboard: toBoolean(result.get('KbdInteractiveAuthentication')),
// TODO: LocalCommand, PermitLocalCommand, RemoteCommand
password: (toBoolean(result.get('PasswordAuthentication')) != false) as any,
port: parseInt(result.get('Port')),
// TODO: PreferredAuthentications (ssh2's non-documented authHandler config property?)
hops: toList(result.get('ProxyJump')),
// TODO: SendEnv, SetEnv (maybe?)
username: result.get('User'),
};
// ConnectTimeout
const connectTimeout = parseInt(result.get('ConnectTimeout'));
if (!isNaN(connectTimeout)) overrides.readyTimeout = connectTimeout;
// LogLevel
const logLevel = result.get('LogLevel');
if (logLevel) {
overrides.debug = logLevel.includes('DEBUG') ? msg => {
Logging.debug(`[ssh2:debug ${config.name}] ${msg}`);
} : () => { };
}
// ProxyCommand
const proxyCommand = result.get('ProxyCommand');
if (proxyCommand) {
overrides.proxy = {
type: 'command',
command: proxyCommand,
};
}
// Cleaning up
for (const key in overrides) {
const val = overrides[key];
if (val === '') delete overrides[key];
if (val === []) delete overrides[key];
if (typeof val === 'number' && isNaN(val)) delete overrides[key];
if (val === undefined) delete overrides[key];
}
Logging.debug(`Config overrides for ${config.name} generated from ssh_config files: ${JSON.stringify(censorConfig(overrides as any), null, 4)}`);
Object.assign(config, overrides);
}

@ -22,3 +22,11 @@ export async function catchingPromise<T>(executor: (resolve: (value?: T | Promis
}
});
}
export async function reduceAsync<R, T>(array: T[], reducer: (prev: R, current: T, index: number, array: T[]) => R | PromiseLike<R>, initial: R | PromiseLike<R>): Promise<R> {
return array.reduce<Promise<R>>((prev, curr, index) => prev.then(p => reducer(p, curr, index, array)), Promise.resolve(initial));
}
export async function reduceRightAsync<R, T>(array: T[], reducer: (prev: R, current: T, index: number, array: T[]) => R | PromiseLike<R>, initial: R | PromiseLike<R>): Promise<R> {
return array.reduceRight<Promise<R>>((prev, curr, index) => prev.then(p => reducer(p, curr, index, array)), Promise.resolve(initial));
}

Loading…
Cancel
Save