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
11 changes: 11 additions & 0 deletions build/dashboard/mining_dashboard/web/static/wizard.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,14 @@
padding-left: 0.75rem;
border-left: 2px solid var(--border);
}

/* The restore-at-setup toggle and its "back" link (#909): a plain button styled as text. */
.wizard-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
116 changes: 116 additions & 0 deletions build/dashboard/mining_dashboard/web/static/wizard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const FIELDS = {
dashPassword: { path: "dashboard.auth.password" },
};

// Restore-at-setup (#909, #786 sub-issue B): mirrors the pithead script's and wizard.py's own
// cap — a Pithead backup holds only config, keys and the dashboard database, never the
// blockchains. Three languages, one number kept in step by hand (no shared source across them).
const RESTORE_MAX_BYTES = 64 * 1024 * 1024;

const TIMEZONES = [
"auto",
"UTC",
Expand Down Expand Up @@ -162,6 +167,25 @@ export const InstallSection = ({
</div>`;
};

// Restore-at-setup (#909): the config form's alternative — an uploaded encrypted backup +
// its emergency-kit passphrase. Validation is host-side (the same "container asks, host
// decides" split as everything else here); this just carries the two answers up.
export const RestoreSection = ({ file, passphrase, onFile, onPassphrase }) => html`<div>
<h3>Restore from a backup</h3>
<${Note}>Upload the encrypted backup archive and its emergency-kit passphrase — shown once,
when the backup was made. This restores settings, wallets, keys and the dashboard's history;
the machine then provisions itself from what it restores, exactly as if you had filled in
the form.<//>
<${Field} label="Backup archive">
<input type="file" accept=".enc,.tar.gz" onChange=${onFile} />
<//>
${file && html`<p class="text-muted">${file.name} (${Math.round(file.size / 1024)} KB)</p>`}
<${Field} label="Passphrase">
<input type="password" value=${passphrase} onInput=${onPassphrase}
autocomplete="off" placeholder="the emergency-kit passphrase" />
<//>
</div>`;

export const Installing = ({ status }) => html`<div class="card">
<p><strong>Installing.</strong> Takes a few minutes. Do not power it off.</p>
${
Expand Down Expand Up @@ -244,6 +268,12 @@ export class WizardApp extends Component {
rigWorker: "",
rigPassword: "",
rigDefaults: {},
// Restore-at-setup (#909): an alternative to the whole form above, toggled independently
// of role/install-target — an uploaded backup replaces the config the operator would
// otherwise type in.
restoreMode: false,
restoreFile: null,
restorePassphrase: "",
status: "",
handoff: null,
};
Expand Down Expand Up @@ -419,6 +449,53 @@ export class WizardApp extends Component {
this.poll();
};

// The restore-at-setup alternative (#909): an uploaded archive + passphrase instead of the
// typed config. Multipart, not URLSearchParams — the archive is a file, not a form field.
// Validation is entirely host-side; the client only enforces the size cap it can check
// without a round trip.
submitRestore = async (e) => {
e.preventDefault();
if (!this.state.restoreFile) {
this.setState({ error: "Choose a backup archive to upload." });
return;
}
if (this.state.restoreFile.size > RESTORE_MAX_BYTES) {
this.setState({
error: `Archive is too large (max ${RESTORE_MAX_BYTES / (1024 * 1024)} MB) — a Pithead backup holds only config, keys and the dashboard database, never the blockchains.`,
});
return;
}
const body = new FormData();
body.append("archive", this.state.restoreFile);
body.append("passphrase", this.state.restorePassphrase);
if (this.state.installer) {
if (!this.state.chosen) {
this.setState({ error: "Choose the disk to install onto." });
return;
}
if (this.state.confirm !== this.state.chosen) {
this.setState({ error: `Type ${this.state.chosen} exactly to confirm the erase.` });
return;
}
body.append("disk", this.state.chosen);
body.append("confirm", this.state.confirm);
body.append("wipe", this.state.wipe);
}
const res = await fetch("/submit-restore", { method: "POST", body });
if (!res.ok) {
let msg = "Restore failed — check the archive and passphrase, and retry.";
try {
msg = (await res.json()).error || msg;
} catch {}
this.setState({ error: msg });
return;
}
// Same in-place wait as a typed submission: no optimistic page swap, the server's stage
// moves the page when it actually does.
this.setState({ submitting: true, error: "" });
this.poll();
};

ack = async () => {
await fetch("/handoff-ack", { method: "POST" });
await this.loadState(); // the server drops out of the handoff stage; the view follows
Expand Down Expand Up @@ -457,7 +534,43 @@ export class WizardApp extends Component {
it appears by this name in the Pithead's Workers view.<//>`;
}

// Restore-at-setup (#909): its own small card, reached by the toggle at the top of the
// normal form and left by the "back" link here — independent of role/disk state, which
// this branch handles on its own (the install target still needs picking on the medium).
renderRestore() {
const { error, installer, disks, chosen, confirm, wipe, restorePassphrase, submitting } =
this.state;
const diskPicked = !installer || Boolean(chosen);
return html`<div class="card">
<p>Upload an encrypted Pithead backup instead of filling in the form below. The machine
decrypts, validates and provisions itself from what it restores.</p>
<${Err}>${error}<//>
<form onSubmit=${this.submitRestore}>
${
installer &&
html`<${InstallSection} disks=${disks} chosen=${chosen} confirm=${confirm}
wipe=${wipe} allowStick=${false}
onPick=${(e) => this.setState({ chosen: e.target.value, wipe: "keep" })}
onConfirm=${(e) => this.setState({ confirm: e.target.value })}
onWipe=${(e) => this.setState({ wipe: e.target.value })} />`
}
${
diskPicked &&
html`<${RestoreSection} file=${this.state.restoreFile} passphrase=${restorePassphrase}
onFile=${(e) => this.setState({ restoreFile: e.target.files[0] || null })}
onPassphrase=${(e) => this.setState({ restorePassphrase: e.target.value })} />
<button type="submit" disabled=${submitting}>
${submitting ? "Validating…" : "Restore and provision"}</button>`
}
</form>
<button type="button" class="wizard-link"
onClick=${() => this.setState({ restoreMode: false, error: "" })}>
Back to the setup form</button>
</div>`;
}

renderSetup() {
if (this.state.restoreMode) return this.renderRestore();
const { cfg, error, jsonText, jsonError } = this.state;
const v = (name) => pathGet(cfg, FIELDS[name].path);
const on = (name) => this.edit(FIELDS[name].path);
Expand Down Expand Up @@ -499,6 +612,9 @@ export class WizardApp extends Component {
: html`Only the answers that cannot be guessed for you. Everything else keeps
its documented default and stays editable from the dashboard.`
}</p>
<p><button type="button" class="wizard-link"
onClick=${() => this.setState({ restoreMode: true, error: "" })}>
Restoring an existing Pithead? Upload its backup instead.</button></p>
<${Err}>${error}<//>
<form onSubmit=${this.submit}>
<${Field} label="What is this machine?">
Expand Down
57 changes: 56 additions & 1 deletion build/dashboard/mining_dashboard/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@

MAX_FAILURES = 5
EXIT_TOKEN_LOCKOUT = 3
# Restore-at-setup upload cap (#909): a Pithead backup holds only config, keys and the
# dashboard database, never the blockchains — 64 MiB is generous headroom over that. Mirrored
# in the pithead script's own RESTORE_MAX_BYTES (client + server, per the spool contract); the
# two can't share a literal across languages, so keep the VALUE in step by hand.
RESTORE_MAX_BYTES = 64 * 1024 * 1024

COOKIE = "wizard_session"

Expand Down Expand Up @@ -303,6 +308,16 @@ def _spool_write_text(name: str, text: str) -> None:
os.replace(tmp, os.path.join(sd, name))


def _spool_write_bytes(name: str, data: bytes) -> None:
"""Binary twin of _spool_write_text — the uploaded archive, never decoded as text."""
sd = spool_dir()
os.makedirs(sd, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=sd, prefix=f".{name}.")
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, os.path.join(sd, name))


def _spool_write_config(cfg: dict) -> None:
_spool_clear_error()
_spool_write_text("config.json", json.dumps(cfg, indent=2))
Expand Down Expand Up @@ -423,6 +438,43 @@ async def submit(request: web.Request) -> web.Response:
return web.json_response({"status": "accepted"})


async def submit_restore(request: web.Request) -> web.Response:
"""Restore-at-setup (#909, #786 sub-issue B): an uploaded encrypted backup archive + its
emergency-kit passphrase, in place of the config form. This server only asks — the archive
and passphrase cross the SAME spool the rest of the wizard uses, and the HOST decrypts,
validates and extracts (firstboot_consume_restore, reusing stack_restore's own machinery).
A rejected archive falls back to the form with the reason, exactly like a rejected config;
the passphrase is written once and the host deletes it immediately either way."""
if not _authed(request):
raise web.HTTPFound("/")
# aiohttp enforces client_max_size (set in make_app) itself, answering 413 before this
# body even finishes reading — no try/except needed to turn that into a response.
form = await request.post()
upload = form.get("archive")
if not isinstance(upload, web.FileField):
return web.json_response({"error": "choose a backup archive to upload"}, status=400)
data = upload.file.read()
if len(data) > RESTORE_MAX_BYTES:
return web.json_response(
{
"error": f"archive too large (max {RESTORE_MAX_BYTES // (1024 * 1024)} MB) — "
"a Pithead backup holds only config, keys and the dashboard database, "
"never the blockchains"
},
status=400,
)
# On the installation medium, disk + wipe ride beside the archive — the SAME gate a typed
# submission takes (see _gate_install_request), so a restore can install too.
if installer_mode():
err = _gate_install_request(dict(form))
if err:
return web.json_response({"error": err}, status=400)
_spool_clear_error()
_spool_write_bytes("restore-archive", data)
_spool_write_text("restore-passphrase", str(form.get("passphrase", "")))
return web.json_response({"status": "accepted"})


async def handoff(request: web.Request) -> web.Response:
"""The credentials card, once the host publishes it: dashboard login, dashboard URL, and the
stratum address. Authed, over the same TLS the operator typed secrets into — a 32-character
Expand Down Expand Up @@ -478,7 +530,9 @@ def make_app(exit_fn=sys.exit) -> web.Application:
# server.py — the wizard serves the same static tree.
mimetypes.add_type("text/javascript", ".mjs")
mimetypes.add_type("text/javascript", ".js")
app = web.Application()
# aiohttp's default (1 MiB) refuses a restore upload before submit_restore's own, clearer
# cap gets a chance to run; a little slack over RESTORE_MAX_BYTES covers multipart overhead.
app = web.Application(client_max_size=RESTORE_MAX_BYTES + 1_048_576)
app["failures"] = 0
app["exit"] = exit_fn
app.add_routes(
Expand All @@ -489,6 +543,7 @@ def make_app(exit_fn=sys.exit) -> web.Application:
web.post("/auth", auth),
web.get("/api/wizard-state", wizard_state),
web.post("/submit", submit),
web.post("/submit-restore", submit_restore),
web.get("/api/handoff", handoff),
web.post("/handoff-ack", handoff_ack),
web.get("/status", status),
Expand Down
Loading