Add initial support for Windows OpenSSH servers (fixes #338)

pull/373/head
Kelvin Schoofs 2 years ago
parent dc207093f6
commit 9410ac23e3

@ -11,6 +11,12 @@
- For this major update a `ssh2.ts` replacing `@types/ssh2` is added to the `common` module - For this major update a `ssh2.ts` replacing `@types/ssh2` is added to the `common` module
- This does pull in a lot of new fixes/features added since `ssh2@1.0.0` though - This does pull in a lot of new fixes/features added since `ssh2@1.0.0` though
- Some feature requests are now easier/possible to implement with these new features - Some feature requests are now easier/possible to implement with these new features
- Add initial support for Windows OpenSSH servers (fixes #338)
- This adds initial support for Command Prompt, and theoretically PowerShell (untested)
- The `REMOTE_COMMANDS` is not yet supported, as it uses the pty's `tty` for cross-terminal communication
- Future `REMOTE_COMMANDS` support for PowerShell (since it can interact with named pipes) is planned
- Future `REMOTE_COMMANDS` support for Command Prompt is currently not yet planned, but might be possible
- Mind that some (future) features won't work (maybe just for now, maybe forever) on Windows
### New features ### New features
- Added `FS_NOTIFY_ERRORS` flag to display notifications for FS errors (#282) - Added `FS_NOTIFY_ERRORS` flag to display notifications for FS errors (#282)

@ -6,7 +6,7 @@ import * as vscode from 'vscode';
import { configMatches, getFlagBoolean, loadConfigs } from './config'; import { configMatches, getFlagBoolean, loadConfigs } from './config';
import { Logging, LOGGING_NO_STACKTRACE } from './logging'; import { Logging, LOGGING_NO_STACKTRACE } from './logging';
import type { SSHPseudoTerminal } from './pseudoTerminal'; import type { SSHPseudoTerminal } from './pseudoTerminal';
import { calculateShellConfig, ShellConfig, tryEcho } from './shellConfig'; import { calculateShellConfig, ShellConfig, tryCommand, tryEcho } from './shellConfig';
import type { SSHFileSystem } from './sshFileSystem'; import type { SSHFileSystem } from './sshFileSystem';
import { mergeEnvironment, toPromise } from './utils'; import { mergeEnvironment, toPromise } from './utils';
@ -51,6 +51,7 @@ export class ConnectionManager {
} }
protected async _createCommandTerminal(client: Client, shellConfig: ShellConfig, authority: string, debugLogging: boolean): Promise<string> { protected async _createCommandTerminal(client: Client, shellConfig: ShellConfig, authority: string, debugLogging: boolean): Promise<string> {
const logging = Logging.scope(`CmdTerm(${authority})`); const logging = Logging.scope(`CmdTerm(${authority})`);
if (!shellConfig.embedSubstitutions) throw new Error(`Shell '${shellConfig.shell}' does not support embedding substitutions`);
const shell = await toPromise<ClientChannel>(cb => client.shell({}, cb)); const shell = await toPromise<ClientChannel>(cb => client.shell({}, cb));
logging.debug(`TTY COMMAND: ${`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`}`); logging.debug(`TTY COMMAND: ${`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`}`);
shell.write(`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`); shell.write(`echo ${shellConfig.embedSubstitutions`::sshfs:${'echo TTY'}:${'tty'}`}\n`);
@ -128,7 +129,15 @@ export class ConnectionManager {
// Calculate shell config // Calculate shell config
const shellConfig = await calculateShellConfig(client, logging); const shellConfig = await calculateShellConfig(client, logging);
// Query home directory // Query home directory
let home = await tryEcho(client, shellConfig, '~').catch((e: Error) => e); let home: string | Error | null;
if (shellConfig.isWindows) {
home = await tryCommand(client, "echo %USERPROFILE%").catch((e: Error) => e);
if (home === null) home = new Error(`No output for "echo %USERPROFILE%"`);
if (typeof home === 'string') home = home.trim();
if (home === "%USERPROFILE%") home = new Error(`Non-substituted output for "echo %USERPROFILE%"`);
} else {
home = await tryEcho(client, shellConfig, '~').catch((e: Error) => e);
}
if (typeof home !== 'string') { if (typeof home !== 'string') {
const [flagCH] = getFlagBoolean('CHECK_HOME', true, config.flags); const [flagCH] = getFlagBoolean('CHECK_HOME', true, config.flags);
logging.error('Could not detect home directory', LOGGING_NO_STACKTRACE); logging.error('Could not detect home directory', LOGGING_NO_STACKTRACE);
@ -153,9 +162,13 @@ export class ConnectionManager {
const [flagRCDV, flagRCDR] = getFlagBoolean('DEBUG_REMOTE_COMMANDS', false, actualConfig.flags); const [flagRCDV, flagRCDR] = getFlagBoolean('DEBUG_REMOTE_COMMANDS', false, actualConfig.flags);
const withDebugStr = flagRCDV ? ` with debug logging enabled by '${flagRCDR}'` : ''; const withDebugStr = flagRCDV ? ` with debug logging enabled by '${flagRCDR}'` : '';
logging.info`Flag REMOTE_COMMANDS provided in '${flagRCR}', setting up command terminal${withDebugStr}`; logging.info`Flag REMOTE_COMMANDS provided in '${flagRCR}', setting up command terminal${withDebugStr}`;
if (shellConfig.isWindows) {
logging.error(`Windows detected, command terminal is not yet supported`, LOGGING_NO_STACKTRACE);
} else {
const cmdPath = await this._createCommandTerminal(client, shellConfig, name, flagRCDV); const cmdPath = await this._createCommandTerminal(client, shellConfig, name, flagRCDV);
environment.push({ key: 'KELVIN_SSHFS_CMD_PATH', value: cmdPath }); environment.push({ key: 'KELVIN_SSHFS_CMD_PATH', value: cmdPath });
} }
}
logging.debug`Environment: ${environment}`; logging.debug`Environment: ${environment}`;
// Set up the Connection object // Set up the Connection object
let timeoutCounter = 0; let timeoutCounter = 0;

@ -156,7 +156,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider
const connection = await this.connectionManager.createConnection(resolved.host); const connection = await this.connectionManager.createConnection(resolved.host);
resolved = await replaceVariablesRecursive(resolved, value => replaceVariables(value, connection.actualConfig)); resolved = await replaceVariablesRecursive(resolved, value => replaceVariables(value, connection.actualConfig));
let { command, workingDirectory } = resolved; let { command, workingDirectory } = resolved;
const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', false, connection.actualConfig.flags); const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', connection.shellConfig.isWindows, connection.actualConfig.flags);
const separator = useWinCmdSep ? ' && ' : '; '; const separator = useWinCmdSep ? ' && ' : '; ';
let { taskCommand = '$COMMAND' } = connection.actualConfig; let { taskCommand = '$COMMAND' } = connection.actualConfig;
taskCommand = joinCommands(taskCommand, separator)!; taskCommand = joinCommands(taskCommand, separator)!;

@ -170,15 +170,16 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
async open(dims) { async open(dims) {
onDidWrite.fire(`Connecting to ${actualConfig.label || actualConfig.name}...\r\n`); onDidWrite.fire(`Connecting to ${actualConfig.label || actualConfig.name}...\r\n`);
try { try {
const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', false, actualConfig.flags); const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', shellConfig.isWindows, actualConfig.flags);
const separator = useWinCmdSep ? ' && ' : '; '; const separator = useWinCmdSep ? ' && ' : '; ';
let commands: string[] = []; let commands: string[] = [];
let SHELL = '$SHELL'; let SHELL = '$SHELL';
if (shellConfig.isWindows) SHELL = shellConfig.shell;
// Add exports for environment variables if needed // Add exports for environment variables if needed
const env = mergeEnvironment(connection.environment, options.environment); const env = mergeEnvironment(connection.environment, options.environment);
commands.push(environmentToExportString(env, shellConfig.setEnv)); commands.push(environmentToExportString(env, shellConfig.setEnv));
// Beta feature to add a "code <file>" command in terminals to open the file locally // Beta feature to add a "code <file>" command in terminals to open the file locally
if (getFlagBoolean('REMOTE_COMMANDS', false, actualConfig.flags)[0]) { if (getFlagBoolean('REMOTE_COMMANDS', false, actualConfig.flags)[0] && shellConfig.setupRemoteCommands) {
const rcCmds = await shellConfig.setupRemoteCommands(connection); const rcCmds = await shellConfig.setupRemoteCommands(connection);
if (rcCmds?.length) commands.push(joinCommands(rcCmds, separator)!); if (rcCmds?.length) commands.push(joinCommands(rcCmds, separator)!);
} }
@ -198,12 +199,16 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
if (cmd.includes('${workingDirectory}')) { if (cmd.includes('${workingDirectory}')) {
cmd = cmd.replace(/\${workingDirectory}/g, workingDirectory); cmd = cmd.replace(/\${workingDirectory}/g, workingDirectory);
} else { } else {
// TODO: Maybe replace with `connection.home`? // TODO: Maybe replace with `connection.home`? Especially with Windows not supporting ~
if (workingDirectory.startsWith('~')) { if (workingDirectory.startsWith('~')) {
if (shellConfig.isWindows)
throw new Error(`Working directory '${workingDirectory}' starts with ~ for a Windows shell`);
// So `cd "~/a/b/..." apparently doesn't work, but `~/"a/b/..."` does // So `cd "~/a/b/..." apparently doesn't work, but `~/"a/b/..."` does
// `"~"` would also fail but `~/""` works fine it seems // `"~"` would also fail but `~/""` works fine it seems
workingDirectory = `~/"${workingDirectory.substr(2)}"`; workingDirectory = `~/"${workingDirectory.slice(2)}"`;
} else { } else {
if (shellConfig.isWindows && workingDirectory.match(/^\/[a-zA-Z]:/))
workingDirectory = workingDirectory.slice(1);
workingDirectory = `"${workingDirectory}"`; workingDirectory = `"${workingDirectory}"`;
} }
cmd = joinCommands([`cd ${workingDirectory}`, ...commands], separator)!; cmd = joinCommands([`cd ${workingDirectory}`, ...commands], separator)!;

@ -1,7 +1,7 @@
import { posix as path } from 'path'; import { posix as path } from 'path';
import type { Client, ClientChannel, SFTP } from "ssh2"; import type { Client, ClientChannel, SFTP } from "ssh2";
import type { Connection } from './connection'; import type { Connection } from './connection';
import { Logger, Logging } from "./logging"; import { Logger, Logging, LOGGING_NO_STACKTRACE } from "./logging";
import { toPromise } from "./utils"; import { toPromise } from "./utils";
const SCRIPT_COMMAND_CODE = `#!/bin/sh const SCRIPT_COMMAND_CODE = `#!/bin/sh
@ -50,26 +50,32 @@ async function rcInitializePATH(connection: Connection): Promise<string[] | stri
export interface ShellConfig { export interface ShellConfig {
shell: string; shell: string;
isWindows: boolean;
setEnv(key: string, value: string): string; setEnv(key: string, value: string): string;
setupRemoteCommands: RemoteCommandInitializer; setupRemoteCommands?: RemoteCommandInitializer;
embedSubstitutions(command: TemplateStringsArray, ...substitutions: (string | number)[]): string; embedSubstitutions?(command: TemplateStringsArray, ...substitutions: (string | number)[]): string;
} }
const KNOWN_SHELL_CONFIGS: Record<string, ShellConfig> = {}; { const KNOWN_SHELL_CONFIGS: Record<string, ShellConfig> = {}; {
const add = (shell: string, const add = (shell: string,
setEnv: (key: string, value: string) => string, setEnv: (key: string, value: string) => string,
setupRemoteCommands: RemoteCommandInitializer, setupRemoteCommands?: RemoteCommandInitializer,
embedSubstitution: (command: TemplateStringsArray, ...substitutions: (string | number)[]) => string) => { embedSubstitutions?: (command: TemplateStringsArray, ...substitutions: (string | number)[]) => string,
KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions: embedSubstitution }; isWindows = false) => {
KNOWN_SHELL_CONFIGS[shell] = { shell, setEnv, setupRemoteCommands, embedSubstitutions, isWindows };
} }
// Ways to set an environment variable // Ways to set an environment variable
const setEnvExport = (key: string, value: string) => `export ${key}=${value}`; const setEnvExport = (key: string, value: string) => `export ${key}=${value}`;
const setEnvSetGX = (key: string, value: string) => `set -gx ${key} ${value}`; const setEnvSetGX = (key: string, value: string) => `set -gx ${key} ${value}`;
const setEnvSetEnv = (key: string, value: string) => `setenv ${key} ${value}`; const setEnvSetEnv = (key: string, value: string) => `setenv ${key} ${value}`;
const setEnvPowerShell = (key: string, value: string) => `$env:${key}="${value}"`;
const setEnvSet = (key: string, value: string) => `set ${key}=${value}`;
// Ways to embed a substitution // Ways to embed a substitution
const embedSubstitutionsBackticks = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string => const embedSubstitutionsBackticks = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string =>
'"' + substitutions.reduce((str, sub, i) => `${str}\`${sub}\`${command[i + 1]}`, command[0]) + '"'; '"' + substitutions.reduce((str, sub, i) => `${str}\`${sub}\`${command[i + 1]}`, command[0]) + '"';
const embedSubstitutionsFish = (command: TemplateStringsArray, ...substitutions: (string | number)[]) => const embedSubstitutionsFish = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string =>
substitutions.reduce((str, sub, i) => `${str}"(${sub})"${command[i + 1]}`, '"' + command[0]) + '"'; substitutions.reduce((str, sub, i) => `${str}"(${sub})"${command[i + 1]}`, '"' + command[0]) + '"';
const embedSubstitutionsPowershell = (command: TemplateStringsArray, ...substitutions: (string | number)[]): string =>
substitutions.reduce((str, sub, i) => `${str}$(${sub})${command[i + 1]}`, '"' + command[0]) + '"';
// Register the known shells // Register the known shells
add('sh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); add('sh', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks);
add('bash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks); add('bash', setEnvExport, rcInitializePATH, embedSubstitutionsBackticks);
@ -81,6 +87,8 @@ const KNOWN_SHELL_CONFIGS: Record<string, ShellConfig> = {}; {
add('fish', setEnvSetGX, rcInitializePATH, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions add('fish', setEnvSetGX, rcInitializePATH, embedSubstitutionsFish); // https://fishshell.com/docs/current/tutorial.html#autoloading-functions
add('csh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); add('csh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks);
add('tcsh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks); add('tcsh', setEnvSetEnv, rcInitializePATH, embedSubstitutionsBackticks);
add('powershell', setEnvPowerShell, undefined, embedSubstitutionsPowershell, true); // experimental
add('cmd.exe', setEnvSet, undefined, undefined, true); // experimental
} }
export async function tryCommand(ssh: Client, command: string): Promise<string | null> { export async function tryCommand(ssh: Client, command: string): Promise<string | null> {
@ -103,17 +111,45 @@ export async function tryCommand(ssh: Client, command: string): Promise<string |
} }
export async function tryEcho(ssh: Client, shellConfig: ShellConfig, variable: string): Promise<string | null> { export async function tryEcho(ssh: Client, shellConfig: ShellConfig, variable: string): Promise<string | null> {
if (!shellConfig.embedSubstitutions) throw new Error(`Shell '${shellConfig.shell}' does not support embedding substitutions`);
const uniq = Date.now() % 1e5; const uniq = Date.now() % 1e5;
const output = await tryCommand(ssh, `echo ${shellConfig.embedSubstitutions`::${'echo ' + uniq}:echo_result:${`echo ${variable}`}:${'echo ' + uniq}::`}`); const output = await tryCommand(ssh, `echo ${shellConfig.embedSubstitutions`::${'echo ' + uniq}:echo_result:${`echo ${variable}`}:${'echo ' + uniq}::`}`);
return output?.match(`::${uniq}:echo_result:(.*?):${uniq}::`)?.[1] || null; return output?.match(`::${uniq}:echo_result:(.*?):${uniq}::`)?.[1] || null;
} }
async function getPowershellVersion(client: Client): Promise<string | null> {
const version = await tryCommand(client, 'echo $PSversionTable.PSVersion.ToString()').catch(e => {
console.error(e);
return null;
});
return !version?.includes('PSVersion') ? version : null;
}
async function getWindowsVersion(client: Client): Promise<string | null> {
const version = await tryCommand(client, 'systeminfo | findstr /BC:"OS Version"').catch(e => {
console.error(e);
return null;
});
const match = version?.trim().match(/^OS Version:[ \t]+(.*)$/);
return match?.[1] || null;
}
export async function calculateShellConfig(client: Client, logging?: Logger): Promise<ShellConfig> { export async function calculateShellConfig(client: Client, logging?: Logger): Promise<ShellConfig> {
try { try {
const shellStdout = await tryCommand(client, 'echo :::SHELL:$SHELL:SHELL:::'); const shellStdout = await tryCommand(client, 'echo :::SHELL:$SHELL:SHELL:::');
const shell = shellStdout?.match(/:::SHELL:([^$].*?):SHELL:::/)?.[1]; const shell = shellStdout?.match(/:::SHELL:([^$].*?):SHELL:::/)?.[1];
if (!shell) { if (!shell) {
if (shellStdout) logging?.error(`Could not get $SHELL from following output:\n${shellStdout}`); const psVersion = await getPowershellVersion(client);
if (psVersion) {
logging?.debug(`Detected PowerShell version ${psVersion}`);
return { ...KNOWN_SHELL_CONFIGS['powershell'], shell: 'PowerShell' };
}
const windowsVersion = await getWindowsVersion(client);
if (windowsVersion) {
logging?.debug(`Detected Command Prompt for Windows ${windowsVersion}`);
return { ...KNOWN_SHELL_CONFIGS['cmd.exe'], shell: 'cmd.exe' };
}
if (shellStdout) logging?.error(`Could not get $SHELL from following output:\n${shellStdout}`, LOGGING_NO_STACKTRACE);
throw new Error('Could not get $SHELL'); throw new Error('Could not get $SHELL');
} }
const known = KNOWN_SHELL_CONFIGS[path.basename(shell)]; const known = KNOWN_SHELL_CONFIGS[path.basename(shell)];

Loading…
Cancel
Save