Public: anyone can read this; the crew can change it.

The forge's own release journey is not rate-limited by the forge and does not spend the strangers' daily allowance #12221

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12169-the-forge-s-own-release-journey-is-not-r → main 2 commits fork

  • Merged. scout/12169-the-forge-s-own-release-journey-is-not-r was merged into main as f8d173e.

Review

Sign in to review this change.
6 files changed +164 −6
src/main.ts +2
@@ -98,6 +98,7 @@ import { record as recordDegraded } from "./kernel/degraded_history.ts";
9898 import { vitals } from "./kernel/vitals.ts";
9999 import { proveRunning } from "./release/prove.ts";
100100 import { walkJourney } from "./release/journey.ts";
101 import { proberKey } from "./release/prober.ts";
101102 import { CHECK_EVERY_MS, setBehind, watchBehind } from "./release/behind.ts";
102103 import { readLandings } from "./release/landings.ts";
103104 import { ownTagsReader } from "./release/own_tags.ts";
@@ -797,6 +798,7 @@ proveRunning({
797798 url: forgeUrl,
798799 token: process.env["FORGE_TOKEN"],
799800 journey: walkJourney,
801 proberKey: proberKey(app.hookSecret),
800802 // The first app's production host: it must never answer as the forge (#6726).
801803 appHost: (() => {
802804 const first = listApps(app.db)[0];
src/release/journey.ts +12 −2
@@ -23,6 +23,7 @@
2323 * is its own.
2424 */
2525 import { FIRST_FIVE_MINUTES } from "../design/first_five_minutes.ts";
26 import { PROBER_HEADER } from "./prober.ts";
2627
2728 export interface JourneyOptions {
2829 /** The live forge's address, http(s). */
@@ -35,6 +36,11 @@ export interface JourneyOptions {
3536 readonly now?: () => number;
3637 /** A site admin's token on the host: removes the throwaway forge afterwards. */
3738 readonly adminToken?: string;
39 /**
40 * This boot's prober key (`release/prober.ts`): sent to the host only, so the forge's own walk
41 * is not a stranger to its rate limits.
42 */
43 readonly proberKey?: string;
3844 /** Pause between polls, in ms (default 500); how a test keeps its clock quick. */
3945 readonly pollMs?: number;
4046 /** Waits `ms`; `Bun.sleep` when left out. */
@@ -116,8 +122,12 @@ export async function walkJourney(options: JourneyOptions): Promise<JourneyResul
116122 return { ok: false, screens, readyMs, firstActivityMs, failure: "the forge's address is not http or https", forge, removed };
117123 const hostUrl = host;
118124
119 const call = (url: URL, init: RequestInit = {}): Promise<Response> =>
120 doFetch(url.href, { redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...init });
125 // The prober key goes to the host's own origin and nowhere else: never to the new forge.
126 const call = (url: URL, init: RequestInit = {}): Promise<Response> => {
127 const own = options.proberKey !== undefined && url.origin === hostUrl.origin;
128 const headers = own ? { ...(init.headers as Record<string, string> | undefined), [PROBER_HEADER]: options.proberKey } : init.headers;
129 return doFetch(url.href, { redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...init, ...(headers === undefined ? {} : { headers }) });
130 };
121131 const form = (url: URL, fields: Record<string, string>, extra: Record<string, string> = {}): Promise<Response> =>
122132 call(url, {
123133 method: "POST",
src/release/prober.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The forge's own release prober (#12169): the stranger's journey the release
3 * proof walks (`release/journey.ts`) is the forge asking itself, so the
4 * stranger rate limits in `spawn/door.ts` do not count it. It is recognised
5 * by a key only this boot holds — an HMAC of the per-boot hook secret, never
6 * the secret itself, so the header that crosses the public address cannot
7 * open the internal listener — and never by a user agent or an address.
8 */
9 import { createHmac, timingSafeEqual } from "node:crypto";
10
11 /** The header the journey sets on its own host's requests. */
12 export const PROBER_HEADER = "x-forge-prober";
13
14 /** This boot's prober key, from its hook secret. */
15 export const proberKey = (hookSecret: string): string =>
16 createHmac("sha256", hookSecret).update("release-journey-prober").digest("hex");
17
18 /**
19 * Whether `request` carries this boot's prober key (constant-time); anything else, forged or
20 * missing, is a stranger.
21 */
22 export function isProber(hookSecret: string, request: Request): boolean {
23 const given = Buffer.from(request.headers.get(PROBER_HEADER) ?? "");
24 const want = Buffer.from(proberKey(hookSecret));
25 return hookSecret.length > 0 && given.length === want.length && timingSafeEqual(given, want);
26 }
src/release/prove.ts +6 −1
@@ -237,6 +237,11 @@ export interface ProveOptions {
237237 readonly signal?: AbortSignal;
238238 /** Walks the stranger's journey after the smoke checks; the forge passes `walkJourney`, and a test its own. Without it no walk runs. */
239239 readonly journey?: typeof walkJourney;
240 /**
241 * This boot's prober key (`release/prober.ts`), handed to the journey so the forge's rate
242 * limits know it.
243 */
244 readonly proberKey?: string;
240245 }
241246
242247 /** Is a proof of `version` due: never tried, or failed `retryAfter` its failures or more ago? */
@@ -307,7 +312,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
307312 if (failures[0] !== undefined || o.journey === undefined)
308313 return finish(failures[0] === undefined ? undefined : checkLine(failures[0]), failures[0]?.slow === true || failures[0]?.empty === true || failures[0]?.hung === true);
309314 // The stranger's journey, the last check: a failed walk is a failed proof, retried on `retryAfter` like any other (one card, paged once).
310 const walked = await o.journey({ base: o.url, fetch: doFetch, ...(o.token ? { adminToken: o.token } : {}) });
315 const walked = await o.journey({ base: o.url, fetch: doFetch, ...(o.token ? { adminToken: o.token } : {}), ...(o.proberKey ? { proberKey: o.proberKey } : {}) });
311316 return finish(walked.ok ? undefined : `a stranger's journey failed: ${walked.failure ?? "it did not finish"}`, false, false, walked);
312317 } catch (error) {
313318 return finish(`the smoke checks could not run: ${String(error)}`);
src/spawn/door.ts +7 −3
@@ -36,6 +36,7 @@ import { dryRun, planSpawn, KEY_BUDGET_NOTE, MAX_APPS, type CatalogApp, type Cre
3636 import { bootHostedChild } from "./host.ts";
3737 import { cellPlacement, ownsBody } from "./cell.ts";
3838 import { cellsAvailable } from "../apps/contain.ts";
39 import { isProber } from "../release/prober.ts";
3940 import { ANON_SPAWNS_PER_DAY, checkAnonSpawn, noteAnonSpawn } from "./rate.ts";
4041 import { readdirSync, readFileSync } from "node:fs";
4142 import { readdir, stat } from "node:fs/promises";
@@ -191,14 +192,17 @@ export async function spawnDoor(
191192 */
192193 opts: { readonly paid?: boolean; readonly page?: boolean } = {},
193194 ): Promise<Door> {
195 // The forge's own release prober (`release/prober.ts`) is no stranger: its walk spends no
196 // stranger's allowance.
194197 const paid = opts.paid === true;
198 const counted = !paid && !isProber(ctx.app.hookSecret, ctx.request);
195199 const anon = user === undefined;
196200 if (!anon && user.kind !== "user") return spawnWords({ kind: "forbidden" });
197201 // A forge asked for in order to contribute is never made: the fork path costs the host no cell.
198202 if (wantsToContribute(raw)) return { status: 200, body: contributeAnswer() };
199203 // A plan is free of children but not of work — it reads every catalog
200204 // app it names — so a stranger's are counted per address and per forge.
201 if (anon && !paid) {
205 if (anon && counted) {
202206 const verdict = takeDoor(ctx.app, "plan", ctx.request);
203207 if (!verdict.allowed)
204208 return {
@@ -346,7 +350,7 @@ export async function spawnDoor(
346350 };
347351 }
348352 // A stranger may start only a few children a day; the plan above is free.
349 if (anon && !paid) {
353 if (anon && counted) {
350354 const limit = checkAnonSpawn(ctx.app.db, ctx.request);
351355 if (!limit.allowed)
352356 return {
@@ -385,7 +389,7 @@ export async function spawnDoor(
385389 if (gift !== undefined && !settled) unclaimGift(ctx.app, gift);
386390 }
387391 if (!made.ok) return spawnWords(made.error);
388 if (anon && !paid && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request);
392 if (anon && counted && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request);
389393 // The visitor who started (or was given) this forge becomes someone, on the host's side (#11265).
390394 if (!made.value.existed && !paid) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid");
391395 // Its apps come up now, so the first visit after a claim finds them awake.
test/release-prober.test.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * The forge's own release journey is no stranger (#12169): with this boot's
3 * prober key it starts a forge when the strangers' limits are spent and
4 * spends none of their allowance; without it, or with a forged or wrong one,
5 * it lands on `/?start=rate` like anyone. The journey sends the key to its
6 * own host only, never to the new forge.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { ok } from "../src/kernel/result.ts";
15 import { usePlacement, useProbe } from "../src/spawn/door.ts";
16 import { ANON_SPAWNS_GLOBAL_PER_DAY, noteAnonSpawn, resetAnonSpawns } from "../src/spawn/rate.ts";
17 import { isProber, PROBER_HEADER, proberKey } from "../src/release/prober.ts";
18 import { walkJourney } from "../src/release/journey.ts";
19
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-prober-"));
25 app = createApp({ dataDir, port: 0 });
26 servers = await serve(app);
27 useProbe(app, () => async () => false);
28 usePlacement(app, {
29 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${plan.name}` }),
30 retire: async () => ok(),
31 });
32 });
33 afterAll(async () => {
34 await servers.stop?.();
35 app.close();
36 await rm(dataDir, { recursive: true, force: true });
37 });
38
39 const start = (name: string, extra: Record<string, string> = {}) =>
40 fetch(`${servers.url}/forges/start`, {
41 method: "POST",
42 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, ...extra },
43 body: new URLSearchParams({ name }),
44 redirect: "manual",
45 });
46 const spent = (): number => (app.db.query("SELECT COUNT(*) AS n FROM anon_spawn").get() as { n: number }).n;
47
48 test("only this boot's key is the prober: not the hook secret, not another boot's, not empty", () => {
49 const req = (v?: string) => new Request("http://x/", v === undefined ? {} : { headers: { [PROBER_HEADER]: v } });
50 expect(isProber(app.hookSecret, req(proberKey(app.hookSecret)))).toBe(true);
51 expect(isProber(app.hookSecret, req())).toBe(false);
52 expect(isProber(app.hookSecret, req(""))).toBe(false);
53 expect(isProber(app.hookSecret, req(app.hookSecret))).toBe(false);
54 expect(isProber(app.hookSecret, req(proberKey("another-boot")))).toBe(false);
55 expect(isProber("", req(proberKey("")))).toBe(false);
56 expect(proberKey(app.hookSecret)).not.toContain(app.hookSecret);
57 });
58
59 test("when the strangers' day is spent, the prober starts a forge and spends nothing; a stranger and a forger land on start=rate", async () => {
60 resetAnonSpawns(app.db);
61 for (let i = 0; i < ANON_SPAWNS_GLOBAL_PER_DAY; i++)
62 noteAnonSpawn(app.db, new Request(`http://x/${i}`, { headers: { "x-forwarded-for": `10.${(i >> 8) & 255}.${i & 255}.1` } }));
63 const before = spent();
64
65 for (const extra of [{}, { [PROBER_HEADER]: "0".repeat(64) }, { [PROBER_HEADER]: app.hookSecret }, { [PROBER_HEADER]: proberKey("forged") }]) {
66 const res = await start("stranger", extra);
67 expect(res.status).toBe(303);
68 expect(res.headers.get("location") ?? "").toStartWith("/?start=rate");
69 }
70
71 const res = await start("probe-own", { [PROBER_HEADER]: proberKey(app.hookSecret) });
72 expect(res.status).toBe(200);
73 expect(await res.text()).toContain('id="made-open"');
74 expect(spent()).toBe(before);
75 });
76
77 test("past the per-minute plan limit the prober still goes through, a forger does not", async () => {
78 resetAnonSpawns(app.db);
79 let limited = false;
80 for (let i = 0; i < 40 && !limited; i++) {
81 const res = await start(`burst-${i}`, { [PROBER_HEADER]: proberKey("forged") });
82 limited = res.status === 303 && (res.headers.get("location") ?? "").startsWith("/?start=rate");
83 }
84 expect(limited).toBe(true);
85 const res = await start("probe-two", { [PROBER_HEADER]: proberKey(app.hookSecret) });
86 expect(res.status).toBe(200);
87 });
88
89 test("the journey sends the key to its own host only, never to the new forge", async () => {
90 const seen: { url: string; key: string | null }[] = [];
91 const code = "a".repeat(43);
92 const fake = (async (input: string | URL | Request, init?: RequestInit) => {
93 const url = new URL(String(input));
94 seen.push({ url: url.href, key: new Headers(init?.headers).get(PROBER_HEADER) });
95 if (url.pathname === "/forges/start") {
96 const name = new URLSearchParams(String(init?.body)).get("name");
97 return new Response(`<a id="made-open" href="https://${name}--kids.test/welcome#${code}">open</a>`, { status: 200 });
98 }
99 if (url.pathname.endsWith("/ready")) return Response.json({ ready: true });
100 if (url.hostname === "host.test") return new Response("ok", { status: 200 });
101 return new Response("no", { status: 500 });
102 }) as typeof fetch;
103 const walked = await walkJourney({ base: "https://host.test/", fetch: fake, proberKey: "k3y", pollMs: 0, sleep: async () => {} });
104 expect(walked.ok).toBe(false);
105 const host = seen.filter((s) => new URL(s.url).hostname === "host.test");
106 const child = seen.filter((s) => new URL(s.url).hostname !== "host.test");
107 expect(host.some((s) => s.url.endsWith("/forges/start"))).toBe(true);
108 expect(host.every((s) => s.key === "k3y")).toBe(true);
109 expect(child.length).toBeGreaterThan(0);
110 expect(child.every((s) => s.key === null)).toBe(true);
111 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs