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
Check · Review · Land · Live — Its check is queued.
mason/forge:mason/11805-the-forge-s-data-is-backed-up-encrypted → main 7 commits fork
25 files
changed +990
−69
docs/OPERATING.md +9 −9
| @@ -103,6 +103,7 @@ down. | ||
| 103 | 103 | <!-- footing:start — written by footingTable() in src/system/footing.ts; test/footing.test.ts holds it equal --> |
| 104 | 104 | - **pg-grand**: DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it. |
| 105 | 105 | - **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. | |
| 106 | 107 | |
| 107 | 108 | | Needed to | Piece | Runs on | If that machine dies | Breaks | Removed by | |
| 108 | 109 | |---|---|---|---|---|---| |
| @@ -111,7 +112,7 @@ down. | ||
| 111 | 112 | | (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. | — | — | |
| 112 | 113 | | (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 | |
| 113 | 114 | | (a) answer a request | Child and app hosts, Traefik IngressRoutes forge-children (<name>--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 | | |
| 115 | 116 | | (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 | |
| 116 | 117 | | (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 | |
| 117 | 118 | | (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. | ||
| 120 | 121 | | (c) build | forgejo-runner-vxrail, a Forgejo Actions runner | vxrail | It goes with the forge, which runs beside it. | — | — | |
| 121 | 122 | | (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 | |
| 122 | 123 | | (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. | — | — | | |
| 128 | 129 | |
| 129 | 130 | If pg-grand were switched off now, the forge breaks in this order (the Breaks column): |
| 130 | 131 | |
| @@ -133,10 +134,9 @@ If pg-grand were switched off now, the forge breaks in this order (the Breaks co | ||
| 133 | 134 | 3. the signup gate: no one signs up |
| 134 | 135 | 4. image pulls and releases |
| 135 | 136 | 5. the control plane: nothing reschedules, Services stop updating |
| 136 | 6. local backups | |
| 137 | 7. the host timers | |
| 137 | 6. the host timers | |
| 138 | 138 | |
| 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. | |
| 140 | 140 | <!-- footing:end --> |
| 141 | 141 | |
| 142 | 142 | 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`). | ||
| 35 | 35 | |
| 36 | 36 | ## Route 1 — the restore command |
| 37 | 37 | |
| 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] | |
| 39 | 39 | |
| 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). | |
| 42 | 42 | |
| 43 | 43 | Then boot the forge on that directory, on a side port, off the public one: |
| 44 | 44 |
docs/RESTORE.md +5
| @@ -56,4 +56,9 @@ refuses `/data`, and leaves the restored copy in place. | ||
| 56 | 56 | `test/restore-drill-script.test.ts` runs it against a tiny snapshot and a |
| 57 | 57 | stub bucket. |
| 58 | 58 | |
| 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). | |
| 63 | ||
| 59 | 64 | With the platform unreachable (the B2 mirror only): [](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), 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 @@ | ||
| 6 | 6 | * writes only into the empty data directory it is given, and reads |
| 7 | 7 | * production through its public API, nothing else of the platform. |
| 8 | 8 | * |
| 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. | |
| 10 | 13 | * |
| 11 | 14 | * It restores, boots the restored forge on a free side port, checks `/` |
| 12 | 15 | * answers, counts the repositories and apps anyone can see there and on |
| @@ -17,6 +20,7 @@ | ||
| 17 | 20 | import { resolve } from "node:path"; |
| 18 | 21 | import { bootRestored } from "../src/snapshot/boot.ts"; |
| 19 | 22 | import { resolveDestination } from "../src/snapshot/destination.ts"; |
| 23 | import { offsiteFromEnv } from "../src/snapshot/mirror.ts"; | |
| 20 | 24 | import { publicCounts, type PublicCounts } from "../src/snapshot/public_counts.ts"; |
| 21 | 25 | import { restoreSnapshot } from "../src/snapshot/restore.ts"; |
| 22 | 26 | import { listSnapshots } from "../src/snapshot/snapshot.ts"; |
| @@ -28,8 +32,10 @@ const PRODUCTION_DATA = "/data"; | ||
| 28 | 32 | const BOOT_MS = 60_000; |
| 29 | 33 | |
| 30 | 34 | /** 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 { | |
| 32 | 36 | const rest = [...args]; |
| 37 | const off = rest.indexOf("--offsite"); | |
| 38 | if (off >= 0) rest.splice(off, 1); | |
| 33 | 39 | let production = PRODUCTION; |
| 34 | 40 | const at = rest.indexOf("--production"); |
| 35 | 41 | if (at >= 0) { |
| @@ -46,10 +52,10 @@ export function parseArgs(args: readonly string[]): { dataDir: string; productio | ||
| 46 | 52 | if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) |
| 47 | 53 | return "--production takes an https URL"; |
| 48 | 54 | 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]"; | |
| 50 | 56 | const dataDir = resolve(rest[0]); |
| 51 | 57 | 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 } : {}) }; | |
| 53 | 59 | } |
| 54 | 60 | |
| 55 | 61 | /** The two sides' counts as lines a person reads, and which differ. */ |
| @@ -69,7 +75,12 @@ async function main(): Promise<number> { | ||
| 69 | 75 | console.error(args); |
| 70 | 76 | return 2; |
| 71 | 77 | } |
| 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; | |
| 73 | 84 | if (store === undefined) { |
| 74 | 85 | console.error("no store: export the S3_* lines of the restore card, or FORGE_SNAPSHOT_DIR"); |
| 75 | 86 | return 2; |
src/http/mcp/tools/restore_drill.ts +5 −3
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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", | |
| 8 | 8 | description: "Drill-restore the newest snapshot.", |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: true, |
| @@ -18,10 +18,12 @@ export const tool: Tool = { | ||
| 18 | 18 | return good({ verdict: "none", says: "This forge has no snapshots to drill." }); |
| 19 | 19 | try { |
| 20 | 20 | 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 }; | |
| 21 | 23 | const answer = |
| 22 | 24 | 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 }); | |
| 25 | 27 | return { ...answer, card: { at: d.at } }; |
| 26 | 28 | } catch (error) { |
| 27 | 29 | 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"; | ||
| 8 | 8 | import { LAG_BAR_MS, overBar } from "../../../perf/lag_minutes.ts"; |
| 9 | 9 | import { slowestRoutes } from "../../../perf/watch.ts"; |
| 10 | 10 | import { footing, riskLines } from "../../../system/footing.ts"; |
| 11 | import { laneWords, readOffsiteLane } from "../../../snapshot/offsite_lane.ts"; | |
| 11 | 12 | import { good, bad, type Tool } from "../shared.ts"; |
| 12 | 13 | |
| 13 | 14 | export const tool: Tool = { |
| 14 | 15 | 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", | |
| 16 | 17 | description: |
| 17 | 18 | "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"] } } }, | |
| 19 | 20 | writes: false, |
| 20 | 21 | async run(ctx, user, args) { |
| 21 | 22 | if (args["kind"] !== undefined) { |
| @@ -30,7 +31,12 @@ export const tool: Tool = { | ||
| 30 | 31 | const f = await footing.read(); |
| 31 | 32 | return good({ ...f, risks: riskLines(f) }); |
| 32 | 33 | } |
| 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"); | |
| 34 | 40 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations"); |
| 35 | 41 | const now = new Date(); |
| 36 | 42 | 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( | ||
| 527 | 527 | })?.store, |
| 528 | 528 | { |
| 529 | 529 | version: running().version, |
| 530 | // The off-site lane (#11805): sealed under the passphrase before it leaves. | |
| 530 | 531 | 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, | |
| 532 | 538 | passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined, |
| 533 | 539 | }, |
| 534 | 540 | ); |
src/snapshot/drill.ts +16 −2
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * takes the newest snapshot, restores it into a fresh directory, opens the |
| 4 | 4 | * database read-only, counts what is in it, opens one sealed value with the |
| 5 | 5 | * 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. | |
| 7 | 7 | * |
| 8 | 8 | * Plain data out: when it ran, which snapshot, how long, what it counted, |
| 9 | 9 | * and a verdict. A red verdict names the step that failed; a step that |
| @@ -19,6 +19,7 @@ import { bootRestored } from "./boot.ts"; | ||
| 19 | 19 | import { forgetKey, open } from "../kernel/secret.ts"; |
| 20 | 20 | import { countRows, mismatched, type DrillCounts } from "./counts.ts"; |
| 21 | 21 | import { restoreSnapshot, type Progress } from "./restore.ts"; |
| 22 | import { isOffBox } from "./off_box.ts"; | |
| 22 | 23 | import { listSnapshots } from "./snapshot.ts"; |
| 23 | 24 | import type { Store } from "./store.ts"; |
| 24 | 25 | |
| @@ -155,6 +156,17 @@ export interface DrillResult { | ||
| 155 | 156 | readonly budgetMs: number; |
| 156 | 157 | /** Why it is red, for the person reading; never a value. */ |
| 157 | 158 | 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}`); | |
| 158 | 170 | } |
| 159 | 171 | |
| 160 | 172 | class StepTimeout extends Error {} |
| @@ -276,7 +288,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 276 | 288 | step = "health"; |
| 277 | 289 | bootStart = performance.now(); |
| 278 | 290 | const booted = await within( |
| 279 | (deps.boot ?? bootRestored)(target, left()), | |
| 291 | (deps.boot ?? bootRestored)(target, left(), undefined, answersRoot), | |
| 280 | 292 | left(), |
| 281 | 293 | budget, |
| 282 | 294 | ); |
| @@ -323,6 +335,8 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> { | ||
| 323 | 335 | phases, |
| 324 | 336 | budgetMs, |
| 325 | 337 | reason, |
| 338 | from: deps.store.where, | |
| 339 | offSite: isOffBox(deps.store), | |
| 326 | 340 | }; |
| 327 | 341 | } |
| 328 | 342 | } |
src/snapshot/drills.ts +18 −1
| @@ -24,6 +24,17 @@ interface Row { | ||
| 24 | 24 | |
| 25 | 25 | const PHASES = new Set(["manifest", "files", "unpack", "bundles", "key", "open", "boot"]); |
| 26 | 26 | |
| 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 | ||
| 27 | 38 | /** The stored JSON as phases; anything that is not a list of known phases with whole ms reads as none. */ |
| 28 | 39 | export function parsePhases(raw: string | null): { phases: PhaseTime[]; budgetMs: number } { |
| 29 | 40 | const none = { phases: [], budgetMs: 0 }; |
| @@ -61,7 +72,12 @@ export function recordDrill(db: Database, result: DrillResult): void { | ||
| 61 | 72 | result.verdict, |
| 62 | 73 | result.failedStep, |
| 63 | 74 | 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 | }), | |
| 65 | 81 | ); |
| 66 | 82 | } |
| 67 | 83 | |
| @@ -91,5 +107,6 @@ export function lastDrill(db: Database): DrillResult | undefined { | ||
| 91 | 107 | reason: row.reason, |
| 92 | 108 | phases: kept.phases, |
| 93 | 109 | budgetMs: kept.budgetMs, |
| 110 | ...parseFrom(row.phases), | |
| 94 | 111 | }; |
| 95 | 112 | } |
src/snapshot/mirror.ts +47 −5
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The off-box mirror: a second S3-compatible bucket (Backblaze B2, say) that |
| 3 | 3 | * 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 | |
| 5 | 7 | * destination (`SNAPSHOT_MIRROR_S3_*`), or given as `FORGE_MIRROR_S3_*`. |
| 6 | 8 | * |
| 7 | 9 | * A pass copies each of the newest snapshots the mirror lacks, the manifest |
| @@ -18,6 +20,7 @@ import { join } from "node:path"; | ||
| 18 | 20 | import type { EventBus } from "../core/events.ts"; |
| 19 | 21 | import { deleteSecret, resolveSecrets } from "../core/secret_store.ts"; |
| 20 | 22 | import { KEEP, listSnapshots, pruneSnapshots, SNAPSHOT_PREFIX } from "./snapshot.ts"; |
| 23 | import { refusingStore, sealedStore } from "./sealed_store.ts"; | |
| 21 | 24 | import { s3Store, type S3Target, type Store } from "./store.ts"; |
| 22 | 25 | |
| 23 | 26 | /** The sealed names, one per field of the form. */ |
| @@ -42,11 +45,15 @@ export interface MirrorSources { | ||
| 42 | 45 | readonly dataDir?: string | undefined; |
| 43 | 46 | readonly bus?: EventBus | undefined; |
| 44 | 47 | 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; | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | export interface Mirror { |
| 48 | 53 | readonly source: "sealed" | "env"; |
| 49 | 54 | readonly store: Store; |
| 55 | /** Whether what lands there is encrypted; without a passphrase nothing lands, and `/system` says why. */ | |
| 56 | readonly encrypted: boolean; | |
| 50 | 57 | } |
| 51 | 58 | |
| 52 | 59 | /** 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"]; | ||
| 75 | 82 | }; |
| 76 | 83 | } |
| 77 | 84 | |
| 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 { | |
| 79 | 94 | 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 }; | |
| 81 | 100 | } |
| 82 | 101 | |
| 83 | 102 | /** Which of the sealed mirror names are set, without opening any. */ |
| @@ -110,6 +129,8 @@ export interface MirrorState { | ||
| 110 | 129 | readonly failingSince?: string; |
| 111 | 130 | readonly failed?: string; |
| 112 | 131 | readonly error?: string; |
| 132 | /** Whether the mirror's objects are encrypted (`Store.sealed`); absent in states kept before #11805. */ | |
| 133 | readonly sealed?: boolean; | |
| 113 | 134 | /** Set (and nothing recorded) when the primary, not the mirror, could not be read. */ |
| 114 | 135 | readonly sourceError?: string; |
| 115 | 136 | } |
| @@ -158,7 +179,9 @@ async function copyOne(src: Store, dst: Store, name: string): Promise<void> { | ||
| 158 | 179 | const prefix = `${SNAPSHOT_PREFIX}${name}/`; |
| 159 | 180 | const manifest = `${prefix}manifest.json`; |
| 160 | 181 | 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]!)); | |
| 162 | 185 | for (let i = 0; i < todo.length; i += COPY_AT_ONCE) |
| 163 | 186 | await Promise.all( |
| 164 | 187 | todo.slice(i, i + COPY_AT_ONCE).map(async (key) => { |
| @@ -207,6 +230,7 @@ export async function mirrorPass( | ||
| 207 | 230 | where: dst.where, |
| 208 | 231 | attemptAt: at, |
| 209 | 232 | reachable: true, |
| 233 | sealed: dst.sealed === true, | |
| 210 | 234 | syncedAt: at, |
| 211 | 235 | ...(newest === undefined ? {} : { newest: newest.name, newestAt: newest.finishedAt }), |
| 212 | 236 | }; |
| @@ -221,6 +245,7 @@ export async function mirrorPass( | ||
| 221 | 245 | where: dst.where, |
| 222 | 246 | attemptAt: at, |
| 223 | 247 | reachable, |
| 248 | sealed: dst.sealed === true, | |
| 224 | 249 | ...(prev?.syncedAt === undefined ? {} : { syncedAt: prev.syncedAt }), |
| 225 | 250 | ...(prev?.newest === undefined ? {} : { newest: prev.newest }), |
| 226 | 251 | ...(prev?.newestAt === undefined ? {} : { newestAt: prev.newestAt }), |
| @@ -254,7 +279,9 @@ export function mirrorNeed(s: MirrorState | undefined, now: number): MirrorNeed | ||
| 254 | 279 | if (s.failingSince !== undefined) |
| 255 | 280 | return { |
| 256 | 281 | 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 | |
| 258 | 285 | ? `Snapshot ${s.failed ?? "(unknown)"} did not land on the off-box mirror ${s.where}: ${s.error ?? "unknown reason"}.` |
| 259 | 286 | : `The off-box mirror ${s.where} is unreachable: ${s.error ?? "unknown reason"}.`, |
| 260 | 287 | since: s.failingSince, |
| @@ -267,3 +294,18 @@ export function mirrorNeed(s: MirrorState | undefined, now: number): MirrorNeed | ||
| 267 | 294 | since: s.syncedAt ? new Date(at + MIRROR_STALE_MS).toISOString() : s.attemptAt, |
| 268 | 295 | }; |
| 269 | 296 | } |
| 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 { | ||
| 45 | 45 | if (times.length === 0) return null; |
| 46 | 46 | return times.reduce((a, b) => (Date.parse(b) > Date.parse(a) ? b : a)); |
| 47 | 47 | } |
| 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 { | ||
| 48 | 48 | import { unwrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 49 | 49 | import { isPackedFile, partPaths, unpacker, type PackedFile, type Unpacker } from "./parts.ts"; |
| 50 | 50 | import { resolveDestination } from "./destination.ts"; |
| 51 | import { offsiteFromEnv } from "./mirror.ts"; | |
| 51 | 52 | import type { Store } from "./store.ts"; |
| 52 | 53 | |
| 53 | 54 | export interface Restored { |
| @@ -393,17 +394,26 @@ if (import.meta.main) { | ||
| 393 | 394 | } |
| 394 | 395 | args.splice(at, 2); |
| 395 | 396 | } |
| 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); | |
| 396 | 400 | const [name, dataDir] = args; |
| 397 | 401 | if (!name || !dataDir || args.length > 2) { |
| 398 | 402 | // CLI usage on stderr for the person at the terminal, not a forge event. |
| 399 | 403 | 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]", | |
| 401 | 405 | ); |
| 402 | 406 | process.exit(2); |
| 403 | 407 | } |
| 404 | 408 | // A restore starts from an empty directory, so nothing is sealed yet: the |
| 405 | 409 | // 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; | |
| 407 | 417 | if (store === undefined) { |
| 408 | 418 | // CLI message on stderr for the person at the terminal. |
| 409 | 419 | 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 { | ||
| 26 | 26 | type Manifest, |
| 27 | 27 | type Progress, |
| 28 | 28 | } 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"; | |
| 30 | 30 | import { BOOT_RED_FLOOR_MS, drillDue, RED_RETRY_MS, snapshotDue } from "./due.ts"; |
| 31 | 31 | import { sealedTarget } from "./destination.ts"; |
| 32 | 32 | import { watchBucket } from "./bucket.ts"; |
| @@ -141,6 +141,8 @@ export function startSnapshots( | ||
| 141 | 141 | /** Test seam: how soon a failed snapshot is tried again. */ |
| 142 | 142 | readonly retryMs?: number | undefined; |
| 143 | 143 | 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; | |
| 144 | 146 | /** Test seam: the drill itself. */ |
| 145 | 147 | readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined; |
| 146 | 148 | /** 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( | ||
| 346 | 348 | return mirrorNow(); |
| 347 | 349 | }; |
| 348 | 350 | |
| 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 | ||
| 349 | 366 | // Single-flight: a restore of every repository is heavy, so a second call |
| 350 | 367 | // while one runs joins it instead of filling the disk with another. |
| 351 | 368 | let drilling: Promise<DrillResult> | undefined; |
| 352 | 369 | const drill = (): Promise<DrillResult> => { |
| 353 | 370 | drilling ??= (async (): Promise<DrillResult> => { |
| 354 | 371 | 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(); | |
| 356 | 377 | if (store === undefined) |
| 357 | 378 | throw new Error("this forge has nowhere to drill from"); |
| 358 | 379 | // 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 { | ||
| 27 | 27 | * that does not retry has nothing to bound and leaves this out. |
| 28 | 28 | */ |
| 29 | 29 | 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; | |
| 30 | 38 | } |
| 31 | 39 | |
| 32 | 40 | /** 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"; | ||
| 30 | 30 | |
| 31 | 31 | export type Need = "answer" | "sign-in" | "build" | "release" | "backup"; |
| 32 | 32 | |
| 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"; | |
| 35 | 35 | |
| 36 | 36 | /** The machine the forge itself runs on, as declared. */ |
| 37 | 37 | export const FORGE_MACHINE: Machine = "vxrail"; |
| @@ -40,8 +40,12 @@ export const FORGE_MACHINE: Machine = "vxrail"; | ||
| 40 | 40 | export const MACHINES: Record<Machine, string> = { |
| 41 | 41 | "pg-grand": "DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it.", |
| 42 | 42 | 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.", | |
| 43 | 44 | }; |
| 44 | 45 | |
| 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 | ||
| 45 | 49 | export type Find = |
| 46 | 50 | /** Pods of one workload (Deployment, StatefulSet, DaemonSet) in a namespace, or every pod (`*`); namespace `""` is the forge's own. */ |
| 47 | 51 | | { readonly kind: "pods"; readonly namespace: string; readonly workload: string } |
| @@ -49,6 +53,8 @@ export type Find = | ||
| 49 | 53 | | { readonly kind: "self" } |
| 50 | 54 | /** The Kubernetes API server: the nodes labelled control-plane. */ |
| 51 | 55 | | { readonly kind: "control-plane" } |
| 56 | /** Object storage outside the cluster and off both machines; nothing to look for. */ | |
| 57 | | { readonly kind: "offsite" } | |
| 52 | 58 | /** Not in the cluster: a systemd unit on a host; the cluster cannot show it, so the declared machine stands. */ |
| 53 | 59 | | { readonly kind: "host"; readonly unit: string } |
| 54 | 60 | /** 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[] = [ | ||
| 78 | 84 | "the signup gate: no one signs up", |
| 79 | 85 | "image pulls and releases", |
| 80 | 86 | "the control plane: nothing reschedules, Services stop updating", |
| 81 | "local backups", | |
| 82 | 87 | "the host timers", |
| 83 | 88 | ]; |
| 84 | 89 | |
| @@ -140,7 +145,7 @@ export const FOOTING: readonly Piece[] = [ | ||
| 140 | 145 | machine: "pg-grand", |
| 141 | 146 | ifDies: "git over ssh and the terminal stop answering.", |
| 142 | 147 | issues: [11686], |
| 143 | breaks: 7, | |
| 148 | breaks: 6, | |
| 144 | 149 | }, |
| 145 | 150 | { |
| 146 | 151 | id: "control-plane", |
| @@ -229,7 +234,7 @@ export const FOOTING: readonly Piece[] = [ | ||
| 229 | 234 | machine: "pg-grand", |
| 230 | 235 | ifDies: "Nothing is released; production keeps the release it has.", |
| 231 | 236 | issues: [11686, 8942], |
| 232 | breaks: 7, | |
| 237 | breaks: 6, | |
| 233 | 238 | }, |
| 234 | 239 | { |
| 235 | 240 | id: "standby-reconcile", |
| @@ -239,7 +244,7 @@ export const FOOTING: readonly Piece[] = [ | ||
| 239 | 244 | machine: "pg-grand", |
| 240 | 245 | ifDies: "A changed manifest is not applied; the running pod is untouched.", |
| 241 | 246 | issues: [8948, 11686], |
| 242 | breaks: 7, | |
| 247 | breaks: 6, | |
| 243 | 248 | }, |
| 244 | 249 | { |
| 245 | 250 | id: "host-timers", |
| @@ -249,33 +254,31 @@ export const FOOTING: readonly Piece[] = [ | ||
| 249 | 254 | machine: "pg-grand", |
| 250 | 255 | ifDies: "The watchdog, the GitHub mirror and image pruning stop.", |
| 251 | 256 | 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], | |
| 262 | 257 | breaks: 6, |
| 263 | 258 | }, |
| 264 | 259 | { |
| 265 | id: "minio", | |
| 260 | id: "backup-lane", | |
| 266 | 261 | 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: [], | |
| 273 | 276 | }, |
| 274 | 277 | ]; |
| 275 | 278 | |
| 276 | 279 | /** On pg-grand, not in the forge's path: what dropping the machine takes besides. */ |
| 277 | 280 | 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."; | |
| 279 | 282 | |
| 280 | 283 | /** How each piece was placed: seen on nodes, or the declared machine kept and why. */ |
| 281 | 284 | export type Seen = |
| @@ -311,7 +314,7 @@ export function declaredFooting(why: string, at = new Date()): Footing { | ||
| 311 | 314 | forgeNode: null, |
| 312 | 315 | unread: why, |
| 313 | 316 | 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) })), | |
| 315 | 318 | }; |
| 316 | 319 | } |
| 317 | 320 | |
| @@ -387,6 +390,7 @@ export async function probeFooting(opts: ProbeOptions = {}): Promise<Footing> { | ||
| 387 | 390 | } |
| 388 | 391 | // Nothing in the cluster sees a systemd unit: the declared machine stands, said as such. |
| 389 | 392 | 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" }; | |
| 390 | 394 | if (f.kind === "unlocated") return { by: "declared", why: "its namespace is not recorded, and the forge does not list every pod in the cluster" }; |
| 391 | 395 | const where = nsOf(f.namespace); |
| 392 | 396 | const list = pods.get(where)!; |
| @@ -403,7 +407,7 @@ export async function probeFooting(opts: ProbeOptions = {}): Promise<Footing> { | ||
| 403 | 407 | const risk = |
| 404 | 408 | seen.by === "cluster" && forgeNode !== null |
| 405 | 409 | ? seen.nodes.some((n) => n !== forgeNode) |
| 406 | : p.machine !== FORGE_MACHINE; | |
| 410 | : !besideTheForge(p.machine); | |
| 407 | 411 | return { ...p, seen, risk }; |
| 408 | 412 | }); |
| 409 | 413 | const unread = |
src/tui/screens/entries.ts +2
| @@ -60,6 +60,7 @@ import { entry as economics } from "./economics.ts"; | ||
| 60 | 60 | import { entry as money } from "./money.ts"; |
| 61 | 61 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 62 | 62 | import { entry as footing } from "./footing.ts"; |
| 63 | import { entry as offsite } from "./offsite.ts"; | |
| 63 | 64 | import { entry as waiting } from "./waiting.ts"; |
| 64 | 65 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 65 | 66 | import { entry as maintenance } from "./maintenance.ts"; |
| @@ -126,6 +127,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 126 | 127 | system, |
| 127 | 128 | loopStalls, |
| 128 | 129 | footing, |
| 130 | offsite, | |
| 129 | 131 | waiting, |
| 130 | 132 | slowPagesEntry, |
| 131 | 133 | 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 | ||
| 48 | 48 | import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts"; |
| 49 | 49 | import { egressReportedLine } from "../../system/egress_line.ts"; |
| 50 | 50 | 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"; | |
| 51 | 52 | import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts"; |
| 52 | 53 | import { childDirOf, listChildren } from "../../actions/spawn.ts"; |
| 53 | 54 | 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 { | ||
| 237 | 238 | ${(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>`; |
| 238 | 239 | } |
| 239 | 240 | |
| 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 | ||
| 240 | 278 | /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */ |
| 241 | 279 | export function stallSection(rows: readonly RouteStall[], worst: Html = html``): Html { |
| 242 | 280 | 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> { | ||
| 439 | 477 | <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div> |
| 440 | 478 | <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section> |
| 441 | 479 | ${footingSection(standsOn)} |
| 480 | ${offsiteSection(timePhase(ctx.request, "offsite", () => readOffsiteLane(ctx.app.config.dataDir, ctx.app.db)))} | |
| 442 | 481 | ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))} |
| 443 | 482 | ${lagSection(lagMinutes.readings())} |
| 444 | 483 | <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div> |
test/footing.test.ts +7 −7
| @@ -148,14 +148,14 @@ describe("probeFooting", () => { | ||
| 148 | 148 | expect(byId(f, "standby-reconcile")).toMatchObject({ seen: { by: "declared" }, risk: true }); |
| 149 | 149 | for (const id of ["forge-ssh", "release-tick", "standby-reconcile", "host-timers"]) expect(byId(f, id).seen.by).toBe("declared"); |
| 150 | 150 | 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); | |
| 156 | 156 | // Each namespace is listed once although two pieces live in forgejo. |
| 157 | 157 | 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"]; | |
| 159 | 159 | expect(f.rows.filter((r) => r.risk).map((r) => r.id).sort()).toEqual([...off].sort()); |
| 160 | 160 | // In the order pg-grand switched off would break the forge: sign-in first, host timers last. |
| 161 | 161 | const lines = riskLines(f); |
| @@ -203,7 +203,7 @@ describe("probeFooting", () => { | ||
| 203 | 203 | expect(fetched).toBe(0); |
| 204 | 204 | expect(f.forgeNode).toBeNull(); |
| 205 | 205 | 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); | |
| 207 | 207 | }); |
| 208 | 208 | |
| 209 | 209 | 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", () => { | ||
| 119 | 119 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 120 | 120 | ["stalls", "Main-thread stalls", "the last hour's stall ms by route"], |
| 121 | 121 | ["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"], | |
| 122 | 123 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 123 | 124 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 124 | 125 | ["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 () => { | ||
| 53 | 53 | "Disks and network", |
| 54 | 54 | "Processes", |
| 55 | 55 | "What the forge stands on", |
| 56 | "Off-site backup", | |
| 56 | 57 | "Loop lag, last 20 minutes", |
| 57 | 58 | "Main-thread stalls, last hour", |
| 58 | 59 | "Telemetry readers", |
new above
offline