capsite/toybox: egress through Flower-style switchboard handles
The switchboard now serves a Cap'n Web Handle on each of its sockets:
public (no loopback or private addresses) and local. A socket's labels
are fixed in config, so holding the socket's fd is holding that handle;
constrain(labels) returns a narrower one, as Flower's Constrain returns
a narrower fd. Labels only accumulate: a missing key matches anything,
and a key both sides set must agree (labels.js, shared by both).
The switchboard enforces the labels; toybox's Egress no longer checks
URLs itself. It opens a session to its one switchboard socket, constrains
the handle to the vat's grant, and forwards through it; a WebSocket
crosses the session as a pair of message streams. A vat narrows its own
grant with env.egress.constrain().
Grants are now labels as JSON. One stored before, a bare host[:port],
reads as {host, port}.
pnpm start and the systemd units give toybox the public handle; the
switchboard unit binds the local one in its own 0700 runtime directory.
The tests give their toybox the local handle to reach an echo server,
and check that a toybox holding only the public one never reaches it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2820c015ee
commit
f0557d29d6
14 files changed
+337
-92
No files matched your search
+59
-9
@@ -1,5 +1,18 @@
|
||||
/** @file toybox supervisor: one Vat per name; its app runs as a facet on installed code. */
|
||||
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { newWebSocketRpcSession } from "capnweb";
|
||||
import { mergeLabels, urlLabels } from "./labels.js";
|
||||
|
||||
/**
|
||||
* A stored grant: labels as JSON, or (before labels) a bare `host[:port]`.
|
||||
*
|
||||
* @param {string} stored
|
||||
*/
|
||||
const grantLabels = (stored) => {
|
||||
if (stored.startsWith("{")) return JSON.parse(stored);
|
||||
const { host, port } = urlLabels(`http://${stored}`);
|
||||
return stored.includes(":") ? { host, port } : { host };
|
||||
};
|
||||
|
||||
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
@@ -27,7 +40,7 @@ export class Vat extends DurableObject {
|
||||
* 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
|
||||
* @param {string | null} egress the app's grant: egress labels as JSON (see labels.js), or none
|
||||
* @returns {Promise<string>} the code's SHA-256, hex
|
||||
*/
|
||||
async start(src, egress) {
|
||||
@@ -47,7 +60,7 @@ export class Vat extends DurableObject {
|
||||
compatibilityDate: "2026-10-01",
|
||||
mainModule: "app.js",
|
||||
modules: { "app.js": src },
|
||||
env: egress ? { egress: this.ctx.exports.Egress({ props: { host: egress } }) } : {},
|
||||
env: egress ? { egress: this.ctx.exports.Egress({ props: { labels: grantLabels(egress) } }) } : {},
|
||||
globalOutbound: null,
|
||||
}));
|
||||
return { class: worker.getDurableObjectClass("App") };
|
||||
@@ -67,14 +80,51 @@ export class Vat extends DurableObject {
|
||||
}
|
||||
|
||||
/**
|
||||
* A grant of one host: `fetch`, WebSocket upgrades included, reaches the
|
||||
* switchboard only for the host in `ctx.props`, which no holder can change.
|
||||
* A vat's egress grant: a switchboard handle constrained to the labels in
|
||||
* `ctx.props`, which no holder can change. The switchboard enforces them;
|
||||
* a refusal comes back as a 403.
|
||||
*/
|
||||
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);
|
||||
/** A narrower grant: these labels too (conflicting ones throw). */
|
||||
constrain(more) {
|
||||
return this.ctx.exports.Egress({ props: { labels: mergeLabels(this.ctx.props.labels, more) } });
|
||||
}
|
||||
|
||||
async #handle() {
|
||||
const resp = await this.env.SWITCHBOARD.fetch("http://switchboard/", { headers: { Upgrade: "websocket" } });
|
||||
const ws = resp.webSocket;
|
||||
if (!ws) throw new Error(`switchboard refused a session: ${resp.status}`);
|
||||
ws.accept();
|
||||
return newWebSocketRpcSession(ws).constrain(this.ctx.props.labels);
|
||||
}
|
||||
|
||||
async fetch(request) {
|
||||
try {
|
||||
const handle = await this.#handle();
|
||||
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return await handle.fetch(request);
|
||||
const protocols = (request.headers.get("Sec-WebSocket-Protocol") ?? "").split(",").map((p) => p.trim()).filter(Boolean);
|
||||
const { readable, writable, protocol } = await handle.webSocket(request.url, protocols);
|
||||
return this.#bridge(readable, writable, protocol);
|
||||
} catch (e) {
|
||||
return new Response(`egress refused: ${e.message}\n`, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/** A WebSocket for the vat whose messages travel the switchboard's streams. */
|
||||
#bridge(readable, writable, protocol) {
|
||||
const [client, server] = Object.values(new WebSocketPair());
|
||||
server.accept();
|
||||
const writer = writable.getWriter();
|
||||
server.addEventListener("message", (e) => writer.write(e.data).catch(() => server.close(1011)));
|
||||
server.addEventListener("close", () => writer.close().catch(() => {}));
|
||||
this.ctx.waitUntil(
|
||||
(async () => {
|
||||
for await (const m of readable) server.send(m);
|
||||
server.close();
|
||||
})().catch(() => server.close(1011)),
|
||||
);
|
||||
const headers = protocol ? { "Sec-WebSocket-Protocol": protocol } : {};
|
||||
return new Response(null, { status: 101, webSocket: client, headers });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +139,7 @@ export class Public extends WorkerEntrypoint {
|
||||
|
||||
/**
|
||||
* The admin socket: `PUT /<vat>` with an ES module body installs and starts
|
||||
* it; `?egress=<host>` grants it that one host.
|
||||
* it; `?egress=<labels as JSON>` grants it egress within those labels.
|
||||
*/
|
||||
export class Admin extends WorkerEntrypoint {
|
||||
async fetch(request) {
|
||||
|
||||
Reference in new issue
Block a user