From 6af1fb1816ca5eac0935d9652a1cb795db09f4c9 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Thu, 13 Aug 2026 10:30:31 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(appliance):=20restore-at-setup=20?= =?UTF-8?q?=E2=80=94=20upload=20an=20encrypted=20backup=20instead=20of=20t?= =?UTF-8?q?he=20config=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements #909 (#786 sub-issue B): the setup wizard accepts an uploaded encrypted pithead-backup archive + its passphrase as an alternative to the config form, on both a plain first boot and the installation medium's combined install+configure page. - wizard.py: POST /submit-restore writes the uploaded archive + passphrase to the spool (same "container asks, host decides" split as the rest of the wizard); a size cap (64 MiB — config/keys/db, never chains) is enforced both by aiohttp's client_max_size and an explicit check with a clearer message. On the installation medium the disk/wipe fields ride beside the archive through the same _gate_install_request every other submission takes. - pithead: firstboot_consume_restore decrypts and integrity-verifies the archive (the same magic-byte + full-stream check stack_restore uses), stages the extraction through a mktemp copy, validates the embedded config.json via the same fresh-process parse_and_validate_config call every other config path uses, and only on success commits the whole tree onto "/" and touches $spool/applied — landing in the identical accept path a typed submission takes. A rejected archive (bad passphrase, wrong format, failed integrity, unusable config) writes error.txt and touches nothing, falling back to the form exactly like a rejected config. The passphrase is read once and deleted immediately either way. - wizard.mjs: a "Restoring an existing Pithead? Upload its backup instead." toggle above the setup form, and its own small card (archive upload + passphrase) that respects the installer's disk picker. - docs: docs/appliance.md gets a "Recovering from a backup" section (fresh flash -> restore -> done) and a troubleshooting entry; docs/dev/appliance-wizard.md documents the new spool channel and validate-through-a-copy design; docs/dev/testing-strategy.md records the new tier-1 and tier-4 coverage. - tests: tier-1 stage/spool cases in test_wizard.py (accept, missing archive, oversize, installer disk gates) and wizard.test.mjs (toggle, size cap, multipart body, installer gates); tier-1 host-side firstboot_consume_restore cases in tests/stack/run.sh against a genuine backup archive (accept, bad passphrase, missing passphrase, oversize, malformed archive); a new KVM install-phase restore leg in tests/os/run.sh (real backup off a live machine, uploaded instead of the form, wallet + Tor identity proven restored) — unverified until the next battery run. Co-Authored-By: Claude Fable 5 --- .../mining_dashboard/web/static/wizard.css | 11 ++ .../mining_dashboard/web/static/wizard.mjs | 116 +++++++++++++ build/dashboard/mining_dashboard/wizard.py | 57 +++++- .../dashboard/tests/frontend/wizard.test.mjs | 132 ++++++++++++++ build/dashboard/tests/web/test_wizard.py | 91 +++++++++- docs/appliance.md | 34 ++++ docs/dev/appliance-wizard.md | 35 +++- docs/dev/testing-strategy.md | 2 + pithead | 136 ++++++++++++++- tests/os/run.sh | 162 +++++++++++++++++- tests/stack/run.sh | 105 ++++++++++++ 11 files changed, 875 insertions(+), 6 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/static/wizard.css b/build/dashboard/mining_dashboard/web/static/wizard.css index 60d2c43f..7a8bb988 100644 --- a/build/dashboard/mining_dashboard/web/static/wizard.css +++ b/build/dashboard/mining_dashboard/web/static/wizard.css @@ -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; +} diff --git a/build/dashboard/mining_dashboard/web/static/wizard.mjs b/build/dashboard/mining_dashboard/web/static/wizard.mjs index 49f6183e..462bbab1 100644 --- a/build/dashboard/mining_dashboard/web/static/wizard.mjs +++ b/build/dashboard/mining_dashboard/web/static/wizard.mjs @@ -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", @@ -162,6 +167,25 @@ export const InstallSection = ({ `; }; +// 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`
+

Restore from a backup

+ <${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"> + + + ${file && html`

${file.name} (${Math.round(file.size / 1024)} KB)

`} + <${Field} label="Passphrase"> + + +
`; + export const Installing = ({ status }) => html`

Installing. Takes a few minutes. Do not power it off.

${ @@ -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, }; @@ -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 @@ -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`
+

Upload an encrypted Pithead backup instead of filling in the form below. The machine + decrypts, validates and provisions itself from what it restores.

+ <${Err}>${error} +
+ ${ + 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 })} /> + ` + } +
+ +
`; + } + 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); @@ -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.` }

+

<${Err}>${error}
<${Field} label="What is this machine?"> diff --git a/build/dashboard/mining_dashboard/wizard.py b/build/dashboard/mining_dashboard/wizard.py index 4aa01c9b..7f905347 100644 --- a/build/dashboard/mining_dashboard/wizard.py +++ b/build/dashboard/mining_dashboard/wizard.py @@ -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" @@ -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)) @@ -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 @@ -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( @@ -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), diff --git a/build/dashboard/tests/frontend/wizard.test.mjs b/build/dashboard/tests/frontend/wizard.test.mjs index 1dad79e8..0b137a2a 100644 --- a/build/dashboard/tests/frontend/wizard.test.mjs +++ b/build/dashboard/tests/frontend/wizard.test.mjs @@ -12,6 +12,7 @@ import { Gate, Installing, InstallSection, + RestoreSection, WizardApp, } from "../../mining_dashboard/web/static/wizard.mjs"; import { html } from "../../mining_dashboard/web/static/preact.mjs"; @@ -571,3 +572,134 @@ test("before a disk is chosen, the page asks ONLY that", async () => { assert.match(after, /Type the disk name to confirm/); restore(); }); + +// --- restore-at-setup (#909, #786 sub-issue B): the config form's alternative ----------------- +// Upload an encrypted backup + its passphrase instead of typing a config. Validation is entirely +// host-side (same "container asks, host decides" split); the client wires the two answers up and +// enforces the size cap it can check without a round trip. + +test("restore section: names what a restore does and asks for the archive + passphrase", () => { + const out = renderToString( + html`<${RestoreSection} file=${null} passphrase="" onFile=${() => {}} onPassphrase=${() => {}} />`, + ); + assert.match(out, /Restore from a backup/); + assert.match(out, /emergency-kit passphrase/); + assert.match(out, /type="file"/); + assert.match(out, /type="password"/); +}); + +test("the setup form offers a toggle into restore mode, and back again", async () => { + const { inst, restore } = await appOn([stateFor("setup")]); + const before = renderToString(inst.render()); + assert.match(before, /Restoring an existing Pithead/); + assert.doesNotMatch(before, /Restore from a backup/); + inst.setState({ restoreMode: true }); + const during = renderToString(inst.render()); + assert.match(during, /Restore from a backup/); + assert.doesNotMatch(during, /Payout addresses/); // the normal form is gone, not just hidden + assert.match(during, /Back to the setup form/); + restore(); +}); + +test("restore mode on the installer asks for the disk before revealing the upload fields", async () => { + const { inst, restore } = await appOn([stateFor("installer", { disks: DISKS })]); + inst.setState({ restoreMode: true }); + const before = renderToString(inst.render()); + assert.match(before, /Target disk/); + assert.doesNotMatch(before, /Restore from a backup/); + inst.setState({ chosen: "nvme0n1" }); + const after = renderToString(inst.render()); + assert.match(after, /Restore from a backup/); + restore(); +}); + +test("submitRestore refuses with no file chosen, client-side, before any fetch", async () => { + const { inst, restore } = await appOn([stateFor("setup")]); + inst.setState({ restoreMode: true }); + let fetched = false; + const real = globalThis.fetch; + globalThis.fetch = async () => { + fetched = true; + return { ok: true, status: 200, json: async () => ({}) }; + }; + await inst.submitRestore({ preventDefault() {} }); + globalThis.fetch = real; + assert.equal(fetched, false); + assert.match(inst.state.error, /Choose a backup archive/); + restore(); +}); + +test("submitRestore refuses an oversize file client-side, naming the cap", async () => { + const { inst, restore } = await appOn([stateFor("setup")]); + const huge = new File([new Uint8Array(10)], "backup.tar.gz.enc"); + Object.defineProperty(huge, "size", { value: 64 * 1024 * 1024 + 1 }); + inst.setState({ restoreMode: true, restoreFile: huge }); + let fetched = false; + const real = globalThis.fetch; + globalThis.fetch = async () => { + fetched = true; + return { ok: true, status: 200, json: async () => ({}) }; + }; + await inst.submitRestore({ preventDefault() {} }); + globalThis.fetch = real; + assert.equal(fetched, false); + assert.match(inst.state.error, /too large/); + restore(); +}); + +test("submitRestore posts multipart with the archive and passphrase, then waits like a normal submit", async () => { + const { inst, restore } = await appOn([stateFor("setup")]); + const file = new File([new Uint8Array(4)], "backup.tar.gz.enc"); + inst.setState({ restoreMode: true, restoreFile: file, restorePassphrase: "fixture-pw" }); + let sentUrl = null; + let sentBody = null; + const real = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + if (String(url).includes("/submit-restore")) { + sentUrl = String(url); + sentBody = opts.body; + return { ok: true, status: 200, json: async () => ({}) }; + } + return { ok: true, status: 200, json: async () => stateFor("done"), text: async () => "" }; + }; + await inst.submitRestore({ preventDefault() {} }); + globalThis.fetch = real; + assert.match(sentUrl, /\/submit-restore$/); + assert.ok(sentBody instanceof FormData); + assert.equal(sentBody.get("archive"), file); + assert.equal(sentBody.get("passphrase"), "fixture-pw"); + assert.equal(inst.state.submitting, true); + restore(); +}); + +test("submitRestore on the installer requires a disk and the exact retype, like a normal install", async () => { + const { inst, restore } = await appOn([stateFor("installer", { disks: DISKS })]); + const file = new File([new Uint8Array(4)], "backup.tar.gz.enc"); + inst.setState({ restoreMode: true, restoreFile: file }); + let fetched = false; + const real = globalThis.fetch; + globalThis.fetch = async () => { + fetched = true; + return { ok: true, status: 200, json: async () => ({}) }; + }; + await inst.submitRestore({ preventDefault() {} }); + assert.equal(fetched, false); + assert.match(inst.state.error, /Choose the disk/); + inst.setState({ chosen: "sda", confirm: "wrong" }); + await inst.submitRestore({ preventDefault() {} }); + assert.equal(fetched, false); + assert.match(inst.state.error, /exactly/); + globalThis.fetch = real; + restore(); +}); + +test("a rejected restore returns to restore mode with the reason, not the typed-config form", async () => { + const { inst, restore } = await appOn([ + stateFor("setup", { error: "wrong passphrase or corrupt archive" }), + ]); + inst.setState({ restoreMode: true }); + const out = renderToString(inst.render()); + assert.match(out, /wrong passphrase or corrupt archive/); + assert.match(out, /Restore from a backup/); + restore(); +}); diff --git a/build/dashboard/tests/web/test_wizard.py b/build/dashboard/tests/web/test_wizard.py index 8468e9c8..af4f1063 100644 --- a/build/dashboard/tests/web/test_wizard.py +++ b/build/dashboard/tests/web/test_wizard.py @@ -10,7 +10,7 @@ import json import pytest -from aiohttp import web +from aiohttp import FormData, web from aiohttp.test_utils import TestClient, TestServer, make_mocked_request from mining_dashboard import wizard @@ -789,3 +789,92 @@ async def test_an_unknown_auth_mode_is_ignored(client, seeded): cfg = {"monero": {"wallet_address": "4XYZ"}, "tari": {"wallet_address": "t"}} await client.post("/submit", data={"config": json.dumps(cfg), "auth_mode": "whatever"}) assert not (seeded / "auth-mode").exists() + + +# --- restore-at-setup (#909, #786 sub-issue B): archive + passphrase cross the spool ---------- +# The HOST decrypts/validates/extracts (firstboot_consume_restore, tested at the shell tier); +# this server only asks — the same "container asks, host decides" split every other channel here +# takes. + + +def _archive_form(data=b"Salted__fixture-ciphertext", passphrase="hunter2", **extra): # noqa: S107 + form = FormData() + form.add_field( + "archive", data, filename="backup.tar.gz.enc", content_type="application/octet-stream" + ) + form.add_field("passphrase", passphrase) + for k, v in extra.items(): + form.add_field(k, v) + return form + + +async def test_restore_writes_the_archive_and_passphrase_and_clears_a_previous_error( + client, seeded +): + seeded.joinpath("error.txt").write_text("old error") + await _auth(client) + r = await client.post("/submit-restore", data=_archive_form()) + assert r.status == 200 + assert (seeded / "restore-archive").read_bytes() == b"Salted__fixture-ciphertext" + assert (seeded / "restore-passphrase").read_text() == "hunter2" + assert not (seeded / "error.txt").exists() + + +async def test_restore_requires_an_uploaded_archive(client, seeded): + await _auth(client) + form = FormData() + form.add_field("passphrase", "hunter2") # noqa: S106 + r = await client.post("/submit-restore", data=form) + assert r.status == 400 + assert "archive" in (await r.json())["error"] + assert not (seeded / "restore-archive").exists() + + +async def test_restore_oversize_upload_is_refused_without_spooling(client, seeded, monkeypatch): + monkeypatch.setattr(wizard, "RESTORE_MAX_BYTES", 8) + await _auth(client) + r = await client.post("/submit-restore", data=_archive_form(data=b"more than eight bytes")) + assert r.status == 400 + assert "too large" in (await r.json())["error"] + assert not (seeded / "restore-archive").exists() + + +async def test_restore_unauthed_redirects_and_writes_nothing(client, seeded): + r = await client.post("/submit-restore", data=_archive_form(), allow_redirects=False) + assert r.status == 302 + assert not (seeded / "restore-archive").exists() + + +async def test_aiohttp_itself_refuses_a_body_over_client_max_size(spool, monkeypatch): + # The explicit RESTORE_MAX_BYTES check above covers OUR refusal on a small, easy-to-build + # body; this proves the OTHER half of the cap — aiohttp's own client_max_size (set from + # the same constant, plus multipart-overhead slack, in make_app) answers 413 while the + # body is still being READ, before submit_restore's own check ever runs. RESTORE_MAX_BYTES + # is patched tiny so the slack-inclusive limit (~1 MiB) is crossable by an ordinary payload + # rather than the real 64 MiB default. + monkeypatch.setattr(wizard, "RESTORE_MAX_BYTES", 8) + app = wizard.make_app() + c = TestClient(TestServer(app)) + await c.start_server() + try: + await _auth(c) + r = await c.post("/submit-restore", data=_archive_form(data=b"x" * 2_000_000)) + assert r.status == 413 + finally: + await c.close() + + +async def test_restore_on_the_installer_takes_the_same_disk_gates(client, installer): + # Identical erase discipline to a typed submission: an offered target, the exact retype. + await _auth(client) + r = await client.post( + "/submit-restore", data=_archive_form(disk="sdz", confirm="sdz", wipe="keep") + ) + assert r.status == 400 + assert not (installer / "restore-archive").exists() + r = await client.post( + "/submit-restore", data=_archive_form(disk="nvme0n1", confirm="nvme0n1", wipe="keep") + ) + assert r.status == 200 + assert (installer / "install-request").read_text() == "nvme0n1\tkeep" + assert (installer / "restore-archive").exists() diff --git a/docs/appliance.md b/docs/appliance.md index 98bfc3d1..080bc9fb 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -298,6 +298,36 @@ pithead factory-reset Both ask you to type the reset name before they do anything. The machine reboots itself into setup when the reset is done. +## Recovering from a backup + +Fresh flash, restore, done — if the machine is gone (dead disk, stolen, dropped), a backup +taken beforehand provisions a replacement in one page, with nothing retyped. + +**Take a backup before you need it.** From the machine's console (or a checkout with SSH +access): + +``` +pithead backup +``` + +This writes an encrypted archive under `backups/`: config, wallets, the Tor identity, and the +dashboard's history — never the blockchain, which re-syncs. Type a passphrase when prompted, or +set `PITHEAD_BACKUP_PASSPHRASE` for an unattended run. Copy the resulting +`pithead-backup-*.tar.gz.enc` off the machine and keep the passphrase somewhere else — the +archive is useless without it, and the machine you are backing up is exactly the thing you +might lose next. + +**Restore it at setup.** Write a fresh image, boot the machine, and on the setup page choose +"Restoring an existing Pithead? Upload its backup instead." above the form. Upload the archive +and its passphrase; the machine decrypts, validates, and provisions itself from what it +restores — the same wallets, the same Tor onion address, the same dashboard login and history, +on hardware that has never seen them. This works on the installation medium's combined page +too, alongside the disk choice. + +A wrong passphrase or a damaged archive is rejected with the reason, and the page falls back to +the normal form — restore never blocks setup. Restore only runs at first setup, on a machine +that has no configuration yet; it does not restore over a running install. + ## If something goes wrong **The machine will not boot from the stick.** Almost always Secure Boot — disable it in @@ -325,6 +355,10 @@ address — at least one character is wrong: re-copy it from your wallet rather it by eye. A Tari address rejected as the wrong network came from a testnet wallet; the stack mines mainnet. +**"Wrong passphrase or corrupt archive."** Confirm you copied the whole `.tar.gz.enc` file (a +partial copy fails the same way) and typed the passphrase exactly as it was set when you ran +`pithead backup`. Nothing is written until this check passes — retry from the same page. + **It came back on the old version after an update.** That is the safety mechanism working: the new version did not come up healthy, so the machine reverted. Nothing is lost. Check the dashboard logs, and expect a fixed version. diff --git a/docs/dev/appliance-wizard.md b/docs/dev/appliance-wizard.md index 09b8077f..f2f591c0 100644 --- a/docs/dev/appliance-wizard.md +++ b/docs/dev/appliance-wizard.md @@ -112,6 +112,38 @@ dashboard and answers on no port, so there is nothing to log into and change. Bo beside it and install with the wipe, and it is a blank machine that can pick any role again. A *keep* reinstall deliberately leaves it a rig — keep means keep whatever the role says. +## Restore-at-setup + +A third spool channel, beside the config candidate and the rig request: an uploaded encrypted +backup (`pithead backup`'s own archive format) plus its passphrase, as an alternative to the +config form. `POST /submit-restore` writes `restore-archive` (binary) and `restore-passphrase` +(plain, read once) — on the installation medium the disk/wipe fields ride beside them through +the SAME `_gate_install_request` a typed submission takes. + +`firstboot_consume_restore` (host-side) does the whole job in one call, staged through a COPY — +the same "validate before mutating real state" idiom `consume_preseed_config` already uses: + +1. Magic-byte format check, then a full-stream integrity verify (decrypt + `tar -tzf`) — + identical to `stack_restore`'s own pre-flight — BEFORE anything is extracted. +2. Extract to a `mktemp -d` staging tree, not to the real filesystem yet. +3. Validate the staged `config.json` through the same fresh-process `parse_and_validate_config` + call `firstboot_consume_spool` uses. +4. Only on success: `cp -a` the whole staged tree onto `/` (config, `.env`, Caddyfile, the Tor + data dir, the dashboard database — never the chains, which `stack_backup` excludes by + default) and touch `applied` — the exact contract a typed submission leaves. The firstboot + loop short-circuits straight into that acceptance path; `prepare_directories` (run by the + `setup` it feeds) unconditionally re-chowns every data dir, so restore does not need to. + +A rejected archive (bad passphrase, wrong format, failed integrity, unparseable config) writes +`error.txt` and returns 1 — nothing is extracted, nothing already on disk is touched, and the +page falls back to the form exactly like a rejected typed config. The passphrase file is deleted +at the top of the call, accepted or not; it never outlives the attempt. + +Deliberately reuses `stack_backup`'s archive format (#786 sub-issue A) rather than inventing a +second one, and deliberately does NOT reuse `stack_restore` directly — that CLI command mutates +real state immediately (no staging) and is written for an operator who already has a shell, +which the wizard's pre-provisioning trust level does not assume. + ## The certificate lifecycle **One certificate for the machine's whole life**, at `appliance_tls_dir()` @@ -271,10 +303,11 @@ had a gap between it and the next one. | pure logic | `tests/frontend/configsync.test.mjs` | path access, typed coercion, address/pair guidance | | view rendering | `tests/frontend/wizard.test.mjs` (probes) | each view given its props | | **app orchestration** | `tests/frontend/wizard.test.mjs` (stubbed server) | **stage mapping, the handoff arriving through the poll, refresh-mid-provision, rejection round-trip, request bodies** | -| host logic | `tests/stack/run.sh` | cert minting + idempotence, remote-node preflight, pre-seed, install requests, the digest-keyed image loader, reinstall pre-fill (secret strip + fail-open), the local-miner legs (derived config, sync seeding, boot-leg wiring), the rig-role legs (pool discovery publisher, rig request consumption, the role marker, the rig boot leg's derived config + prebuilt-first + volatile journal + refusals, and both unit conditions) | +| host logic | `tests/stack/run.sh` | cert minting + idempotence, remote-node preflight, pre-seed, install requests, the digest-keyed image loader, reinstall pre-fill (secret strip + fail-open), the local-miner legs (derived config, sync seeding, boot-leg wiring), the rig-role legs (pool discovery publisher, rig request consumption, the role marker, the rig boot leg's derived config + prebuilt-first + volatile journal + refusals, and both unit conditions), restore-at-setup (`firstboot_consume_restore`: accept against a genuine backup archive, wrong passphrase, missing passphrase, oversize, malformed archive, empty spool) | | the artifact | `tests/os/verify-image.sh` | both role paths present in the shipped image: the boot script's fork, the unit conditions that admit each role, the baked prebuilt, no swap anywhere | | the real thing | `tests/os/run.sh --phase provision` | token from the console → submit → handoff → ack → running stack → built-in miner up and its shares accepted → reboot through a corrupted Caddyfile → no failed units → slot self-commit → miner back | | the other real thing | `tests/os/run.sh --phase rig` | the same page answered `RigForge` → rig card with no login → mining from the byte-identical baked binary → **no containers at all** → reboot owned by `pithead-boot`, wizard closed → slot self-commit on an unanswered pool → A/B install, uncommitted rollback, self-commit, persistence | +| the restore leg | `tests/os/run.sh --phase install` | a real encrypted backup taken off a live, fully-provisioned machine, pulled to the harness, uploaded through `/submit-restore` on a FRESH installer boot instead of the form — the wallet address and the Tor onion identity prove restored, not regenerated | The orchestration row is the one that was missing. pytest proved the endpoint published the credentials; a render probe proved the card renders given them; nothing proved the app *asked*. diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md index b1ae8cc3..5a2cbcae 100644 --- a/docs/dev/testing-strategy.md +++ b/docs/dev/testing-strategy.md @@ -174,10 +174,12 @@ no VM needed, run on every image build. | Situation | Trigger | Tier | |---|---|---| | Wizard Q&A → `config.json`, spool round-trips, token gate, error re-display | `test_wizard.py` (pytest) + `wizard.test.mjs` (node) | 1 ✅ | +| Restore-at-setup (#909): archive+passphrase spool contract (accept, bad passphrase, oversize, malformed → fallback) — `test_wizard.py`/`wizard.test.mjs` for the server+client contract, `tests/stack/run.sh` for `firstboot_consume_restore` against a genuine backup archive | `test_wizard.py` + `wizard.test.mjs` + `tests/stack/run.sh` | 1 ✅ | | Image invariants: variant stamp, enabled units, watchdog/governor config, dev-keyring refusal | `tests/os/verify-image.sh` (static, no KVM) | build-time ✅ | | EFI boot; first-boot wizard window; token gate answers | battery `--phase boot` | 4 ✅ | | A/B contract: uncommitted auto-rollback, committed update persists, rollback off a committed slot, containers refreshed, `/data` grew | battery `--phase update` | 4 ✅ | | Install-to-disk copies a COMPLETE system; reinstall keep/fresh paths | battery `--phase install` | 4 ✅ | +| Restore leg: a real encrypted backup off a live machine, uploaded instead of the form on a fresh install, wallet + Tor identity restored not regenerated | battery `--phase install` | 4 (added — unverified until the next battery run) | | Wizard's real HTTP flow provisions the STACK (images verified, Tor-only egress enforced, miner up); unaided reboot return; commit-gate honesty both ways; the migration hold starts the chain only post-commit | battery `--phase provision` | 4 ✅ | | Rig role: no containers, mines from the baked binary, takes an A/B update like a coordinator | battery `--phase rig` | 4 ✅ | | Power cuts mid-write and mid-commit; corrupt bundle — a brick is disqualifying | battery `--phase fault` (opt-in) | 4 ✅ | diff --git a/pithead b/pithead index 5f760997..96293f2f 100755 --- a/pithead +++ b/pithead @@ -72,6 +72,12 @@ readonly REAL_USER="${SUDO_USER:-${USER:-$(id -un)}}" readonly APP_UID=1000 readonly APP_GID=1000 +# Upper bound on a wizard-time restore upload (#909): a Pithead backup holds only config, +# keys and the dashboard database, never the blockchains — 64 MiB is generous headroom over +# that. Mirrored in wizard.py's own cap (client + server, per the spool contract); the two +# can't share a literal across languages, so keep the VALUE in step by hand. +readonly RESTORE_MAX_BYTES=67108864 + REBOOT_REQUIRED=false SKIP_OPTIMIZE=0 SKIP_DEPS=0 @@ -1506,6 +1512,119 @@ firstboot_consume_rig() { # return 0 } +# Consume a restore-at-setup submission (#909, #786 sub-issue B): an uploaded encrypted backup +# archive + its emergency-kit passphrase, in place of the config form. Same decrypt/verify +# machinery as `stack_restore` (magic-byte format check, full-stream integrity verify BEFORE +# anything is touched), but staged through a COPY like consume_preseed_config — the exact +# validate-through-a-copy idiom this codebase already uses for "never mutate real state until +# accepted" — because a wizard-time restore must be able to fail clean and fall back to the +# form, not leave a half-restored Tor identity or dashboard database behind for a follow-up +# manual submit to inherit. rc: 0 landed (config.json + $spool/applied, identical to a typed +# submission — the caller falls into the SAME accept path), 1 rejected (error.txt written), 2 +# none. The passphrase is read once and deleted immediately either way — it never outlives +# this call. +firstboot_consume_restore() { # + local spool="$1" archive="$1/restore-archive" passfile="$1/restore-passphrase" + local pass="" size magic encrypted=0 tmp staged_cfg err + [ -f "$archive" ] || return 2 + { set +x; } 2>/dev/null # xtrace would print the passphrase below + pass=$(cat "$passfile" 2>/dev/null || true) + rm -f "$passfile" # never persisted beyond this attempt, accepted or not + + # Server-side cap already refused an oversize upload before it reached the spool; checked + # again here so a spool file dropped by any other means gets the same honest refusal. + size=$(wc -c <"$archive" 2>/dev/null || echo 0) + if [ "$size" -gt "$RESTORE_MAX_BYTES" ]; then + printf 'backup archive is too large (max %s MB) — a Pithead backup holds only config, keys and the dashboard database, never the blockchains' "$((RESTORE_MAX_BYTES / 1048576))" >"$spool/error.txt" + rm -f "$archive" + return 1 + fi + + magic=$(head -c 8 "$archive" | od -An -tx1 | tr -d ' \n') + case "$magic" in + 53616c7465645f5f) encrypted=1 ;; # "Salted__" + 1f8b*) ;; # gzip + *) + printf 'not a Pithead backup archive' >"$spool/error.txt" + rm -f "$archive" + return 1 + ;; + esac + + if [ "$encrypted" -eq 1 ]; then + if [ -z "$pass" ]; then + printf 'this archive is encrypted — enter its passphrase' >"$spool/error.txt" + rm -f "$archive" + return 1 + fi + local plain_magic + plain_magic=$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ + -pass fd:3 -in "$archive" 2>/dev/null 3< <(printf '%s' "$pass") | + head -c 2 | od -An -tx1 | tr -d ' \n') || true + if [ "$plain_magic" != "1f8b" ]; then + printf 'wrong passphrase or corrupt archive' >"$spool/error.txt" + rm -f "$archive" + pass="" + return 1 + fi + if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ + -pass fd:3 -in "$archive" 2>/dev/null 3< <(printf '%s' "$pass") | + tar -tzf - >/dev/null 2>&1; then + printf 'archive fails integrity verification (tampered or truncated)' >"$spool/error.txt" + rm -f "$archive" + pass="" + return 1 + fi + else + if ! tar -tzf "$archive" >/dev/null 2>&1; then + printf 'archive fails integrity verification (tampered or truncated)' >"$spool/error.txt" + rm -f "$archive" + return 1 + fi + fi + + tmp=$(mktemp -d) || { + printf 'could not stage the restore' >"$spool/error.txt" + rm -f "$archive" + return 1 + } + if [ "$encrypted" -eq 1 ]; then + openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \ + -pass fd:3 -in "$archive" 2>/dev/null 3< <(printf '%s' "$pass") | tar -xzf - -C "$tmp" + else + tar -xzf "$archive" -C "$tmp" + fi + pass="" + rm -f "$archive" + + # The archive stores paths relative to "/" (same convention `stack_backup`/`stack_restore` + # use), so the staged config lands at exactly $PWD/$CONFIG_FILE underneath $tmp. + staged_cfg="$tmp/${PWD#/}/$CONFIG_FILE" + if [ ! -f "$staged_cfg" ] || ! jq -e . "$staged_cfg" >/dev/null 2>&1; then + rm -rf "$tmp" + printf 'archive does not contain a usable configuration' >"$spool/error.txt" + return 1 + fi + # Validated through the COPY — parse_and_validate_config fills in generated fields as it + # goes (consume_preseed_config's own reasoning), and only a config that survives this is + # ever promoted to the real config.json. + if ! err=$(PITHEAD_CONFIG_FILE="$staged_cfg" bash -c "source '${BASH_SOURCE[0]}' && parse_and_validate_config" 2>&1); then + rm -rf "$tmp" + printf '%s' "$err" | tail -n 2 | tr -d '[:cntrl:]' | tail -c 240 >"$spool/error.txt" + return 1 + fi + + # Commit: everything the archive carried (config.json, .env, Caddyfile, the Tor data dir, + # the dashboard database) lands at its real absolute path in one move — the same + # destination `tar -xzf archive -C /` would use directly, just proven safe first. + # prepare_directories (run by the `setup` this feeds) unconditionally re-chowns every data + # dir afterwards, so ownership here does not need fixing up by hand. + cp -a "$tmp"/. / + rm -rf "$tmp" + touch "$spool/applied" + return 0 +} + # --- disk installer (appliance only) ------------------------------------------------------- # The appliance can boot from the installation medium itself. When it does, the wizard leads with # a disk picker instead of the setup form: the operator installs first, reboots, and configures @@ -1901,7 +2020,8 @@ firstboot_wizard() { # failed-provisioning path just wrote for the reopened page. rm -f "$spool/handoff.json" "$spool/handoff-ack" "$spool/installing" \ "$spool/installed" "$spool/applied" "$spool/install-request" \ - "$spool/rig-request.json" "$spool/role" + "$spool/rig-request.json" "$spool/role" \ + "$spool/restore-archive" "$spool/restore-passphrase" # A pre-seeded token is the operator's own choice and stays fixed across restarts of # this loop; without one, mint a fresh secret every round. token=$(preseed_token) || token=$(wizard_mint_token) @@ -2057,7 +2177,19 @@ firstboot_wizard() { provision_rig_miner || true return 0 fi - if firstboot_consume_spool "$spool"; then + # The restore-from-backup alternative (#909) travels its own spool channel, same as + # the rig role above — but on acceptance it has ALREADY written config.json and + # touched applied (firstboot_consume_restore does the whole job, staged through a + # copy), so a successful restore short-circuits straight into the identical accept + # path a typed submission takes, below. + local rec=0 + firstboot_consume_restore "$spool" || rec=$? + if [ "$rec" -eq 1 ]; then + warn "Restore rejected — the page shows the reason." + sleep 2 + continue + fi + if [ "$rec" -eq 0 ] || firstboot_consume_spool "$spool"; then # Reachability before commitment: a remote node that cannot be dialed fails HERE, # on the page, with the attempt kept for editing — not minutes into provisioning. local pf_err diff --git a/tests/os/run.sh b/tests/os/run.sh index 0d7df294..f1dc7a44 100755 --- a/tests/os/run.sh +++ b/tests/os/run.sh @@ -1032,7 +1032,167 @@ phase_install() { else bad "the reinstalled machine still serves the old dashboard image (got: $dm)" fi - rm -f "$target_disk" + + # ---- restore-at-setup leg (#909, #786 sub-issue B) ----------------------------------- + # A genuine encrypted backup pulled off THIS live, fully-provisioned machine seeds a + # totally fresh disk through the wizard's upload path instead of the config form — the + # disaster-recovery loop #908 (export) opens and this closes. Real archive, real upload + # over curl -F, real decrypt+extract on the guest, and the identity (wallet, Tor onion) + # must survive — proof the "restored config drives provisioning as if pre-seeded" promise + # actually holds, which nothing below tier 4 can prove. + local restore_archive="/tmp/pithead-os-restore-test.tar.gz.enc" + local restore_pass="pithead-os-restore-test-passphrase" # fixture value, not real secret material + rm -f "$restore_archive" + if _ssh "cd /data/pithead && PITHEAD_BACKUP_PASSPHRASE=$restore_pass ./pithead backup -y >/tmp/restore-backup.log 2>&1"; then + ok "restore leg: took a real encrypted backup off the live machine" + else + bad "restore leg: could not take the source backup" + rm -f "$target_disk" + return + fi + local remote_archive + remote_archive=$(_ssh "ls /data/pithead/backups/pithead-backup-*.tar.gz.enc" | tail -1) + scp -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -q \ + "root@$ip:$remote_archive" "$restore_archive" || { + bad "restore leg: could not pull the backup archive off the guest" + rm -f "$target_disk" + return + } + local orig_onion + orig_onion=$(_ssh "grep MONERO_ONION_ADDRESS /data/pithead/.env" | cut -d= -f2) + _ssh "systemctl poweroff" 2>/dev/null || true + sleep 8 + vm_destroy + + local restore_target="/srv/code/bench-vm/pithead-restore-target.img" + rm -f "$restore_target" + qemu-img create -f raw "$restore_target" 30G >/dev/null + img=$(_build_image v1) || { + bad "restore leg: image build failed" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + cp "$img" "$DISK" + qemu-img resize "$DISK" 16G >/dev/null 2>&1 || true + : >"$SERIAL" + virt-install --name "$VM" --memory 16384 --vcpus 4 --cpu host-passthrough \ + --osinfo debian12 \ + --boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \ + --import \ + --disk "path=$DISK,format=raw,bus=usb,removable=on,boot.order=1" \ + --disk "path=$restore_target,format=raw,bus=virtio,boot.order=2" \ + --network network=default,model=virtio --graphics none \ + --serial "file,path=$SERIAL" --noautoconsole >/dev/null 2>&1 || { + bad "restore leg: virt-install failed for the fresh installer boot" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + _wait_dhcp_ip 120 + _wait_ssh 240 || { + bad "restore leg: installer guest never answered SSH" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + _ssh "for i in \$(seq 36); do [ -s /data/pithead/data/firstboot/disks.tsv ] && exit 0; sleep 5; done; exit 1" || { + bad "restore leg: installer never reached installer mode" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + token="" + tries2=0 + while [ -z "$token" ] && [ "$tries2" -lt 40 ]; do + token=$(tr -d '\r' <"$SERIAL" | grep -oE 'pit-[A-Z0-9]{6}' | tail -1) + [ -n "$token" ] || sleep 3 + tries2=$((tries2 + 1)) + done + [ -n "$token" ] || { + bad "restore leg: no one-time token on the installer console" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + _wait_setup_page 120 || { + bad "restore leg: wizard never served its gate page" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + jar=$(mktemp) + curl -fsSk -c "$jar" -d "token=$token" "https://$ip/auth" -o /dev/null 2>/dev/null && + grep -q "wizard_session" "$jar" || { + bad "restore leg: auth failed" + rm -f "$jar" "$target_disk" "$restore_archive" "$restore_target" + return + } + # The combined leg: ONE upload carries the archive, its passphrase, AND the disk choice — + # the same _gate_install_request every other installer submission takes. + scode=$(curl -sSk -b "$jar" \ + -F "archive=@$restore_archive" -F "passphrase=$restore_pass" \ + -F "disk=vda" -F "confirm=vda" -F "wipe=keep" \ + "https://$ip/submit-restore" -o /dev/null -w '%{http_code}' 2>/dev/null) + [ "$scode" = "200" ] || { + bad "restore leg: upload did not return 200 (got ${scode:-none})" + rm -f "$jar" "$target_disk" "$restore_archive" "$restore_target" + return + } + ok "restore leg: uploaded the backup archive instead of the form" + tries2=0 + while [ "$tries2" -lt 24 ]; do + curl -sSk -b "$jar" -m 5 "https://$ip/api/handoff" 2>/dev/null | grep -q '"password"' && break + sleep 5 + tries2=$((tries2 + 1)) + done + [ "$tries2" -lt 24 ] || { + bad "restore leg: no credentials card after the restore — the restored config never drove provisioning" + rm -f "$jar" "$target_disk" "$restore_archive" "$restore_target" + return + } + ok "restore leg: the restored config drove provisioning to a credentials card" + curl -sSk -b "$jar" -X POST "https://$ip/handoff-ack" -o /dev/null 2>/dev/null + rm -f "$jar" + tries2=0 + while [ "$tries2" -lt 60 ]; do + [ "$(virsh domstate "$VM" 2>/dev/null)" = "shut off" ] && break + sleep 5 + tries2=$((tries2 + 1)) + done + if [ "$(virsh domstate "$VM" 2>/dev/null)" = "shut off" ]; then + ok "restore leg: installed and switched itself off" + else + bad "restore leg: never powered off after the ack" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + fi + vm_destroy + : >"$SERIAL" + virt-install --name "$VM" --memory 16384 --vcpus 4 --cpu host-passthrough \ + --osinfo debian12 \ + --boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \ + --import --disk "path=$restore_target,format=raw,bus=virtio" \ + --network network=default,model=virtio --graphics none \ + --serial "file,path=$SERIAL" --noautoconsole >/dev/null 2>&1 || { + bad "restore leg: virt-install failed for the restored machine" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + _wait_dhcp_ip 120 + _wait_ssh 300 || { + bad "restore leg: restored machine never answered SSH" + rm -f "$target_disk" "$restore_archive" "$restore_target" + return + } + ok "restore leg: the restored machine boots from the fresh disk" + if _ssh "grep -q \"$HARNESS_WALLET\" /data/pithead/config.json"; then + ok "restore leg: restored machine carries the ORIGINAL wallet address, not a fresh one" + else + bad "restore leg: restored machine's config does not carry the original wallet" + fi + local new_onion + new_onion=$(_ssh "grep MONERO_ONION_ADDRESS /data/pithead/.env" | cut -d= -f2) + if [ -n "$new_onion" ] && [ "$new_onion" = "$orig_onion" ]; then + ok "restore leg: restored machine kept the ORIGINAL Tor identity, not a regenerated one" + else + bad "restore leg: onion address changed ($orig_onion -> ${new_onion:-none}) — identity was not restored" + fi + rm -f "$target_disk" "$restore_archive" "$restore_target" } phase_provision() { diff --git a/tests/stack/run.sh b/tests/stack/run.sh index e64e325e..0c00952a 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -3771,6 +3771,111 @@ printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","n out=$(run_sourced "$SANDBOX" render_quadlet_units "$SANDBOX/no-such.env" "$SANDBOX/quadlet-none" 2>&1) assert_contains "render-quadlet missing env errors" "$out" "env file not found" +echo "== unit: firstboot_consume_restore — restore-at-setup (#909, #786 sub-issue B) ==" +# A genuine encrypted backup (the same `pithead backup` #908 rides), fed through the wizard's +# restore-consume exactly as the host loop would: decrypt, verify BEFORE anything is touched, +# validate the embedded config through a copy, and land it as a normal accepted config.json — +# the SAME contract firstboot_consume_spool gives a typed submission. Physical path (#695): see +# the backup/restore black-box block above for why `pwd -P` matters here too. +RS="$(cd "$SANDBOX" && pwd -P)/restore-consume" +mkdir -p "$RS/build/tari" "$RS/data/tor" "$RS/data/dashboard" "$RS/bin" +cp "$STACK" "$RS/pithead" +cp "$ROOT/build/tari/config.toml.template" "$RS/build/tari/" +cat >"$RS/bin/docker" <<'EOF' +#!/usr/bin/env bash +case "$*" in + "compose ps --status running -q") exit 0 ;; # empty output -> stack treated as not running +esac +exit 0 +EOF +cat >"$RS/bin/sudo" <<'EOF' +#!/usr/bin/env bash +[ "$1" = "chown" ] && exit 0 +exec "$@" +EOF +chmod +x "$RS/bin/docker" "$RS/bin/sudo" +cat >"$RS/.env" <"$RS/config.json" +printf 'CADDY-ORIG\n' >"$RS/Caddyfile" +printf 'ONIONKEY-ORIG\n' >"$RS/data/tor/hs_ed25519_secret_key" +printf 'DBDATA-ORIG\n' >"$RS/data/dashboard/dashboard.db" +out="$(cd "$RS" && PATH="$RS/bin:$PATH" PITHEAD_BACKUP_PASSPHRASE=hunter2 ./pithead backup -y 2>&1)" +rc=$? +assert_rc "restore fixture: backup exits 0" "$rc" "0" +rarchive="$(ls "$RS"/backups/pithead-backup-*.tar.gz.enc 2>/dev/null | head -1)" +{ [ -n "$rarchive" ] && [ -f "$rarchive" ]; } && ok "restore fixture: encrypted archive created" || bad "restore fixture: encrypted archive created" "no .enc archive" + +RSPOOL="$RS/data/firstboot-test" +mkdir -p "$RSPOOL" +rm -f "$RS/config.json" + +# 1) Accept: the right passphrase decrypts, verifies, validates and lands config.json — settings, +# the Tor identity and the dashboard database all come back, and neither the archive nor the +# passphrase survive the attempt. +cp "$rarchive" "$RSPOOL/restore-archive" +printf 'hunter2' >"$RSPOOL/restore-passphrase" # test fixture, not a real secret +out=$(cd "$RS" && PATH="$RS/bin:$PATH" run_sourced "$RS" firstboot_consume_restore "$RSPOOL" && echo rc0) +assert_contains "valid restore accepted" "$out" "rc0" +assert_eq "valid restore installs config.json" "$([ -f "$RS/config.json" ] && echo yes)" "yes" +assert_contains "valid restore carries the original wallet" "$(cat "$RS/config.json" 2>/dev/null)" "$WALLET" +assert_eq "valid restore brings back the Caddyfile" "$(cat "$RS/Caddyfile" 2>/dev/null)" "CADDY-ORIG" +assert_eq "valid restore brings back the dashboard db" "$(cat "$RS/data/dashboard/dashboard.db" 2>/dev/null)" "DBDATA-ORIG" +assert_eq "applied marker set" "$([ -f "$RSPOOL/applied" ] && echo yes)" "yes" +assert_eq "the archive is consumed" "$([ -f "$RSPOOL/restore-archive" ] || echo gone)" "gone" +assert_eq "the passphrase is never retained" "$([ -f "$RSPOOL/restore-passphrase" ] || echo gone)" "gone" +rm -f "$RSPOOL/applied" "$RS/config.json" # clean slate for the rejection cases below + +# 2) Bad passphrase: rejected before anything is touched. +printf 'CORRUPTED\n' >"$RS/Caddyfile" +cp "$rarchive" "$RSPOOL/restore-archive" +printf 'not-the-passphrase' >"$RSPOOL/restore-passphrase" # test fixture +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "wrong passphrase rejected" "$out" "rc1" +assert_contains "wrong passphrase names the cause" "$(cat "$RSPOOL/error.txt" 2>/dev/null)" "assphrase" +assert_eq "wrong passphrase leaves live files untouched" "$(cat "$RS/Caddyfile")" "CORRUPTED" +assert_eq "the archive is consumed even on rejection" "$([ -f "$RSPOOL/restore-archive" ] || echo gone)" "gone" +assert_eq "the passphrase is never retained even on rejection" "$([ -f "$RSPOOL/restore-passphrase" ] || echo gone)" "gone" +printf 'CADDY-ORIG\n' >"$RS/Caddyfile" +rm -f "$RSPOOL/error.txt" + +# 3) Encrypted archive, no passphrase supplied at all. +cp "$rarchive" "$RSPOOL/restore-archive" +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "missing passphrase rejected" "$out" "rc1" +assert_contains "missing passphrase names the cause" "$(cat "$RSPOOL/error.txt" 2>/dev/null)" "passphrase" +rm -f "$RSPOOL/error.txt" + +# 4) Oversize: refused on SIZE alone, before any decrypt/extract — content is irrelevant. +truncate -s 67108865 "$RSPOOL/restore-archive" +printf 'hunter2' >"$RSPOOL/restore-passphrase" # test fixture +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "oversize archive rejected" "$out" "rc1" +assert_contains "oversize archive names the cap" "$(cat "$RSPOOL/error.txt" 2>/dev/null)" "too large" +rm -f "$RSPOOL/error.txt" + +# 5) Malformed: neither the encrypted magic nor gzip's — falls back exactly like a rejected +# config, never blocking setup. +printf 'garbage-not-an-archive' >"$RSPOOL/restore-archive" +printf 'hunter2' >"$RSPOOL/restore-passphrase" # test fixture +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "malformed archive rejected" "$out" "rc1" +assert_contains "malformed archive names the problem" "$(cat "$RSPOOL/error.txt" 2>/dev/null)" "not a Pithead backup archive" +assert_eq "malformed archive leaves config.json untouched" "$([ -f "$RS/config.json" ] || echo gone)" "gone" +rm -f "$RSPOOL/error.txt" + +# 6) Nothing to consume. +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "empty spool is rc2" "$out" "rc2" +rm -rf "$RS" + echo "== black-box: doctor --json + support-bundle (#77 phase 1) ==" # doctor --json: valid JSON on stdout, the human report on stderr, counters consistent with the # check list (info lines are context, not verdicts). From 5b4ee02f207d65bbf494359236d71a493449d44d Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Thu, 13 Aug 2026 11:24:42 -0500 Subject: [PATCH 2/2] fix(wizard): reject restore archives with escaping paths or links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifier caught it: firstboot_consume_restore extracted to a temp dir then cp -a'd the tree to /, but only checked config.json landed — an absolute path, a .. component, or a symlink/hardlink member could write outside the restore set. Modern tar refuses these, but the destination is the filesystem root, so audit the member list and fail closed regardless of tar version. Tier-1 cases cover a symlink and an absolute-path member (both refused, live files untouched). Co-Authored-By: Claude Fable 5 --- pithead | 23 +++++++++++++++++++++++ tests/stack/run.sh | 23 ++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/pithead b/pithead index 96293f2f..90d92123 100755 --- a/pithead +++ b/pithead @@ -1583,6 +1583,29 @@ firstboot_consume_restore() { # fi fi + # Path-safety audit BEFORE staging: the accepted tree is copied to "/" below, so a member with + # an absolute path, a ".." component, or a symlink/hardlink could write outside the restore + # set (a symlink extracted first, then written through). Modern tar refuses these, but the + # destination is the filesystem root — do not trust the tar version. A Pithead backup carries + # only regular files and dirs under known prefixes, so any escaping path or link is corruption + # or an attack: fail closed. Lists names (whole-line, absolute/".." check) and the verbose + # form (link check) separately, because a name with spaces is unparseable from `tar -tv`. + local rnames rlinks + if [ "$encrypted" -eq 1 ]; then + rnames=$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass fd:3 -in "$archive" 2>/dev/null 3< <(printf '%s' "$pass") | tar -tz 2>/dev/null) + rlinks=$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass fd:3 -in "$archive" 2>/dev/null 3< <(printf '%s' "$pass") | tar -tvz 2>/dev/null) + else + rnames=$(tar -tzf "$archive" 2>/dev/null) + rlinks=$(tar -tvzf "$archive" 2>/dev/null) + fi + if printf '%s\n' "$rnames" | grep -qE '^/|(^|/)\.\.(/|$)' || + printf '%s\n' "$rlinks" | grep -qE '^l| -> | link to '; then + rm -f "$archive" + pass="" + printf 'archive contains unsafe paths or links — refusing to restore' >"$spool/error.txt" + return 1 + fi + tmp=$(mktemp -d) || { printf 'could not stage the restore' >"$spool/error.txt" rm -f "$archive" diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 0c00952a..6674752d 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -3871,7 +3871,28 @@ assert_contains "malformed archive names the problem" "$(cat "$RSPOOL/error.txt" assert_eq "malformed archive leaves config.json untouched" "$([ -f "$RS/config.json" ] || echo gone)" "gone" rm -f "$RSPOOL/error.txt" -# 6) Nothing to consume. +# 6) Path-traversal / symlink defense: a well-formed gzip archive (passes the magic + integrity +# checks) whose members escape the restore set must be refused BEFORE anything is staged to "/". +# A Pithead backup is only regular files under known prefixes, so a symlink or a ".." member is an +# attack. Built with real tar so the guard faces the exact bytes it would on a box. +MAL="$RS/mal" +mkdir -p "$MAL/pithead" +printf 'CADDY-ORIG\n' >"$RS/Caddyfile" # live file the escape would try to clobber via symlink +ln -s /etc/shadow "$MAL/pithead/Caddyfile" # symlink escape +(cd "$MAL" && tar -czf "$RSPOOL/restore-archive" pithead) 2>/dev/null +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "a symlink member is refused" "$out" "rc1" +assert_contains "the symlink refusal names the cause" "$(cat "$RSPOOL/error.txt" 2>/dev/null)" "unsafe paths or links" +assert_eq "a symlink archive touches nothing" "$(cat "$RS/Caddyfile")" "CADDY-ORIG" +rm -f "$RSPOOL/error.txt" "$RSPOOL/restore-passphrase" +# Absolute-path member (stored with a leading slash via -P): would land at /… on cp -a. +printf 'EVIL\n' >"$MAL/evil" +(cd "$MAL" && tar -Pczf "$RSPOOL/restore-archive" "$MAL/evil") 2>/dev/null +out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") +assert_contains "an absolute-path member is refused" "$out" "rc1" +rm -f "$RSPOOL/error.txt" + +# 7) Nothing to consume. out=$(run_sourced "$RS" firstboot_consume_restore "$RSPOOL" || echo "rc$?") assert_contains "empty spool is rc2" "$out" "rc2" rm -rf "$RS"