capsite/toybox: a switchboard, the vats' only way out
A vat granted a host (PUT /<vat>?egress=<host> on the admin socket) gets Egress as its globalOutbound, with the host in ctx.props where the vat can't change it. Egress forwards matching requests, WebSocket upgrades included, to the switchboard and refuses the rest with a 403; with no grant, fetch still throws. The switchboard is a separate workerd: a Switchboard DO that calls fetch and returns the response, so a 101's webSocket passes through. toybox reaches it over a unix socket in HTTP proxy style, which keeps the full URL, scheme included. Its network service is the outer fence, with TLS trusting the browser CAs; systemd units for it join toybox's, and only the toybox group can open its socket. The loaded Worker is now named by vat and incarnation, not by code hash: the same code with a different grant must not share a Worker. The new incarnation table sits beside the old ones; vats installed before it read as incarnation 0 with no grant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
88739d9495
commit
7bf6eb44c7
13 files changed
+279
-51
No files matched your search
+92
-26
@@ -1,12 +1,13 @@
|
||||
/** @file toybox end to end: a real workerd, an admin socket, a public port. */
|
||||
/** @file toybox end to end: a real workerd, an admin socket, a public port, a switchboard. */
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { request } from "node:http";
|
||||
import { createServer } from "node:net";
|
||||
import { createServer as createHttpServer, request } from "node:http";
|
||||
import { connect, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const here = new URL("..", import.meta.url).pathname;
|
||||
// the package binary, not the .bin shim: the shim forks it, so a kill would orphan workerd
|
||||
@@ -21,11 +22,19 @@ const freePort = () =>
|
||||
});
|
||||
});
|
||||
|
||||
/** Resolves once something accepts connections at `opts` (a port or a socket path). */
|
||||
const accepting = (opts) =>
|
||||
new Promise((resolve) => {
|
||||
const s = connect(opts, () => (s.end(), resolve(true)));
|
||||
s.on("error", () => resolve(false));
|
||||
});
|
||||
|
||||
/** @param {string} socketPath the admin socket; holding it is the authority to install */
|
||||
const makeAdmin = (socketPath) => ({
|
||||
install: (vat, src) =>
|
||||
install: (vat, src, egress) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = request({ socketPath, path: `/${vat}`, method: "PUT", agent: false }, (res) => {
|
||||
const path = egress ? `/${vat}?egress=${encodeURIComponent(egress)}` : `/${vat}`;
|
||||
const req = request({ socketPath, path, method: "PUT", agent: false }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (c) => (body += c));
|
||||
res.on("end", () => resolve(body.trim()));
|
||||
@@ -37,49 +46,80 @@ const makeAdmin = (socketPath) => ({
|
||||
|
||||
/** @param {string} base the public origin */
|
||||
const makePublic = (base) => ({
|
||||
get: async (vat) => (await fetch(`${base}/${vat}`)).text(),
|
||||
get: async (path) => (await fetch(`${base}/${path}`)).text(),
|
||||
put: async (vat, src) => (await fetch(`${base}/${vat}`, { method: "PUT", body: src })).status,
|
||||
});
|
||||
|
||||
/** Run workerd on `dir`'s state until `stop()`; resolves once the port answers. */
|
||||
const startWorkerd = async (dir, port) => {
|
||||
await rm(join(dir, "admin.sock"), { force: true }); // left behind by a crash; bind() would fail
|
||||
const child = spawn(
|
||||
workerd,
|
||||
["serve", "--experimental", join(here, "config.capnp"),
|
||||
"-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`,
|
||||
"-d", `state=${join(dir, "state")}`],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
/**
|
||||
* Run workerd on `config` until `stop()`; resolves once `listening` accepts.
|
||||
*
|
||||
* @param {string} config
|
||||
* @param {string[]} args socket, directory, and external overrides
|
||||
* @param {{ port: number } | { path: string }} listening
|
||||
* @param {string[]} stale socket files a crash leaves behind; bind() would fail on them
|
||||
*/
|
||||
const startWorkerd = async (config, args, listening, stale = []) => {
|
||||
for (const f of stale) await rm(f, { force: true });
|
||||
const child = spawn(workerd, ["serve", "--experimental", join(here, config), ...args], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
let log = "";
|
||||
child.stderr.on("data", (c) => (log += c));
|
||||
const exited = new Promise((resolve) => child.on("exit", resolve));
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/`);
|
||||
// SIGKILL: a crash is the harder durability test
|
||||
return { stop: () => (child.kill("SIGKILL"), exited) };
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
// SIGKILL: a crash is the harder durability test
|
||||
if (await accepting(listening)) return { stop: () => (child.kill("SIGKILL"), exited) };
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
throw new Error(`workerd did not start: ${log}`);
|
||||
};
|
||||
|
||||
let dir, port, server, admin, pub;
|
||||
/** An HTTP server that answers GET with "plain ok" and echoes WebSocket messages. */
|
||||
const startEcho = async () => {
|
||||
const server = createHttpServer((_req, res) => res.end("plain ok"));
|
||||
const wss = new WebSocketServer({ server });
|
||||
wss.on("connection", (ws) => ws.on("message", (m) => ws.send(String(m))));
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return {
|
||||
host: `127.0.0.1:${server.address().port}`,
|
||||
stop: () => new Promise((resolve) => (wss.close(), server.closeAllConnections(), server.close(resolve))),
|
||||
};
|
||||
};
|
||||
|
||||
let dir, port, server, switchboard, echo, admin, pub;
|
||||
|
||||
const startToybox = () =>
|
||||
startWorkerd(
|
||||
"config.capnp",
|
||||
[
|
||||
"-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`,
|
||||
"-d", `state=${join(dir, "state")}`, "-e", `switchboard=unix:${join(dir, "switchboard.sock")}`,
|
||||
],
|
||||
{ port, host: "127.0.0.1" },
|
||||
[join(dir, "admin.sock")],
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "toybox-"));
|
||||
await mkdir(join(dir, "state"));
|
||||
port = await freePort();
|
||||
server = await startWorkerd(dir, port);
|
||||
echo = await startEcho();
|
||||
switchboard = await startWorkerd(
|
||||
"switchboard.capnp",
|
||||
["-s", `http=unix:${join(dir, "switchboard.sock")}`],
|
||||
{ path: join(dir, "switchboard.sock") },
|
||||
[join(dir, "switchboard.sock")],
|
||||
);
|
||||
server = await startToybox();
|
||||
admin = makeAdmin(join(dir, "admin.sock"));
|
||||
pub = makePublic(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.stop();
|
||||
await switchboard?.stop();
|
||||
await echo?.stop();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -114,6 +154,32 @@ test("code and state survive a restart", async () => {
|
||||
await admin.install("dave", await app("counter-v1.js"));
|
||||
assert.equal(await pub.get("dave"), "v1 count 1\n");
|
||||
await server.stop();
|
||||
server = await startWorkerd(dir, port);
|
||||
server = await startToybox();
|
||||
assert.equal(await pub.get("dave"), "v1 count 2\n");
|
||||
});
|
||||
|
||||
const probe = (vat, target, ws) =>
|
||||
pub.get(`${vat}?url=${encodeURIComponent(`http://${target}/`)}${ws ? "&ws=1" : ""}`);
|
||||
|
||||
test("a vat granted a host reaches it through the switchboard: fetch and WebSocket", async () => {
|
||||
await admin.install("erin", await app("egress-probe.js"), echo.host);
|
||||
assert.equal(await probe("erin", echo.host), "got: plain ok\n");
|
||||
assert.equal(await probe("erin", echo.host, true), "echo: hello\n");
|
||||
});
|
||||
|
||||
test("same code, no grant: no fetch", async () => {
|
||||
await admin.install("frank", await app("egress-probe.js"));
|
||||
assert.match(await probe("frank", echo.host), /^refused: This worker is not permitted to access the internet/);
|
||||
});
|
||||
|
||||
test("a grant covers one host only", async () => {
|
||||
await admin.install("grace", await app("egress-probe.js"), "127.0.0.1:1");
|
||||
assert.equal(await probe("grace", echo.host), `got: egress to ${echo.host} not granted\n\n`);
|
||||
assert.match(await probe("grace", echo.host, true), /^no websocket: 403 egress to .* not granted/);
|
||||
});
|
||||
|
||||
test("a grant survives a restart", async () => {
|
||||
await server.stop();
|
||||
server = await startToybox();
|
||||
assert.equal(await probe("erin", echo.host, true), "echo: hello\n");
|
||||
});
|
||||
Reference in new issue
Block a user