diff --git a/toybox/.gitignore b/toybox/.gitignore new file mode 100644 index 0000000..03fb7e6 --- /dev/null +++ b/toybox/.gitignore @@ -0,0 +1,2 @@ +state/ +admin.sock diff --git a/toybox/README.md b/toybox/README.md new file mode 100644 index 0000000..84a0388 --- /dev/null +++ b/toybox/README.md @@ -0,0 +1,36 @@ +# 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. + +- [supervisor.js](supervisor.js): `Vat` stores code by SHA-256 and runs the app on it; `Public` and `Admin` are the two entrypoints. +- [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 | + +An app must export `class App extends DurableObject` (see [apps/](apps/)). It gets an empty `env` and no `fetch`. + +## 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 +``` + +Then, in another 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 +``` + +## 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. + +`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/counter-v1.js b/toybox/apps/counter-v1.js new file mode 100644 index 0000000..d8ab577 --- /dev/null +++ b/toybox/apps/counter-v1.js @@ -0,0 +1,8 @@ +import { DurableObject } from "cloudflare:workers"; +export class App extends DurableObject { + fetch() { + const n = (this.ctx.storage.kv.get("n") ?? 0) + 1; + this.ctx.storage.kv.put("n", n); + return new Response(`v1 count ${n}\n`); + } +} diff --git a/toybox/apps/counter-v2.js b/toybox/apps/counter-v2.js new file mode 100644 index 0000000..bd207cc --- /dev/null +++ b/toybox/apps/counter-v2.js @@ -0,0 +1,8 @@ +import { DurableObject } from "cloudflare:workers"; +export class App extends DurableObject { + fetch() { + const n = (this.ctx.storage.kv.get("n") ?? 0) + 1; + this.ctx.storage.kv.put("n", n); + return new Response(`v2 count ${n}\n`); + } +} diff --git a/toybox/apps/try-fetch.js b/toybox/apps/try-fetch.js new file mode 100644 index 0000000..30b5a2e --- /dev/null +++ b/toybox/apps/try-fetch.js @@ -0,0 +1,7 @@ +import { DurableObject } from "cloudflare:workers"; +export class App extends DurableObject { + async fetch() { + try { await fetch("https://example.com/"); return new Response("fetch ALLOWED\n"); } + catch (e) { return new Response(`fetch refused: ${e.message}\n`); } + } +} diff --git a/toybox/config.capnp b/toybox/config.capnp new file mode 100644 index 0000000..28b4b63 --- /dev/null +++ b/toybox/config.capnp @@ -0,0 +1,32 @@ +# toybox: vats whose code is installed at runtime, each a Durable Object facet. +# +# 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=. +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .supervisor), + (name = "nowhere", network = (allow = [])), + (name = "state", disk = (path = "state", writable = true)), + ], + sockets = [ + # reach a running vat by name + (name = "http", http = (), service = (name = "main", entrypoint = "Public")), + # install code into a vat and (re)start it + (name = "admin", http = (), service = (name = "main", entrypoint = "Admin")), + ], +); + +const supervisor :Workerd.Worker = ( + compatibilityDate = "2026-10-01", + modules = [(name = "supervisor.js", esModule = embed "supervisor.js")], + durableObjectNamespaces = [(className = "Vat", uniqueKey = "toybox-vat", enableSql = true)], + durableObjectStorage = (localDisk = "state"), + bindings = [ + (name = "VATS", durableObjectNamespace = "Vat"), + (name = "LOADER", workerLoader = ()), + ], + globalOutbound = "nowhere", +); diff --git a/toybox/package.json b/toybox/package.json new file mode 100644 index 0000000..618eb22 --- /dev/null +++ b/toybox/package.json @@ -0,0 +1,13 @@ +{ + "name": "toybox", + "version": "0.0.1", + "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", + "test": "node --test" + }, + "devDependencies": { + "workerd": "1.20261006.1" + } +} diff --git a/toybox/pnpm-lock.yaml b/toybox/pnpm-lock.yaml new file mode 100644 index 0000000..0ae4679 --- /dev/null +++ b/toybox/pnpm-lock.yaml @@ -0,0 +1,75 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + workerd: + specifier: 1.20261006.1 + version: 1.20261006.1 + +packages: + + '@cloudflare/workerd-darwin-64@1.20261006.1': + resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261006.1': + resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261006.1': + resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261006.1': + resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261006.1': + resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + workerd@1.20261006.1: + resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==} + engines: {node: '>=16'} + hasBin: true + +snapshots: + + '@cloudflare/workerd-darwin-64@1.20261006.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261006.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261006.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261006.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261006.1': + optional: true + + workerd@1.20261006.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261006.1 + '@cloudflare/workerd-darwin-arm64': 1.20261006.1 + '@cloudflare/workerd-linux-64': 1.20261006.1 + '@cloudflare/workerd-linux-arm64': 1.20261006.1 + '@cloudflare/workerd-windows-64': 1.20261006.1 diff --git a/toybox/supervisor.js b/toybox/supervisor.js new file mode 100644 index 0000000..6727200 --- /dev/null +++ b/toybox/supervisor.js @@ -0,0 +1,72 @@ +/** @file toybox supervisor: one Vat per name; its app runs as a facet on installed code. */ +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. + * + * 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`. + */ +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)"); + } + + /** + * 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` + * @returns {Promise} the code's SHA-256, hex + */ + async start(src) { + 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); + this.ctx.facets.abort("app", new Error("upgraded")); + return hash; + } + + #app(hash, src) { + return this.ctx.facets.get("app", () => { + const worker = this.env.LOADER.get(hash, () => ({ + compatibilityDate: "2026-10-01", + mainModule: "app.js", + modules: { "app.js": src }, + env: {}, + globalOutbound: null, + })); + return { class: worker.getDurableObjectClass("App") }; + }); + } + + fetch(request) { + const [row] = this.ctx.storage.sql + .exec("SELECT b.hash, b.src FROM current c JOIN bundle b USING (hash)") + .toArray(); + if (!row) return new Response("no code installed\n", { status: 404 }); + return this.#app(row.hash, row.src).fetch(request); + } +} + +const vatOf = (env, url) => env.VATS.getByName(new URL(url).pathname.split("/")[1]); + +/** The http socket: `//...` goes to that vat's app. */ +export class Public extends WorkerEntrypoint { + fetch(request) { + return vatOf(this.env, request.url).fetch(request); + } +} + +/** The admin socket: `PUT /` with an ES module body installs and starts it. */ +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"); + } +} + +export default Public; diff --git a/toybox/systemd/toybox.service b/toybox/systemd/toybox.service new file mode 100644 index 0000000..5869d6b --- /dev/null +++ b/toybox/systemd/toybox.service @@ -0,0 +1,16 @@ +[Unit] +Description=toybox vats (workerd) +Requires=toybox.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 +DynamicUser=yes +StateDirectory=toybox +# no network but the sockets handed in +PrivateNetwork=yes +RestrictAddressFamilies=AF_UNIX +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +SystemCallFilter=@system-service +# not MemoryDenyWriteExecute: V8's JIT needs W+X diff --git a/toybox/systemd/toybox.socket b/toybox/systemd/toybox.socket new file mode 100644 index 0000000..310d983 --- /dev/null +++ b/toybox/systemd/toybox.socket @@ -0,0 +1,13 @@ +[Unit] +Description=toybox vats: public port and admin socket + +[Socket] +# order sets the fds: http=3, admin=4 (see toybox.service) +ListenStream=127.0.0.1:8787 +ListenStream=/run/toybox/admin.sock +# whoever can open admin.sock can install code +SocketUser=root +SocketMode=0600 + +[Install] +WantedBy=sockets.target diff --git a/toybox/test/vat.test.js b/toybox/test/vat.test.js new file mode 100644 index 0000000..86d1870 --- /dev/null +++ b/toybox/test/vat.test.js @@ -0,0 +1,119 @@ +/** @file toybox end to end: a real workerd, an admin socket, a public port. */ +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 { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; + +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 +const workerd = join(here, "node_modules/workerd/bin/workerd"); +const app = (name) => readFile(join(here, "apps", name), "utf8"); + +const freePort = () => + new Promise((resolve) => { + const s = createServer().listen(0, "127.0.0.1", () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); + +/** @param {string} socketPath the admin socket; holding it is the authority to install */ +const makeAdmin = (socketPath) => ({ + install: (vat, src) => + new Promise((resolve, reject) => { + const req = request({ socketPath, path: `/${vat}`, method: "PUT", agent: false }, (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => resolve(body.trim())); + }); + req.on("error", reject); + req.end(src); + }), +}); + +/** @param {string} base the public origin */ +const makePublic = (base) => ({ + get: async (vat) => (await fetch(`${base}/${vat}`)).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"] }, + ); + 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)); + } + } + child.kill("SIGKILL"); + throw new Error(`workerd did not start: ${log}`); +}; + +let dir, port, server, admin, pub; + +before(async () => { + dir = await mkdtemp(join(tmpdir(), "toybox-")); + await mkdir(join(dir, "state")); + port = await freePort(); + server = await startWorkerd(dir, port); + admin = makeAdmin(join(dir, "admin.sock")); + pub = makePublic(`http://127.0.0.1:${port}`); +}); + +after(async () => { + await server?.stop(); + 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"); +}); + +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("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"); +}); + +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/); +}); + +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"); +}); + +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); + assert.equal(await pub.get("dave"), "v1 count 2\n"); +});