diff --git a/Dockerfile b/Dockerfile index f56e4ee..acff766 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN apk add --no-cache ca-certificates tzdata wget tini git rsync openssh-client # source build uses, so the image running in production was the one NOT # checking what it executed. ARG TARGETARCH -ARG TEPLOY_VERSION=v0.1.54 +ARG TEPLOY_VERSION=v0.1.55 RUN set -eux; \ archive="teploy_linux_${TARGETARCH:-amd64}.tar.gz"; \ release_url="https://github.com/useteploy/teploy-cli/releases/download/${TEPLOY_VERSION}"; \ @@ -45,6 +45,10 @@ RUN set -eux; \ COPY --from=builder /teploy-dash /usr/local/bin/teploy-dash +# How `teploy server siblings` recognizes this container, and how Dash proves which +# server hosts it. +LABEL dev.teploy.product="dash" + EXPOSE 3456 VOLUME ["/var/teploy-dash"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index 2d64d98..fda2161 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -9,7 +9,7 @@ RUN apk add --no-cache ca-certificates tzdata wget tini git rsync openssh-client # Teploy CLI - used by dash to delegate deploy/rollback actions. BuildKit sets # TARGETARCH from the goreleaser build platform (amd64 or arm64). ARG TARGETARCH -ARG TEPLOY_VERSION=v0.1.54 +ARG TEPLOY_VERSION=v0.1.55 RUN set -eux; \ archive="teploy_linux_${TARGETARCH}.tar.gz"; \ release_url="https://github.com/useteploy/teploy-cli/releases/download/${TEPLOY_VERSION}"; \ @@ -23,6 +23,10 @@ RUN set -eux; \ COPY teploy-dash /usr/local/bin/teploy-dash +# How `teploy server siblings` recognizes this container, and how Dash proves which +# server hosts it. +LABEL dev.teploy.product="dash" + EXPOSE 3456 VOLUME ["/var/teploy-dash"] diff --git a/README.md b/README.md index 603a500..03d42d3 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,38 @@ CLI's fleet engine (v0.1.52 on the dashboard host, capability `backup-fleet-v1`; an older CLI is a note and the page stays empty — mixed fleets keep working because the engine runs on each server, not here). +**Protect my data** (teploy v0.1.55, capability `backup-setup-v1`) takes a new user to +verified, encrypted, off-site backups from this page, in six steps. The manual controls +stay under **Advanced**; both drive the same engine. + +1. **Check your servers**, before any key is asked for: reachable, teploy version, aws, + age, python3, free disk. Tools and old server binaries are only changed with consent. +2. **Storage.** Paste an expiring Backblaze key with listBuckets, writeBuckets and + writeKeys. Dash reads the capabilities and S3 endpoint from it, reuses a bucket or + creates a private `teploy-backups-`, merges its lifecycle rules, and mints + one key per server limited to the bucket and `teploy//`, without deleteFiles. + Each key must really hide a test object before it reaches a server, or setup stops: + retention cannot work without that. The pasted key lives only in that request. +3. **Recovery key.** Made in your browser and saved as a file, which you then pick again + so the browser can check it. Only the public key reaches Dash. +4. **Alerts**, ntfy or email with a test send. Skippable, and it keeps reminding you. +5. **Turn on.** Shows what each server will cover, including databases teploy does not + manage, which are backed up too unless you untick them, and storage as a multiple of + one backup. "Back up now" or "Start with the next scheduled run". +6. **Prove recovery.** Pick the key file again: Dash reads the first 128 KiB of a real + backup from the server, your browser decrypts it, and Dash records the result. + +**Protected** means a verified backup on each covered target, a restore test passed +within 21 days for each covered database, and no database left out of a backup. Volume-only +servers need no restore test. If you chose to skip a database, the page says "Protected, +except" and names it. If you untick everything on a server, Dash writes no policy or cron +line there, leaves any existing policy alone, keeps the server out of failures and alerts, +and shows it as skipped until you tick it again. A server that has a target is left alone unless you tick it, because a +push replaces its target and Dash cannot put the old key back. New, +rebuilt and retired servers each have their own short action on the page. +[docs/recovery.md](docs/recovery.md) covers getting data back; it has not been rehearsed +yet. + - **First-run checklist** — storage → alert channel → recovery-key drill → first verified backup — shown until complete, then never again. - **Connect storage once.** Presets for Backblaze B2 (recommended: application @@ -284,9 +316,10 @@ because the engine runs on each server, not here). probes write/read/delete first, then pushes `server backup-target set` to every server with the credentials on stdin, then forgets them: the record keeps the bucket name and per-server prefixes only. A B2 key that cannot delete is the - documented good setup (a compromised server cannot destroy history; the bucket's - lifecycle rules remove hidden versions after 30 days). Rotation re-prompts, pushes, verifies - each server and tells you when to revoke the old key. + documented good setup, so long as it can still hide: the probe confirms the test object + is hidden, and stops if the store refuses (a compromised server cannot destroy history; + the bucket's lifecycle rules remove hidden versions after 14 days). Rotation re-prompts, + pushes, verifies each server and tells you when to revoke the old key. - **Recovery key.** `teploy backup keygen` output shown exactly once (re-auth required; the private key is stored nowhere); the public half is pushed to every server and every object is age-encrypted to it *and* the server's own @@ -321,18 +354,22 @@ because the engine runs on each server, not here). remove* once Dash has watched two consecutive good policy backups arrive AND a restore test of the same engine kind passed. The known hand-written job names (akiroo-backup, teploy-backup.sh, teploy-wp-dbbackup, omni-backup, - teploy-nucleus-backup, dash-state-backup) are listed as the owner's checklist; - removal itself stays a human action on the server, and old dumps stay until - they age out. Dash cannot list a server's systemd/cron jobs — no CLI read for - that exists yet (recorded as a gap). -- **Dash itself** is not yet covered by the policy engine: the CLI cannot encrypt - to the owner key alone or target arbitrary paths. Until it can, the page says - so and points at [`docs/rebuild-dash.md`](docs/rebuild-dash.md) — the rebuild - runbook and the interim dash-state job (age to the owner key only). - -Every action is audited; connecting storage and the recovery key need -`administer.credentials`, the policy and excludes `execute.mutate`, and nothing -here is exposed over MCP. + teploy-nucleus-backup, dash-state-backup) are listed as the owner's checklist, + shown only when Dash finds signs of such jobs: legacy cron lines, or a policy + something else manages. Removal itself stays a human action on the server, and + old dumps stay until they age out. Dash cannot list a server's systemd/cron jobs + (no CLI read for that exists yet, recorded as a gap). +- **Dash itself** is backed up as the `dash/volumes` entry when it runs from the + deployment template. Objects encrypted to the owner key and the server open with + the owner key alone. Dash's volumes hold its SSH key, so a policy with no recovery + key excludes them on the server proved to host this Dash (`TEPLOY_APP` and the + `dev.teploy.product=dash` label, through `server siblings`). If that cannot be proved, + the page warns rather than excluding by name. Paths outside the template's volumes are + reported as uncovered. See [`docs/recovery.md`](docs/recovery.md). + +Every action is audited; connecting storage, the recovery key and every step of +"Protect my data" need `administer.credentials`, the policy and excludes +`execute.mutate`, and nothing here is exposed over MCP. ### Cleanup **Cleanup** answers "what is left over on each server?" with the CLI's read-only @@ -739,7 +776,20 @@ so the direct role claim is available here and takes precedence over groups. | GET | `/api/domains` | Domain health: one row per domain (DNS, route, certificate, HTTP, first failing layer, evidence age), per-server check state, CLI gate. `view.metadata`. | | GET | `/api/backups` | The fleet backups view: cached per-server status (coverage rows, targets, tooling, last run), storage record (never credentials), recovery-key state + drill, the applied policy, dash-owned restore tests with history, migration readiness, projected storage. `view.metadata`. | | POST | `/api/backups/refresh` | Ask for a new fleet status read now (the page follows the cache). `execute.mutate`. | -| POST | `/api/backups/storage` | Probe write/read/delete, then push `server backup-target set` to every server with the credentials on stdin and forget them. `{provider: b2\|r2\|custom, bucket, region/account_id/endpoint, base_prefix, access_key, secret_key, rotate?}`; B2 delete-refusal is accepted, others need `delete_refusal_ok`. `administer.credentials`. | +| POST | `/api/backups/storage` | Probe write/read/delete, then push `server backup-target set` to every server with the credentials on stdin and forget them. `{provider: b2\|r2\|custom, bucket, region/account_id/endpoint, base_prefix, access_key, secret_key, rotate?}`; a refused delete needs `delete_refusal_ok`, except on B2, where it connects with a warning, as does an accepted delete that leaves the object readable. `administer.credentials`. | +| POST | `/api/backups/setup/preflight` | Check servers: `{servers?, install_tools?, upgrade_binary?}`; the last two name servers whose tools or binary may be changed. `administer.credentials`, as is everything under `/api/backups/setup/`. | +| POST | `/api/backups/setup/storage/check` | Read a Backblaze key: `{key_id, application_key, bucket?}` returns its missing capabilities, expiry and buckets with their warnings. The key is forgotten with the request. | +| POST | `/api/backups/setup/storage/apply` | Set up a bucket and a key per named server: `{password, key_id, application_key, servers, bucket?, accept_warnings?, replace_configured?, rotate?, empty_base?, hide_days?, s3_managed?}`. Rerunning skips servers verified in the operation that pushed their key, and pushes a new key to the rest. Older keys go only after a push that succeeded and verified. | +| POST | `/api/backups/setup/owners` | Clear a rebuilt server's old marker: `{server, fingerprint, force?, password?}`. Forcing needs the password. | +| POST | `/api/backups/setup/leftovers/dismiss` | Dismiss a notice about a key Dash could not delete: `{key_id}`. | +| POST | `/api/backups/setup/recovery` | Record the public key the browser made: `{password, public_key}`. | +| POST | `/api/backups/setup/alerts/skip` | Skip alerts for now; the page keeps reminding. | +| POST | `/api/backups/setup/coverage` | What named servers would cover, with the storage multiplier: `{servers}`. | +| POST | `/api/backups/setup/turn-on` | Write the policy to named servers: `{password, servers, start: now\|next, skip?, interval?, preset?, skip_volumes?, volume_max_gb?}`. Unmanaged databases are adopted unless named in `skip` by server. What is left out keeps its existing value. | +| POST | `/api/backups/setup/run` | Back up now: `{servers}`, through a detached run. | +| POST | `/api/backups/setup/proof/start` | Read the first 128 KiB of the newest encrypted backup, as stored, for the browser to decrypt. | +| POST | `/api/backups/setup/proof/result` | Record the outcome: `{key, passed, detail?}`. | +| POST | `/api/backups/setup/retire` | Retire a server: `{password, key_id, application_key, server, data: keep\|remove, confirm?}`. | | POST | `/api/backups/recovery-key/generate` | `teploy backup keygen` — the private key is returned exactly once and stored nowhere. Re-auth (account password) required. `administer.credentials`. | | POST | `/api/backups/recovery-key/confirm` | "I saved it": pushes the public key as `--recipient` to every server. `administer.credentials`. | | POST | `/api/backups/recovery-key/drill` | Record the decrypt drill: `{output}` is the JSON `teploy backup fetch --identity` printed; checked against the assigned object and its plausible plaintext size. `administer.credentials`. | diff --git a/ci/contracts.pin b/ci/contracts.pin index 60e4088..5a7c931 100644 --- a/ci/contracts.pin +++ b/ci/contracts.pin @@ -3,5 +3,5 @@ # this file records WHICH revision dash CI proves it can decode. Bump both # lines together, quoting the MANIFEST's revision table, in the same change # that updates .github/workflows/ci.yml expectations. -cli_rev=59e37d6ba9fe67708f9415ba73fad3a3abf1c526 -corpus_rev=20 +cli_rev=d4c9bb3736871edcbcc936ec04a4558bfe6dfc7d +corpus_rev=21 diff --git a/ci/validate-contracts.mjs b/ci/validate-contracts.mjs index 97cbd09..1471826 100644 --- a/ci/validate-contracts.mjs +++ b/ci/validate-contracts.mjs @@ -87,6 +87,11 @@ const artifacts = { "backup-status-envelope": { schema: "backup-status-envelope.schema.json", classes: { valid: "validate" } }, "backup-run-envelope": { schema: "backup-run-envelope.schema.json", classes: { valid: "validate" } }, "backup-policy-envelope": { schema: "backup-policy-envelope.schema.json", classes: { valid: "validate" } }, + // Simple backup setup (corpus rev 21): preflight, the ranged read the browser + // proves a recovery key against, and the detached run. + "backup-preflight-envelope": { schema: "backup-preflight-envelope.schema.json", classes: { valid: "validate" } }, + "backup-head-envelope": { schema: "backup-head-envelope.schema.json", classes: { valid: "validate" } }, + "backup-detach-envelope": { schema: "backup-detach-envelope.schema.json", classes: { valid: "validate" } }, // Git-source artifacts (corpus rev 16, plan D4): the connected-source // show envelope, credential provisioning receipts and the poll results. // Dash adopts these surfaces separately (D4); classified here so the diff --git a/cmd/teploy-dash/frontend-test/age_vendor.test.mjs b/cmd/teploy-dash/frontend-test/age_vendor.test.mjs new file mode 100644 index 0000000..2e5b067 --- /dev/null +++ b/cmd/teploy-dash/frontend-test/age_vendor.test.mjs @@ -0,0 +1,73 @@ +// The vendored browser crypto, on what Dash really runs on: plain HTTP, where +// crypto.subtle is undefined. Fixtures come from the native age CLI, so this also +// proves the two agree. testdata/age/test-key.txt exists only for this test. +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixture = name => new Uint8Array(readFileSync(join(here, 'testdata/age', name))); +const keyFile = readFileSync(join(here, 'testdata/age/test-key.txt'), 'utf8'); +let age; + +before(() => { + Object.defineProperty(globalThis.crypto, 'subtle', { value: undefined, configurable: true }); + assert.equal(globalThis.crypto.subtle, undefined); + new Function(readFileSync(join(here, '../frontend/js/age.vendor.js'), 'utf8'))(); + age = globalThis.TeployAge; +}); + +test('generates a key and derives the same public half without crypto.subtle', async () => { + const { identity, publicKey } = await age.generate(); + assert.match(identity, /^AGE-SECRET-KEY-1[0-9A-Z]+$/); + assert.match(publicKey, /^age1[0-9a-z]{58}$/); + assert.equal(await age.derivePublic(identity), publicKey); +}); + +test('the file parser skips comment lines and reads the notes', () => { + const text = '# Teploy backup recovery key.\n\n# bucket: my-bucket\n# endpoint: https://s3.us-west-004.backblazeb2.com\n# base: teploy\nAGE-SECRET-KEY-1ABC\n'; + const parsed = age.parseIdentityFile(text); + assert.equal(parsed.identity, 'AGE-SECRET-KEY-1ABC'); + assert.equal(parsed.notes.bucket, 'my-bucket'); + assert.equal(parsed.notes.base, 'teploy'); + assert.equal(age.parseIdentityFile('\r\n# c\r\nAGE-SECRET-KEY-1ABC\r\n').identity, 'AGE-SECRET-KEY-1ABC'); +}); + +test('the file parser refuses what is not one key', () => { + assert.throws(() => age.parseIdentityFile('# only comments\n'), /no recovery key/); + assert.throws(() => age.parseIdentityFile('AGE-SECRET-KEY-1ABC\nAGE-SECRET-KEY-1DEF\n'), /more than one/); + assert.throws(() => age.parseIdentityFile('hello\n'), /does not look like/); +}); + +test('reads a real age-keygen file and matches its public key', async () => { + const { identity } = age.parseIdentityFile(keyFile); + const expected = /public key: (age1[0-9a-z]+)/.exec(keyFile)[1]; + assert.equal(await age.derivePublic(identity), expected); +}); + +test('decrypts a whole file from the native age CLI', async () => { + const { identity } = age.parseIdentityFile(keyFile); + const out = await age.decryptStart(identity, fixture('whole.age')); + assert.equal(out.length, 'hello backups\n'.length); + assert.equal(out.truncated, false); +}); + +test('a truncated prefix gives its first chunk: 65536 bytes is the proof', async () => { + const { identity } = age.parseIdentityFile(keyFile); + const out = await age.decryptStart(identity, fixture('prefix-70k.age')); + assert.equal(out.length, 65536); + assert.equal(out.truncated, true); +}); + +test('a wrong key is refused before any byte comes out', async () => { + const { identity } = await age.generate(); + await assert.rejects(() => age.decryptStart(identity, fixture('prefix-70k.age'))); + await assert.rejects(() => age.decryptStart(identity, fixture('whole.age'))); +}); + +test('a prefix cut before the first chunk ends proves nothing', async () => { + const { identity } = age.parseIdentityFile(keyFile); + await assert.rejects(() => age.decryptStart(identity, fixture('prefix-70k.age').slice(0, 1000))); +}); diff --git a/cmd/teploy-dash/frontend-test/backup_setup.test.mjs b/cmd/teploy-dash/frontend-test/backup_setup.test.mjs new file mode 100644 index 0000000..7cf5382 --- /dev/null +++ b/cmd/teploy-dash/frontend-test/backup_setup.test.mjs @@ -0,0 +1,489 @@ +// "Protect my data" in the browser: against a stubbed API and the real vendored crypto. +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { harness, response } from './identity_harness.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const keyFile = readFileSync(join(here, 'testdata/age/test-key.txt'), 'utf8'); +const prefix = readFileSync(join(here, 'testdata/age/prefix-70k.age')).toString('base64'); + +before(() => { + Object.defineProperty(globalThis.crypto, 'subtle', { value: undefined, configurable: true }); + new Function(readFileSync(join(here, '../frontend/js/age.vendor.js'), 'utf8'))(); +}); + +const baseView = (setup = {}, extra = {}) => ({ + cli_supported: true, recovery: {}, servers: [], rows: [], tests: [], intervals: ['24h'], presets: ['standard'], + self_backup: { available: false, reason: 'x', runbook: 'docs/recovery.md' }, + setup: { supported: true, state: 'none', headline: 'Your data is not protected yet.', servers: [], recovery_doc: 'https://example/recovery.md', ...setup }, + ...extra, +}); + +function setupHarness(routes, setup = {}, extra = {}) { + const h = harness('/backups'); + h.respond(url => { + if (url === '/api/backups') return response(baseView(setup, extra)); + for (const [path, fn] of Object.entries(routes)) { + if (url === path) return fn(); + } + return response({}, { status: 404 }); + }); + return h; +} + +const preflight = () => response({ servers: [ + { server: 'a', reachable: true, selected: true, binary_version: '0.1.55', free_bytes: 1 << 30, target_state: 'absent', tools: {} }, + { server: 'b', reachable: true, selected: false, target_state: 'configured', target_bucket: 'old', tools: {} }, + { server: 'c', reachable: true, selected: true, needs_tools: ['aws'], needs_upgrade: true, target_state: 'absent', tools: {} }, + { server: 'd', reachable: false, blocked: 'Dash could not reach this server', selected: false, tools: {} }, +] }); + +test('the page offers the flow only on a CLI that supports it, and keeps the manual form', async () => { + const h = setupHarness({}); + const p = h.page('backupsPage'); + await p.init(); + assert.equal(p.setupSupported(), true); + const html = h.readHTML(); + assert.ok(html.includes('Advanced')); + assert.ok(html.includes(`:open="!setupSupported()"`), 'an older CLI still gets its manual form open'); + assert.ok(html.includes('@submit.prevent="connectStorage()"'), 'the manual storage form is still there'); + assert.ok(html.includes('@submit.prevent="applyPolicy()"'), 'the manual policy form is still there'); + p.destroy(); +}); + +test('preflight ticks what is safe: a server with a target and a blocked one start unticked', async () => { + const h = setupHarness({ '/api/backups/setup/preflight': preflight }); + const p = h.page('backupsPage'); + await p.init(); + await p.openSetup(); + assert.equal(p.setupStep, 'check'); + assert.deepEqual(p.selectedServers(), ['a', 'c']); + assert.equal(p.blockingNeeds().length, 2); + // A person's choice survives the next check, and a blocked server stays unticked. + p.setupTicked.c = false; + p.setupTicked.b = true; + await p.runPreflight(); + assert.deepEqual(p.selectedServers(), ['a', 'b']); + assert.equal(p.blockingNeeds().length, 0); + p.destroy(); +}); + +test('tools install and upgrades go only for what was ticked', async () => { + const h = setupHarness({ '/api/backups/setup/preflight': preflight }); + const p = h.page('backupsPage'); + await p.init(); + await p.openSetup(); + assert.equal(p.anyPrepareTicked(), false); + p.setupInstall.c = true; + assert.equal(p.anyPrepareTicked(), true); + await p.prepareServers(); + const sent = h.requests.filter(r => r.url === '/api/backups/setup/preflight').at(-1).body; + assert.deepEqual(sent.install_tools, ['c']); + assert.deepEqual(sent.upgrade_binary, []); + p.destroy(); +}); + +test('one new server is the same flow for that server', async () => { + const h = setupHarness({ '/api/backups/setup/preflight': () => response({ servers: [{ server: 'compute-3', reachable: true, selected: false, tools: {} }] }) }); + const p = h.page('backupsPage'); + await p.init(); + await p.openSetup('compute-3'); + assert.deepEqual(h.requests.filter(r => r.url === '/api/backups/setup/preflight').at(-1).body.servers, ['compute-3']); + assert.deepEqual(p.selectedServers(), ['compute-3']); + p.destroy(); +}); + +test('the key check decides what can go on', async () => { + let check = { missing: ['writeKeys'], buckets: [], can_create_bucket: true }; + const h = setupHarness({ '/api/backups/setup/storage/check': () => response(check) }); + const p = h.page('backupsPage'); + await p.init(); + p.setupKey = { id: ' KEY ', secret: ' SECRET ' }; + await p.checkKey(); + const sent = h.requests.filter(r => r.url === '/api/backups/setup/storage/check').at(-1).body; + assert.equal(sent.key_id, 'KEY'); + assert.equal(p.canApplyStorage(), false, 'missing capabilities stop it'); + check = { missing: [], buckets: [{ name: 'x', refusals: ['no'], warnings: [] }, { name: 'y', refusals: [], warnings: ['careful'] }], can_create_bucket: true }; + await p.checkKey(); + assert.equal(p.canApplyStorage(), true, 'a new bucket is fine'); + p.setupBucket = 'x'; + assert.equal(p.canApplyStorage(), false, 'a refused bucket is not'); + p.setupBucket = 'y'; + assert.equal(p.canApplyStorage(), false, 'a warning needs reading'); + p.setupAccept = true; + assert.equal(p.canApplyStorage(), true); + check = { missing: [], capped: true, listing_error: 'daily transaction cap', buckets: [], can_create_bucket: true }; + await p.checkKey(); + assert.equal(p.canApplyStorage(), false, 'a capped listing shows why and stops'); + p.destroy(); +}); + +test('a key limited to buckets starts on its first bucket', async () => { + const h = setupHarness({ '/api/backups/setup/storage/check': () => response({ missing: [], buckets: [{ name: 'only', refusals: [], warnings: [] }], can_create_bucket: false }) }); + const p = h.page('backupsPage'); + await p.init(); + p.setupKey = { id: 'K', secret: 'S' }; + await p.checkKey(); + assert.equal(p.setupBucket, 'only'); + p.destroy(); +}); + +test('applying sends the ticked servers, and a finished run forgets the key', async () => { + let reply = { all_ok: false, servers: [{ server: 'a', verified: false, key_kept: true, error: 'timeout' }], key_note: 'n', warnings: [] }; + const h = setupHarness({ + '/api/backups/setup/preflight': preflight, + '/api/backups/setup/storage/check': () => response({ missing: [], buckets: [], can_create_bucket: true }), + '/api/backups/setup/storage/apply': () => response(reply), + }); + const p = h.page('backupsPage'); + await p.init(); + await p.openSetup(); + p.setupTicked = { a: true }; + p.setupKey = { id: 'KEYID', secret: 'APPKEY' }; + p.setupPassword = 'pw'; + await p.checkKey(); + await p.applyStorage(); + const sent = h.requests.filter(r => r.url === '/api/backups/setup/storage/apply').at(-1).body; + assert.deepEqual(sent.servers, ['a']); + assert.equal(sent.application_key, 'APPKEY'); + assert.equal(p.setupKey.secret, 'APPKEY', 'a partial run keeps it in memory to retry'); + assert.match(p.storageResultLine(reply.servers[0]), /kept every key/); + assert.equal(p.setupPassword, ''); + reply = { all_ok: true, servers: [{ server: 'a', verified: true }], key_note: 'n', warnings: [] }; + p.setupPassword = 'pw'; + await p.applyStorage(); + assert.equal(p.setupKey.secret, ''); + p.setupKey = { id: 'A', secret: 'B' }; + p.closeSetup(); + assert.equal(p.setupKey.secret, '', 'closing forgets it'); + p.destroy(); +}); + +test('the recovery key: only its public half reaches Dash, after the saved file is checked', async () => { + const h = setupHarness({ '/api/backups/setup/recovery': () => response({}) }); + const p = h.page('backupsPage'); + await p.init(); + p.setupStorage = { bucket: 'my-bucket', endpoint: 'https://s3.us-west-004.backblazeb2.com', base: 'teploy' }; + await p.generateRecovery(); + assert.ok(p.recoveryGen.fileText.includes('# bucket: my-bucket')); + assert.ok(p.recoveryGen.fileText.includes('# endpoint: https://s3.us-west-004.backblazeb2.com')); + assert.ok(p.recoveryGen.fileText.includes('# base: teploy')); + + await p.saveRecovery(); + assert.equal(h.requests.filter(r => r.url === '/api/backups/setup/recovery').length, 0, 'not before the file is checked'); + + await p.checkRecoveryFile(keyFile); + assert.equal(p.recoveryProven, false); + assert.match(p.recoveryError, /different key/); + + const publicKey = p.recoveryGen.publicKey; + await p.checkRecoveryFile(p.recoveryGen.fileText); + assert.equal(p.recoveryProven, true); + await p.saveRecovery(); + const sent = h.requests.filter(r => r.url === '/api/backups/setup/recovery').at(-1).body; + assert.equal(sent.public_key, publicKey); + assert.equal(p.recoveryGen, null); + for (const r of h.requests) assert.ok(!JSON.stringify(r.body || {}).includes('AGE-SECRET-KEY'), `${r.url} carried the private key`); + p.destroy(); +}); + +test('the key is a file, because checking and proving only take a file', () => { + const h = setupHarness({}); + const html = h.readHTML(); + assert.ok(html.includes('Download the key file')); + assert.ok(!html.includes('copyRecovery')); + assert.ok(!html.includes('password manager')); +}); + +test('closing with an unsaved key warns that its file is no use', async () => { + const h = setupHarness({}); + const p = h.page('backupsPage'); + await p.init(); + p.setupOpen = true; + p.recoveryGen = { publicKey: 'age1x', fileText: 'x', fileName: 'y' }; + const seen = []; + globalThis.confirm = text => { seen.push(text); return false; }; + p.closeSetup(); + assert.equal(p.setupOpen, true); + assert.match(seen[0], /delete it/); + globalThis.confirm = () => true; + p.closeSetup(); + assert.equal(p.setupOpen, false); + assert.equal(p.recoveryGen, null); + delete globalThis.confirm; + p.destroy(); +}); + +test('M11: the suggested bucket has its warnings and its confirm box', async () => { + const h = setupHarness({ '/api/backups/setup/storage/check': () => response({ missing: [], suggested_bucket: 'mine', can_create_bucket: true, buckets: [{ name: 'mine', refusals: [], warnings: ['careful'] }] }) }); + const p = h.page('backupsPage'); + await p.init(); + p.setupKey = { id: 'K', secret: 'S' }; + await p.checkKey(); + assert.equal(p.setupBucket, ''); + assert.equal(p.chosenBucket().name, 'mine'); + assert.equal(p.canApplyStorage(), false); + p.setupAccept = true; + assert.equal(p.canApplyStorage(), true); + p.destroy(); +}); + +test('M9: replacing a server key needs an explicit yes', async () => { + const h = setupHarness({ '/api/backups/setup/preflight': preflight, '/api/backups/setup/storage/check': () => response({ missing: [], buckets: [], can_create_bucket: true }) }); + const p = h.page('backupsPage'); + await p.init(); + await p.openSetup(); + p.setupTicked = { a: true, b: true }; + p.setupKey = { id: 'K', secret: 'S' }; + await p.checkKey(); + assert.deepEqual(p.replaceConsentNeeded(), ['b']); + assert.equal(p.canApplyStorage(), false); + p.setupReplaceOK = true; + assert.equal(p.canApplyStorage(), true); + assert.ok(h.readHTML().includes('Dash cannot put the old one back')); + p.destroy(); +}); + +test('H3: the way to a key is plain steps, and a key missing permissions gets two ways forward', () => { + const html = harness('/backups').readHTML(); + assert.ok(!html.includes('b2 key create'), 'not only the command line'); + assert.ok(html.includes('Add a New Application Key')); + assert.ok(html.includes('Master Application Key')); + assert.ok(html.includes('Dash cannot delete it')); + assert.ok(html.includes('that would break anything else using it')); + assert.ok(html.includes('Set up by hand')); +}); + +test('re-auth follows how this viewer signed in', async () => { + for (const [mode, want] of [['password', 'password'], ['local_account', 'local_account'], [undefined, 'none']]) { + const h = setupHarness({}, { reauth: mode }); + const p = h.page('backupsPage'); + await p.init(); + assert.equal(p.reauthMode(), want); + p.destroy(); + } + assert.ok(harness('/backups').readHTML().includes('sign in with a local admin account')); +}); + +test('a leftover notice can be dismissed', async () => { + const h = setupHarness({ '/api/backups/setup/leftovers/dismiss': () => response({ ok: true }) }); + const p = h.page('backupsPage'); + await p.init(); + await p.dismissLeftover('K1'); + assert.deepEqual(h.requests.filter(r => r.url === '/api/backups/setup/leftovers/dismiss').at(-1).body, { key_id: 'K1' }); + p.destroy(); +}); + +test('M5: Continue goes to the proof only when nothing is waiting to be set up', async () => { + const h = setupHarness({ '/api/backups/setup/preflight': preflight }, { turned_on: true, servers: [{ server: 'a', state: 'not_protected' }] }); + const p = h.page('backupsPage'); + await p.init(); + await p.openSetup(); + assert.equal(p.setupStep, 'check'); + p.destroy(); + const h2 = setupHarness({}, { turned_on: true, servers: [{ server: 'a', state: 'waiting_first_backup' }] }); + const p2 = h2.page('backupsPage'); + await p2.init(); + await p2.openSetup(); + assert.equal(p2.setupStep, 'prove'); + p2.destroy(); +}); + +test('M10: a key that worked but could not be recorded is not a key that failed', async () => { + let fail = true; + const h = setupHarness({ + '/api/backups/setup/proof/start': () => response({ server: 'a', key: 's3://b/k.age', length: 71680, data_base64: prefix }), + '/api/backups/setup/proof/result': () => (fail ? response({ error: 'down' }, { raw: true, status: 502 }) : response({ recorded: true })), + }); + const p = h.page('backupsPage'); + await p.init(); + await p.proveWith(keyFile); + assert.deepEqual(p.proofUnrecorded, { key: 's3://b/k.age' }); + assert.equal(p.proofState, null); + assert.ok(!/did not open your backup/.test(p.setupError)); + assert.equal(h.requests.filter(r => r.url === '/api/backups/setup/proof/result').every(r => r.body.passed), true, 'never recorded as a failure'); + fail = false; + await p.retryProofRecord(); + assert.equal(p.proofUnrecorded, null); + assert.deepEqual(p.proofState, { passed: true, key: 's3://b/k.age' }); + p.destroy(); +}); + +test('a bad file and an unreadable backup are not blamed on the key', async () => { + const h = setupHarness({ '/api/backups/setup/proof/start': () => response({ error: 'server down' }, { raw: true, status: 502 }) }); + const p = h.page('backupsPage'); + await p.init(); + await p.proveWith('hello'); + assert.match(p.setupError, /does not look like/); + await p.proveWith(keyFile); + assert.match(p.setupError, /server down/); + p.destroy(); +}); + +test('proving recovery decrypts the real prefix and records a pass', async () => { + const h = setupHarness({ + '/api/backups/setup/proof/start': () => response({ server: 'a', key: 's3://b/k.age', length: 71680, data_base64: prefix }), + '/api/backups/setup/proof/result': () => response({ recorded: true }), + }); + const p = h.page('backupsPage'); + await p.init(); + await p.proveWith(keyFile); + assert.deepEqual(p.proofState, { passed: true, key: 's3://b/k.age' }); + const sent = h.requests.filter(r => r.url === '/api/backups/setup/proof/result').at(-1).body; + assert.deepEqual(sent, { key: 's3://b/k.age', passed: true }); + for (const r of h.requests) assert.ok(!JSON.stringify(r.body || {}).includes('AGE-SECRET-KEY')); + p.destroy(); +}); + +test('proving with the wrong key records a failure and says what it means', async () => { + const h = setupHarness({ + '/api/backups/setup/proof/start': () => response({ server: 'a', key: 's3://b/k.age', length: 71680, data_base64: prefix }), + '/api/backups/setup/proof/result': () => response({ recorded: true }), + }); + const p = h.page('backupsPage'); + await p.init(); + const other = await globalThis.TeployAge.generate(); + await p.proveWith(`# a comment\n${other.identity}\n`); + assert.equal(p.proofState, null); + assert.match(p.setupError, /did not open your backup/); + assert.equal(h.requests.filter(r => r.url === '/api/backups/setup/proof/result').at(-1).body.passed, false); + p.destroy(); +}); + +test('alerts use the settings channel API with a test send, and can be skipped', async () => { + const seen = []; + const h = harness('/backups'); + h.respond((url, options) => { + seen.push([options.method, url]); + if (url === '/api/backups') return response(baseView()); + if (url.endsWith('/test')) return response({ ok: true }); + return response({}); + }); + const p = h.page('backupsPage'); + await p.init(); + p.alertForm.url = 'https://ntfy.sh/t'; + await p.saveAlert(); + assert.deepEqual(p.alertTest, { ok: true }); + assert.ok(seen.some(([m, u]) => m === 'PUT' && u.startsWith('/api/notifications/channels/ntfy-'))); + assert.equal(h.requests.find(r => r.method === 'PUT').body.url, 'https://ntfy.sh/t'); + await p.skipAlerts(); + assert.ok(seen.some(([, u]) => u === '/api/backups/setup/alerts/skip')); + p.destroy(); +}); + +test('turning on names the ticked servers and the start choice, and unticking skips', async () => { + const h = setupHarness({ + '/api/backups/setup/coverage': () => response({ servers: [{ server: 'a', items: [{ name: 'blog/volumes', kind: 'volumes', app: 'blog', size_bytes: 2048 }], unmanaged: [{ name: 'pg' }, { name: 'scratch' }], not_coverable: [] }], multiplier: 39, interval: '24h', preset: 'standard', volumes: false }), + '/api/backups/setup/turn-on': () => response({ all_ok: true, servers: [{ server: 'a', ok: true, run: { status: 'started' } }] }), + }); + const p = h.page('backupsPage'); + await p.init(); + p.setupTicked = { a: true }; + await p.loadCoverage(); + assert.equal(p.setupCoverage.multiplier, 39); + assert.equal(p.setupPolicy.skip_volumes, true, 'starts from what is saved'); + assert.equal(p.isSkipped('a', 'pg'), false, 'ticked by default'); + assert.match(p.coverageLine(p.setupCoverage.servers[0].items[0]), /blog files, 2\.0 KB/); + p.setSkipped('a', 'scratch', true); + await p.turnOn('now'); + const sent = h.requests.filter(r => r.url === '/api/backups/setup/turn-on').at(-1).body; + assert.deepEqual(sent.servers, ['a']); + assert.equal(sent.start, 'now'); + assert.deepEqual(sent.skip, { a: ['scratch'] }); + assert.equal(sent.interval, '24h'); + assert.match(p.turnOnLine(p.turnOnResult.servers[0]), /backing up now/); + p.destroy(); +}); + +test('retiring needs an explicit choice', async () => { + const h = setupHarness({ '/api/backups/setup/retire': () => response({ notes: [] }) }); + const p = h.page('backupsPage'); + await p.init(); + p.beginRetire('old'); + await p.submitRetire(); + assert.match(p.setupError, /keep or remove/); + assert.equal(h.requests.filter(r => r.url === '/api/backups/setup/retire').length, 0); + p.retireData = 'keep'; + p.setupKey = { id: 'K', secret: 'S' }; + await p.submitRetire(); + assert.equal(h.requests.filter(r => r.url === '/api/backups/setup/retire').at(-1).body.data, 'keep'); + assert.equal(p.retireFor, null); + assert.equal(p.setupKey.secret, ''); + p.destroy(); +}); + +test('plain HTTP outside localhost and a tailnet warns before a key is pasted', () => { + const h = setupHarness({}); + const p = h.page('backupsPage'); + const cases = [['http:', 'dash.example.com', true], ['https:', 'dash.example.com', false], ['http:', 'localhost', false], + ['http:', '127.0.0.1', false], ['http:', '100.108.1.2', false], ['http:', 'infra.tail1234.ts.net', false], ['http:', '100.200.1.2', true], ['http:', '192.168.1.5', true]]; + for (const [protocol, hostname, want] of cases) { + globalThis.location = { pathname: '/backups', search: '', protocol, hostname }; + assert.equal(p.insecurePage(), want, `${protocol}//${hostname}`); + } +}); + +test('states read in plain words', () => { + const h = setupHarness({}); + const p = h.page('backupsPage'); + assert.equal(p.setupStateClass('protected'), 'green'); + assert.equal(p.setupStateClass('failing'), 'red'); + assert.equal(p.setupStateLabel('capped'), 'daily cap reached'); + assert.equal(p.setupStateLabel('restore_test_overdue'), 'restore test overdue'); + assert.equal(p.setupStateClass('not_covered'), 'red'); + assert.equal(p.setupStateClass('all_skipped'), 'gray'); + assert.match(p.turnOnLine({ server: 'a', skipped: true }), /skipped, as you chose/); + assert.equal(p.canBackUpNow({ state: 'late' }), true); + assert.equal(p.canBackUpNow({ state: 'protected' }), false); +}); + +test('the hand-written-jobs list shows only when some are found', async () => { + const h = setupHarness({}); + const p = h.page('backupsPage'); + await p.init(); + assert.equal(p.hasHandwritten(), false); + p.view.handwritten = [{ name: 'omni-backup', where: 'cron' }]; + assert.equal(p.hasHandwritten(), true); + assert.ok(h.readHTML().includes('x-show="hasHandwritten()"')); + p.destroy(); +}); + +test('the home page says so until protected, and a failed read says nothing', async () => { + const h = harness('/'); + let view = baseView(); + h.respond(() => response(view)); + const banner = h.components.protectBanner(); + await banner.init(); + assert.equal(banner.visible(), true); + view = baseView({ state: 'protected' }); + await banner.init(); + assert.equal(banner.visible(), false); + view = baseView({ supported: false }); + await banner.init(); + assert.equal(banner.visible(), false); + h.respond(() => response({}, { status: 500 })); + await banner.init(); + assert.equal(banner.visible(), false); + assert.ok(h.readHTML().includes('x-data="protectBanner"')); +}); + +test('no emoji in the page, and the layout holds at phone width', () => { + const root = join(here, '../frontend'); + const emoji = /\p{Extended_Pictographic}/u; + const js = readFileSync(join(root, 'js/app.js'), 'utf8'); + const start = js.indexOf('function backupSetupComponent'); + const end = js.indexOf('// ── Backups Page (plan P2)'); + assert.ok(start > 0 && end > start); + assert.equal(emoji.test(js.slice(start, end)), false); + const html = readFileSync(join(root, 'index.html'), 'utf8'); + const section = html.slice(html.indexOf(' + + -