/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as crypto from 'crypto'; import Log from './common/logger'; import { getVSCodeServerConfig } from './serverConfig'; import SSHConnection from './ssh/sshConnection'; export interface ServerInstallOptions { id: string; quality: string; commit: string; version: string; release?: string; // void specific extensionIds: string[]; envVariables: string[]; useSocketPath: boolean; serverApplicationName: string; serverDataFolderName: string; serverDownloadUrlTemplate: string; } export interface ServerInstallResult { exitCode: number; listeningOn: number | string; connectionToken: string; logFile: string; osReleaseId: string; arch: string; platform: string; tmpDir: string; [key: string]: any; } export class ServerInstallError extends Error { constructor(message: string) { super(message); } } const DEFAULT_DOWNLOAD_URL_TEMPLATE = 'https://github.com/voideditor/binaries/releases/download/${version}.${release}/void-reh-${os}-${arch}-${version}.${release}.tar.gz'; export async function installCodeServer(conn: SSHConnection, serverDownloadUrlTemplate: string | undefined, extensionIds: string[], envVariables: string[], platform: string | undefined, useSocketPath: boolean, logger: Log): Promise { let shell = 'powershell'; // detect platform and shell for windows if (!platform || platform === 'windows') { const result = await conn.exec('uname -s'); if (result.stdout) { if (result.stdout.includes('windows32')) { platform = 'windows'; } else if (result.stdout.includes('MINGW64')) { platform = 'windows'; shell = 'bash'; } } else if (result.stderr) { if (result.stderr.includes('FullyQualifiedErrorId : CommandNotFoundException')) { platform = 'windows'; } if (result.stderr.includes('is not recognized as an internal or external command')) { platform = 'windows'; shell = 'cmd'; } } if (platform) { logger.trace(`Detected platform: ${platform}, ${shell}`); } } const scriptId = crypto.randomBytes(12).toString('hex'); const vscodeServerConfig = await getVSCodeServerConfig(); const installOptions: ServerInstallOptions = { id: scriptId, version: vscodeServerConfig.version, commit: vscodeServerConfig.commit, quality: vscodeServerConfig.quality, release: vscodeServerConfig.release, extensionIds, envVariables, useSocketPath, serverApplicationName: vscodeServerConfig.serverApplicationName, serverDataFolderName: vscodeServerConfig.serverDataFolderName, serverDownloadUrlTemplate: serverDownloadUrlTemplate ?? vscodeServerConfig.serverDownloadUrlTemplate ?? DEFAULT_DOWNLOAD_URL_TEMPLATE, }; let commandOutput: { stdout: string; stderr: string }; if (platform === 'windows') { const installServerScript = generatePowerShellInstallScript(installOptions); logger.trace('Server install command:', installServerScript); const installDir = `$HOME\\${vscodeServerConfig.serverDataFolderName}\\install`; const installScript = `${installDir}\\${vscodeServerConfig.commit}.ps1`; const endRegex = new RegExp(`${scriptId}: end`); // investigate if it's possible to use `-EncodedCommand` flag // https://devblogs.microsoft.com/powershell/invoking-powershell-with-complex-expressions-using-scriptblocks/ let command = ''; if (shell === 'powershell') { command = `md -Force ${installDir}; echo @'\n${installServerScript}\n'@ | Set-Content ${installScript}; powershell -ExecutionPolicy ByPass -File "${installScript}"`; } else if (shell === 'bash') { command = `mkdir -p ${installDir.replace(/\\/g, '/')} && echo '\n${installServerScript.replace(/'/g, '\'"\'"\'')}\n' > ${installScript.replace(/\\/g, '/')} && powershell -ExecutionPolicy ByPass -File "${installScript}"`; } else if (shell === 'cmd') { const script = installServerScript.trim() // remove comments .replace(/^#.*$/gm, '') // remove empty lines .replace(/\n{2,}/gm, '\n') // remove leading spaces .replace(/^\s*/gm, '') // escape double quotes (from powershell/cmd) .replace(/"/g, '"""') // escape single quotes (from cmd) .replace(/'/g, `''`) // escape redirect (from cmd) .replace(/>/g, `^>`) // escape new lines (from powershell/cmd) .replace(/\n/g, '\'`n\''); command = `powershell "md -Force ${installDir}" && powershell "echo '${script}'" > ${installScript.replace('$HOME', '%USERPROFILE%')} && powershell -ExecutionPolicy ByPass -File "${installScript.replace('$HOME', '%USERPROFILE%')}"`; logger.trace('Command length (8191 max):', command.length); if (command.length > 8191) { throw new ServerInstallError(`Command line too long`); } } else { throw new ServerInstallError(`Not supported shell: ${shell}`); } commandOutput = await conn.execPartial(command, (stdout: string) => endRegex.test(stdout)); } else { const installServerScript = generateBashInstallScript(installOptions); logger.trace('Server install command:', installServerScript); // Fish shell does not support heredoc so let's workaround it using -c option, // also replace single quotes (') within the script with ('\'') as there's no quoting within single quotes, see https://unix.stackexchange.com/a/24676 commandOutput = await conn.exec(`bash -c '${installServerScript.replace(/'/g, `'\\''`)}'`); } if (commandOutput.stderr) { logger.trace('Server install command stderr:', commandOutput.stderr); } logger.trace('Server install command stdout:', commandOutput.stdout); const resultMap = parseServerInstallOutput(commandOutput.stdout, scriptId); if (!resultMap) { throw new ServerInstallError(`Failed parsing install script output`); } const exitCode = parseInt(resultMap.exitCode, 10); if (exitCode !== 0) { throw new ServerInstallError(`Couldn't install vscode server on remote server, install script returned non-zero exit status`); } const listeningOn = resultMap.listeningOn.match(/^\d+$/) ? parseInt(resultMap.listeningOn, 10) : resultMap.listeningOn; const remoteEnvVars = Object.fromEntries(Object.entries(resultMap).filter(([key,]) => envVariables.includes(key))); return { exitCode, listeningOn, connectionToken: resultMap.connectionToken, logFile: resultMap.logFile, osReleaseId: resultMap.osReleaseId, arch: resultMap.arch, platform: resultMap.platform, tmpDir: resultMap.tmpDir, ...remoteEnvVars }; } function parseServerInstallOutput(str: string, scriptId: string): { [k: string]: string } | undefined { const startResultStr = `${scriptId}: start`; const endResultStr = `${scriptId}: end`; const startResultIdx = str.indexOf(startResultStr); if (startResultIdx < 0) { return undefined; } const endResultIdx = str.indexOf(endResultStr, startResultIdx + startResultStr.length); if (endResultIdx < 0) { return undefined; } const installResult = str.substring(startResultIdx + startResultStr.length, endResultIdx); const resultMap: { [k: string]: string } = {}; const resultArr = installResult.split(/\r?\n/); for (const line of resultArr) { const [key, value] = line.split('=='); resultMap[key] = value; } return resultMap; } function generateBashInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); return ` # Server installation script TMP_DIR="\${XDG_RUNTIME_DIR:-"/tmp"}" DISTRO_VERSION="${version}" DISTRO_COMMIT="${commit}" DISTRO_QUALITY="${quality}" DISTRO_VOID_RELEASE="${release ?? ''}" SERVER_APP_NAME="${serverApplicationName}" SERVER_INITIAL_EXTENSIONS="${extensions}" SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}" SERVER_DATA_DIR="$HOME/${serverDataFolderName}" SERVER_DIR="$SERVER_DATA_DIR/bin/$DISTRO_COMMIT" SERVER_SCRIPT="$SERVER_DIR/bin/$SERVER_APP_NAME" SERVER_LOGFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.log" SERVER_PIDFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.pid" SERVER_TOKENFILE="$SERVER_DATA_DIR/.$DISTRO_COMMIT.token" SERVER_ARCH= SERVER_CONNECTION_TOKEN= SERVER_DOWNLOAD_URL= LISTENING_ON= OS_RELEASE_ID= ARCH= PLATFORM= # Mimic output from logs of remote-ssh extension print_install_results_and_exit() { echo "${id}: start" echo "exitCode==$1==" echo "listeningOn==$LISTENING_ON==" echo "connectionToken==$SERVER_CONNECTION_TOKEN==" echo "logFile==$SERVER_LOGFILE==" echo "osReleaseId==$OS_RELEASE_ID==" echo "arch==$ARCH==" echo "platform==$PLATFORM==" echo "tmpDir==$TMP_DIR==" ${envVariables.map(envVar => `echo "${envVar}==$${envVar}=="`).join('\n')} echo "${id}: end" exit 0 } # Check if platform is supported KERNEL="$(uname -s)" case $KERNEL in Darwin) PLATFORM="darwin" ;; Linux) PLATFORM="linux" ;; FreeBSD) PLATFORM="freebsd" ;; DragonFly) PLATFORM="dragonfly" ;; *) echo "Error platform not supported: $KERNEL" print_install_results_and_exit 1 ;; esac # Check machine architecture ARCH="$(uname -m)" case $ARCH in x86_64 | amd64) SERVER_ARCH="x64" ;; armv7l | armv8l) SERVER_ARCH="armhf" ;; arm64 | aarch64) SERVER_ARCH="arm64" ;; ppc64le) SERVER_ARCH="ppc64le" ;; riscv64) SERVER_ARCH="riscv64" ;; loongarch64) SERVER_ARCH="loong64" ;; s390x) SERVER_ARCH="s390x" ;; *) echo "Error architecture not supported: $ARCH" print_install_results_and_exit 1 ;; esac # https://www.freedesktop.org/software/systemd/man/os-release.html OS_RELEASE_ID="$(grep -i '^ID=' /etc/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" if [[ -z $OS_RELEASE_ID ]]; then OS_RELEASE_ID="$(grep -i '^ID=' /usr/lib/os-release 2>/dev/null | sed 's/^ID=//gi' | sed 's/"//g')" if [[ -z $OS_RELEASE_ID ]]; then OS_RELEASE_ID="unknown" fi fi # Create installation folder if [[ ! -d $SERVER_DIR ]]; then mkdir -p $SERVER_DIR if (( $? > 0 )); then echo "Error creating server install directory" print_install_results_and_exit 1 fi fi # adjust platform for void download, if needed if [[ $OS_RELEASE_ID = alpine ]]; then PLATFORM=$OS_RELEASE_ID fi SERVER_DOWNLOAD_URL="$(echo "${serverDownloadUrlTemplate.replace(/\$\{/g, '\\${')}" | sed "s/\\\${quality}/$DISTRO_QUALITY/g" | sed "s/\\\${version}/$DISTRO_VERSION/g" | sed "s/\\\${commit}/$DISTRO_COMMIT/g" | sed "s/\\\${os}/$PLATFORM/g" | sed "s/\\\${arch}/$SERVER_ARCH/g" | sed "s/\\\${release}/$DISTRO_VOID_RELEASE/g")" # Check if server script is already installed if [[ ! -f $SERVER_SCRIPT ]]; then case "$PLATFORM" in darwin | linux | alpine ) ;; *) echo "Error '$PLATFORM' needs manual installation of remote extension host" print_install_results_and_exit 1 ;; esac pushd $SERVER_DIR > /dev/null if [[ ! -z $(which wget) ]]; then wget --tries=3 --timeout=10 --continue --no-verbose -O vscode-server.tar.gz $SERVER_DOWNLOAD_URL elif [[ ! -z $(which curl) ]]; then curl --retry 3 --connect-timeout 10 --location --show-error --silent --output vscode-server.tar.gz $SERVER_DOWNLOAD_URL else echo "Error no tool to download server binary" print_install_results_and_exit 1 fi if (( $? > 0 )); then echo "Error downloading server from $SERVER_DOWNLOAD_URL" print_install_results_and_exit 1 fi tar -xf vscode-server.tar.gz --strip-components 1 if (( $? > 0 )); then echo "Error while extracting server contents" print_install_results_and_exit 1 fi if [[ ! -f $SERVER_SCRIPT ]]; then echo "Error server contents are corrupted" print_install_results_and_exit 1 fi rm -f vscode-server.tar.gz popd > /dev/null else echo "Server script already installed in $SERVER_SCRIPT" fi # Try to find if server is already running if [[ -f $SERVER_PIDFILE ]]; then SERVER_PID="$(cat $SERVER_PIDFILE)" SERVER_RUNNING_PROCESS="$(ps -o pid,args -p $SERVER_PID | grep $SERVER_SCRIPT)" else SERVER_RUNNING_PROCESS="$(ps -o pid,args -A | grep $SERVER_SCRIPT | grep -v grep)" fi if [[ -z $SERVER_RUNNING_PROCESS ]]; then if [[ -f $SERVER_LOGFILE ]]; then rm $SERVER_LOGFILE fi if [[ -f $SERVER_TOKENFILE ]]; then rm $SERVER_TOKENFILE fi touch $SERVER_TOKENFILE chmod 600 $SERVER_TOKENFILE SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" echo $SERVER_CONNECTION_TOKEN > $SERVER_TOKENFILE $SERVER_SCRIPT --start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms &> $SERVER_LOGFILE & echo $! > $SERVER_PIDFILE else echo "Server script is already running $SERVER_SCRIPT" fi if [[ -f $SERVER_TOKENFILE ]]; then SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" else echo "Error server token file not found $SERVER_TOKENFILE" print_install_results_and_exit 1 fi if [[ -f $SERVER_LOGFILE ]]; then for i in {1..5}; do LISTENING_ON="$(cat $SERVER_LOGFILE | grep -E 'Extension host agent listening on .+' | sed 's/Extension host agent listening on //')" if [[ -n $LISTENING_ON ]]; then break fi sleep 0.5 done if [[ -z $LISTENING_ON ]]; then echo "Error server did not start successfully" print_install_results_and_exit 1 fi else echo "Error server log file not found $SERVER_LOGFILE" print_install_results_and_exit 1 fi # Finish server setup print_install_results_and_exit 0 `; } function generatePowerShellInstallScript({ id, quality, version, commit, release, extensionIds, envVariables, useSocketPath, serverApplicationName, serverDataFolderName, serverDownloadUrlTemplate }: ServerInstallOptions) { const extensions = extensionIds.map(id => '--install-extension ' + id).join(' '); const downloadUrl = serverDownloadUrlTemplate .replace(/\$\{quality\}/g, quality) .replace(/\$\{version\}/g, version) .replace(/\$\{commit\}/g, commit) .replace(/\$\{os\}/g, 'win32') .replace(/\$\{arch\}/g, 'x64') .replace(/\$\{release\}/g, release ?? ''); return ` # Server installation script $TMP_DIR="$env:TEMP\\$([System.IO.Path]::GetRandomFileName())" $ProgressPreference = "SilentlyContinue" $DISTRO_VERSION="${version}" $DISTRO_COMMIT="${commit}" $DISTRO_QUALITY="${quality}" $DISTRO_VOID_RELEASE="${release ?? ''}" $SERVER_APP_NAME="${serverApplicationName}" $SERVER_INITIAL_EXTENSIONS="${extensions}" $SERVER_LISTEN_FLAG="${useSocketPath ? `--socket-path="$TMP_DIR/vscode-server-sock-${crypto.randomUUID()}"` : '--port=0'}" $SERVER_DATA_DIR="$(Resolve-Path ~)\\${serverDataFolderName}" $SERVER_DIR="$SERVER_DATA_DIR\\bin\\$DISTRO_COMMIT" $SERVER_SCRIPT="$SERVER_DIR\\bin\\$SERVER_APP_NAME.cmd" $SERVER_LOGFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.log" $SERVER_PIDFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.pid" $SERVER_TOKENFILE="$SERVER_DATA_DIR\\.$DISTRO_COMMIT.token" $SERVER_ARCH= $SERVER_CONNECTION_TOKEN= $SERVER_DOWNLOAD_URL= $LISTENING_ON= $OS_RELEASE_ID= $ARCH= $PLATFORM="win32" function printInstallResults($code) { "${id}: start" "exitCode==$code==" "listeningOn==$LISTENING_ON==" "connectionToken==$SERVER_CONNECTION_TOKEN==" "logFile==$SERVER_LOGFILE==" "osReleaseId==$OS_RELEASE_ID==" "arch==$ARCH==" "platform==$PLATFORM==" "tmpDir==$TMP_DIR==" ${envVariables.map(envVar => `"${envVar}==$${envVar}=="`).join('\n')} "${id}: end" } # Check machine architecture $ARCH=$env:PROCESSOR_ARCHITECTURE # Use x64 version for ARM64, as it's not yet available. if(($ARCH -eq "AMD64") -or ($ARCH -eq "IA64") -or ($ARCH -eq "ARM64")) { $SERVER_ARCH="x64" } else { "Error architecture not supported: $ARCH" printInstallResults 1 exit 0 } # Create installation folder if(!(Test-Path $SERVER_DIR)) { try { ni -it d $SERVER_DIR -f -ea si } catch { "Error creating server install directory - $($_.ToString())" exit 1 } if(!(Test-Path $SERVER_DIR)) { "Error creating server install directory" exit 1 } } cd $SERVER_DIR # Check if server script is already installed if(!(Test-Path $SERVER_SCRIPT)) { del vscode-server.tar.gz $REQUEST_ARGUMENTS = @{ Uri="${downloadUrl}" TimeoutSec=20 OutFile="vscode-server.tar.gz" UseBasicParsing=$True } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Invoke-RestMethod @REQUEST_ARGUMENTS if(Test-Path "vscode-server.tar.gz") { tar -xf vscode-server.tar.gz --strip-components 1 del vscode-server.tar.gz } if(!(Test-Path $SERVER_SCRIPT)) { "Error while installing the server binary" exit 1 } } else { "Server script already installed in $SERVER_SCRIPT" } # Try to find if server is already running if(Get-Process node -ErrorAction SilentlyContinue | Where-Object Path -Like "$SERVER_DIR\\*") { echo "Server script is already running $SERVER_SCRIPT" } else { if(Test-Path $SERVER_LOGFILE) { del $SERVER_LOGFILE } if(Test-Path $SERVER_PIDFILE) { del $SERVER_PIDFILE } if(Test-Path $SERVER_TOKENFILE) { del $SERVER_TOKENFILE } $SERVER_CONNECTION_TOKEN="${crypto.randomUUID()}" [System.IO.File]::WriteAllLines($SERVER_TOKENFILE, $SERVER_CONNECTION_TOKEN) $SCRIPT_ARGUMENTS="--start-server --host=127.0.0.1 $SERVER_LISTEN_FLAG $SERVER_INITIAL_EXTENSIONS --connection-token-file $SERVER_TOKENFILE --telemetry-level off --enable-remote-auto-shutdown --accept-server-license-terms *> '$SERVER_LOGFILE'" $START_ARGUMENTS = @{ FilePath = "powershell.exe" WindowStyle = "hidden" ArgumentList = @( "-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NoProfile", "-NonInteractive", "-c", "$SERVER_SCRIPT $SCRIPT_ARGUMENTS" ) PassThru = $True } $SERVER_ID = (start @START_ARGUMENTS).ID if($SERVER_ID) { [System.IO.File]::WriteAllLines($SERVER_PIDFILE, $SERVER_ID) } } if(Test-Path $SERVER_TOKENFILE) { $SERVER_CONNECTION_TOKEN="$(cat $SERVER_TOKENFILE)" } else { "Error server token file not found $SERVER_TOKENFILE" printInstallResults 1 exit 0 } sleep -Milliseconds 500 $SELECT_ARGUMENTS = @{ Path = $SERVER_LOGFILE Pattern = "Extension host agent listening on (\\d+)" } for($I = 1; $I -le 5; $I++) { if(Test-Path $SERVER_LOGFILE) { $GROUPS = (Select-String @SELECT_ARGUMENTS).Matches.Groups if($GROUPS) { $LISTENING_ON = $GROUPS[1].Value break } } sleep -Milliseconds 500 } if(!(Test-Path $SERVER_LOGFILE)) { "Error server log file not found $SERVER_LOGFILE" printInstallResults 1 exit 0 } # Finish server setup printInstallResults 0 if($SERVER_ID) { while($True) { if(!(gps -Id $SERVER_ID)) { "server died, exit" exit 0 } sleep 30 } } `; }