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
+42
-12
@@ -4,40 +4,51 @@ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
||||
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
/**
|
||||
* Holds the vat's installed code; the app facet holds the app's state.
|
||||
* Holds the vat's installed code and egress grant; the app facet holds the app's state.
|
||||
*
|
||||
* Code is stored by hash so the facet can be rebuilt after hibernation or a
|
||||
* restart. The app gets an empty `env` and no `fetch`.
|
||||
* restart. The app gets an empty `env`; its `fetch` reaches only the host it
|
||||
* was granted, if any, through {@link Egress}.
|
||||
*
|
||||
* Each `start` begins a new incarnation, which names the loaded Worker: the
|
||||
* same code with a different grant must not share a Worker.
|
||||
*/
|
||||
export class Vat extends DurableObject {
|
||||
constructor(ctx, env) {
|
||||
super(ctx, env);
|
||||
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS bundle (hash TEXT PRIMARY KEY, src TEXT)");
|
||||
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS current (k INTEGER PRIMARY KEY CHECK (k = 0), hash TEXT)");
|
||||
ctx.storage.sql.exec(
|
||||
"CREATE TABLE IF NOT EXISTS incarnation (k INTEGER PRIMARY KEY CHECK (k = 0), n INTEGER, egress TEXT)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `src` and run the app on it; the app's storage carries over.
|
||||
*
|
||||
* @param {string} src an ES module exporting `class App extends DurableObject`
|
||||
* @param {string | null} egress the one host (`name[:port]`) the app may fetch, or none
|
||||
* @returns {Promise<string>} the code's SHA-256, hex
|
||||
*/
|
||||
async start(src) {
|
||||
async start(src, egress) {
|
||||
const hash = hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(src)));
|
||||
this.ctx.storage.sql.exec("INSERT OR IGNORE INTO bundle VALUES (?, ?)", hash, src);
|
||||
this.ctx.storage.sql.exec("INSERT OR REPLACE INTO current VALUES (0, ?)", hash);
|
||||
const { sql } = this.ctx.storage;
|
||||
sql.exec("INSERT OR IGNORE INTO bundle VALUES (?, ?)", hash, src);
|
||||
sql.exec("INSERT OR REPLACE INTO current VALUES (0, ?)", hash);
|
||||
const [prev] = sql.exec("SELECT n FROM incarnation").toArray();
|
||||
sql.exec("INSERT OR REPLACE INTO incarnation VALUES (0, ?, ?)", (prev?.n ?? 0) + 1, egress);
|
||||
this.ctx.facets.abort("app", new Error("upgraded"));
|
||||
return hash;
|
||||
}
|
||||
|
||||
#app(hash, src) {
|
||||
#app({ hash, src, n, egress }) {
|
||||
return this.ctx.facets.get("app", () => {
|
||||
const worker = this.env.LOADER.get(hash, () => ({
|
||||
const worker = this.env.LOADER.get(`${this.ctx.id}:${n}`, () => ({
|
||||
compatibilityDate: "2026-10-01",
|
||||
mainModule: "app.js",
|
||||
modules: { "app.js": src },
|
||||
env: {},
|
||||
globalOutbound: null,
|
||||
globalOutbound: egress ? this.ctx.exports.Egress({ props: { host: egress } }) : null,
|
||||
}));
|
||||
return { class: worker.getDurableObjectClass("App") };
|
||||
});
|
||||
@@ -45,10 +56,25 @@ export class Vat extends DurableObject {
|
||||
|
||||
fetch(request) {
|
||||
const [row] = this.ctx.storage.sql
|
||||
.exec("SELECT b.hash, b.src FROM current c JOIN bundle b USING (hash)")
|
||||
.exec(
|
||||
`SELECT b.hash, b.src, coalesce(i.n, 0) AS n, i.egress
|
||||
FROM current c JOIN bundle b USING (hash) LEFT JOIN incarnation i ON i.k = 0`,
|
||||
)
|
||||
.toArray();
|
||||
if (!row) return new Response("no code installed\n", { status: 404 });
|
||||
return this.#app(row.hash, row.src).fetch(request);
|
||||
return this.#app(row).fetch(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A vat's `globalOutbound`: its `fetch`, WebSocket upgrades included, reaches
|
||||
* the switchboard only for the host in `ctx.props`, which the vat can't set.
|
||||
*/
|
||||
export class Egress extends WorkerEntrypoint {
|
||||
fetch(request) {
|
||||
const { host } = new URL(request.url);
|
||||
if (host !== this.ctx.props.host) return new Response(`egress to ${host} not granted\n`, { status: 403 });
|
||||
return this.env.SWITCHBOARD.fetch(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +87,15 @@ export class Public extends WorkerEntrypoint {
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin socket: `PUT /<vat>` with an ES module body installs and starts it. */
|
||||
/**
|
||||
* The admin socket: `PUT /<vat>` with an ES module body installs and starts
|
||||
* it; `?egress=<host>` grants it that one host.
|
||||
*/
|
||||
export class Admin extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
if (request.method !== "PUT") return new Response("PUT code to /<vat>\n", { status: 405 });
|
||||
return new Response((await vatOf(this.env, request.url).start(await request.text())) + "\n");
|
||||
const egress = new URL(request.url).searchParams.get("egress");
|
||||
return new Response((await vatOf(this.env, request.url).start(await request.text(), egress)) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user