Automatically allocate one uid per session

This commit is contained in:
Radon Rosborough 2020-06-22 12:35:38 -06:00
parent e9ff1d92d3
commit afad563d56
10 changed files with 291 additions and 27 deletions

View File

@ -38,8 +38,7 @@ EXPOSE 6120
ENTRYPOINT ["/usr/local/bin/pid1.bash"]
COPY scripts/pid1.bash /usr/local/bin/
RUN sudo deluser docker sudo
CMD ["yarn", "run", "server"]
RUN mkdir /tmp/riju
COPY --chown=docker:docker package.json yarn.lock /tmp/riju/
@ -49,8 +48,10 @@ COPY --chown=docker:docker frontend /tmp/riju/frontend
RUN cd /tmp/riju && yarn run frontend
COPY --chown=docker:docker backend /tmp/riju/backend
RUN cd /tmp/riju && yarn run backend
COPY --chown=docker:docker system /tmp/riju/system
RUN cd /tmp/riju && RIJU_PRIVILEGED=1 yarn run system
COPY --chown=docker:docker . /home/docker/src
RUN cp -R /tmp/riju/* /home/docker/src/ && rm -rf /tmp/riju
WORKDIR /home/docker/src
CMD ["yarn", "run", "server"]
RUN sudo deluser docker sudo

View File

@ -20,6 +20,7 @@ usual to install dependencies. For production, it's:
$ yarn backend
$ yarn frontend
$ yarn system
$ yarn server
For development with file watching and automatic server rebooting and
@ -27,15 +28,18 @@ all that, it's:
$ yarn backend-dev
$ yarn frontend-dev
$ yarn system-dev
$ yarn server-dev
The webserver listens on `localhost:6119`. Now, although the server
itself will work, the only languages that will work are the ones that
happen to be installed on your machine. (I'm sure you can find a few
that are already.) If you want to test with *all* the languages (or
you're working on adding a new language), then you need to use Docker.
Running the app is exactly the same as before, you just have to jump
into the container first:
that are already.) Also, sandboxing using UNIX filesystem permissions
will be disabled, because that requires root privileges. If you want
to test with *all* the languages plus sandboxing (or you're working on
adding a new language), then you need to use Docker. Running the app
is exactly the same as before, you just have to jump into the
container first:
$ make docker

View File

@ -1,64 +1,88 @@
import * as fs from "fs";
import * as mkdirp from "mkdirp";
import * as pty from "node-pty";
import { IPty } from "node-pty";
import * as path from "path";
import * as tmp from "tmp";
import * as WebSocket from "ws";
import * as mkdirp from "mkdirp";
import * as nodeCleanup from "node-cleanup";
import * as pty from "node-pty";
import { IPty } from "node-pty";
import * as tmp from "tmp";
import { v4 as getUUID } from "uuid";
import { LangConfig, langs } from "./langs";
import { borrowUser } from "./users";
export class Session {
id: string;
code: string;
config: LangConfig;
term: { pty: IPty | null; live: boolean };
ws: WebSocket;
tmpdir: string | null;
tmpdirCleanup: (() => void) | null;
uid: number | null;
uidCleanup: (() => Promise<void>) | null;
log = (msg: string) => console.log(`[${this.id}] ${msg}`);
constructor(ws: WebSocket, lang: string) {
this.id = getUUID();
this.log(`Creating session, language ${lang}`);
this.ws = ws;
this.config = langs[lang];
this.term = { pty: null, live: false };
this.code = "";
this.tmpdir = null;
this.tmpdirCleanup = null;
this.uid = null;
this.uidCleanup = null;
ws.on("message", this.handleClientMessage);
ws.on("close", this.cleanup);
this.run().catch(console.error);
ws.on("close", () =>
this.cleanup().catch((err) =>
this.log(`Error during session cleanup: ${err}`)
)
);
nodeCleanup();
this.run().catch((err) => this.log(`Error while running: ${err}`));
}
handleClientMessage = (event: string) => {
let msg: any;
try {
msg = JSON.parse(event);
} catch (err) {
console.error(`failed to parse client message: ${msg}`);
this.log(`Failed to parse client message: ${msg}`);
return;
}
switch (msg?.event) {
case "terminalInput":
if (!this.term) {
console.error(`terminalInput: no terminal`);
this.log(`Got terminal input before pty was started`);
} else if (typeof msg.input !== "string") {
console.error(`terminalInput: missing or malformed input field`);
this.log(`Got malformed terminal input message`);
} else {
this.term.pty!.write(msg.input);
}
break;
case "runCode":
if (typeof msg.code !== "string") {
console.error(`runCode: missing or malformed code field`);
this.log(`Got malformed run message`);
} else {
this.code = msg.code;
this.run();
}
break;
default:
console.error(`unknown client message type: ${msg.event}`);
this.log(`Got unknown message type: ${msg.event}`);
break;
}
};
run = async () => {
if (this.uid === null) {
({ uid: this.uid, cleanup: this.uidCleanup } = await borrowUser(
this.log
));
}
this.log(`Borrowed uid ${this.uid}`);
const { name, repl, main, suffix, compile, run, hacks } = this.config;
if (this.term.pty) {
this.term.pty.kill();
@ -72,13 +96,16 @@ export class Session {
if (this.tmpdir == null) {
({ path: this.tmpdir, cleanup: this.tmpdirCleanup } = await new Promise(
(resolve, reject) =>
tmp.dir({ unsafeCleanup: true }, (err, path, cleanup) => {
if (err) {
reject(err);
} else {
resolve({ path, cleanup });
tmp.dir(
{ unsafeCleanup: true, dir: "riju" },
(err, path, cleanup) => {
if (err) {
reject(err);
} else {
resolve({ path, cleanup });
}
}
})
)
));
}
let cmdline: string;
@ -157,9 +184,13 @@ export class Session {
}
});
};
cleanup = () => {
cleanup = async () => {
this.log(`Cleaning up session`);
if (this.tmpdirCleanup) {
this.tmpdirCleanup();
}
if (this.uidCleanup) {
await this.uidCleanup();
}
};
}

100
backend/src/users.ts Normal file
View File

@ -0,0 +1,100 @@
import { spawn } from "child_process";
import * as fs from "fs";
import * as process from "process";
import * as AsyncLock from "async-lock";
import * as _ from "lodash";
import * as parsePasswd from "parse-passwd";
// Keep in sync with system/src/riju-system-privileged.c
const MIN_UID = 2000;
const MAX_UID = 65000;
const PRIVILEGED = process.env.RIJU_PRIVILEGED ? true : false;
const CUR_UID = parseInt(process.env.UID || "") || null;
let availIds: number[] | null = null;
let nextId: number | null = null;
let lock = new AsyncLock();
async function readExistingUsers(log: (msg: string) => void) {
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_user"))
.map(({ uid }) => parseInt(uid))
.filter((uid) => !isNaN(uid) && uid >= MIN_UID && uid < MAX_UID);
nextId = (_.max(availIds) || MIN_UID - 1) + 1;
log(`Found ${availIds.length} existing users, next ID is ${nextId}`);
}
async function createUser(log: (msg: string) => void): Promise<number> {
if (nextId! >= MAX_UID) {
throw new Error("too many users");
}
return await new Promise((resolve, reject) => {
const uid = nextId!;
const useradd = spawn("system/out/riju-system-privileged", [
"useradd",
`${uid}`,
]);
let output = "";
useradd.stdout.on("data", (data) => {
output += `${data}`;
});
useradd.stderr.on("data", (data) => {
output += `${data}`;
});
useradd.on("close", (code) => {
output = output.trim();
if (output) {
log("Output from useradd:\n" + output);
}
if (code === 0) {
log(`Created new user with ID ${uid}`);
nextId! += 1;
resolve(uid);
} else {
reject(`useradd failed with error code ${code}`);
}
});
});
}
export async function borrowUser(log: (msg: string) => void) {
if (!PRIVILEGED) {
if (CUR_UID === null) {
throw new Error("unable to determine current UID");
} else {
return { uid: CUR_UID, cleanup: async () => {} };
}
} else {
return await lock.acquire("key", async () => {
if (availIds === null || nextId === null) {
await readExistingUsers(log);
}
let uid: number;
if (availIds!.length > 0) {
uid = availIds!.pop()!;
} else {
uid = await createUser(log);
}
return {
uid,
cleanup: async () => {
await lock.acquire("key", () => {
availIds!.push(uid);
});
},
};
});
}
}

View File

@ -5,12 +5,16 @@
"private": true,
"dependencies": {
"@types/app-root-path": "^1.2.4",
"@types/async-lock": "^1.1.2",
"@types/express": "^4.17.6",
"@types/express-ws": "^3.0.0",
"@types/lodash": "^4.14.155",
"@types/mkdirp": "^1.0.1",
"@types/parse-passwd": "^1.0.0",
"@types/tmp": "^0.2.0",
"@types/uuid": "^8.0.0",
"app-root-path": "^3.0.0",
"async-lock": "^1.2.4",
"css-loader": "^3.5.3",
"ejs": "^3.1.3",
"express": "^4.17.1",
@ -19,11 +23,14 @@
"lodash": "^4.17.15",
"mkdirp": "^1.0.4",
"monaco-editor": "^0.20.0",
"node-cleanup": "^2.1.2",
"node-pty": "^0.9.0",
"parse-passwd": "^1.0.0",
"style-loader": "^1.2.1",
"tmp": "^0.2.1",
"ts-loader": "^7.0.5",
"typescript": "^3.9.5",
"uuid": "^8.1.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"xterm": "^4.6.0",
@ -34,7 +41,9 @@
"backend-dev": "tsc --watch",
"frontend": "webpack --production",
"frontend-dev": "webpack --development --watch",
"server": "node backend/out/server.js",
"server-dev": "watchexec -w backend/out -r -n node backend/out/server.js"
"server": "scripts/setup.bash && node backend/out/server.js",
"server-dev": "watchexec -w backend/out -r 'scripts/setup.bash && node backend/out/server.js'",
"system": "scripts/compile-system.bash",
"system-dev": "watchexec -w system/src -n scripts/compile-system.bash"
}
}

26
scripts/compile-system.bash Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -e
set -o pipefail
if [[ ! -d system/src ]]; then
echo "compile-system.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:docker "${out}"
sudo chmod a=,g=rx,u=rwxs "${out}"
fi
done

View File

@ -8,6 +8,7 @@ export LC_ALL=C.UTF-8
export SHELL="$(which bash)"
export HOST=0.0.0.0
export RIJU_PRIVILEGED=yes
if [[ -d /home/docker/src ]]; then
cd /home/docker/src

7
scripts/setup.bash Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
set -o pipefail
mkdir -p /tmp/riju
rm -rf /tmp/riju/*

View File

@ -0,0 +1,55 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
// Keep in sync with backend/src/users.ts
const int MIN_UID = 2000;
const int MAX_UID = 65000;
void die(const char *msg)
{
fprintf(stderr, "%s\n", msg);
exit(1);
}
void die_with_usage()
{
die("usage:\n"
" riju-system-privileged useradd UID");
}
void useradd(int uid)
{
char *cmdline;
if (asprintf(&cmdline, "useradd -M -N -l -r -u %1$d riju_user%1$d", uid) < 0) {
die("asprintf failed");
}
int status = system(cmdline);
if (status) {
die("useradd failed");
}
}
int main(int argc, char **argv)
{
setuid(0);
if (argc < 2)
die_with_usage();
if (!strcmp(argv[1], "useradd")) {
if (argc != 3)
die_with_usage();
char *endptr;
long uid = strtol(argv[2], &endptr, 10);
if (!argv[2] || *endptr) {
die("uid must be an integer");
}
if (uid < MIN_UID || uid >= MAX_UID) {
die("uid is out of range");
}
useradd(uid);
return 0;
}
die_with_usage();
}

View File

@ -7,6 +7,11 @@
resolved "https://registry.yarnpkg.com/@types/app-root-path/-/app-root-path-1.2.4.tgz#a78b703282b32ac54de768f5512ecc3569919dc7"
integrity sha1-p4twMoKzKsVN52j1US7MNWmRncc=
"@types/async-lock@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.1.2.tgz#cbc26a34b11b83b28f7783a843c393b443ef8bef"
integrity sha512-j9n4bb6RhgFIydBe0+kpjnBPYumDaDyU8zvbWykyVMkku+c2CSu31MZkLeaBfqIwU+XCxlDpYDfyMQRkM0AkeQ==
"@types/body-parser@*":
version "1.19.0"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
@ -77,6 +82,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.11.tgz#61d4886e2424da73b7b25547f59fdcb534c165a3"
integrity sha512-lCvvI24L21ZVeIiyIUHZ5Oflv1hhHQ5E1S25IRlKIXaRkVgmXpJMI3wUJkmym2bTbCe+WoIibQnMVAU3FguaOg==
"@types/parse-passwd@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-passwd/-/parse-passwd-1.0.0.tgz#72fc50feb14d484547f40ba7da54dfcfc711dcf1"
integrity sha512-+kETlH3XJMQKUpJ4dYfU4MlfYqyjKwsOC9PLJcGiUhPcHrNEgvcEbllSJ5HlsvflDLswTtyDoKJEUcCQg2l9PA==
"@types/qs@*":
version "6.9.3"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.3.tgz#b755a0934564a200d3efdf88546ec93c369abd03"
@ -100,6 +110,11 @@
resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.2.0.tgz#e3f52b4d7397eaa9193592ef3fdd44dc0af4298c"
integrity sha512-flgpHJjntpBAdJD43ShRosQvNC0ME97DCfGvZEDlAThQmnerRXrLbX6YgzRBQCZTthET9eAWFAMaYP0m0Y4HzQ==
"@types/uuid@^8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0"
integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw==
"@types/ws@*":
version "7.2.5"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.2.5.tgz#513f28b04a1ea1aa9dc2cad3f26e8e37c88aae49"
@ -390,6 +405,11 @@ async-limiter@~1.0.0:
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
async-lock@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.2.4.tgz#80d0d612383045dd0c30eb5aad08510c1397cb91"
integrity sha512-UBQJC2pbeyGutIfYmErGc9RaJYnpZ1FHaxuKwb0ahvGiiCkPUf3p67Io+YLPmmv3RHY+mF6JEtNW8FlHsraAaA==
async@0.9.x:
version "0.9.2"
resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
@ -2190,6 +2210,11 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
node-cleanup@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c"
integrity sha1-esGavSl+Caf3KnFUXZUbUX5N3iw=
node-libs-browser@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425"
@ -3299,6 +3324,11 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d"
integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==
v8-compile-cache@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe"