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.
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`
`
: 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