Import webserver and get it running

This commit is contained in:
Radon Rosborough 2020-12-24 21:49:26 -08:00
parent 967cf770c2
commit d54d0fb5bb
38 changed files with 5377 additions and 20 deletions

View File

@ -4,3 +4,4 @@
**/.terraform
**/build
**/node_modules
**/out

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
.terraform
build
node_modules
out

View File

@ -21,13 +21,13 @@ help:
sed -E 's/[.]PHONY: */ make /' | \
sed -E 's/[#]## *(.+)/\n (\1)\n/'
### Build things locally
### Build artifacts locally
.PHONY: image
image:
@: $${I}
ifeq ($(I),composite)
node src/build-composite-image.js
node tools/build-composite-image.js
else
docker build . -f docker/$(I)/Dockerfile -t riju:$(I) --pull
endif
@ -47,7 +47,7 @@ pkg:
cd $(BUILD)/src && pkg="$(PWD)/$(BUILD)/pkg" ../build.bash
fakeroot dpkg-deb --build $(BUILD)/pkg $(BUILD)/$(DEB)
### Run things inside Docker
### Manipulate artifacts inside Docker
VOLUME_MOUNT ?= $(PWD)
@ -57,7 +57,7 @@ shell:
ifeq ($(I),admin)
docker run -it --rm --hostname $(I) -v $(VOLUME_MOUNT):/src -v /var/run/docker.sock:/var/run/docker.sock -v $(HOME)/.aws:/var/riju/.aws:ro -e AWS_REGION -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e VOLUME_MOUNT=$(VOLUME_MOUNT) --network host riju:$(I)
else
docker run -it --rm --hostname $(I) -v $(VOLUME_MOUNT):/src riju:$(I)
docker run -it --rm --hostname $(I) -v $(VOLUME_MOUNT):/src -p 127.0.0.1:6119:6119 -p 127.0.0.1:6120:6120 riju:$(I)
endif
.PHONY: install
@ -66,7 +66,42 @@ install:
[[ -z "$$(ls -A /var/lib/apt/lists)" ]] && sudo apt update
sudo apt reinstall -y ./$(BUILD)/$(DEB)
### Fetch things from registries
### Build and run application code
.PHONY: frontend
frontend:
npx webpack --mode=production
.PHONY: frontend-dev
frontend-dev:
npx webpack --mode=development --watch
.PHONY: system
system:
./system/compile.bash
.PHONY: system-dev
system-dev:
watchexec -w system/src -n ./system/compile.bash
.PHONY: server
server:
node backend/server.js
.PHONY: server-dev
server-dev:
watchexec -w backend -r -n node backend/server.js
.PHONY: build
build: frontend system
.PHONY: dev
dev:
make -j2 frontend-dev system-dev server-dev
### Run application code
### Fetch artifacts from registries
.PHONY: pull
pull:
@ -80,7 +115,7 @@ download:
mkdir -p $(BUILD)
aws s3 cp $(S3_DEB) $(BUILD)/$(DEB)
### Publish things to registries
### Publish artifacts to registries
.PHONY: push
push:

436
backend/api.js Normal file
View File

@ -0,0 +1,436 @@
import { spawn } from "child_process";
import path from "path";
import WebSocket from "ws";
import pty from "node-pty";
import PQueue from "p-queue";
import rpc from "vscode-jsonrpc";
import { v4 as getUUID } from "uuid";
import { langs } from "./langs.js";
import { borrowUser } from "./users.js";
import * as util from "./util.js";
import { bash } from "./util.js";
const allSessions = new Set();
export class Session {
get homedir() {
return `/tmp/riju/${this.uuid}`;
}
get config() {
return langs[this.lang];
}
get uid() {
return this.uidInfo.uid;
}
returnUID = async () => {
this.uidInfo && (await this.uidInfo.returnUID());
};
get context() {
return { uid: this.uid, uuid: this.uuid };
}
log = (msg) => this.logPrimitive(`[${this.uuid}] ${msg}`);
constructor(ws, lang, log) {
this.ws = ws;
this.uuid = getUUID();
this.lang = lang;
this.tearingDown = false;
this.uidInfo = null;
this.term = null;
this.lsp = null;
this.daemon = null;
this.formatter = null;
this.logPrimitive = log;
this.msgQueue = new PQueue({ concurrency: 1 });
this.log(`Creating session, language ${this.lang}`);
}
run = async (args, options) => {
return await util.run(args, this.log, options);
};
privilegedSetup = () => util.privilegedSetup(this.context);
privilegedSpawn = (args) => util.privilegedSpawn(this.context, args);
privilegedUseradd = () => util.privilegedUseradd(this.uid);
privilegedTeardown = () => util.privilegedTeardown(this.context);
setup = async () => {
try {
allSessions.add(this);
const { uid, returnUID } = await borrowUser(this.log);
this.uidInfo = { uid, returnUID };
this.log(`Borrowed uid ${this.uid}`);
await this.run(this.privilegedSetup());
if (this.config.setup) {
await this.run(this.privilegedSpawn(bash(this.config.setup)));
}
await this.runCode();
if (this.config.daemon) {
const daemonArgs = this.privilegedSpawn(bash(this.config.daemon));
const daemonProc = spawn(daemonArgs[0], daemonArgs.slice(1));
this.daemon = {
proc: daemonProc,
};
for (const stream of [daemonProc.stdout, daemonProc.stderr]) {
stream.on("data", (data) =>
this.send({
event: "serviceLog",
service: "daemon",
output: data.toString("utf8"),
})
);
daemonProc.on("close", (code, signal) =>
this.send({
event: "serviceFailed",
service: "daemon",
error: `Exited with status ${signal || code}`,
})
);
daemonProc.on("error", (err) =>
this.send({
event: "serviceFailed",
service: "daemon",
error: `${err}`,
})
);
}
}
if (this.config.lsp) {
if (this.config.lsp.setup) {
await this.run(this.privilegedSpawn(bash(this.config.lsp.setup)));
}
const lspArgs = this.privilegedSpawn(bash(this.config.lsp.start));
const lspProc = spawn(lspArgs[0], lspArgs.slice(1));
this.lsp = {
proc: lspProc,
reader: new rpc.StreamMessageReader(lspProc.stdout),
writer: new rpc.StreamMessageWriter(lspProc.stdin),
};
this.lsp.reader.listen((data) => {
this.send({ event: "lspOutput", output: data });
});
lspProc.stderr.on("data", (data) =>
this.send({
event: "serviceLog",
service: "lsp",
output: data.toString("utf8"),
})
);
lspProc.on("close", (code, signal) =>
this.send({
event: "serviceFailed",
service: "lsp",
error: `Exited with status ${signal || code}`,
})
);
lspProc.on("error", (err) =>
this.send({ event: "serviceFailed", service: "lsp", error: `${err}` })
);
this.send({ event: "lspStarted", root: this.homedir });
}
this.ws.on("message", (msg) =>
this.msgQueue.add(() => this.receive(msg))
);
this.ws.on("close", async () => {
await this.teardown();
});
this.ws.on("error", async (err) => {
this.log(`Websocket error: ${err}`);
await this.teardown();
});
} catch (err) {
this.log(`Error while setting up environment`);
console.log(err);
this.sendError(err);
await this.teardown();
}
};
send = async (msg) => {
try {
if (this.tearingDown) {
return;
}
this.ws.send(JSON.stringify(msg));
} catch (err) {
this.log(`Failed to send websocket message: ${err}`);
console.log(err);
await this.teardown();
}
};
sendError = async (err) => {
await this.send({ event: "terminalClear" });
await this.send({
event: "terminalOutput",
output: `Riju encountered an unexpected error: ${err}
\r
\rYou may want to save your code and refresh the page.
`,
});
};
logBadMessage = (msg) => {
this.log(`Got malformed message from client: ${JSON.stringify(msg)}`);
};
receive = async (event) => {
try {
if (this.tearingDown) {
return;
}
let msg;
n;
try {
msg = JSON.parse(event);
} catch (err) {
this.log(`Failed to parse message from client: ${event}`);
return;
}
switch (msg && msg.event) {
case "terminalInput":
if (typeof msg.input !== "string") {
this.logBadMessage(msg);
break;
}
if (!this.term) {
this.log("terminalInput ignored because term is null");
break;
}
this.term.pty.write(msg.input);
break;
case "runCode":
if (typeof msg.code !== "string") {
this.logBadMessage(msg);
break;
}
await this.runCode(msg.code);
break;
case "formatCode":
if (typeof msg.code !== "string") {
this.logBadMessage(msg);
break;
}
await this.formatCode(msg.code);
break;
case "lspInput":
if (typeof msg.input !== "object" || !msg) {
this.logBadMessage(msg);
break;
}
if (!this.lsp) {
this.log(`lspInput ignored because lsp is null`);
break;
}
this.lsp.writer.write(msg.input);
break;
case "ensure":
if (!this.config.ensure) {
this.log(`ensure ignored because of missing configuration`);
break;
}
await this.ensure(this.config.ensure);
break;
default:
this.logBadMessage(msg);
break;
}
} catch (err) {
this.log(`Error while handling message from client`);
console.log(err);
this.sendError(err);
}
};
writeCode = async (code) => {
if (this.config.main.includes("/")) {
await this.run(
this.privilegedSpawn([
"mkdir",
"-p",
path.dirname(`${this.homedir}/${this.config.main}`),
])
);
}
await this.run(
this.privilegedSpawn([
"sh",
"-c",
`cat > ${path.resolve(this.homedir, this.config.main)}`,
]),
{ input: code }
);
};
runCode = async (code) => {
try {
const {
name,
repl,
main,
suffix,
createEmpty,
compile,
run,
template,
} = this.config;
if (this.term) {
const pid = this.term.pty.pid;
const args = this.privilegedSpawn(
bash(`kill -SIGTERM ${pid}; sleep 1; kill -SIGKILL ${pid}`)
);
spawn(args[0], args.slice(1));
// Signal to terminalOutput message generator using closure.
this.term.live = false;
this.term = null;
}
this.send({ event: "terminalClear" });
let cmdline;
if (code) {
cmdline = run;
if (compile) {
cmdline = `( ${compile} ) && ( ${run} )`;
}
} else if (repl) {
cmdline = repl;
} else {
cmdline = `echo '${name} has no REPL, press Run to see it in action'`;
}
if (code === undefined) {
code = createEmpty !== undefined ? createEmpty : template;
}
if (code && suffix) {
code += suffix;
}
await this.writeCode(code);
const termArgs = this.privilegedSpawn(bash(cmdline));
const term = {
pty: pty.spawn(termArgs[0], termArgs.slice(1), {
name: "xterm-color",
}),
live: true,
};
this.term = term;
this.term.pty.on("data", (data) => {
// Capture term in closure so that we don't keep sending output
// from the old pty even after it's been killed (see ghci).
if (term.live) {
this.send({ event: "terminalOutput", output: data });
}
});
this.term.pty.on("exit", (code, signal) => {
if (term.live) {
this.send({
event: "serviceFailed",
service: "terminal",
error: `Exited with status ${signal || code}`,
});
}
});
} catch (err) {
this.log(`Error while running user code`);
console.log(err);
this.sendError(err);
}
};
formatCode = async (code) => {
try {
if (!this.config.format) {
this.log("formatCode ignored because format is null");
return;
}
if (this.formatter) {
const pid = this.formatter.proc.pid;
const args = this.privilegedSpawn(
bash(`kill -SIGTERM ${pid}; sleep 1; kill -SIGKILL ${pid}`)
);
spawn(args[0], args.slice(1));
this.formatter.live = false;
this.formatter = null;
}
const args = this.privilegedSpawn(bash(this.config.format.run));
const formatter = {
proc: spawn(args[0], args.slice(1)),
live: true,
input: code,
output: "",
};
formatter.proc.stdin.end(code);
formatter.proc.stdout.on("data", (data) => {
if (!formatter.live) return;
formatter.output += data.toString("utf8");
});
formatter.proc.stderr.on("data", (data) => {
if (!formatter.live) return;
this.send({
event: "serviceLog",
service: "formatter",
output: data.toString("utf8"),
});
});
formatter.proc.on("close", (code, signal) => {
if (!formatter.live) return;
if (code === 0) {
this.send({
event: "formattedCode",
code: formatter.output,
originalCode: formatter.input,
});
} else {
this.send({
event: "serviceFailed",
service: "formatter",
error: `Exited with status ${signal || code}`,
});
}
});
formatter.proc.on("error", (err) => {
if (!formatter.live) return;
this.send({
event: "serviceFailed",
service: "formatter",
error: `${err}`,
});
});
this.formatter = formatter;
} catch (err) {
this.log(`Error while running code formatter`);
console.log(err);
this.sendError(err);
}
};
ensure = async (cmd) => {
const code = await this.run(this.privilegedSpawn(bash(cmd)), {
check: false,
});
this.send({ event: "ensured", code });
};
teardown = async () => {
try {
if (this.tearingDown) {
return;
}
this.log(`Tearing down session`);
this.tearingDown = true;
allSessions.delete(this);
if (this.uidInfo) {
await this.run(this.privilegedTeardown());
await this.returnUID();
}
this.ws.terminate();
} catch (err) {
this.log(`Error during teardown`);
console.log(err);
}
};
}

3
backend/config.js Normal file
View File

@ -0,0 +1,3 @@
import process from "process";
export const PRIVILEGED = process.env.RIJU_PRIVILEGED ? true : false;

2
backend/langs.js Normal file
View File

@ -0,0 +1,2 @@
// TODO
export const langs = {};

110
backend/lsp-repl.js Normal file
View File

@ -0,0 +1,110 @@
import child_process from "child_process";
import process from "process";
import readline from "historic-readline";
import { quote } from "shell-quote";
import rpc from "vscode-jsonrpc";
import { langs } from "./langs";
const args = process.argv.slice(2);
function printUsage() {
console.log(`usage: yarn lsp-repl (LANG | CMDLINE...)`);
}
if (args.length === 0) {
printUsage();
process.exit(1);
}
if (["-h", "-help", "--help", "help"].includes(args[0])) {
printUsage();
process.exit(0);
}
let cmdline;
if (args.length === 1 && langs[args[0]] && langs[args[0]].lsp) {
cmdline = ["bash", "-c", langs[args[0]].lsp.start];
} else {
cmdline = args;
}
console.error(quote(cmdline));
const proc = child_process.spawn(cmdline[0], cmdline.slice(1));
proc.stderr.on("data", (data) => process.stderr.write(data));
proc.on("close", (code, signal) => {
if (code) {
console.error(`Language server exited with code ${code}`);
process.exit(code);
} else {
console.error(`Language server exited due to signal ${signal}`);
process.exit(1);
}
});
proc.on("error", (err) => {
console.error(`Failed to start language server: ${err}`);
process.exit(1);
});
const reader = new rpc.StreamMessageReader(proc.stdout);
const writer = new rpc.StreamMessageWriter(proc.stdin);
reader.listen((data) => {
console.log("<<< " + JSON.stringify(data) + "\n");
});
// https://stackoverflow.com/a/10608048/3538165
function fixStdoutFor(cli) {
var oldStdout = process.stdout;
var newStdout = Object.create(oldStdout);
newStdout.write = function () {
cli.output.write("\x1b[2K\r");
var result = oldStdout.write.apply(
this,
Array.prototype.slice.call(arguments)
);
cli._refreshLine();
return result;
};
process.__defineGetter__("stdout", function () {
return newStdout;
});
}
readline.createInterface({
input: process.stdin,
output: process.stdout,
path: ".lsp-repl-history",
next: (cli) => {
fixStdoutFor(cli);
cli.setPrompt(">>> ");
cli.on("line", (line) => {
if (line) {
let data;
try {
data = JSON.parse(line);
} catch (err) {
console.error(`Invalid JSON: ${err}`);
cli.prompt();
return;
}
console.log();
writer.write(data);
}
cli.prompt();
});
cli.on("SIGINT", () => {
console.error("^C");
cli.write("", { ctrl: true, name: "u" });
cli.prompt();
});
cli.on("close", () => {
console.error();
process.exit(0);
});
console.log();
cli.prompt();
},
});

56
backend/sandbox.js Normal file
View File

@ -0,0 +1,56 @@
import { spawn } from "child_process";
import fs from "fs";
import { v4 as getUUID } from "uuid";
import { langs } from "./langs";
import { MIN_UID, MAX_UID, borrowUser, ignoreUsers } from "./users";
import {
privilegedSetup,
privilegedSpawn,
privilegedTeardown,
run,
} from "./util";
function die(msg) {
console.error(msg);
process.exit(1);
}
function log(msg) {
console.log(msg);
}
async function main() {
const dirs = await new Promise((resolve, reject) =>
fs.readdir("/tmp/riju", (err, dirs) => (err ? reject(err) : resolve(dirs)))
);
const uids = (
await Promise.all(
dirs.map(
(dir) =>
new Promise((resolve, reject) =>
fs.stat(`/tmp/riju/${dir}`, (err, stat) =>
err ? reject(err) : resolve(stat.uid)
)
)
)
)
).filter((uid) => uid >= MIN_UID && uid < MAX_UID);
await ignoreUsers(uids, log);
const uuid = getUUID();
const { uid, returnUID } = await borrowUser(log);
await run(privilegedSetup({ uid, uuid }), log);
const args = privilegedSpawn({ uid, uuid }, ["bash"]);
const proc = spawn(args[0], args.slice(1), {
stdio: "inherit",
});
await new Promise((resolve, reject) => {
proc.on("error", reject);
proc.on("close", resolve);
});
await run(privilegedTeardown({ uid, uuid }), log);
await returnUID();
}
main().catch(die);

113
backend/server.js Normal file
View File

@ -0,0 +1,113 @@
import http from "http";
import https from "https";
import path from "path";
import express from "express";
import ws from "express-ws";
import _ from "lodash";
import * as api from "./api.js";
import { langs } from "./langs.js";
const host = process.env.HOST || "localhost";
const port = parseInt(process.env.PORT || "") || 6119;
const tlsPort = parseInt(process.env.TLS_PORT || "") || 6120;
const useTLS = process.env.TLS ? true : false;
const analyticsEnabled = process.env.ANALYTICS ? true : false;
const app = express();
app.set("query parser", (qs) => new URLSearchParams(qs));
app.set("view engine", "ejs");
app.get("/", (_, res) => {
res.render(path.resolve("frontend/pages/index"), {
langs,
analyticsEnabled,
});
});
for (const [lang, { aliases }] of Object.entries(langs)) {
if (aliases) {
for (const alias of aliases) {
app.get(`/${_.escapeRegExp(alias)}`, (_, res) => {
res.redirect(301, `/${lang}`);
});
}
}
}
app.get("/:lang", (req, res) => {
const lang = req.params.lang;
const lowered = lang.toLowerCase();
if (lowered !== lang) {
res.redirect(301, `/${lowered}`);
} else if (langs[lang]) {
res.render(path.resolve("frontend/pages/app"), {
config: { id: lang, ...langs[lang] },
analyticsEnabled,
});
} else {
res.send(`No such language: ${lang}`);
}
});
app.use("/css", express.static("frontend/styles"));
app.use("/js", express.static("frontend/out"));
function addWebsocket(baseApp, httpsServer) {
const app = ws(baseApp, httpsServer).app;
app.ws("/api/v1/ws", (ws, req) => {
const lang = req.query.get("lang");
if (!lang) {
ws.send(
JSON.stringify({
event: "error",
errorMessage: "No language specified",
})
);
ws.close();
} else if (!langs[lang]) {
ws.send(
JSON.stringify({
event: "error",
errorMessage: `No such language: ${lang}`,
})
);
ws.close();
} else {
new api.Session(ws, lang, console.log).setup();
}
});
return app;
}
if (useTLS) {
const httpsServer = https.createServer(
{
key: Buffer.from(process.env.TLS_PRIVATE_KEY || "", "base64").toString(
"ascii"
),
cert: Buffer.from(process.env.TLS_CERTIFICATE || "", "base64").toString(
"ascii"
),
},
app
);
addWebsocket(app, httpsServer);
httpsServer.listen(tlsPort, host, () =>
console.log(`Listening on https://${host}:${tlsPort}`)
);
const server = http
.createServer((req, res) => {
res.writeHead(301, {
Location: "https://" + req.headers["host"] + req.url,
});
res.end();
})
.listen(port, host, () =>
console.log(`Listening on http://${host}:${port}`)
);
} else {
addWebsocket(app, undefined);
const server = app.listen(port, host, () =>
console.log(`Listening on http://${host}:${port}`)
);
}

746
backend/test-runner.js Normal file
View File

@ -0,0 +1,746 @@
import fs from "fs";
import process from "process";
import { promisify } from "util";
import _ from "lodash";
import { Moment } from "moment";
import moment from "moment";
import PQueue from "p-queue";
import rimraf from "rimraf";
import stripAnsi from "strip-ansi";
import { v4 as getUUID } from "uuid";
import api from "./api";
import { LangConfig, langs } from "./langs";
function parseIntOr(thing, def) {
const num = parseInt(thing);
return Number.isNaN(num) ? def : num;
}
const TIMEOUT_FACTOR = parseIntOr(process.env.TIMEOUT_FACTOR, 1);
const CONCURRENCY = parseIntOr(process.env.CONCURRENCY, 2);
const BASE_TIMEOUT_SECS = 5;
function findPosition(str, idx) {
const lines = str.substring(0, idx).split("\n");
const line = lines.length - 1;
const character = lines[lines.length - 1].length;
return { line, character };
}
async function sendInput(send, input) {
for (const line of input.split("\n")) {
if (line === "EOF") {
send({ event: "terminalInput", input: "\u0004" });
} else if (line.startsWith("DELAY:")) {
const delay = parseFloat(line.replace(/DELAY: */, ""));
if (Number.isNaN(delay)) continue;
await new Promise((resolve) =>
setTimeout(resolve, delay * 1000 * TIMEOUT_FACTOR)
);
} else {
send({ event: "terminalInput", input: line + "\r" });
}
}
}
class Test {
get config() {
return langs[this.lang];
}
record = (msg) => {
const dur = moment.duration(moment().diff(this.startTime));
this.messages.push({ time: dur.asSeconds(), ...msg });
};
send = (msg) => {
this.ws.onMessage(JSON.stringify(msg));
this.record(msg);
this.handledMessages += 1;
};
constructor(lang, type) {
this.lang = lang;
this.type = type;
this.messages = [];
this.timedOut = false;
this.handledMessages = 0;
this.handleUpdate = () => {};
this.startTime = null;
this.ws = null;
}
getLog = (opts) => {
opts = opts || {};
return this.messages
.map((msg) => JSON.stringify(msg, null, opts.pretty && 2))
.join("\n");
};
run = async () => {
if ((this.config.skip || []).includes(this.type)) {
return "skipped";
}
this.startTime = moment();
let session = null;
let timeout = null;
try {
const that = this;
this.ws = {
on: function (type, handler) {
switch (type) {
case "message":
this.onMessage = handler;
for (const msg of this.messageQueue) {
this.onMessage(msg);
}
this.messageQueue = [];
break;
case "close":
case "error":
// No need to clean up, we'll call teardown() explicitly.
break;
default:
throw new Error(`unexpected websocket handler type: ${type}`);
}
},
onMessage: function (msg) {
this.messageQueue.push(msg);
},
messageQueue: [],
send: function (data) {
that.record(JSON.parse(data));
that.handleUpdate();
},
terminate: function () {},
};
session = new api.Session(this.ws, this.lang, (msg) => {
this.record({ event: "serverLog", message: msg });
});
timeout = setTimeout(() => {
this.timedOut = true;
this.handleUpdate();
}, (this.config.timeout || BASE_TIMEOUT_SECS) * 1000 * TIMEOUT_FACTOR);
await session.setup();
switch (this.type) {
case "ensure":
await this.testEnsure();
break;
case "run":
await this.testRun();
break;
case "repl":
await this.testRepl();
break;
case "runrepl":
await this.testRunRepl();
break;
case "scope":
await this.testScope();
break;
case "format":
await this.testFormat();
break;
case "lsp":
await this.testLsp();
break;
default:
throw new Error(`Unexpected test type: ${this.type}`);
}
} finally {
this.ws = null;
if (timeout) {
clearTimeout(timeout);
}
if (session) {
await session.teardown();
}
}
};
wait = async (desc, handler) => {
return await new Promise((resolve, reject) => {
this.handleUpdate = () => {
if (this.timedOut) {
reject(new Error(`timeout while waiting for ${desc}`));
} else {
while (this.handledMessages < this.messages.length) {
const msg = this.messages[this.handledMessages];
const result = handler(msg);
if (![undefined, null, false].includes(result)) {
resolve(result);
}
this.handledMessages += 1;
}
}
};
this.handleUpdate();
});
};
waitForOutput = async (pattern, maxLength) => {
pattern = pattern.replace(/\n/g, "\r\n");
let output = "";
return await this.wait(`output ${JSON.stringify(pattern)}`, (msg) => {
const prevLength = output.length;
if (msg.event === "terminalOutput") {
// Applying stripAnsi here is wrong because escape sequences
// could be split across multiple messages. Who cares?
output += stripAnsi(msg.output);
}
if (typeof maxLength === "number") {
return (
output
.substring(prevLength - maxLength)
.match(new RegExp(pattern)) !== null
);
} else {
return output.indexOf(pattern, prevLength - pattern.length) != -1;
}
});
};
testEnsure = async () => {
this.send({ event: "ensure" });
const code = await this.wait("ensure response", (msg) => {
if (msg.event === "ensured") {
return msg.code;
}
});
if (code !== 0) {
throw new Error(`ensure failed with code ${code}`);
}
};
testRun = async () => {
const pattern = this.config.hello || "Hello, world!";
this.send({ event: "runCode", code: this.config.template });
if (this.config.helloInput !== undefined) {
sendInput(this.send, this.config.helloInput);
}
await this.waitForOutput(pattern, this.config.helloMaxLength);
};
testRepl = async () => {
const input = this.config.input || "123 * 234";
const output = this.config.output || "28782";
sendInput(this.send, input);
await this.waitForOutput(output);
};
testRunRepl = async () => {
const input = this.config.runReplInput || this.config.input || "123 * 234";
const output = this.config.runReplOutput || this.config.output || "28782";
this.send({ event: "runCode", code: this.config.template });
sendInput(this.send, input);
await this.waitForOutput(output);
};
testScope = async () => {
const code = this.config.scope.code;
const after = this.config.scope.after;
const input = this.config.scope.input || "x";
const output = this.config.scope.output || "28782";
let allCode = this.config.template;
if (!allCode.endsWith("\n")) {
allCode += "\n";
}
if (after) {
allCode = allCode.replace(after + "\n", after + "\n" + code + "\n");
} else {
allCode = allCode + code + "\n";
}
this.send({ event: "runCode", code: allCode });
sendInput(this.send, input);
await this.waitForOutput(output);
};
testFormat = async () => {
const input = this.config.format.input;
const output = this.config.format.output || this.config.template;
this.send({ event: "formatCode", code: input });
const result = await this.wait("formatter response", (msg) => {
if (msg.event === "formattedCode") {
return msg.code;
}
});
if (output !== result) {
throw new Error("formatted code did not match");
}
};
testLsp = async () => {
const insertedCode = this.config.lsp.code;
const after = this.config.lsp.after;
const item = this.config.lsp.item;
const idx = after
? this.config.template.indexOf(after) + after.length
: this.config.template.length;
const code =
this.config.template.slice(0, idx) +
insertedCode +
this.config.template.slice(idx);
const root = await this.wait("lspStarted message", (msg) => {
if (msg.event === "lspStarted") {
return msg.root;
}
});
this.send({
event: "lspInput",
input: {
jsonrpc: "2.0",
id: "0d75333a-47d8-4da8-8030-c81d7bd9eed7",
method: "initialize",
params: {
processId: null,
clientInfo: { name: "vscode" },
rootPath: root,
rootUri: `file://${root}`,
capabilities: {
workspace: {
applyEdit: true,
workspaceEdit: {
documentChanges: true,
resourceOperations: ["create", "rename", "delete"],
failureHandling: "textOnlyTransactional",
},
didChangeConfiguration: { dynamicRegistration: true },
didChangeWatchedFiles: { dynamicRegistration: true },
symbol: {
dynamicRegistration: true,
symbolKind: {
valueSet: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
],
},
},
executeCommand: { dynamicRegistration: true },
configuration: true,
workspaceFolders: true,
},
textDocument: {
publishDiagnostics: {
relatedInformation: true,
versionSupport: false,
tagSupport: { valueSet: [1, 2] },
},
synchronization: {
dynamicRegistration: true,
willSave: true,
willSaveWaitUntil: true,
didSave: true,
},
completion: {
dynamicRegistration: true,
contextSupport: true,
completionItem: {
snippetSupport: true,
commitCharactersSupport: true,
documentationFormat: ["markdown", "plaintext"],
deprecatedSupport: true,
preselectSupport: true,
tagSupport: { valueSet: [1] },
},
completionItemKind: {
valueSet: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
],
},
},
hover: {
dynamicRegistration: true,
contentFormat: ["markdown", "plaintext"],
},
signatureHelp: {
dynamicRegistration: true,
signatureInformation: {
documentationFormat: ["markdown", "plaintext"],
parameterInformation: { labelOffsetSupport: true },
},
contextSupport: true,
},
definition: { dynamicRegistration: true, linkSupport: true },
references: { dynamicRegistration: true },
documentHighlight: { dynamicRegistration: true },
documentSymbol: {
dynamicRegistration: true,
symbolKind: {
valueSet: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
],
},
hierarchicalDocumentSymbolSupport: true,
},
codeAction: {
dynamicRegistration: true,
isPreferredSupport: true,
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [
"",
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
],
},
},
},
codeLens: { dynamicRegistration: true },
formatting: { dynamicRegistration: true },
rangeFormatting: { dynamicRegistration: true },
onTypeFormatting: { dynamicRegistration: true },
rename: { dynamicRegistration: true, prepareSupport: true },
documentLink: { dynamicRegistration: true, tooltipSupport: true },
typeDefinition: { dynamicRegistration: true, linkSupport: true },
implementation: { dynamicRegistration: true, linkSupport: true },
colorProvider: { dynamicRegistration: true },
foldingRange: {
dynamicRegistration: true,
rangeLimit: 5000,
lineFoldingOnly: true,
},
declaration: { dynamicRegistration: true, linkSupport: true },
},
},
initializationOptions: this.config.lsp.init || {},
trace: "off",
workspaceFolders: [
{
uri: `file://${root}`,
name: `file://${root}`,
},
],
},
},
});
await this.wait("response to lsp initialize", (msg) => {
return (
msg.event === "lspOutput" &&
msg.output.id === "0d75333a-47d8-4da8-8030-c81d7bd9eed7"
);
});
this.send({
event: "lspInput",
input: { jsonrpc: "2.0", method: "initialized", params: {} },
});
this.send({
event: "lspInput",
input: {
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: {
textDocument: {
uri: `file://${root}/${this.config.main}`,
languageId:
this.config.lsp.lang || this.config.monacoLang || "plaintext",
version: 1,
text: code,
},
},
},
});
this.send({
event: "lspInput",
input: {
jsonrpc: "2.0",
id: "ecdb8a55-f755-4553-ae8e-91d6ebbc2045",
method: "textDocument/completion",
params: {
textDocument: {
uri: `file://${root}/${this.config.main}`,
},
position: findPosition(code, idx + insertedCode.length),
context: { triggerKind: 1 },
},
},
});
const items = await this.wait(
"response to lsp completion request",
(msg) => {
if (msg.event === "lspOutput") {
if (msg.output.method === "workspace/configuration") {
this.send({
event: "lspInput",
input: {
jsonrpc: "2.0",
id: msg.output.id,
result: Array(msg.output.params.items.length).fill(
this.config.lsp.config == undefined
? this.config.lsp.config
: {}
),
},
});
} else if (msg.output.id === "ecdb8a55-f755-4553-ae8e-91d6ebbc2045") {
return msg.output.result.items || msg.output.result;
}
}
}
);
if (!(items && items.filter(({ label }) => label === item).length > 0)) {
throw new Error("completion item did not appear");
}
};
}
function lint(lang) {
const config = langs[lang];
if (!config.template.endsWith("\n")) {
throw new Error("template is missing a trailing newline");
}
// These can be removed when the types are adjusted to make these
// situations impossible.
if (
config.format &&
!config.format.input &&
!(config.skip || []).includes("format")
) {
throw new Error("formatter is missing test");
}
if (
config.lsp &&
!(config.lsp.code && config.lsp.item) &&
!(config.skip || []).includes("lsp")
) {
throw new Error("LSP is missing test");
}
}
const testTypes = {
ensure: {
pred: ({ ensure }) => (ensure ? true : false),
},
run: { pred: (config) => true },
repl: {
pred: ({ repl }) => (repl ? true : false),
},
runrepl: {
pred: ({ repl }) => (repl ? true : false),
},
scope: {
pred: ({ scope }) => (scope ? true : false),
},
format: {
pred: ({ format }) => (format ? true : false),
},
lsp: { pred: ({ lsp }) => (lsp ? true : false) },
};
function getTestList() {
const tests = [];
for (const [id, cfg] of Object.entries(langs)) {
for (const [type, { pred }] of Object.entries(testTypes)) {
if (pred(cfg)) {
tests.push({ lang: id, type });
}
}
}
return tests;
}
async function writeLog(lang, type, result, log) {
log = `${result.toUpperCase()}: ${lang}/${type}\n` + log;
await promisify(fs.mkdir)(`tests/${lang}`, { recursive: true });
await promisify(fs.writeFile)(`tests/${lang}/${type}.log`, log);
await promisify(fs.mkdir)(`tests-run/${lang}`, { recursive: true });
await promisify(fs.symlink)(
`../../tests/${lang}/${type}.log`,
`tests-run/${lang}/${type}.log`
);
await promisify(fs.mkdir)(`tests-${result}/${lang}`, { recursive: true });
await promisify(fs.symlink)(
`../../tests/${lang}/${type}.log`,
`tests-${result}/${lang}/${type}.log`
);
}
async function main() {
let tests = getTestList();
const args = process.argv.slice(2);
for (const arg of args) {
tests = tests.filter(
({ lang, type }) =>
arg
.split(",")
.filter((arg) =>
[lang, type].concat(langs[lang].aliases || []).includes(arg)
).length > 0
);
}
if (tests.length === 0) {
console.error("no tests selected");
process.exit(1);
}
console.error(`Running ${tests.length} test${tests.length !== 1 ? "s" : ""}`);
const lintSeen = new Set();
let lintPassed = new Set();
let lintFailed = new Map();
for (const { lang } of tests) {
if (!lintSeen.has(lang)) {
lintSeen.add(lang);
try {
lint(lang);
lintPassed.add(lang);
} catch (err) {
lintFailed.set(lang, err);
}
}
}
if (lintFailed.size > 0) {
console.error(
`Language${lintFailed.size !== 1 ? "s" : ""} failed linting:`
);
console.error(
Array.from(lintFailed)
.map(([lang, err]) => ` - ${lang} (${err})`)
.join("\n")
);
process.exit(1);
}
await promisify(rimraf)("tests-run");
await promisify(rimraf)("tests-passed");
await promisify(rimraf)("tests-skipped");
await promisify(rimraf)("tests-failed");
const queue = new PQueue({ concurrency: CONCURRENCY });
let passed = new Set();
let skipped = new Set();
let failed = new Map();
for (const { lang, type } of tests) {
queue.add(async () => {
const test = new Test(lang, type);
let err;
try {
err = await test.run();
} catch (error) {
err = error;
}
if (err === "skipped") {
skipped.add({ lang, type });
console.error(`SKIPPED: ${lang}/${type}`);
await writeLog(lang, type, "skipped", "");
} else if (!err) {
passed.add({ lang, type });
console.error(`PASSED: ${lang}/${type}`);
await writeLog(
lang,
type,
"passed",
test.getLog({ pretty: true }) + "\n"
);
} else {
failed.set({ lang, type }, err);
console.error(`FAILED: ${lang}/${type}`);
console.error(test.getLog());
console.error(err);
await writeLog(
lang,
type,
"failed",
test.getLog({ pretty: true }) +
"\n" +
(err.stack ? err.stack + "\n" : err ? `${err}` : "")
);
}
});
}
await queue.onIdle();
console.error();
console.error(
"================================================================================"
);
console.error();
if (passed.size > 0) {
console.error(`${passed.size} test${passed.size !== 1 ? "s" : ""} PASSED`);
}
if (skipped.size > 0) {
console.error(
`${skipped.size} test${skipped.size !== 1 ? "s" : ""} SKIPPED`
);
}
if (failed.size > 0) {
console.error(`${failed.size} test${failed.size !== 1 ? "s" : ""} FAILED`);
_.sortBy(Array.from(failed), [
([{ lang }, _]) => lang,
([{ type }, _]) => type,
]).forEach(([{ lang, type }, err]) =>
console.error(` - ${lang}/${type} (${err})`)
);
}
process.exit(failed.size > 0 ? 1 : 0);
}
main().catch(console.error);

96
backend/users.js Normal file
View File

@ -0,0 +1,96 @@
import { spawn } from "child_process";
import fs from "fs";
import os from "os";
import AsyncLock from "async-lock";
import _ from "lodash";
import parsePasswd from "parse-passwd";
import { PRIVILEGED } from "./config.js";
import { privilegedUseradd, run } from "./util.js";
// Keep in sync with system/src/riju-system-privileged.c
export const MIN_UID = 2000;
export const MAX_UID = 65000;
const CUR_UID = os.userInfo().uid;
let availIds = null;
let nextId = null;
let lock = new AsyncLock();
async function readExistingUsers(log) {
availIds = parsePasswd(
await new Promise((resolve, reject) =>
fs.readFile("/etc/passwd", "utf-8", (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
})
)
)
.filter(({ username }) => username.startsWith("riju"))
.map(({ uid }) => parseInt(uid))
.filter((uid) => !isNaN(uid) && uid >= MIN_UID && uid < MAX_UID)
.reverse();
nextId = (_.max(availIds) || MIN_UID - 1) + 1;
log(`Found ${availIds.length} existing users, next is riju${nextId}`);
}
async function createUser(log) {
if (nextId >= MAX_UID) {
throw new Error("too many users");
}
const uid = nextId;
await run(privilegedUseradd(uid), log);
log(`Created new user with ID ${uid}`);
nextId += 1;
return uid;
}
export async function ignoreUsers(uids, log) {
await lock.acquire("key", async () => {
if (availIds === null || nextId === null) {
await readExistingUsers(log);
}
const uidSet = new Set(uids);
if (uidSet.size > 0) {
const plural = uidSet.size !== 1 ? "s" : "";
log(
`Ignoring user${plural} from open session${plural}: ${Array.from(uidSet)
.sort()
.map((uid) => `riju${uid}`)
.join(", ")}`
);
}
availIds = availIds.filter((uid) => !uidSet.has(uid));
});
}
export async function borrowUser(log) {
if (!PRIVILEGED) {
return { uid: CUR_UID, returnUID: async () => {} };
} else {
return await lock.acquire("key", async () => {
if (availIds === null || nextId === null) {
await readExistingUsers(log);
}
let uid;
if (availIds.length > 0) {
uid = availIds.pop();
} else {
uid = await createUser(log);
}
return {
uid,
returnUID: async () => {
await lock.acquire("key", () => {
availIds.push(uid);
});
},
};
});
}
}

124
backend/util.js Normal file
View File

@ -0,0 +1,124 @@
import { spawn, spawnSync } from "child_process";
import os from "os";
import process from "process";
import { quote } from "shell-quote";
import { PRIVILEGED } from "./config.js";
import { MIN_UID, MAX_UID } from "./users.js";
export const rijuSystemPrivileged = "system/out/riju-system-privileged";
const rubyVersion = (() => {
try {
return spawnSync("ruby", ["-e", "puts RUBY_VERSION"])
.stdout.toString()
.trim();
} catch (err) {
return null;
}
})();
function getEnv({ uid, uuid }) {
const cwd = `/tmp/riju/${uuid}`;
const path = [
rubyVersion && `${cwd}/.gem/ruby/${rubyVersion}/bin`,
`${cwd}/.local/bin`,
`${cwd}/node_modules/.bin`,
`/usr/local/sbin`,
`/usr/local/bin`,
`/usr/sbin`,
`/usr/bin`,
`/bin`,
].filter((x) => x);
const username =
uid >= MIN_UID && uid < MAX_UID ? `riju${uid}` : os.userInfo().username;
return {
HOME: cwd,
HOSTNAME: "riju",
LANG: process.env.LANG || "",
LC_ALL: process.env.LC_ALL || "",
LOGNAME: username,
PATH: PRIVILEGED ? path.join(":") : process.env.PATH || "",
PWD: cwd,
SHELL: "/usr/bin/bash",
TERM: "xterm-256color",
TMPDIR: `${cwd}`,
USER: username,
USERNAME: username,
};
}
function getEnvString(ctx) {
return Object.entries(getEnv(ctx))
.map(([key, val]) => `${key}=${quote([val])}`)
.join(" ");
}
export async function run(args, log, options) {
options = options || {};
const input = options.input;
const check = options.check === undefined ? true : options.check;
delete options.input;
delete options.check;
const proc = spawn(args[0], args.slice(1), options);
if (typeof input === "string") {
proc.stdin.end(input);
}
let output = "";
proc.stdout.on("data", (data) => {
output += `${data}`;
});
proc.stderr.on("data", (data) => {
output += `${data}`;
});
return await new Promise((resolve, reject) => {
proc.on("error", reject);
proc.on("close", (code, signal) => {
output = output.trim();
if (output) {
log(`Output from ${args[0]}:\n` + output);
}
if (code === 0 || !check) {
resolve(code);
} else {
reject(`command ${args[0]} failed with error code ${signal || code}`);
}
});
});
}
export function privilegedUseradd(uid) {
return [rijuSystemPrivileged, "useradd", `${uid}`];
}
export function privilegedSetup({ uid, uuid }) {
return [rijuSystemPrivileged, "setup", `${uid}`, uuid];
}
export function privilegedSpawn(ctx, args) {
const { uid, uuid } = ctx;
return [
rijuSystemPrivileged,
"spawn",
`${uid}`,
uuid,
"sh",
"-c",
`exec env -i ${getEnvString(ctx)} "$@"`,
"--",
].concat(args);
}
export function privilegedTeardown({ uid, uuid }) {
return [rijuSystemPrivileged, "teardown", `${uid}`, uuid];
}
export function bash(cmdline) {
if (!cmdline.match(/[;|&(){}=]/)) {
// Reduce number of subshells we generate, if we're just running a
// single command (no shell logic).
cmdline = "exec " + cmdline;
}
return ["bash", "-c", cmdline];
}

View File

@ -4,6 +4,6 @@ COPY docker/admin/install.bash /tmp/
RUN /tmp/install.bash
WORKDIR /src
COPY docker/admin/pid1.bash /usr/local/sbin/
ENTRYPOINT ["/usr/local/sbin/pid1.bash"]
COPY docker/shared/my_init docker/admin/pid1.bash /usr/local/sbin/
ENTRYPOINT ["/usr/local/sbin/my_init", "/usr/local/sbin/pid1.bash"]
CMD ["bash"]

View File

@ -27,7 +27,7 @@ deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) sta
EOF
apt-get update
apt-get install -y docker-ce-cli less make man nodejs sudo unzip wget yarn
apt-get install -y docker-ce-cli less make man nodejs sudo tmux unzip wget yarn
wget https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -O awscli.zip
unzip awscli.zip

View File

@ -3,7 +3,7 @@
set -euo pipefail
groupadd -g "$(stat -c %g "$PWD")" -o -p '!' -r riju
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -m -N -l -s /usr/bin/bash -G sudo riju
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -p '!' -m -N -l -s /usr/bin/bash -G sudo riju
runuser -u riju -- touch /home/riju/.sudo_as_admin_successful
runuser -u riju -- ln -sT /var/riju/.aws /home/riju/.aws

12
docker/app/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM riju:compile AS compile
FROM riju:composite
RUN useradd -p '!' -m -l -s /usr/bin/bash riju
WORKDIR /src
ENV RIJU_PRIVILEGED=1
COPY --chown=riju:riju --from=compile . ./
RUN chown root:riju system/out/*-privileged && chmod a=,g=rx,u=rwxs system/out/*-privileged
USER riju

29
docker/app/install.bash Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euxo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get dist-upgrade -y
apt-get install -y curl gnupg lsb-release
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add -
curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
ubuntu_ver="$(lsb_release -rs)"
ubuntu_name="$(lsb_release -cs)"
node_repo="$(curl -sS https://deb.nodesource.com/setup_current.x | grep NODEREPO= | grep -Eo 'node_[0-9]+\.x' | head -n1)"
tee -a /etc/apt/sources.list.d/custom.list >/dev/null <<EOF
deb [arch=amd64] https://deb.nodesource.com/${node_repo} ${ubuntu_name} main
deb [arch=amd64] https://dl.yarnpkg.com/debian/ stable main
EOF
apt-get update
apt-get install -y make nodejs yarn
rm -rf /var/lib/apt/lists/*
rm "$0"

21
docker/compile/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM ubuntu:rolling AS build
COPY docker/runtime/install.bash /tmp/
RUN /tmp/install.bash
WORKDIR /src
ENV RIJU_PRIVILEGED=1
COPY package.json yarn.lock ./
RUN yarn install
COPY webpack.config.cjs ./
COPY frontend/src ./frontend/src/
RUN make frontend
COPY system ./system/
RUN make system
COPY frontend/pages ./frontend/pages/
COPY frontend/styles ./frontend/styles/
COPY backend ./backend/

View File

@ -4,6 +4,6 @@ COPY docker/packaging/install.bash /tmp/
RUN /tmp/install.bash
WORKDIR /src
COPY docker/packaging/pid1.bash /usr/local/sbin/
ENTRYPOINT ["/usr/local/sbin/pid1.bash"]
COPY docker/shared/my_init docker/packaging/pid1.bash /usr/local/sbin/
ENTRYPOINT ["/usr/local/sbin/my_init", "/usr/local/sbin/pid1.bash"]
CMD ["bash"]

View File

@ -3,7 +3,7 @@
set -euo pipefail
groupadd -g "$(stat -c %g "$PWD")" -o -p '!' -r riju
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -m -N -l -s /usr/bin/bash -G sudo riju
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -p '!' -m -N -l -s /usr/bin/bash -G sudo riju
runuser -u riju touch /home/riju/.sudo_as_admin_successful
runuser -u riju -- yarn install

View File

@ -4,6 +4,8 @@ COPY docker/runtime/install.bash /tmp/
RUN /tmp/install.bash
WORKDIR /src
COPY docker/runtime/pid1.bash /usr/local/sbin/
ENTRYPOINT ["/usr/local/sbin/pid1.bash"]
COPY docker/shared/my_init docker/runtime/pid1.bash /usr/local/sbin/
ENTRYPOINT ["/usr/local/sbin/my_init", "/usr/local/sbin/pid1.bash"]
CMD ["bash"]
EXPOSE 6119
EXPOSE 6120

View File

@ -2,6 +2,12 @@
set -euxo pipefail
latest_release() {
curl -sSL "https://api.github.com/repos/$1/releases/latest" | jq -r .tag_name
}
pushd /tmp
export DEBIAN_FRONTEND=noninteractive
apt-get update
@ -28,7 +34,12 @@ apt-get install -y dctrl-tools
libicu="$(grep-aptavail -wF Package 'libicu[0-9]+' -s Package -n | head -n1)"
apt-get update
apt-get install -y less "${libicu}" make man nodejs sudo yarn
apt-get install -y less clang jq "${libicu}" make man nodejs sudo tmux wget yarn
ver="$(latest_release watchexec/watchexec)"
wget "https://github.com/watchexec/watchexec/releases/download/${ver}/watchexec-${ver}-x86_64-unknown-linux-gnu.deb"
apt-get install -y ./watchexec-*.deb
rm watchexec-*.deb
rm -rf /var/lib/apt/lists/*
@ -36,4 +47,6 @@ tee /etc/sudoers.d/90-riju >/dev/null <<"EOF"
%sudo ALL=(ALL:ALL) NOPASSWD: ALL
EOF
popd
rm "$0"

View File

@ -3,7 +3,7 @@
set -euo pipefail
groupadd -g "$(stat -c %g "$PWD")" -o -p '!' -r riju
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -m -N -l -s /usr/bin/bash -G sudo riju
useradd -u "$(stat -c %u "$PWD")" -g "$(stat -c %g "$PWD")" -o -p '!' -m -N -l -s /usr/bin/bash -G sudo riju
runuser -u riju touch /home/riju/.sudo_as_admin_successful
runuser -u riju -- yarn install

424
docker/shared/my_init Executable file
View File

@ -0,0 +1,424 @@
#!/usr/bin/python3 -u
# -*- coding: utf-8 -*-
#
# From https://github.com/phusion/baseimage-docker/blob/5078b027ba58cce8887acb5c1add0bb8d56f5d38/image/bin/my_init
# Copyright 2013-2015 Phusion Holding B.V. under MIT License
# See https://github.com/phusion/baseimage-docker/blob/5078b027ba58cce8887acb5c1add0bb8d56f5d38/LICENSE.txt
import argparse
import errno
import json
import os
import os.path
import re
import signal
import stat
import sys
import time
ENV_INIT_DIRECTORY = os.environ.get('ENV_INIT_DIRECTORY', '/etc/my_init.d')
KILL_PROCESS_TIMEOUT = int(os.environ.get('KILL_PROCESS_TIMEOUT', 30))
KILL_ALL_PROCESSES_TIMEOUT = int(os.environ.get('KILL_ALL_PROCESSES_TIMEOUT', 30))
LOG_LEVEL_ERROR = 1
LOG_LEVEL_WARN = 1
LOG_LEVEL_INFO = 2
LOG_LEVEL_DEBUG = 3
SHENV_NAME_WHITELIST_REGEX = re.compile('\W')
log_level = None
terminated_child_processes = {}
_find_unsafe = re.compile(r'[^\w@%+=:,./-]').search
class AlarmException(Exception):
pass
def error(message):
if log_level >= LOG_LEVEL_ERROR:
sys.stderr.write("*** %s\n" % message)
def warn(message):
if log_level >= LOG_LEVEL_WARN:
sys.stderr.write("*** %s\n" % message)
def info(message):
if log_level >= LOG_LEVEL_INFO:
sys.stderr.write("*** %s\n" % message)
def debug(message):
if log_level >= LOG_LEVEL_DEBUG:
sys.stderr.write("*** %s\n" % message)
def ignore_signals_and_raise_keyboard_interrupt(signame):
signal.signal(signal.SIGTERM, signal.SIG_IGN)
signal.signal(signal.SIGINT, signal.SIG_IGN)
raise KeyboardInterrupt(signame)
def raise_alarm_exception():
raise AlarmException('Alarm')
def listdir(path):
try:
result = os.stat(path)
except OSError:
return []
if stat.S_ISDIR(result.st_mode):
return sorted(os.listdir(path))
else:
return []
def is_exe(path):
try:
return os.path.isfile(path) and os.access(path, os.X_OK)
except OSError:
return False
def import_envvars(clear_existing_environment=True, override_existing_environment=True):
if not os.path.exists("/etc/container_environment"):
return
new_env = {}
for envfile in listdir("/etc/container_environment"):
name = os.path.basename(envfile)
with open("/etc/container_environment/" + envfile, "r") as f:
# Text files often end with a trailing newline, which we
# don't want to include in the env variable value. See
# https://github.com/phusion/baseimage-docker/pull/49
value = re.sub('\n\Z', '', f.read())
new_env[name] = value
if clear_existing_environment:
os.environ.clear()
for name, value in new_env.items():
if override_existing_environment or name not in os.environ:
os.environ[name] = value
def export_envvars(to_dir=True):
if not os.path.exists("/etc/container_environment"):
return
shell_dump = ""
for name, value in os.environ.items():
if name in ['HOME', 'USER', 'GROUP', 'UID', 'GID', 'SHELL']:
continue
if to_dir:
with open("/etc/container_environment/" + name, "w") as f:
f.write(value)
shell_dump += "export " + sanitize_shenvname(name) + "=" + shquote(value) + "\n"
with open("/etc/container_environment.sh", "w") as f:
f.write(shell_dump)
with open("/etc/container_environment.json", "w") as f:
f.write(json.dumps(dict(os.environ)))
def shquote(s):
"""Return a shell-escaped version of the string *s*."""
if not s:
return "''"
if _find_unsafe(s) is None:
return s
# use single quotes, and put single quotes into double quotes
# the string $'b is then quoted as '$'"'"'b'
return "'" + s.replace("'", "'\"'\"'") + "'"
def sanitize_shenvname(s):
"""Return string with [0-9a-zA-Z_] characters"""
return re.sub(SHENV_NAME_WHITELIST_REGEX, "_", s)
# Waits for the child process with the given PID, while at the same time
# reaping any other child processes that have exited (e.g. adopted child
# processes that have terminated).
def waitpid_reap_other_children(pid):
global terminated_child_processes
status = terminated_child_processes.get(pid)
if status:
# A previous call to waitpid_reap_other_children(),
# with an argument not equal to the current argument,
# already waited for this process. Return the status
# that was obtained back then.
del terminated_child_processes[pid]
return status
done = False
status = None
while not done:
try:
# https://github.com/phusion/baseimage-docker/issues/151#issuecomment-92660569
this_pid, status = os.waitpid(pid, os.WNOHANG)
if this_pid == 0:
this_pid, status = os.waitpid(-1, 0)
if this_pid == pid:
done = True
else:
# Save status for later.
terminated_child_processes[this_pid] = status
except OSError as e:
if e.errno == errno.ECHILD or e.errno == errno.ESRCH:
return None
else:
raise
return status
def stop_child_process(name, pid, signo=signal.SIGTERM, time_limit=KILL_PROCESS_TIMEOUT):
info("Shutting down %s (PID %d)..." % (name, pid))
try:
os.kill(pid, signo)
except OSError:
pass
signal.alarm(time_limit)
try:
try:
waitpid_reap_other_children(pid)
except OSError:
pass
except AlarmException:
warn("%s (PID %d) did not shut down in time. Forcing it to exit." % (name, pid))
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
try:
waitpid_reap_other_children(pid)
except OSError:
pass
finally:
signal.alarm(0)
def run_command_killable(*argv):
filename = argv[0]
status = None
pid = os.spawnvp(os.P_NOWAIT, filename, argv)
try:
status = waitpid_reap_other_children(pid)
except BaseException:
warn("An error occurred. Aborting.")
stop_child_process(filename, pid)
raise
if status != 0:
if status is None:
error("%s exited with unknown status\n" % filename)
else:
error("%s failed with status %d\n" % (filename, os.WEXITSTATUS(status)))
sys.exit(1)
def run_command_killable_and_import_envvars(*argv):
run_command_killable(*argv)
import_envvars()
export_envvars(False)
def kill_all_processes(time_limit):
info("Killing all processes...")
try:
os.kill(-1, signal.SIGTERM)
except OSError:
pass
signal.alarm(time_limit)
try:
# Wait until no more child processes exist.
done = False
while not done:
try:
os.waitpid(-1, 0)
except OSError as e:
if e.errno == errno.ECHILD:
done = True
else:
raise
except AlarmException:
warn("Not all processes have exited in time. Forcing them to exit.")
try:
os.kill(-1, signal.SIGKILL)
except OSError:
pass
finally:
signal.alarm(0)
def run_startup_files():
# Run ENV_INIT_DIRECTORY/*
for name in listdir(ENV_INIT_DIRECTORY):
filename = os.path.join(ENV_INIT_DIRECTORY, name)
if is_exe(filename):
info("Running %s..." % filename)
run_command_killable_and_import_envvars(filename)
# Run /etc/rc.local.
if is_exe("/etc/rc.local"):
info("Running /etc/rc.local...")
run_command_killable_and_import_envvars("/etc/rc.local")
def run_pre_shutdown_scripts():
debug("Running pre-shutdown scripts...")
# Run /etc/my_init.pre_shutdown.d/*
for name in listdir("/etc/my_init.pre_shutdown.d"):
filename = "/etc/my_init.pre_shutdown.d/" + name
if is_exe(filename):
info("Running %s..." % filename)
run_command_killable(filename)
def run_post_shutdown_scripts():
debug("Running post-shutdown scripts...")
# Run /etc/my_init.post_shutdown.d/*
for name in listdir("/etc/my_init.post_shutdown.d"):
filename = "/etc/my_init.post_shutdown.d/" + name
if is_exe(filename):
info("Running %s..." % filename)
run_command_killable(filename)
def start_runit():
info("Booting runit daemon...")
pid = os.spawnl(os.P_NOWAIT, "/usr/bin/runsvdir", "/usr/bin/runsvdir",
"-P", "/etc/service")
info("Runit started as PID %d" % pid)
return pid
def wait_for_runit_or_interrupt(pid):
status = waitpid_reap_other_children(pid)
return (True, status)
def shutdown_runit_services(quiet=False):
if not quiet:
debug("Begin shutting down runit services...")
os.system("/usr/bin/sv -w %d force-stop /etc/service/* > /dev/null" % KILL_PROCESS_TIMEOUT)
def wait_for_runit_services():
debug("Waiting for runit services to exit...")
done = False
while not done:
done = os.system("/usr/bin/sv status /etc/service/* | grep -q '^run:'") != 0
if not done:
time.sleep(0.1)
# According to https://github.com/phusion/baseimage-docker/issues/315
# there is a bug or race condition in Runit, causing it
# not to shutdown services that are already being started.
# So during shutdown we repeatedly instruct Runit to shutdown
# services.
shutdown_runit_services(True)
def install_insecure_key():
info("Installing insecure SSH key for user root")
run_command_killable("/usr/sbin/enable_insecure_key")
def main(args):
import_envvars(False, False)
export_envvars()
if args.enable_insecure_key:
install_insecure_key()
if not args.skip_startup_files:
run_startup_files()
runit_exited = False
exit_code = None
if not args.skip_runit:
runit_pid = start_runit()
try:
exit_status = None
if len(args.main_command) == 0:
runit_exited, exit_code = wait_for_runit_or_interrupt(runit_pid)
if runit_exited:
if exit_code is None:
info("Runit exited with unknown status")
exit_status = 1
else:
exit_status = os.WEXITSTATUS(exit_code)
info("Runit exited with status %d" % exit_status)
else:
info("Running %s..." % " ".join(args.main_command))
pid = os.spawnvp(os.P_NOWAIT, args.main_command[0], args.main_command)
try:
exit_code = waitpid_reap_other_children(pid)
if exit_code is None:
info("%s exited with unknown status." % args.main_command[0])
exit_status = 1
else:
exit_status = os.WEXITSTATUS(exit_code)
info("%s exited with status %d." % (args.main_command[0], exit_status))
except KeyboardInterrupt:
stop_child_process(args.main_command[0], pid)
raise
except BaseException:
warn("An error occurred. Aborting.")
stop_child_process(args.main_command[0], pid)
raise
sys.exit(exit_status)
finally:
if not args.skip_runit:
run_pre_shutdown_scripts()
shutdown_runit_services()
if not runit_exited:
stop_child_process("runit daemon", runit_pid)
wait_for_runit_services()
run_post_shutdown_scripts()
# Parse options.
parser = argparse.ArgumentParser(description='Initialize the system.')
parser.add_argument('main_command', metavar='MAIN_COMMAND', type=str, nargs='*',
help='The main command to run. (default: runit)')
parser.add_argument('--enable-insecure-key', dest='enable_insecure_key',
action='store_const', const=True, default=False,
help='Install the insecure SSH key')
parser.add_argument('--skip-startup-files', dest='skip_startup_files',
action='store_const', const=True, default=False,
help='Skip running /etc/my_init.d/* and /etc/rc.local')
parser.add_argument('--skip-runit', dest='skip_runit',
action='store_const', const=True, default=False,
help='Do not run runit services')
parser.add_argument('--no-kill-all-on-exit', dest='kill_all_on_exit',
action='store_const', const=False, default=True,
help='Don\'t kill all processes on the system upon exiting')
parser.add_argument('--quiet', dest='log_level',
action='store_const', const=LOG_LEVEL_WARN, default=LOG_LEVEL_INFO,
help='Only print warnings and errors')
args = parser.parse_args()
log_level = args.log_level
if args.skip_runit and len(args.main_command) == 0:
error("When --skip-runit is given, you must also pass a main command.")
sys.exit(1)
# Run main function.
signal.signal(signal.SIGTERM, lambda signum, frame: ignore_signals_and_raise_keyboard_interrupt('SIGTERM'))
signal.signal(signal.SIGINT, lambda signum, frame: ignore_signals_and_raise_keyboard_interrupt('SIGINT'))
signal.signal(signal.SIGALRM, lambda signum, frame: raise_alarm_exception())
try:
main(args)
except KeyboardInterrupt:
warn("Init system aborted.")
exit(2)
finally:
if args.kill_all_on_exit:
kill_all_processes(KILL_ALL_PROCESSES_TIMEOUT)

30
frontend/pages/app.ejs Normal file
View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title><%= config.name %> - Riju</title>
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/css/app.css" />
</head>
<body>
<div id="app">
<div id="editor" class="column"></div>
<div id="terminal" class="column"></div>
<button type="button" class="btn btn-success" id="runButton">Run</button>
<button type="button" class="btn btn-info" id="formatButton">Prettify</button>
<a href="/" class="btn btn-secondary" id="backButton">Switch to a different language</a>
</div>
<script>
window.rijuConfig = <%- JSON.stringify(config) %>;
</script>
<script src="/js/app.js"></script>
<% if (analyticsEnabled) { %>
<script src="https://cdn.usefathom.com/script.js" site="JOBZEHJE" defer></script>
<% } %>
</body>
</html>

33
frontend/pages/index.ejs Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Riju</title>
<link rel="stylesheet" href="/css/index.css" />
</head>
<body>
<h1>Riju: <i>fast</i> online playground for every programming language</h1>
<i>Pick your favorite language to get started:</i>
<div class="grid">
<% for (const [id, {name}] of Object.entries(langs).sort(
([id1, {name: name1}], [id2, {name: name2}]) => name1.toLowerCase().localeCompare(name2.toLowerCase()))) { %>
<a href=<%= "/" + encodeURIComponent(id) %> class="language">
<div class="language">
<%= name %>
</div>
</a>
<% } %>
</div>
<p>
<i>
Created by
<a href="https://github.com/raxod502">Radon Rosborough</a>.
Check out the project
<a href="https://github.com/raxod502/riju">on GitHub</a>.
</i>
</p>
<% if (analyticsEnabled) { %>
<script src="https://cdn.usefathom.com/script.js" site="JOBZEHJE" defer></script>
<% } %>
</body>
</html>

309
frontend/src/app.js Normal file
View File

@ -0,0 +1,309 @@
import monaco from "monaco-editor";
import {
createConnection,
MonacoLanguageClient,
MonacoServices,
Services,
} from "monaco-languageclient";
import { Disposable } from "vscode";
import { createMessageConnection } from "vscode-jsonrpc";
import {
AbstractMessageReader,
DataCallback,
} from "vscode-jsonrpc/lib/messageReader.js";
import { AbstractMessageWriter } from "vscode-jsonrpc/lib/messageWriter.js";
import { Message } from "vscode-jsonrpc/lib/messages.js";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import "xterm/css/xterm.css";
const DEBUG = window.location.hash === "#debug";
const config = window.rijuConfig;
class RijuMessageReader extends AbstractMessageReader {
constructor(socketSocket) {
super();
this.state = "initial";
this.callback = null;
this.messageQueue = [];
this.socket = socket;
this.socket.addEventListener("message", (event) => {
this.readMessage(event.data);
});
}
listen(callback) {
if (this.state === "initial") {
this.state = "listening";
this.callback = callback;
while (this.messageQueue.length > 0) {
this.readMessage(this.messageQueue.pop());
}
}
}
readMessage(rawMessage) {
if (this.state === "initial") {
this.messageQueue.splice(0, 0, rawMessage);
} else if (this.state === "listening") {
let message;
try {
message = JSON.parse(rawMessage);
} catch (err) {
return;
}
switch (message && message.event) {
case "lspOutput":
if (DEBUG) {
console.log("RECEIVE LSP:", message.output);
}
this.callback(message.output);
break;
}
}
}
}
class RijuMessageWriter extends AbstractMessageWriter {
constructor(socket) {
super();
this.socket = socket;
}
write(msg) {
switch (msg.method) {
case "initialize":
msg.params.processId = null;
if (config.lsp.disableDynamicRegistration) {
this.disableDynamicRegistration(msg);
}
break;
case "textDocument/didOpen":
if (config.lsp.lang) {
msg.params.textDocument.languageId = config.lsp.lang;
}
}
if (DEBUG) {
console.log("SEND LSP:", msg);
}
this.socket.send(JSON.stringify({ event: "lspInput", input: msg }));
}
disableDynamicRegistration(msg) {
if (!msg || typeof msg !== "object") return;
for (const [key, val] of Object.entries(msg)) {
if (key === "dynamicRegistration" && val === true)
msg.dynamicRegistration = false;
this.disableDynamicRegistration(val);
}
}
}
async function main() {
const term = new Terminal();
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
window.addEventListener("resize", () => fitAddon.fit());
await new Promise((resolve) =>
term.write("Connecting to server...", resolve)
);
const initialRetryDelayMs = 200;
let retryDelayMs = initialRetryDelayMs;
function sendMessage(message) {
if (DEBUG) {
console.log("SEND:", message);
}
if (socket) {
socket.send(JSON.stringify(message));
}
}
function tryConnect() {
let clientDisposable = null;
let servicesDisposable = null;
const serviceLogBuffers = {};
console.log("Connecting to server...");
socket = new WebSocket(
(document.location.protocol === "http:" ? "ws://" : "wss://") +
document.location.host +
`/api/v1/ws?lang=${encodeURIComponent(config.id)}`
);
socket.addEventListener("open", () => {
console.log("Successfully connected to server");
});
socket.addEventListener("message", (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (err) {
console.error("Malformed message from server:", event.data);
return;
}
if (
DEBUG &&
message &&
message.event !== "lspOutput" &&
message.event !== "serviceLog"
) {
console.log("RECEIVE:", message);
}
if (message && message.event && message.event !== "error") {
retryDelayMs = initialRetryDelayMs;
}
switch (message && message.event) {
case "terminalClear":
term.reset();
return;
case "terminalOutput":
if (typeof message.output !== "string") {
console.error("Unexpected message from server:", message);
return;
}
term.write(message.output);
return;
case "formattedCode":
if (
typeof message.code !== "string" ||
typeof message.originalCode !== "string"
) {
console.error("Unexpected message from server:", message);
return;
}
if (editor.getValue() === message.originalCode) {
editor.setValue(message.code);
}
return;
case "lspStarted":
if (typeof message.root !== "string") {
console.error("Unexpected message from server:", message);
return;
}
const services = MonacoServices.create(editor, {
rootUri: `file://${message.root}`,
});
servicesDisposable = Services.install(services);
editor.setModel(
monaco.editor.createModel(
editor.getModel().getValue(),
undefined,
monaco.Uri.parse(`file://${message.root}/${config.main}`)
)
);
const connection = createMessageConnection(
new RijuMessageReader(socket),
new RijuMessageWriter(socket)
);
const client = new MonacoLanguageClient({
name: "Riju",
clientOptions: {
documentSelector: [{ pattern: "**" }],
middleware: {
workspace: {
configuration: (params, token, configuration) => {
return Array(configuration(params, token).length).fill(
config.lsp.config !== undefined ? config.lsp.config : {}
);
},
},
},
initializationOptions: config.lsp.init || {},
},
connectionProvider: {
get: (errorHandler, closeHandler) =>
Promise.resolve(
createConnection(connection, errorHandler, closeHandler)
),
},
});
clientDisposable = client.start();
return;
case "lspOutput":
// Should be handled by RijuMessageReader
return;
case "serviceLog":
if (
typeof message.service !== "string" ||
typeof message.output !== "string"
) {
console.error("Unexpected message from server:", message);
return;
}
if (DEBUG) {
let buffer = serviceLogBuffers[message.service] || "";
buffer += message.output;
while (buffer.includes("\n")) {
const idx = buffer.indexOf("\n");
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
console.log(`${message.service.toUpperCase()} || ${line}`);
}
serviceLogBuffers[message.service] = buffer;
}
return;
case "serviceCrashed":
return;
default:
console.error("Unexpected message from server:", message);
return;
}
});
socket.addEventListener("close", (event) => {
if (event.wasClean) {
console.log("Connection closed cleanly");
} else {
console.error("Connection died");
}
if (clientDisposable) {
clientDisposable.dispose();
clientDisposable = null;
}
if (servicesDisposable) {
servicesDisposable.dispose();
servicesDisposable = null;
}
scheduleConnect();
});
}
function scheduleConnect() {
const delay = retryDelayMs * Math.random();
console.log(`Trying to reconnect in ${Math.floor(delay)}ms`);
setTimeout(tryConnect, delay);
retryDelayMs *= 2;
}
let socket = null;
tryConnect();
term.onData((data) => sendMessage({ event: "terminalInput", input: data }));
const editor = monaco.editor.create(document.getElementById("editor"), {
minimap: { enabled: false },
scrollbar: { verticalScrollbarSize: 0 },
});
window.addEventListener("resize", () => editor.layout());
editor.getModel().setValue(config.template);
monaco.editor.setModelLanguage(
editor.getModel(),
config.monacoLang || "plaintext"
);
document.getElementById("runButton").addEventListener("click", () => {
sendMessage({ event: "runCode", code: editor.getValue() });
});
if (config.format) {
document.getElementById("formatButton").classList.add("visible");
document.getElementById("formatButton").addEventListener("click", () => {
sendMessage({ event: "formatCode", code: editor.getValue() });
});
}
}
main().catch(console.error);

45
frontend/styles/app.css Normal file
View File

@ -0,0 +1,45 @@
body {
margin: 0;
height: 100vh;
}
#app {
height: 100%;
}
.column {
width: 50%;
height: 100%;
float: left;
}
#editor {
overflow: hidden;
}
#terminal {
background: black;
}
#runButton {
position: absolute;
top: 25px;
right: calc(50% + 25px);
}
#formatButton {
position: absolute;
bottom: 25px;
right: calc(50% + 25px);
visibility: hidden;
}
#formatButton.visible {
visibility: visible;
}
#backButton {
position: absolute;
left: 25px;
bottom: 25px;
}

33
frontend/styles/index.css Normal file
View File

@ -0,0 +1,33 @@
body {
display: flex;
flex-direction: column;
align-items: center;
font-family: sans-serif;
text-align: center;
padding-bottom: 20px;
}
.grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
margin-top: 20px;
}
div.language {
width: 140px;
height: 60px;
border: solid;
margin: 5px;
padding: 5px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
font-size: 18px;
}
a.language {
text-decoration: none;
color: black;
}

View File

@ -5,8 +5,31 @@
"private": true,
"type": "module",
"dependencies": {
"@babel/core": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"async-lock": "^1.2.6",
"babel-loader": "^8.2.2",
"commander": "^6.2.1",
"css-loader": "^5.0.1",
"ejs": "^3.1.5",
"express": "^4.17.1",
"express-ws": "^4.0.0",
"file-loader": "^6.2.0",
"lodash": "^4.17.20",
"monaco-editor": "^0.21.2",
"monaco-editor-webpack-plugin": "^2.1.0",
"monaco-languageclient": "^0.13.0",
"node-pty": "^0.9.0",
"p-queue": "^6.6.2",
"parse-passwd": "^1.0.0",
"shell-quote": "^1.7.2",
"style-loader": "^2.0.0",
"uuid": "^8.3.2",
"vscode-languageserver-protocol": "3.15.3",
"webpack": "^5.11.0",
"webpack-cli": "^4.2.0",
"xterm": "^4.9.0",
"xterm-addon-fit": "^0.4.0",
"yaml": "^1.10.0"
}
}

25
system/compile.bash Executable file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -d system/src ]]; then
echo "compile.bash: no system/src directory" >&2
exit 1
fi
function verbosely {
echo "$@"
"$@"
}
mkdir -p system/out
rm -f system/out/*
for src in system/src/*.c; do
out="${src/src/out}"
out="${out/.c}"
verbosely clang -Wall -Wextra -Werror -std=c11 "${src}" -o "${out}"
if [[ "${out}" == *-privileged && -n "${RIJU_PRIVILEGED:-}" ]]; then
sudo chown root:riju "${out}"
sudo chmod a=,g=rx,u=rwxs "${out}"
fi
done

View File

@ -0,0 +1,178 @@
#define _GNU_SOURCE
#include <errno.h>
#include <grp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
// Keep in sync with backend/src/users.ts
const int MIN_UID = 2000;
const int MAX_UID = 65000;
int privileged;
void __attribute__ ((noreturn)) die(char *msg)
{
fprintf(stderr, "%s\n", msg);
exit(1);
}
void die_with_usage()
{
die("usage:\n"
" riju-system-privileged useradd UID\n"
" riju-system-privileged setup UID UUID\n"
" riju-system-privileged spawn UID UUID CMDLINE...\n"
" riju-system-privileged teardown UID UUID");
}
int parseUID(char *str)
{
if (!privileged)
return -1;
char *endptr;
long uid = strtol(str, &endptr, 10);
if (!*str || *endptr)
die("uid must be an integer");
if (uid < MIN_UID || uid >= MAX_UID)
die("uid is out of range");
return uid;
}
char *parseUUID(char *uuid)
{
if (!*uuid)
die("illegal uuid");
for (char *ptr = uuid; *ptr; ++ptr)
if (!((*ptr >= 'a' && *ptr <= 'z') || (*ptr >= '0' && *ptr <= '9') || *ptr == '-'))
die("illegal uuid");
return uuid;
}
void useradd(int uid)
{
if (!privileged)
die("useradd not allowed without root privileges");
char *cmdline;
if (asprintf(&cmdline, "groupadd -g %1$d riju%1$d", uid) < 0)
die("asprintf failed");
int status = system(cmdline);
if (status != 0)
die("groupadd failed");
if (asprintf(&cmdline, "useradd -M -N -l -r -u %1$d -g %1$d -p '!' -s /usr/bin/bash riju%1$d", uid) < 0)
die("asprintf failed");
status = system(cmdline);
if (status != 0)
die("useradd failed");
}
void spawn(int uid, char *uuid, char **cmdline)
{
char *cwd;
if (asprintf(&cwd, "/tmp/riju/%s", uuid) < 0)
die("asprintf failed");
if (chdir(cwd) < 0)
die("chdir failed");
if (privileged) {
if (setgid(uid) < 0)
die("setgid failed");
if (setgroups(0, NULL) < 0)
die("setgroups failed");
if (setuid(uid) < 0)
die("setuid failed");
}
umask(077);
execvp(cmdline[0], cmdline);
die("execvp failed");
}
void setup(int uid, char *uuid)
{
char *cmdline;
if (asprintf(&cmdline, privileged
? "install -d -o riju%1$d -g riju%1$d -m 700 /tmp/riju/%2$s"
: "install -d -m 700 /tmp/riju/%2$s", uid, uuid) < 0)
die("asprintf failed");
int status = system(cmdline);
if (status != 0)
die("install failed");
}
void teardown(int uid, char *uuid)
{
char *cmdline;
int status;
char *users;
if (uid >= MIN_UID && uid < MAX_UID) {
if (asprintf(&users, "%d", uid) < 0)
die("asprintf failed");
} else {
cmdline = "getent passwd | grep -Eo '^riju[0-9]{4}' | paste -s -d, - | tr -d '\n'";
FILE *fp = popen(cmdline, "r");
if (fp == NULL)
die("popen failed");
static char buf[(MAX_UID - MIN_UID) * 9];
if (fgets(buf, sizeof(buf), fp) == NULL) {
if (feof(fp))
users = NULL;
else {
die("fgets failed");
}
} else
users = buf;
}
if (users != NULL) {
if (asprintf(&cmdline, "while pkill -9 --uid %1$s; do sleep 0.01; done", users) < 0)
die("asprintf failed");
status = system(cmdline);
if (status != 0 && status != 256)
die("pkill failed");
}
if (asprintf(&cmdline, "rm -rf /tmp/riju/%s", uuid) < 0)
die("asprintf failed");
status = system(cmdline);
if (status != 0)
die("rm failed");
}
int main(int argc, char **argv)
{
int code = setuid(0);
if (code != 0 && code != -EPERM)
die("setuid failed");
privileged = code == 0;
if (argc < 2)
die_with_usage();
if (!strcmp(argv[1], "useradd")) {
if (argc != 3)
die_with_usage();
useradd(parseUID(argv[2]));
return 0;
}
if (!strcmp(argv[1], "spawn")) {
if (argc < 5)
die_with_usage();
spawn(parseUID(argv[2]), parseUUID(argv[3]), &argv[4]);
return 0;
}
if (!strcmp(argv[1], "setup")) {
if (argc != 4)
die_with_usage();
int uid = parseUID(argv[2]);
char *uuid = parseUUID(argv[3]);
setup(uid, uuid);
return 0;
}
if (!strcmp(argv[1], "teardown")) {
if (argc != 4)
die_with_usage();
int uid = strcmp(argv[2], "*") ? parseUID(argv[2]) : -1;
char *uuid = strcmp(argv[3], "*") ? parseUUID(argv[3]) : "*";
teardown(uid, uuid);
return 0;
}
die_with_usage();
}

55
webpack.config.cjs Normal file
View File

@ -0,0 +1,55 @@
const path = require("path");
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
function isProduction(argv) {
return !argv.development;
}
module.exports = (_, argv) => ({
devtool: isProduction(argv) ? undefined : "source-map",
entry: "./frontend/src/app.js",
mode: isProduction(argv) ? "production" : "development",
module: {
rules: [
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.ttf$/,
use: ["file-loader"],
},
{
test: /\.js$/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
include: /vscode-jsonrpc/,
},
],
},
output: {
path: path.resolve(__dirname, "frontend/out"),
publicPath: "/js/",
filename: "app.js",
},
performance: {
hints: false,
},
plugins: [new MonacoWebpackPlugin()],
resolve: {
alias: {
vscode: require.resolve("monaco-languageclient/lib/vscode-compatibility"),
},
fallback: {
crypto: false,
net: false,
os: false,
path: false,
},
},
});

2408
yarn.lock

File diff suppressed because it is too large Load Diff