From afad563d5643b901a753a574ad85976eabc67c4b Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Mon, 22 Jun 2020 12:35:38 -0600 Subject: [PATCH] Automatically allocate one uid per session --- Dockerfile.prod | 7 +- README.md | 12 ++-- backend/src/api.ts | 67 ++++++++++++++----- backend/src/users.ts | 100 ++++++++++++++++++++++++++++ package.json | 13 +++- scripts/compile-system.bash | 26 ++++++++ scripts/pid1.bash | 1 + scripts/setup.bash | 7 ++ system/src/riju-system-privileged.c | 55 +++++++++++++++ yarn.lock | 30 +++++++++ 10 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 backend/src/users.ts create mode 100755 scripts/compile-system.bash create mode 100755 scripts/setup.bash create mode 100644 system/src/riju-system-privileged.c diff --git a/Dockerfile.prod b/Dockerfile.prod index 277bb38..063ce53 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -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 diff --git a/README.md b/README.md index ccc1ee1..e5c140b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/src/api.ts b/backend/src/api.ts index de321f9..c3b5e59 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -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) | 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(); + } }; } diff --git a/backend/src/users.ts b/backend/src/users.ts new file mode 100644 index 0000000..6e3c8f4 --- /dev/null +++ b/backend/src/users.ts @@ -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 { + 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); + }); + }, + }; + }); + } +} diff --git a/package.json b/package.json index 6fdb2a0..3f23bc1 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/compile-system.bash b/scripts/compile-system.bash new file mode 100755 index 0000000..ac3a77f --- /dev/null +++ b/scripts/compile-system.bash @@ -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 diff --git a/scripts/pid1.bash b/scripts/pid1.bash index 3e88423..ceea05c 100755 --- a/scripts/pid1.bash +++ b/scripts/pid1.bash @@ -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 diff --git a/scripts/setup.bash b/scripts/setup.bash new file mode 100755 index 0000000..b884776 --- /dev/null +++ b/scripts/setup.bash @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +mkdir -p /tmp/riju +rm -rf /tmp/riju/* diff --git a/system/src/riju-system-privileged.c b/system/src/riju-system-privileged.c new file mode 100644 index 0000000..6ada086 --- /dev/null +++ b/system/src/riju-system-privileged.c @@ -0,0 +1,55 @@ +#define _GNU_SOURCE +#include +#include +#include +#include + +// 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(); +} diff --git a/yarn.lock b/yarn.lock index 1fa7b4a..a312345 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"