diff --git a/toybox/.gitignore b/toybox/.gitignore index 03fb7e6..3de5902 100644 --- a/toybox/.gitignore +++ b/toybox/.gitignore @@ -1,2 +1,3 @@ state/ admin.sock +switchboard.sock diff --git a/toybox/README.md b/toybox/README.md index 84a0388..c9b59e6 100644 --- a/toybox/README.md +++ b/toybox/README.md @@ -2,35 +2,45 @@ 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. -- [supervisor.js](supervisor.js): `Vat` stores code by SHA-256 and runs the app on it; `Public` and `Admin` are the two entrypoints. +- [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 vat's `fetch`. +- [switchboard.js](switchboard.js): a separate workerd process, the vats' only way out: a forward proxy for HTTP and WebSockets. - [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 | +| `admin` | `Admin` | `PUT /` with an ES module body: install it and (re)start the app; its storage carries over. `?egress=` grants the app that one host. | -An app must export `class App extends DurableObject` (see [apps/](apps/)). It gets an empty `env` and no `fetch`. +An app must export `class App extends DurableObject` (see [apps/](apps/)). It gets an empty `env`. With no grant, `fetch` throws; with one, its `globalOutbound` is `Egress` with the granted host in `ctx.props`, which forwards matching requests, WebSocket upgrades included, to the switchboard and refuses the rest with a 403. + +toybox reaches the switchboard over a unix socket (`-e switchboard=unix:…`), in HTTP proxy style so the full URL, scheme included, survives the hop. ## Run it ```sh pnpm install -pnpm test # a real workerd: install, upgrade, isolation, no fetch, crash and restart -pnpm start # systemd-socket-activate on 127.0.0.1:8787 and ./admin.sock; state in ./state +pnpm test # real workerds: install, upgrade, isolation, egress grants, fetch and WebSocket out, crash and restart +pnpm switchboard # systemd-socket-activate on ./switchboard.sock +pnpm start # likewise on 127.0.0.1:8787 and ./admin.sock; state in ./state ``` -Then, in another terminal: +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 + +curl -X PUT --unix-socket admin.sock --data-binary @apps/egress-probe.js 'http://toybox/erin?egress=example.com' +curl 'localhost:8787/erin?url=https://example.com/' # got: … +curl 'localhost:8787/erin?url=https://example.org/' # got: egress to example.org not granted ``` ## Under systemd -[systemd/](systemd/) has a socket and service pair, assuming this directory is installed at `/opt/toybox`. The service gets no network of its own (`PrivateNetwork=yes`): only the two sockets it is handed. +[systemd/](systemd/) has socket and service pairs for toybox and its switchboard, assuming this directory is installed at `/opt/toybox`. toybox gets no network of its own (`PrivateNetwork=yes`): only the two sockets it is handed and the switchboard's socket, which only members of the `toybox` group can open. The switchboard has the network, fenced by `IPAddressDeny=`; `switchboard.capnp` allows `local` addresses only so the tests can reach a local echo server. + +Neither unit has been run under the system manager yet. `PrivateNetwork=` needs the system manager; as a `systemd-run --user` unit, systemd logs "lack privileges for network namespace, proceeding without". diff --git a/toybox/apps/egress-probe.js b/toybox/apps/egress-probe.js new file mode 100644 index 0000000..0547e3d --- /dev/null +++ b/toybox/apps/egress-probe.js @@ -0,0 +1,23 @@ +import { DurableObject } from "cloudflare:workers"; + +/** `?url=` fetches it; with `&ws=1`, opens a WebSocket there and reports the echo of "hello". */ +export class App extends DurableObject { + async fetch(request) { + const params = new URL(request.url).searchParams; + const target = params.get("url"); + try { + if (!params.has("ws")) return new Response(`got: ${await (await fetch(target)).text()}\n`); + const resp = await fetch(target, { headers: { Upgrade: "websocket" } }); + const ws = resp.webSocket; + if (!ws) return new Response(`no websocket: ${resp.status} ${await resp.text()}`); + ws.accept(); + const reply = new Promise((resolve) => ws.addEventListener("message", (e) => resolve(e.data))); + ws.send("hello"); + const got = await reply; + ws.close(); + return new Response(`echo: ${got}\n`); + } catch (e) { + return new Response(`refused: ${e.message}\n`); + } + } +} diff --git a/toybox/config.capnp b/toybox/config.capnp index 28b4b63..47c58f0 100644 --- a/toybox/config.capnp +++ b/toybox/config.capnp @@ -2,13 +2,16 @@ # # Sockets have no address here: whoever starts workerd hands them in # (-S name=fd from systemd, or -s name=addr), so holding a socket is the -# authority it grants. `state` is likewise overridden with -d state=. +# authority it grants. `state` is likewise overridden with -d state=, +# and the switchboard's address with -e switchboard=. using Workerd = import "/workerd/workerd.capnp"; const config :Workerd.Config = ( services = [ (name = "main", worker = .supervisor), (name = "nowhere", network = (allow = [])), + # the way out: see switchboard.capnp + (name = "switchboard", external = (address = "unix:switchboard.sock", http = (style = proxy))), (name = "state", disk = (path = "state", writable = true)), ], sockets = [ @@ -27,6 +30,7 @@ const supervisor :Workerd.Worker = ( bindings = [ (name = "VATS", durableObjectNamespace = "Vat"), (name = "LOADER", workerLoader = ()), + (name = "SWITCHBOARD", service = "switchboard"), ], globalOutbound = "nowhere", ); diff --git a/toybox/package.json b/toybox/package.json index 618eb22..eb67ba4 100644 --- a/toybox/package.json +++ b/toybox/package.json @@ -4,10 +4,12 @@ "private": true, "type": "module", "scripts": { - "start": "mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l ./admin.sock -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state", + "start": "mkdir -p state && rm -f admin.sock && systemd-socket-activate -l 127.0.0.1:8787 -l ./admin.sock -- node_modules/workerd/bin/workerd serve --experimental config.capnp -S http=3 -S admin=4 -d state=state -e switchboard=unix:./switchboard.sock", + "switchboard": "rm -f switchboard.sock && systemd-socket-activate -l ./switchboard.sock -- node_modules/workerd/bin/workerd serve --experimental switchboard.capnp -S http=3", "test": "node --test" }, "devDependencies": { - "workerd": "1.20261006.1" + "workerd": "1.20261006.1", + "ws": "8.22.0" } } diff --git a/toybox/pnpm-lock.yaml b/toybox/pnpm-lock.yaml index 0ae4679..50e5b12 100644 --- a/toybox/pnpm-lock.yaml +++ b/toybox/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: workerd: specifier: 1.20261006.1 version: 1.20261006.1 + ws: + specifier: 8.22.0 + version: 8.22.0 packages: @@ -49,6 +52,18 @@ packages: engines: {node: '>=16'} hasBin: true + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + snapshots: '@cloudflare/workerd-darwin-64@1.20261006.1': @@ -73,3 +88,5 @@ snapshots: '@cloudflare/workerd-linux-64': 1.20261006.1 '@cloudflare/workerd-linux-arm64': 1.20261006.1 '@cloudflare/workerd-windows-64': 1.20261006.1 + + ws@8.22.0: {} diff --git a/toybox/supervisor.js b/toybox/supervisor.js index 6727200..48dc8b4 100644 --- a/toybox/supervisor.js +++ b/toybox/supervisor.js @@ -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} 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 /` with an ES module body installs and starts it. */ +/** + * The admin socket: `PUT /` with an ES module body installs and starts + * it; `?egress=` grants it that one host. + */ export class Admin extends WorkerEntrypoint { async fetch(request) { if (request.method !== "PUT") return new Response("PUT code to /\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"); } } diff --git a/toybox/switchboard.capnp b/toybox/switchboard.capnp new file mode 100644 index 0000000..3d28a4d --- /dev/null +++ b/toybox/switchboard.capnp @@ -0,0 +1,26 @@ +# switchboard: toybox's only way out. A forward proxy (HTTP and WebSocket) +# that toybox's Egress gateway reaches over a socket handed in with -S/-s. +# +# The network service is the switchboard's outer fence; under systemd, +# IPAddressAllow=/IPAddressDeny= narrow it further (see systemd/). +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .switchboard), + (name = "internet", network = (allow = ["public", "local"], tlsOptions = (trustBrowserCas = true))), + ], + sockets = [ + # proxy style: the request line carries the full URL, scheme included + (name = "http", http = (style = proxy), service = "main"), + ], +); + +const switchboard :Workerd.Worker = ( + compatibilityDate = "2026-10-01", + modules = [(name = "switchboard.js", esModule = embed "switchboard.js")], + durableObjectNamespaces = [(className = "Switchboard", uniqueKey = "toybox-switchboard")], + durableObjectStorage = (inMemory = void), + bindings = [(name = "BOARD", durableObjectNamespace = "Switchboard")], + globalOutbound = "internet", +); diff --git a/toybox/switchboard.js b/toybox/switchboard.js new file mode 100644 index 0000000..f90d8e9 --- /dev/null +++ b/toybox/switchboard.js @@ -0,0 +1,22 @@ +/** @file switchboard: forwards each proxied request, WebSocket upgrades included. */ +import { DurableObject } from "cloudflare:workers"; + +/** + * 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. + */ +export class Switchboard extends DurableObject { + fetch(request) { + return fetch(request); + } +} + +export default { + fetch(request, env) { + return env.BOARD.getByName("switchboard").fetch(request); + }, +}; diff --git a/toybox/systemd/toybox-switchboard.service b/toybox/systemd/toybox-switchboard.service new file mode 100644 index 0000000..fdfb23b --- /dev/null +++ b/toybox/systemd/toybox-switchboard.service @@ -0,0 +1,13 @@ +[Unit] +Description=toybox switchboard (workerd forward proxy) +Requires=toybox-switchboard.socket + +[Service] +ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/switchboard.capnp -S http=3 +DynamicUser=yes +# the outer fence: switchboard.capnp allows "local" for tests; deny it here +IPAddressDeny=localhost link-local multicast +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +SystemCallFilter=@system-service diff --git a/toybox/systemd/toybox-switchboard.socket b/toybox/systemd/toybox-switchboard.socket new file mode 100644 index 0000000..5cd94c3 --- /dev/null +++ b/toybox/systemd/toybox-switchboard.socket @@ -0,0 +1,11 @@ +[Unit] +Description=toybox switchboard: the vats' only way out + +[Socket] +ListenStream=/run/toybox/switchboard.sock +# only toybox may dial out: its service joins this group +SocketGroup=toybox +SocketMode=0660 + +[Install] +WantedBy=sockets.target diff --git a/toybox/systemd/toybox.service b/toybox/systemd/toybox.service index 5869d6b..a17b960 100644 --- a/toybox/systemd/toybox.service +++ b/toybox/systemd/toybox.service @@ -1,12 +1,15 @@ [Unit] Description=toybox vats (workerd) -Requires=toybox.socket +Requires=toybox.socket toybox-switchboard.socket +After=toybox-switchboard.socket [Service] -ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/config.capnp -S http=3 -S admin=4 -d state=/var/lib/toybox +ExecStart=/opt/toybox/node_modules/workerd/bin/workerd serve --experimental /opt/toybox/config.capnp -S http=3 -S admin=4 -d state=/var/lib/toybox -e switchboard=unix:/run/toybox/switchboard.sock DynamicUser=yes +# to open the switchboard socket +SupplementaryGroups=toybox StateDirectory=toybox -# no network but the sockets handed in +# no network but the sockets handed in, and the switchboard's PrivateNetwork=yes RestrictAddressFamilies=AF_UNIX ProtectSystem=strict diff --git a/toybox/test/vat.test.js b/toybox/test/vat.test.js index 86d1870..0e18b04 100644 --- a/toybox/test/vat.test.js +++ b/toybox/test/vat.test.js @@ -1,12 +1,13 @@ -/** @file toybox end to end: a real workerd, an admin socket, a public port. */ +/** @file toybox end to end: a real workerd, an admin socket, a public port, a switchboard. */ import { spawn } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import { request } from "node:http"; -import { createServer } from "node:net"; +import { createServer as createHttpServer, request } from "node:http"; +import { connect, createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, test } from "node:test"; import assert from "node:assert/strict"; +import { WebSocketServer } from "ws"; const here = new URL("..", import.meta.url).pathname; // the package binary, not the .bin shim: the shim forks it, so a kill would orphan workerd @@ -21,11 +22,19 @@ const freePort = () => }); }); +/** Resolves once something accepts connections at `opts` (a port or a socket path). */ +const accepting = (opts) => + new Promise((resolve) => { + const s = connect(opts, () => (s.end(), resolve(true))); + s.on("error", () => resolve(false)); + }); + /** @param {string} socketPath the admin socket; holding it is the authority to install */ const makeAdmin = (socketPath) => ({ - install: (vat, src) => + install: (vat, src, egress) => new Promise((resolve, reject) => { - const req = request({ socketPath, path: `/${vat}`, method: "PUT", agent: false }, (res) => { + const path = egress ? `/${vat}?egress=${encodeURIComponent(egress)}` : `/${vat}`; + const req = request({ socketPath, path, method: "PUT", agent: false }, (res) => { let body = ""; res.on("data", (c) => (body += c)); res.on("end", () => resolve(body.trim())); @@ -37,49 +46,80 @@ const makeAdmin = (socketPath) => ({ /** @param {string} base the public origin */ const makePublic = (base) => ({ - get: async (vat) => (await fetch(`${base}/${vat}`)).text(), + get: async (path) => (await fetch(`${base}/${path}`)).text(), put: async (vat, src) => (await fetch(`${base}/${vat}`, { method: "PUT", body: src })).status, }); -/** Run workerd on `dir`'s state until `stop()`; resolves once the port answers. */ -const startWorkerd = async (dir, port) => { - await rm(join(dir, "admin.sock"), { force: true }); // left behind by a crash; bind() would fail - const child = spawn( - workerd, - ["serve", "--experimental", join(here, "config.capnp"), - "-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`, - "-d", `state=${join(dir, "state")}`], - { stdio: ["ignore", "ignore", "pipe"] }, - ); +/** + * Run workerd on `config` until `stop()`; resolves once `listening` accepts. + * + * @param {string} config + * @param {string[]} args socket, directory, and external overrides + * @param {{ port: number } | { path: string }} listening + * @param {string[]} stale socket files a crash leaves behind; bind() would fail on them + */ +const startWorkerd = async (config, args, listening, stale = []) => { + for (const f of stale) await rm(f, { force: true }); + const child = spawn(workerd, ["serve", "--experimental", join(here, config), ...args], { + stdio: ["ignore", "ignore", "pipe"], + }); let log = ""; child.stderr.on("data", (c) => (log += c)); const exited = new Promise((resolve) => child.on("exit", resolve)); for (let i = 0; i < 100; i++) { - try { - await fetch(`http://127.0.0.1:${port}/`); - // SIGKILL: a crash is the harder durability test - return { stop: () => (child.kill("SIGKILL"), exited) }; - } catch { - await new Promise((r) => setTimeout(r, 50)); - } + // SIGKILL: a crash is the harder durability test + if (await accepting(listening)) return { stop: () => (child.kill("SIGKILL"), exited) }; + await new Promise((r) => setTimeout(r, 50)); } child.kill("SIGKILL"); throw new Error(`workerd did not start: ${log}`); }; -let dir, port, server, admin, pub; +/** An HTTP server that answers GET with "plain ok" and echoes WebSocket messages. */ +const startEcho = async () => { + const server = createHttpServer((_req, res) => res.end("plain ok")); + const wss = new WebSocketServer({ server }); + wss.on("connection", (ws) => ws.on("message", (m) => ws.send(String(m)))); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + host: `127.0.0.1:${server.address().port}`, + stop: () => new Promise((resolve) => (wss.close(), server.closeAllConnections(), server.close(resolve))), + }; +}; + +let dir, port, server, switchboard, echo, admin, pub; + +const startToybox = () => + startWorkerd( + "config.capnp", + [ + "-s", `http=127.0.0.1:${port}`, "-s", `admin=unix:${join(dir, "admin.sock")}`, + "-d", `state=${join(dir, "state")}`, "-e", `switchboard=unix:${join(dir, "switchboard.sock")}`, + ], + { port, host: "127.0.0.1" }, + [join(dir, "admin.sock")], + ); before(async () => { dir = await mkdtemp(join(tmpdir(), "toybox-")); await mkdir(join(dir, "state")); port = await freePort(); - server = await startWorkerd(dir, port); + echo = await startEcho(); + switchboard = await startWorkerd( + "switchboard.capnp", + ["-s", `http=unix:${join(dir, "switchboard.sock")}`], + { path: join(dir, "switchboard.sock") }, + [join(dir, "switchboard.sock")], + ); + server = await startToybox(); admin = makeAdmin(join(dir, "admin.sock")); pub = makePublic(`http://127.0.0.1:${port}`); }); after(async () => { await server?.stop(); + await switchboard?.stop(); + await echo?.stop(); await rm(dir, { recursive: true, force: true }); }); @@ -114,6 +154,32 @@ 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"); await server.stop(); - server = await startWorkerd(dir, port); + server = await startToybox(); assert.equal(await pub.get("dave"), "v1 count 2\n"); }); + +const probe = (vat, target, ws) => + pub.get(`${vat}?url=${encodeURIComponent(`http://${target}/`)}${ws ? "&ws=1" : ""}`); + +test("a vat granted a host reaches it through the switchboard: fetch and WebSocket", async () => { + await admin.install("erin", await app("egress-probe.js"), echo.host); + assert.equal(await probe("erin", echo.host), "got: plain ok\n"); + assert.equal(await probe("erin", echo.host, true), "echo: hello\n"); +}); + +test("same code, no grant: no fetch", async () => { + await admin.install("frank", await app("egress-probe.js")); + assert.match(await probe("frank", echo.host), /^refused: This worker is not permitted to access the internet/); +}); + +test("a grant covers one host only", async () => { + await admin.install("grace", await app("egress-probe.js"), "127.0.0.1:1"); + assert.equal(await probe("grace", echo.host), `got: egress to ${echo.host} not granted\n\n`); + assert.match(await probe("grace", echo.host, true), /^no websocket: 403 egress to .* not granted/); +}); + +test("a grant survives a restart", async () => { + await server.stop(); + server = await startToybox(); + assert.equal(await probe("erin", echo.host, true), "echo: hello\n"); +});