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..90d92123 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,142 @@ 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 + + # 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" + 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 +2043,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 +2200,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..6674752d 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -3771,6 +3771,132 @@ 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) 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" + 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).