// The WebRTC transport for browser hosts: com.fedfork.exec.webrtc.connect (find the guest, seal and signal // the offer, open the answer, check the DTLS fingerprint, open the datachannel, ssh over it) and the // webrtc: scheme fetcher (HTTP/1.1 over a tag's datachannels). No deps (age, the peer connection and the // ssh library are passed in). A page passes it in (hostCall's calls, runModule's fetchers). import { httpChannel } from "./http-datachannel.js"; import { connectSsh, sshKeyPair } from "./ssh-datachannel.js"; export { connectSsh, httpChannel, sshKeyPair }; export * from "./record-signals.js"; // socialweb.computer guest transport: one STUN server, no TURN, non-trickle ICE. const STUN = "stun:stun.cloudflare.com:3478"; const MAX_CIPHERTEXT = 16384; const GATHER_MS = 20000; const SRFLX_MS = 2000; const POLL_MS = 5000; const ANSWER_MS = 180000; const OPEN_MS = 30000; // runModule's fetchers for a page that only fetches (no store needed). export const fetchers = (options) => new WebrtcHost(options).fetchers(); export const WEBRTC_NSID = "com.fedfork.exec.webrtc.connect"; // One fetch's cap: the guest agent's exec runs up to 600 s. const FETCH_MS = 660000; const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; // httpChannel frames the request itself. const FRAMING = new Set(["host", "content-length", "transfer-encoding", "connection"]); // What the transport keeps in its store (a page's Cubit state, merged with the page's own): // phase: idle -> finding -> found -> offering -> offered -> answering -> connecting -> open -> closed, // error at any step (error holds why) // signal: the requester `signal` input of the sealed offer // ssh over the datachannel: authorizedKey to put in user_data, hostKey seen, hostKeys published export const WEBRTC_STATE = Object.freeze({ phase: "idle", receipt: null, tag: "", guest: null, signal: null, output: "", error: null, ssh: { authorizedKey: null, user: "root", hostKey: null, pinned: null, busy: false, error: null }, hostKeys: [], }); // A receipt arrives as its strong ref JSON {uri, cid}; the bidder is its repo unless given. export function parseReceipt(text) { const ref = typeof text === "string" ? JSON.parse(text) : text; const bidder = ref?.bidder ?? ref?.uri?.match(/^at:\/\/(did:[^/]+)\//)?.[1]; if (!bidder || !ref.cid) throw new Error("receipt must be a strong ref {uri, cid}"); return { uri: ref.uri, cid: ref.cid, bidder }; } const b64url = { encode: (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), decode: (text) => new TextDecoder().decode(Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))), }; // webrtc://///, ref = base64url(JSON {receipt: {uri, cid}, bidder?}). export const webrtcUrl = ({ receipt: { uri, cid }, bidder }, tag, path = "") => `webrtc:///${b64url.encode(JSON.stringify({ receipt: { uri, cid }, ...(bidder && { bidder }) }))}/${tag}/${path.replace(/^\//, "")}`; // A market endpoint (kcp offering endpoints {kind: webrtc, address}): webrtc://
/, address = // base64url(JSON {did, listen}), the answerer's computer.socialweb.ingress.listen/. Its receipt is that // listen record's uri; it is signalled over records (the host's `records`, recordSignals), datachannel label market. export const LISTEN_COLLECTION = "computer.socialweb.ingress.listen"; export const endpointUrl = ({ did, listen }, path = "") => `webrtc://${b64url.encode(JSON.stringify({ did, listen }))}/${path.replace(/^\//, "")}`; function parseEndpointUrl(u) { let ref; try { ref = JSON.parse(b64url.decode(u.host)); } catch { throw new Error("a webrtc endpoint URL's host must be base64url JSON {did, listen}"); } if (!(typeof ref?.did === "string" && /^did:[a-z]+:[\w.:%-]+$/.test(ref.did)) || !(typeof ref.listen === "string" && /^[A-Za-z0-9._:~-]{1,512}$/.test(ref.listen))) { throw new Error("a webrtc endpoint URL names {did, listen}"); } return { receipt: { uri: `at://${ref.did}/${LISTEN_COLLECTION}/${ref.listen}`, cid: "listen", bidder: ref.did }, tag: "market", path: `${u.pathname || "/"}${u.search}`, records: true }; } // A webrtc: URL as { receipt (parseReceipt, bidder from the ref when given), tag, path (with the query) }; an // endpoint URL (webrtc://
/...) also has records: true. export function parseWebrtcUrl(url) { const u = new URL(url); if (u.protocol === "webrtc:" && u.host) return parseEndpointUrl(u); const [, ref, tag, ...rest] = u.pathname.split("/"); if (u.protocol !== "webrtc:" || u.host || !ref || !tag) throw new Error("a webrtc URL is webrtc://///"); let parsed; try { parsed = JSON.parse(b64url.decode(ref)); } catch { throw new Error("a webrtc URL's ref must be base64url JSON {receipt: {uri, cid}, bidder?}"); } const { uri, cid } = parsed?.receipt ?? {}; if (parsed.bidder !== undefined && !(typeof parsed.bidder === "string" && /^did:[a-z]+:[\w.:%-]+$/.test(parsed.bidder))) throw new Error("a webrtc URL's bidder must be a DID"); const receipt = parseReceipt({ uri, cid, bidder: parsed.bidder }); const at = decodeURIComponent(tag); if (!/^[\x21-\x7e]{1,32}$/.test(at) || at === "ssh") throw new Error("a webrtc URL's tag must be a tunnels.json tag (1 to 32 printable bytes, not ssh)"); return { receipt, tag: at, path: `/${rest.join("/")}${u.search}` }; } const normal = (fingerprint) => fingerprint.trim().split(/\s+/).join(" ").toLowerCase(); // Every a=fingerprint in the answer must be the one the guest reported in vm.onNetwork. export function checkFingerprint(sdp, expected) { const found = sdp.split(/\r?\n/).filter((l) => l.startsWith("a=fingerprint:")).map((l) => normal(l.slice("a=fingerprint:".length))); if (!found.length) throw new Error("the answer has no a=fingerprint line"); const bad = found.find((f) => f !== normal(expected)); if (bad) throw new Error(`the answer's DTLS fingerprint ${bad} is not the guest's ${expected}`); } // The guest's age key and DTLS fingerprint for a tag, and the answers, from guest events. export function guestOf(events, tag) { const values = (kind, any = false) => events.filter((e) => e.kind === kind && (any || e.value?.tag === tag)).map((e) => e.value) .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))); // The agent has one DTLS certificate for all tags, and hat-9 published vm.onNetwork for // only the first tag (2026-11-08), so fall back to any webrtc onNetwork of this receipt. const network = (any) => values("vm.onNetwork", any).filter((v) => v.kind === "webrtc").at(-1)?.address; return { key: values("guest.publicKey").at(-1)?.key ?? null, fingerprint: network(false) ?? network(true) ?? null, hostKeys: events.filter((e) => e.kind === "vm.hostKeys").at(-1)?.value?.keys ?? [], answers: values("guest.signalWebRTC").filter((v) => v.kind === "answer").map((v) => v.ciphertext).reverse(), }; } // The `signals` option for a local guest (`webrtc-peer`) in place of // the bidder's records: GET events answers what `watch` returns ({kind, value} guest events), POST signal // takes the `signal` input ({receipt, tag, offer}). Same sealed offers and answers, no account, no PDS. // The body goes as text/plain, so the cross-port fetch needs no CORS preflight (webrtc-peer --origin allows // only this page's origin). const json = async (what, response) => { if (!response.ok) throw new Error(`${what} failed with ${response.status}: ${await response.text()}`); return response.json(); }; export const localSignals = (base, fetchImpl = fetch) => ({ events: async () => (await json("events", await fetchImpl(new URL("events", base)))).events, signal: async (input) => json("signal", await fetchImpl(new URL("signal", base), { method: "POST", body: JSON.stringify(input) })), }); // The state of a peer the page does not show (a fetch peer): a Cubit's state, emit and change events. class Store extends EventTarget { state = WEBRTC_STATE; emit(patch) { this.state = { ...this.state, ...patch }; this.dispatchEvent(new Event("change")); } } // The transport: com.fedfork.exec.webrtc.connect for a browser host (lexicons/com/fedfork/exec/webrtc/connect.json), // `call` for hostCall's calls: a request finds, offers, answers and opens (then a command over ssh, tag ssh), // a subscribe is a channel session. The page brings the rest: // store a Cubit (atlibs javascript/src/cubit) whose state holds WEBRTC_STATE (state, emit, change events) // events ({receipt, tag}) => guest events ({kind, value}) of that receipt and tag: the requester `watch`, or localSignals // signal (input) => posts the sealed offer { receipt, tag, offer }: the requester `signal` as the session, or // localSignals; resolving without posting leaves it in state.signal for a host that can // makePeer config => RTCPeerConnection // age the age-encryption (typage) module // ssh @microsoft/dev-tunnels-ssh namespace plus its Buffer (optional: ssh over the channel) export class WebrtcHost { #store; #events; #signal; #makePeer; #age; #ssh; #sleep; #identity; #pc; #dc; #sshKey; #sshSession; #received = []; // Protocol 2 subscriptions to the datachannel (#channel): each gets every message and the close. #listeners = new Set(); // webrtc: fetches: " " -> Promise of { host (its own peer), idle (httpChannels) }. #peers = new Map(); // endpoint URLs' signalling: { events, signal } over records (recordSignals) #records; constructor({ store = new Store(), events, signal, records, makePeer, age, ssh, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) { this.#store = store; this.#records = records; this.#events = events; this.#signal = signal; this.#makePeer = makePeer; this.#age = age; this.#ssh = ssh; this.#sleep = sleep; } // hostCall's calls[WEBRTC_NSID] call = (request = {}) => (request.subscribe ? this.#channel(request.params ?? {}) : this.#connect(request.input ?? {})); // The NSIDs this transport serves, for hostCall's calls (their keys are what the host provides). get calls() { return { [WEBRTC_NSID]: this.call }; } // runModule's fetchers: the webrtc: scheme. fetchers() { return { "webrtc:": this.fetch }; } // The webrtc: scheme fetcher: {method, url, headers, body, signal} -> {status, headers, body (text)}, HTTP/1.1 // to the guest's tunnel tag over a datachannel. Each (receipt, tag) has its own peer connection, kept for later // fetches and redialled once it fails, and a pool of datachannels, each one HTTP/1.1 connection serving one // request at a time: concurrent fetches (a long-poll read beside an exec) each take an idle one or open another. fetch = async ({ method = "GET", url, headers = {}, body, signal } = {}) => { const { receipt, tag, path, records } = parseWebrtcUrl(url); if (records && !this.#records) throw new Error("webrtc fetch: an endpoint URL needs the host's records signalling (recordSignals)"); if (!TOKEN.test(method)) throw new Error(`webrtc fetch: a bad method ${method}`); const sent = {}; for (const [k, v] of Object.entries(headers ?? {})) { if (!TOKEN.test(k) || typeof v !== "string" || /[\r\n\0]/.test(v)) throw new Error(`webrtc fetch: a bad header ${k}`); if (!FRAMING.has(k.toLowerCase())) sent[k] = v; } if (signal?.aborted) throw new Error("webrtc fetch: aborted"); const peer = await this.#peer(receipt, tag, records ? this.#records : this); let conn; while ((conn = peer.idle.pop()) && !conn.reusable); if (!conn) { try { conn = httpChannel(await peer.host.#newChannel()); } catch (error) { // The peer cannot open channels any more: the next fetch dials again. if (this.#peers.get(`${receipt.uri} ${tag}`) === peer.held) this.#forget(`${receipt.uri} ${tag}`); throw error; } } const stop = () => conn.close(); const timer = setTimeout(stop, FETCH_MS); signal?.addEventListener("abort", stop, { once: true }); let response; try { response = await conn.request({ method, path, headers: sent, body: body ?? "" }); } catch (error) { throw new Error(`webrtc fetch: ${signal?.aborted ? "aborted" : error.message}`); } finally { clearTimeout(timer); signal?.removeEventListener("abort", stop); } if (conn.reusable) peer.idle.push(conn); return { status: response.status, headers: response.headers, body: new TextDecoder().decode(response.body) }; }; #forget(key) { this.#peers.get(key)?.then((p) => p.host.close(), () => {}); this.#peers.delete(key); } // The peer of (receipt, tag): connected once, then reused while its connection holds. async #peer(ref, tag, via = this) { const key = `${ref.uri} ${tag}`; const held = this.#peers.get(key); const peer = held && (await held.catch(() => null)); if (peer && !["failed", "closed"].includes(peer.host.#pc?.connectionState)) return peer; if (held && this.#peers.get(key) === held) this.#forget(key); if (!this.#peers.has(key)) { const signals = via === this ? { events: this.#events, signal: this.#signal } : via; const host = new WebrtcHost({ events: signals.events, signal: signals.signal, makePeer: this.#makePeer, age: this.#age, sleep: this.#sleep }); const connecting = host.#connect({ receipt: ref, tag }).then(() => ({ host, idle: [httpChannel(host.#dc)], held: connecting })); connecting.catch(() => this.#peers.get(key) === connecting && this.#peers.delete(key)); this.#peers.set(key, connecting); } return this.#peers.get(key); } // Another datachannel on this peer, labelled its tag; resolves open. #newChannel() { return new Promise((resolve, reject) => { const dc = this.#pc.createDataChannel(this.#store.state.tag, { ordered: true }); const timer = setTimeout(() => (dc.close(), reject(new Error(`the datachannel did not open in ${OPEN_MS / 1000} s`))), OPEN_MS); dc.onopen = () => (clearTimeout(timer), resolve(dc)); dc.onclose = () => (clearTimeout(timer), reject(new Error("the datachannel closed before it opened"))); }); } // com.fedfork.exec.webrtc.connect (lexicons/com/fedfork/exec/webrtc/connect.json): find, offer // (signal runs as this session), answer, open; then the command over ssh (tag ssh). The // channel stays open for the page. async #connect({ receipt, tag, command, stdin }) { if (command !== undefined && (tag !== "ssh" || !this.#ssh)) throw new Error(`${WEBRTC_NSID} runs a command only over tag ssh, with an ssh client in the page`); await this.find(receipt, tag); if (this.#store.state.phase === "found") await this.offer(); if (this.#store.state.phase === "offered") await this.answer(); await this.#opened(); const { fingerprint } = this.#store.state.guest; if (command === undefined) return { tag, fingerprint }; if (stdin !== undefined && !(typeof stdin === "string" && stdin.length <= 65536)) throw new Error("stdin must be a string of at most 65536 bytes"); const output = await this.sshExec(command, undefined, stdin); return { tag, fingerprint, output, hostKey: { ...this.#store.state.ssh.hostKey, pinned: this.#store.state.ssh.pinned } }; } // com.fedfork.exec.webrtc.connect with subscribe (params { receipt, tag }): connect as above, then // every datachannel message is a subscription message (text) and send(data) writes it (a string // as is, anything else as JSON). Closing the subscription leaves the channel open for the page. // Raw tags only: tag ssh carries the page's ssh session, not a byte stream. async #channel({ receipt, tag }) { if (tag === "ssh") throw new Error(`${WEBRTC_NSID} subscribe carries raw bytes; use a tag other than ssh`); await this.#connect({ receipt, tag }); const dc = this.#dc; const queue = []; const decoder = new TextDecoder(); let closed = false; let wake; const listener = (data) => { if (data === null) closed = true; else queue.push(typeof data === "string" ? data : decoder.decode(data, { stream: true })); wake?.(); }; this.#listeners.add(listener); const close = () => (this.#listeners.delete(listener), listener(null)); return { close, send: async (data) => { if (closed || dc.readyState !== "open") throw new Error("the datachannel is closed"); dc.send(new TextEncoder().encode(typeof data === "string" ? data : JSON.stringify(data))); }, async *[Symbol.asyncIterator]() { for (;;) { while (!queue.length && !closed) await new Promise((resolve) => (wake = resolve)); if (queue.length) yield queue.shift(); else return; } }, }; } // Resolves when the datachannel opens; rejects on error, close, or OPEN_MS. #opened() { return new Promise((resolve, reject) => { const done = (settle) => { clearTimeout(timer); this.#store.removeEventListener("change", check); settle(); }; const check = () => { const { phase, error } = this.#store.state; if (phase === "open") done(resolve); else if (phase === "error" || phase === "closed") done(() => reject(new Error(error ?? "the datachannel closed"))); }; const timer = setTimeout(() => done(() => reject(new Error(`the datachannel did not open in ${OPEN_MS / 1000} s`))), OPEN_MS); this.#store.addEventListener("change", check); check(); }); } async find(receiptText, tag) { try { this.#close(); this.#store.emit({ phase: "finding", receipt: parseReceipt(receiptText), tag: tag.trim(), guest: null, signal: null, output: "", error: null }); const { receipt, tag: at } = this.#store.state; const { key, fingerprint, hostKeys } = guestOf(await this.#events({ receipt, tag: at }), at); this.#store.emit({ hostKeys }); if (!key || !fingerprint) throw new Error(`the guest has not reported ${key ? "" : "a guest.publicKey "}${fingerprint ? "" : "a vm.onNetwork kind webrtc "}for tag ${this.#store.state.tag} yet`); this.#store.emit({ phase: "found", guest: { key, fingerprint } }); } catch (error) { this.#store.emit({ phase: "error", error: error.message }); } } // Non-trickle: gather every candidate, then seal {sdp, replyTo} to the guest's key. async offer() { try { this.#store.emit({ phase: "offering", error: null }); const age = this.#age; this.#identity = await age.generateX25519Identity(); const replyTo = await age.identityToRecipient(this.#identity); const pc = (this.#pc = this.#makePeer({ iceServers: [{ urls: STUN }] })); this.#wire(pc.createDataChannel(this.#store.state.tag, { ordered: true })); pc.addEventListener("connectionstatechange", () => { if (pc.connectionState !== "failed") return; // After the channel opened, a failure is the guest going away (e.g. released), not NAT. if (["open", "closed"].includes(this.#store.state.phase)) return this.#store.emit({ phase: "closed" }); this.#store.emit({ phase: "error", error: `ICE ${pc.iceConnectionState}: a symmetric NAT on either side defeats STUN-only WebRTC (${STUN}, no TURN)` }); }); const gathered = this.#gathered(pc); await pc.setLocalDescription(await pc.createOffer()); await gathered; const encrypter = new age.Encrypter(); encrypter.addRecipient(this.#store.state.guest.key); const offer = age.armor.encode(await encrypter.encrypt(JSON.stringify({ sdp: pc.localDescription.sdp, replyTo }))); if (offer.length > MAX_CIPHERTEXT) throw new Error(`the sealed offer is ${offer.length} bytes, over ${MAX_CIPHERTEXT} (too many local candidates)`); const { uri, cid } = this.#store.state.receipt; const signal = { receipt: { uri, cid }, tag: this.#store.state.tag, offer }; await this.#signal(signal); this.#store.emit({ phase: "offered", signal }); } catch (error) { this.#store.emit({ phase: "error", error: error.message }); } } // Non-trickle, without waiting out the cap: done when gathering completes, on the null // candidate, or SRFLX_MS after the first server-reflexive (STUN) candidate; GATHER_MS caps it. // Attach before setLocalDescription, which starts gathering. #gathered(pc) { return new Promise((resolve) => { let srflx; const done = () => { clearTimeout(cap); clearTimeout(srflx); pc.removeEventListener("icegatheringstatechange", onState); pc.removeEventListener("icecandidate", onCandidate); resolve(); }; const cap = setTimeout(done, GATHER_MS); const onState = () => pc.iceGatheringState === "complete" && done(); const onCandidate = ({ candidate }) => { if (!candidate) return done(); if (srflx === undefined && / typ srflx /.test(candidate.candidate)) srflx = setTimeout(done, SRFLX_MS); }; pc.addEventListener("icegatheringstatechange", onState); pc.addEventListener("icecandidate", onCandidate); }); } // Signals name no key: open every answer for the tag and keep the one sealed to us. async answer() { this.#store.emit({ phase: "answering", error: null }); try { for (const deadline = Date.now() + ANSWER_MS; Date.now() < deadline; await this.#sleep(POLL_MS)) { const { receipt, tag } = this.#store.state; for (const ciphertext of guestOf(await this.#events({ receipt, tag }), tag).answers) { const sdp = await this.#open(ciphertext); if (!sdp) continue; checkFingerprint(sdp, this.#store.state.guest.fingerprint); await this.#pc.setRemoteDescription({ type: "answer", sdp }); if (this.#store.state.phase === "answering") this.#store.emit({ phase: "connecting" }); return; } if (this.#store.state.phase !== "answering") return; } throw new Error("no answer sealed to this session arrived"); } catch (error) { this.#store.emit({ phase: "error", error: error.message }); } } async #open(ciphertext) { try { const decrypter = new this.#age.Decrypter(); decrypter.addIdentity(this.#identity); return JSON.parse(await decrypter.decrypt(this.#age.armor.decode(ciphertext), "text")).sdp || null; } catch { return null; } } // The datachannel is raw TCP to the guest's tunnel port. #wire(dc) { this.#dc = dc; dc.binaryType = "arraybuffer"; const decoder = new TextDecoder(); dc.onopen = () => this.#store.emit({ phase: "open" }); dc.onclose = () => { for (const listener of this.#listeners) listener(null); this.#listeners.clear(); if (this.#store.state.phase !== "error") this.#store.emit({ phase: "closed" }); }; dc.onmessage = ({ data }) => { if (this.#listeners.size) return this.#listeners.forEach((listener) => listener(data)); // Tag ssh: keep the bytes (sshd's banner) for connectSsh instead of printing them. if (this.#store.state.tag === "ssh" && this.#ssh) return this.#received.push(data); this.#store.emit({ output: this.#store.state.output + (typeof data === "string" ? data : decoder.decode(data, { stream: true })) }); }; } // Tag ssh: the line runs over ssh; any other tag: raw bytes to the guest port. send(line) { if (this.#dc?.readyState !== "open") return; if (this.#store.state.tag === "ssh" && this.#ssh) return this.sshExec(line).catch(() => undefined); this.#dc.send(new TextEncoder().encode(`${line}\n`)); } // The authorized_keys line of this page's ssh key (memory only: a reload makes a new one). async sshKey() { this.#sshKey ??= sshKeyPair(this.#ssh); const { authorizedKey } = await this.#sshKey; this.#store.emit({ ssh: { ...this.#store.state.ssh, authorizedKey } }); return authorizedKey; } // Runs a command over ssh on the open datachannel (connects on first use); output joins state.output. // stdin (optional) goes to the command, never into state. async sshExec(command, user = this.#store.state.ssh.user, stdin) { this.#store.emit({ ssh: { ...this.#store.state.ssh, busy: true, error: null } }); try { if (!this.#sshSession) { const { keyPair } = await (this.#sshKey ??= sshKeyPair(this.#ssh)); const session = await connectSsh(this.#ssh, this.#dc, keyPair, user, this.#received.splice(0)); this.#sshSession = session; const pinned = this.#store.state.hostKeys.some((k) => k.fingerprint === session.hostKey.fingerprint); this.#store.emit({ ssh: { ...this.#store.state.ssh, user, hostKey: session.hostKey, pinned } }); } const output = await this.#sshSession.exec(command, stdin); this.#store.emit({ output: `${this.#store.state.output}$ ${command}\n${output}`, ssh: { ...this.#store.state.ssh, busy: false } }); return output; } catch (error) { this.#store.emit({ ssh: { ...this.#store.state.ssh, busy: false, error: error.message } }); throw error; } } #close() { this.#sshSession?.close(); this.#pc?.close(); this.#pc = this.#dc = this.#identity = this.#sshSession = undefined; } // Closes the page's channel and every fetch peer. close() { for (const key of [...this.#peers.keys()]) this.#forget(key); this.#close(); this.#store.emit({ phase: "closed" }); } }