Files
quixos-nix-helpers/restricted-execution/service.mjs
T

509 lines
21 KiB
JavaScript

import { spawn } from "node:child_process";
import { createServer, request as httpRequest } from "node:http";
import { mkdtemp, readFile, writeFile, rm, open } from "node:fs/promises";
import { constants } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
const abi = "quixos-transaction-ports-v1";
const fixedError = (code) => Object.assign(Error(code), { code });
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
const absolute = (value) =>
typeof value === "string" &&
value.startsWith("/") &&
!/[\s\u0000-\u001f]/.test(value) &&
!value.split("/").includes("..");
async function boundedSource(path, maximum, code) {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > maximum) throw fixedError(code);
const bytes = Buffer.alloc(stat.size + 1);
let offset = 0;
while (offset < bytes.length) {
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
if (!read.bytesRead) break;
offset += read.bytesRead;
}
if (offset !== stat.size) throw fixedError(code);
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, offset));
} finally {
await handle.close();
}
}
/** Trusted stdio adapter inside one workspace VM. Engine processes never own
* coordinator credentials. The supervising service must impose VM/OS limits.
* Per-frame process replacement prevents retained globals/handles crossing calls. */
export async function serveRestrictedRegistry(config, io = process) {
if (
!object(config) ||
!absolute(config.workerd) ||
!absolute(config.worker) ||
!absolute(config.module) ||
!/^[$A-Z_a-z][$\w]*$/.test(config.registryExport) ||
!Array.isArray(config.exports) ||
!config.exports.length ||
config.exports.length > 10000 ||
config.exports.some((x) => typeof x !== "string" || !x || x.length > 4096) ||
new Set(config.exports).size !== config.exports.length ||
!Array.isArray(config.sdk) ||
config.sdk.length > 8 ||
config.sdk.some(
(m) =>
!object(m) ||
!absolute(m.path) ||
typeof m.name !== "string" ||
!m.name ||
/[\u0000-\u001f]/.test(m.name) ||
["entry.mjs", "package.mjs"].includes(m.name),
) ||
new Set(config.sdk.map((m) => m.name)).size !== config.sdk.length
)
throw fixedError("INVALID_RESTRICTED_CONFIGURATION");
const limits = {
maxBytes: config.maxBytes ?? 1024 * 1024,
maxPortCalls: config.maxPortCalls ?? 1000,
deadlineMs: config.deadlineMs ?? 4000,
maxQueued: config.maxQueued ?? 8,
maxActive: config.maxActive ?? 2,
maxNested: config.maxNested ?? 4,
};
if (
!Number.isSafeInteger(limits.maxBytes) ||
limits.maxBytes < 1024 ||
limits.maxBytes > 4 * 1024 * 1024 ||
!Number.isSafeInteger(limits.maxPortCalls) ||
limits.maxPortCalls < 1 ||
limits.maxPortCalls > 10000 ||
!Number.isSafeInteger(limits.deadlineMs) ||
limits.deadlineMs < 1 ||
limits.deadlineMs > 60000 ||
!Number.isSafeInteger(limits.maxQueued) ||
limits.maxQueued < 0 ||
limits.maxQueued > 64 ||
!Number.isSafeInteger(limits.maxActive) ||
limits.maxActive < 1 ||
limits.maxActive > 16 ||
!Number.isSafeInteger(limits.maxNested) ||
limits.maxNested < 1 ||
limits.maxNested > 16
)
throw fixedError("INVALID_RESTRICTED_LIMITS");
const packageSource = await boundedSource(config.module, 1024 * 1024, "RESTRICTED_SOURCE_LIMIT");
const workerSource = await boundedSource(config.worker, 65536, "RESTRICTED_ENTRY_LIMIT");
const sdk = [];
for (const m of config.sdk)
sdk.push({ ...m, source: await boundedSource(m.path, 2 * 1024 * 1024, "RESTRICTED_SDK_LIMIT") });
const active = new Map();
let buffered = Buffer.alloc(0),
stopped = false;
const queued = [];
const send = (value) => {
if (stopped) return;
const line = JSON.stringify({ abi, ...value }) + "\n";
if (
Buffer.byteLength(line) > limits.maxBytes ||
io.stdout.writableLength + Buffer.byteLength(line) > limits.maxBytes * 2
)
throw fixedError("RESTRICTED_BACKPRESSURE");
io.stdout.write(line);
};
// Linux USER_HZ is 100 on the supported NixOS architectures. Read process
// CPU outside the engine; authored code cannot forge these usage messages.
const sampleCPU = async (call) => {
if (!call.child?.pid) return;
let text;
try {
text = await readFile(`/proc/${call.child.pid}/stat`, "utf8");
} catch (error) {
if (error.code === "ENOENT" || error.code === "ESRCH") return;
throw error;
}
const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
const cpuMicroseconds = (Number(fields[11]) + Number(fields[12])) * 10000;
if (!Number.isSafeInteger(cpuMicroseconds) || cpuMicroseconds < (call.cpuMicroseconds ?? 0))
throw fixedError("RESTRICTED_CPU_ACCOUNTING");
if (cpuMicroseconds !== (call.cpuMicroseconds ?? 0)) {
call.cpuMicroseconds = cpuMicroseconds;
send({ kind: "usage", invocation: call.message.invocation, frame: call.message.frame, cpuMicroseconds });
}
};
const watchCPU = (call) => {
call.cpuTimer = setTimeout(() => {
call.cpuRead = sampleCPU(call).then(() => {
if (call.open) watchCPU(call);
});
void call.cpuRead.catch(fail);
}, 20);
};
const retire = async (call, reason) => {
if (call.retiring) return call.retiring;
call.open = false;
call.retiring = (async () => {
clearTimeout(call.timer);
clearTimeout(call.cpuTimer);
const index = queued.indexOf(call);
if (index >= 0) queued.splice(index, 1);
for (const pending of call.pending.values()) pending.reject(reason);
call.pending.clear();
// Cancellation can arrive during async directory/socket preparation.
// Wait for it to settle before claiming every process/handle is gone.
await call.preparing?.catch(() => {});
clearTimeout(call.cpuTimer);
await call.cpuRead?.catch(() => {});
let usageFailure;
try {
await sampleCPU(call);
} catch (error) {
usageFailure = error;
}
if (call.child?.pid) {
try {
process.kill(-call.child.pid, "SIGKILL");
} catch (error) {
if (error.code !== "ESRCH") throw error;
}
await call.exited;
}
call.server?.closeAllConnections();
if (call.server) await new Promise((resolve) => call.server.close(resolve));
if (call.directory) await rm(call.directory, { recursive: true, force: true });
active.delete(call.message.invocation);
if (usageFailure) throw usageFailure;
})();
return call.retiring;
};
const fail = async (error) => {
if (stopped) return;
stopped = true;
io.stdin.destroy();
for (const call of [...queued]) await retire(call, error);
await Promise.all([...active.values()].map((call) => retire(call, error)));
io.stdout.end();
io.exitCode = 1;
};
const body = async (stream) => {
const chunks = [];
let bytes = 0;
for await (const chunk of stream) {
bytes += chunk.length;
if (bytes > limits.maxBytes) throw fixedError("RESTRICTED_MESSAGE_LIMIT");
chunks.push(chunk);
}
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
};
const available = (call) =>
[...active.values()].filter((entry) => entry.nested === call.nested).length <
(call.nested ? limits.maxNested : limits.maxActive);
const pump = () => {
while (!stopped) {
const index = queued.findIndex(available);
if (index < 0) return;
void invoke(queued.splice(index, 1)[0]).catch(fail);
}
};
const finish = async (call, reason, reply) => {
if (reply.kind === "cancelled") call.cancellationReply = reply;
return (call.finished ??= (async () => {
await retire(call, reason);
send(call.cancellationReply ?? reply);
pump();
})());
};
const invoke = async (call) => {
const { message } = call;
active.set(message.invocation, call);
const terminate = (code) => {
if (!call.open) return;
void finish(call, fixedError(code), {
invocation: message.invocation,
status: "failed",
error: { code, message: code },
}).catch(fail);
};
call.terminate = terminate;
try {
let engine;
call.preparing = (async () => {
call.directory = await mkdtemp(join(tmpdir(), "qx-frame-"));
if (!call.open) return;
const broker = join(call.directory, "broker.sock");
engine = join(call.directory, "engine.sock");
call.server = createServer(async (request, response) => {
try {
if (!call.open || request.method !== "POST" || request.url !== "/port") throw fixedError("EXPIRED_CONTEXT");
const port = await body(request);
if (
!call.open ||
!object(port) ||
Object.keys(port).some((key) => !["portId", "action", "input", "key"].includes(key)) ||
typeof port.portId !== "string" ||
!object(port.action) ||
!Object.hasOwn(port, "input")
)
throw fixedError("INVALID_RESTRICTED_PORT");
if (
++call.sequence > Math.min(limits.maxPortCalls, message.budget?.calls ?? limits.maxPortCalls) ||
call.pending.size >= limits.maxPortCalls
)
throw fixedError("RESTRICTED_PORT_LIMIT");
const sequence = call.sequence;
const result = new Promise((resolve, reject) => call.pending.set(sequence, { resolve, reject }));
void result.catch(() => {});
send({
kind: "port",
invocation: message.invocation,
frame: message.frame,
sequence,
portId: port.portId,
action: { ...port.action, ...(port.key === undefined ? {} : { key: port.key }) },
input: port.input,
});
const value = await result;
if (!call.open) throw fixedError("EXPIRED_CONTEXT");
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded) > limits.maxBytes) throw fixedError("RESTRICTED_MESSAGE_LIMIT");
response.writeHead(200, { "content-type": "application/json" });
response.end(encoded);
} catch {
response.writeHead(409);
response.end('{"status":"failed","error":{"code":"RESTRICTED_PORT_FAILED"}}');
}
});
await new Promise((resolve, reject) => {
call.server.once("error", reject);
call.server.listen(broker, resolve);
});
await writeFile(join(call.directory, "package.mjs"), packageSource);
await writeFile(join(call.directory, "entry.mjs"), workerSource);
const modules = [
{ name: "entry.mjs", esModule: 'embed "entry.mjs"' },
{ name: "package.mjs", esModule: 'embed "package.mjs"' },
];
for (const [index, module] of sdk.entries()) {
const file = `sdk-${index}.mjs`;
await writeFile(join(call.directory, file), module.source);
modules.push({ name: module.name, esModule: "embed " + JSON.stringify(file) });
}
const setting = { registryExport: config.registryExport, exports: config.exports, maxBytes: limits.maxBytes };
const configuration = `using Workerd = import "/workerd/workerd.capnp"; const config :Workerd.Config = (
services = [(name="main", worker=(modules=[${modules.map((m) => `(name=${JSON.stringify(m.name)}, esModule=${m.esModule})`).join(",")}],
compatibilityDate="2026-10-05", globalOutbound="deny",
bindings=[(name="BROKER", service="broker"),(name="CONFIG",json=${JSON.stringify(JSON.stringify(setting))})])),
(name="broker",external=(address=${JSON.stringify("unix:" + broker)},http=())), (name="deny",network=(allow=[]))],
sockets=[(name="frame",address=${JSON.stringify("unix:" + engine)},http=(),service="main")]);`;
const file = join(call.directory, "config.capnp");
await writeFile(file, configuration);
if (!call.open) return;
call.child = spawn(config.workerd, ["serve", file], {
detached: true,
env: { TZ: "UTC", LANG: "C.UTF-8" },
cwd: call.directory,
stdio: ["ignore", "ignore", "pipe"],
});
call.exited = new Promise((resolve) => {
call.child.once("exit", resolve);
call.child.once("error", resolve);
});
watchCPU(call);
let diagnostics = 0;
call.child.stderr.on("data", (chunk) => {
if ((diagnostics += chunk.length) > 65536) terminate("RESTRICTED_DIAGNOSTIC_LIMIT");
});
call.child.once("error", () => terminate("RESTRICTED_START_FAILED"));
void call.exited.then(() => {
if (call.open) terminate("RESTRICTED_ENGINE_EXITED");
});
})();
await call.preparing;
if (!call.open) return;
const result = await new Promise((resolve, reject) => {
const attempt = () => {
if (!call.open) {
reject(fixedError("EXPIRED_CONTEXT"));
return;
}
const request = httpRequest(
{ socketPath: engine, path: "/", method: "POST", agent: false },
async (response) => {
try {
const result = await body(response);
if (response.statusCode !== 200) throw fixedError("RESTRICTED_PACKAGE_FAILED");
resolve(result);
} catch (error) {
reject(error);
}
},
);
request.once("error", (error) => {
if (call.open && ["ENOENT", "ECONNREFUSED"].includes(error.code)) setTimeout(attempt, 5);
else reject(error);
});
request.end(JSON.stringify(message));
};
attempt();
});
if (!call.open) return;
if (
!object(result) ||
!["returned", "failed"].includes(result.status) ||
Object.keys(result).some(
(key) => !(result.status === "returned" ? ["status", "output"] : ["status", "error"]).includes(key),
) ||
(result.status === "returned"
? !Object.hasOwn(result, "output")
: !object(result.error) || !/^[A-Z0-9_]{1,128}$/.test(result.error.code))
)
throw fixedError("RESTRICTED_PACKAGE_FAILED");
// Authored code can alter serialization prototypes. Treat even the
// platform worker's JSON as untrusted; never accept routing/ABI fields.
const reply =
result.status === "returned"
? { invocation: message.invocation, status: "returned", output: result.output }
: {
invocation: message.invocation,
status: "failed",
error: { code: result.error.code, message: result.error.code },
};
await finish(call, fixedError("INVOCATION_ENDED"), reply);
} catch (error) {
if (call.open) {
const code = /^RESTRICTED_[A-Z_]+$/.test(error.code ?? "") ? error.code : "RESTRICTED_PACKAGE_FAILED";
await finish(call, error, { invocation: message.invocation, status: "failed", error: { code, message: code } });
}
}
};
const receive = (message) => {
if (!object(message) || message.abi !== abi) throw fixedError("RESTRICTED_ABI_MISMATCH");
if (message.kind === "shutdown") {
void fail(fixedError("SERVICE_SHUTDOWN"));
return;
}
if (message.kind === "invoke") {
if (
!config.exports.includes(message.exportId) ||
!["snapshot", "transaction"].includes(message.effect) ||
typeof message.invocation !== "string" ||
!message.invocation ||
message.invocation.length > 4096 ||
typeof message.frame !== "string" ||
!message.frame ||
message.frame.length > 4096
)
throw fixedError("INVALID_RESTRICTED_INVOCATION");
if (
message.budget !== undefined &&
(!object(message.budget) ||
Object.keys(message.budget).sort().join(",") !== "calls,deadlineMs,depth,root" ||
typeof message.budget.root !== "string" ||
!message.budget.root ||
message.budget.root.length > 4096 ||
!Number.isSafeInteger(message.budget.depth) ||
message.budget.depth < 0 ||
message.budget.depth > 32 ||
!Number.isSafeInteger(message.budget.calls) ||
message.budget.calls < 1 ||
message.budget.calls > 1000000 ||
!Number.isSafeInteger(message.budget.deadlineMs) ||
message.budget.deadlineMs < 1 ||
message.budget.deadlineMs > 60000)
)
throw fixedError("INVALID_RESTRICTED_BUDGET");
if (active.has(message.invocation) || queued.some((call) => call.message.invocation === message.invocation))
throw fixedError("RESTRICTED_DUPLICATE_INVOCATION");
const call = { message, open: true, pending: new Map(), sequence: 0, nested: (message.budget?.depth ?? 0) > 1 };
// Descendants cannot wait behind ancestors that hold all nested capacity.
// Reject excess fan-out/depth rather than deadlock an admitted call tree.
if (!available(call) && (call.nested || queued.length >= limits.maxQueued)) {
send({
invocation: message.invocation,
status: "failed",
error: { code: "RESTRICTED_QUEUE_FULL", message: "RESTRICTED_QUEUE_FULL" },
});
return;
}
// Deadline includes queue wait: overload cannot extend a transaction frame.
call.timer = setTimeout(
() => {
if (!call.open) return;
void finish(call, fixedError("RESTRICTED_DEADLINE"), {
invocation: message.invocation,
status: "failed",
error: { code: "RESTRICTED_DEADLINE", message: "RESTRICTED_DEADLINE" },
}).catch(fail);
},
Math.min(limits.deadlineMs, message.budget?.deadlineMs ?? limits.deadlineMs),
);
queued.push(call);
pump();
return;
}
const waiting = queued.find(
(call) => call.message.invocation === message.invocation && call.message.frame === message.frame,
);
if (waiting && message.kind === "cancel") {
void finish(waiting, fixedError("INVOCATION_CANCELLED"), {
kind: "cancelled",
invocation: message.invocation,
frame: message.frame,
}).catch(fail);
return;
}
const call = active.get(message.invocation);
if (!call || message.invocation !== call.message.invocation || message.frame !== call.message.frame) {
if (message.kind === "cancel") send({ kind: "cancelled", invocation: message.invocation, frame: message.frame });
else if (message.kind !== "port-result") throw fixedError("RESTRICTED_UNKNOWN_INVOCATION");
return;
}
if (message.kind === "cancel") {
void finish(call, fixedError("INVOCATION_CANCELLED"), {
kind: "cancelled",
invocation: message.invocation,
frame: message.frame,
}).catch(fail);
return;
}
if (message.kind !== "port-result" || !["returned", "failed"].includes(message.status))
throw fixedError("INVALID_RESTRICTED_RESULT");
const pending = call.pending.get(message.sequence);
if (!pending) throw fixedError("RESTRICTED_UNKNOWN_PORT");
call.pending.delete(message.sequence);
pending.resolve(message);
};
io.stdin.on("data", (chunk) => {
if (stopped) return;
try {
let offset = 0;
while (offset < chunk.length) {
const newline = chunk.indexOf(10, offset),
end = newline < 0 ? chunk.length : newline;
if (buffered.length + end - offset > limits.maxBytes) throw fixedError("RESTRICTED_MESSAGE_LIMIT");
buffered = Buffer.concat([buffered, chunk.subarray(offset, end)]);
if (newline < 0) break;
receive(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffered)));
buffered = Buffer.alloc(0);
offset = newline + 1;
if (stopped) break;
}
} catch (error) {
void fail(error);
}
});
io.stdin.on("end", () => void fail(fixedError("COORDINATOR_DISCONNECTED")));
io.stdin.on("error", (error) => void fail(error));
io.stdout.on("error", (error) => void fail(error));
send({ kind: "ready", exports: config.exports });
return { close: () => fail(fixedError("SERVICE_SHUTDOWN")) };
}
if (process.argv[1] === new URL(import.meta.url).pathname) {
const config = JSON.parse(await readFile(process.argv[2], "utf8"));
await serveRestrictedRegistry({
...config,
workerd: process.env.QUIXOS_RESTRICTED_WORKERD,
worker: process.env.QUIXOS_RESTRICTED_WORKER,
module: join(dirname(process.argv[2]), config.module),
});
}