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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build/dashboard/tests/service/test_data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
43 changes: 43 additions & 0 deletions build/dashboard/tests/web/test_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions docs/appliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion docs/dev/appliance-wizard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions pithead
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,60 @@ publish_disk_inventory() { # <spool-dir>
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() { # <config-file> -> 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() { # <spool-dir>
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 "$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,
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/os/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading