diff --git a/toybox/README.md b/toybox/README.md index 6df609a..3e1ab5f 100644 --- a/toybox/README.md +++ b/toybox/README.md @@ -1,16 +1,29 @@ # toybox -Vats whose code is installed at runtime: each vat is a Durable Object whose app runs as a [facet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) on code loaded through workerd's Worker Loader. Think `vatAdmin.createVat(bundle)`, with a supervisor per vat. +Vats whose code is installed at runtime: each vat is a Durable Object whose app runs as a [facet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/) on code loaded through workerd's Worker Loader. Think `vatAdmin.createVat(bundle)`, with a supervisor per vat. Vats have no names: creating one returns its id, which addresses it only on the admin socket. Everything else a vat can do or be reached by is granted explicitly: egress by labels it may fetch within, ingress by labels it is mounted at. -- [supervisor.js](supervisor.js): `Vat` stores code by SHA-256 and runs the app on it; `Public` and `Admin` are the two entrypoints; `Egress` is a grant of one host. +- [supervisor.js](supervisor.js): `Vat` stores code by SHA-256 and runs the app on it; `Directory` holds the mounts; `Public` (the ingress) and `Admin` are the two entrypoints; `Egress` is an egress grant. - [switchboard.js](switchboard.js): a separate workerd process, the vats' only way out, after [Flower](https://github.com/NuxiNL/flower)'s switchboard: each of its sockets serves a Cap'n Web `Handle` with fixed labels, and `constrain(labels)` returns a narrower one. -- [labels.js](labels.js): Flower's label rule, shared by both: labels only accumulate, a missing key matches anything, and a key both sides set must agree. +- [labels.js](labels.js): Flower's label rule, shared by both: labels only accumulate, a missing key matches anything, and a key both sides set must agree. Ingress lookup is stricter (below). - [config.capnp](config.capnp): the sockets have no addresses; whoever starts workerd hands them in, so holding a socket is the authority it grants. | socket | reaches | what its holder can do | |---|---|---| -| `http` | `Public` | `GET //…`: talk to a vat's app | -| `admin` | `Admin` | `PUT /` with an ES module body: install it and (re)start the app; its storage carries over. `?egress=` grants the app egress within those labels. | +| `http` | `Public` | reach the vat mounted at labels the request carries | +| `admin` | `Admin` | `POST /` with an ES module body: create a vat and start it; the reply is `{"vat": , "hash": }`. `PUT /` with a body: upgrade it; its storage carries over. `PUT /by-name/`: the same, for a vat from before ids. | + +Every start states the vat's grants, as JSON labels in query parameters; absent means none: + +- `?egress=`: what the app may fetch (below). +- `?mount=`: where the ingress finds it. A mount must conflict with every other one (some shared key, different values), so no request can carry two mounts' labels; an overlapping mount is a 409. + +## Ingress + +A request's labels are its query parameters, plus a `Toybox-Labels` header (JSON) for secret ones. A URL ends up in logs and `Referer`s, so a secret label belongs in the header: a webkey page keeps the secret in its fragment and sends it in-band. A key given twice is a 400. + +The request reaches the vat whose mount labels it carries, every one with the same value. That is stricter than Flower's match (no conflicting key): an ingress client holds no constrained handle, so a label it omits must not match, or a secret label would protect nothing. The path is the app's own. The app sees the mount's and the request's labels together in `Toybox-Connection-Labels` (JSON), as a Flower server gets its connection labels. Responses carry `Referrer-Policy: no-referrer`. + +## Egress An app must export `class App extends DurableObject` (see [apps/](apps/)). Its global `fetch` always throws: nothing in a vat is ambient. A grant arrives as `env.egress`, an `Egress` with the labels in `ctx.props`. Its `fetch`, WebSocket upgrades included, goes through a switchboard handle constrained to those labels; the switchboard refuses the rest, which comes back as a 403. `env.egress.constrain(labels)` returns a narrower `Egress`. It is an object, so the app can pass it to whichever of its parts should have it. @@ -27,7 +40,7 @@ The switchboard has two sockets, so two root handles: `public` (no loopback or p ```sh pnpm install -pnpm test # real workerds: install, upgrade, isolation, egress grants and constrain, fetch and WebSocket out, crash and restart +pnpm test # real workerds: createVat, upgrade, mounts, secret labels, egress grants and constrain, fetch and WebSocket out, crash and restart pnpm switchboard # systemd-socket-activate on ./switchboard-public.sock and ./switchboard-local.sock pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state ``` @@ -35,14 +48,15 @@ pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./stat Then, in a third terminal: ```sh -curl -X PUT --unix-socket admin.sock --data-binary @apps/counter-v1.js http://toybox/alice -curl localhost:8787/alice # v1 count 1 -curl -X PUT --unix-socket admin.sock --data-binary @apps/counter-v2.js http://toybox/alice -curl localhost:8787/alice # v2 count 2 +admin() { curl -sg --unix-socket admin.sock "$@"; } +alice=$(admin -X POST --data-binary @apps/counter-v1.js 'http://toybox/?mount={"vat":"alice"}' | jq -r .vat) +curl 'localhost:8787/?vat=alice' # v1 count 1 +admin -X PUT --data-binary @apps/counter-v2.js "http://toybox/$alice?mount={\"vat\":\"alice\"}" +curl 'localhost:8787/?vat=alice' # v2 count 2 -curl -g -X PUT --unix-socket admin.sock --data-binary @apps/egress-probe.js 'http://toybox/erin?egress={"host":"example.com"}' -curl 'localhost:8787/erin?url=https://example.com/' # got: … -curl 'localhost:8787/erin?url=https://example.org/' # got: egress refused: egress to example.org not granted +admin -X POST --data-binary @apps/egress-probe.js 'http://toybox/?mount={"vat":"erin"}&egress={"host":"example.com"}' +curl 'localhost:8787/?vat=erin&url=https://example.com/' # got: … +curl 'localhost:8787/?vat=erin&url=https://example.org/' # got: egress refused: egress to example.org not granted ``` ## Under systemd diff --git a/toybox/apps/show-labels.js b/toybox/apps/show-labels.js new file mode 100644 index 0000000..a061b9c --- /dev/null +++ b/toybox/apps/show-labels.js @@ -0,0 +1,9 @@ +import { DurableObject } from "cloudflare:workers"; + +/** Reports its path and its connection labels. */ +export class App extends DurableObject { + fetch(request) { + const { pathname } = new URL(request.url); + return new Response(`${pathname} ${request.headers.get("Toybox-Connection-Labels")}\n`); + } +} diff --git a/toybox/config.capnp b/toybox/config.capnp index 37403b8..a361282 100644 --- a/toybox/config.capnp +++ b/toybox/config.capnp @@ -15,9 +15,9 @@ const config :Workerd.Config = ( (name = "state", disk = (path = "state", writable = true)), ], sockets = [ - # reach a running vat by name + # the ingress: reach the vat mounted at a request's labels (name = "http", http = (), service = (name = "main", entrypoint = "Public")), - # install code into a vat and (re)start it + # create vats, install code, grant egress and ingress (name = "admin", http = (), service = (name = "main", entrypoint = "Admin")), ], ); @@ -29,10 +29,14 @@ const supervisor :Workerd.Worker = ( (name = "labels.js", esModule = embed "labels.js"), (name = "capnweb", esModule = embed "node_modules/capnweb/dist/index-workers.js"), ], - durableObjectNamespaces = [(className = "Vat", uniqueKey = "toybox-vat", enableSql = true)], + durableObjectNamespaces = [ + (className = "Vat", uniqueKey = "toybox-vat", enableSql = true), + (className = "Directory", uniqueKey = "toybox-directory", enableSql = true), + ], durableObjectStorage = (localDisk = "state"), bindings = [ (name = "VATS", durableObjectNamespace = "Vat"), + (name = "DIRECTORY", durableObjectNamespace = "Directory"), (name = "LOADER", workerLoader = ()), (name = "SWITCHBOARD", service = "switchboard"), ], diff --git a/toybox/labels.js b/toybox/labels.js index 75f36ad..2887b56 100644 --- a/toybox/labels.js +++ b/toybox/labels.js @@ -50,3 +50,24 @@ export const checkUrl = (labels, url) => { if (k in labels && labels[k] !== v) throw new Error(`egress to ${actual.host} not granted`); } }; + +/** + * Do `a` and `b` give some key different values? Flower refuses to register + * a target that doesn't conflict with every other: lookups stay unambiguous. + * + * @param {Record} a + * @param {Record} b + */ +export const conflict = (a, b) => Object.entries(a).some(([k, v]) => k in b && b[k] !== v); + +/** + * Does `request` carry every label of `mount`, with the same values? + * + * Stricter than Flower's match (no conflict): an ingress client holds no + * constrained handle, so a label it omits must not match, or a secret + * label would protect nothing. + * + * @param {Record} mount + * @param {Record} request + */ +export const carries = (mount, request) => Object.entries(mount).every(([k, v]) => request[k] === v); diff --git a/toybox/supervisor.js b/toybox/supervisor.js index 0b543a7..f255f8c 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -1,7 +1,7 @@ -/** @file toybox supervisor: one Vat per name; its app runs as a facet on installed code. */ +/** @file toybox supervisor: vats with code installed at runtime, each app a facet; ingress by mount, egress by grant. */ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers"; import { newWebSocketRpcSession } from "capnweb"; -import { mergeLabels, urlLabels } from "./labels.js"; +import { carries, conflict, mergeLabels, urlLabels } from "./labels.js"; /** * A stored grant: labels as JSON, or (before labels) a bare `host[:port]`. @@ -17,7 +17,8 @@ const grantLabels = (stored) => { const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); /** - * Holds the vat's installed code and egress grant; the app facet holds the app's state. + * One vat, by DO id: holds its installed code and egress grant; the app + * facet holds the app's state. Its mount, if any, is in the {@link Directory}. * * Code is stored by hash so the facet can be rebuilt after hibernation or a * restart. The app's global `fetch` throws; a granted host arrives as @@ -128,24 +129,143 @@ export class Egress extends WorkerEntrypoint { } } -const vatOf = (env, url) => env.VATS.getByName(new URL(url).pathname.split("/")[1]); +/** + * Mounts: which vat serves which label set. One instance, by name. + * + * Registration follows Flower: a mount must conflict with every other, so + * no request can carry two mounts' labels. Lookup is stricter than + * Flower's: the request must carry all of a mount's labels. + */ +export class Directory extends DurableObject { + constructor(ctx, env) { + super(ctx, env); + ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS mount (vat TEXT PRIMARY KEY, labels TEXT)"); + } -/** The http socket: `//...` goes to that vat's app. */ -export class Public extends WorkerEntrypoint { - fetch(request) { - return vatOf(this.env, request.url).fetch(request); + #mounts() { + return this.ctx.storage.sql + .exec("SELECT vat, labels FROM mount") + .toArray() + .map(({ vat, labels }) => ({ vat, labels: JSON.parse(labels) })); + } + + /** + * Mount `vat` at `labels`, replacing its mount; `null` unmounts it. + * + * @param {string} vat a Vat's DO id, hex + * @param {Record | null} labels + */ + mount(vat, labels) { + if (labels) { + const overlap = this.#mounts().find((m) => m.vat !== vat && !conflict(m.labels, labels)); + if (overlap) throw new Error("mount overlaps another: add a label whose value tells them apart"); + this.ctx.storage.sql.exec("INSERT OR REPLACE INTO mount VALUES (?, ?)", vat, JSON.stringify(labels)); + } else { + this.ctx.storage.sql.exec("DELETE FROM mount WHERE vat = ?", vat); + } + } + + /** + * The mount whose labels `request` carries, if any. + * + * @param {Record} request + */ + lookup(request) { + return this.#mounts().find((m) => carries(m.labels, request)) ?? null; } } /** - * The admin socket: `PUT /` with an ES module body installs and starts - * it; `?egress=` grants it egress within those labels. + * A request's labels: its query parameters, plus a `Toybox-Labels` header + * (JSON) for labels too secret for a URL, which ends up in logs and + * `Referer`s. A webkey page keeps such a secret in its fragment and sends + * it in the header. + * + * @param {Request} request + */ +const requestLabels = (request) => { + const query = {}; + for (const [k, v] of new URL(request.url).searchParams) { + if (k in query) throw new Error(`label ${k} given twice`); + query[k] = v; + } + const header = request.headers.get("Toybox-Labels"); + return mergeLabels(query, header ? JSON.parse(header) : {}); +}; + +/** + * The http socket: the ingress. A request reaches the vat mounted at labels + * it carries, which sees the mount's and the request's labels together in + * a `Toybox-Connection-Labels` header (JSON), as a Flower server gets its + * connection labels. + */ +export class Public extends WorkerEntrypoint { + async fetch(request) { + let labels; + try { + labels = requestLabels(request); + } catch (e) { + return new Response(`bad labels: ${e.message}\n`, { status: 400 }); + } + const mount = await this.env.DIRECTORY.getByName("directory").lookup(labels); + if (!mount) return new Response("nothing mounted here\n", { status: 404 }); + const forwarded = new Request(request); + forwarded.headers.set("Toybox-Connection-Labels", JSON.stringify(mergeLabels(mount.labels, labels))); + const resp = await this.env.VATS.get(this.env.VATS.idFromString(mount.vat)).fetch(forwarded); + if (resp.webSocket) return resp; + const sealed = new Response(resp.body, resp); + sealed.headers.set("Referrer-Policy", "no-referrer"); + return sealed; + } +} + +/** + * Grants in a query parameter, as JSON labels; absent means none. + * + * @param {URL} url + * @param {string} name + */ +const grantParam = (url, name) => { + const v = url.searchParams.get(name); + return v ? JSON.parse(v) : null; +}; + +/** + * The admin socket: `createVat` and its `adminNode`, over HTTP. + * + * - `POST /` with an ES module body creates a vat and starts it; the reply + * is `{"vat": , "hash": }`, and the id addresses it here. + * - `PUT /` with a body upgrades it; its storage carries over. + * - `PUT /by-name/` does the same for a vat from before ids, when + * vats were named. + * + * Every start states the vat's grants, absent meaning none: + * `?egress=` (see {@link Egress}) and `?mount=` (ingress: + * see {@link Public}). */ export class Admin extends WorkerEntrypoint { async fetch(request) { - if (request.method !== "PUT") return new Response("PUT code to /\n", { status: 405 }); - const egress = new URL(request.url).searchParams.get("egress"); - return new Response((await vatOf(this.env, request.url).start(await request.text(), egress)) + "\n"); + const url = new URL(request.url); + const [, first, second] = url.pathname.split("/"); + let id; + if (request.method === "POST" && !first) id = this.env.VATS.newUniqueId(); + else if (request.method === "PUT" && first === "by-name" && second) id = this.env.VATS.idFromName(second); + else if (request.method === "PUT" && first && !second) { + try { + id = this.env.VATS.idFromString(first); + } catch { + return new Response("no such vat\n", { status: 404 }); + } + } else return new Response("POST / or PUT / or PUT /by-name/, with code\n", { status: 405 }); + const vat = id.toString(); + try { + await this.env.DIRECTORY.getByName("directory").mount(vat, grantParam(url, "mount")); + } catch (e) { + return new Response(`${e.message}\n`, { status: 409 }); + } + const egress = url.searchParams.get("egress"); + const hash = await this.env.VATS.get(id).start(await request.text(), egress); + return Response.json({ vat, hash }); } } diff --git a/toybox/test/vat.test.js b/toybox/test/vat.test.js index 63709c4..23531a2 100644 --- a/toybox/test/vat.test.js +++ b/toybox/test/vat.test.js @@ -29,26 +29,42 @@ const accepting = (opts) => s.on("error", () => resolve(false)); }); -/** @param {string} socketPath the admin socket; holding it is the authority to install */ -const makeAdmin = (socketPath) => ({ - install: (vat, src, labels) => +/** + * The admin socket: holding it is the authority to create and upgrade vats. + * + * @param {string} socketPath + */ +const makeAdmin = (socketPath) => { + const send = (method, path, src, { egress, mount } = {}) => new Promise((resolve, reject) => { - const grant = typeof labels === "string" ? labels : JSON.stringify(labels); - const path = labels ? `/${vat}?egress=${encodeURIComponent(grant)}` : `/${vat}`; - const req = request({ socketPath, path, method: "PUT", agent: false }, (res) => { + const q = new URLSearchParams(); + if (egress) q.set("egress", typeof egress === "string" ? egress : JSON.stringify(egress)); + if (mount) q.set("mount", JSON.stringify(mount)); + const req = request({ socketPath, path: `${path}?${q}`, method, agent: false }, (res) => { let body = ""; res.on("data", (c) => (body += c)); - res.on("end", () => resolve(body.trim())); + res.on("end", () => resolve(res.statusCode === 200 ? JSON.parse(body) : { status: res.statusCode, body })); }); req.on("error", reject); req.end(src); - }), -}); + }); + return { + /** `createVat`: a new vat, reachable only by its id here, and by its mount */ + create: (src, grants) => send("POST", "/", src, grants), + upgrade: (vat, src, grants) => send("PUT", `/${vat}`, src, grants), + /** a vat from before ids */ + byName: (name, src, grants) => send("PUT", `/by-name/${name}`, src, grants), + }; +}; /** @param {string} base the public origin */ const makePublic = (base) => ({ - get: async (path) => (await fetch(`${base}/${path}`)).text(), - put: async (vat, src) => (await fetch(`${base}/${vat}`, { method: "PUT", body: src })).status, + get: async (pathAndQuery, headers = {}) => { + const resp = await fetch(`${base}${pathAndQuery}`, { headers }); + return { status: resp.status, text: await resp.text(), referrerPolicy: resp.headers.get("Referrer-Policy") }; + }, + text: async (pathAndQuery) => (await fetch(`${base}${pathAndQuery}`)).text(), + put: async (pathAndQuery, src) => (await fetch(`${base}${pathAndQuery}`, { method: "PUT", body: src })).status, }); /** @@ -137,72 +153,124 @@ after(async () => { await rm(dir, { recursive: true, force: true }); }); -test("a vat with no code is 404", async () => { - assert.equal(await pub.get("nobody"), "no code installed\n"); +const at = (name) => ({ mount: { vat: name } }); + +test("nothing mounted: 404", async () => { + assert.equal((await pub.get("/?vat=nobody")).status, 404); }); -test("install, run, upgrade: the app's state carries over", async () => { - assert.match(await admin.install("alice", await app("counter-v1.js")), /^[0-9a-f]{64}$/); - assert.equal(await pub.get("alice"), "v1 count 1\n"); - assert.equal(await pub.get("alice"), "v1 count 2\n"); - await admin.install("alice", await app("counter-v2.js")); - assert.equal(await pub.get("alice"), "v2 count 3\n"); +test("createVat returns an id and a code hash; unmounted, the vat is unreachable", async () => { + const { vat, hash } = await admin.create(await app("counter-v1.js")); + assert.match(vat, /^[0-9a-f]{64}$/); + assert.match(hash, /^[0-9a-f]{64}$/); + assert.equal((await pub.get("/")).status, 404); +}); + +test("install, run, upgrade: the app's state carries over; every start states its grants", async () => { + const { vat } = await admin.create(await app("counter-v1.js"), at("alice")); + assert.equal(await pub.text("/?vat=alice"), "v1 count 1\n"); + assert.equal(await pub.text("/?vat=alice"), "v1 count 2\n"); + await admin.upgrade(vat, await app("counter-v2.js"), at("alice")); + assert.equal(await pub.text("/?vat=alice"), "v2 count 3\n"); + await admin.upgrade(vat, await app("counter-v2.js")); + assert.equal((await pub.get("/?vat=alice")).status, 404); + await admin.upgrade(vat, await app("counter-v2.js"), at("alice")); + assert.equal(await pub.text("/?vat=alice"), "v2 count 4\n"); }); test("vats don't share state", async () => { - await admin.install("carol", await app("counter-v1.js")); - assert.equal(await pub.get("carol"), "v1 count 1\n"); + await admin.create(await app("counter-v1.js"), at("bob")); + await admin.create(await app("counter-v1.js"), at("carol")); + assert.equal(await pub.text("/?vat=bob"), "v1 count 1\n"); + assert.equal(await pub.text("/?vat=carol"), "v1 count 1\n"); }); test("an app gets no fetch", async () => { - await admin.install("bob", await app("try-fetch.js")); - assert.match(await pub.get("bob"), /^fetch refused: This worker is not permitted to access the internet/); + await admin.create(await app("try-fetch.js"), at("bob-fetch")); + assert.match(await pub.text("/?vat=bob-fetch"), /^fetch refused: This worker is not permitted to access the internet/); }); test("the public port can't install code", async () => { - assert.equal(await pub.put("mallory", "export class App {}"), 404); - assert.equal(await pub.get("mallory"), "no code installed\n"); + assert.equal(await pub.put("/", "export class App {}"), 404); }); -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"); +test("the path is the app's; its labels are the mount's and the request's together", async () => { + await admin.create(await app("show-labels.js"), at("lab")); + const got = await pub.get("/a/b?vat=lab&page=2"); + assert.equal(got.text, `/a/b ${JSON.stringify({ vat: "lab", page: "2" })}\n`); + assert.equal(got.referrerPolicy, "no-referrer"); +}); + +test("a label given twice is a 400", async () => { + assert.equal((await pub.get("/?vat=lab&vat=lab")).status, 400); +}); + +test("mounts that could both match a request are refused", async () => { + const src = await app("show-labels.js"); + await admin.create(src, at("x")); + const narrower = await admin.create(src, { mount: { vat: "x", n: "2" } }); + assert.equal(narrower.status, 409); + assert.match(narrower.body, /overlaps another/); + // no key in common with the {vat} mounts: ?vat=x&app=x would carry both + assert.equal((await admin.create(src, { mount: { app: "x" } })).status, 409); + assert.match((await admin.create(src, { mount: { vat: "y", n: "2" } })).vat, /^[0-9a-f]{64}$/); +}); + +test("a secret label: in a header, not the URL; omitting it doesn't match", async () => { + const secret = "k".repeat(43); + await admin.create(await app("show-labels.js"), { mount: { vat: "safe", key: secret } }); + assert.equal((await pub.get("/?vat=safe")).status, 404); + assert.equal((await pub.get("/?vat=safe&key=wrong")).status, 404); + const got = await pub.get("/?vat=safe", { "Toybox-Labels": JSON.stringify({ key: secret }) }); + assert.equal(got.status, 200); +}); + +test("code, state, and mount survive a restart", async () => { + await admin.create(await app("counter-v1.js"), at("dave")); + assert.equal(await pub.text("/?vat=dave"), "v1 count 1\n"); await server.stop(); server = await startToybox("main", port, "local"); - assert.equal(await pub.get("dave"), "v1 count 2\n"); + assert.equal(await pub.text("/?vat=dave"), "v1 count 2\n"); +}); + +test("a vat from before ids is unreachable until the admin mounts it by its old name", async () => { + await admin.byName("old", await app("counter-v1.js")); + assert.equal((await pub.get("/?vat=old")).status, 404); + await admin.byName("old", await app("counter-v1.js"), at("old")); + assert.equal(await pub.text("/?vat=old"), "v1 count 1\n"); }); const probe = (vat, target, { ws, constrain } = {}) => - pub.get( - `${vat}?url=${encodeURIComponent(`http://${target}/`)}` + + pub.text( + `/?vat=${vat}&url=${encodeURIComponent(`http://${target}/`)}` + (ws ? "&ws=1" : "") + (constrain ? `&constrain=${encodeURIComponent(JSON.stringify(constrain))}` : ""), ); test("a vat granted a host reaches it through the switchboard: fetch and WebSocket", async () => { - await admin.install("erin", await app("egress-probe.js"), echoLabels); + await admin.create(await app("egress-probe.js"), { ...at("erin"), egress: echoLabels }); assert.equal(await probe("erin", echo.host), "got: plain ok\n"); assert.equal(await probe("erin", echo.host, { ws: true }), "echo: hello\n"); }); test("same code, no grant: no egress", async () => { - await admin.install("frank", await app("egress-probe.js")); + await admin.create(await app("egress-probe.js"), at("frank")); assert.equal(await probe("frank", echo.host), "no egress granted\n"); }); test("a grant is an object, not ambient: the global fetch still throws", async () => { - await admin.install("heidi", await app("try-fetch.js"), echoLabels); - assert.match(await pub.get("heidi"), /^fetch refused: This worker is not permitted to access the internet/); + await admin.create(await app("try-fetch.js"), { ...at("heidi"), egress: echoLabels }); + assert.match(await pub.text("/?vat=heidi"), /^fetch refused: This worker is not permitted to access the internet/); }); test("the switchboard refuses what a grant doesn't cover", async () => { - await admin.install("grace", await app("egress-probe.js"), { host: "127.0.0.1", port: "1" }); + await admin.create(await app("egress-probe.js"), { ...at("grace"), egress: { host: "127.0.0.1", port: "1" } }); assert.match(await probe("grace", echo.host), /^got: egress refused: .*egress to 127\.0\.0\.1 not granted/); assert.match(await probe("grace", echo.host, { ws: true }), /^no websocket: 403 egress refused: .*not granted/); }); test("a vat can narrow its grant, but not contradict it", async () => { - await admin.install("ivan", await app("egress-probe.js"), { scheme: "http" }); + await admin.create(await app("egress-probe.js"), { ...at("ivan"), egress: { scheme: "http" } }); assert.equal(await probe("ivan", echo.host, { constrain: echoLabels }), "got: plain ok\n"); assert.match( await probe("ivan", echo.host, { constrain: { scheme: "https" } }), @@ -211,7 +279,7 @@ test("a vat can narrow its grant, but not contradict it", async () => { }); test("a grant stored before labels, a bare host:port, still works", async () => { - await admin.install("kim", await app("egress-probe.js"), echo.host); + await admin.create(await app("egress-probe.js"), { ...at("kim"), egress: echo.host }); assert.equal(await probe("kim", echo.host), "got: plain ok\n"); }); @@ -226,9 +294,11 @@ test("a toybox holding only the public handle can't reach localhost, whatever th const other = await startToybox("public-only", port2, "public"); try { const admin2 = makeAdmin(join(dir, "public-only", "admin.sock")); - await admin2.install("judy", await app("egress-probe.js"), echoLabels); + await admin2.create(await app("egress-probe.js"), { ...at("judy"), egress: echoLabels }); const before = echo.hits(); - const got = await (await fetch(`http://127.0.0.1:${port2}/judy?url=${encodeURIComponent(`http://${echo.host}/`)}`)).text(); + const got = await ( + await fetch(`http://127.0.0.1:${port2}/?vat=judy&url=${encodeURIComponent(`http://${echo.host}/`)}`) + ).text(); // workerd reports the public network's refusal (restrictPeers) to JS only as "internal error" assert.match(got, /^got: egress refused: /); assert.equal(echo.hits(), before);