Public: anyone can read this; the crew can change it.
The Backups page takes the snapshot destination: bucket and keys sealed in the forge, not plaintext in its environment #367
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/338-the-backups-page-takes-the-snapshot-dest → main 2 commits fork
9 files
changed +681
−36
docs/SNAPSHOT.md +7
| @@ -57,6 +57,13 @@ Delete the old ones from the bucket if the bucket is not trusted. | ||
| 57 | 57 | in the pod (`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, |
| 58 | 58 | `S3_REGION`). The platform's own lanes mirror the bucket off the machine. |
| 59 | 59 | - Anywhere else, `FORGE_SNAPSHOT_DIR=/some/dir`. |
| 60 | - Or your own bucket, set on the Backups page (endpoint, bucket, region, | |
| 61 | access key, secret key): sealed as forge-level secrets | |
| 62 | (`SNAPSHOT_S3_*`), write-only, with a test button that lists the bucket. | |
| 63 | Precedence: sealed, then `FORGE_SNAPSHOT_DIR`, then `S3_*`; the page says | |
| 64 | which is in effect. A restore into an empty directory has nothing sealed | |
| 65 | yet, so it reads the environment: keep `S3_*` or `FORGE_SNAPSHOT_DIR` | |
| 66 | at hand for it. | |
| 60 | 67 | - Given neither, no snapshots are taken, and the Backups page says so |
| 61 | 68 | instead of showing an empty list that looks like a clean bill. |
| 62 | 69 |
src/actions/destination.ts added +153
| @@ -0,0 +1,153 @@ | ||
| 1 | /** | |
| 2 | * The snapshot destination, as an admin sets it. Every field is validated | |
| 3 | * here, then sealed as a forge-level secret; nothing is read back to a | |
| 4 | * person except which fields are set. A field left blank keeps what was | |
| 5 | * sealed, so the secret key is write-only and never re-shown. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { putSecret } from "./secrets.ts"; | |
| 10 | import { | |
| 11 | clearSealed, | |
| 12 | sealedSet, | |
| 13 | sealedTarget, | |
| 14 | SEALED, | |
| 15 | testTarget, | |
| 16 | } from "../snapshot/destination.ts"; | |
| 17 | import { s3FromEnv, type S3Target } from "../snapshot/store.ts"; | |
| 18 | import { snapshotsOf } from "../snapshot/service.ts"; | |
| 19 | import type { User } from "../core/users.ts"; | |
| 20 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 21 | ||
| 22 | export interface DestinationInput { | |
| 23 | readonly endpoint: string; | |
| 24 | readonly bucket: string; | |
| 25 | readonly region: string; | |
| 26 | readonly accessKey: string; | |
| 27 | readonly secretKey: string; | |
| 28 | } | |
| 29 | ||
| 30 | const KEY = /^[\x21-\x7e]{1,256}$/; | |
| 31 | ||
| 32 | /** The reason a field is not acceptable, or undefined when it is (blank is fine). */ | |
| 33 | export function invalidField(i: DestinationInput): string | undefined { | |
| 34 | if (i.endpoint !== "") { | |
| 35 | let url: URL | undefined; | |
| 36 | try { | |
| 37 | url = new URL(i.endpoint); | |
| 38 | } catch { | |
| 39 | /* reported below */ | |
| 40 | } | |
| 41 | if ( | |
| 42 | !url || | |
| 43 | !/^https?:$/.test(url.protocol) || | |
| 44 | url.username !== "" || | |
| 45 | url.password !== "" || | |
| 46 | i.endpoint.length > 300 | |
| 47 | ) | |
| 48 | return "The endpoint must be an http(s) URL without credentials in it."; | |
| 49 | } | |
| 50 | if (i.bucket !== "" && !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(i.bucket)) | |
| 51 | return "The bucket name is 3–63 lowercase letters, digits, dots or dashes."; | |
| 52 | if (i.region !== "" && !/^[a-z0-9-]{1,32}$/.test(i.region)) | |
| 53 | return "The region is lowercase letters, digits and dashes."; | |
| 54 | if (i.accessKey !== "" && !KEY.test(i.accessKey)) | |
| 55 | return "The access key must be printable characters without spaces."; | |
| 56 | if (i.secretKey !== "" && !KEY.test(i.secretKey)) | |
| 57 | return "The secret key must be printable characters without spaces."; | |
| 58 | return undefined; | |
| 59 | } | |
| 60 | ||
| 61 | const isAdmin = (actor: User | undefined): boolean => actor?.isAdmin === true; | |
| 62 | ||
| 63 | /** Seal what was given (an error is `not_found` or a sentence for the page); blank fields keep their sealed value. Says what is still missing. */ | |
| 64 | export function saveDestination( | |
| 65 | app: App, | |
| 66 | actor: User | undefined, | |
| 67 | input: DestinationInput, | |
| 68 | ): Result<string, string> { | |
| 69 | if (!isAdmin(actor)) return err("not_found"); | |
| 70 | const bad = invalidField(input); | |
| 71 | if (bad) return err(bad); | |
| 72 | const fields: [string, string][] = [ | |
| 73 | [SEALED.endpoint, input.endpoint], | |
| 74 | [SEALED.bucket, input.bucket], | |
| 75 | [SEALED.region, input.region], | |
| 76 | [SEALED.accessKey, input.accessKey], | |
| 77 | [SEALED.secretKey, input.secretKey], | |
| 78 | ]; | |
| 79 | for (const [name, value] of fields) { | |
| 80 | if (value === "") continue; | |
| 81 | const set = putSecret(app, actor, "forge", "", name, value); | |
| 82 | if (!set.ok) return err("That could not be sealed."); | |
| 83 | } | |
| 84 | snapshotsOf(app)?.forget(); | |
| 85 | const have = sealedSet(app.db); | |
| 86 | const missing = [ | |
| 87 | [SEALED.endpoint, "endpoint"], | |
| 88 | [SEALED.bucket, "bucket"], | |
| 89 | [SEALED.accessKey, "access key"], | |
| 90 | [SEALED.secretKey, "secret key"], | |
| 91 | ] | |
| 92 | .filter(([n]) => !have.includes(n!)) | |
| 93 | .map(([, label]) => label); | |
| 94 | return ok( | |
| 95 | missing.length === 0 | |
| 96 | ? "Destination sealed; the next snapshot goes there." | |
| 97 | : `Sealed, but not in effect until the ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} set.`, | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | export function forgetDestination( | |
| 102 | app: App, | |
| 103 | actor: User | undefined, | |
| 104 | ): Result<void, "not_found"> { | |
| 105 | if (!isAdmin(actor)) return err("not_found"); | |
| 106 | clearSealed(app.db); | |
| 107 | app.bus.append({ | |
| 108 | kind: "secret.deleted", | |
| 109 | actorId: actor!.id, | |
| 110 | payload: { name: "SNAPSHOT_S3_*", scope: "forge" }, | |
| 111 | }); | |
| 112 | snapshotsOf(app)?.forget(); | |
| 113 | return ok(); | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * List the bucket and say what came of it, in one sentence. Fields given | |
| 118 | * are tried as typed (all four needed); with none given, the destination in | |
| 119 | * effect — sealed, else the environment's — is tried. | |
| 120 | */ | |
| 121 | export async function testDestination( | |
| 122 | app: App, | |
| 123 | actor: User | undefined, | |
| 124 | input: DestinationInput, | |
| 125 | ): Promise<Result<string, "not_found">> { | |
| 126 | if (!isAdmin(actor)) return err("not_found"); | |
| 127 | const bad = invalidField(input); | |
| 128 | if (bad) return ok(bad); | |
| 129 | const typed = | |
| 130 | input.endpoint && input.bucket && input.accessKey && input.secretKey; | |
| 131 | let target: S3Target | undefined; | |
| 132 | if (typed) | |
| 133 | target = { | |
| 134 | endpoint: input.endpoint, | |
| 135 | bucket: input.bucket, | |
| 136 | accessKeyId: input.accessKey, | |
| 137 | secretAccessKey: input.secretKey, | |
| 138 | region: input.region || undefined, | |
| 139 | }; | |
| 140 | else if ( | |
| 141 | input.endpoint + input.bucket + input.accessKey + input.secretKey === | |
| 142 | "" | |
| 143 | ) | |
| 144 | target = | |
| 145 | sealedTarget({ db: app.db, dataDir: app.config.dataDir, bus: app.bus }) ?? | |
| 146 | s3FromEnv(); | |
| 147 | else | |
| 148 | return ok( | |
| 149 | "To test typed values give the endpoint, bucket, access key and secret key; or leave them all blank to test what is in effect.", | |
| 150 | ); | |
| 151 | if (!target) return ok("There is no S3 destination to test; seal one first."); | |
| 152 | return ok(await testTarget(target)); | |
| 153 | } |
src/http/server.ts +10 −1
| @@ -46,7 +46,13 @@ import { followingSubmit } from "../web/pages/following.ts"; | ||
| 46 | 46 | import { activityAtom } from "../web/feeds.ts"; |
| 47 | 47 | import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts"; |
| 48 | 48 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| 49 | import { backupsNow, backupsPage } from "../web/pages/backups.ts"; | |
| 49 | import { | |
| 50 | backupsClear, | |
| 51 | backupsDestination, | |
| 52 | backupsNow, | |
| 53 | backupsPage, | |
| 54 | backupsTest, | |
| 55 | } from "../web/pages/backups.ts"; | |
| 50 | 56 | import { |
| 51 | 57 | subscriptionAdd, |
| 52 | 58 | subscriptionDelete, |
| @@ -479,6 +485,9 @@ export function buildRouter(): Router<Handler> { | ||
| 479 | 485 | router.add("POST", "/settings/secrets/delete", secretDelete); |
| 480 | 486 | router.add("GET", "/settings/backups", backupsPage); |
| 481 | 487 | router.add("POST", "/settings/backups/now", backupsNow); |
| 488 | router.add("POST", "/settings/backups/destination", backupsDestination); | |
| 489 | router.add("POST", "/settings/backups/test", backupsTest); | |
| 490 | router.add("POST", "/settings/backups/clear", backupsClear); | |
| 482 | 491 | router.add("GET", "/settings/crew", crewPage); |
| 483 | 492 | router.add("POST", "/settings/crew", agentCreate); |
| 484 | 493 | router.add("GET", "/settings/crew/:username", agentPage); |
src/main.ts +18 −20
| @@ -18,7 +18,7 @@ import { startChecks } from "./checks/service.ts"; | ||
| 18 | 18 | import { startLooks } from "./looks/service.ts"; |
| 19 | 19 | import { startProbes } from "./contract/probes.ts"; |
| 20 | 20 | import { startSnapshots } from "./snapshot/service.ts"; |
| 21 | import { dirStore, s3FromEnv, s3Store } from "./snapshot/store.ts"; | |
| 21 | import { resolveDestination } from "./snapshot/destination.ts"; | |
| 22 | 22 | import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts"; |
| 23 | 23 | import { health } from "./http/health.ts"; |
| 24 | 24 | import { STALE_AFTER_MS } from "./runs/lease.ts"; |
| @@ -159,26 +159,24 @@ const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined; | ||
| 159 | 159 | // Is it so: the forge knocks on every door of an app with a contract after |
| 160 | 160 | // each production deploy and once a night, and compares with the table. |
| 161 | 161 | const probes = apps === undefined ? undefined : startProbes(app, apps); |
| 162 | // The snapshot: everything that cannot be rebuilt, nightly, to the bucket the | |
| 163 | // platform gave this app (`S3_*`) or to a directory (`FORGE_SNAPSHOT_DIR`). | |
| 164 | // A forge given neither takes none, and its settings page says so. | |
| 162 | // The snapshot: everything that cannot be rebuilt, nightly, to what an | |
| 163 | // operator sealed on the Backups page, else the platform's (`S3_*`) or a | |
| 164 | // directory (`FORGE_SNAPSHOT_DIR`). A forge given none says so on that page. | |
| 165 | 165 | const VERSION = |
| 166 | 166 | ((await import("../package.json")) as { version?: string }).version ?? "0"; |
| 167 | const snapshotDir = process.env["FORGE_SNAPSHOT_DIR"]?.trim(); | |
| 168 | const s3 = s3FromEnv(); | |
| 169 | const snapshotPassphrase = | |
| 170 | process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined; | |
| 171 | const snapshots = snapshotDir | |
| 172 | ? startSnapshots(app, dirStore(snapshotDir), { | |
| 173 | version: VERSION, | |
| 174 | passphrase: snapshotPassphrase, | |
| 175 | }) | |
| 176 | : s3 | |
| 177 | ? startSnapshots(app, s3Store(s3), { | |
| 178 | version: VERSION, | |
| 179 | passphrase: snapshotPassphrase, | |
| 180 | }) | |
| 181 | : undefined; | |
| 167 | const snapshots = startSnapshots( | |
| 168 | app, | |
| 169 | () => | |
| 170 | resolveDestination({ | |
| 171 | db: app.db, | |
| 172 | dataDir: app.config.dataDir, | |
| 173 | bus: app.bus, | |
| 174 | })?.store, | |
| 175 | { | |
| 176 | version: VERSION, | |
| 177 | passphrase: process.env["FORGE_SNAPSHOT_PASSPHRASE"] || undefined, | |
| 178 | }, | |
| 179 | ); | |
| 182 | 180 | const crew = appsOn |
| 183 | 181 | ? await startCrew(app, { |
| 184 | 182 | forgeUrl, |
| @@ -294,7 +292,7 @@ function shutdown(): void { | ||
| 294 | 292 | checks?.stop(), |
| 295 | 293 | looks?.stop(), |
| 296 | 294 | probes?.stop(), |
| 297 | snapshots?.stop(), | |
| 295 | snapshots.stop(), | |
| 298 | 296 | ]) |
| 299 | 297 | .catch(() => {}) |
| 300 | 298 | .then(() => { |
src/snapshot/destination.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * Where snapshots go, decided in one place. The nightly snapshot, the | |
| 3 | * button, the test and the restore command all ask `resolveDestination`; | |
| 4 | * none reads `S3_*` itself. Precedence: the destination sealed in this | |
| 5 | * forge (set on the Backups page) wins, then `FORGE_SNAPSHOT_DIR`, then | |
| 6 | * the `S3_*` variables a host-provisioned forge is given. | |
| 7 | * | |
| 8 | * The sealed settings are forge-level secrets, opened only through | |
| 9 | * `resolveSecrets`, so every open is audited and no value is ever read back | |
| 10 | * to a page. Pure apart from that. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import type { EventBus } from "../core/events.ts"; | |
| 15 | import { deleteSecret, resolveSecrets } from "../core/secret_store.ts"; | |
| 16 | import { | |
| 17 | dirStore, | |
| 18 | s3FromEnv, | |
| 19 | s3Store, | |
| 20 | type S3Target, | |
| 21 | type Store, | |
| 22 | } from "./store.ts"; | |
| 23 | ||
| 24 | export type DestinationSource = "sealed" | "dir" | "env"; | |
| 25 | ||
| 26 | export interface Destination { | |
| 27 | readonly source: DestinationSource; | |
| 28 | readonly store: Store; | |
| 29 | } | |
| 30 | ||
| 31 | /** The sealed names, one per field of the form. */ | |
| 32 | export const SEALED = { | |
| 33 | endpoint: "SNAPSHOT_S3_ENDPOINT", | |
| 34 | bucket: "SNAPSHOT_S3_BUCKET", | |
| 35 | region: "SNAPSHOT_S3_REGION", | |
| 36 | accessKey: "SNAPSHOT_S3_ACCESS_KEY", | |
| 37 | secretKey: "SNAPSHOT_S3_SECRET_KEY", | |
| 38 | } as const; | |
| 39 | export const SEALED_NAMES: readonly string[] = Object.values(SEALED); | |
| 40 | ||
| 41 | export interface Sources { | |
| 42 | readonly db?: Database | undefined; | |
| 43 | readonly dataDir?: string | undefined; | |
| 44 | readonly bus?: EventBus | undefined; | |
| 45 | readonly env?: Record<string, string | undefined>; | |
| 46 | } | |
| 47 | ||
| 48 | /** The sealed target, or undefined when the four required fields are not all set. */ | |
| 49 | export function sealedTarget(s: Sources): S3Target | undefined { | |
| 50 | if (!s.db || s.dataDir === undefined || !s.bus) return undefined; | |
| 51 | const v = resolveSecrets(s.db, s.dataDir, s.bus, SEALED_NAMES, {}); | |
| 52 | const endpoint = v[SEALED.endpoint]; | |
| 53 | const bucket = v[SEALED.bucket]; | |
| 54 | const accessKeyId = v[SEALED.accessKey]; | |
| 55 | const secretAccessKey = v[SEALED.secretKey]; | |
| 56 | if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) | |
| 57 | return undefined; | |
| 58 | return { | |
| 59 | endpoint, | |
| 60 | bucket, | |
| 61 | accessKeyId, | |
| 62 | secretAccessKey, | |
| 63 | region: v[SEALED.region], | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | export function resolveDestination(s: Sources = {}): Destination | undefined { | |
| 68 | const env = s.env ?? process.env; | |
| 69 | const sealed = sealedTarget(s); | |
| 70 | if (sealed) return { source: "sealed", store: s3Store(sealed) }; | |
| 71 | const dir = env["FORGE_SNAPSHOT_DIR"]?.trim(); | |
| 72 | if (dir) return { source: "dir", store: dirStore(dir) }; | |
| 73 | const fromEnv = s3FromEnv(env); | |
| 74 | if (fromEnv) return { source: "env", store: s3Store(fromEnv) }; | |
| 75 | return undefined; | |
| 76 | } | |
| 77 | ||
| 78 | /** Which of the sealed names are set, without opening any. */ | |
| 79 | export function sealedSet(db: Database): string[] { | |
| 80 | return db | |
| 81 | .query<{ name: string }, []>( | |
| 82 | `SELECT name FROM secret WHERE scope = 'forge' AND scope_id = '' | |
| 83 | AND name LIKE 'SNAPSHOT\\_S3\\_%' ESCAPE '\\'`, | |
| 84 | ) | |
| 85 | .all() | |
| 86 | .map((r) => r.name); | |
| 87 | } | |
| 88 | ||
| 89 | /** Forget the sealed destination; the environment's, if any, takes over. */ | |
| 90 | export function clearSealed(db: Database): void { | |
| 91 | for (const name of SEALED_NAMES) deleteSecret(db, "forge", "", name); | |
| 92 | } | |
| 93 | ||
| 94 | /** Does this target answer? One sentence either way, never a key in it. */ | |
| 95 | export async function testTarget(target: S3Target): Promise<string> { | |
| 96 | const store = s3Store(target); | |
| 97 | try { | |
| 98 | const keys = await store.list(""); | |
| 99 | return `Reached ${store.where}: ${keys.length === 1 ? "1 object" : `${keys.length} objects`} listed.`; | |
| 100 | } catch (error) { | |
| 101 | let reason = error instanceof Error ? error.message : String(error); | |
| 102 | for (const secret of [target.accessKeyId, target.secretAccessKey]) | |
| 103 | reason = reason.split(secret).join("…"); | |
| 104 | return `Could not list ${store.where}: ${reason.slice(0, 200)}`; | |
| 105 | } | |
| 106 | } |
src/snapshot/restore.ts +5 −4
| @@ -26,7 +26,8 @@ import { installHooks } from "../git/hooks.ts"; | ||
| 26 | 26 | import { repoDir } from "../git/repos.ts"; |
| 27 | 27 | import { keyOf, SNAPSHOT_PREFIX, type Manifest } from "./snapshot.ts"; |
| 28 | 28 | import { unwrapKey, WRAPPED_KEY } from "./wrap.ts"; |
| 29 | import { dirStore, s3FromEnv, s3Store, type Store } from "./store.ts"; | |
| 29 | import { resolveDestination } from "./destination.ts"; | |
| 30 | import type { Store } from "./store.ts"; | |
| 30 | 31 | |
| 31 | 32 | export interface Restored { |
| 32 | 33 | readonly manifest: Manifest; |
| @@ -162,9 +163,9 @@ if (import.meta.main) { | ||
| 162 | 163 | ); |
| 163 | 164 | process.exit(2); |
| 164 | 165 | } |
| 165 | const dir = process.env["FORGE_SNAPSHOT_DIR"]?.trim(); | |
| 166 | const s3 = s3FromEnv(); | |
| 167 | const store = dir ? dirStore(dir) : s3 ? s3Store(s3) : undefined; | |
| 166 | // A restore starts from an empty directory, so nothing is sealed yet: the | |
| 167 | // same resolver answers from the environment alone. | |
| 168 | const store = resolveDestination()?.store; | |
| 168 | 169 | if (store === undefined) { |
| 169 | 170 | // CLI message on stderr for the person at the terminal. |
| 170 | 171 | console.error( |
src/snapshot/service.ts +29 −5
| @@ -25,7 +25,8 @@ const log = createLog("snapshots"); | ||
| 25 | 25 | const NIGHTLY_HOUR_UTC = 8; |
| 26 | 26 | |
| 27 | 27 | export interface SnapshotsHandle { |
| 28 | readonly store: Store; | |
| 28 | /** The store in effect now; undefined when the forge has been given none. */ | |
| 29 | readonly store: Store | undefined; | |
| 29 | 30 | /** Whether snapshots carry the key, wrapped under a passphrase. */ |
| 30 | 31 | readonly wraps: boolean; |
| 31 | 32 | /** Take one now; waits for any in flight. */ |
| @@ -34,6 +35,8 @@ export interface SnapshotsHandle { | ||
| 34 | 35 | readonly list: () => Promise<Manifest[]>; |
| 35 | 36 | /** The last one taken by this process, and what it said. */ |
| 36 | 37 | readonly last: () => { manifest?: Manifest; error?: string; at?: string }; |
| 38 | /** Forget the resolved store; the next use resolves again (a new destination was saved). */ | |
| 39 | readonly forget: () => void; | |
| 37 | 40 | readonly stop: () => Promise<void>; |
| 38 | 41 | } |
| 39 | 42 | |
| @@ -50,7 +53,7 @@ export function untilNightly(now = Date.now()): number { | ||
| 50 | 53 | |
| 51 | 54 | export function startSnapshots( |
| 52 | 55 | app: App, |
| 53 | store: Store, | |
| 56 | destination: Store | (() => Store | undefined), | |
| 54 | 57 | options: { |
| 55 | 58 | readonly version: string; |
| 56 | 59 | readonly nightly?: boolean; |
| @@ -59,12 +62,25 @@ export function startSnapshots( | ||
| 59 | 62 | version: "0", |
| 60 | 63 | }, |
| 61 | 64 | ): SnapshotsHandle { |
| 65 | // Resolving opens sealed secrets, which is audited: do it once per change, | |
| 66 | // not once per page view. | |
| 67 | let resolved: { store: Store | undefined } | undefined; | |
| 68 | const current = (): Store | undefined => { | |
| 69 | if (typeof destination !== "function") return destination; | |
| 70 | resolved ??= { store: destination() }; | |
| 71 | return resolved.store; | |
| 72 | }; | |
| 62 | 73 | let stopped = false; |
| 63 | 74 | let inFlight: Promise<Manifest> | undefined; |
| 64 | 75 | let last: { manifest?: Manifest; error?: string; at?: string } = {}; |
| 65 | 76 | |
| 66 | 77 | const take = (): Promise<Manifest> => { |
| 67 | 78 | const run = (inFlight ?? Promise.resolve()).then(async () => { |
| 79 | // Resolved per run, so a destination saved on the Backups page is the | |
| 80 | // very next snapshot's. | |
| 81 | const store = current(); | |
| 82 | if (store === undefined) | |
| 83 | throw new Error("this forge has nowhere to put a snapshot"); | |
| 68 | 84 | const manifest = await takeSnapshot( |
| 69 | 85 | { |
| 70 | 86 | db: app.db, |
| @@ -104,11 +120,19 @@ export function startSnapshots( | ||
| 104 | 120 | schedule(); |
| 105 | 121 | |
| 106 | 122 | const handle: SnapshotsHandle = { |
| 107 | store, | |
| 123 | get store() { | |
| 124 | return current(); | |
| 125 | }, | |
| 108 | 126 | wraps: !!options.passphrase, |
| 109 | 127 | now: take, |
| 110 | list: () => listSnapshots(store), | |
| 128 | list: async () => { | |
| 129 | const store = current(); | |
| 130 | return store === undefined ? [] : listSnapshots(store); | |
| 131 | }, | |
| 111 | 132 | last: () => last, |
| 133 | forget: () => { | |
| 134 | resolved = undefined; | |
| 135 | }, | |
| 112 | 136 | stop: async () => { |
| 113 | 137 | stopped = true; |
| 114 | 138 | if (timer !== undefined) clearTimeout(timer); |
| @@ -118,7 +142,7 @@ export function startSnapshots( | ||
| 118 | 142 | }; |
| 119 | 143 | handles.set(app, handle); |
| 120 | 144 | log.info("snapshots on", { |
| 121 | where: store.where, | |
| 145 | where: current()?.where ?? "nowhere", | |
| 122 | 146 | keep: KEEP, |
| 123 | 147 | key: options.passphrase ? "wrapped" : "none", |
| 124 | 148 | }); |
src/web/pages/backups.ts +149 −6
| @@ -8,11 +8,18 @@ | ||
| 8 | 8 | * and a stopped forge, and a person at a terminal is the one who has both. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { | |
| 12 | forgetDestination, | |
| 13 | saveDestination, | |
| 14 | testDestination, | |
| 15 | type DestinationInput, | |
| 16 | } from "../../actions/destination.ts"; | |
| 17 | import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; | |
| 11 | 18 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 12 | 19 | import { snapshotsOf } from "../../snapshot/service.ts"; |
| 13 | 20 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 21 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 15 | import { readForm } from "../form.ts"; | |
| 22 | import { field, readForm, type Form } from "../form.ts"; | |
| 16 | 23 | import { html, when, type Html } from "../html.ts"; |
| 17 | 24 | import { flashTo, page } from "../layout.ts"; |
| 18 | 25 | import { btn, card, empty, fileSize, icon, time } from "../ui.ts"; |
| @@ -81,14 +88,15 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 81 | 88 | const denied = requireAdmin(ctx); |
| 82 | 89 | if (denied) return denied; |
| 83 | 90 | const svc = snapshotsOf(ctx.app); |
| 84 | if (svc === undefined) | |
| 91 | const form = destinationCard(ctx); | |
| 92 | if (svc === undefined || svc.store === undefined) | |
| 85 | 93 | return shell( |
| 86 | 94 | ctx, |
| 87 | empty({ | |
| 95 | html`${empty({ | |
| 88 | 96 | icon: "download", |
| 89 | 97 | title: "No snapshots are taken", |
| 90 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Restart with one set and a snapshot is taken every night at 08:00 UTC.", | |
| 91 | }), | |
| 98 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.", | |
| 99 | })}${form}`, | |
| 92 | 100 | ); |
| 93 | 101 | // The store is somewhere else; a listing that fails is a sentence on the |
| 94 | 102 | // page, never a page that fails. |
| @@ -185,10 +193,145 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` | ||
| 185 | 193 | ${restore}`, |
| 186 | 194 | }, |
| 187 | 195 | ) |
| 188 | }`, | |
| 196 | } | |
| 197 | ${form}`, | |
| 189 | 198 | ); |
| 190 | 199 | } |
| 191 | 200 | |
| 201 | const SOURCE: Record<string, string> = { | |
| 202 | sealed: "sealed in this forge (set here)", | |
| 203 | dir: "the directory named by FORGE_SNAPSHOT_DIR", | |
| 204 | env: "the host's S3_* environment variables", | |
| 205 | }; | |
| 206 | ||
| 207 | /** The destination form: which one is in effect, and the write-only way to change it. */ | |
| 208 | function destinationCard(ctx: Ctx): Html { | |
| 209 | const dest = resolveDestination({ | |
| 210 | db: ctx.app.db, | |
| 211 | dataDir: ctx.app.config.dataDir, | |
| 212 | bus: ctx.app.bus, | |
| 213 | }); | |
| 214 | const set = sealedSet(ctx.app.db); | |
| 215 | const mark = (name: string): string => | |
| 216 | set.includes(name) ? "set — leave blank to keep" : ""; | |
| 217 | const input = ( | |
| 218 | name: string, | |
| 219 | label: string, | |
| 220 | sealed: string, | |
| 221 | opts: { type?: string; placeholder?: string } = {}, | |
| 222 | ): Html => | |
| 223 | html`<label class="field" | |
| 224 | ><span>${label}</span | |
| 225 | ><input | |
| 226 | name="${name}" | |
| 227 | type="${opts.type ?? "text"}" | |
| 228 | autocomplete="off" | |
| 229 | spellcheck="false" | |
| 230 | placeholder="${mark(sealed) || opts.placeholder || ""}" | |
| 231 | /></label>`; | |
| 232 | return card( | |
| 233 | html`<p> | |
| 234 | In effect: | |
| 235 | <strong | |
| 236 | >${dest ? SOURCE[dest.source] : "nothing — no snapshots are taken"}</strong | |
| 237 | >. A destination sealed here wins over the environment; clear it and the | |
| 238 | environment's, if any, takes over. Keys are sealed and never shown | |
| 239 | again. | |
| 240 | </p> | |
| 241 | <form method="post" action="/settings/backups/destination" class="stack"> | |
| 242 | ${input("endpoint", "Endpoint", "SNAPSHOT_S3_ENDPOINT", { | |
| 243 | placeholder: "https://s3.example.com", | |
| 244 | })} | |
| 245 | ${input("bucket", "Bucket", "SNAPSHOT_S3_BUCKET")} | |
| 246 | ${input("region", "Region (optional)", "SNAPSHOT_S3_REGION", { | |
| 247 | placeholder: "auto", | |
| 248 | })} | |
| 249 | ${input("access_key", "Access key", "SNAPSHOT_S3_ACCESS_KEY")} | |
| 250 | ${input("secret_key", "Secret key", "SNAPSHOT_S3_SECRET_KEY", { | |
| 251 | type: "password", | |
| 252 | })} | |
| 253 | <div class="row-actions"> | |
| 254 | ${btn("Seal destination", { | |
| 255 | type: "submit", | |
| 256 | variant: "primary", | |
| 257 | icon: "lock", | |
| 258 | })} | |
| 259 | <button class="btn" type="submit" formaction="/settings/backups/test"> | |
| 260 | Test connection | |
| 261 | </button> | |
| 262 | <button | |
| 263 | class="btn" | |
| 264 | type="submit" | |
| 265 | formaction="/settings/backups/clear" | |
| 266 | formnovalidate | |
| 267 | > | |
| 268 | Clear sealed destination | |
| 269 | </button> | |
| 270 | </div> | |
| 271 | </form>`, | |
| 272 | { | |
| 273 | head: html`${icon("lock")} | |
| 274 | <h2>Destination</h2>`, | |
| 275 | }, | |
| 276 | ); | |
| 277 | } | |
| 278 | ||
| 279 | function destinationInput(form: Form): DestinationInput { | |
| 280 | return { | |
| 281 | endpoint: field(form, "endpoint").trim(), | |
| 282 | bucket: field(form, "bucket").trim(), | |
| 283 | region: field(form, "region").trim(), | |
| 284 | accessKey: field(form, "access_key").trim(), | |
| 285 | secretKey: field(form, "secret_key").trim(), | |
| 286 | }; | |
| 287 | } | |
| 288 | ||
| 289 | /** POST /settings/backups/destination — seal the destination. */ | |
| 290 | export async function backupsDestination(ctx: Ctx): Promise<Response> { | |
| 291 | const denied = requireAdmin(ctx); | |
| 292 | if (denied) return denied; | |
| 293 | const form = await readForm(ctx, "admin"); | |
| 294 | if (form instanceof Response) return form; | |
| 295 | const saved = saveDestination(ctx.app, ctx.auth.user, destinationInput(form)); | |
| 296 | return flashTo( | |
| 297 | ctx, | |
| 298 | "/settings/backups", | |
| 299 | saved.ok | |
| 300 | ? { message: saved.value } | |
| 301 | : { tone: "danger", message: saved.error }, | |
| 302 | ); | |
| 303 | } | |
| 304 | ||
| 305 | /** POST /settings/backups/test — list the bucket, say what came of it. */ | |
| 306 | export async function backupsTest(ctx: Ctx): Promise<Response> { | |
| 307 | const denied = requireAdmin(ctx); | |
| 308 | if (denied) return denied; | |
| 309 | const form = await readForm(ctx, "admin"); | |
| 310 | if (form instanceof Response) return form; | |
| 311 | const said = await testDestination( | |
| 312 | ctx.app, | |
| 313 | ctx.auth.user, | |
| 314 | destinationInput(form), | |
| 315 | ); | |
| 316 | const message = said.ok ? said.value : "Not found."; | |
| 317 | return flashTo(ctx, "/settings/backups", { | |
| 318 | tone: message.startsWith("Reached") ? "success" : "danger", | |
| 319 | message, | |
| 320 | }); | |
| 321 | } | |
| 322 | ||
| 323 | /** POST /settings/backups/clear — forget the sealed destination. */ | |
| 324 | export async function backupsClear(ctx: Ctx): Promise<Response> { | |
| 325 | const denied = requireAdmin(ctx); | |
| 326 | if (denied) return denied; | |
| 327 | const form = await readForm(ctx, "admin"); | |
| 328 | if (form instanceof Response) return form; | |
| 329 | forgetDestination(ctx.app, ctx.auth.user); | |
| 330 | return flashTo(ctx, "/settings/backups", { | |
| 331 | message: "The sealed destination is cleared.", | |
| 332 | }); | |
| 333 | } | |
| 334 | ||
| 192 | 335 | /** POST /settings/backups/now — take a snapshot, and say what came of it. */ |
| 193 | 336 | export async function backupsNow(ctx: Ctx): Promise<Response> { |
| 194 | 337 | const denied = requireAdmin(ctx); |
test/snapshot-destination.test.ts added +204
| @@ -0,0 +1,204 @@ | ||
| 1 | /** | |
| 2 | * The snapshot destination: sealed settings beat the environment and the | |
| 3 | * page says which is in effect; one resolver serves nightly and restore; | |
| 4 | * a failed connection test is one sentence with no key in it; writing is | |
| 5 | * for admins and the keys are never shown again. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { | |
| 14 | forgetDestination, | |
| 15 | saveDestination, | |
| 16 | testDestination, | |
| 17 | } from "../src/actions/destination.ts"; | |
| 18 | import { createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { resolveDestination } from "../src/snapshot/destination.ts"; | |
| 20 | import { startSnapshots } from "../src/snapshot/service.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const input = { | |
| 27 | endpoint: "http://127.0.0.1:1", | |
| 28 | bucket: "my-bucket", | |
| 29 | region: "", | |
| 30 | accessKey: "AKIAEXAMPLE", | |
| 31 | secretKey: "s3cr3t-value-xyz", | |
| 32 | }; | |
| 33 | ||
| 34 | describe("the snapshot destination", () => { | |
| 35 | let root = ""; | |
| 36 | let app: App; | |
| 37 | let admin: User; | |
| 38 | let plain: User; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | root = mkdtempSync(join(tmpdir(), "forge-dest-")); | |
| 42 | app = createApp({ dataDir: root, port: 0 }); | |
| 43 | const a = await createUser(app.db, { | |
| 44 | username: "alice", | |
| 45 | email: "[email protected]", | |
| 46 | password: "a-good-passphrase", | |
| 47 | adminIfFirst: true, | |
| 48 | }); | |
| 49 | if (!a.ok) throw new Error(a.error); | |
| 50 | admin = a.value; | |
| 51 | const b = await createUser(app.db, { | |
| 52 | username: "bob", | |
| 53 | email: "[email protected]", | |
| 54 | password: "another-good-passphrase", | |
| 55 | }); | |
| 56 | if (!b.ok) throw new Error(b.error); | |
| 57 | plain = b.value; | |
| 58 | }); | |
| 59 | afterAll(() => { | |
| 60 | app.close(); | |
| 61 | rmSync(root, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | const sources = () => ({ | |
| 65 | db: app.db, | |
| 66 | dataDir: root, | |
| 67 | bus: app.bus, | |
| 68 | }); | |
| 69 | const env = { | |
| 70 | S3_ENDPOINT: "https://env.example.com", | |
| 71 | S3_BUCKET: "envbucket", | |
| 72 | S3_ACCESS_KEY: "k", | |
| 73 | S3_SECRET_KEY: "s", | |
| 74 | }; | |
| 75 | ||
| 76 | test("precedence: sealed over the directory over the environment", () => { | |
| 77 | expect(resolveDestination({ ...sources(), env: {} })).toBeUndefined(); | |
| 78 | expect(resolveDestination({ ...sources(), env })?.source).toBe("env"); | |
| 79 | expect( | |
| 80 | resolveDestination({ | |
| 81 | ...sources(), | |
| 82 | env: { ...env, FORGE_SNAPSHOT_DIR: join(root, "d") }, | |
| 83 | })?.source, | |
| 84 | ).toBe("dir"); | |
| 85 | expect(saveDestination(app, admin, input).ok).toBe(true); | |
| 86 | const sealed = resolveDestination({ | |
| 87 | ...sources(), | |
| 88 | env: { ...env, FORGE_SNAPSHOT_DIR: join(root, "d") }, | |
| 89 | }); | |
| 90 | expect(sealed?.source).toBe("sealed"); | |
| 91 | expect(sealed?.store.where).toBe("127.0.0.1:1/my-bucket"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the service follows a saved or cleared destination without a restart", async () => { | |
| 95 | const svc = startSnapshots( | |
| 96 | app, | |
| 97 | () => resolveDestination({ ...sources(), env })?.store, | |
| 98 | { version: "t", nightly: false }, | |
| 99 | ); | |
| 100 | try { | |
| 101 | expect(svc.store?.where).toBe("127.0.0.1:1/my-bucket"); | |
| 102 | forgetDestination(app, admin); | |
| 103 | svc.forget(); | |
| 104 | expect(svc.store?.where).toBe("env.example.com/envbucket"); | |
| 105 | } finally { | |
| 106 | await svc.stop(); | |
| 107 | } | |
| 108 | }); | |
| 109 | ||
| 110 | test("blank fields keep what is sealed; a partial seal says what is missing", () => { | |
| 111 | const partial = saveDestination(app, admin, { | |
| 112 | ...input, | |
| 113 | accessKey: "", | |
| 114 | secretKey: "", | |
| 115 | }); | |
| 116 | expect(partial.ok && partial.value).toContain( | |
| 117 | "access key, secret key are set", | |
| 118 | ); | |
| 119 | const rest = saveDestination(app, admin, { | |
| 120 | endpoint: "", | |
| 121 | bucket: "", | |
| 122 | region: "", | |
| 123 | accessKey: input.accessKey, | |
| 124 | secretKey: input.secretKey, | |
| 125 | }); | |
| 126 | expect(rest.ok && rest.value).toContain("the next snapshot goes there"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a failed test-connection is one sentence and leaks no key", async () => { | |
| 130 | const said = await testDestination(app, admin, input); | |
| 131 | expect(said.ok).toBe(true); | |
| 132 | const sentence = said.ok ? said.value : ""; | |
| 133 | expect(sentence).toStartWith("Could not list 127.0.0.1:1/my-bucket: "); | |
| 134 | expect(sentence).not.toContain("\n"); | |
| 135 | expect(sentence).not.toContain(input.secretKey); | |
| 136 | expect(sentence).not.toContain(input.accessKey); | |
| 137 | }); | |
| 138 | ||
| 139 | test("bad input is refused and non-admins are told nothing", async () => { | |
| 140 | for (const bad of [ | |
| 141 | { ...input, endpoint: "ftp://x" }, | |
| 142 | { ...input, endpoint: "https://u:[email protected]" }, | |
| 143 | { ...input, bucket: "A_B" }, | |
| 144 | { ...input, secretKey: "has space" }, | |
| 145 | ]) { | |
| 146 | const r = saveDestination(app, admin, bad); | |
| 147 | expect(r.ok).toBe(false); | |
| 148 | } | |
| 149 | const nobody = saveDestination(app, plain, input); | |
| 150 | expect(!nobody.ok && nobody.error).toBe("not_found"); | |
| 151 | expect((await testDestination(app, plain, input)).ok).toBe(false); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the page names what is in effect and never shows a key", async () => { | |
| 155 | const servers: Servers = await serve(app); | |
| 156 | try { | |
| 157 | const signedIn = await fetch(`${servers.url}/signin`, { | |
| 158 | method: "POST", | |
| 159 | headers: { origin: servers.url }, | |
| 160 | body: new URLSearchParams({ | |
| 161 | username: "alice", | |
| 162 | password: "a-good-passphrase", | |
| 163 | }), | |
| 164 | redirect: "manual", | |
| 165 | }); | |
| 166 | const cookie = (signedIn.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 167 | const post = (path: string, body: Record<string, string>) => | |
| 168 | fetch(`${servers.url}${path}`, { | |
| 169 | method: "POST", | |
| 170 | headers: { cookie, origin: servers.url }, | |
| 171 | body: new URLSearchParams(body), | |
| 172 | redirect: "manual", | |
| 173 | }); | |
| 174 | const page = async () => | |
| 175 | ( | |
| 176 | await fetch(`${servers.url}/settings/backups`, { | |
| 177 | headers: { cookie }, | |
| 178 | }) | |
| 179 | ).text(); | |
| 180 | expect(await page()).toContain("In effect:"); | |
| 181 | const saved = await post("/settings/backups/destination", { | |
| 182 | endpoint: input.endpoint, | |
| 183 | bucket: input.bucket, | |
| 184 | access_key: input.accessKey, | |
| 185 | secret_key: input.secretKey, | |
| 186 | }); | |
| 187 | expect(saved.status).toBe(303); | |
| 188 | const html = await page(); | |
| 189 | expect(html).toContain("sealed in this forge"); | |
| 190 | expect(html).not.toContain(input.secretKey); | |
| 191 | expect(html).not.toContain(input.accessKey); | |
| 192 | const cross = await fetch(`${servers.url}/settings/backups/clear`, { | |
| 193 | method: "POST", | |
| 194 | headers: { cookie, origin: "https://evil.example" }, | |
| 195 | redirect: "manual", | |
| 196 | }); | |
| 197 | expect(cross.status).toBeGreaterThanOrEqual(400); | |
| 198 | await post("/settings/backups/clear", {}); | |
| 199 | expect(await page()).not.toContain("sealed in this forge"); | |
| 200 | } finally { | |
| 201 | servers.stop(); | |
| 202 | } | |
| 203 | }); | |
| 204 | }); |
new above
offline