Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions build/dashboard/mining_dashboard/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import mimetypes
import os
import re
import uuid

from aiohttp import web

Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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),
]
)

Expand Down
153 changes: 153 additions & 0 deletions build/dashboard/mining_dashboard/web/static/backupview.mjs
Original file line number Diff line number Diff line change
@@ -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`<div class="config-modal-backdrop">
<div class="card config-modal">
<h3>Create a backup</h3>
<p>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.</p>
<p>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.</p>
<div class="config-modal-actions">
<button class="btn-toggle" onClick=${() => this.setState({ phase: "idle" })}>Cancel</button>
<button class="btn-toggle active" onClick=${() => this.run()}>Create backup</button>
</div>
</div>
</div>`;
}

renderCreating() {
return html`<div class="config-modal-backdrop">
<div class="card config-modal">
<h3>Creating a backup…</h3>
<p class="text-muted">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.</p>
</div>
</div>`;
}

renderKit(id, result) {
const kitText = buildKitText(result);
const kitHref = "data:text/plain;charset=utf-8," + encodeURIComponent(kitText);
return html`<div class="card">
<h3>Backup created</h3>
<p class="status-warn">Save this passphrase now — it is shown once and cannot be
recovered. Without it, the archive is useless.</p>
<p class="config-error-tail kit-passphrase font-mono">${result.passphrase}</p>
<p class="text-muted text-xs">Archive: <span class="font-mono">${result.archive}</span>${" "}
— created ${fmtEpoch(result.ts)}</p>
<p class="text-muted text-xs">Contains: ${(result.contents || []).join(", ")}.</p>
<div class="config-actions">
<a class="btn-toggle active" href=${kitHref} download=${kitFilename(result.archive)}>Download kit (.txt)</a>
<a class="btn-toggle" href=${"/api/control/backup-download?id=" + encodeURIComponent(id)}>Download archive</a>
<button class="btn-toggle" onClick=${() => this.setState({ phase: "idle", id: null, result: null })}>I've saved it — close</button>
</div>
</div>`;
}

renderFailed(result) {
return html`<div class="card">
<h3>Backup</h3>
<p class="status-bad">${(result && result.error) || "The host runner reported a failure."}</p>
<button class="btn-toggle" onClick=${() => this.setState({ phase: "idle", result: null })}>Close</button>
</div>`;
}

render() {
if (!this.props.enabled) {
return html`<div class="card">
<h3>Backup</h3>
<p>Backup export is off with the rest of the control channel. To enable it, set
<code>dashboard.control.enabled: true</code> in <code>config.json</code> on the host
and run <code>./pithead apply</code>. It requires a dashboard login.</p>
</div>`;
}
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`<div class="card">
<h3>Backup</h3>
<p>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.</p>
<button class="btn-toggle active" disabled=${phase !== "idle"}
onClick=${() => this.setState({ phase: "confirm" })}>Back up now</button>
</div>${modal}`;
}
}
3 changes: 2 additions & 1 deletion build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1246,7 +1247,7 @@ function DashboardView({
<${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} />
${
configView
? html`<div class="card-stack"><${ConfigView} /><${SecurityPanel} /></div>`
? html`<div class="card-stack"><${ConfigView} /><${BackupPanel} enabled=${state.control_enabled} /><${SecurityPanel} /></div>`
: null
}
${
Expand Down
16 changes: 9 additions & 7 deletions build/dashboard/mining_dashboard/web/static/configview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions build/dashboard/mining_dashboard/web/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog> 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. */
Expand Down
Loading