parent
8c0b5c4d1a
commit
e5049e0163
@ -0,0 +1,289 @@
|
||||
|
||||
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, pattern);
|
||||
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);
|
||||
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[] = [new SSHConfig(SSHConfigType.GLOBAL, this.source, 0)];
|
||||
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)) continue;
|
||||
result.merge(config);
|
||||
mergeConfigIntoContext(result, 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 = holder.configs[0];
|
||||
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(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;
|
||||
|
||||
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')) as any,
|
||||
port: parseInt(result.get('Port')),
|
||||
// TODO: PreferredAuthentications (ssh2's non-documented authHandler config property?)
|
||||
// TODO: ProxyCommand, ProxyJump, ProxyUseFdpass (can't support the latter I'm afraid)
|
||||
// TODO: SendEnv, SetEnv (maybe?)
|
||||
username: result.get('User'),
|
||||
};
|
||||
const connectTimeout = parseInt(result.get('ConnectTimeout'));
|
||||
if (!isNaN(connectTimeout)) overrides.readyTimeout = connectTimeout;
|
||||
const logLevel = result.get('LogLevel');
|
||||
if (logLevel) {
|
||||
overrides.debug = logLevel.includes('DEBUG') ? msg => {
|
||||
Logging.debug(`[ssh2:debug ${config.name}] ${msg}`);
|
||||
} : () => { };
|
||||
}
|
||||
for (const key in overrides) {
|
||||
const val = overrides[key];
|
||||
if (val === '') delete overrides[key];
|
||||
if (val === []) delete overrides[key];
|
||||
if (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);
|
||||
}
|
Loading…
Reference in new issue