diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index a4b31d1d..bc07ca35 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -3,6 +3,7 @@ import mimetypes import os import re +import uuid from aiohttp import web @@ -214,6 +215,45 @@ async def handle_control_upgrade(request): return web.json_response({"id": rid, "status": "pending"}, status=202) +async def handle_control_backup(request): + """Ask the host runner for an encrypted backup archive + a one-time emergency kit (#908). + + No body: unlike commit/upgrade this verb takes no operator input — the host generates its own + passphrase and never accepts one from the container (encrypted-only, refused otherwise). The + archive/openssl work and the brief stack stop+restart stack_backup performs can take a while, + so — like upgrade — this returns 202 immediately and the client polls /api/control/result.""" + _require_control_header(request) + try: + rid = control_service.submit("backup", actor=request.headers.get("X-Auth-User", "")) + except Exception: + logger.exception("Error submitting backup request") + return web.json_response({"error": "Failed to submit the backup request."}, status=500) + return web.json_response({"id": rid, "status": "pending"}, status=202) + + +async def handle_backup_download(request): + """Stream the archive an applied backup produced (#908). + + Read-only, no CSRF header required (matches the other GET routes) — a cross-site GET can + trigger this but can't read a cross-origin response, and the archive is useless without the + passphrase shown once in the kit. The id must resolve to an "applied" result naming an + archive; anything else 404s rather than hinting whether some OTHER id exists.""" + try: + rid = str(uuid.UUID(request.query.get("id", ""))) + except ValueError: + raise web.HTTPBadRequest(text="'id' must be a UUID.") from None + res = control_service.result(rid) + archive_name = (res or {}).get("archive") + if not res or res.get("status") != "applied" or not archive_name: + raise web.HTTPNotFound(text="No completed backup for that id.") + # FileResponse stats the path itself and answers 404 if the archive isn't there — no need to + # check twice. + path = os.path.join(config.CONTROL_RESULTS_DIR, f"{rid}.tar.gz.enc") + return web.FileResponse( + path, headers={"Content-Disposition": f'attachment; filename="{archive_name}"'} + ) + + def _record_worker_result(state_mgr, worker, changes, res): """Log a worker-apply outcome to the per-worker config history (#185). Only terminal-ish statuses are kept; ``changes`` carries no secret (the rig token stays host-side).""" @@ -489,6 +529,10 @@ def create_app(state_manager, latest_data_ref): # One-click rig upgrade (#597): spools name + confirmed version only; the host # re-derives the real target and dials the rig. Same gate as the rest. web.post("/api/control/worker-upgrade", handle_worker_upgrade), + # Encrypted backup + one-time emergency kit (#908): trigger, then stream the + # archive the host produced for the id it names in its result. + web.post("/api/control/backup", handle_control_backup), + web.get("/api/control/backup-download", handle_backup_download), ] ) diff --git a/build/dashboard/mining_dashboard/web/static/backupview.mjs b/build/dashboard/mining_dashboard/web/static/backupview.mjs new file mode 100644 index 00000000..971535fa --- /dev/null +++ b/build/dashboard/mining_dashboard/web/static/backupview.mjs @@ -0,0 +1,153 @@ +// Backup card (#908): trigger an encrypted stack backup from the dashboard, then a genuine +// one-time "download this now" reveal for the passphrase the host generated. +// +// The host NEVER accepts a passphrase from this container — it mints one, runs it through the +// existing `pithead backup` machinery encrypted-only, and hands the result back exactly once +// through the normal /api/control/result poll. There is no way to fetch it again: the host +// overwrites the passphrase with null on its own bounded timer, and this component keeps no +// copy once the operator navigates away (a reload starts a fresh idle card). Save-it-now is not +// a UI suggestion, it is the only chance — match that in the copy, not just the code. + +import { pollResult } from "./configview.mjs"; +import { Component, html } from "./preact.mjs"; +import { fmtEpoch } from "./securityview.mjs"; + +const CONTROL_HEADERS = { "Content-Type": "application/json", "X-Pithead-Control": "1" }; +const BACKUP_POLL_MAX = 90; // 3 minutes — backup stops and restarts the whole stack, dashboard included + +// POST the backup intent, then poll past the runner's interim "running" to a terminal result. +// Exported for node --test — this network flow is the logic; BackupPanel only maps its outcome +// onto UI state. +export async function runBackup() { + const res = await fetch("/api/control/backup", { method: "POST", headers: CONTROL_HEADERS }); + if (!res.ok && res.status !== 202) throw new Error(`HTTP ${res.status}`); + const { id } = await res.json(); + const result = await pollResult(id, "running", BACKUP_POLL_MAX); + return { id, ...result }; +} + +// The downloadable kit as plain text — the archive name, when it was made, what it holds, and +// the passphrase itself, so an operator has one file to store somewhere other than this host. +export function buildKitText(result) { + const lines = [ + "Pithead backup emergency kit", + "", + `Archive: ${result.archive || ""}`, + `Created: ${fmtEpoch(result.ts)}`, + `Passphrase: ${result.passphrase || ""}`, + "", + "Contents:", + ...(result.contents || []).map((c) => ` - ${c}`), + "", + "This passphrase is shown once and cannot be recovered — without it the archive cannot be", + "decrypted. Keep this file somewhere other than the box it backs up.", + ]; + return lines.join("\n"); +} + +function kitFilename(archive) { + return (archive || "pithead-backup").replace(/\.tar\.gz(\.enc)?$/, "") + "-kit.txt"; +} + +export class BackupPanel extends Component { + constructor(props) { + super(props); + // idle | confirm | creating | kit | failed + this.state = { phase: "idle", id: null, result: null }; + } + + async run() { + this.setState({ phase: "creating" }); + try { + const out = await runBackup(); + this.setState({ + id: out.id, + result: out, + phase: out.status === "applied" ? "kit" : "failed", + }); + } catch (e) { + this.setState({ phase: "failed", result: { error: String(e) } }); + } + } + + renderConfirm() { + return html`
+
+

Create a backup

+

The host stops the stack, archives config.json, .env, the Tor onion-service keys + and the dashboard database into an encrypted file, then starts the stack again. + Mining pauses for the duration — usually under a minute. Blockchains are excluded; + they re-sync.

+

The passphrase is generated on the host and shown once, right after this. There is + no way to see it again — download the kit or write it down when it appears.

+
+ + +
+
+
`; + } + + renderCreating() { + return html`
+
+

Creating a backup…

+

The stack is stopping, archiving, and starting again. This page + may briefly disconnect — leave it open; it shows the passphrase when the archive is + ready.

+
+
`; + } + + renderKit(id, result) { + const kitText = buildKitText(result); + const kitHref = "data:text/plain;charset=utf-8," + encodeURIComponent(kitText); + return html`
+

Backup created

+

Save this passphrase now — it is shown once and cannot be + recovered. Without it, the archive is useless.

+

${result.passphrase}

+

Archive: ${result.archive}${" "} + — created ${fmtEpoch(result.ts)}

+

Contains: ${(result.contents || []).join(", ")}.

+
+ Download kit (.txt) + Download archive + +
+
`; + } + + renderFailed(result) { + return html`
+

Backup

+

${(result && result.error) || "The host runner reported a failure."}

+ +
`; + } + + render() { + if (!this.props.enabled) { + return html`
+

Backup

+

Backup export is off with the rest of the control channel. To enable it, set + dashboard.control.enabled: true in config.json on the host + and run ./pithead apply. It requires a dashboard login.

+
`; + } + const { phase, id, result } = this.state; + if (phase === "kit" && result) return this.renderKit(id, result); + if (phase === "failed") return this.renderFailed(result); + let modal = null; + if (phase === "confirm") modal = this.renderConfirm(); + else if (phase === "creating") modal = this.renderCreating(); + return html`
+

Backup

+

Export an encrypted archive of config.json, .env, the Tor onion-service keys, and the + dashboard database — the state a dead box takes with it. Blockchains are excluded; they + re-sync.

+ +
${modal}`; + } +} diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index 5f9df9a9..87b861f4 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -3,6 +3,7 @@ // (variant: "ok"/"purple"/"accent"/"muted", level: "high"/"ok"); the client maps those to // classes — it does no number formatting or business logic of its own. +import { BackupPanel } from "./backupview.mjs"; import { ChartCard } from "./chart.mjs"; import { ConfigView, UpgradeControl } from "./configview.mjs"; import { @@ -1246,7 +1247,7 @@ function DashboardView({ <${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} /> ${ configView - ? html`
<${ConfigView} /><${SecurityPanel} />
` + ? html`
<${ConfigView} /><${BackupPanel} enabled=${state.control_enabled} /><${SecurityPanel} />
` : null } ${ diff --git a/build/dashboard/mining_dashboard/web/static/configview.mjs b/build/dashboard/mining_dashboard/web/static/configview.mjs index 659385fa..420feb40 100644 --- a/build/dashboard/mining_dashboard/web/static/configview.mjs +++ b/build/dashboard/mining_dashboard/web/static/configview.mjs @@ -39,13 +39,15 @@ const POLL_MS = 2000; const POLL_MAX = 90; // 3 minutes — a commit recreates containers, which can take a while const UPGRADE_POLL_MAX = 450; // 15 minutes — an upgrade pulls a whole release of images first -// Poll /api/control/result until a terminal result lands; shared by the Configuration view and -// the Upgrade button (#59). `skip` ignores an intermediate status under the same id (the -// still-present "previewed" result while a commit runs; "running" while an upgrade runs). Both -// flows recreate the dashboard container itself, so a fetch here can transiently fail — either a -// dropped connection (proxy down) or a 502/503/504 (proxy up, upstream mid-restart, #622). Ride -// both out and keep polling until the result file answers. -async function pollResult(id, skip, max = POLL_MAX) { +// Poll /api/control/result until a terminal result lands; shared by the Configuration view, the +// Upgrade button (#59), and the Backup card (#908). `skip` ignores an intermediate status under +// the same id (the still-present "previewed" result while a commit runs; "running" while an +// upgrade or backup runs). Commit/upgrade/backup all briefly recreate or stop+restart the stack +// — commit/upgrade take the dashboard container itself down, backup takes the whole compose +// project down and back up — so a fetch here can transiently fail: a dropped connection (proxy +// down too, for backup) or a 502/503/504 (proxy up, upstream mid-restart, #622). Ride both out +// and keep polling until the result file answers. +export async function pollResult(id, skip, max = POLL_MAX) { for (let i = 0; i < max; i++) { await new Promise((r) => setTimeout(r, POLL_MS)); let res; diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index d0a05087..937d0b64 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -1450,6 +1450,14 @@ button.upgrade-btn { white-space: pre-wrap; } +/* Backup emergency kit (#908): the one-time passphrase reveal — same box recipe as + * .config-error-tail, larger and breaking mid-word so a long alnum passphrase never overflows. */ +.kit-passphrase { + font-size: 1rem; + word-break: break-all; + margin: 8px 0; +} + /* Worker Inspect (#185): a native opened from a worker name in the Workers Alive table. * Shows the rig's live telemetry, a writable-config editor, and the change history. showModal() * gives Escape-to-close and focus trapping for free; ::backdrop replaces the old overlay div. */ diff --git a/build/dashboard/tests/frontend/backupview.test.mjs b/build/dashboard/tests/frontend/backupview.test.mjs new file mode 100644 index 00000000..2a0a7dc1 --- /dev/null +++ b/build/dashboard/tests/frontend/backupview.test.mjs @@ -0,0 +1,141 @@ +// Backup card (#908): the one-click backup POST + poll flow, and the render states of the card +// (disabled / idle / confirm / creating / kit / failed). The kit reveal is the one genuinely new +// bit of client logic — everything else (the poll resilience itself) is pollResult's, already +// covered by configview.test.mjs; these tests only check BackupPanel drives it correctly. +// +// Run with Node's built-in test runner: +// node --test build/dashboard/tests/frontend/*.test.mjs +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + BackupPanel, + buildKitText, + runBackup, +} from "../../mining_dashboard/web/static/backupview.mjs"; +import { renderToString } from "./helpers/render.mjs"; + +const ID = "11111111-1111-4111-8111-111111111111"; +const okResult = (body) => ({ status: 200, ok: true, json: async () => body }); + +// Same technique configview.test.mjs uses: fire setTimeout synchronously so pollResult's 2s +// cadence doesn't slow the test. +async function withFastPoll(fetchStub, fn) { + const realFetch = globalThis.fetch; + const realTimeout = globalThis.setTimeout; + globalThis.fetch = fetchStub; + globalThis.setTimeout = (cb) => { + cb(); + return 0; + }; + try { + return await fn(); + } finally { + globalThis.fetch = realFetch; + globalThis.setTimeout = realTimeout; + } +} + +test("runBackup posts with no body, skips 'running', and returns the id + terminal result", async () => { + let posted = null; + let polls = 0; + const fetchStub = async (url, opts) => { + if (url === "/api/control/backup") { + posted = opts; + return { status: 202, ok: false, json: async () => ({ id: ID, status: "pending" }) }; + } + polls++; + if (polls === 1) return okResult({ status: "running" }); + return okResult({ + status: "applied", + passphrase: "abc123", + archive: "pithead-backup-20260813-000000.tar.gz.enc", + contents: ["config.json"], + ts: 1_000, + }); + }; + const out = await withFastPoll(fetchStub, () => runBackup()); + assert.equal(out.id, ID); + assert.equal(out.status, "applied"); + assert.equal(out.passphrase, "abc123"); + assert.equal(posted.headers["X-Pithead-Control"], "1"); // CSRF guard rides every mutation + assert.equal(posted.body, undefined); // no operator input travels with this verb +}); + +test("runBackup surfaces a host-side rejection as the outcome, not a throw", async () => { + const fetchStub = async (url) => + url === "/api/control/backup" + ? { status: 202, ok: false, json: async () => ({ id: ID, status: "pending" }) } + : okResult({ status: "rejected", error: "a backup was started less than 10 minutes ago" }); + const out = await withFastPoll(fetchStub, () => runBackup()); + assert.equal(out.status, "rejected"); + assert.match(out.error, /10 minutes/); +}); + +test("buildKitText carries the passphrase, archive, and contents — the whole one-time kit", () => { + const text = buildKitText({ + archive: "pithead-backup-20260813-000000.tar.gz.enc", + ts: 1_000, + passphrase: "S3cr3tPass", + contents: ["config.json", "the dashboard database"], + }); + assert.match(text, /S3cr3tPass/); + assert.match(text, /pithead-backup-20260813-000000\.tar\.gz\.enc/); + assert.match(text, /- config\.json/); + assert.match(text, /- the dashboard database/); + assert.match(text, /shown once and cannot be recovered/); +}); + +function inst(props) { + const c = new BackupPanel(props); + c.props = props; + return c; +} + +test("BackupPanel renders the disabled explainer when the control channel is off", () => { + const out = renderToString(inst({ enabled: false }).render()); + assert.match(out, /off with the rest of the control channel/); + assert.doesNotMatch(out, /Back up now/); +}); + +test("BackupPanel idle phase offers the Back up now button", () => { + const out = renderToString(inst({ enabled: true }).render()); + assert.match(out, /Back up now/); +}); + +test("BackupPanel confirm phase shows the disruption notice with Cancel/Create", () => { + const c = inst({ enabled: true }); + c.state.phase = "confirm"; + const out = renderToString(c.render()); + assert.match(out, /stops the stack/); + assert.match(out, /Create backup/); + assert.match(out, /Cancel/); +}); + +test("BackupPanel kit phase reveals the passphrase exactly once, with download links", () => { + const c = inst({ enabled: true }); + c.state = { + phase: "kit", + id: ID, + result: { + status: "applied", + passphrase: "hunter2-strong-pass", + archive: "pithead-backup-20260813-000000.tar.gz.enc", + contents: ["config.json", "the dashboard database"], + ts: 1_000, + }, + }; + const out = renderToString(c.render()); + assert.match(out, /hunter2-strong-pass/); + assert.match(out, /shown once and cannot be\s+recovered/); + assert.match(out, /pithead-backup-20260813-000000\.tar\.gz\.enc/); + assert.match(out, new RegExp(`/api/control/backup-download\\?id=${ID}`)); + assert.match(out, /Download kit/); + assert.match(out, /I.ve saved it/); +}); + +test("BackupPanel failed phase surfaces the host's error", () => { + const c = inst({ enabled: true }); + c.state = { phase: "failed", id: null, result: { status: "failed", error: "boom" } }; + assert.match(renderToString(c.render()), /boom/); +}); diff --git a/build/dashboard/tests/web/test_server.py b/build/dashboard/tests/web/test_server.py index 282941c6..d76df7ff 100644 --- a/build/dashboard/tests/web/test_server.py +++ b/build/dashboard/tests/web/test_server.py @@ -182,6 +182,8 @@ async def test_control_routes_absent_when_disabled(self, client): assert (await client.post("/api/control/commit", json={})).status == 404 assert (await client.post("/api/control/upgrade", json={})).status == 404 assert (await client.get("/api/control/result?id=x")).status == 404 + assert (await client.post("/api/control/backup")).status == 404 + assert (await client.get("/api/control/backup-download?id=x")).status == 404 # The config-change audit view is a control-channel artifact — absent with it (#349). assert (await client.get("/api/audit")).status == 404 @@ -256,7 +258,12 @@ async def test_get_config_degrades_to_no_core_keys_when_file_is_missing(self, co async def test_post_without_control_header_forbidden(self, control_client): # The custom header forces a CORS preflight cross-site, which is never granted (CSRF). - for path in ("/api/control/preview", "/api/control/commit", "/api/control/upgrade"): + for path in ( + "/api/control/preview", + "/api/control/commit", + "/api/control/upgrade", + "/api/control/backup", + ): resp = await control_client.post(path, json={"config": {}}) assert resp.status == 403, path @@ -409,6 +416,73 @@ async def test_upgrade_spool_failure_is_sanitized(self, control_client, monkeypa assert resp.status == 500 assert "nonexistent" not in json.dumps(await resp.json()) + async def test_backup_submits_bare_intent_and_returns_pending( + self, control_client, control_spool + ): + # No body, unlike commit/upgrade: the host picks its own passphrase, never the container's. + resp = await control_client.post( + "/api/control/backup", headers={**CONTROL_HEADERS, "X-Auth-User": "admin"} + ) + assert resp.status == 202 + body = await resp.json() + assert body["status"] == "pending" + req = json.loads((control_spool / "requests" / f"{body['id']}.json").read_text()) + # Closed shape: exactly these keys — no config leg, no passphrase field to smuggle one in. + assert req == {"id": body["id"], "action": "backup", "actor": "admin"} + + async def test_backup_spool_failure_is_sanitized(self, control_client, monkeypatch): + monkeypatch.setattr(control_service.config, "CONTROL_REQUESTS_DIR", "/nonexistent/requests") + resp = await control_client.post("/api/control/backup", headers=CONTROL_HEADERS) + assert resp.status == 500 + assert "nonexistent" not in json.dumps(await resp.json()) + + async def test_backup_download_rejects_bad_id(self, control_client): + resp = await control_client.get("/api/control/backup-download?id=..%2Fx") + assert resp.status == 400 + + async def test_backup_download_404_without_a_result(self, control_client): + resp = await control_client.get(f"/api/control/backup-download?id={uuid.uuid4()}") + assert resp.status == 404 + + async def test_backup_download_404_when_not_applied(self, control_client, control_spool): + rid = str(uuid.uuid4()) + (control_spool / "results" / f"{rid}.json").write_text( + json.dumps({"status": "failed", "error": "boom"}) + ) + resp = await control_client.get(f"/api/control/backup-download?id={rid}") + assert resp.status == 404 + + async def test_backup_download_404_when_archive_missing_on_disk( + self, control_client, control_spool + ): + # The result names an archive but the file itself is gone — 404, not a 500/traceback. + rid = str(uuid.uuid4()) + (control_spool / "results" / f"{rid}.json").write_text( + json.dumps({"status": "applied", "archive": "pithead-backup-x.tar.gz.enc"}) + ) + resp = await control_client.get(f"/api/control/backup-download?id={rid}") + assert resp.status == 404 + + async def test_backup_download_streams_the_archive(self, control_client, control_spool): + rid = str(uuid.uuid4()) + (control_spool / "results" / f"{rid}.json").write_text( + json.dumps( + { + "status": "applied", + "archive": "pithead-backup-20260101-000000.tar.gz.enc", + "passphrase": None, # already redacted; the download must not depend on it + } + ) + ) + (control_spool / "results" / f"{rid}.tar.gz.enc").write_bytes(b"ENCRYPTED-ARCHIVE-BYTES") + resp = await control_client.get(f"/api/control/backup-download?id={rid}") + assert resp.status == 200 + assert await resp.read() == b"ENCRYPTED-ARCHIVE-BYTES" + assert ( + 'filename="pithead-backup-20260101-000000.tar.gz.enc"' + in resp.headers["Content-Disposition"] + ) + async def test_config_read_failure_is_sanitized(self, control_client, monkeypatch): monkeypatch.setattr(control_service.config, "HOST_CONFIG_PATH", "/nonexistent/config.json") resp = await control_client.get("/api/config") diff --git a/docs/appliance.md b/docs/appliance.md index 98bfc3d1..9d1ce828 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -270,6 +270,29 @@ install-then-fall-back protection as any other update. The practical consequence an old image keeps running fine, but it keeps the old image's known holes too. The base system is Debian 13, which receives security support upstream into 2030. +## Backing up your data + +The machine holds state a resync cannot rebuild: your wallet settings, the Tor onion +keys that give it its address, and the dashboard's history. There is no filesystem to +copy from a shell-less box, so the dashboard's **Configuration → Backup** card exports it +for you as one encrypted file. + +Click **Back up now** and the machine stops the stack, archives `config.json`, `.env`, +the Tor onion-service keys and the dashboard database into a single file, and starts the +stack again — mining pauses for the archive's duration. The blockchains are left out; they +resync from the network on their own. + +The archive is encrypted, and the machine picks the passphrase for you: a long, random +one, shown exactly once, right after the archive is ready. There is no way to see it +again — the page shows it inside a downloadable kit (the passphrase, the archive's name, +and what it contains), and warns you before it moves on. Save the kit and download the +archive together, and keep them somewhere other than this machine. Without the +passphrase, the archive cannot be opened. + +**NOTE:** restoring from a backup is not available yet. Exporting a backup off the +machine closes the "a dead box loses everything" gap; a guided restore back onto a fresh +install is separate, coming work. + ## Starting over: the two resets There are two ways to reset the machine, and the difference between them is days of your diff --git a/pithead b/pithead index 5f760997..86d1a847 100755 --- a/pithead +++ b/pithead @@ -8100,8 +8100,11 @@ run_chain() { # --- Dashboard control channel (#33) --- # The dashboard container can only ASK: it drops typed JSON intents into $CONTROL_DIR/requests # (its single writable spool mount). This host-side runner claims each request, validates it, and -# dispatches EXACTLY three actions — `apply --dry-run --porcelain` (preview), `apply -y` (commit), -# and `upgrade` to the latest published release (#59, target re-derived host-side). +# dispatches a FIXED set of actions, each a hardcoded host command the request's `action` string +# only SELECTS between — `apply --dry-run --porcelain` (preview), `apply -y` (commit), `upgrade` +# to the latest published release (#59, target re-derived host-side), `restart`/`apply` (the +# Telegram lifecycle verbs, #338), `worker-apply`/`worker-upgrade` (a rig's own control API, +# #185/#597), and `backup` (an encrypted archive + one-time emergency kit, #908). # Outcomes land in results/ and an audit line in audit/, both mounted read-only in the container — # as is masked/, the pre-masked config copy the editor form prefills from (#440); the raw # config.json is never mounted, so the container holds no secret it wasn't given. @@ -8854,6 +8857,124 @@ control_lifecycle() { # rm -f "$logf" } +# One-shot encrypted backup + one-time "emergency kit" (#908). Reuses stack_backup UNCHANGED +# (~L2793), encrypted ONLY — no request field can pick --no-encrypt (it stays CLI-only), and a +# failure to mint a passphrase refuses before anything is touched, never falls back to plaintext. +# The passphrase is generated HOST-SIDE (generate_node_password: the same 32-char-alnum strength +# already used for the local node RPC creds) and crosses to stack_backup only through its existing +# PITHEAD_BACKUP_PASSPHRASE env-var input — the same channel an unattended cron backup already +# uses — never argv (what the support bundle's redaction targets, #77) and never a file. +# +# One-time handoff lifecycle: the kit (passphrase + archive name + contents + created-at/`ts`) +# rides back through the SAME results/ leg every other verb uses, keyed by the request id — but +# results/ is mounted READ-ONLY into the dashboard container (#33's trust boundary: it can only +# ASK, via requests/), so the container can never itself delete or ack this file the way the +# first-boot wizard's handoff/handoff-ack does (that spool is mounted read-write end to end). The +# deliberate substitute here: a bounded, blocking TTL. Short enough that a stuck backup doesn't +# stall the single-threaded runner's other queued verbs for long; generous next to the dashboard's +# own long-poll window (CONTROL_WAIT_S) so an ordinary page load always sees it. Once it elapses +# the passphrase is overwritten with null — read or not, it is gone. The archive/filename/contents +# stay: it is ciphertext, useless without the passphrase, so it remains downloadable. +# ponytail: TTL, not a container->host ack request (a "backup-ack" verb through requests/ would be +# more precise but is a whole extra verb) — add one if this window proves too tight/loose live. +# Backstop for control_backup's one-time kit: null the passphrase in any kit JSON whose `ts` is +# older than the TTL but which still carries one — the case where the runner was killed during the +# self-redaction sleep (a reboot racing the window) and left a wallet-grade secret in plaintext on +# /data. Run at the top of every drain, so the fresh runner after such a reboot cleans it up. A +# generous margin over the TTL (2x, floor 120s) so this never races the in-band redaction of a kit +# whose window is still open. +control_redact_stale_kits() { # + local results="$1" f now cutoff ts + [ -d "$results" ] || return 0 + now=$(date +%s) + cutoff=$((2 * ${CONTROL_BACKUP_KIT_TTL_S:-20})) + [ "$cutoff" -lt 120 ] && cutoff=120 + for f in "$results"/*.json; do + [ -f "$f" ] || continue + # Cheap gate first: only kits that still hold a passphrase are candidates. + jq -e '.passphrase // "" | length > 0' "$f" >/dev/null 2>&1 || continue + ts=$(jq -r '.ts // 0' "$f" 2>/dev/null) + [ "$((now - ts))" -ge "$cutoff" ] || continue + jq '.passphrase = null | .note = "The passphrase was shown once and is no longer available on this host — back up again if you did not save it."' \ + "$f" >"$results/.$(basename "$f").tmp" 2>/dev/null && + mv "$results/.$(basename "$f").tmp" "$f" + done +} + +control_backup() { # + local id="$1" actor="$2" cdir="$3" rc=0 + local results="$cdir/results" auditf="$cdir/audit/control.log" + control_audit "$auditf" "$id" "$actor" "backup" "started" + # Throttle (mirrors control_upgrade's #59 stamp): a compromised container flooding this verb + # would repeatedly stop/start the whole mining stack, not just burn CPU — one attempt per 10 + # minutes, checked before the passphrase is even generated. + local stamp="$cdir/staged/.backup-stamp" + if [ -n "$(find "$stamp" -mmin -10 2>/dev/null)" ]; then + control_write_result "$results" "$id" "$(jq -n '{status:"rejected",error:"a backup was started less than 10 minutes ago — wait for it to finish, then retry.",ts:(now|floor)}')" + control_audit "$auditf" "$id" "$actor" "backup" "rejected" + return 0 + fi + { set +x; } 2>/dev/null # xtrace would print the passphrase assignment below + local pass + pass=$(generate_node_password) + if [ -z "$pass" ]; then + control_write_result "$results" "$id" "$(jq -n '{status:"rejected",error:"could not generate a backup passphrase — nothing was backed up.",ts:(now|floor)}')" + control_audit "$auditf" "$id" "$actor" "backup" "rejected" + return 0 + fi + touch "$stamp" 2>/dev/null || true # claim the throttle before the disruptive part starts + control_write_result "$results" "$id" "$(jq -n '{status:"running",ts:(now|floor)}')" + local self="${PITHEAD_SELF:-$0}" logf="$cdir/staged/.$id.log" + # Run as a CHILD PROCESS, like control_lifecycle/control_commit's own re-invocations: + # stack_backup's error() exits its whole process on failure, which must not take the drain + # loop's other pending requests down with it. + export PITHEAD_BACKUP_PASSPHRASE="$pass" + "$self" backup -y >"$logf" 2>&1 || rc=$? + unset PITHEAD_BACKUP_PASSPHRASE + if [ "$rc" -ne 0 ]; then + control_write_result "$results" "$id" "$(jq -n --arg e "$(tail -c 2000 "$logf")" '{status:"failed",error:$e,ts:(now|floor)}')" + control_audit "$auditf" "$id" "$actor" "backup" "failed" + rm -f "$logf" + pass="" + return 0 + fi + local archive + archive=$(sed -n 's/^\[pithead\] Backup written to: //p' "$logf" | tail -n1) + rm -f "$logf" + if [ -z "$archive" ] || [ ! -f "$archive" ]; then + control_write_result "$results" "$id" "$(jq -n '{status:"failed",error:"the backup ran but the archive could not be located afterward.",ts:(now|floor)}')" + control_audit "$auditf" "$id" "$actor" "backup" "failed" + pass="" + return 0 + fi + # Place it on the ALREADY-shared results/ leg (#33) — no new bind mount, keyed by the same id + # as its own result. Tighter perms than the rest of results/ (which relies on default, + # effectively world-readable perms — fine, nothing there is a secret): root-owned, + # group-readable by the dashboard's own uid/gid only (APP_UID/APP_GID, #255), because this + # file briefly shares a directory with its own passphrase below. + local fname dest + fname=$(basename "$archive") + dest="$results/$id.tar.gz.enc" + mv "$archive" "$dest" + chown "0:$APP_GID" "$dest" 2>/dev/null || true + chmod 640 "$dest" 2>/dev/null || true + (umask 077 && control_write_result "$results" "$id" "$(jq -n --arg p "$pass" --arg f "$fname" ' + {status:"applied", passphrase:$p, archive:$f, + contents:["config.json","the stack .env (secrets)","Caddyfile, if present", + "the Tor onion-service key directory, if present","the dashboard database"], + note:"This passphrase is shown once and cannot be recovered — save it now.", + ts:(now|floor)}')") + pass="" + chown "0:$APP_GID" "$results/$id.json" 2>/dev/null || true + chmod 640 "$results/$id.json" 2>/dev/null || true + control_audit "$auditf" "$id" "$actor" "backup" "applied" + # The blocking TTL described above the function. Overridable so tests don't sit through it. + sleep "${CONTROL_BACKUP_KIT_TTL_S:-20}" + jq '.passphrase = null | .note = "The passphrase was shown once and is no longer available on this host — back up again if you did not save it."' \ + "$results/$id.json" >"$results/.$id.json.tmp" 2>/dev/null && + mv "$results/.$id.json.tmp" "$results/$id.json" +} + # Worker config apply (#185): POST an operator's writable-key change to a RigForge rig's control API # and record the outcome for the dashboard's config history. The intent carries ONLY the worker NAME # and the CHANGES — never a host, port, or token: the runner resolves the rig's real address + bearer @@ -9228,6 +9349,7 @@ control_process_request() { # worker-apply) control_worker_apply "$file" "$id" "$actor" "$cdir" ;; worker-upgrade) control_worker_upgrade "$file" "$id" "$actor" "$cdir" ;; restart | apply) control_lifecycle "$action" "$id" "$actor" "$cdir" ;; + backup) control_backup "$id" "$actor" "$cdir" ;; *) control_write_result "$cdir/results" "$id" "$(jq -n '{status:"rejected",error:"unknown action",ts:(now|floor)}')" control_audit "$cdir/audit/control.log" "$id" "$actor" "${action:-none}" "rejected" @@ -9256,6 +9378,12 @@ control_run_pending() { # "$claim"` below (an errexit gap, e.g.) leaves a `.claim.` file sitting directly in # $cdir forever. Same age cutoff — a claim in flight never lives past a single drain. find "$cdir" -maxdepth 1 -type f -name '.claim.*' -mmin +60 -delete 2>/dev/null || true + # Stale backup-kit passphrases: control_backup's one-time kit self-redacts after a blocking + # TTL, but a runner killed mid-sleep (a reboot racing the window) would leave a wallet-grade + # passphrase in results/ in plaintext on /data indefinitely. This backstop — a fresh runner + # after that reboot runs it — nulls the passphrase in any kit older than the TTL that still + # carries one. Belt to the TTL's braces; the passphrase is only ever meant for the live window. + control_redact_stale_kits "$cdir/results" local names name req claim n=0 # Per-run cap (#33 hardening): a single trigger drains at most this many intents, so a flood in # the spool can't hold the root runner for an unbounded stretch — the leftovers wait for the diff --git a/tests/stack/run.sh b/tests/stack/run.sh index e64e325e..89798198 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -7621,6 +7621,150 @@ assert_contains "a rig non-202 (incl. an old-rig < v1.11.2 refusal) is surfaced assert_not_contains "the rig's error text is truncated at 500 chars" \ "$(jq -r '.error // ""' "$refuse_dir/results/$w17.json")" "OVERFLOW-TAIL" +# --------------------------------------------------------------------------- +echo "== control channel: backup verb (#908) ==" +# control_backup generates its OWN passphrase (never accepted from the container), runs the +# real backup as a CHILD "$self backup -y" (stack_backup's own error() exits its process, which +# must not take the drain loop's other pending requests with it), and hands back a one-time kit +# through results/. A stub self reproduces stack_backup's own "Backup written to: " log +# line so this stays a fast, docker-free test of the GLUE — the archive mechanics themselves are +# already covered by the backup/restore round-trip tests above (#140/#374). +BKC="$SANDBOX/ctrl908" +mkdir -p "$BKC/staged" "$BKC/results" "$BKC/audit" +cat >"$BKC/self" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"${SELF_LOG:-/dev/null}" +printf '%s\n' "${PITHEAD_BACKUP_PASSPHRASE:-}" >>"${PASS_LOG:-/dev/null}" +if [ "${BACKUP_FAIL:-0}" = "1" ]; then + echo "boom: disk full" >&2 + exit 1 +fi +mkdir -p "$(dirname "$FAKE_ARCHIVE")" +printf 'FAKE-ENCRYPTED-BYTES' >"$FAKE_ARCHIVE" +echo "[pithead] Backup written to: $FAKE_ARCHIVE" +exit 0 +EOF +chmod +x "$BKC/self" +export PITHEAD_SELF="$BKC/self" +export SELF_LOG="$BKC/self.log" +export PASS_LOG="$BKC/pass.log" +export CONTROL_BACKUP_KIT_TTL_S=0 # redact immediately — this block only checks the applied shape + +bid1="a0a0a0a0-0000-4000-8000-000000000001" +export FAKE_ARCHIVE="$BKC/fake-backups/pithead-backup-20260813-000000.tar.gz.enc" +: >"$SELF_LOG" +: >"$PASS_LOG" +printf '{"id":"%s","action":"backup","actor":"admin"}\n' "$bid1" >"$BKC/req1.json" +run_sourced "$SANDBOX" control_process_request "$BKC/req1.json" "$BKC" >/dev/null 2>&1 +assert_eq "backup runs the fixed 'backup -y' verb (never --no-encrypt)" "$(cat "$SELF_LOG")" "backup -y" +assert_eq "backup result is applied" "$(jq -r .status "$BKC/results/$bid1.json")" "applied" +pass1="$(cat "$PASS_LOG")" +{ [ -n "$pass1" ] && [ "$pass1" != "" ]; } && + ok "the child gets a non-empty passphrase (via env, never argv)" || + bad "the child gets a non-empty passphrase (via env, never argv)" "got: $pass1" +assert_eq "the passphrase never rides argv (the child's own argv log shows only 'backup -y')" \ + "$(cat "$SELF_LOG")" "backup -y" +assert_eq "the kit names the archive by basename" \ + "$(jq -r .archive "$BKC/results/$bid1.json")" "pithead-backup-20260813-000000.tar.gz.enc" +assert_contains "the kit lists what the archive holds" \ + "$(jq -r '.contents | join(",")' "$BKC/results/$bid1.json")" "config.json" +[ -f "$BKC/results/$bid1.tar.gz.enc" ] && + ok "the archive lands under results/ (the container's existing ro mount — no new bind mount)" || + bad "the archive lands under results/ (the container's existing ro mount — no new bind mount)" "missing" +assert_eq "the archive's content is preserved by the move into results/" \ + "$(cat "$BKC/results/$bid1.tar.gz.enc")" "FAKE-ENCRYPTED-BYTES" +assert_contains "backup is audited applied" \ + "$(cat "$BKC/audit/control.log")" '"action":"backup","status":"applied"' +# TTL=0 above means the redaction ran synchronously before control_process_request returned. +assert_eq "the passphrase is gone once the TTL elapses (redacted in place, whether read or not)" \ + "$(jq -r '.passphrase // "null"' "$BKC/results/$bid1.json")" "null" +assert_contains "the redaction note explains the passphrase is gone" \ + "$(jq -r .note "$BKC/results/$bid1.json")" "no longer available" +assert_eq "the archive name survives the redaction (ciphertext stays downloadable)" \ + "$(jq -r .archive "$BKC/results/$bid1.json")" "pithead-backup-20260813-000000.tar.gz.enc" + +echo "== control channel: backup verb — the kit is visible before its TTL, gone after (#908) ==" +# A wider TTL, checked mid-flight: the passphrase is readable for a real window (long enough for +# an ordinary dashboard poll), then null either way — "consumed or not, it's gone". +rm -f "$BKC/staged/.backup-stamp" # bid1 above already claimed the 10-minute throttle +bid2="a0a0a0a0-0000-4000-8000-000000000002" +export FAKE_ARCHIVE="$BKC/fake-backups/pithead-backup-20260813-000001.tar.gz.enc" +export CONTROL_BACKUP_KIT_TTL_S=3 +: >"$SELF_LOG" +: >"$PASS_LOG" +printf '{"id":"%s","action":"backup","actor":"admin"}\n' "$bid2" >"$BKC/req2.json" +run_sourced "$SANDBOX" control_process_request "$BKC/req2.json" "$BKC" >/dev/null 2>&1 & +bg_pid=$! +sleep 0.5 # well inside the 3s TTL — the stubbed child + write are effectively instant +mid_pass="$(jq -r '.passphrase // "null"' "$BKC/results/$bid2.json" 2>/dev/null)" +{ [ -n "$mid_pass" ] && [ "$mid_pass" != "null" ]; } && + ok "the passphrase IS present while inside the TTL window" || + bad "the passphrase IS present while inside the TTL window" "got: $mid_pass" +assert_eq "the kit's passphrase is exactly what the child received (same secret both ends)" \ + "$mid_pass" "$(cat "$PASS_LOG")" +wait "$bg_pid" +assert_eq "the passphrase is null once the TTL elapses" \ + "$(jq -r '.passphrase // "null"' "$BKC/results/$bid2.json" 2>/dev/null)" "null" +[ -f "$BKC/results/$bid2.tar.gz.enc" ] && + ok "the archive file itself is untouched by the redaction" || + bad "the archive file itself is untouched by the redaction" "missing" +unset bg_pid mid_pass + +echo "== control channel: backup verb — failure and throttle (#908) ==" +rm -f "$BKC/staged/.backup-stamp" # bid2 above already claimed the 10-minute throttle +bid3="a0a0a0a0-0000-4000-8000-000000000003" +export CONTROL_BACKUP_KIT_TTL_S=0 +export BACKUP_FAIL=1 +: >"$SELF_LOG" +printf '{"id":"%s","action":"backup","actor":"admin"}\n' "$bid3" >"$BKC/req3.json" +run_sourced "$SANDBOX" control_process_request "$BKC/req3.json" "$BKC" >/dev/null 2>&1 +assert_eq "a failed child backup is reported failed, not applied" \ + "$(jq -r .status "$BKC/results/$bid3.json")" "failed" +assert_contains "the failure carries the child's own error tail" \ + "$(jq -r .error "$BKC/results/$bid3.json")" "boom: disk full" +assert_eq "a failed backup's result never carries a passphrase field" \ + "$(jq -r 'has("passphrase")' "$BKC/results/$bid3.json")" "false" +assert_contains "the failed attempt is audited" \ + "$(cat "$BKC/audit/control.log")" '"action":"backup","status":"failed"' +unset BACKUP_FAIL + +# Throttle (mirrors #59's upgrade throttle): bid1 above already claimed the 10-minute window — +# a fourth attempt right after is refused before the passphrase is even generated. +bid4="a0a0a0a0-0000-4000-8000-000000000004" +: >"$SELF_LOG" +printf '{"id":"%s","action":"backup","actor":"admin"}\n' "$bid4" >"$BKC/req4.json" +run_sourced "$SANDBOX" control_process_request "$BKC/req4.json" "$BKC" >/dev/null 2>&1 +assert_contains "an immediate second backup attempt is throttled" \ + "$(jq -r .error "$BKC/results/$bid4.json")" "less than 10 minutes" +assert_eq "a throttled attempt never runs the child" "$(cat "$SELF_LOG")" "" + +# The request schema itself cannot carry a passphrase — control_process_request's fixed key +# allowlist (id/action/config/actor/version/worker/changes/confirm) rejects any other field +# before the action even dispatches, so the container has no field to smuggle one through. +bid5="a0a0a0a0-0000-4000-8000-000000000005" +printf '{"id":"%s","action":"backup","actor":"admin","passphrase":"leaked"}\n' "$bid5" >"$BKC/req5.json" +run_sourced "$SANDBOX" control_process_request "$BKC/req5.json" "$BKC" >/dev/null 2>&1 +assert_contains "a request carrying a passphrase field is refused outright (unexpected keys)" \ + "$(jq -r .error "$BKC/results/$bid5.json")" "unexpected keys" + +# Backstop: a kit whose runner was KILLED mid-TTL keeps a plaintext passphrase on /data. The next +# drain's control_redact_stale_kits must null it once past the TTL, while leaving a still-in-window +# kit and a non-kit result alone. +export CONTROL_BACKUP_KIT_TTL_S=20 # cutoff = max(2x, 120) = 120s +old_ts=$(($(date +%s) - 3600)) # an hour stale +now_ts=$(date +%s) # fresh +jq -n --argjson t "$old_ts" '{status:"applied",passphrase:"STRANDED-SECRET",archive:"a.enc",ts:$t}' >"$BKC/results/stale.json" +jq -n --argjson t "$now_ts" '{status:"applied",passphrase:"LIVE-SECRET",archive:"b.enc",ts:$t}' >"$BKC/results/fresh.json" +jq -n --argjson t "$old_ts" '{status:"applied",change_id:"c",ts:$t}' >"$BKC/results/other.json" # not a kit +run_sourced "$SANDBOX" control_redact_stale_kits "$BKC/results" >/dev/null 2>&1 +assert_eq "a stranded kit passphrase (runner died mid-TTL) is redacted on the next drain" \ + "$(jq -r '.passphrase // "null"' "$BKC/results/stale.json")" "null" +assert_eq "a kit still inside its window keeps its passphrase" \ + "$(jq -r '.passphrase' "$BKC/results/fresh.json")" "LIVE-SECRET" +assert_eq "a non-kit result is left untouched" \ + "$(jq -r '.change_id' "$BKC/results/other.json")" "c" +unset PITHEAD_SELF SELF_LOG PASS_LOG FAKE_ARCHIVE CONTROL_BACKUP_KIT_TTL_S bid1 bid2 bid3 bid4 bid5 pass1 old_ts now_ts + # --------------------------------------------------------------------------- echo "== unit: config.reference.json stays a complete superset of every path pithead reads (#561) ==" # The closed-schema control gate (#537, pithead ~L4706) relies on this invariant: every config.json