aboutsummaryrefslogtreecommitdiff
path: root/broker/broker.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'broker/broker.mjs')
-rw-r--r--broker/broker.mjs388
1 files changed, 388 insertions, 0 deletions
diff --git a/broker/broker.mjs b/broker/broker.mjs
new file mode 100644
index 0000000..4af8e9d
--- /dev/null
+++ b/broker/broker.mjs
@@ -0,0 +1,388 @@
+#!/usr/bin/env node
+// Standalone broker for a pi-chrome companion extension on a shared host (see FORK.md).
+//
+// Upstream's bridge lives inside whichever pi session started first on the machine running the
+// browser: it owns 127.0.0.1:17318, the companion extension long-polls it, and later sessions on
+// that same machine forward through it. That cannot serve pi sessions on other hosts - the
+// extension only ever polls its own loopback, and the owner disappears with its session.
+//
+// This process takes the owner's place, permanently, on the browser's host:
+//
+// extension listener EXT_HOST:PORT (loopback) /next, /result, /status, /command
+// peer listener PEER_HOST:PORT (private) /command, /status - nothing else
+//
+// Keeping /next and /result off the peer listener means nothing on the network can drain the
+// extension's command queue or forge its results. /command needs the shared bearer token on
+// both listeners, because loopback here is shared with everything else running as the user and
+// the browser behind it is signed into real accounts.
+//
+// The HTTP contract towards the extension is upstream's, unchanged: the extension is loaded as
+// shipped and has no idea it is talking to anything but a pi session.
+//
+// Node builtins only, so it runs straight from a checkout with no install step.
+
+import { createServer } from "node:http";
+import { readFileSync } from "node:fs";
+import { execSync } from "node:child_process";
+import { createHash, timingSafeEqual } from "node:crypto";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const PKG_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
+
+const PORT = Number(process.env.PI_CHROME_BROKER_PORT ?? "17318");
+const EXT_HOST = process.env.PI_CHROME_BROKER_EXT_HOST ?? "127.0.0.1";
+const PEER_HOST = process.env.PI_CHROME_BROKER_PEER_HOST?.trim() || undefined;
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+const MAX_TIMEOUT_MS = 10 * 60_000;
+const LONG_POLL_MS = 25_000;
+// Results carry screenshots and full-page tiles as base64, so this is sized for those.
+const MAX_BODY_BYTES = 64 * 1024 * 1024;
+
+function log(level, msg, extra = {}) {
+ const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
+ if (level === "error") console.error(line);
+ else console.log(line);
+}
+
+function readVersion() {
+ try {
+ return JSON.parse(readFileSync(PKG_PATH, "utf8")).version ?? "0.0.0-dev";
+ } catch {
+ return "0.0.0-dev";
+ }
+}
+
+// PI_CHROME_TOKEN, or the output of PI_CHROME_TOKEN_COMMAND for a secret store - the same token
+// every client sends. There is no default: the browser behind this is signed into real accounts.
+function loadToken() {
+ const fromEnv = process.env.PI_CHROME_TOKEN?.trim();
+ if (fromEnv) return fromEnv;
+ const command = process.env.PI_CHROME_TOKEN_COMMAND?.trim();
+ if (!command) throw new Error("the broker needs a token: set PI_CHROME_TOKEN, or PI_CHROME_TOKEN_COMMAND to read one from a secret store");
+ const token = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
+ if (!token) throw new Error(`PI_CHROME_TOKEN_COMMAND produced nothing: ${command}`);
+ return token;
+}
+
+const TOKEN_DIGEST = createHash("sha256").update(loadToken()).digest();
+
+// Compare digests rather than the raw strings: timingSafeEqual needs equal lengths, and the
+// length of a guess must not leak either.
+function hasValidToken(request) {
+ const raw = String(request.headers.authorization ?? "");
+ const match = /^Bearer\s+(.+)$/i.exec(raw);
+ if (!match) return false;
+ return timingSafeEqual(createHash("sha256").update(match[1].trim()).digest(), TOKEN_DIGEST);
+}
+
+// Same origin rules as upstream's bridge (index.ts), so the extension sees identical behaviour.
+function corsHeadersFor(request) {
+ const origin = String(request.headers.origin ?? "");
+ if (!origin.startsWith("chrome-extension://")) return {};
+ return {
+ "access-control-allow-origin": origin,
+ "access-control-allow-methods": "GET,POST,OPTIONS",
+ "access-control-allow-headers": "content-type",
+ "access-control-expose-headers": "x-pi-chrome-version",
+ "vary": "origin",
+ };
+}
+
+function isBrowserOriginAllowed(request) {
+ const origin = String(request.headers.origin ?? "");
+ if (origin) return origin.startsWith("chrome-extension://");
+ const secFetchSite = String(request.headers["sec-fetch-site"] ?? "");
+ return !secFetchSite || secFetchSite === "none" || secFetchSite === "same-origin";
+}
+
+function isLocalProcessRequest(request) {
+ return !request.headers.origin && !request.headers["sec-fetch-site"];
+}
+
+function sendJson(response, status, body, extraHeaders = {}) {
+ if (response.writableEnded) return;
+ response.writeHead(status, {
+ "content-type": "application/json; charset=utf-8",
+ "cache-control": "no-store",
+ ...extraHeaders,
+ });
+ response.end(JSON.stringify(body));
+}
+
+function readBody(request) {
+ return new Promise((resolveBody, rejectBody) => {
+ const chunks = [];
+ let size = 0;
+ request.on("data", (chunk) => {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) {
+ rejectBody(new Error(`request body over ${MAX_BODY_BYTES} bytes`));
+ request.destroy();
+ return;
+ }
+ chunks.push(Buffer.from(chunk));
+ });
+ request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
+ request.on("error", rejectBody);
+ });
+}
+
+// --- command queue: a port of upstream's ChromeProfileBridge server half ----------------------
+
+const queue = [];
+const waiters = [];
+const pending = new Map();
+let lastSeenAt;
+let clientName;
+const startedAt = Date.now();
+
+function enqueue(command) {
+ const waiter = waiters.shift();
+ if (waiter) waiter(command);
+ else queue.push(command);
+}
+
+function dropQueued(id) {
+ const index = queue.findIndex((queued) => queued.id === id);
+ if (index !== -1) queue.splice(index, 1);
+}
+
+function waitForCommand(timeoutMs, registerWaiter) {
+ return new Promise((resolveWait) => {
+ let settled = false;
+ const waiter = (command) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ const index = waiters.indexOf(waiter);
+ if (index !== -1) waiters.splice(index, 1);
+ resolveWait(command);
+ };
+ const timer = setTimeout(() => waiter(undefined), timeoutMs);
+ waiters.push(waiter);
+ registerWaiter(waiter);
+ });
+}
+
+// Upstream's three-way diagnosis, so a client sees the same actionable timeout text.
+function timeoutMessage(entry, timeoutMs) {
+ const pollAgeMs = lastSeenAt === undefined ? undefined : Date.now() - lastSeenAt;
+ if (entry?.deliveredAt) {
+ return `Timed out after ${timeoutMs}ms: the browser extension received the command but never returned a result. The action may be long-running, or the result post failed. Retry; if it persists, reload 'Pi Chrome Connector' on the browser host.`;
+ }
+ if (pollAgeMs === undefined || pollAgeMs > 60_000) {
+ return `Timed out after ${timeoutMs}ms: the browser extension is not polling the broker (last seen ${pollAgeMs === undefined ? "never" : Math.round(pollAgeMs / 1000) + "s ago"}). The browser on the broker's host is closed, or 'Pi Chrome Connector' is not loaded in it.`;
+ }
+ return `Timed out after ${timeoutMs}ms: the browser extension is polling (last seen ${Math.round(pollAgeMs / 1000)}s ago) but did not pick up this command in time. Retry; if it persists, reload 'Pi Chrome Connector' on the browser host.`;
+}
+
+function runCommand(action, params, timeoutMs, onAbort) {
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+ const command = { id, action, params };
+ return new Promise((resolveCommand, rejectCommand) => {
+ const timer = setTimeout(() => {
+ const entry = pending.get(id);
+ pending.delete(id);
+ dropQueued(id);
+ rejectCommand(new Error(timeoutMessage(entry, timeoutMs)));
+ }, timeoutMs);
+ pending.set(id, { command, resolve: resolveCommand, reject: rejectCommand, timer });
+ // A client that hangs up before delivery should not leave its command to run later
+ // against a browser nobody is waiting on. Once delivered, it is the extension's.
+ onAbort(() => {
+ const entry = pending.get(id);
+ if (!entry || entry.deliveredAt) return;
+ clearTimeout(entry.timer);
+ pending.delete(id);
+ dropQueued(id);
+ rejectCommand(new Error("client went away"));
+ });
+ enqueue(command);
+ });
+}
+
+function status() {
+ return {
+ mode: "broker",
+ version: readVersion(),
+ startedAt,
+ connected: lastSeenAt !== undefined && Date.now() - lastSeenAt < 5 * 60_000,
+ lastSeenAt,
+ clientName,
+ queuedCommands: queue.length,
+ pendingCommands: pending.size,
+ };
+}
+
+async function handleCommand(request, response) {
+ if (!isLocalProcessRequest(request)) {
+ sendJson(response, 403, { ok: false, error: "Chrome commands are accepted only from local Pi processes" });
+ return;
+ }
+ if (!hasValidToken(request)) {
+ sendJson(response, 401, { ok: false, error: "pi-chrome broker rejected the bearer token (check PI_CHROME_TOKEN on both ends)" });
+ return;
+ }
+ let body;
+ try {
+ body = JSON.parse(await readBody(request));
+ } catch (error) {
+ sendJson(response, 400, { ok: false, error: `Bad command body: ${error.message}` });
+ return;
+ }
+ if (!body?.action) {
+ sendJson(response, 400, { ok: false, error: "Missing command action" });
+ return;
+ }
+ const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || DEFAULT_TIMEOUT_MS, 1_000), MAX_TIMEOUT_MS);
+ const started = Date.now();
+ const peer = request.socket.remoteAddress;
+ try {
+ const result = await runCommand(body.action, body.params ?? {}, timeoutMs, (abort) => {
+ response.once("close", () => {
+ if (!response.writableEnded) abort();
+ });
+ });
+ // Log the action, never its params: those carry typed text, form values and page data.
+ log("info", "command", { action: body.action, peer, ok: true, ms: Date.now() - started });
+ sendJson(response, 200, { ok: true, result });
+ } catch (error) {
+ log("warn", "command", { action: body.action, peer, ok: false, ms: Date.now() - started, error: error.message });
+ sendJson(response, 504, { ok: false, error: error.message });
+ }
+}
+
+async function handleNext(request, response, url) {
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
+ return;
+ }
+ if (lastSeenAt === undefined || Date.now() - lastSeenAt > 60_000) {
+ log("info", "extension polling", { name: url.searchParams.get("name") ?? undefined });
+ }
+ lastSeenAt = Date.now();
+ clientName = url.searchParams.get("name") ?? undefined;
+ let aborted = false;
+ let activeWaiter;
+ response.once("close", () => {
+ if (response.writableEnded) return;
+ aborted = true;
+ const index = activeWaiter ? waiters.indexOf(activeWaiter) : -1;
+ if (index !== -1) waiters.splice(index, 1);
+ });
+ let command = queue.shift();
+ if (!command) {
+ command = await waitForCommand(LONG_POLL_MS, (waiter) => {
+ activeWaiter = waiter;
+ });
+ }
+ if (aborted) {
+ // The long-poll died before delivery: put the command back for the next poll.
+ if (command) queue.unshift(command);
+ return;
+ }
+ if (command) {
+ const entry = pending.get(command.id);
+ if (entry) entry.deliveredAt = Date.now();
+ }
+ const version = readVersion();
+ sendJson(
+ response,
+ 200,
+ command ? { type: "command", command, expectedExtensionVersion: version } : { type: "none", expectedExtensionVersion: version },
+ { ...corsHeadersFor(request), "x-pi-chrome-version": version },
+ );
+}
+
+async function handleResult(request, response) {
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
+ return;
+ }
+ lastSeenAt = Date.now();
+ const corsHeaders = corsHeadersFor(request);
+ let result;
+ try {
+ result = JSON.parse(await readBody(request));
+ } catch (error) {
+ sendJson(response, 400, { ok: false, error: `Bad result body: ${error.message}` }, corsHeaders);
+ return;
+ }
+ const entry = pending.get(result?.id);
+ if (!entry) {
+ sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders);
+ return;
+ }
+ clearTimeout(entry.timer);
+ pending.delete(result.id);
+ if (result.ok) entry.resolve(result.result);
+ else entry.reject(new Error(result.error ?? "Chrome extension command failed"));
+ sendJson(response, 200, { ok: true }, corsHeaders);
+}
+
+async function handle(listener, request, response) {
+ const url = new URL(request.url ?? "/", "http://broker");
+ const route = `${request.method} ${url.pathname}`;
+
+ if (route === "POST /command") return handleCommand(request, response);
+
+ if (route === "GET /status") {
+ if (listener === "peer" && !hasValidToken(request)) {
+ sendJson(response, 401, { ok: false, error: "bearer token required" });
+ return;
+ }
+ sendJson(response, 200, status());
+ return;
+ }
+
+ if (listener === "ext") {
+ if (request.method === "OPTIONS") {
+ if (!isBrowserOriginAllowed(request)) {
+ sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
+ return;
+ }
+ sendJson(response, 200, { ok: true }, corsHeadersFor(request));
+ return;
+ }
+ if (route === "GET /next") return handleNext(request, response, url);
+ if (route === "POST /result") return handleResult(request, response);
+ }
+
+ sendJson(response, 404, { error: "not found" });
+}
+
+function listen(listener, host) {
+ const server = createServer((request, response) => {
+ handle(listener, request, response).catch((error) => {
+ log("error", "request failed", { listener, error: error.message });
+ sendJson(response, 500, { ok: false, error: error.message });
+ });
+ });
+ server.on("error", (error) => {
+ // Most likely the private address is not up yet. Exit and let the supervisor retry, rather than
+ // run half a broker that the other hosts cannot reach.
+ log("error", "listen failed", { listener, host, port: PORT, error: error.message });
+ process.exit(1);
+ });
+ server.listen(PORT, host, () => log("info", "listening", { listener, host, port: PORT, version: readVersion() }));
+ return server;
+}
+
+const servers = [listen("ext", EXT_HOST)];
+if (PEER_HOST && PEER_HOST !== EXT_HOST) servers.push(listen("peer", PEER_HOST));
+
+function shutdown(signal) {
+ log("info", "shutting down", { signal, pending: pending.size });
+ for (const entry of pending.values()) {
+ clearTimeout(entry.timer);
+ entry.reject(new Error("pi-chrome broker stopped"));
+ }
+ pending.clear();
+ for (const waiter of [...waiters]) waiter(undefined);
+ for (const server of servers) server.close();
+ setTimeout(() => process.exit(0), 1_000).unref();
+}
+process.on("SIGTERM", () => shutdown("SIGTERM"));
+process.on("SIGINT", () => shutdown("SIGINT"));