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
+74
-15
@@ -1,22 +1,81 @@
|
||||
/** @file switchboard: forwards each proxied request, WebSocket upgrades included. */
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
/** @file switchboard: Flower-style egress handles, served over Cap'n Web. */
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
|
||||
import { checkUrl, mergeLabels } from "./labels.js";
|
||||
|
||||
/**
|
||||
* The one place egress happens. A 101 response from `fetch` carries its
|
||||
* `webSocket`; returning it passes the socket through to the caller.
|
||||
*
|
||||
* Which vat may reach which host is decided before a request gets here
|
||||
* (toybox's Egress gateway); this process's network service and its
|
||||
* systemd unit are the outer fence.
|
||||
* One egress handle: reaches only URLs within its labels, through the
|
||||
* network its `network` label names. Like a Flower switchboard handle, it
|
||||
* can only be narrowed; unlike one, it is a Cap'n Web stub, not an fd.
|
||||
*/
|
||||
export class Switchboard extends DurableObject {
|
||||
export class Handle extends RpcTarget {
|
||||
#labels;
|
||||
#net;
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} labels
|
||||
* @param {Fetcher} net
|
||||
*/
|
||||
constructor(labels, net) {
|
||||
super();
|
||||
this.#labels = labels;
|
||||
this.#net = net;
|
||||
}
|
||||
|
||||
/** @param {Record<string, string>} more */
|
||||
constrain(more) {
|
||||
return new Handle(mergeLabels(this.#labels, more), this.#net);
|
||||
}
|
||||
|
||||
labels() {
|
||||
return this.#labels;
|
||||
}
|
||||
|
||||
/** @param {Request} request */
|
||||
fetch(request) {
|
||||
return fetch(request);
|
||||
checkUrl(this.#labels, request.url);
|
||||
return this.#net.fetch(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a WebSocket to `url`; its messages cross the session as streams.
|
||||
*
|
||||
* @param {string} url `http:` or `https:`, as for a `fetch` upgrade
|
||||
* @param {string[]} [protocols]
|
||||
*/
|
||||
async webSocket(url, protocols = []) {
|
||||
checkUrl(this.#labels, url);
|
||||
const headers = { Upgrade: "websocket" };
|
||||
if (protocols.length) headers["Sec-WebSocket-Protocol"] = protocols.join(", ");
|
||||
const resp = await this.#net.fetch(url, { headers });
|
||||
const ws = resp.webSocket;
|
||||
if (!ws) throw new Error(`no websocket: ${resp.status}`);
|
||||
ws.accept();
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
ws.addEventListener("message", (e) => controller.enqueue(e.data));
|
||||
ws.addEventListener("close", () => controller.close());
|
||||
ws.addEventListener("error", () => controller.error(new Error("websocket error")));
|
||||
},
|
||||
});
|
||||
const writable = new WritableStream({
|
||||
write: (chunk) => ws.send(chunk),
|
||||
close: () => ws.close(),
|
||||
abort: () => ws.close(1011),
|
||||
});
|
||||
return { readable, writable, protocol: resp.headers.get("Sec-WebSocket-Protocol") ?? "" };
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
fetch(request, env) {
|
||||
return env.BOARD.getByName("switchboard").fetch(request);
|
||||
},
|
||||
};
|
||||
/**
|
||||
* A switchboard socket: a WebSocket upgrade here starts a Cap'n Web
|
||||
* session whose main object is a {@link Handle} with this socket's labels.
|
||||
*/
|
||||
export default class Root extends WorkerEntrypoint {
|
||||
fetch(request) {
|
||||
const labels = Object.freeze({ ...this.ctx.props });
|
||||
const net = { public: this.env.PUBLIC, local: this.env.LOCAL }[labels.network];
|
||||
if (!net) return new Response("socket has no network\n", { status: 500 });
|
||||
return newWorkersRpcResponse(request, new Handle(labels, net));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user