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

Merged opened this in answer to

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

Merged

scout/forge:scout/338-the-backups-page-takes-the-snapshot-dest → main 2 commits fork

  • Merged. scout/338-the-backups-page-takes-the-snapshot-dest was merged into main as d0b8075.

Review

Sign in to review this change.
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.
5757 in the pod (`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
5858 `S3_REGION`). The platform's own lanes mirror the bucket off the machine.
5959 - 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.
6067 - Given neither, no snapshots are taken, and the Backups page says so
6168 instead of showing an empty list that looks like a clean bill.
6269
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";
4646 import { activityAtom } from "../web/feeds.ts";
4747 import { auditCsv, auditPage, auditProbeSubmit } from "../web/pages/audit.ts";
4848 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";
5056 import {
5157 subscriptionAdd,
5258 subscriptionDelete,
@@ -479,6 +485,9 @@ export function buildRouter(): Router<Handler> {
479485 router.add("POST", "/settings/secrets/delete", secretDelete);
480486 router.add("GET", "/settings/backups", backupsPage);
481487 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);
482491 router.add("GET", "/settings/crew", crewPage);
483492 router.add("POST", "/settings/crew", agentCreate);
484493 router.add("GET", "/settings/crew/:username", agentPage);
src/main.ts +18 −20
@@ -18,7 +18,7 @@ import { startChecks } from "./checks/service.ts";
1818 import { startLooks } from "./looks/service.ts";
1919 import { startProbes } from "./contract/probes.ts";
2020 import { startSnapshots } from "./snapshot/service.ts";
21 import { dirStore, s3FromEnv, s3Store } from "./snapshot/store.ts";
21 import { resolveDestination } from "./snapshot/destination.ts";
2222 import { CHECK_KINDS, failAbandonedRuns } from "./core/runs.ts";
2323 import { health } from "./http/health.ts";
2424 import { STALE_AFTER_MS } from "./runs/lease.ts";
@@ -159,26 +159,24 @@ const looks = appsOn ? startLooks(app, { localUrl: servers.url }) : undefined;
159159 // Is it so: the forge knocks on every door of an app with a contract after
160160 // each production deploy and once a night, and compares with the table.
161161 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.
165165 const VERSION =
166166 ((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 );
182180 const crew = appsOn
183181 ? await startCrew(app, {
184182 forgeUrl,
@@ -294,7 +292,7 @@ function shutdown(): void {
294292 checks?.stop(),
295293 looks?.stop(),
296294 probes?.stop(),
297 snapshots?.stop(),
295 snapshots.stop(),
298296 ])
299297 .catch(() => {})
300298 .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";
2626 import { repoDir } from "../git/repos.ts";
2727 import { keyOf, SNAPSHOT_PREFIX, type Manifest } from "./snapshot.ts";
2828 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";
3031
3132 export interface Restored {
3233 readonly manifest: Manifest;
@@ -162,9 +163,9 @@ if (import.meta.main) {
162163 );
163164 process.exit(2);
164165 }
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;
168169 if (store === undefined) {
169170 // CLI message on stderr for the person at the terminal.
170171 console.error(
src/snapshot/service.ts +29 −5
@@ -25,7 +25,8 @@ const log = createLog("snapshots");
2525 const NIGHTLY_HOUR_UTC = 8;
2626
2727 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;
2930 /** Whether snapshots carry the key, wrapped under a passphrase. */
3031 readonly wraps: boolean;
3132 /** Take one now; waits for any in flight. */
@@ -34,6 +35,8 @@ export interface SnapshotsHandle {
3435 readonly list: () => Promise<Manifest[]>;
3536 /** The last one taken by this process, and what it said. */
3637 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;
3740 readonly stop: () => Promise<void>;
3841 }
3942
@@ -50,7 +53,7 @@ export function untilNightly(now = Date.now()): number {
5053
5154 export function startSnapshots(
5255 app: App,
53 store: Store,
56 destination: Store | (() => Store | undefined),
5457 options: {
5558 readonly version: string;
5659 readonly nightly?: boolean;
@@ -59,12 +62,25 @@ export function startSnapshots(
5962 version: "0",
6063 },
6164 ): 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 };
6273 let stopped = false;
6374 let inFlight: Promise<Manifest> | undefined;
6475 let last: { manifest?: Manifest; error?: string; at?: string } = {};
6576
6677 const take = (): Promise<Manifest> => {
6778 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");
6884 const manifest = await takeSnapshot(
6985 {
7086 db: app.db,
@@ -104,11 +120,19 @@ export function startSnapshots(
104120 schedule();
105121
106122 const handle: SnapshotsHandle = {
107 store,
123 get store() {
124 return current();
125 },
108126 wraps: !!options.passphrase,
109127 now: take,
110 list: () => listSnapshots(store),
128 list: async () => {
129 const store = current();
130 return store === undefined ? [] : listSnapshots(store);
131 },
111132 last: () => last,
133 forget: () => {
134 resolved = undefined;
135 },
112136 stop: async () => {
113137 stopped = true;
114138 if (timer !== undefined) clearTimeout(timer);
@@ -118,7 +142,7 @@ export function startSnapshots(
118142 };
119143 handles.set(app, handle);
120144 log.info("snapshots on", {
121 where: store.where,
145 where: current()?.where ?? "nowhere",
122146 keep: KEEP,
123147 key: options.passphrase ? "wrapped" : "none",
124148 });
src/web/pages/backups.ts +149 −6
@@ -8,11 +8,18 @@
88 * and a stopped forge, and a person at a terminal is the one who has both.
99 */
1010
11 import {
12 forgetDestination,
13 saveDestination,
14 testDestination,
15 type DestinationInput,
16 } from "../../actions/destination.ts";
17 import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
1118 import { keyOf } from "../../snapshot/snapshot.ts";
1219 import { snapshotsOf } from "../../snapshot/service.ts";
1320 import type { Ctx } from "../../http/context.ts";
1421 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
15 import { readForm } from "../form.ts";
22 import { field, readForm, type Form } from "../form.ts";
1623 import { html, when, type Html } from "../html.ts";
1724 import { flashTo, page } from "../layout.ts";
1825 import { btn, card, empty, fileSize, icon, time } from "../ui.ts";
@@ -81,14 +88,15 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
8188 const denied = requireAdmin(ctx);
8289 if (denied) return denied;
8390 const svc = snapshotsOf(ctx.app);
84 if (svc === undefined)
91 const form = destinationCard(ctx);
92 if (svc === undefined || svc.store === undefined)
8593 return shell(
8694 ctx,
87 empty({
95 html`${empty({
8896 icon: "download",
8997 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}`,
92100 );
93101 // The store is somewhere else; a listing that fails is a sentence on the
94102 // page, never a page that fails.
@@ -185,10 +193,145 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
185193 ${restore}`,
186194 },
187195 )
188 }`,
196 }
197 ${form}`,
189198 );
190199 }
191200
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
192335 /** POST /settings/backups/now — take a snapshot, and say what came of it. */
193336 export async function backupsNow(ctx: Ctx): Promise<Response> {
194337 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs