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
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @file Egress labels, after [Flower](https://github.com/NuxiNL/flower)'s
|
||||
* switchboard: string key-value pairs that only accumulate. A missing key
|
||||
* matches anything; a key present on both sides must agree.
|
||||
*
|
||||
* Keys that constrain a URL: `scheme` (`http`, `https`), `host` (name or
|
||||
* address, no port), `port` (explicit or the scheme's default). The
|
||||
* switchboard sets `network` per socket; it constrains no URL.
|
||||
*/
|
||||
|
||||
const DEFAULT_PORT = { http: "80", https: "443" };
|
||||
|
||||
/**
|
||||
* Union of two label sets; throws on a key they give different values,
|
||||
* naming the key but not the values (they may end up in logs).
|
||||
*
|
||||
* @param {Record<string, string>} held
|
||||
* @param {Record<string, string>} more
|
||||
*/
|
||||
export const mergeLabels = (held, more) => {
|
||||
const merged = { ...held };
|
||||
for (const [k, v] of Object.entries(more)) {
|
||||
if (typeof v !== "string") throw new TypeError(`label ${k} is not a string`);
|
||||
if (k in merged && merged[k] !== v) throw new Error(`label ${k} is already set to a different value`);
|
||||
merged[k] = v;
|
||||
}
|
||||
return Object.freeze(merged);
|
||||
};
|
||||
|
||||
/**
|
||||
* The labels a URL carries.
|
||||
*
|
||||
* @param {string} url
|
||||
*/
|
||||
export const urlLabels = (url) => {
|
||||
const u = new URL(url);
|
||||
const scheme = u.protocol.replace(/:$/, "");
|
||||
return { scheme, host: u.hostname, port: u.port || DEFAULT_PORT[scheme] || "" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless `url` is within `labels`.
|
||||
*
|
||||
* @param {Record<string, string>} labels
|
||||
* @param {string} url
|
||||
*/
|
||||
export const checkUrl = (labels, url) => {
|
||||
const actual = urlLabels(url);
|
||||
for (const [k, v] of Object.entries(actual)) {
|
||||
if (k in labels && labels[k] !== v) throw new Error(`egress to ${actual.host} not granted`);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user