diff --git a/Dockerfile.dev b/Dockerfile.dev index 5c7df74..8210b26 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,6 +2,8 @@ FROM ubuntu:focal ARG UID +COPY scripts/my_init /usr/bin/my_init + COPY scripts/docker-install-phase0.bash /tmp/ RUN /tmp/docker-install-phase0.bash @@ -44,6 +46,6 @@ RUN chmod go-rwx /home/docker EXPOSE 6119 EXPOSE 6120 -ENTRYPOINT ["/usr/local/bin/pid1.bash"] +ENTRYPOINT ["/usr/bin/my_init", "/usr/local/bin/pid1.bash"] COPY scripts/pid1.bash /usr/local/bin/ CMD ["bash"] diff --git a/Dockerfile.prod b/Dockerfile.prod index 38f0854..83629f0 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -4,6 +4,8 @@ FROM ubuntu:focal # prod, it's not actually read by anything. ARG UID +COPY scripts/my_init /usr/bin/my_init + COPY scripts/docker-install-phase0.bash /tmp/ RUN /tmp/docker-install-phase0.bash @@ -46,7 +48,7 @@ RUN chmod go-rwx /home/docker EXPOSE 6119 EXPOSE 6120 -ENTRYPOINT ["/usr/local/bin/pid1.bash"] +ENTRYPOINT ["/usr/bin/my_init", "/usr/local/bin/pid1.bash"] COPY scripts/pid1.bash /usr/local/bin/ CMD ["yarn", "run", "server"] diff --git a/README.md b/README.md index c290c31..55f2e37 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ container first: $ make docker -Note that building the image takes about 30 minutes on high-end -hardware and ethernet, and it requires about 15 GB of disk space. +Note that building the image can take up to 45 minutes even on +high-end hardware and internet, and it requires about 15 GB of disk +space. ## Flag diff --git a/backend/src/api.ts b/backend/src/api.ts index aad5e61..946b838 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -9,288 +9,354 @@ import { v4 as getUUID } from "uuid"; import { LangConfig, langs } from "./langs"; import { borrowUser } from "./users"; -import { - callPrivileged, - getEnv, - rijuSystemPrivileged, - spawnPrivileged, -} from "./util"; +import * as util from "./util"; +import { Context, Options, bash } from "./util"; + +const allSessions: Set = new Set(); export class Session { + ws: WebSocket; uuid: string; - code: string | null; - config: LangConfig; - term: { pty: IPty | null; live: boolean }; + lang: string; + + tearingDown: boolean = false; + + // Initialized by setup() + uidInfo: { + uid: number; + returnUID: () => Promise; + } | null = null; + + // Initialized later or never + term: { pty: IPty; live: boolean } | null = null; lsp: { proc: ChildProcess; reader: rpc.StreamMessageReader; writer: rpc.StreamMessageWriter; - } | null; - daemon: ChildProcess | null; - ws: WebSocket; - homedir: string | null; - uid: number | null; - uidCleanup: (() => Promise) | null; + } | null = null; + daemon: { proc: ChildProcess } | null = null; + + 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 }; + } + + get env() { + return util.getEnv(this.uuid); + } log = (msg: string) => console.log(`[${this.uuid}] ${msg}`); constructor(ws: WebSocket, lang: string) { - this.uuid = getUUID(); - this.log(`Creating session, language ${lang}`); this.ws = ws; - this.config = langs[lang]; - this.term = { pty: null, live: false }; - this.lsp = null; - this.daemon = null; - this.code = null; - this.homedir = null; - this.uid = null; - this.uidCleanup = null; - ws.on("message", this.handleClientMessage); - ws.on("close", () => - this.cleanup().catch((err) => { - this.log(`Error during session cleanup`); - console.log(err); - }) - ); - this.run().catch((err) => { - this.log(`Error while setting up environment for pty`); - console.log(err); - this.send({ event: "terminalClear" }); - this.send({ - event: "terminalOutput", - output: `Riju encountered an unexpected error: ${err} -\rYou may want to save your code and refresh the page. -`, - }); - }); + this.uuid = getUUID(); + this.lang = lang; + this.log(`Creating session, language ${this.lang}`); + this.setup(); } - send = (msg: any) => { + + run = async (args: string[], options?: Options) => { + return await util.run(args, this.log, options); + }; + + privilegedSetup = () => util.privilegedSetup(this.context); + privilegedSpawn = (args: string[]) => + 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()); + await this.runCode(); + if (this.config.daemon) { + const daemonArgs = this.privilegedSpawn(bash(this.config.daemon)); + const daemonProc = spawn(daemonArgs[0], daemonArgs.slice(1), { + env: this.env, + }); + 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("exit", (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.lspSetup) { + await this.run(this.privilegedSpawn(bash(this.config.lspSetup))); + } + const lspArgs = this.privilegedSpawn(bash(this.config.lsp)); + const lspProc = spawn(lspArgs[0], lspArgs.slice(1), { env: this.env }); + this.lsp = { + proc: lspProc, + reader: new rpc.StreamMessageReader(lspProc.stdout), + writer: new rpc.StreamMessageWriter(lspProc.stdin), + }; + this.lsp.reader.listen((data: any) => { + this.send({ event: "lspOutput", output: data }); + }); + lspProc.stderr.on("data", (data) => + this.send({ + event: "serviceLog", + service: "lsp", + output: data.toString("utf8"), + }) + ); + lspProc.on("exit", (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", this.receive); + 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: any) => { + try { + if (this.tearingDown) { + return; + } this.ws.send(JSON.stringify(msg)); } catch (err) { - // + this.log(`Failed to send websocket message: ${err}`); + await this.teardown(); } }; - handleClientMessage = (event: string) => { - let msg: any; - try { - msg = JSON.parse(event); - } catch (err) { - this.log(`Failed to parse client message: ${msg}`); - return; - } - switch (msg?.event) { - case "terminalInput": - if (!this.term) { - this.log(`Got terminal input before pty was started`); - } else if (typeof msg.input !== "string") { - this.log(`Got malformed terminal input message`); - } else { - this.term.pty!.write(msg.input); - } - break; - case "runCode": - if (typeof msg.code !== "string") { - this.log(`Got malformed run message`); - } else { - this.code = msg.code; - this.run(); - } - break; - case "lspInput": - if (!this.lsp) { - this.log(`Got LSP input before language server was started`); - } else { - this.lsp.writer.write(msg.input); - } - break; - default: - 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, - daemon, - repl, - main, - suffix, - createEmpty, - compile, - run, - lspSetup, - lsp, - template, - hacks, - } = this.config; - if (this.term.pty) { - this.term.pty.kill(); - this.term.live = false; - } - this.send({ event: "terminalClear" }); - if (this.homedir == null) { - this.homedir = `/tmp/riju/${this.uuid}`; - await callPrivileged(["setup", `${this.uid}`, this.uuid], this.log); - } - let cmdline: string; - if (!run) { - cmdline = `echo 'Support for ${this.config.name} is not yet implemented.'`; - } else if (this.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'`; - } - let code = this.code; - if (this.code === null) { - code = createEmpty ? "" : template; - } - if (code && suffix) { - code += suffix; - } - if (main.includes("/")) { - await spawnPrivileged( - this.uid, - this.uuid, - ["mkdir", "-p", path.dirname(path.resolve(this.homedir, main))], - this.log - ); - } - await spawnPrivileged( - this.uid, - this.uuid, - ["sh", "-c", `cat > ${path.resolve(this.homedir, main)}`], - this.log, - { input: code as string } - ); - if (hacks && hacks.includes("ghci-config") && run) { - if (this.code) { - const contents = ":load Main\nmain\n"; - await spawnPrivileged( - this.uid, - this.uuid, - ["sh", "-c", `cat > ${path.resolve(this.homedir, ".ghci")}`], - this.log, - { input: contents } - ); - } else { - await spawnPrivileged( - this.uid, - this.uuid, - ["rm", "-f", path.resolve(this.homedir, ".ghci")], - this.log - ); - } - } - const args = [ - rijuSystemPrivileged, - "spawn", - `${this.uid}`, - `${this.uuid}`, - "bash", - "-c", - cmdline, - ]; - const env = getEnv(this.uuid); - const term = { - pty: pty.spawn(args[0], args.slice(1), { - name: "xterm-color", - env, - }), - live: true, - }; - this.term = term; - 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 }); - } + + sendError = async (err: any) => { + 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. +`, }); - if (daemon && this.daemon === null) { - this.daemon = spawn("bash", ["-c", daemon], { env: getEnv(this.uuid) }); - this.daemon.on("exit", (code) => - this.send({ event: "daemonCrashed", code }) - ); - this.daemon.stdout!.on("data", (data) => - this.send({ event: "daemonLog", output: data.toString("utf8") }) - ); - this.daemon.stderr!.on("data", (data) => - this.send({ event: "daemonLog", output: data.toString("utf8") }) - ); - } - if (lsp && this.lsp === null) { - if (lspSetup) { - await spawnPrivileged( - this.uid!, - this.uuid, - ["bash", "-c", lspSetup], - this.log - ); + }; + + logBadMessage = (msg: any) => { + this.log(`Got malformed message from client: ${msg}`); + }; + + receive = async (event: string) => { + try { + if (this.tearingDown) { + return; } - const lspArgs = [ - rijuSystemPrivileged, - "spawn", - `${this.uid}`, - `${this.uuid}`, - "bash", - "-c", - lsp, - ]; - const proc = spawn(lspArgs[0], lspArgs.slice(1), { - env: getEnv(this.uuid), - }); - proc.on("exit", (code) => this.send({ event: "lspCrashed", code })); - proc.stderr.on("data", (data) => - this.send({ event: "lspLog", output: data.toString("utf8") }) - ); - this.lsp = { - proc, - reader: new rpc.StreamMessageReader(proc.stdout), - writer: new rpc.StreamMessageWriter(proc.stdin), - }; - this.lsp.reader.listen((data) => { - this.send({ event: "lspOutput", output: data }); - }); - this.send({ event: "lspStarted", root: `/tmp/riju/${this.uuid}` }); + let msg: any; + try { + msg = JSON.parse(event); + } catch (err) { + this.log(`Failed to parse message from client: ${msg}`); + 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 "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; + default: + this.logBadMessage(msg); + break; + } + } catch (err) { + this.log(`Error while handling message from client`); + console.log(err); + this.sendError(err); } }; - cleanup = async () => { - this.log(`Cleaning up session`); - if (this.term.pty) { - await spawnPrivileged( - this.uid!, - this.uuid, - ["bash", "-c", `kill -9 ${this.term.pty.pid} 2>/dev/null || true`], - this.log + + runCode = async (code?: string) => { + try { + const { + name, + repl, + main, + suffix, + createEmpty, + compile, + run, + template, + hacks, + } = this.config; + if (this.term) { + const pid = this.term.pty.pid; + const args = this.privilegedSpawn( + bash(`kill -SIGTERM ${pid}; sleep 3; kill -SIGKILL ${pid}`) + ); + spawn(args[0], args.slice(1), { env: this.env }); + // Signal to terminalOutput message generator using closure. + this.term.live = false; + this.term = null; + } + this.send({ event: "terminalClear" }); + let cmdline: string; + 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 ? "" : template; + } + if (code && suffix) { + code += suffix; + } + if (main.includes("/")) { + await this.run( + this.privilegedSpawn([ + "mkdir", + "-p", + path.dirname(`${this.homedir}/${main}`), + ]) + ); + } + await this.run( + this.privilegedSpawn([ + "sh", + "-c", + `cat > ${path.resolve(this.homedir, main)}`, + ]), + { input: code } ); + if (hacks && hacks.includes("ghci-config") && run) { + if (code) { + await this.run( + this.privilegedSpawn(["sh", "-c", `cat > ${this.homedir}/.ghci`]), + { input: ":load Main\nmain\n" } + ); + } else { + await this.run( + this.privilegedSpawn(["rm", "-f", `${this.homedir}/.ghci`]) + ); + } + } + const termArgs = this.privilegedSpawn(bash(cmdline)); + const term = { + pty: pty.spawn(termArgs[0], termArgs.slice(1), { + name: "xterm-color", + env: this.env, + }), + 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 }); + } + }); + } catch (err) { + this.log(`Error while running user code`); + console.log(err); + this.sendError(err); } - if (this.lsp !== null) { - await spawnPrivileged( - this.uid!, - this.uuid, - ["bash", "-c", `kill -9 ${this.lsp.proc.pid} 2>/dev/null || true`], - this.log - ); - } - if (this.homedir) { - await callPrivileged(["teardown", this.uuid], this.log); - } - if (this.uidCleanup) { - await this.uidCleanup(); - this.log(`Returned uid ${this.uid}`); + }; + + teardown = async () => { + try { + if (this.tearingDown) { + return; + } + this.log(`Tearing down session`); + this.tearingDown = true; + allSessions.delete(this); + await new Promise((resolve) => setTimeout(resolve, 5000)); + await this.run(this.privilegedTeardown()); + await this.returnUID(); + this.ws.terminate(); + } catch (err) { + this.log(`Error during teardown`); + console.log(err); } }; } diff --git a/backend/src/sandbox.ts b/backend/src/sandbox.ts index f9fd273..f147a80 100644 --- a/backend/src/sandbox.ts +++ b/backend/src/sandbox.ts @@ -5,7 +5,13 @@ import { v4 as getUUID } from "uuid"; import { langs } from "./langs"; import { borrowUser } from "./users"; -import { callPrivileged, getEnv, rijuSystemPrivileged } from "./util"; +import { + getEnv, + privilegedSetup, + privilegedSpawn, + privilegedTeardown, + run, +} from "./util"; function die(msg: any) { console.error(msg); @@ -18,9 +24,9 @@ function log(msg: any) { async function main() { const uuid = getUUID(); - const { uid, cleanup } = await borrowUser(log); - await callPrivileged(["setup", `${uid}`, uuid], log); - const args = [rijuSystemPrivileged, "spawn", `${uid}`, `${uuid}`, "bash"]; + 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), { env: getEnv(uuid), stdio: "inherit", @@ -29,7 +35,8 @@ async function main() { proc.on("error", reject); proc.on("exit", resolve); }); - await cleanup(); + await run(privilegedTeardown({ uid, uuid }), log); + await returnUID(); } main().catch(die); diff --git a/backend/src/server.ts b/backend/src/server.ts index 9134449..862fd6f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -100,7 +100,7 @@ if (useTLS) { httpsServer.listen(tlsPort, host, () => console.log(`Listening on https://${host}:${tlsPort}`) ); - http + const server = http .createServer((req, res) => { res.writeHead(301, { Location: "https://" + req.headers["host"] + req.url, @@ -112,7 +112,7 @@ if (useTLS) { ); } else { addWebsocket(app, undefined); - app.listen(port, host, () => + const server = app.listen(port, host, () => console.log(`Listening on http://${host}:${port}`) ); } diff --git a/backend/src/users.ts b/backend/src/users.ts index 89862b5..d41d07d 100644 --- a/backend/src/users.ts +++ b/backend/src/users.ts @@ -7,7 +7,7 @@ import * as _ from "lodash"; import * as parsePasswd from "parse-passwd"; import { PRIVILEGED } from "./config"; -import { callPrivileged } from "./util"; +import { privilegedUseradd, run } from "./util"; // Keep in sync with system/src/riju-system-privileged.c const MIN_UID = 2000; @@ -21,7 +21,7 @@ let lock = new AsyncLock(); async function readExistingUsers(log: (msg: string) => void) { availIds = parsePasswd( - await new Promise((resolve, reject) => + await new Promise((resolve: (result: string) => void, reject) => fs.readFile("/etc/passwd", "utf-8", (err, data) => { if (err) { reject(err); @@ -43,7 +43,7 @@ async function createUser(log: (msg: string) => void): Promise { throw new Error("too many users"); } const uid = nextId!; - await callPrivileged(["useradd", `${uid}`], log); + await run(privilegedUseradd(uid), log); log(`Created new user with ID ${uid}`); nextId! += 1; return uid; @@ -51,7 +51,7 @@ async function createUser(log: (msg: string) => void): Promise { export async function borrowUser(log: (msg: string) => void) { if (!PRIVILEGED) { - return { uid: CUR_UID, cleanup: async () => {} }; + return { uid: CUR_UID, returnUID: async () => {} }; } else { return await lock.acquire("key", async () => { if (availIds === null || nextId === null) { @@ -65,7 +65,7 @@ export async function borrowUser(log: (msg: string) => void) { } return { uid, - cleanup: async () => { + returnUID: async () => { await lock.acquire("key", () => { availIds!.push(uid); }); diff --git a/backend/src/util.ts b/backend/src/util.ts index eebe1d1..59cb89a 100644 --- a/backend/src/util.ts +++ b/backend/src/util.ts @@ -3,11 +3,16 @@ import * as process from "process"; import * as appRoot from "app-root-path"; -interface Options extends SpawnOptions { +export interface Options extends SpawnOptions { input?: string; check?: boolean; } +export interface Context { + uid: number; + uuid: string; +} + export const rijuSystemPrivileged = appRoot.resolve( "system/out/riju-system-privileged" ); @@ -26,7 +31,7 @@ export function getEnv(uuid: string) { }; } -export async function call( +export async function run( args: string[], log: (msg: string) => void, options?: Options @@ -63,26 +68,22 @@ export async function call( }); } -export async function callPrivileged( - args: string[], - log: (msg: string) => void, - options?: Options -) { - await call([rijuSystemPrivileged].concat(args), log, options); +export function privilegedUseradd(uid: number) { + return [rijuSystemPrivileged, "useradd", `${uid}`]; } -export async function spawnPrivileged( - uid: number, - uuid: string, - args: string[], - log: (msg: string) => void, - options?: Options -) { - options = options || {}; - options.env = getEnv(uuid); - await callPrivileged( - ["spawn", `${uid}`, `${uuid}`].concat(args), - log, - options - ); +export function privilegedSetup({ uid, uuid }: Context) { + return [rijuSystemPrivileged, "setup", `${uid}`, uuid]; +} + +export function privilegedSpawn({ uid, uuid }: Context, args: string[]) { + return [rijuSystemPrivileged, "spawn", `${uid}`, uuid].concat(args); +} + +export function privilegedTeardown({ uid, uuid }: Context) { + return [rijuSystemPrivileged, "teardown", `${uid}`, uuid]; +} + +export function bash(cmdline: string) { + return ["bash", "-c", cmdline]; } diff --git a/frontend/src/app.ts b/frontend/src/app.ts index bdbe5a0..0aa0dd8 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -66,12 +66,12 @@ class RijuMessageReader extends AbstractMessageReader { } catch (err) { return; } - switch (message?.event) { + switch (message && message.event) { case "lspOutput": if (DEBUG) { - console.log("RECEIVE LSP:", message?.output); + console.log("RECEIVE LSP:", message.output); } - this.callback!(message?.output); + this.callback!(message.output); break; } } @@ -135,14 +135,15 @@ async function main() { if (DEBUG) { console.log("SEND", message); } - socket?.send(JSON.stringify(message)); + if (socket) { + socket.send(JSON.stringify(message)); + } } function tryConnect() { let clientDisposable: Disposable | null = null; let servicesDisposable: Disposable | null = null; - let lspLogBuffer = ""; - let daemonLogBuffer = ""; + const serviceLogBuffers: { [index: string]: string } = {}; console.log("Connecting to server..."); socket = new WebSocket( (document.location.protocol === "http:" ? "ws://" : "wss://") + @@ -162,16 +163,17 @@ async function main() { } if ( DEBUG && - message?.event !== "lspOutput" && - message?.event !== "lspLog" && - message?.event !== "daemonLog" + message && + message.event !== "lspOutput" && + message.event !== "lspLog" && + message.event !== "daemonLog" ) { console.log("RECEIVE:", message); } - if (message?.event && message?.event !== "error") { + if (message && message.event && message.event !== "error") { retryDelayMs = initialRetryDelayMs; } - switch (message?.event) { + switch (message && message.event) { case "terminalClear": term.reset(); return; @@ -208,7 +210,11 @@ async function main() { documentSelector: [{ pattern: "**" }], middleware: { workspace: { - configuration: (params, token, configuration) => { + configuration: ( + params: any, + token: any, + configuration: any + ) => { return Array( (configuration(params, token) as {}[]).length ).fill( @@ -220,7 +226,7 @@ async function main() { initializationOptions: config.lspInit || {}, }, connectionProvider: { - get: (errorHandler, closeHandler) => + get: (errorHandler: any, closeHandler: any) => Promise.resolve( createConnection(connection, errorHandler, closeHandler) ), @@ -231,38 +237,27 @@ async function main() { case "lspOutput": // Should be handled by RijuMessageReader return; - case "lspLog": - if (typeof message.output !== "string") { + case "serviceLog": + if ( + typeof message.service !== "string" || + typeof message.output !== "string" + ) { console.error("Unexpected message from server:", message); return; } if (DEBUG) { - lspLogBuffer += message.output; - while (lspLogBuffer.includes("\n")) { - const idx = lspLogBuffer.indexOf("\n"); - const line = lspLogBuffer.slice(0, idx); - lspLogBuffer = lspLogBuffer.slice(idx + 1); - console.log(`LSP || ${line}`); + 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 "daemonLog": - if (typeof message.output !== "string") { - console.error("Unexpected message from server:", message); - return; - } - if (DEBUG) { - daemonLogBuffer += message.output; - while (daemonLogBuffer.includes("\n")) { - const idx = daemonLogBuffer.indexOf("\n"); - const line = daemonLogBuffer.slice(0, idx); - daemonLogBuffer = daemonLogBuffer.slice(idx + 1); - console.log(`DAEMON || ${line}`); - } - } - return; - case "lspCrashed": - case "daemonCrashed": + case "serviceCrashed": return; default: console.error("Unexpected message from server:", message); diff --git a/package.json b/package.json index db50a61..c75e371 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/express-ws": "^3.0.0", "@types/lodash": "^4.14.155", "@types/mkdirp": "^1.0.1", + "@types/node-cleanup": "^2.1.1", "@types/parse-passwd": "^1.0.0", "@types/rimraf": "^3.0.0", "@types/shell-quote": "^1.7.0", @@ -30,7 +31,6 @@ "monaco-editor": "^0.20.0", "monaco-editor-webpack-plugin": "^1.9.0", "monaco-languageclient": "^0.13.0", - "node-cleanup": "^2.1.2", "node-pty": "^0.9.0", "npm-run-all": "^4.1.5", "parse-passwd": "^1.0.0", diff --git a/scripts/docker-install-phase5.bash b/scripts/docker-install-phase5.bash index 0e7df14..16c1bf5 100755 --- a/scripts/docker-install-phase5.bash +++ b/scripts/docker-install-phase5.bash @@ -36,7 +36,7 @@ wget -nv https://github.com/dhall-lang/dhall-haskell/releases/download/1.33.1/dh mkdir dhall-json tar -xf dhall-json-*-x86_64-linux.tar.bz2 -C dhall-json mv dhall-json/bin/dhall-to-json dhall-json/bin/json-to-dhall /usr/bin/ -rm dhall-json dhall-json-*-x86_64-linux.tar.bz2 +rm -rf dhall-json dhall-json-*-x86_64-linux.tar.bz2 # Elixir wget -nv https://github.com/elixir-lsp/elixir-ls/releases/download/v0.5.0/elixir-ls.zip @@ -57,12 +57,13 @@ mv rebar3 /usr/bin/rebar3 # Go export GO111MODULE=on -export GOPATH=/tmp/go -mv /tmp/go/bin/gopls /usr/bin/gopls -rm -rf /tmp/go +export GOPATH="$PWD/go" +go get golang.org/x/tools/gopls@latest +mv go/bin/gopls /usr/bin/gopls +rm -rf go # Haskell -wget https://get.haskellstack.org/stable/linux-x86_64-static.tar.gz +wget -nv https://get.haskellstack.org/stable/linux-x86_64-static.tar.gz tar -xf linux-x86_64-static.tar.gz mv stack-*-linux-x86_64-static/stack /usr/bin/stack rm -rf stack-*-linux-x86_64-static linux-x86_64-static.tar.gz diff --git a/scripts/my_init b/scripts/my_init new file mode 100755 index 0000000..9177e6d --- /dev/null +++ b/scripts/my_init @@ -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) diff --git a/scripts/setup.bash b/scripts/setup.bash index e9a597e..af87aaa 100755 --- a/scripts/setup.bash +++ b/scripts/setup.bash @@ -5,6 +5,6 @@ set -o pipefail mkdir -p /tmp/riju if [[ -x system/out/riju-system-privileged ]]; then - system/out/riju-system-privileged teardown "*" || true + system/out/riju-system-privileged teardown "*" "*" || true fi chmod a=x,u=rwx /tmp/riju diff --git a/system/src/riju-system-privileged.c b/system/src/riju-system-privileged.c index 312b2fa..e9a774a 100644 --- a/system/src/riju-system-privileged.c +++ b/system/src/riju-system-privileged.c @@ -14,7 +14,7 @@ const int MAX_UID = 65000; int privileged; -void die(char *msg) +void __attribute__ ((noreturn)) die(char *msg) { fprintf(stderr, "%s\n", msg); exit(1); @@ -24,8 +24,8 @@ void die_with_usage() { die("usage:\n" " riju-system-privileged useradd UID\n" - " riju-system-privileged spawn UID CMDLINE...\n" " riju-system-privileged setup UID UUID\n" + " riju-system-privileged spawn UID UUID CMDLINE...\n" " riju-system-privileged teardown UUID"); } @@ -60,12 +60,12 @@ void useradd(int uid) if (asprintf(&cmdline, "groupadd -g %1$d riju%1$d", uid) < 0) die("asprintf failed"); int status = system(cmdline); - if (status) + 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) + if (status != 0) die("useradd failed"); } @@ -97,17 +97,44 @@ void setup(int uid, char *uuid) : "install -d -m 700 /tmp/riju/%2$s", uid, uuid) < 0) die("asprintf failed"); int status = system(cmdline); - if (status) + if (status != 0) die("install failed"); } -void teardown(char *uuid) +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, "pkill -SIGKILL --uid %s", 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"); - int status = system(cmdline); - if (status) + status = system(cmdline); + if (status != 0) die("rm failed"); } @@ -140,10 +167,11 @@ int main(int argc, char **argv) return 0; } if (!strcmp(argv[1], "teardown")) { - if (argc != 3) + if (argc != 4) die_with_usage(); - char *uuid = strcmp(argv[2], "*") ? parseUUID(argv[2]) : "*"; - teardown(uuid); + 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(); diff --git a/tsconfig-webpack.json b/tsconfig-webpack.json index 358bebf..e27d0d9 100644 --- a/tsconfig-webpack.json +++ b/tsconfig-webpack.json @@ -1,7 +1,8 @@ { "compilerOptions": { "outDir": "./frontend/out", - "rootDir": "./frontend/src" + "rootDir": "./frontend/src", + "target": "ES3" }, "extends": "./tsconfig.json", "include": ["frontend/src"] diff --git a/tsconfig.json b/tsconfig.json index 9e16e58..c964b6b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "resolveJsonModule": true, "rootDir": "./backend/src", "sourceMap": true, - "strict": true + "strict": true, + "target": "ES5" }, "include": ["backend/src"] } diff --git a/yarn.lock b/yarn.lock index 387f7bf..de64bc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -882,6 +882,11 @@ dependencies: "@types/node" "*" +"@types/node-cleanup@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@types/node-cleanup/-/node-cleanup-2.1.1.tgz#c8f78a648897d2a40ed10632268ce15d343cc191" + integrity sha512-Q1s5Sszz6YfhaGr1pbaZihr9IYaiQT0aOK/3c2qb9lOUbEBhcAb9ZEU7RBTtopnHSIJF80adLRcOGTay2W5QVQ== + "@types/node@*": version "14.0.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.11.tgz#61d4886e2424da73b7b25547f59fdcb534c165a3" @@ -3450,11 +3455,6 @@ 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"