319 lines
10 KiB
JavaScript
319 lines
10 KiB
JavaScript
const express = require('express')
|
|
const { spawn } = require('child_process');
|
|
const readline = require('readline');
|
|
const AWS = require('aws-sdk');
|
|
const config = require('./config.js');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { get } = require('http');
|
|
const app = express();
|
|
const port = 3000;
|
|
|
|
// FILE FUNCS
|
|
// Configure S3 client if not using local
|
|
let s3;
|
|
if (config.buckregion !== 'local') { // assuming 'local' means local file system
|
|
s3 = new AWS.S3({
|
|
accessKeyId: config.accessKeyId,
|
|
secretAccessKey: config.secretAccessKey,
|
|
endpoint: config.endpoint,
|
|
s3ForcePathStyle: true, // needed for self-hosted S3 like MinIO or garage
|
|
});
|
|
} else {
|
|
console.log("!! Using local storage for files. This is not recommended for production.")
|
|
}
|
|
|
|
function getFileFromStorage(loc) {
|
|
return new Promise((resolve, reject) => {
|
|
if (config.buckregion === 'local') {
|
|
fs.readFile(path.join("localstorage", loc), { encoding: 'utf-8' }, (err, data) => {
|
|
if (err) return reject(err);
|
|
resolve(data);
|
|
});
|
|
} else {
|
|
// S3 access
|
|
const params = {
|
|
Bucket: config.bucketname,
|
|
Key: loc, // loc is the file key in S3
|
|
};
|
|
s3.getObject(params, (err, data) => {
|
|
if (err) return reject(err);
|
|
resolve(data.Body.toString('utf-8'));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function writeLocalFile(loc, data) {
|
|
return new Promise((resolve, reject) => {
|
|
fs.writeFile(loc, data, (err) => {
|
|
if (err) return reject(err);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
app.get('/', (req, res) => {
|
|
res.send(`<h1>ShittyServerless execution instance.</h1> If you are an end-user, contact site-admin for documentation.<br>
|
|
If you are a developer, deploy your serverless functions by placing files in the garage webui manually.`);
|
|
})
|
|
|
|
const exitCodeLookup = {
|
|
0: 200,
|
|
1: 500,
|
|
2: 403,
|
|
3: 401,
|
|
4: 404,
|
|
} // Lookup table to map exit codes to HTTP status codes
|
|
|
|
function execFileAsync(req,res, command, location, opts = {}) {
|
|
// opts: { timeoutMs: number } optional
|
|
const timeoutMs = opts.timeoutMs || Infinity; // default no timeout
|
|
let killTimer; // to hold timeout timer
|
|
return new Promise((resolve, reject) => {
|
|
let finished = false;
|
|
function onceFinish(err) {
|
|
if (finished) return;
|
|
finished = true;
|
|
try{clearTimeout(killTimer);console.log("Cleared timer")}catch(e){}
|
|
if (err) {
|
|
try { res.status(500).end(); } catch (e) {}
|
|
reject(err);
|
|
} else {
|
|
resolve();
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
// --- prepare temp file (keeps your original logic) ---
|
|
const tempDir = '/tmp';
|
|
const fileName = path.basename(location[0]);
|
|
const tempFilePath = path.join(tempDir, fileName);
|
|
|
|
const contents = await getFileFromStorage(location[0]);
|
|
await writeLocalFile(tempFilePath, contents);
|
|
|
|
// --- spawn process ---
|
|
const child = spawn(command, [tempFilePath]);
|
|
|
|
// make sure we DONT close stdin (we need to send data to the child for it to have reactiveness)
|
|
|
|
child.on('error', (err) => {
|
|
console.error('child process error', err);
|
|
onceFinish(new Error('Failed to spawn child process: ' + err.message));
|
|
});
|
|
|
|
// safety timeout to avoid indefinite hangs
|
|
killTimer = setTimeout(() => {
|
|
if (finished) return;
|
|
console.error(`child timeout after ${timeoutMs}ms — killing pid ${child.pid}`);
|
|
try { child.kill('SIGKILL'); } catch (e) {}
|
|
onceFinish(new Error(`Child process timeout after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
// --- streaming header parser ---
|
|
let isReadingHeaders = true;
|
|
let headerBuf = ''; // string buffer for header parsing
|
|
let headers = { 'Content-Type': 'text/plain' };
|
|
let statusCode = 200;
|
|
|
|
function parseHeadersAndFlush(remainder) {
|
|
const lines = headerBuf.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
// if line looks like "Key: value"
|
|
const idx = line.indexOf(':');
|
|
if (idx > -1) {
|
|
const key = line.slice(0, idx).trim();
|
|
const val = line.slice(idx + 1).trim();
|
|
// treat status header specially if you want
|
|
if (key.toLowerCase() === 'status') {
|
|
const parsed = parseInt(val, 10);
|
|
if (!Number.isNaN(parsed)) statusCode = parsed;
|
|
} else {
|
|
headers[key] = val;
|
|
}
|
|
}
|
|
}
|
|
// Send headers and write remainder
|
|
try {
|
|
res.writeHead(statusCode, headers);
|
|
} catch (e) {
|
|
console.warn('res.writeHead failed', e);
|
|
}
|
|
if (remainder && remainder.length) {
|
|
res.write(remainder);
|
|
}
|
|
}
|
|
|
|
// We will listen on stdout data and look for the token.
|
|
child.stdout.on('data', (chunk) => {
|
|
if (finished) return;
|
|
const str = chunk.toString('utf8');
|
|
|
|
if (isReadingHeaders) {
|
|
headerBuf += str;
|
|
// Look for the token on its own line anywhere in headerBuf
|
|
const token = '\n!!STARTBODY';
|
|
let index = headerBuf.indexOf(token);
|
|
|
|
// also allow token at very start (no preceding newline)
|
|
if (index === -1 && headerBuf.indexOf('!!STARTBODY') === 0) {
|
|
index = 0;
|
|
}
|
|
|
|
if (headerBuf.endsWith("!!RECVDATA")) {
|
|
console.log("Child is requesting data from us...");
|
|
child.stdin.write(JSON.stringify({
|
|
method: req.method,
|
|
headers: req.headers,
|
|
query: req.query,
|
|
params: req.params
|
|
}));
|
|
child.stdin.write("\n"); // newline to signal end of JSON, because child most likely uses input() or readline()
|
|
headerBuf = headerBuf.slice(0, headerBuf.length - "!!RECVDATA".length); // remove the token, to avoid cluttering headerspace
|
|
return; // wait for more data
|
|
}
|
|
|
|
if (index !== -1) {
|
|
// header portion is everything before token (strip a preceding newline)
|
|
let headerPart = headerBuf.slice(0, index);
|
|
// remainder after token
|
|
let remainder = headerBuf.slice(index + token.length);
|
|
// If token was at start without the leading \n, we've included token; handle that case
|
|
if (headerPart.startsWith('\n')) headerPart = headerPart.slice(1);
|
|
|
|
// parse headerPart lines
|
|
headerBuf = headerPart;
|
|
parseHeadersAndFlush(remainder);
|
|
|
|
// switch mode
|
|
isReadingHeaders = false;
|
|
} else {
|
|
// if no token yet, keep buffering — but to avoid unbounded growth, cap buffer size
|
|
const MAX_HEADER_BUF = 64 * 1024; // 64KB
|
|
if (headerBuf.length > MAX_HEADER_BUF) {
|
|
// give up and treat whole buffer as headers
|
|
parseHeadersAndFlush('');
|
|
isReadingHeaders = false;
|
|
}
|
|
}
|
|
} else {
|
|
// already reading body — stream straight to response
|
|
res.write(str);
|
|
}
|
|
});
|
|
|
|
// also forward stderr to console (and optionally to response)
|
|
child.stderr.on('data', (data) => {
|
|
console.error(`stderr: ${data.toString()}`);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
// If we never left header mode, send a helpful message
|
|
if (isReadingHeaders) {
|
|
try {
|
|
res.writeHead(200, headers);
|
|
res.write("No body content. Double check that the child process emits the delimiter `!!STARTBODY` followed by a newline.\n");
|
|
} catch (e) {
|
|
console.warn('writeHead on close failed', e);
|
|
}
|
|
}
|
|
console.log(`child process exited with code ${code}`);
|
|
const mappedStatus = exitCodeLookup[code] || 500;
|
|
console.log(`Mapping exit code ${code} to HTTP status ${mappedStatus}`);
|
|
try { res.status(mappedStatus);
|
|
res.end(); } catch (e) {console.log(e)}
|
|
// Remove temp file
|
|
try {
|
|
fs.unlink(tempFilePath, (err) => {
|
|
if (err) {
|
|
console.warn('Failed to remove temp file', tempFilePath, err);
|
|
} else {
|
|
console.log('Temp file removed', tempFilePath);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.warn('unlink failed', e);
|
|
}
|
|
onceFinish(null,code);
|
|
});
|
|
|
|
} catch (err) {
|
|
onceFinish(err);
|
|
}
|
|
})();
|
|
});
|
|
}
|
|
|
|
|
|
|
|
const lookup = {
|
|
sh: 'bash',
|
|
py: 'python3',
|
|
js: 'node',
|
|
}
|
|
|
|
|
|
|
|
|
|
app.get('/*path', async (req, res) => {
|
|
const filePath = req.path.replace(/^\/+/, ''); // remove leading slash(s)
|
|
console.log(`[request] filePath="${filePath}"`);
|
|
|
|
if (!filePath) {
|
|
return res.status(400).send('No file requested');
|
|
}
|
|
|
|
// default timeout (ms) for execFileAsync; tune as needed
|
|
const execOpts = { timeoutMs: 30_000 };
|
|
|
|
try {
|
|
if (filePath.includes('.')) {
|
|
const ext = filePath.split('.').pop();
|
|
console.log(`[lookup] requested ext="${ext}"`);
|
|
if (lookup[ext]) {
|
|
await execFileAsync(req,res, lookup[ext], [filePath], execOpts);
|
|
return; // response handled by execFileAsync
|
|
} else {
|
|
console.log(`[lookup] no handler for extension "${ext}"`);
|
|
}
|
|
}
|
|
|
|
// Brute-force check (ensure fs checks the correct cwd or use absolute paths)
|
|
let found = false;
|
|
for (const ext in lookup) {
|
|
const candidate = filePath + '.' + ext;
|
|
console.log(`[bruteforce] checking ${candidate}`);
|
|
try{
|
|
await getFileFromStorage(candidate);
|
|
console.log(`[bruteforce] found ${candidate} -> handler ${lookup[ext]}`);
|
|
await execFileAsync(req,res, lookup[ext], [candidate], execOpts);
|
|
found = true;
|
|
break;
|
|
} catch {continue;}
|
|
}
|
|
|
|
if (!found) {
|
|
if (!res.headersSent) {
|
|
res.status(404).send("No matching file found for serverless execution");
|
|
} else {
|
|
res.end();
|
|
}
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.error('[execFileAsync] error:', err && err.stack ? err.stack : err);
|
|
// If execFileAsync already sent headers/body, don't try to send another response
|
|
if (!res.headersSent) {
|
|
res.status(500).send("Serverless function failed to execute");
|
|
} else {
|
|
try { res.end(); } catch (e) {}
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
app.listen(port, () => {
|
|
console.log(`Example app listening on port ${port}`)
|
|
}) |