| // Copyright (c) 2017, Matt Godbolt |
| // All rights reserved. |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are met: |
| // |
| // * Redistributions of source code must retain the above copyright notice, |
| // this list of conditions and the following disclaimer. |
| // * Redistributions in binary form must reproduce the above copyright |
| // notice, this list of conditions and the following disclaimer in the |
| // documentation and/or other materials provided with the distribution. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
| // ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE |
| // LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR |
| // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
| // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
| // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN |
| // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
| // POSSIBILITY OF SUCH DAMAGE. |
| |
| const child_process = require('child_process'), |
| path = require('path'), |
| fs = require('fs'), |
| logger = require('./logger').logger, |
| treeKill = require('tree-kill'), |
| execProps = require('./properties').propsFor('execution'), |
| Graceful = require('node-graceful'); |
| |
| function setupOnError(stream, name) { |
| if (stream === undefined) return; |
| stream.on('error', err => { |
| logger.error('Error with ' + name + ' stream:', err); |
| }); |
| } |
| |
| function executeDirect(command, args, options) { |
| options = options || {}; |
| const maxOutput = options.maxOutput || 1024 * 1024; |
| const timeoutMs = options.timeoutMs || 0; |
| const env = options.env; |
| |
| if (options.wrapper) { |
| args = args.slice(0); // prevent mutating the caller's arguments |
| args.unshift(command); |
| command = options.wrapper; |
| |
| if (command.startsWith('./')) command = path.join(process.cwd(), command); |
| } |
| |
| let okToCache = true; |
| const cwd = options.customCwd ? options.customCwd : ( |
| (command.startsWith("/mnt") && process.env.wsl) ? process.env.winTmp : process.env.tmpDir |
| ); |
| logger.debug("Execution", {type: "executing", command: command, args: args, env: env, cwd: cwd}); |
| // AP: Run Windows-volume executables in winTmp. Otherwise, run in tmpDir (which may be undefined). |
| // https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options |
| const child = child_process.spawn(command, args, { |
| cwd: cwd, |
| env: env, |
| detached: process.platform === 'linux' |
| }); |
| let running = true; |
| |
| const kill = options.killChild || (() => { |
| if (running) treeKill(child.pid); |
| }); |
| |
| const streams = { |
| stderr: "", |
| stdout: "", |
| truncated: false |
| }; |
| let timeout; |
| if (timeoutMs) timeout = setTimeout(() => { |
| logger.warn("Timeout for", command, args, "after", timeoutMs, "ms"); |
| okToCache = false; |
| kill(); |
| streams.stderr += "\nKilled - processing time exceeded"; |
| }, timeoutMs); |
| |
| function setupStream(stream, name) { |
| if (stream === undefined) return; |
| stream.on('data', data => { |
| if (streams.truncated) return; |
| const newLength = (streams[name].length + data.length); |
| if ((maxOutput > 0) && (newLength > maxOutput)) { |
| streams[name] = streams[name] + data.slice(0, maxOutput - streams[name].length); |
| streams[name] += "\n[Truncated]"; |
| streams.truncated = true; |
| kill(); |
| return; |
| } |
| streams[name] += data; |
| }); |
| setupOnError(stream, name); |
| } |
| |
| setupOnError(child.stdin, 'stdin'); |
| setupStream(child.stdout, 'stdout'); |
| setupStream(child.stderr, 'stderr'); |
| child.on('exit', code => { |
| logger.debug("Execution", {type: 'exited', code: code}); |
| if (timeout !== undefined) clearTimeout(timeout); |
| running = false; |
| }); |
| return new Promise((resolve, reject) => { |
| child.on('error', e => { |
| logger.debug("Execution", "Error with " + command + " args", args, ":", e); |
| reject(e); |
| }); |
| child.on('close', code => { |
| // Being killed externally gives a NULL error code. Synthesize something different here. |
| if (code === null) code = -1; |
| if (timeout !== undefined) clearTimeout(timeout); |
| const result = { |
| code: code, |
| stdout: streams.stdout, |
| stderr: streams.stderr, |
| okToCache: okToCache |
| }; |
| logger.debug("Execution", {type: "executed", command: command, args: args, result: result}); |
| resolve(result); |
| }); |
| if (options.input) child.stdin.write(options.input); |
| child.stdin.end(); |
| }); |
| } |
| |
| |
| function sandboxDocker(command, args, options) { |
| logger.info("Sandbox execution via docker", command, args); |
| const execPath = path.dirname(command); |
| const execName = path.basename(command); |
| return new Promise((resolve, reject) => { |
| logger.debug("Starting sandbox docker container for", command, args); |
| let containerId = null; |
| let killed = false; |
| const timeoutMs = options.timeoutMs || 0; |
| |
| function removeContainer() { |
| if (containerId) { |
| logger.debug("Removing container", containerId); |
| execute("docker", ["rm", containerId]); |
| } else { |
| logger.debug("No container to remove"); |
| } |
| } |
| |
| // Start the docker container and detach... |
| execute( |
| "docker", |
| [ |
| "run", |
| "--detach", |
| "--cpu-shares=128", |
| "--cpu-quota=25000", |
| "--ulimit", "nofile=20", // needs at least this to function normally it seems |
| "--ulimit", "cpu=3", // hopefully 3 seconds' CPU time |
| "--ulimit", "rss=" + (128 * 1024), // hopefully RSS size limit |
| "--network=none", |
| "--memory=128M", |
| "--memory-swap=0", |
| "-v" + execPath + ":/home/ce-user:ro", |
| "mattgodbolt/compiler-explorer:exec", |
| "./" + execName |
| ].concat(args), |
| {}) |
| .then(result => { |
| containerId = result.stdout.trim(); |
| logger.debug("Docker container id is", containerId); |
| if (result.code !== 0) { |
| logger.error("Failed to start docker", result); |
| result.stdout = []; |
| result.stderr = []; |
| if (containerId !== "") { |
| // If we didn't get a container ID, reject... |
| reject(result); |
| } |
| } |
| }) |
| .then(() => execute( |
| "docker", |
| [ |
| "wait", |
| containerId |
| ], |
| { |
| timeoutMs: timeoutMs, |
| killChild: () => { |
| logger.debug("Killing docker container", containerId); |
| execute("docker", ["kill", containerId]); |
| killed = true; |
| } |
| })) |
| .then(result => { |
| if (result.code !== 0) { |
| logger.error("Failed to wait for", containerId); |
| removeContainer(); |
| reject(result); |
| return; |
| } |
| const returnValue = parseInt(result.stdout); |
| return execute( |
| "docker", |
| [ |
| "logs", |
| containerId |
| ], options) |
| .then(logResult => { |
| if (logResult.code !== 0) { |
| logger.error("Failed to get logs for", containerId); |
| removeContainer(); |
| reject(logResult); |
| return; |
| } |
| if (killed) |
| logResult.stdout += "\n### Killed after " + timeoutMs + "ms"; |
| logResult.code = returnValue; |
| return logResult; |
| }); |
| }) |
| .then(result => { |
| removeContainer(); |
| resolve(result); |
| }) |
| .catch(err => { |
| removeContainer(); |
| reject(err); |
| }); |
| }); |
| } |
| |
| function sandboxFirejail(command, args, options) { |
| logger.info("Sandbox execution via firejail", command, args); |
| const execPath = path.dirname(command); |
| const execName = path.basename(command); |
| const jailingOptions = [ |
| '--quiet', |
| '--profile=etc/firejail/sandbox.profile', |
| // '--debug', |
| // TODO: If no cwd, just `--private` '--private=/tmp/private-home', else infer... |
| // hack for now: which relies on knowing the execPath will be in the private temp directory. |
| `--private=${execPath}`, |
| `./${execName}` |
| ].concat(args); |
| command = 'firejail'; |
| args = jailingOptions; |
| return executeDirect(command, args, options); |
| } |
| |
| const sandboxDispatchTable = { |
| none: (command, args, options) => { |
| logger.info("Sandbox execution (sandbox disabled)", command, args); |
| return executeDirect(command, args, options); |
| }, |
| firejail: sandboxFirejail, |
| docker: sandboxDocker |
| }; |
| |
| function sandbox(command, args, options) { |
| const type = execProps("sandboxType", "docker"); |
| const dispatchEntry = sandboxDispatchTable[type]; |
| if (!dispatchEntry) |
| return Promise.reject(`Bad sandbox type ${type}`); |
| return dispatchEntry(command, args, options); |
| } |
| |
| function initialiseWine() { |
| const wine = execProps("wine"); |
| if (!wine) { |
| logger.info("WINE not configured"); |
| return Promise.resolve(); |
| } |
| |
| const server = execProps("wineServer"); |
| const firejail = execProps("executionType", "none") === "firejail" ? execProps("firejail") : null; |
| const env = applyWineEnv({PATH: process.env.PATH}); |
| const prefix = env.WINEPREFIX; |
| |
| logger.info(`Initialising WINE in ${prefix}`); |
| if (!fs.existsSync(prefix) || !fs.statSync(prefix).isDirectory()) { |
| logger.info(`Creating directory ${prefix}`); |
| fs.mkdirSync(prefix); |
| } |
| |
| logger.info(`Killing any pre-existing wine-server`); |
| let result = child_process.execSync(`${server} -k || true`, {env: env}); |
| logger.info(`Result: ${result}`); |
| logger.info(`Waiting for any pre-existing server to stop...`); |
| result = child_process.execSync(`${server} -w`, {env: env}); |
| logger.info(`Result: ${result}`); |
| |
| // Once the server is running, we fire up a single process through to: |
| // * test that WINE works |
| // * be the "babysitter". WINE loves to make lots of long-lived processes |
| // on the first execution, and this upsets things like firejail, which won't |
| // exit until the last child process dies. That makes compilations hang. |
| // We wait until the process has printed out some known good text, but don't wait |
| // for it to exit (it won't). |
| |
| let waitForOk, wineServer; |
| if (firejail) { |
| logger.info(`Starting a new, firejailed, long-lived wineserver ${server}`); |
| wineServer = child_process.spawn( |
| firejail, |
| [ |
| "--quiet", |
| "--profile=etc/firejail/wine.profile", |
| "--private", |
| server, |
| "-p" |
| ], |
| { |
| env: env, |
| detached: true |
| }); |
| logger.info(`firejailed wineserver pid=${wineServer.pid}`); |
| logger.info(`Initialising WINE babysitter with ${wine}...`); |
| waitForOk = child_process.spawn( |
| firejail, |
| [ |
| "--quiet", |
| "--profile=etc/firejail/wine.profile", |
| wine, |
| "cmd"], |
| {env: env, detached: true}); |
| } else { |
| logger.info(`Starting a new, long-lived wineserver ${server}`); |
| wineServer = child_process.spawn(server, ["-p"], { |
| env: env, |
| detached: true |
| }); |
| logger.info(`wineserver pid=${wineServer.pid}`); |
| logger.info(`Initialising WINE babysitter with ${wine}...`); |
| waitForOk = child_process.spawn( |
| wine, |
| ["cmd"], |
| {env: env, detached: true}); |
| } |
| |
| wineServer.on('close', code => { |
| logger.info(`WINE server exited with code ${code}`); |
| }); |
| wineServer.stdout.on('data', |
| data => logger.info(`stdout output from wine server process: ${data.toString().trim()}`)); |
| wineServer.stderr.on('data', |
| data => logger.info(`stderr output from wine server process: ${data.toString().trim()}`)); |
| |
| Graceful.on('exit', () => { |
| const waitingPromises = []; |
| |
| function waitForExit(process, name) { |
| return new Promise((resolve) => { |
| process.on('close', () => { |
| logger.info(`Process '${name}' closed`); |
| resolve(); |
| }); |
| }); |
| } |
| |
| if (waitForOk && !waitForOk.killed) { |
| logger.info('Shutting down WINE babysitter'); |
| waitForOk.kill(); |
| if (waitForOk.killed) { |
| waitingPromises.push(waitForExit(waitForOk, "WINE babysitter")); |
| } |
| waitForOk = null; |
| } |
| if (wineServer && !wineServer.killed) { |
| logger.info('Shutting down WINE server'); |
| wineServer.kill(); |
| if (wineServer.killed) { |
| waitingPromises.push(waitForExit(wineServer, "WINE server")); |
| } |
| wineServer = null; |
| } |
| return Promise.all(waitingPromises); |
| }); |
| return new Promise((resolve, reject) => { |
| setupOnError(waitForOk.stdin, "stdin"); |
| setupOnError(waitForOk.stdout, "stdout"); |
| setupOnError(waitForOk.stderr, "stderr"); |
| const magicString = "!!EVERYTHING IS WORKING!!"; |
| waitForOk.stdin.write(`echo ${magicString}`); |
| waitForOk.stdin.end(); |
| |
| let output = ""; |
| waitForOk.stdout.on('data', data => { |
| logger.info(`Output from wine process: ${data.toString().trim()}`); |
| output += data; |
| if (output.includes(magicString)) { |
| resolve(); |
| } |
| }); |
| waitForOk.stderr.on('data', |
| data => logger.info(`stderr output from wine process: ${data.toString().trim()}`)); |
| waitForOk.on('error', e => { |
| logger.error(`WINE babysitting process exited with ${e}`); |
| reject(e); |
| }); |
| waitForOk.on('close', code => { |
| logger.info(`WINE test exited with code ${code}`); |
| reject(); |
| }); |
| }); |
| } |
| |
| function applyWineEnv(env) { |
| const prefix = execProps("winePrefix"); |
| env = env || {}; |
| // Force use of wine vcruntime (See change 45106c382) |
| env.WINEDLLOVERRIDES = "vcruntime140=b"; |
| env.WINEDEBUG = "-all"; |
| env.WINEPREFIX = prefix; |
| return env; |
| } |
| |
| function needsWine(command) { |
| return command.match(/\.exe$/i) && process.platform === 'linux'; |
| } |
| |
| function executeWineDirect(command, args, options) { |
| options = options || {}; |
| options.env = applyWineEnv(options.env); |
| args.unshift(command); |
| return executeDirect(execProps("wine"), args, options); |
| } |
| |
| function executeFirejail(command, args, options) { |
| options = options || {}; |
| const firejail = execProps("firejail"); |
| const baseOptions = ['--quiet']; |
| if (needsWine(command)) { |
| logger.debug("WINE execution via firejail", command, args); |
| options.env = applyWineEnv(options.env); |
| args.unshift(command); |
| command = execProps("wine"); |
| baseOptions.push('--profile=etc/firejail/wine.profile'); |
| baseOptions.push('--private'); |
| delete options.customCwd; |
| baseOptions.push(command); |
| return executeDirect(firejail, baseOptions.concat(args), options); |
| } |
| |
| logger.debug("Regular execution via firejail", command, args); |
| baseOptions.push('--profile=etc/firejail/execute.profile'); |
| if (options.customCwd) { |
| baseOptions.push(`--private=${options.customCwd}`); |
| args = args.map(opt => opt.replace(options.customCwd, ".")); |
| delete options.customCwd; |
| } else { |
| baseOptions.push('--private'); |
| } |
| baseOptions.push(command); |
| return executeDirect(firejail, baseOptions.concat(args), options); |
| } |
| |
| function executeNone(command, args, options) { |
| if (needsWine(command)) { |
| return executeWineDirect(command, args, options); |
| } |
| return executeDirect(command, args, options); |
| } |
| |
| const executeDispatchTable = { |
| none: executeNone, |
| firejail: executeFirejail |
| }; |
| |
| function execute(command, args, options) { |
| const type = execProps("executionType", "none"); |
| const dispatchEntry = executeDispatchTable[type]; |
| if (!dispatchEntry) |
| return Promise.reject(`Bad sandbox type ${type}`); |
| return dispatchEntry(command, args, options); |
| } |
| |
| module.exports = { |
| execute, |
| sandbox, |
| initialiseWine |
| }; |