diff --git a/build/dashboard/tests/service/test_data_service.py b/build/dashboard/tests/service/test_data_service.py index cc483416..2b3d7aa3 100644 --- a/build/dashboard/tests/service/test_data_service.py +++ b/build/dashboard/tests/service/test_data_service.py @@ -858,9 +858,10 @@ class TestMoneroSyncBlipHolding: mid-sync — the bench box flashed 'Synced 0 / 1' while monerod ground through 99%.""" def _svc(self): - from mining_dashboard.service.data_service import DataService from unittest.mock import MagicMock + from mining_dashboard.service.data_service import DataService + return DataService(MagicMock(), MagicMock(), MagicMock()) def test_blip_holds_the_last_real_reading(self): diff --git a/build/dashboard/tests/web/test_wizard.py b/build/dashboard/tests/web/test_wizard.py index beb0b754..9386a551 100644 --- a/build/dashboard/tests/web/test_wizard.py +++ b/build/dashboard/tests/web/test_wizard.py @@ -383,6 +383,49 @@ async def test_keep_everything_submits_no_config_and_gets_no_handoff_machinery(c assert not (installer / "last-attempt.json").exists() +async def test_installer_state_serves_a_published_prefill(client, seeded, installer): + # A reinstall's pre-fill: the HOST mounts the target's previous /data read-only, strips + # the secrets and publishes the remainder as last-attempt.json — the state API is the only + # channel to the page, and it must merge that pre-fill over the defaults exactly as the + # pre-seed path's does. + installer.joinpath("last-attempt.json").write_text( + json.dumps({"monero": {"wallet_address": "4PREV"}, "tari": {"mode": "remote"}}) + ) + await _auth(client) + s = await (await client.get("/api/wizard-state")).json() + assert s["stage"] == "installer" + assert s["config"]["monero"]["wallet_address"] == "4PREV" + assert s["config"]["tari"]["mode"] == "remote" + assert s["config"]["monero"]["prune"] is True # the defaults still fill the gaps + + +async def test_a_broken_prefill_degrades_to_defaults_not_an_error(client, seeded, installer): + # The pre-fill is pure convenience: an unparseable file means the form opens on the + # documented defaults, with no error shown and nothing blocked. + installer.joinpath("last-attempt.json").write_text("{not json") + await _auth(client) + r = await client.get("/api/wizard-state") + assert r.status == 200 + s = await r.json() + assert s["config"]["monero"]["wallet_address"] == "" + assert s["error"] is None + + +async def test_keep_submit_leaves_a_published_prefill_alone(client, installer): + # keep collapses the form and no config crosses. The pre-fill exists for the fresh/data + # paths where the operator re-answers; an untouched keep must neither consume it nor + # write anything beside the install request. + installer.joinpath("last-attempt.json").write_text('{"monero": {"wallet_address": "4PREV"}}') + await _auth(client) + r = await client.post("/submit", data={"disk": "sda", "confirm": "sda", "wipe": "keep"}) + assert r.status == 200 + assert (installer / "install-request").read_text() == "sda\tkeep" + assert not (installer / "config.json").exists() + assert json.loads((installer / "last-attempt.json").read_text()) == { + "monero": {"wallet_address": "4PREV"} + } + + async def test_keep_with_a_crafted_config_still_takes_the_keep_branch(client, installer): await _auth(client) r = await client.post( diff --git a/docs/appliance.md b/docs/appliance.md index c69d239c..be8dab3b 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -139,6 +139,13 @@ with what is on it: - **Wipe everything** — chains included. The new install re-downloads them from scratch, and the full set of questions is asked. +On either fresh start, the page opens with the previous install's answers already filled in — +wallet addresses, node locations, pool tier — read from the disk you are about to wipe, so you +only change what you came to change. Its secrets never make the trip: the dashboard login, +node passwords, view keys, worker tokens and alert credentials are left out, and anything the +machine generates for itself is generated anew. If the old configuration cannot be read, the +page opens blank. + Type the disk's name to confirm. ### The questions diff --git a/docs/dev/appliance-wizard.md b/docs/dev/appliance-wizard.md index b61704f5..10bdc792 100644 --- a/docs/dev/appliance-wizard.md +++ b/docs/dev/appliance-wizard.md @@ -48,6 +48,17 @@ and stages the accepted config onto the target's ESP. The first boot from disk p headlessly through the pre-seed path; there is no second wizard. A missing ack installs nothing: the erase waits for a human, and a timeout hands the form back intact. +On a reinstall the form opens with the previous machine's answers. When the inventory holds +exactly one disk that already carries an install, the host mounts its data partition +read-only, reads the previous `config.json`, strips every secret +(`strip_config_secrets` — the login, worker inventory, node credentials, view keys, alert +tokens, the ssh key) and publishes the remainder through the same `last-attempt.json` channel +the pre-seed path fills; an operator pre-seed outranks it. Derived fresh each boot and cleared +first — a fleet stick's spool survives between machines, and machine 2 must never open on +machine 1's answers. Pure convenience: any failure (no config, unreadable, ambiguous targets) +opens the form blank and blocks nothing. On keep, none of it matters — the survivor config +wins and no config crosses. + **Why the server owns this.** Two defects came from the client deciding: - A client-side stage flag was set with `setState` and read back on the next line. Preact @@ -136,7 +147,7 @@ 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 | +| host logic | `tests/stack/run.sh` | cert minting + idempotence, remote-node preflight, pre-seed, install requests, reinstall pre-fill (secret strip + fail-open) | | the real thing | `tests/os/run.sh --phase provision` | token from the console → submit → handoff → ack → running stack → reboot through a corrupted Caddyfile → no failed units → slot self-commit | The orchestration row is the one that was missing. pytest proved the endpoint published the diff --git a/pithead b/pithead index ae478733..e1d5a608 100755 --- a/pithead +++ b/pithead @@ -1369,6 +1369,60 @@ publish_disk_inventory() { # mv -f "$tmp" "$1/disks.tsv" } +# The strip a previous install's config passes through before any of it may be SHOWN (#794): +# every leaf CONTROL_SECRET_PATHS names, plus the whole objects whose value IS access — the +# dashboard login, the worker inventory (its per-entry tokens live in a variable-length array +# the fixed paths cannot reach), alert credentials, the ssh key. Wallet addresses, node modes +# and hosts, the pool tier stay: those are the answers the operator came back for, and none of +# them is a secret. Errs toward stripping more — a lost convenience beats a leaked credential. +# rc non-zero when the file is not a usable config object; callers treat that as "no pre-fill". +strip_config_secrets() { # -> stripped JSON on stdout + jq -e --argjson paths "$CONTROL_SECRET_PATHS" ' + delpaths($paths) + | del(.dashboard.auth, .dashboard.workers, .workers, .telegram, + .healthchecks, .notifications, .ssh, .tari.spend_public_key)' "$1" 2>/dev/null +} + +# Reinstall pre-fill (#794). When the offered targets include exactly one disk that already +# carries an install, mount its data partition READ-ONLY, read the previous config, strip the +# secrets and publish the remainder as the page's starting point — the same last-attempt.json +# channel the pre-seed path fills. The operator sees the answers the machine already knew and +# changes what they came to change; a bench box once had its remote-Tari setting silently +# defaulted back to a local chain by this gap. Host-side only (the container never mounts +# anything) and pure convenience: every failure path returns 1 and the page simply opens +# blank — nothing here may block an install. rc 0 = a pre-fill was published. +prefill_from_previous_install() { # + local spool="$1" disk part mnt cfg tmp rc=1 + disk=$(awk -F'\t' '$5 == "pithead-with-data" {print $1}' "$spool/disks.tsv" 2>/dev/null) + # Two candidates would make the pre-fill a guess about WHICH machine's answers; offer none. + [ -n "$disk" ] && [ "$(printf '%s\n' "$disk" | wc -l)" -eq 1 ] || return 1 + part=$(lsblk -lnpo NAME,PARTLABEL "/dev/$disk" 2>/dev/null | awk '$2 == "data" {print $1; exit}') + [ -n "$part" ] || return 1 + mnt=$(mktemp -d) || return 1 + # -t ext4 pinned: the appliance only ever formats data partitions as ext4, and an + # auto-probed type would let an arbitrary disk pick which filesystem parser the kernel + # runs against its content. + if mount -t ext4 -o ro,nosuid,nodev,noexec "$part" "$mnt" 2>/dev/null; then + cfg="$mnt/pithead/config.json" + tmp="$spool/.last-attempt.json.$$" + # The -L guards close a symlink escape: a crafted disk could point pithead/ or + # config.json at a file on the RUNNING host, and jq follows symlinks — the read-only + # mount keeps both components stable under the checks. The size cap bounds what an + # arbitrary disk can make this boot path chew on; jq's parse (and the object shape + # it needs) rejects everything else. + if [ ! -L "$mnt/pithead" ] && [ ! -L "$cfg" ] && + [ -f "$cfg" ] && [ "$(wc -c <"$cfg" 2>/dev/null || echo 0)" -le 1048576 ] && + [ -s "$cfg" ] && strip_config_secrets "$cfg" >"$tmp"; then + chown 1000:1000 "$tmp" 2>/dev/null || true + mv -f "$tmp" "$spool/last-attempt.json" && rc=0 + fi + rm -f "$tmp" + umount "$mnt" 2>/dev/null || umount -l "$mnt" 2>/dev/null || true + fi + rmdir "$mnt" 2>/dev/null || true + return "$rc" +} + # Dial every remote node the candidate names, BEFORE the wizard closes and provisioning churns. # A wrong host or a firewalled port otherwise surfaces minutes later as a failed setup — a bench # session read exactly that as a crash. TCP connect only (bash /dev/tcp): proves reachability, @@ -1487,9 +1541,15 @@ firstboot_wizard() { installer=1 publish_disk_inventory "$spool" log "Running from the installation medium — install and configure on one page." + # The pre-fill is derived fresh every boot, never inherited: the stick's spool + # survives between machines, and machine 2 must not open on machine 1's answers — + # the same staleness rule the per-session flow-marker clear below enforces. + rm -f "$spool/last-attempt.json" if [ "$operator_preseed" -eq 1 ] && jq -c . "$PRESEED_DIR/pithead-config.json" >"$spool/last-attempt.json" 2>/dev/null; then chown 1000:1000 "$spool/last-attempt.json" 2>/dev/null || true log "Pre-seeded configuration found — the page opens with it filled in." + elif prefill_from_previous_install "$spool"; then + log "Found the previous installation's settings on the target disk — the page opens with them filled in (secrets left out)." fi elif boot_is_removable; then # Setup will proceed, but the operator should know what they are standing on: a USB stick diff --git a/tests/os/run.sh b/tests/os/run.sh index f6eadb99..7ee260de 100755 --- a/tests/os/run.sh +++ b/tests/os/run.sh @@ -724,6 +724,39 @@ phase_install() { else bad "inventory does not flag the installed disk as carrying data" fi + # ---- reinstall pre-fill: the previous machine's answers, never its secrets ---------- + # The host mounted the target's data partition read-only at wizard start and published + # the stripped previous config as the page's pre-fill. Two assertions, both through the + # page's own state API: the first leg's wallet came back, and no password crossed. Runs + # BEFORE the wipe legs on purpose — they destroy the config the pre-fill was read from. + 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 + if [ -n "$token" ] && _wizard_up; then + jar=$(mktemp) + local pf_state="" + curl -fsSk -c "$jar" -d "token=$token" "https://$ip/auth" -o /dev/null 2>/dev/null && + pf_state=$(curl -fsSk -b "$jar" "https://$ip/api/wizard-state" 2>/dev/null) + rm -f "$jar" + if printf '%s' "$pf_state" | grep -q '"wallet_address": "4AAAA'; then + ok "pre-fill carries the previous install's wallet" + else + bad "the previous install's answers did not pre-fill the reinstall page" + fi + # The provisioned config held a generated dashboard password; the merged state may + # only ever show the reference's empty default for any "password" key. + if printf '%s' "$pf_state" | grep -Eq '"password": "[^"]'; then + bad "a password crossed into the reinstall page's state" + else + ok "no password reaches the reinstall page" + fi + else + bad "no wizard session for the pre-fill check (token: ${token:-none})" + fi # ---- wipe legs: the three-way reinstall data choice, asserted on the raw partition ---- # Mounted from the installer VM (the target's data partition is vda4) rather than booting # between legs — the assertion is about what is ON the disk, and this keeps three slot diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 609e0de4..c34a5d84 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -7877,6 +7877,131 @@ assert_rc "installer failure -> rc 1" "$?" "1" unset PITHEAD_INSTALL_BIN FAKE_LOG FAKE_RC INSTSB +echo "== unit: strip_config_secrets — no secret class survives the reinstall pre-fill ==" +# The strip runs before a previous install's config may be SHOWN on the setup page. Every +# secret carries the same marker value, so one grep proves the whole list at once; the +# non-secret answers (the point of the pre-fill) must all survive. +SCS=$(mktemp -d) +cat >"$SCS/prev.json" <<'PREV' +{ + "monero": {"wallet_address": "4KEEP-WALLET", "mode": "remote", + "remote": {"host": "node.lan", "rpc_port": 18081, "zmq_port": 18083}, + "node_username": "LEAK-user", "node_password": "LEAK-nodepw", "view_key": "LEAK-mvk"}, + "tari": {"wallet_address": "KEEP-TARI", "mode": "remote", + "remote": {"host": "tari.lan", "grpc_port": 18142}, + "view_key": "LEAK-tvk", "spend_public_key": "LEAK-tspk"}, + "p2pool": {"pool": "main", "stratum_password": "LEAK-stratum"}, + "dashboard": {"timezone": "Europe/Berlin", + "auth": {"username": "LEAK-dashuser", "password": "LEAK-dashpw"}, + "workers": [{"name": "w0", "host": "h", "token": "LEAK-oldworker"}]}, + "workers": {"api_auth": true, "api_token": "LEAK-apitoken", + "list": [{"name": "rig1", "host": "rig1.lan", "token": "LEAK-workertoken"}]}, + "telegram": {"enabled": true, "bot_token": "LEAK-bot", "chat_id": "LEAK-chat"}, + "healthchecks": {"ping_url": "https://hc.example/LEAK-ping"}, + "notifications": {"ntfy": {"url": "https://ntfy.example/LEAK-url", "token": "LEAK-ntfy"}}, + "ssh": {"enabled": true, "authorized_key": "ssh-ed25519 LEAK-sshkey"}, + "xvb": {"enabled": true, "standby": {"source": "LEAK-standby"}} +} +PREV +stripped=$(run_sourced "$SANDBOX" strip_config_secrets "$SCS/prev.json") +assert_rc "a real config strips cleanly (rc 0)" "$?" "0" +assert_eq "no secret of ANY class survives" "$(printf '%s' "$stripped" | grep -c 'LEAK-')" "0" +assert_eq "wallet address survives" "$(printf '%s' "$stripped" | jq -r '.monero.wallet_address')" "4KEEP-WALLET" +assert_eq "remote node mode survives" "$(printf '%s' "$stripped" | jq -r '.tari.mode')" "remote" +assert_eq "remote node host survives" "$(printf '%s' "$stripped" | jq -r '.tari.remote.host')" "tari.lan" +assert_eq "pool tier survives" "$(printf '%s' "$stripped" | jq -r '.p2pool.pool')" "main" +assert_eq "timezone survives" "$(printf '%s' "$stripped" | jq -r '.dashboard.timezone')" "Europe/Berlin" +# Not-a-config shapes are refused, not partially stripped: rc != 0 means "no pre-fill". +printf 'not json at all' >"$SCS/garbage.json" +if run_sourced "$SANDBOX" strip_config_secrets "$SCS/garbage.json" >/dev/null 2>&1; then + bad "garbage file -> refused" "rc 0" +else + ok "garbage file -> refused" +fi +printf '[1,2,3]' >"$SCS/array.json" +if run_sourced "$SANDBOX" strip_config_secrets "$SCS/array.json" >/dev/null 2>&1; then + bad "non-object JSON -> refused" "rc 0" +else + ok "non-object JSON -> refused" +fi +rm -rf "$SCS" +unset SCS stripped + +echo "== unit: prefill_from_previous_install — fail open, publish only the stripped remainder ==" +# The orchestration around the strip: exactly one disk with an install, a read-only mount, and +# every failure degrading to "no pre-fill" — never to a blocked install. mount/umount/lsblk are +# PATH stubs; the fake mount copies a fixture tree under the mountpoint. +PFSB=$(mktemp -d) +mkdir -p "$PFSB/bin" "$PFSB/spool" "$PFSB/prev/pithead" +printf '#!/bin/bash\necho "/dev/fake2 data"\n' >"$PFSB/bin/lsblk" +cat >"$PFSB/bin/mount" <<'MNT' +#!/bin/bash +[ "${PF_MOUNT_RC:-0}" -eq 0 ] || exit "$PF_MOUNT_RC" +# The mountpoint is the last argument; -P keeps fixture symlinks AS symlinks. +cp -RP "${PF_TREE:?}/." "${!#}/" +MNT +printf '#!/bin/bash\nexit 0\n' >"$PFSB/bin/umount" +chmod +x "$PFSB/bin/lsblk" "$PFSB/bin/mount" "$PFSB/bin/umount" +export PF_TREE="$PFSB/prev" +printf 'sda\t4T\tPrev Disk\tSN9\tpithead-with-data\n' >"$PFSB/spool/disks.tsv" +printf '{"monero":{"wallet_address":"4PREV"},"dashboard":{"auth":{"username":"op","password":"LEAK-pw"}}}' \ + >"$PFSB/prev/pithead/config.json" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "previous install found -> pre-fill published (rc 0)" "$?" "0" +assert_eq "pre-fill keeps the wallet" "$(jq -r '.monero.wallet_address' "$PFSB/spool/last-attempt.json")" "4PREV" +assert_eq "pre-fill carries NO login" "$(grep -c 'LEAK-' "$PFSB/spool/last-attempt.json")" "0" +assert_eq "no temp file left beside the atomic target" "$(find "$PFSB/spool" -name '.last-attempt*' | wc -l | tr -d ' ')" "0" + +# Broken previous config -> no pre-fill, no error surfaced to the page. +rm -f "$PFSB/spool/last-attempt.json" +printf '{broken' >"$PFSB/prev/pithead/config.json" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "broken previous config -> rc 1" "$?" "1" +[ -f "$PFSB/spool/last-attempt.json" ] && bad "broken config publishes nothing" "file exists" || ok "broken config publishes nothing" +[ -f "$PFSB/spool/error.txt" ] && bad "broken config surfaces no page error" "error.txt written" || ok "broken config surfaces no page error" + +# Absent previous config -> no pre-fill. +rm -f "$PFSB/prev/pithead/config.json" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "no previous config -> rc 1" "$?" "1" +[ -f "$PFSB/spool/last-attempt.json" ] && bad "no config publishes nothing" "file exists" || ok "no config publishes nothing" + +# A mount failure (corrupt filesystem, busy partition) fails open too. +printf '{"monero":{"wallet_address":"4PREV"}}' >"$PFSB/prev/pithead/config.json" +PF_MOUNT_RC=32 PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "mount failure -> rc 1, nothing blocked" "$?" "1" + +# Symlink escape: a crafted disk pointing config.json — or the pithead dir itself — at a file +# on the RUNNING host must publish nothing, even when the target parses as a valid config. +printf '{"monero":{"wallet_address":"4HOST-FILE"}}' >"$PFSB/outside.json" +rm -f "$PFSB/prev/pithead/config.json" +ln -s "$PFSB/outside.json" "$PFSB/prev/pithead/config.json" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "symlinked config.json -> rc 1, refused" "$?" "1" +[ -f "$PFSB/spool/last-attempt.json" ] && bad "symlinked config publishes nothing" "file exists" || ok "symlinked config publishes nothing" +mkdir -p "$PFSB/outside-dir" +printf '{"monero":{"wallet_address":"4HOST-FILE"}}' >"$PFSB/outside-dir/config.json" +rm -rf "$PFSB/prev/pithead" +ln -s "$PFSB/outside-dir" "$PFSB/prev/pithead" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "symlinked pithead dir -> rc 1, refused" "$?" "1" +[ -f "$PFSB/spool/last-attempt.json" ] && bad "symlinked dir publishes nothing" "file exists" || ok "symlinked dir publishes nothing" +rm -rf "$PFSB/prev/pithead" +mkdir -p "$PFSB/prev/pithead" + +# Two disks carrying installs: WHICH machine's answers is a guess — publish none. +printf 'sda\t4T\tPrev A\tSN9\tpithead-with-data\nsdb\t4T\tPrev B\tSN8\tpithead-with-data\n' >"$PFSB/spool/disks.tsv" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "two candidate disks -> rc 1, no guessing" "$?" "1" +[ -f "$PFSB/spool/last-attempt.json" ] && bad "ambiguous target publishes nothing" "file exists" || ok "ambiguous target publishes nothing" + +# No disk with data at all (the everyday fresh-install stick) -> rc 1, quietly. +printf 'vda\t40G\tBlank\tSN1\tempty\n' >"$PFSB/spool/disks.tsv" +PATH="$PFSB/bin:$PATH" run_sourced "$SANDBOX" prefill_from_previous_install "$PFSB/spool" >/dev/null 2>&1 +assert_rc "no install on any disk -> rc 1" "$?" "1" +rm -rf "$PFSB" +unset PFSB PF_TREE + # --------------------------------------------------------------------------- echo "" printf 'pithead tests: \033[1;32m%d passed\033[0m, ' "$PASS"