Skip to content

Instantly share code, notes, and snippets.

@dcollien
Last active February 9, 2017 16:14
Show Gist options
  • Select an option

  • Save dcollien/4c8f80505bf6209c983548270baa2dfb to your computer and use it in GitHub Desktop.

Select an option

Save dcollien/4c8f80505bf6209c983548270baa2dfb to your computer and use it in GitHub Desktop.
HTTP-ify anything: A server which executes commands.
{
"name": "http-exec",
"version": "1.0.0",
"description": "Command Execution Server",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"author": "David Collien",
"license": "ISC",
"dependencies": {
"command-line-usage": "^4.0.0",
"form-data": "^2.1.2",
"minimist": "^1.2.0",
"stream-replace": "^1.0.0",
"temp": "^0.8.3"
}
}
"use strict";
const http = require('http');
const exec = require('child_process').exec;
const argv = require('minimist')(process.argv.slice(2));
const temp = require('temp').track();
const fs = require('fs');
const usage = require('command-line-usage');
const FormData = require('form-data');
const replace = require('stream-replace');
const PORT = argv['p'] || argv['port'] || 7113;
const MIME_TYPE = argv['m'] || argv['mimetype'] || 'text/plain';
const FILE_TYPE = argv['f'] || argv['filetype'];
const NO_MULTI = argv['n'] || argv['nomulti'] || false;
const CMD = argv['c'] || argv['command'];
const INPUT_FILE_EXT = argv['ext'] || argv['i'] || '.txt';
const OUTPUT_FILE_EXT = argv['oext'] || argv['o'] || '.txt';
const NO_LOG = argv['silent'] || argv['s'] || false;
const INPUT_FILE_PATTERN = '{input}';
const OUTPUT_FILE_PATTERN = '{output}';
const CORS_ORIGIN = argv['cors'] || argv['x'];
const CORS = CORS_ORIGIN === true ? '*' : CORS_ORIGIN;
if (!CMD || argv['help']) {
console.log(usage([
{
header: 'Command Server',
content: 'Run a server which responds with the output of a command.'
},
{
header: 'Options',
optionList: [
{
name: 'command',
alias: 'c',
typeLabel: '[underline]{command}',
description: `Command to execute. The {${INPUT_FILE_PATTERN}} keyword is replaced with a filename containing the request body. If not present, request body is sent via stdin. The {${OUTPUT_FILE_PATTERN}} keyword is replaced with a filename of a temporary file which is to contain the response body. The {${OUTPUT_FILE_PATTERN}} keyword can only be used in conjunction with stdout if [bold]{--nomulti} is not set.`
},
{
name: 'port',
alias: 'p',
typeLabel: '[underline]{port}',
description: 'Port to listen on.'
},
{
name: 'silent',
alias: 's',
description: 'Disable logging.'
},
{
name: 'mimetype',
alias: 'm',
typeLabel: '[underline]{mimetype}',
description: 'Mime-Type which the command outputs to stdout (default: text/plain).'
},
{
name: 'filetype',
alias: 'f',
typeLabel: '[underline]{mimetype}',
description: `Mime-Type which the command outputs to {${OUTPUT_FILE_PATTERN}} (default: [bold]{--mimetype}).`
},
{
name: 'ext',
alias: 'i',
typeLabel: '[underline]{extension}',
description: `File extension (default .txt) which is used for the file given to {${INPUT_FILE_PATTERN}}.`
},
{
name: 'oext',
alias: 'o',
typeLabel: '[underline]{extension}',
description: `File extension (default .txt) which is used for the file given to {${OUTPUT_FILE_PATTERN}}.`
},
{
name: 'cors',
alias: 'x',
typeLabel: '[underline]{origin (optional)}',
description: `Enable CORS access headers, origin is '*' if unspecified.`
},
{
name: 'nomulti',
alias: 'n',
description: 'Response Mime-Type will become the same as [bold]{--mimetype} (rather than multipart/form-data). Either stdout will be returned (if no stderr), or stderr will be returned with a status 502'
},
{
name: 'help',
description: 'Print this usage guide.'
}
]
}
]));
process.exit();
}
console.log(`Running server on port ${PORT}`);
console.log(`Command: ${CMD}`);
console.log(`Stdout Mime-Type: ${MIME_TYPE}`);
console.log(`File Mime-Type: ${FILE_TYPE}`);
console.log(`Response Mime-Type: ${NO_MULTI ? 'As above.' : 'multipart/form-data'}`);
const CORS_HEADERS = CORS ? {
'Access-Control-Allow-Origin': CORS,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Expose-Headers': 'Content-Type, X-Return-Code'
} : {};
const operations = {
processRequest(request, response) {
if (request.method === 'OPTIONS') {
const cors_headers = CORS ? {
'Access-Control-Allow-Origin': CORS,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Return-Code'
} : {};
response.writeHead(200, cors_headers);
response.end();
} else if (request.method === 'GET') {
response.writeHead(405, Object.assign({
'Allow': 'POST, OPTIONS'
}, CORS_HEADERS));
response.end('POST requests only.');
} else if (CMD.includes(INPUT_FILE_PATTERN)) {
// Stream the request into a temporary file
const file = temp.createWriteStream({suffix: INPUT_FILE_EXT});
const command = CMD.replace(INPUT_FILE_PATTERN, file.path);
file.on('error', (error) => {
operations.error(response, error);
});
file.on('finish', () => {
// when file has been written
operations.exec(response, command, null, file.path);
});
request.pipe(file);
} else {
operations.exec(response, CMD, request, null);
}
},
exec(response, commandToRun, stdin, filePath) {
let outputPath = null;
if (commandToRun.includes(OUTPUT_FILE_PATTERN)) {
outputPath = temp.path({suffix: OUTPUT_FILE_EXT});
commandToRun = commandToRun.replace(OUTPUT_FILE_PATTERN, outputPath);
}
const child = exec(commandToRun);
const stdinChunks = [];
const stderrChunks = [];
const stdoutChunks = [];
const stdout = child.stdout;
const stderr = child.stderr.pipe(replace(filePath, 'code' + INPUT_FILE_EXT));
if (stdin) {
stdin.on('data', (data) => {
stdinChunks.push(data);
});
}
stdout.on('data', (data) => {
stdoutChunks.push(data);
});
stderr.on('data', (data) => {
stderrChunks.push(data);
});
child.on('error', (error) => {
operations.error(response, error);
});
child.on('close', (code) => {
const stdoutStr = stdoutChunks.join('');
const stderrStr = stderrChunks.join('');
operations.writeResponse(response, code, stdoutStr, stderrStr, outputPath);
// Logging
if (!NO_LOG) {
const stdinStr = stdinChunks.join('');
if (stdinStr) {
console.log('* In', '\n' + stdinStr);
} else {
console.log('* File (in)', filePath);
}
if (stdoutStr) {
console.log('* Stdout', '\n' + stdoutStr);
}
if (stderrStr) {
console.log('* Stderr', '\n' + stderrStr);
}
if (outputPath) {
console.log('* File (out)', outputPath);
}
console.log('*******');
}
});
if (stdin) {
stdin.pipe(child.stdin);
}
},
writeResponse(response, code, stdout, stderr, outputPath) {
if (NO_MULTI) {
if (code !== 0) {
// If the return code is non-zero, return stderr only with a 502
response.writeHead(502, Object.assign({
'Content-Type': 'text/plain',
'X-Return-Code': '' + code
}, CORS_HEADERS));
response.end(stderr);
} else if (outputPath) {
// Use the output file only
response.writeHead(200, Object.assign({
'Content-Type': FILE_TYPE || MIME_TYPE
}, CORS_HEADERS));
fs.createReadStream(outputPath).pipe(response);
} else if (stdout || !stderr) {
// Use stdout only
response.writeHead(200, Object.assign({
'Content-Type': MIME_TYPE
}, CORS_HEADERS));
response.end(stdout);
} else {
// No output file and no stdout, return stderr only with a 502
response.writeHead(502, Object.assign({
'Content-Type': 'text/plain',
'X-Return-Code': '' + code
}, CORS_HEADERS));
response.end(stderr);
}
} else {
const form = new FormData();
form.append('stdout', stdout, {
contentType: MIME_TYPE
});
form.append('stderr', stderr, {
contentType: 'text/plain'
});
if (outputPath) {
form.append('output', fs.createReadStream(outputPath), {
contentType: FILE_TYPE || MIME_TYPE,
filename: 'output' + OUTPUT_FILE_EXT
});
}
response.writeHead(200, Object.assign({
'Content-Type': `multipart/form-data; boundary=${form.getBoundary()}`,
'X-Return-Code': '' + code
}, CORS_HEADERS));
form.pipe(response);
}
},
error(response, error) {
console.log(error);
response.writeHead(500, Object.assign({
'Content-Type': 'text/plain'
}, CORS_HEADERS));
response.end('Error: Unable to execute due to a server issue.');
}
};
http.createServer(operations.processRequest).listen(PORT);
@dcollien
Copy link
Copy Markdown
Author

dcollien commented Feb 7, 2017

Usage (e.g. python execution server):
node server.js --command "python {input}" --ext ".py" --nomulti
or just:
node server.js --command "python" --nomulti
or if the program outputs an image (filename taken as first argument):
node server.js --command "python {input} {output}" --ext ".py" --oext ".png" --filetype "image/png"
will return multipart/form-data response with sections for stderr (text/plain), stdout (text/plain), and output (image/png)

Run some python:

$ curl localhost:7113 --data "print 'hello world'" -i
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Tue, 07 Feb 2017 15:38:00 GMT
Connection: keep-alive
Transfer-Encoding: chunked

hello world

Run some broken python:

$ curl localhost:7113 --data "syntax error" -i
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain
X-Return-Code: 1
Date: Tue, 07 Feb 2017 15:40:17 GMT
Connection: keep-alive
Transfer-Encoding: chunked

  File "code.py", line 1
    syntax error
               ^
SyntaxError: invalid syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment