Automatically allocate one uid per session
This commit is contained in:
parent
e9ff1d92d3
commit
afad563d56
|
@ -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
|
||||
|
|
12
README.md
12
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
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
13
package.json
13
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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
mkdir -p /tmp/riju
|
||||
rm -rf /tmp/riju/*
|
|
@ -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();
|
||||
}
|
30
yarn.lock
30
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"
|
||||
|
|
Loading…
Reference in New Issue