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

The forge's data is backed up encrypted to object storage on neither machine, and a restore is drilled #11835

opened this

Check · Review · Land · Live — Its check is queued. Next: reviewer, pushed since the last review.

Ready to land

mason/forge:mason/11805-the-forge-s-data-is-backed-up-encrypted → main 7 commits fork

Review

Sign in to review this change.
25 files changed +990 −69
docs/OPERATING.md +9 −9
@@ -103,6 +103,7 @@ down.
103103 <!-- footing:start — written by footingTable() in src/system/footing.ts; test/footing.test.ts holds it equal -->
104104 - **pg-grand**: DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it.
105105 - **vxrail**: A k3s agent joined to pg-grand. The forge runs here.
106 - **off-site**: Object storage on neither machine, at a provider of its own: the off-site backup lane's bucket.
106107
107108 | Needed to | Piece | Runs on | If that machine dies | Breaks | Removed by |
108109 |---|---|---|---|---|---|
@@ -111,7 +112,7 @@ down.
111112 | (a) answer a request | The vxrail's own tunnel (standby/cloudflared-forge-home, 2 replicas): it too sends *.open-platform.sh to Traefik; only forge-home-test.open-platform.sh and arm--forge-trevato-apps.open-platform.sh go straight to the forge | vxrail | It goes with the forge, which runs beside it. | — | — |
112113 | (a) answer a request | Traefik (kube-system/traefik): every public request goes tunnel → Traefik → the forge's Service | pg-grand | The forge is off the internet; it keeps running inside. | 1 | #11686, #8946 |
113114 | (a) answer a request | Child and app hosts, Traefik IngressRoutes forge-children (&lt;name&gt;--forge.open-platform.sh), forge-apps (*--forge-trevato-apps.open-platform.sh) and forge-old-host, and game.cheffryjames.com and www.cheffryjames.com in the forge's namespace | pg-grand | No child forge, app or custom domain answers. | 1 | #11686, #8946 |
114 | (a) answer a request | forge-ssh, a host socket proxy (:2222 → the forge's ssh service) | pg-grand | git over ssh and the terminal stop answering. | 7 | #11686 |
115 | (a) answer a request | forge-ssh, a host socket proxy (:2222 → the forge's ssh service) | pg-grand | git over ssh and the terminal stop answering. | 6 | #11686 |
115116 | (a) answer a request | The k3s API server (the vxrail is an agent joined to it) | pg-grand | Running pods keep running, but nothing reschedules, Services stop updating, and no release rolls or Job runs. | 5 | #11686 |
116117 | (b) sign a person in | Sign-in edge: a forwardAuth middleware on the forge's Ingress, answered by mcp/plat-auth | pg-grand | Every request through the edge answers 500, /healthz too. | 2 | #11686 |
117118 | (b) sign a person in | forge-signup-gate, the pod /signup is routed to | pg-grand | No one can sign up. | 3 | #11686 |
@@ -120,11 +121,11 @@ down.
120121 | (c) build | forgejo-runner-vxrail, a Forgejo Actions runner | vxrail | It goes with the forge, which runs beside it. | — | — |
121122 | (d) release | Forgejo's registry (git.open-platform.sh), which the forge's image is pulled from | pg-grand | A pod that restarts on a node without the image cached cannot start; nothing new is released. | 4 | #11686, #8941, #8942 |
122123 | (d) release | registry-node, a registry on the vxrail | vxrail | It goes with the forge, which runs beside it. | — | — |
123 | (d) release | forge-release-tick, a host timer every 5 min | pg-grand | Nothing is released; production keeps the release it has. | 7 | #11686, #8942 |
124 | (d) release | standby-reconcile, a host timer every 2 min that applies the forge's manifests from Forgejo; Flux's Kustomization for the forge is suspended | pg-grand | A changed manifest is not applied; the running pod is untouched. | 7 | #8948, #11686 |
125 | (d) release | Host timers forge-watchdog, forge-github-mirror and forge-prune-images | pg-grand | The watchdog, the GitHub mirror and image pruning stop. | 7 | #11686 |
126 | (e) back up | The backup namespace's CronJobs (FORGE_BACKUP_LANES) | pg-grand | No new backup is taken. | 6 | #11686 |
127 | (e) back up | MinIO, where the backup lanes store the forge's backups | pg-grand | No new backup is stored, and those it holds go with the machine. | 6 | #11686 |
124 | (d) release | forge-release-tick, a host timer every 5 min | pg-grand | Nothing is released; production keeps the release it has. | 6 | #11686, #8942 |
125 | (d) release | standby-reconcile, a host timer every 2 min that applies the forge's manifests from Forgejo; Flux's Kustomization for the forge is suspended | pg-grand | A changed manifest is not applied; the running pod is untouched. | 6 | #8948, #11686 |
126 | (d) release | Host timers forge-watchdog, forge-github-mirror and forge-prune-images | pg-grand | The watchdog, the GitHub mirror and image pruning stop. | 6 | #11686 |
127 | (e) back up | The forge's own backup lane (src/snapshot): the nightly snapshot and the hourly off-site pass, run in the forge's process, every object sealed under FORGE_SNAPSHOT_PASSPHRASE before it leaves; the nightly lands on the forge's own disk (FORGE_SNAPSHOT_DIR on its volume), not the platform's MinIO | vxrail | No new backup is taken; the sealed copies wait off both machines. | — | — |
128 | (e) back up | The off-site lane's bucket (FORGE_MIRROR_S3_*, or the mirror sealed on the Backups page), on neither machine | off-site | Neither machine holds it: losing either leaves every sealed copy in place. | — | — |
128129
129130 If pg-grand were switched off now, the forge breaks in this order (the Breaks column):
130131
@@ -133,10 +134,9 @@ If pg-grand were switched off now, the forge breaks in this order (the Breaks co
133134 3. the signup gate: no one signs up
134135 4. image pulls and releases
135136 5. the control plane: nothing reschedules, Services stop updating
136 6. local backups
137 7. the host timers
137 6. the host timers
138138
139 Also on pg-grand, not in the forge's path: Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and MinIO.
139 Also on pg-grand, not in the forge's path: Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and the platform's backup CronJobs (the backup namespace, FORGE_BACKUP_LANES) with the MinIO they write to; the forge's own backup no longer needs them.
140140 <!-- footing:end -->
141141
142142 The same list is read from the live cluster on `/system`, under "What the
docs/RECOVERY.md +3 −3
@@ -35,10 +35,10 @@ A machine with `git`, `bun` and a clone of the forge (`bun install`).
3535
3636 ## Route 1 — the restore command
3737
38 bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N]
38 bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N] [--offsite]
3939
40 `<data-dir>` must be empty or new. `--clones N` (1–32) sets how many
41 repositories clone at once (default: cores less one, 2–16).
40 `<data-dir>` must be empty or new. `--clones N` (1–32): repositories cloned at once
41 (default: cores less one, 2–16). `--offsite`: from the sealed lane, [features/offsite-lane.md](features/offsite-lane.md).
4242
4343 Then boot the forge on that directory, on a side port, off the public one:
4444
docs/RESTORE.md +5
@@ -56,4 +56,9 @@ refuses `/data`, and leaves the restored copy in place.
5656 `test/restore-drill-script.test.ts` runs it against a tiny snapshot and a
5757 stub bucket.
5858
59 ## From the off-site lane
60
61 The forge's own sealed backup lane (#11805), how to restore from it and how
62 it is drilled: [features/offsite-lane.md](features/offsite-lane.md).
63
5964 With the platform unreachable (the B2 mirror only): [backup-truth.md](features/backup-truth.md), _Leaving the platform_.
docs/features/offsite-lane.md added +57
@@ -0,0 +1,57 @@
1 # The off-site backup lane
2
3 The forge's own backup lane (#11805) is the mirror pass that runs in the
4 forge's process every hour and after every snapshot. It copies the newest
5 snapshots to a bucket on neither machine (`FORGE_MIRROR_S3_*`, or the mirror
6 sealed on the Backups page), and every object it writes is sealed first:
7 AES-256-GCM under a fresh IV, the key derived from `FORGE_SNAPSHOT_PASSPHRASE`
8 by scrypt (`src/snapshot/sealed_store.ts`). Paths inside a snapshot are hidden
9 (an HMAC of each path), so the bucket does not even name a repository.
10
11 - **The salt is the lane's own.** The first pass writes one unsealed header,
12 `sealed/lane.json`: a random 16-byte salt and a check value (an HMAC under
13 the derived key, never the key). A guess at the passphrase costs one scrypt
14 for that lane alone, and a wrong passphrase is said as such before any
15 object is read.
16 - **The key is in the passphrase alone**: not in this repository, not on the
17 bucket. Keep it with the operator, apart from the bucket's keys.
18 - **Without the passphrase the lane writes nothing.** Every pass fails saying
19 `FORGE_SNAPSHOT_PASSPHRASE is not set`, and `/system` shows the lane red;
20 not one unsealed byte leaves the forge.
21
22 For the lane to stand without pg-grand, the nightly snapshot itself must land
23 on the forge's own disk (`FORGE_SNAPSHOT_DIR` on its volume) rather than the
24 platform's MinIO: the lane copies from there.
25
26 ## Restore from it
27
28 On a fresh machine, follow [RESTORE.md](../RESTORE.md), exporting the lane's
29 bucket in place of line 3, keeping line 4 (the passphrase), and adding
30 `--offsite` to line 5:
31
32 export FORGE_MIRROR_S3_ENDPOINT=… FORGE_MIRROR_S3_BUCKET=… FORGE_MIRROR_S3_ACCESS_KEY=… FORGE_MIRROR_S3_SECRET_KEY=…
33 bun run src/snapshot/restore.ts latest <data-dir> --offsite
34
35 `bun scripts/restore-drill.ts <empty-data-dir> --offsite` drills it the same
36 way. Without the passphrase both refuse before writing anything; a restore
37 never starts a lane on an empty bucket.
38
39 ## The drill
40
41 The weekly drill, the Backups page's button and the MCP `restore_drill`
42 restore the newest snapshot from the off-site lane when the forge has one
43 (the destination otherwise) into a scratch forge, boot it, and check that
44 `/api/v1/health` answers and `/` answers below 400. A drill waits for a mirror
45 pass in flight, at most the drill's floor budget (10 min), then reads the lane
46 as it stands. Each drill is kept with the store it restored from.
47
48 ## Where to see it
49
50 - **Web**: `/system`, under _Off-site backup_: one sentence, and behind it
51 where the lane writes, whether it is encrypted, its last success, and the
52 last drill with where it restored from.
53 - **Terminal**: the palette's **Off-site backup**, the same rows.
54 - **MCP**: `system` with `{ kind: "offsite" }`, the same reading as
55 `{ health, says, where, sealed, lastSuccess, newest, error, drill }`.
56
57 All three are a forge admin's alone.
scripts/restore-drill.ts +16 −5
@@ -6,7 +6,10 @@
66 * writes only into the empty data directory it is given, and reads
77 * production through its public API, nothing else of the platform.
88 *
9 * bun scripts/restore-drill.ts <data-dir> [--production https://forge.open-platform.sh]
9 * bun scripts/restore-drill.ts <data-dir> [--production https://forge.open-platform.sh] [--offsite]
10 *
11 * `--offsite` reads the sealed off-site lane (`FORGE_MIRROR_S3_*` and the
12 * passphrase, #11805) in place of the destination.
1013 *
1114 * It restores, boots the restored forge on a free side port, checks `/`
1215 * answers, counts the repositories and apps anyone can see there and on
@@ -17,6 +20,7 @@
1720 import { resolve } from "node:path";
1821 import { bootRestored } from "../src/snapshot/boot.ts";
1922 import { resolveDestination } from "../src/snapshot/destination.ts";
23 import { offsiteFromEnv } from "../src/snapshot/mirror.ts";
2024 import { publicCounts, type PublicCounts } from "../src/snapshot/public_counts.ts";
2125 import { restoreSnapshot } from "../src/snapshot/restore.ts";
2226 import { listSnapshots } from "../src/snapshot/snapshot.ts";
@@ -28,8 +32,10 @@ const PRODUCTION_DATA = "/data";
2832 const BOOT_MS = 60_000;
2933
3034 /** The arguments as plain data, or the reason they are not usable. */
31 export function parseArgs(args: readonly string[]): { dataDir: string; production: string } | string {
35 export function parseArgs(args: readonly string[]): { dataDir: string; production: string; offsite?: true } | string {
3236 const rest = [...args];
37 const off = rest.indexOf("--offsite");
38 if (off >= 0) rest.splice(off, 1);
3339 let production = PRODUCTION;
3440 const at = rest.indexOf("--production");
3541 if (at >= 0) {
@@ -46,10 +52,10 @@ export function parseArgs(args: readonly string[]): { dataDir: string; productio
4652 if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
4753 return "--production takes an https URL";
4854 if (rest.length !== 1 || !rest[0] || rest[0].startsWith("-"))
49 return "usage: bun scripts/restore-drill.ts <data-dir> [--production URL]";
55 return "usage: bun scripts/restore-drill.ts <data-dir> [--production URL] [--offsite]";
5056 const dataDir = resolve(rest[0]);
5157 if (dataDir === PRODUCTION_DATA) return `${dataDir} is production's data directory; restore into the box's own`;
52 return { dataDir, production: url.origin };
58 return { dataDir, production: url.origin, ...(off >= 0 ? { offsite: true as const } : {}) };
5359 }
5460
5561 /** The two sides' counts as lines a person reads, and which differ. */
@@ -69,7 +75,12 @@ async function main(): Promise<number> {
6975 console.error(args);
7076 return 2;
7177 }
72 const store = resolveDestination()?.store;
78 const lane = args.offsite ? offsiteFromEnv() : undefined;
79 if (typeof lane === "string") {
80 console.error(lane);
81 return 2;
82 }
83 const store = lane ?? resolveDestination()?.store;
7384 if (store === undefined) {
7485 console.error("no store: export the S3_* lines of the restore card, or FORGE_SNAPSHOT_DIR");
7586 return 2;
src/http/mcp/tools/restore_drill.ts +5 −3
@@ -4,7 +4,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts";
44
55 export const tool: Tool = {
66 name: "restore_drill",
7 doc: "no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says }`, worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token",
7 doc: "no input: restore the newest snapshot as a drill and answer `{ verdict, failedStep?, says, from?, offSite? }` (from: the store it restored, the off-site lane when there is one), worded as the Backups page's button; no store, a failure or a red drill is an answer, not an error; a forge admin's, with an admin token",
88 description: "Drill-restore the newest snapshot.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: true,
@@ -18,10 +18,12 @@ export const tool: Tool = {
1818 return good({ verdict: "none", says: "This forge has no snapshots to drill." });
1919 try {
2020 const d = await svc.drill();
21 // Where it restored from (the off-site lane when the forge has one, #11805), never a key.
22 const from = d.from === undefined ? {} : { from: d.from, offSite: d.offSite === true };
2123 const answer =
2224 d.verdict === "green"
23 ? good({ verdict: "green", says: "The drill restored green." })
24 : good({ verdict: "red", failedStep: d.failedStep, says: `The drill failed at ${d.failedStep}.` });
25 ? good({ verdict: "green", says: "The drill restored green.", ...from })
26 : good({ verdict: "red", failedStep: d.failedStep, says: `The drill failed at ${d.failedStep}.`, ...from });
2527 return { ...answer, card: { at: d.at } };
2628 } catch (error) {
2729 return good({ verdict: "none", says: `The drill could not run: ${String(error)}` });
src/http/mcp/tools/system.ts +9 −3
@@ -8,14 +8,15 @@ import { lagMinutes, loopWatch, perfRecord } from "../../../perf/live.ts";
88 import { LAG_BAR_MS, overBar } from "../../../perf/lag_minutes.ts";
99 import { slowestRoutes } from "../../../perf/watch.ts";
1010 import { footing, riskLines } from "../../../system/footing.ts";
11 import { laneWords, readOffsiteLane } from "../../../snapshot/offsite_lane.ts";
1112 import { good, bad, type Tool } from "../shared.ts";
1213
1314 export const tool: Tool = {
1415 name: "system",
15 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only. `{ kind: \"footing\" }`: what the forge stands on (#11685), as /system's \"What the forge stands on\": `forgeNode`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues }` read from the live cluster, with `risks`, one line per piece not on the forge's machine; a forge admin only",
16 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only. `{ kind: \"footing\" }`: what the forge stands on (#11685), as /system's \"What the forge stands on\": `forgeNode`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues }` read from the live cluster, with `risks`, one line per piece not on the forge's machine; a forge admin only. `{ kind: \"offsite\" }`: the off-site backup lane (#11805), as /system's \"Off-site backup\" and the terminal's: `{ health, says, where, sealed, lastSuccess, newest, error, drill: { at, verdict, from, offSite, reason } | null }`; a forge admin only",
1617 description:
1718 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).",
18 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing"] } } },
19 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing", "offsite"] } } },
1920 writes: false,
2021 async run(ctx, user, args) {
2122 if (args["kind"] !== undefined) {
@@ -30,7 +31,12 @@ export const tool: Tool = {
3031 const f = await footing.read();
3132 return good({ ...f, risks: riskLines(f) });
3233 }
33 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, or absent for the machine");
34 if (args["kind"] === "offsite") {
35 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its off-site backup lane");
36 const lane = readOffsiteLane(ctx.app.config.dataDir, ctx.app.db);
37 return good({ ...lane, says: laneWords(lane) });
38 }
39 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, offsite, or absent for the machine");
3440 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations");
3541 const now = new Date();
3642 return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now), scriptErrors: scriptErrorCounts(ctx.app.db, 24, now) });
src/main.ts +7 −1
@@ -527,8 +527,14 @@ const snapshots = startSnapshots(
527527 })?.store,
528528 {
529529 version: running().version,
530 // The off-site lane (#11805): sealed under the passphrase before it leaves.
530531 mirror: () =>
531 resolveMirror({ db: app.db, dataDir: app.config.dataDir, bus: app.bus })?.store,
532 resolveMirror({
533 db: app.db,
534 dataDir: app.config.dataDir,
535 bus: app.bus,
536 passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined,
537 })?.store,
532538 passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined,
533539 },
534540 );
src/snapshot/drill.ts +16 −2
@@ -3,7 +3,7 @@
33 * takes the newest snapshot, restores it into a fresh directory, opens the
44 * database read-only, counts what is in it, opens one sealed value with the
55 * restored key, boots the restored directory on a side port and reads
6 * `/api/v1/health`, and removes the directory whatever happened.
6 * `/api/v1/health` and `/`, and removes the directory whatever happened.
77 *
88 * Plain data out: when it ran, which snapshot, how long, what it counted,
99 * and a verdict. A red verdict names the step that failed; a step that
@@ -19,6 +19,7 @@ import { bootRestored } from "./boot.ts";
1919 import { forgetKey, open } from "../kernel/secret.ts";
2020 import { countRows, mismatched, type DrillCounts } from "./counts.ts";
2121 import { restoreSnapshot, type Progress } from "./restore.ts";
22 import { isOffBox } from "./off_box.ts";
2223 import { listSnapshots } from "./snapshot.ts";
2324 import type { Store } from "./store.ts";
2425
@@ -155,6 +156,17 @@ export interface DrillResult {
155156 readonly budgetMs: number;
156157 /** Why it is red, for the person reading; never a value. */
157158 readonly reason: string | null;
159 /** The store it restored from (`Store.where`, never a key); absent on drills kept before #11805. */
160 readonly from?: string | undefined;
161 /** Whether that store is off the forge's machine (`isOffBox`). */
162 readonly offSite?: boolean | undefined;
163 }
164
165 /** The restored forge's front door: `/` answers below 400 (a redirect to sign in is an answer), or this throws. */
166 export async function answersRoot(base: string): Promise<void> {
167 const res = await fetch(`${base}/`, { redirect: "manual", signal: AbortSignal.timeout(5_000) });
168 await res.body?.cancel();
169 if (res.status >= 400) throw new Error(`the restored forge answered / with ${res.status}`);
158170 }
159171
160172 class StepTimeout extends Error {}
@@ -276,7 +288,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
276288 step = "health";
277289 bootStart = performance.now();
278290 const booted = await within(
279 (deps.boot ?? bootRestored)(target, left()),
291 (deps.boot ?? bootRestored)(target, left(), undefined, answersRoot),
280292 left(),
281293 budget,
282294 );
@@ -323,6 +335,8 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
323335 phases,
324336 budgetMs,
325337 reason,
338 from: deps.store.where,
339 offSite: isOffBox(deps.store),
326340 };
327341 }
328342 }
src/snapshot/drills.ts +18 −1
@@ -24,6 +24,17 @@ interface Row {
2424
2525 const PHASES = new Set(["manifest", "files", "unpack", "bundles", "key", "open", "boot"]);
2626
27 /** Where a kept drill restored from; empty when it was kept before that was recorded, or the JSON is not ours. */
28 export function parseFrom(raw: string | null): { from?: string; offSite?: boolean } {
29 try {
30 const v = JSON.parse(raw ?? "null") as { from?: unknown; offSite?: unknown } | null;
31 if (typeof v?.from !== "string" || v.from.length > 300) return {};
32 return { from: v.from, offSite: v.offSite === true };
33 } catch {
34 return {};
35 }
36 }
37
2738 /** The stored JSON as phases; anything that is not a list of known phases with whole ms reads as none. */
2839 export function parsePhases(raw: string | null): { phases: PhaseTime[]; budgetMs: number } {
2940 const none = { phases: [], budgetMs: 0 };
@@ -61,7 +72,12 @@ export function recordDrill(db: Database, result: DrillResult): void {
6172 result.verdict,
6273 result.failedStep,
6374 result.reason,
64 JSON.stringify({ phases: result.phases, budgetMs: result.budgetMs }),
75 // Where it restored from rides the same column: no migration for two plain fields (#11805).
76 JSON.stringify({
77 phases: result.phases,
78 budgetMs: result.budgetMs,
79 ...(result.from === undefined ? {} : { from: result.from, offSite: result.offSite === true }),
80 }),
6581 );
6682 }
6783
@@ -91,5 +107,6 @@ export function lastDrill(db: Database): DrillResult | undefined {
91107 reason: row.reason,
92108 phases: kept.phases,
93109 budgetMs: kept.budgetMs,
110 ...parseFrom(row.phases),
94111 };
95112 }
src/snapshot/mirror.ts +47 −5
@@ -1,7 +1,9 @@
11 /**
22 * The off-box mirror: a second S3-compatible bucket (Backblaze B2, say) that
33 * every snapshot is copied to, so losing the machine does not lose the
4 * snapshots with it. Its keys are sealed in the forge like the Backups
4 * snapshots with it. It is the forge's off-site backup lane (#11805): the
5 * pass runs in the forge's own process, and with the operator's passphrase
6 * every object it writes is sealed (`sealed_store.ts`). Its keys are sealed in the forge like the Backups
57 * destination (`SNAPSHOT_MIRROR_S3_*`), or given as `FORGE_MIRROR_S3_*`.
68 *
79 * A pass copies each of the newest snapshots the mirror lacks, the manifest
@@ -18,6 +20,7 @@ import { join } from "node:path";
1820 import type { EventBus } from "../core/events.ts";
1921 import { deleteSecret, resolveSecrets } from "../core/secret_store.ts";
2022 import { KEEP, listSnapshots, pruneSnapshots, SNAPSHOT_PREFIX } from "./snapshot.ts";
23 import { refusingStore, sealedStore } from "./sealed_store.ts";
2124 import { s3Store, type S3Target, type Store } from "./store.ts";
2225
2326 /** The sealed names, one per field of the form. */
@@ -42,11 +45,15 @@ export interface MirrorSources {
4245 readonly dataDir?: string | undefined;
4346 readonly bus?: EventBus | undefined;
4447 readonly env?: Record<string, string | undefined>;
48 /** The operator's passphrase: given, every object the mirror holds is sealed under it (#11805). */
49 readonly passphrase?: string | undefined;
4550 }
4651
4752 export interface Mirror {
4853 readonly source: "sealed" | "env";
4954 readonly store: Store;
55 /** Whether what lands there is encrypted; without a passphrase nothing lands, and `/system` says why. */
56 readonly encrypted: boolean;
5057 }
5158
5259 /** The mirror's target: sealed in this forge, else `FORGE_MIRROR_S3_*`; undefined when neither is whole. */
@@ -75,9 +82,21 @@ export function mirrorTarget(s: MirrorSources = {}): { source: Mirror["source"];
7582 };
7683 }
7784
78 export function resolveMirror(s: MirrorSources = {}): Mirror | undefined {
85 /** Said by every call of a lane with no passphrase: the board shows it, and nothing is written. */
86 export const NO_PASSPHRASE = "FORGE_SNAPSHOT_PASSPHRASE is not set, so the off-site lane writes nothing";
87
88 /**
89 * The lane as the forge writes it: sealed under the passphrase. Without one
90 * it fails closed (#11805): a store that refuses every call, so not one
91 * unsealed byte leaves and each pass fails saying why.
92 */
93 export function resolveMirror(s: MirrorSources = {}, options: { create?: boolean } = {}): Mirror | undefined {
7994 const t = mirrorTarget(s);
80 return t === undefined ? undefined : { source: t.source, store: s3Store(t.target) };
95 if (t === undefined) return undefined;
96 const plain = s3Store(t.target);
97 return s.passphrase
98 ? { source: t.source, store: sealedStore(plain, s.passphrase, options), encrypted: true }
99 : { source: t.source, store: refusingStore(plain.where, NO_PASSPHRASE), encrypted: false };
81100 }
82101
83102 /** Which of the sealed mirror names are set, without opening any. */
@@ -110,6 +129,8 @@ export interface MirrorState {
110129 readonly failingSince?: string;
111130 readonly failed?: string;
112131 readonly error?: string;
132 /** Whether the mirror's objects are encrypted (`Store.sealed`); absent in states kept before #11805. */
133 readonly sealed?: boolean;
113134 /** Set (and nothing recorded) when the primary, not the mirror, could not be read. */
114135 readonly sourceError?: string;
115136 }
@@ -158,7 +179,9 @@ async function copyOne(src: Store, dst: Store, name: string): Promise<void> {
158179 const prefix = `${SNAPSHOT_PREFIX}${name}/`;
159180 const manifest = `${prefix}manifest.json`;
160181 const have = new Set(await dst.list(prefix));
161 const todo = (await src.list(prefix)).filter((k) => k !== manifest && !have.has(k));
182 const listed = (await src.list(prefix)).filter((k) => k !== manifest);
183 const stored = await Promise.all(listed.map((k) => dst.storedKey?.(k) ?? k));
184 const todo = listed.filter((_, i) => !have.has(stored[i]!));
162185 for (let i = 0; i < todo.length; i += COPY_AT_ONCE)
163186 await Promise.all(
164187 todo.slice(i, i + COPY_AT_ONCE).map(async (key) => {
@@ -207,6 +230,7 @@ export async function mirrorPass(
207230 where: dst.where,
208231 attemptAt: at,
209232 reachable: true,
233 sealed: dst.sealed === true,
210234 syncedAt: at,
211235 ...(newest === undefined ? {} : { newest: newest.name, newestAt: newest.finishedAt }),
212236 };
@@ -221,6 +245,7 @@ export async function mirrorPass(
221245 where: dst.where,
222246 attemptAt: at,
223247 reachable,
248 sealed: dst.sealed === true,
224249 ...(prev?.syncedAt === undefined ? {} : { syncedAt: prev.syncedAt }),
225250 ...(prev?.newest === undefined ? {} : { newest: prev.newest }),
226251 ...(prev?.newestAt === undefined ? {} : { newestAt: prev.newestAt }),
@@ -254,7 +279,9 @@ export function mirrorNeed(s: MirrorState | undefined, now: number): MirrorNeed
254279 if (s.failingSince !== undefined)
255280 return {
256281 key: `fail:${s.failingSince}`,
257 sentence: s.reachable
282 sentence: s.error === NO_PASSPHRASE
283 ? `The off-box mirror ${s.where} writes nothing: set FORGE_SNAPSHOT_PASSPHRASE.`
284 : s.reachable
258285 ? `Snapshot ${s.failed ?? "(unknown)"} did not land on the off-box mirror ${s.where}: ${s.error ?? "unknown reason"}.`
259286 : `The off-box mirror ${s.where} is unreachable: ${s.error ?? "unknown reason"}.`,
260287 since: s.failingSince,
@@ -267,3 +294,18 @@ export function mirrorNeed(s: MirrorState | undefined, now: number): MirrorNeed
267294 since: s.syncedAt ? new Date(at + MIRROR_STALE_MS).toISOString() : s.attemptAt,
268295 };
269296 }
297
298 /**
299 * The off-site lane as a restore on a fresh machine reads it (#11805): from
300 * the environment alone (`FORGE_MIRROR_S3_*`), and sealed, so the
301 * passphrase is required. A sentence when either is missing.
302 */
303 export function offsiteFromEnv(env: Record<string, string | undefined> = process.env): Store | string {
304 const passphrase = env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined;
305 // A restore reads: an empty bucket is said, never started as a new lane.
306 const m = resolveMirror({ env, passphrase }, { create: false });
307 if (m === undefined)
308 return "no off-site lane: export FORGE_MIRROR_S3_ENDPOINT, FORGE_MIRROR_S3_BUCKET, FORGE_MIRROR_S3_ACCESS_KEY and FORGE_MIRROR_S3_SECRET_KEY";
309 if (passphrase === undefined) return "the off-site lane is sealed: export FORGE_SNAPSHOT_PASSPHRASE";
310 return m.store;
311 }
src/snapshot/off_box.ts +1
@@ -45,3 +45,4 @@ export function lastOffBox(dataDir: string): string | null {
4545 if (times.length === 0) return null;
4646 return times.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a));
4747 }
48
src/snapshot/offsite_lane.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * The forge's off-site backup lane in one reading (#11805): the mirror pass
3 * the forge runs in its own process, where it writes, whether what it writes
4 * is sealed, when it last succeeded, and the last restore drill and where
5 * that drill restored from. `/system` shows it.
6 *
7 * `/system`, its terminal screen (`src/tui/screens/offsite.ts`) and the MCP
8 * `system` tool (`kind: offsite`) all read it through `readOffsiteLane`.
9 *
10 * Plain data in (the mirror's kept state and the last drill), plain data
11 * out; no network, no clock but the one given.
12 */
13
14 import type { Database } from "bun:sqlite";
15 import type { DrillResult } from "./drill.ts";
16 import { lastDrill } from "./drills.ts";
17 import { lastMirror, MIRROR_STALE_MS, type MirrorState } from "./mirror.ts";
18
19 export type LaneHealth = "none" | "ok" | "late" | "failing" | "unsealed";
20
21 export interface OffsiteLane {
22 readonly health: LaneHealth;
23 /** The lane's bucket (`host/bucket`), never a key; null when the forge has no off-site lane. */
24 readonly where: string | null;
25 readonly sealed: boolean;
26 /** The last pass that left the lane level with the newest snapshot. */
27 readonly lastSuccess: string | null;
28 /** The newest snapshot the lane holds. */
29 readonly newest: string | null;
30 /** Why passes are failing now, one line. */
31 readonly error: string | null;
32 readonly drill: {
33 readonly at: string;
34 readonly verdict: "green" | "red";
35 readonly from: string | null;
36 readonly offSite: boolean;
37 readonly reason: string | null;
38 } | null;
39 }
40
41 export function offsiteLane(state: MirrorState | undefined, drill: DrillResult | undefined, now: number): OffsiteLane {
42 const drillSeen = drill === undefined
43 ? null
44 : { at: drill.at, verdict: drill.verdict, from: drill.from ?? null, offSite: drill.offSite === true, reason: drill.reason };
45 if (state === undefined)
46 return { health: "none", where: null, sealed: false, lastSuccess: null, newest: null, error: null, drill: drillSeen };
47 const sealed = state.sealed === true;
48 const synced = Date.parse(state.syncedAt ?? "");
49 // A lane without the passphrase refuses every write (#11805); that, not its failing passes, is what to fix.
50 const health: LaneHealth =
51 !sealed ? "unsealed"
52 : state.failingSince !== undefined ? "failing"
53 : !Number.isFinite(synced) || now - synced > MIRROR_STALE_MS ? "late"
54 : "ok";
55 return {
56 health,
57 where: state.where,
58 sealed,
59 lastSuccess: state.syncedAt ?? null,
60 newest: state.newest ?? null,
61 error: state.failingSince === undefined ? null : (state.error ?? "unknown reason"),
62 drill: drillSeen,
63 };
64 }
65
66 /** The lane now, from what the last pass and the last drill kept: one small file and one row, no network. */
67 export function readOffsiteLane(dataDir: string, db: Database, now = Date.now()): OffsiteLane {
68 return offsiteLane(lastMirror(dataDir), lastDrill(db), now);
69 }
70
71 /** One sentence for the lane, as `/system` heads it. */
72 export function laneWords(l: OffsiteLane): string {
73 switch (l.health) {
74 case "none":
75 return "No off-site lane: set FORGE_MIRROR_S3_* (or seal a mirror on the Backups page) and FORGE_SNAPSHOT_PASSPHRASE.";
76 case "failing":
77 return `The off-site lane is failing: ${l.error}.`;
78 case "late":
79 return "The off-site lane has not succeeded for over a day.";
80 case "unsealed":
81 return "The off-site lane writes nothing until FORGE_SNAPSHOT_PASSPHRASE is set: it never writes unsealed.";
82 case "ok":
83 return "The off-site lane is level and sealed.";
84 }
85 }
src/snapshot/restore.ts +12 −2
@@ -48,6 +48,7 @@ import {
4848 import { unwrapKey, WRAPPED_KEY } from "./wrap.ts";
4949 import { isPackedFile, partPaths, unpacker, type PackedFile, type Unpacker } from "./parts.ts";
5050 import { resolveDestination } from "./destination.ts";
51 import { offsiteFromEnv } from "./mirror.ts";
5152 import type { Store } from "./store.ts";
5253
5354 export interface Restored {
@@ -393,17 +394,26 @@ if (import.meta.main) {
393394 }
394395 args.splice(at, 2);
395396 }
397 // `--offsite`: read the sealed off-site lane (FORGE_MIRROR_S3_*) in place of the destination (#11805).
398 const offsite = args.indexOf("--offsite");
399 if (offsite >= 0) args.splice(offsite, 1);
396400 const [name, dataDir] = args;
397401 if (!name || !dataDir || args.length > 2) {
398402 // CLI usage on stderr for the person at the terminal, not a forge event.
399403 console.error(
400 "usage: bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N]",
404 "usage: bun run src/snapshot/restore.ts <snapshot-name|latest> <data-dir> [--clones N] [--offsite]",
401405 );
402406 process.exit(2);
403407 }
404408 // A restore starts from an empty directory, so nothing is sealed yet: the
405409 // same resolver answers from the environment alone.
406 const store = resolveDestination()?.store;
410 const lane = offsite >= 0 ? offsiteFromEnv() : undefined;
411 if (typeof lane === "string") {
412 // CLI message on stderr for the person at the terminal.
413 console.error(lane);
414 process.exit(2);
415 }
416 const store = lane ?? resolveDestination()?.store;
407417 if (store === undefined) {
408418 // CLI message on stderr for the person at the terminal.
409419 console.error(
src/snapshot/sealed_store.ts added +177
@@ -0,0 +1,177 @@
1 /**
2 * The off-site lane's store (#11805): a store whose every object is sealed
3 * before it leaves the forge, so the bucket on neither machine holds only
4 * ciphertext. The key is derived from the operator's passphrase
5 * (`FORGE_SNAPSHOT_PASSPHRASE`) with scrypt under a random salt of the
6 * lane's own, kept beside the objects in one unsealed header
7 * (`sealed/lane.json`: the salt and a check value, never the key), so a
8 * guess at the passphrase costs a scrypt per lane and nothing computed for
9 * one lane opens another. The passphrase lives in the forge's environment
10 * and with the operator, never in the repository and never on the bucket.
11 *
12 * Each body is AES-256-GCM under a fresh 12-byte IV (`FSO1`, IV, ciphertext,
13 * tag), so a tampered object fails the tag and says so. Object names are
14 * hidden too: everything after `snapshots/<name>/` is an HMAC of the path,
15 * since a path names a repository. The snapshot's name (a UTC moment) stays
16 * readable, so a listing still sorts, prunes and resumes. Everything sits
17 * under `sealed/`, apart from anything written there before the lane sealed.
18 *
19 * Plain data in, plain data out; the inner store does the I/O.
20 */
21
22 import { createCipheriv, createDecipheriv, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
23 import type { Store } from "./store.ts";
24
25 export const SEALED_PREFIX = "sealed/";
26 const MAGIC = Buffer.from("FSO1");
27 const IV_BYTES = 12;
28 const TAG_BYTES = 16;
29 /** The lane's header, beside the sealed objects and unsealed: the salt and a check value, never the key. */
30 export const LANE_HEADER = `${SEALED_PREFIX}lane.json`;
31 const SALT_BYTES = 16;
32 const SALT_HEX = /^[0-9a-f]{32}$/;
33 const CHECK_HEX = /^[0-9a-f]{64}$/;
34 const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } as const;
35 const HASHED = /^[0-9a-f]{64}$/;
36 const IN_SNAPSHOT = /^(snapshots\/[^/]+\/)(.+)$/;
37
38 export interface SealKeys {
39 readonly enc: Buffer;
40 readonly mac: Buffer;
41 }
42
43 /** The two keys a passphrase gives under a lane's salt: one seals bodies, one hides names. One scrypt, about 100 ms, off the main thread. */
44 export function sealKeys(passphrase: string, salt: Uint8Array): Promise<SealKeys> {
45 return new Promise((resolve, reject) => {
46 scrypt(passphrase, salt, 64, SCRYPT, (error, k) => {
47 if (error) reject(error);
48 else resolve({ enc: k.subarray(0, 32), mac: k.subarray(32) });
49 });
50 });
51 }
52
53 /** What the header keeps to tell the right passphrase from a wrong one; an HMAC, so it gives no shortcut past scrypt. */
54 const checkOf = (keys: SealKeys): string => createHmac("sha256", keys.mac).update("forge off-site lane header").digest("hex");
55
56 /** A header's salt and check, or a sentence when the object is not one the lane wrote. */
57 export function parseHeader(body: Uint8Array): { salt: Buffer; check: string } | string {
58 try {
59 const v = JSON.parse(new TextDecoder().decode(body)) as { v?: unknown; salt?: unknown; check?: unknown };
60 if (v.v !== 1 || typeof v.salt !== "string" || !SALT_HEX.test(v.salt) || typeof v.check !== "string" || !CHECK_HEX.test(v.check))
61 return `${LANE_HEADER} is not a lane header`;
62 return { salt: Buffer.from(v.salt, "hex"), check: v.check };
63 } catch {
64 return `${LANE_HEADER} is not a lane header`;
65 }
66 }
67
68 /**
69 * The lane's keys: from its header's salt, checked, when the lane has one;
70 * else, when `create`, a new random salt kept as the header first. A wrong
71 * passphrase throws here, before any object is read or written.
72 */
73 export async function laneKeys(inner: Store, passphrase: string, create: boolean): Promise<SealKeys> {
74 const body = await inner.get(LANE_HEADER);
75 if (body === undefined) {
76 if (!create) throw new Error(`${inner.where} holds no sealed lane (no ${LANE_HEADER})`);
77 const salt = randomBytes(SALT_BYTES);
78 const keys = await sealKeys(passphrase, salt);
79 await inner.put(LANE_HEADER, JSON.stringify({ v: 1, salt: salt.toString("hex"), check: checkOf(keys) }));
80 return keys;
81 }
82 const h = parseHeader(body);
83 if (typeof h === "string") throw new Error(h);
84 const keys = await sealKeys(passphrase, h.salt);
85 if (!timingSafeEqual(Buffer.from(checkOf(keys), "hex"), Buffer.from(h.check, "hex")))
86 throw new Error(`the passphrase does not open the lane at ${inner.where}`);
87 return keys;
88 }
89
90 export function sealBody(keys: SealKeys, body: Uint8Array | string): Uint8Array {
91 const iv = randomBytes(IV_BYTES);
92 const cipher = createCipheriv("aes-256-gcm", keys.enc, iv, { authTagLength: TAG_BYTES });
93 const plain = typeof body === "string" ? Buffer.from(body) : body;
94 return Buffer.concat([MAGIC, iv, cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
95 }
96
97 /** The plain body; throws, naming the key, when the passphrase is wrong or the object is not one the lane sealed. */
98 export function openBody(keys: SealKeys, sealed: Uint8Array, key: string): Uint8Array {
99 const b = Buffer.from(sealed.buffer, sealed.byteOffset, sealed.byteLength);
100 if (b.length < MAGIC.length + IV_BYTES + TAG_BYTES || !b.subarray(0, MAGIC.length).equals(MAGIC))
101 throw new Error(`${key} is not a sealed object`);
102 const iv = b.subarray(MAGIC.length, MAGIC.length + IV_BYTES);
103 const decipher = createDecipheriv("aes-256-gcm", keys.enc, iv, { authTagLength: TAG_BYTES });
104 decipher.setAuthTag(b.subarray(b.length - TAG_BYTES));
105 try {
106 return new Uint8Array(Buffer.concat([decipher.update(b.subarray(MAGIC.length + IV_BYTES, b.length - TAG_BYTES)), decipher.final()]));
107 } catch {
108 throw new Error(`${key} did not open: wrong passphrase, or the object was changed`);
109 }
110 }
111
112 /**
113 * A key as the lane sees it: `snapshots/<name>/<hmac>`. A key already in
114 * that form (as `list` returns it) is left as it is, so a listed key can be
115 * read and deleted.
116 */
117 export function hiddenKey(keys: SealKeys, key: string): string {
118 const hide = (path: string): string => (HASHED.test(path) ? path : createHmac("sha256", keys.mac).update(path).digest("hex"));
119 const m = IN_SNAPSHOT.exec(key);
120 return m ? `${m[1]}${hide(m[2]!)}` : hide(key);
121 }
122
123 export interface SealedOptions {
124 /** Whether a lane with no header yet starts one (the mirror pass); a restore reads, so it does not. */
125 readonly create?: boolean;
126 }
127
128 /** `inner`, every object sealed under `passphrase` and named so a path never shows. The keys are derived on first use. */
129 export function sealedStore(inner: Store, passphrase: string, options: SealedOptions = {}): Store {
130 let pending: Promise<SealKeys> | undefined;
131 // One derivation for the store and every bounded view of it; a failed read of the header is tried again next time.
132 const keys = (): Promise<SealKeys> =>
133 (pending ??= laneKeys(inner, passphrase, options.create ?? true).catch((error: unknown) => {
134 pending = undefined;
135 throw error;
136 }));
137 return over(inner, keys);
138 }
139
140 function over(inner: Store, keys: () => Promise<SealKeys>): Store {
141 const at = async (key: string): Promise<string> => SEALED_PREFIX + hiddenKey(await keys(), key);
142 const store: Store = {
143 where: `${inner.where} (sealed)`,
144 sealed: true,
145 async put(key, body) {
146 const k = await keys();
147 await inner.put(SEALED_PREFIX + hiddenKey(k, key), sealBody(k, body));
148 },
149 async get(key) {
150 const k = await keys();
151 const body = await inner.get(SEALED_PREFIX + hiddenKey(k, key));
152 return body === undefined ? undefined : openBody(k, body, key);
153 },
154 async list(prefix) {
155 await keys();
156 return (await inner.list(SEALED_PREFIX + prefix)).map((k) => k.slice(SEALED_PREFIX.length));
157 },
158 async names(prefix) {
159 await keys();
160 return inner.names(SEALED_PREFIX + prefix);
161 },
162 async delete(key) {
163 await inner.delete(await at(key));
164 },
165 storedKey: async (key) => hiddenKey(await keys(), key),
166 };
167 const bounded = inner.bounded;
168 return bounded === undefined ? store : { ...store, bounded: (leftMs) => over(bounded.call(inner, leftMs), keys) };
169 }
170
171 /** A lane that may not write: every call fails with `reason`, so the board says why and nothing leaves unsealed. */
172 export function refusingStore(where: string, reason: string): Store {
173 const no = async (): Promise<never> => {
174 throw new Error(reason);
175 };
176 return { where, put: no, get: no, list: no, names: no, delete: no };
177 }
src/snapshot/service.ts +23 −2
@@ -26,7 +26,7 @@ import {
2626 type Manifest,
2727 type Progress,
2828 } from "./snapshot.ts";
29 import { describeTimes, runDrill, type DrillDeps, type DrillResult } from "./drill.ts";
29 import { describeTimes, DRILL_TIMEOUT_MS, runDrill, type DrillDeps, type DrillResult } from "./drill.ts";
3030 import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS, snapshotDue } from "./due.ts";
3131 import { sealedTarget } from "./destination.ts";
3232 import { watchBucket } from "./bucket.ts";
@@ -141,6 +141,8 @@ export function startSnapshots(
141141 /** Test seam: how soon a failed snapshot is tried again. */
142142 readonly retryMs?: number | undefined;
143143 readonly passphrase?: string | undefined;
144 /** Test seam: how long a drill waits for a mirror pass in flight before it reads the lane as it stands. */
145 readonly drillWaitMs?: number | undefined;
144146 /** Test seam: the drill itself. */
145147 readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined;
146148 /** Test seams: how soon after boot an owed drill runs, and when a red one is owed again. */
@@ -346,13 +348,32 @@ export function startSnapshots(
346348 return mirrorNow();
347349 };
348350
351 /** Wait for the passes in flight, `ms` at most. */
352 const mirrorSettled = async (ms: number): Promise<void> => {
353 const end = Date.now() + ms;
354 while (mirroring !== undefined) {
355 const left = end - Date.now();
356 if (left <= 0) {
357 log.warn("restore drill: the mirror pass is still running; drilling the lane as it stands");
358 return;
359 }
360 let timer: ReturnType<typeof setTimeout> | undefined;
361 await Promise.race([mirroring.catch(() => {}), new Promise((r) => (timer = setTimeout(r, left)))]);
362 clearTimeout(timer);
363 }
364 };
365
349366 // Single-flight: a restore of every repository is heavy, so a second call
350367 // while one runs joins it instead of filling the disk with another.
351368 let drilling: Promise<DrillResult> | undefined;
352369 const drill = (): Promise<DrillResult> => {
353370 drilling ??= (async (): Promise<DrillResult> => {
354371 await (inFlight ?? Promise.resolve()).catch(() => {});
355 const store = current();
372 // The off-site copy is the one a lost machine leaves (#11805): drill it,
373 // once the pass that follows every snapshot has brought it level, or
374 // the drill's floor budget has gone by, so a stuck pass cannot hang it.
375 await mirrorSettled(options.drillWaitMs ?? DRILL_TIMEOUT_MS);
376 const store = currentMirror() ?? current();
356377 if (store === undefined)
357378 throw new Error("this forge has nowhere to drill from");
358379 // A drill that could not run is not a drill that passed: it is kept red,
src/snapshot/store.ts +8
@@ -27,6 +27,14 @@ export interface Store {
2727 * that does not retry has nothing to bound and leaves this out.
2828 */
2929 bounded?(leftMs: () => number): Store;
30 /**
31 * The form `list` gives `key` in, when the store renames what it holds
32 * (the sealed lane hides paths); a store that keeps keys as given leaves
33 * this out.
34 */
35 storedKey?(key: string): Promise<string>;
36 /** True when every object is encrypted before it reaches the store (the off-site lane, #11805). */
37 readonly sealed?: boolean;
3038 }
3139
3240 /** The S3 credentials as the platform hands them to an app. */
src/system/footing.ts +30 −26
@@ -30,8 +30,8 @@ import { serviceAccount, type Credentials } from "../cluster/jobs.ts";
3030
3131 export type Need = "answer" | "sign-in" | "build" | "release" | "backup";
3232
33 /** The machines in the forge's path, by the name the steward's inventory gives them. */
34 export type Machine = "pg-grand" | "vxrail";
33 /** The machines in the forge's path, by the name the steward's inventory gives them; `off-site` is neither (#11805). */
34 export type Machine = "pg-grand" | "vxrail" | "off-site";
3535
3636 /** The machine the forge itself runs on, as declared. */
3737 export const FORGE_MACHINE: Machine = "vxrail";
@@ -40,8 +40,12 @@ export const FORGE_MACHINE: Machine = "vxrail";
4040 export const MACHINES: Record<Machine, string> = {
4141 "pg-grand": "DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it.",
4242 vxrail: "A k3s agent joined to pg-grand. The forge runs here.",
43 "off-site": "Object storage on neither machine, at a provider of its own: the off-site backup lane's bucket.",
4344 };
4445
46 /** A piece on the forge's own machine or on neither machine: losing pg-grand does not touch it. */
47 const besideTheForge = (m: Machine): boolean => m === FORGE_MACHINE || m === "off-site";
48
4549 export type Find =
4650 /** Pods of one workload (Deployment, StatefulSet, DaemonSet) in a namespace, or every pod (`*`); namespace `""` is the forge's own. */
4751 | { readonly kind: "pods"; readonly namespace: string; readonly workload: string }
@@ -49,6 +53,8 @@ export type Find =
4953 | { readonly kind: "self" }
5054 /** The Kubernetes API server: the nodes labelled control-plane. */
5155 | { readonly kind: "control-plane" }
56 /** Object storage outside the cluster and off both machines; nothing to look for. */
57 | { readonly kind: "offsite" }
5258 /** Not in the cluster: a systemd unit on a host; the cluster cannot show it, so the declared machine stands. */
5359 | { readonly kind: "host"; readonly unit: string }
5460 /** A pod whose namespace is not recorded: not looked for, since that would list every pod in the cluster. */
@@ -78,7 +84,6 @@ export const BREAK_STEPS: readonly string[] = [
7884 "the signup gate: no one signs up",
7985 "image pulls and releases",
8086 "the control plane: nothing reschedules, Services stop updating",
81 "local backups",
8287 "the host timers",
8388 ];
8489
@@ -140,7 +145,7 @@ export const FOOTING: readonly Piece[] = [
140145 machine: "pg-grand",
141146 ifDies: "git over ssh and the terminal stop answering.",
142147 issues: [11686],
143 breaks: 7,
148 breaks: 6,
144149 },
145150 {
146151 id: "control-plane",
@@ -229,7 +234,7 @@ export const FOOTING: readonly Piece[] = [
229234 machine: "pg-grand",
230235 ifDies: "Nothing is released; production keeps the release it has.",
231236 issues: [11686, 8942],
232 breaks: 7,
237 breaks: 6,
233238 },
234239 {
235240 id: "standby-reconcile",
@@ -239,7 +244,7 @@ export const FOOTING: readonly Piece[] = [
239244 machine: "pg-grand",
240245 ifDies: "A changed manifest is not applied; the running pod is untouched.",
241246 issues: [8948, 11686],
242 breaks: 7,
247 breaks: 6,
243248 },
244249 {
245250 id: "host-timers",
@@ -249,33 +254,31 @@ export const FOOTING: readonly Piece[] = [
249254 machine: "pg-grand",
250255 ifDies: "The watchdog, the GitHub mirror and image pruning stop.",
251256 issues: [11686],
252 breaks: 7,
253 },
254 {
255 id: "backup-lanes",
256 need: "backup",
257 what: "The backup namespace's CronJobs (FORGE_BACKUP_LANES)",
258 find: { kind: "pods", namespace: "backup", workload: "*" },
259 machine: "pg-grand",
260 ifDies: "No new backup is taken.",
261 issues: [11686],
262257 breaks: 6,
263258 },
264259 {
265 id: "minio",
260 id: "backup-lane",
266261 need: "backup",
267 what: "MinIO, where the backup lanes store the forge's backups",
268 find: { kind: "pods", namespace: "minio", workload: "*" },
269 machine: "pg-grand",
270 ifDies: "No new backup is stored, and those it holds go with the machine.",
271 issues: [11686],
272 breaks: 6,
262 what: "The forge's own backup lane (src/snapshot): the nightly snapshot and the hourly off-site pass, run in the forge's process, every object sealed under FORGE_SNAPSHOT_PASSPHRASE before it leaves; the nightly lands on the forge's own disk (FORGE_SNAPSHOT_DIR on its volume), not the platform's MinIO",
263 find: { kind: "self" },
264 machine: "vxrail",
265 ifDies: "No new backup is taken; the sealed copies wait off both machines.",
266 issues: [],
267 },
268 {
269 id: "offsite-bucket",
270 need: "backup",
271 what: "The off-site lane's bucket (FORGE_MIRROR_S3_*, or the mirror sealed on the Backups page), on neither machine",
272 find: { kind: "offsite" },
273 machine: "off-site",
274 ifDies: "Neither machine holds it: losing either leaves every sealed copy in place.",
275 issues: [],
273276 },
274277 ];
275278
276279 /** On pg-grand, not in the forge's path: what dropping the machine takes besides. */
277280 export const ALSO_ON_PG_GRAND =
278 "Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and MinIO.";
281 "Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and the platform's backup CronJobs (the backup namespace, FORGE_BACKUP_LANES) with the MinIO they write to; the forge's own backup no longer needs them.";
279282
280283 /** How each piece was placed: seen on nodes, or the declared machine kept and why. */
281284 export type Seen =
@@ -311,7 +314,7 @@ export function declaredFooting(why: string, at = new Date()): Footing {
311314 forgeNode: null,
312315 unread: why,
313316 at: at.toISOString(),
314 rows: FOOTING.map((p) => ({ ...p, seen: { by: "declared", why }, risk: p.machine !== FORGE_MACHINE })),
317 rows: FOOTING.map((p) => ({ ...p, seen: { by: "declared", why }, risk: !besideTheForge(p.machine) })),
315318 };
316319 }
317320
@@ -387,6 +390,7 @@ export async function probeFooting(opts: ProbeOptions = {}): Promise<Footing> {
387390 }
388391 // Nothing in the cluster sees a systemd unit: the declared machine stands, said as such.
389392 if (f.kind === "host") return { by: "declared", why: `${f.unit} is a systemd unit on a host; the cluster cannot show it` };
393 if (f.kind === "offsite") return { by: "declared", why: "object storage outside the cluster, on neither machine" };
390394 if (f.kind === "unlocated") return { by: "declared", why: "its namespace is not recorded, and the forge does not list every pod in the cluster" };
391395 const where = nsOf(f.namespace);
392396 const list = pods.get(where)!;
@@ -403,7 +407,7 @@ export async function probeFooting(opts: ProbeOptions = {}): Promise<Footing> {
403407 const risk =
404408 seen.by === "cluster" && forgeNode !== null
405409 ? seen.nodes.some((n) => n !== forgeNode)
406 : p.machine !== FORGE_MACHINE;
410 : !besideTheForge(p.machine);
407411 return { ...p, seen, risk };
408412 });
409413 const unread =
src/tui/screens/entries.ts +2
@@ -60,6 +60,7 @@ import { entry as economics } from "./economics.ts";
6060 import { entry as money } from "./money.ts";
6161 import { entry as loopStalls } from "./loopstalls.ts";
6262 import { entry as footing } from "./footing.ts";
63 import { entry as offsite } from "./offsite.ts";
6364 import { entry as waiting } from "./waiting.ts";
6465 import { entry as slowPagesEntry } from "./slowpages.ts";
6566 import { entry as maintenance } from "./maintenance.ts";
@@ -126,6 +127,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
126127 system,
127128 loopStalls,
128129 footing,
130 offsite,
129131 waiting,
130132 slowPagesEntry,
131133 telemetry,
src/tui/screens/offsite.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * The off-site backup lane, in the terminal (#11805): what /system shows
3 * under "Off-site backup", in the same order. First the lane's one sentence,
4 * then where it writes, whether it seals what it writes, when it last
5 * succeeded, and the last restore drill with where it restored from. The
6 * reading is `readOffsiteLane`'s, the page's and the MCP tool's (one small
7 * file and one row; no network). The gate is /system's: a forge admin who
8 * is a person. Read-only.
9 */
10
11 import { laneWords, readOffsiteLane, type OffsiteLane } from "../../snapshot/offsite_lane.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { pad } from "../term/width.ts";
17 import { Scroll, drawLines, hitScroll } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
19
20 const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent";
21
22 const fact = (label: string, value: string): string => ` ${pad(label, 14)}${value}`;
23
24 /** The screen's lines; empty for anyone the gate refuses. */
25 export function offsiteLines(session: Session, l: OffsiteLane = readOffsiteLane(session.app.config.dataDir, session.app.db)): string[] {
26 if (!mayRead(session)) return [];
27 const lines = [laneWords(l), ""];
28 if (l.where !== null)
29 lines.push(
30 "Backup lane",
31 fact("Writes to", l.where),
32 fact("Encrypted", l.sealed ? "yes, under the snapshot passphrase" : "no"),
33 fact("Last success", l.lastSuccess ?? "never"),
34 ...(l.newest === null ? [] : [fact("Newest", l.newest)]),
35 "",
36 );
37 const d = l.drill;
38 lines.push("Restore drill");
39 if (d === null) lines.push(fact("Last", "never run"));
40 else
41 lines.push(
42 fact("Last", d.at),
43 fact("Verdict", d.verdict === "green" ? "green" : `red${d.reason === null ? "" : `: ${d.reason}`}`),
44 fact("From", d.from === null ? "not recorded" : `${d.from}${d.offSite ? " (off-site)" : " (on this machine)"}`),
45 );
46 return lines;
47 }
48
49 export function offsiteScreen(session: Session, ctx: Ctx): Screen {
50 const scroll = new Scroll();
51 return {
52 title: "Off-site backup",
53 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
54 render(area, s) {
55 const c = ctx.theme.c;
56 const lines = offsiteLines(session);
57 drawLines(s, area, c, scroll, lines.map((text) => [{ text, style: style(text.startsWith(" ") ? c.mutedFg : c.fg, c.bg) }]));
58 hitScroll(ctx, area, scroll, lines.length);
59 },
60 key(e) {
61 if (scroll.key(e, offsiteLines(session).length)) return true;
62 if (e.key === "esc" || e.key === "q") {
63 ctx.pop();
64 return true;
65 }
66 return false;
67 },
68 };
69 }
70
71 export const entry: PaletteEntry = {
72 id: "offsite",
73 title: "Off-site backup",
74 hint: "where the sealed lane writes, when it last succeeded, and the last restore drill",
75 after: ["footing"],
76 offered: mayRead,
77 run: (session, ctx) => ctx.push(offsiteScreen(session, ctx)),
78 };
src/web/pages/system.ts +39
@@ -48,6 +48,7 @@ import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tre
4848 import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts";
4949 import { egressReportedLine } from "../../system/egress_line.ts";
5050 import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords, type Footing, type Machine } from "../../system/footing.ts";
51 import { laneWords, readOffsiteLane, type OffsiteLane } from "../../snapshot/offsite_lane.ts";
5152 import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts";
5253 import { childDirOf, listChildren } from "../../actions/spawn.ts";
5354 import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts";
@@ -237,6 +238,43 @@ export function footingSection(f: Footing): Html {
237238 ${(Object.keys(MACHINES) as Machine[]).map((m) => html`<p class="small muted" data-machine="${m}"><span class="mono">${m}</span>: ${MACHINES[m]}</p>`)}`, { id: "sys-footing-more" })}</section>`;
238239 }
239240
241 /**
242 * The off-site backup lane (#11805): where the forge's own mirror pass
243 * writes, whether it seals what it writes, when it last succeeded, and the
244 * last restore drill with where it restored from. `snapshot/offsite_lane.ts`'s reading.
245 */
246 export function offsiteSection(l: OffsiteLane): Html {
247 // A lane that exists and is not level and sealed is red; no lane at all is a setting, said in words (first sight, #11111).
248 const tone = l.health === "ok" || l.health === "none" ? null : badge(l.health, { tone: "danger" });
249 const d = l.drill;
250 // First sight (#11111): the heading and one sentence stay; the rows open behind one disclosure.
251 return html`<section class="settings-block" id="sys-offsite" data-health="${l.health}"><h2>Off-site backup</h2>
252 ${disclosure(html`${tone} ${laneWords(l)}`, html`<ul class="list" aria-label="Off-site backup">
253 ${l.where === null ? null : row({
254 title: "Backup lane",
255 facts: [
256 { label: "Writes to", value: html`<span class="mono">${l.where}</span>` },
257 { label: "Encrypted", value: l.sealed ? "yes, under the snapshot passphrase" : "no" },
258 { label: "Last success", value: l.lastSuccess === null ? "never" : time(l.lastSuccess) },
259 ...(l.newest === null ? [] : [{ label: "Newest", value: html`<span class="mono">${l.newest}</span>` }]),
260 ],
261 data: { lane: "offsite", sealed: String(l.sealed), ...(l.lastSuccess === null ? {} : { success: l.lastSuccess }) },
262 })}
263 ${row({
264 title: "Restore drill",
265 facts: d === null
266 ? [{ label: "Last", value: "never run" }]
267 : [
268 { label: "Last", value: time(d.at) },
269 { label: "Verdict", value: d.verdict === "green" ? "green" : html`${badge("red", { tone: "danger" })} ${d.reason ?? ""}` },
270 { label: "From", value: d.from === null ? "not recorded" : html`<span class="mono">${d.from}</span>${d.offSite ? " (off-site)" : " (on this machine)"}` },
271 ],
272 data: { drill: d?.verdict ?? "none", ...(d === null ? {} : { "off-site": String(d.offSite) }) },
273 })}
274 </ul>
275 <p class="small"><a href="/settings/backups">Backups</a> runs a drill; so does the MCP <code>restore_drill</code>.</p>`, { id: "sys-offsite-more" })}</section>`;
276 }
277
240278 /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */
241279 export function stallSection(rows: readonly RouteStall[], worst: Html = html``): Html {
242280 return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>${
@@ -439,6 +477,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
439477 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
440478 <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section>
441479 ${footingSection(standsOn)}
480 ${offsiteSection(timePhase(ctx.request, "offsite", () => readOffsiteLane(ctx.app.config.dataDir, ctx.app.db)))}
442481 ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))}
443482 ${lagSection(lagMinutes.readings())}
444483 <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div>
test/footing.test.ts +7 −7
@@ -148,14 +148,14 @@ describe("probeFooting", () => {
148148 expect(byId(f, "standby-reconcile")).toMatchObject({ seen: { by: "declared" }, risk: true });
149149 for (const id of ["forge-ssh", "release-tick", "standby-reconcile", "host-timers"]) expect(byId(f, id).seen.by).toBe("declared");
150150 expect(byId(f, "plat-auth").risk).toBe(true);
151 // Forbidden: the declared machine is kept and the reason named.
152 expect(byId(f, "minio").seen).toEqual({ by: "declared", why: "the forge's service account may not read pods in minio" });
153 expect(byId(f, "minio").risk).toBe(true);
154 // A CronJob between runs: only a finished pod, so declared.
155 expect(byId(f, "backup-lanes").seen).toEqual({ by: "declared", why: "no pod running in backup now" });
151 // The backup lane is the forge's own process, and its bucket is on neither machine (#11805).
152 expect(byId(f, "backup-lane")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false });
153 expect(byId(f, "offsite-bucket")).toMatchObject({ machine: "off-site", seen: { by: "declared" }, risk: false });
154 // Neither the platform's backup namespace nor MinIO is asked for any more.
155 expect(kube.seen.some((p) => p.includes("/namespaces/backup/") || p.includes("/namespaces/minio/"))).toBe(false);
156156 // Each namespace is listed once although two pieces live in forgejo.
157157 expect(kube.seen.filter((p) => p === "/api/v1/namespaces/forgejo/pods").length).toBe(1);
158 const off = ["forge-ssh", "control-plane", "plat-auth", "signup-gate", "forgejo", "cnpg", "registry", "release-tick", "standby-reconcile", "host-timers", "backup-lanes", "minio"];
158 const off = ["forge-ssh", "control-plane", "plat-auth", "signup-gate", "forgejo", "cnpg", "registry", "release-tick", "standby-reconcile", "host-timers"];
159159 expect(f.rows.filter((r) => r.risk).map((r) => r.id).sort()).toEqual([...off].sort());
160160 // In the order pg-grand switched off would break the forge: sign-in first, host timers last.
161161 const lines = riskLines(f);
@@ -203,7 +203,7 @@ describe("probeFooting", () => {
203203 expect(fetched).toBe(0);
204204 expect(f.forgeNode).toBeNull();
205205 expect(f.unread).toBe("not read: not running in a cluster");
206 expect(f.rows.every((r) => r.seen.by === "declared" && r.risk === (r.machine !== "vxrail"))).toBe(true);
206 expect(f.rows.every((r) => r.seen.by === "declared" && r.risk === (r.machine === "pg-grand"))).toBe(true);
207207 });
208208
209209 test("the forge's own pod unreadable: its node is unknown and the declared machines decide risk", async () => {
test/offsite-lane.test.ts added +334
@@ -0,0 +1,334 @@
1 /**
2 * The off-site backup lane (#11805): the mirror pass writes only ciphertext
3 * under names that hide every path, a restore from it with the passphrase
4 * brings the forge back, the drill restores from it and keeps where, and
5 * `/system` says when the lane last succeeded.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs";
9 import { rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join, relative } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { answersRoot, runDrill, type DrillResult } from "../src/snapshot/drill.ts";
15 import { lastDrill } from "../src/snapshot/drills.ts";
16 import { lastMirror, mirrorPass, NO_PASSPHRASE, offsiteFromEnv, resolveMirror } from "../src/snapshot/mirror.ts";
17 import { parseArgs } from "../scripts/restore-drill.ts";
18 import { offsiteLane, readOffsiteLane } from "../src/snapshot/offsite_lane.ts";
19 import { restoreSnapshot } from "../src/snapshot/restore.ts";
20 import { hiddenKey, LANE_HEADER, openBody, parseHeader, sealBody, sealedStore, sealKeys, SEALED_PREFIX } from "../src/snapshot/sealed_store.ts";
21 import { startSnapshots } from "../src/snapshot/service.ts";
22 import { listSnapshots, SNAPSHOT_PREFIX } from "../src/snapshot/snapshot.ts";
23 import { dirStore, type Store } from "../src/snapshot/store.ts";
24 import { tool as systemTool } from "../src/http/mcp/tools/system.ts";
25 import { Session } from "../src/tui/forge.ts";
26 import { offsiteLines } from "../src/tui/screens/offsite.ts";
27 import { FOOTING } from "../src/system/footing.ts";
28 import { offsiteSection } from "../src/web/pages/system.ts";
29
30 const PASS = "a long operator passphrase";
31 const SALT = new Uint8Array(16).fill(7);
32
33 function files(dir: string, out: string[] = []): string[] {
34 for (const n of readdirSync(dir)) {
35 const p = join(dir, n);
36 if (statSync(p).isDirectory()) files(p, out);
37 else out.push(p);
38 }
39 return out;
40 }
41
42 describe("the sealed store", () => {
43 test("a body goes in as ciphertext and comes back whole; the wrong passphrase or a changed byte does not open", async () => {
44 const k = await sealKeys(PASS, SALT);
45 const sealed = sealBody(k, "SQLite format 3 and a secret");
46 expect(Buffer.from(sealed).toString("latin1")).not.toContain("SQLite");
47 expect(Buffer.from(openBody(k, sealed, "x")).toString()).toBe("SQLite format 3 and a secret");
48 expect(() => openBody(k, sealed, "x")).not.toThrow();
49 const other = await sealKeys("not the passphrase", SALT);
50 expect(() => openBody(other, sealed, "x")).toThrow("wrong passphrase");
51 // The same passphrase under another lane's salt is another key.
52 const otherSalt = await sealKeys(PASS, new Uint8Array(16).fill(8));
53 expect(() => openBody(otherSalt, sealed, "x")).toThrow("did not open");
54 const changed = new Uint8Array(sealed);
55 changed[20] = changed[20]! ^ 1;
56 expect(() => openBody(k, changed, "x")).toThrow("did not open");
57 expect(() => openBody(k, new TextEncoder().encode("plain"), "x")).toThrow("not a sealed object");
58 });
59
60 test("a path inside a snapshot is hidden; the snapshot's name stays; a listed key reads back", async () => {
61 const k = await sealKeys(PASS, SALT);
62 const hidden = hiddenKey(k, "snapshots/2026-11-12T03-00-00Z/repos/alice/private-plans.bundle");
63 expect(hidden).toMatch(/^snapshots\/2026-11-12T03-00-00Z\/[0-9a-f]{64}$/);
64 expect(hiddenKey(k, hidden)).toBe(hidden);
65 });
66
67 test("each lane keeps a random salt of its own in an unsealed header; a wrong passphrase is said before any object is read", async () => {
68 const root = mkdtempSync(join(tmpdir(), "forge-offsite-salt-"));
69 try {
70 const one = sealedStore(dirStore(join(root, "one")), PASS);
71 const two = sealedStore(dirStore(join(root, "two")), PASS);
72 await one.put("snapshots/2026-11-12T03-00-00Z/x", "body");
73 await two.put("snapshots/2026-11-12T03-00-00Z/x", "body");
74 const h1 = parseHeader(readFileSync(join(root, "one", LANE_HEADER)));
75 const h2 = parseHeader(readFileSync(join(root, "two", LANE_HEADER)));
76 if (typeof h1 === "string" || typeof h2 === "string") throw new Error("no header");
77 expect(h1.salt.length).toBe(16);
78 expect(h1.salt.equals(h2.salt)).toBe(false);
79 expect(readFileSync(join(root, "one", LANE_HEADER), "utf8")).not.toContain(PASS);
80 // Same passphrase, same path, other salt: the hidden names differ, so nothing computed for one lane serves the other.
81 expect(await one.list("snapshots/")).not.toEqual(await two.list("snapshots/"));
82 // A reopened lane reads its own salt back.
83 expect(new TextDecoder().decode(await sealedStore(dirStore(join(root, "one")), PASS).get("snapshots/2026-11-12T03-00-00Z/x"))).toBe("body");
84 await expect(sealedStore(dirStore(join(root, "one")), "not it").get("snapshots/2026-11-12T03-00-00Z/x")).rejects.toThrow("does not open the lane");
85 // A reader (a restore) never starts a lane on an empty bucket.
86 await expect(sealedStore(dirStore(join(root, "empty")), PASS, { create: false }).list("snapshots/")).rejects.toThrow("holds no sealed lane");
87 expect(parseHeader(new TextEncoder().encode('{"v":1,"salt":"zz","check":"00"}'))).toBeTypeOf("string");
88 } finally {
89 await rm(root, { recursive: true, force: true });
90 }
91 });
92
93 test("without a passphrase the lane fails closed: no request reaches the bucket, and the board says why", async () => {
94 const seen: string[] = [];
95 const bucket = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => (seen.push(`${req.method} ${new URL(req.url).pathname}`), new Response("", { status: 200 })) });
96 const root = mkdtempSync(join(tmpdir(), "forge-offsite-closed-"));
97 try {
98 const env = {
99 FORGE_MIRROR_S3_ENDPOINT: `http://127.0.0.1:${String(bucket.port)}`,
100 FORGE_MIRROR_S3_BUCKET: "offsite",
101 FORGE_MIRROR_S3_ACCESS_KEY: "a",
102 FORGE_MIRROR_S3_SECRET_KEY: "s",
103 };
104 const plain = resolveMirror({ env });
105 expect(plain?.encrypted).toBe(false);
106 expect(plain?.store.sealed).toBeUndefined();
107 await expect(plain!.store.put("snapshots/x/forge.db", "SQLite format 3")).rejects.toThrow(NO_PASSPHRASE);
108 const primary = dirStore(join(root, "primary"));
109 await primary.put("snapshots/2026-11-12T03-00-00Z/forge.db", "SQLite format 3");
110 await primary.put("snapshots/2026-11-12T03-00-00Z/manifest.json", JSON.stringify({ name: "2026-11-12T03-00-00Z", startedAt: "2026-11-12T03:00:00Z", finishedAt: "2026-11-12T03:01:00Z", files: [] }));
111 const state = await mirrorPass(root, primary, plain!.store);
112 expect(seen).toEqual([]);
113 expect(state.error).toBe(NO_PASSPHRASE);
114 const lane = offsiteLane(state, undefined, Date.now());
115 expect(lane.health).toBe("unsealed");
116 expect(String(offsiteSection(lane))).toContain("writes nothing until FORGE_SNAPSHOT_PASSPHRASE is set");
117
118 const sealed = resolveMirror({ env, passphrase: PASS });
119 expect(sealed?.encrypted).toBe(true);
120 expect(sealed?.store.sealed).toBe(true);
121 expect(sealed?.store.where).toBe(`127.0.0.1:${String(bucket.port)}/offsite (sealed)`);
122 } finally {
123 await bucket.stop(true);
124 await rm(root, { recursive: true, force: true });
125 }
126 });
127
128 test("a restore on a fresh machine reads the lane from the environment, and only with the passphrase", async () => {
129 const lane = {
130 FORGE_MIRROR_S3_ENDPOINT: "https://s3.b2.example.com",
131 FORGE_MIRROR_S3_BUCKET: "offsite",
132 FORGE_MIRROR_S3_ACCESS_KEY: "a",
133 FORGE_MIRROR_S3_SECRET_KEY: "s",
134 };
135 expect(offsiteFromEnv({})).toStartWith("no off-site lane");
136 expect(offsiteFromEnv(lane)).toBe("the off-site lane is sealed: export FORGE_SNAPSHOT_PASSPHRASE");
137 const store = offsiteFromEnv({ ...lane, FORGE_SNAPSHOT_PASSPHRASE: PASS });
138 expect(typeof store === "string" ? store : store.sealed).toBe(true);
139 expect(parseArgs(["/tmp/x", "--offsite"])).toMatchObject({ dataDir: "/tmp/x", offsite: true });
140 expect(parseArgs(["/tmp/x"])).not.toHaveProperty("offsite");
141 // The card's command refuses before it touches the network or the directory.
142 const env: Record<string, string> = { PATH: process.env["PATH"] ?? "", ...lane };
143 const run = Bun.spawnSync([process.execPath, "run", "src/snapshot/restore.ts", "latest", join(tmpdir(), "never-written"), "--offsite"], {
144 cwd: join(import.meta.dir, ".."),
145 env,
146 });
147 expect(run.exitCode).toBe(2);
148 expect(run.stderr.toString()).toContain("the off-site lane is sealed");
149 });
150
151 });
152
153 describe("the off-site lane end to end", () => {
154 let root: string;
155 let app: App;
156 let offDir: string;
157
158 beforeAll(async () => {
159 root = mkdtempSync(join(tmpdir(), "forge-offsite-"));
160 offDir = join(root, "offsite-bucket");
161 app = createApp({ dataDir: join(root, "data"), port: 0 });
162 const u = await createUser(app.db, {
163 origin: "seed",
164 trusted: true,
165 username: "alice",
166 email: "[email protected]",
167 password: "a-good-passphrase",
168 adminIfFirst: true,
169 });
170 if (!u.ok) throw new Error(u.error);
171 });
172 afterAll(async () => {
173 await rm(root, { recursive: true, force: true });
174 });
175
176 test("the pass seals every object, a restore with the passphrase brings the forge back, and the drill restores from it", async () => {
177 const mirror = sealedStore(dirStore(offDir), PASS);
178 const svc = startSnapshots(app, dirStore(join(root, "primary")), {
179 version: "t",
180 nightly: false,
181 passphrase: PASS,
182 mirror,
183 drill: (deps) => runDrill({ ...deps, boot: async () => ({ ok: true, reason: "" }) }),
184 });
185 const m = await svc.now();
186 const state = await svc.mirrorFresh();
187 expect(state?.failingSince).toBeUndefined();
188 expect(state?.sealed).toBe(true);
189 expect(lastMirror(app.config.dataDir)?.sealed).toBe(true);
190
191 // On the bucket: only `sealed/snapshots/<name>/<hmac>`, and not one plain byte of the database or the manifest.
192 const onBucket = files(offDir);
193 // Every object of the snapshot and its manifest, and the lane's header.
194 expect(onBucket.length).toBe(m.files.length + 2);
195 expect(onBucket.map((f) => relative(offDir, f))).toContain(LANE_HEADER);
196 for (const f of onBucket.filter((f) => relative(offDir, f) !== LANE_HEADER)) {
197 expect(relative(offDir, f)).toMatch(new RegExp(`^${SEALED_PREFIX}${SNAPSHOT_PREFIX}${m.name}/[0-9a-f]{64}$`));
198 const raw = readFileSync(f).toString("latin1");
199 expect(raw.startsWith("FSO1")).toBe(true);
200 expect(raw).not.toContain("SQLite format");
201 expect(raw).not.toContain('"startedAt"');
202 }
203
204 // The lane lists and restores like any store, given the passphrase; another passphrase opens nothing.
205 expect((await listSnapshots(mirror)).map((x) => x.name)).toEqual([m.name]);
206 const target = join(root, "restored");
207 const done = await restoreSnapshot(sealedStore(dirStore(offDir), PASS), m.name, target, { passphrase: PASS });
208 expect(done.manifest.name).toBe(m.name);
209 expect(statSync(join(target, "forge.db")).size).toBeGreaterThan(0);
210 // Another passphrase is refused at the header, and the bytes there do not open under it.
211 await expect(sealedStore(dirStore(offDir), "wrong").get(`${SNAPSHOT_PREFIX}${m.name}/manifest.json`)).rejects.toThrow("does not open the lane");
212 const header = parseHeader(readFileSync(join(offDir, LANE_HEADER)));
213 if (typeof header === "string") throw new Error(header);
214 const sealedFile = onBucket.find((f) => relative(offDir, f) !== LANE_HEADER)!;
215 const wrong = await sealKeys("wrong", header.salt);
216 expect(() => openBody(wrong, readFileSync(sealedFile), "x")).toThrow("wrong passphrase");
217
218 // The drill restores the newest off-site snapshot, green, and keeps where it came from.
219 const d = await svc.drill();
220 expect(d.verdict).toBe("green");
221 expect(d.snapshotId).toBe(m.name);
222 expect(d.from).toBe(mirror.where);
223 const kept = lastDrill(app.db);
224 expect(kept?.from).toBe(mirror.where);
225 expect(kept?.verdict).toBe("green");
226 await svc.stop();
227 }, 60_000);
228
229 test("/system's section says when the lane last succeeded and what the drill restored from", () => {
230 const lane = offsiteLane(lastMirror(app.config.dataDir), lastDrill(app.db), Date.now());
231 expect(lane.health).toBe("ok");
232 const out = String(offsiteSection(lane));
233 expect(out).toContain('id="sys-offsite"');
234 expect(out).toContain('data-health="ok"');
235 expect(out).toContain('data-sealed="true"');
236 expect(out).toContain(`data-success="${lane.lastSuccess}"`);
237 expect(out).toContain("Last success");
238 expect(out).toContain('data-drill="green"');
239 });
240
241 test("the terminal screen and the MCP read say what /system says, to a forge admin alone", async () => {
242 const lane = readOffsiteLane(app.config.dataDir, app.db);
243 const ident = (u: Awaited<ReturnType<typeof person>>) => ({ sshUsername: u.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: u });
244 const admin = await person("lane-keeper", true);
245 const member = await person("lane-reader", false);
246 const lines = offsiteLines(new Session(app, ident(admin)));
247 expect(lines[0]).toBe("The off-site lane is level and sealed.");
248 expect(lines.join("\n")).toContain(`Writes to ${lane.where!}`);
249 expect(lines.join("\n")).toContain(`Last success ${lane.lastSuccess!}`);
250 expect(lines.join("\n")).toContain(`From ${lane.drill!.from!} (off-site)`);
251 expect(offsiteLines(new Session(app, ident(member)))).toEqual([]);
252
253 const read = await systemTool.run({ app } as never, admin, { kind: "offsite" });
254 expect(JSON.stringify(read)).toContain(`"lastSuccess":"${lane.lastSuccess!}"`);
255 expect(JSON.stringify(read)).toContain('"says":"The off-site lane is level and sealed."');
256 expect(JSON.stringify(await systemTool.run({ app } as never, member, { kind: "offsite" }))).toContain("forbidden");
257 });
258
259 test("a mirror pass that never ends holds a drill no longer than the drill's wait", async () => {
260 let release = (): void => {};
261 const held = new Promise<void>((r) => (release = r));
262 const stuck: Store = { ...dirStore(join(root, "stuck")), where: "stuck", names: async () => (await held, []) };
263 let drilled = false;
264 const svc = startSnapshots(app, dirStore(join(root, "primary")), {
265 version: "t",
266 nightly: false,
267 mirror: stuck,
268 drillWaitMs: 50,
269 drill: async (): Promise<DrillResult> => {
270 drilled = true;
271 return { at: new Date().toISOString(), snapshotId: null, ms: 0, counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 }, health: "ok", verdict: "green", failedStep: null, phases: [], budgetMs: 0, reason: null };
272 },
273 });
274 const pass = svc.mirrorFresh();
275 const started = performance.now();
276 await svc.drill();
277 expect(drilled).toBe(true);
278 expect(performance.now() - started).toBeLessThan(5_000);
279 release();
280 await pass;
281 await svc.stop();
282 });
283
284 async function person(username: string, isAdmin: boolean) {
285 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", isAdmin });
286 if (!r.ok) throw new Error(r.error);
287 return r.value;
288 }
289 });
290
291 describe("answersRoot", () => {
292 test("a restored forge's / answers below 400, a redirect included; a 5xx is red", async () => {
293 const statuses: Record<string, number> = { "/ok/": 200, "/moved/": 302, "/broken/": 500 };
294 const server = Bun.serve({
295 port: 0,
296 hostname: "127.0.0.1",
297 fetch: (req) => {
298 const status = statuses[new URL(req.url).pathname] ?? 404;
299 return new Response("", { status, headers: status === 302 ? { location: "/signin" } : {} });
300 },
301 });
302 try {
303 const base = `http://127.0.0.1:${String(server.port)}`;
304 await answersRoot(`${base}/ok`);
305 await answersRoot(`${base}/moved`);
306 await expect(answersRoot(`${base}/broken`)).rejects.toThrow("answered / with 500");
307 } finally {
308 await server.stop(true);
309 }
310 });
311 });
312
313 describe("the lane's health", () => {
314 const now = Date.parse("2026-11-12T12:00:00Z");
315 const base = { where: "b2.example.com/offsite (sealed)", attemptAt: "2026-11-12T11:00:00Z", reachable: true };
316 test("none, ok, unsealed, late and failing", () => {
317 expect(offsiteLane(undefined, undefined, now).health).toBe("none");
318 expect(offsiteLane({ ...base, sealed: true, syncedAt: "2026-11-12T11:00:00Z" }, undefined, now).health).toBe("ok");
319 expect(offsiteLane({ ...base, sealed: false, syncedAt: "2026-11-12T11:00:00Z" }, undefined, now).health).toBe("unsealed");
320 expect(offsiteLane({ ...base, sealed: true, syncedAt: "2026-11-10T11:00:00Z" }, undefined, now).health).toBe("late");
321 const failing = offsiteLane({ ...base, sealed: true, failingSince: "2026-11-12T10:00:00Z", error: "refused" }, undefined, now);
322 expect(failing.health).toBe("failing");
323 expect(String(offsiteSection(failing))).toContain("The off-site lane is failing: refused.");
324 });
325 });
326
327 describe("FOOTING", () => {
328 test("no backup piece is on pg-grand", () => {
329 const backup = FOOTING.filter((p) => p.need === "backup");
330 expect(backup.length).toBeGreaterThan(0);
331 expect(backup.every((p) => p.machine !== "pg-grand")).toBe(true);
332 expect(backup.map((p) => p.machine).sort()).toEqual(["off-site", "vxrail"]);
333 });
334 });
test/palette-roster.test.ts +1
@@ -119,6 +119,7 @@ test("a site-admin person is offered the whole roster", () => {
119119 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
120120 ["stalls", "Main-thread stalls", "the last hour's stall ms by route"],
121121 ["footing", "What the forge stands on", "each piece on another machine, in the order it would break the forge"],
122 ["offsite", "Off-site backup", "where the sealed lane writes, when it last succeeded, and the last restore drill"],
122123 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
123124 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
124125 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],
test/system-parts.test.ts +1
@@ -53,6 +53,7 @@ test("one <h2> per part, each in a settings-block, and no card", async () => {
5353 "Disks and network",
5454 "Processes",
5555 "What the forge stands on",
56 "Off-site backup",
5657 "Loop lag, last 20 minutes",
5758 "Main-thread stalls, last hour",
5859 "Telemetry readers",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs