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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions build/dashboard/mining_dashboard/web/static/wizard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -477,14 +477,16 @@ export class WizardApp extends Component {
<${Field} label="Mine on this machine too?">
<select value=${String(v("localMiner") ?? false)} onChange=${on("localMiner")}>
<option value="false">No — this box only coordinates the miners (default)</option>
<option value="true">Yes — I will install RigForge on it to mine with its CPU</option>
<option value="true">Yes — this machine also mines with its own CPU (built-in RigForge)</option>
</select>
<//>
${
v("localMiner") === true &&
html`<${Note}>Pithead serves the pool; RigForge does the mining. After setup,
install RigForge on this machine and point it at the stratum address the final
page shows — it owns all CPU tuning.<//>`
html`<${Note}>Nothing to install: the machine carries its own RigForge miner,
pointed at its own pool. It starts by itself once the stack is up and appears
in the dashboard's Workers view. Mining tunes the whole box for hashrate —
CPU governor, memory reservations — which is exactly what a dedicated
appliance is for.<//>`
}

<h3>First sync</h3>
Expand Down
12 changes: 12 additions & 0 deletions build/dashboard/tests/frontend/wizard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,18 @@ test("the mine-on-this-box choice is a labeled select naming RigForge, default N
restore();
});

test("saying yes promises the built-in miner, never a manual install", async () => {
// The appliance honours the choice itself now: an operator who answers Yes must not be
// told to install anything — that instruction pointed at a rig that could never exist
// on a machine with no shell.
const { inst, restore } = await appOn([stateFor("setup")]);
inst.editJson({ target: { value: JSON.stringify({ local_miner: { enabled: true } }) } });
const out = renderToString(inst.render());
assert.match(out, /Nothing to install/);
assert.doesNotMatch(out, /install RigForge/i);
restore();
});

test("before a disk is chosen, the page asks ONLY that", async () => {
const { inst, restore } = await appOn([
stateFor("installer", {
Expand Down
2 changes: 1 addition & 1 deletion docs/appliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Then a handful of choices, all with sensible defaults:
| Telegram bot | — | Optional. Alerts and status commands; needs both the token and the chat id. |
| Monero node | run it here | Point at a node you already run. |
| Tari node | run it here | Same, over a network you trust. |
| Mine with this machine's CPU | off | On if this box should mine as well as coordinate. |
| Mine with this machine's CPU | off | On if this box should mine as well as coordinate. Nothing to install: the image carries its own [RigForge](https://github.com/p2pool-starter-stack/rigforge) miner, pointed at this machine's own pool. It starts by itself once the stack is up, comes back on every boot, and appears in the dashboard's Workers view. Mining tunes the whole box for hashrate (CPU governor, memory reservations) — what a dedicated appliance is for. |
| First sync | private over Tor | Faster over the open internet if days of syncing is too slow; it uses Tor afterwards either way. |
| Time zone | UTC | For dashboard timestamps. |
| Dashboard login | generate one for me | Or choose your own password. "No login" is offered but leaves the dashboard — payout addresses, hashrate — open to anyone on your network; never combine it with the Tor onion. |
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/appliance-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ is the only thing standing between the hand-written boot path and a fleet.
|---|---|---|
| `boot` | EFI boot to userspace; first-boot wizard announces itself with a console token; wizard serves the token gate on `:80` | 3 |
| `update` | `/data` grew to the disk and slots did not (#784); bundle installs into the spare; spare boots; **an uncommitted update reverts on reboot**; a committed update persists; **after the commit, the page served comes from the NEW dashboard image** (marker baked into the image and read back over HTTP — the tag never changes, so "containers run" proves nothing about staleness, #798); an operator can roll back off a committed version | 13 |
| `provision` | a config submitted through the wizard's real HTTP flow (token read from the console, exactly as a human would) provisions the stack: validation, cosign-verified image pulls, containers running under podman, dashboard served through caddy. Then a **reboot with no hands on it** — the stack must return unaided through `pithead-boot` (load baked images, render the derived layer, compose up, health-gated slot commit), the failure mode being a miner that sits dark after every power blip. The Caddyfile is corrupted and the archive digest records dropped before the reboot on purpose: derived things are regenerated every boot, and a stale one killed TLS on hardware. Catches an appliance whose engine cannot run the product — which happened, invisibly to every other phase | 10 |
| `provision` | a config submitted through the wizard's real HTTP flow (token read from the console, exactly as a human would) provisions the stack: validation, cosign-verified image pulls, containers running under podman, dashboard served through caddy. The submitted config also enables the on-box miner, so the **built-in RigForge worker** must come up on its own and get a share accepted upstream — the proxy logs `accepted` only for shares p2pool took. Then a **reboot with no hands on it** — the stack must return unaided through `pithead-boot` (load baked images, render the derived layer, compose up, health-gated slot commit, then the local-miner leg), the failure mode being a miner that sits dark after every power blip. The Caddyfile is corrupted and the archive digest records dropped before the reboot on purpose: derived things are regenerated every boot, and a stale one killed TLS on hardware. Catches an appliance whose engine cannot run the product — which happened, invisibly to every other phase | 13 |
| `install` | the image boots as **removable** media (usb bus — the gate keys on it); the inventory offers the internal disk and never the boot medium; the real installer runs; the machine then boots from the target alone with a **complete** copy (`/var/lib/dpkg` — the overlay made an incomplete copy easy and invisible), a fresh machine-id, `/data` sized to the target, and the wizard serving. Then the **reinstall leg**: a sentinel planted in `/data`, a second install over the same disk, and the sentinel required afterwards — the chain-preserving promise, tested. The keep leg reinstalls from a **newer stick** over a `/data` that already holds the old dashboard image and its digest record: the image ID must change and the page served must come from the newer image (#798) | 21 |
| `fault` | three power cuts mid-write; a deliberately corrupted bundle is refused without crashing and without bricking; a power cut inside the commit window; operator rollback after all of it; the box is still updatable afterwards | 11 |

Expand Down
17 changes: 14 additions & 3 deletions docs/dev/appliance-wizard.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ certificate beats no dashboard.

## The boot contract (provisioned machines)

After provisioning, every boot runs one unit — `pithead-boot` — whose four steps each answer
After provisioning, every boot runs one unit — `pithead-boot` — whose five steps each answer
a hardware-validated failure:

1. **`pithead load-images`** — load the baked container-image archives when their content
Expand All @@ -136,6 +136,17 @@ a hardware-validated failure:
site and proves nothing). A slot that boots but cannot serve stays uncommitted on purpose:
that is the state A/B fallback exists for. Unprovisioned machines never commit — GRUB's
clear-and-retry keeps them booting, and a bad update before provisioning reverts.
5. **`pithead local-miner`** — converge the built-in RigForge worker to `local_miner.enabled`,
deliberately LAST: the miner needs the stack's stratum listening, and it must never delay
or block the slot commit — the stack serving is the product's health, the miner is a
passenger (`|| true`). When enabled, this runs RigForge's setup in appliance mode from the
tree `pithead-sync` keeps on `/data/rigforge`: its unit renders into `/run` with
`--runtime` enablement (gone every boot, recreated here, like the control-runner units),
and the cached XMRig build on `/data` makes the run a re-render rather than a recompile.
The miner's config is derived by `render` (step 2's family): the stack's own stratum over
loopback, the stratum password, and the stack's HugePages budget declared as
`hugepages_reserve_extra_mb` — RigForge's grow-only sysctl then sizes the shared pool as
the single writer, and pithead's own HugePages write never shrinks a grown pool back.

**Rule for changes:** anything generated from `config.json` or the program is derived and must
be rebuilt by `render` — adding one anywhere else recreates the staleness bug. The container
Expand All @@ -157,8 +168,8 @@ 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 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 |
| 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 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 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*.
Expand Down
14 changes: 12 additions & 2 deletions docs/workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,15 @@ A box that runs the stack 24/7 has spare CPU between syncs. You can put that CPU
co-locating a RigForge worker on the stack host, pointed at the stack's own stratum over loopback —
no LAN exposure, no firewall rule, no Tor hop.

**On the appliance, this is built in.** The image carries a pinned RigForge tree and a prebuilt
XMRig, so answering yes to "Mine on this machine too?" in the setup wizard is the whole job:
pithead renders the miner's configuration (pool, stratum password, and the stack's HugePages
headroom), runs RigForge's setup in its appliance mode after the stack is up, and repeats that on
every boot — the miner's service lives in `/run` and is re-created each time, like every other
derived thing on the appliance. The worker appears in the dashboard's Workers Alive table; toggle
`local_miner.enabled` from the dashboard's configuration view to turn it off or on again. The
rest of this section is the DIY flow.

Opt in during `./pithead setup` (the prompt "Also mine on this machine with its spare CPU?", off by
default), or set it in `config.json`:

Expand All @@ -409,8 +418,9 @@ and the stratum password (the `PROXY_STRATUM_PASSWORD` already in `.env`, shown
on the same host and enter those two values when it asks; the worker self-registers and appears in
the dashboard's Workers Alive table like any other rig.

Pithead does not install, run, or tune the miner. RigForge owns all host-level tuning — HugePages,
GRUB, MSR, the CPU governor, and the miner service. Two things to know before enabling it:
On DIY, Pithead does not install, run, or tune the miner. RigForge owns all host-level tuning —
HugePages, GRUB, MSR, the CPU governor, and the miner service. Two things to know before enabling
it:

- **RigForge's tuning is host-global.** It sets a `performance` CPU governor and may reserve
HugePages box-wide, which affects every workload on the machine, not just the miner. That is fine
Expand Down
10 changes: 10 additions & 0 deletions os/overlay/pithead-boot
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ for _ in $(seq 90); do
if [ "${code:-000}" != "000" ]; then
command -v rauc >/dev/null 2>&1 && rauc status mark-good
echo "pithead-boot: stack is serving (HTTP $code) — booted slot committed"
# The on-box miner comes LAST, after the slot commit: it needs the stack's stratum
# listening, and a miner that cannot start must never delay or block the commit — the
# stack serving is the product's health, the miner is a passenger. Idempotent every
# boot: RigForge's appliance mode re-renders its /run unit and re-tunes from the tree
# pithead-sync keeps on /data. No-op unless local_miner.enabled.
# timeout, not just || true: the boot unit runs with TimeoutStartSec=infinity, so a
# hung miner setup (a stalled native rebuild, a wedged mount) would otherwise leave the
# unit activating forever. 30 minutes clears a worst-case first native recompile; the
# prebuilt path finishes in seconds.
timeout 1800 ./pithead local-miner || true
exit 0
fi
sleep 10
Expand Down
39 changes: 37 additions & 2 deletions os/overlay/pithead-sync
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
# Copy the program tree from the system slot onto the data partition, leaving state untouched.
# Runs every boot, so an A/B update delivers its new program the first time the new slot boots.
set -euo pipefail
SRC=/opt/pithead
DST=/data/pithead
# Env-overridable so the stack tests can run this script against a sandbox.
SRC="${PITHEAD_SYNC_SRC:-/opt/pithead}"
DST="${PITHEAD_SYNC_DST:-/data/pithead}"
RF_SRC="${PITHEAD_SYNC_RIGFORGE_SRC:-/opt/rigforge}"
RF_DST="${PITHEAD_SYNC_RIGFORGE_DST:-/data/rigforge}"

mkdir -p "$DST"
for f in pithead pithead-completion.bash VERSION docker-compose.yml \
Expand All @@ -23,6 +26,38 @@ if [ -d "$SRC/build" ]; then
fi
# An images/ copy from an older layout is dead weight on the data partition — reclaim it.
rm -rf "${DST:?}/images"

# The RigForge leg: the image bakes a pinned RigForge tree, and the miner must run from /data —
# RigForge roots its workspace under its own script dir (DYNAMIC_HOME), so tree placement alone
# puts the XMRig build on writable storage. Program files are replaced wholesale like pithead's;
# state the baked tree never carries (config.json — pithead-rendered — and the data/ workspace)
# is untouched by construction, because only names present in the source are replaced.
if [ -d "$RF_SRC" ]; then
mkdir -p "$RF_DST"
for f in "$RF_SRC"/* "$RF_SRC"/.[!.]*; do
[ -e "$f" ] || continue
base=$(basename "$f")
# prebuilt/ is a seed, not a program file — handled below, never synced as a tree.
[ "$base" = "prebuilt" ] && continue
rm -rf "${RF_DST:?}/$base"
cp -a "$f" "$RF_DST/$base"
done
# Seed the image's prebuilt XMRig into the workspace when the pinned commit changed (or
# nothing is built yet). Same-commit builds are LEFT ALONE: a native recompile of the same
# pin is the operator's better binary, and RigForge's own integrity record covers it. A new
# pin arrives with a new image and its new prebuilt, so the on-box clone path (github over
# clearnet — unreachable from a Tor-only appliance) never needs to run.
if [ -d "$RF_SRC/prebuilt/xmrig" ]; then
baked_commit=$(cat "$RF_SRC/prebuilt/xmrig/.rigforge-commit" 2>/dev/null || true)
have_commit=$(cat "$RF_DST/data/worker/xmrig/.rigforge-commit" 2>/dev/null || true)
if [ -n "$baked_commit" ] && [ "$baked_commit" != "$have_commit" ]; then
mkdir -p "$RF_DST/data/worker"
rm -rf "$RF_DST/data/worker/xmrig"
cp -a "$RF_SRC/prebuilt/xmrig" "$RF_DST/data/worker/xmrig"
fi
fi
echo "pithead-sync: rigforge tree synced to $RF_DST (state preserved)"
fi
# repart formats /data with ext4's default 5% root reserve — ~50 GB lost on a 1 TB disk that
# exists to hold chain data. 0.5% keeps a real safety margin (the pre-repart layout used the
# same figure). tune2fs is idempotent and instant, so every boot is the simplest correct hook.
Expand Down
45 changes: 45 additions & 0 deletions os/rootfs/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,51 @@ RUN curl -fsSL -o /usr/local/bin/docker-compose \
&& chmod +x /usr/local/bin/docker-compose /usr/local/bin/cosign \
&& touch /etc/containers/nodocker

# The built-in miner (#796): a pinned RigForge tree plus everything it needs, baked at image
# build because nothing can be installed later — apt cannot run on the read-only root, and even
# a rw-remount install leaves its /etc state (alternatives, ld.so.cache) on the volatile overlay
# where the first reboot breaks the compiler. RIGFORGE_REF pins a commit on rigforge's develop
# (the first ref with appliance mode); move it to the next tagged rigforge release when one
# ships. pithead-sync copies the tree to /data/rigforge every boot; `pithead local-miner` runs
# its setup with RIGFORGE_APPLIANCE=1 when local_miner.enabled.
#
# The toolchain is RigForge's Debian dependency list, with one naming fix: trixie ships the
# cpupower CLI as linux-cpupower (linux-tools-common is Ubuntu's name and does not exist here).
# msr-tools gives doctor its rdmsr probe; gettext-base is envsubst, which renders the unit on
# every boot and is the ONLY tool a prebuilt tree strictly needs.
RUN apt-get install -y --no-install-recommends \
git build-essential cmake libuv1-dev libssl-dev libhwloc-dev gettext-base python3 \
linux-cpupower msr-tools
ARG RIGFORGE_REF=32be3a433b261110dde371f0ed5dab88acafd6f4
RUN curl -fsSL -o /tmp/rigforge.tar.gz \
"https://github.com/p2pool-starter-stack/rigforge/archive/${RIGFORGE_REF}.tar.gz" \
&& mkdir -p /opt/rigforge \
&& tar -xzf /tmp/rigforge.tar.gz -C /opt/rigforge --strip-components=1 \
&& rm /tmp/rigforge.tar.gz \
&& chmod +x /opt/rigforge/rigforge.sh \
&& printf 'ref=%s version=%s\n' "$RIGFORGE_REF" "$(tr -d ' \t\r\n' </opt/rigforge/VERSION)" \
>/opt/rigforge/RIGFORGE_REF
# Prebuild XMRig at RigForge's own pin, read from the baked tree so the two cannot drift. The
# appliance may be Tor-only, and RigForge's build step clones github over clearnet — so the
# clone happens HERE, on the build host, and pithead-sync seeds the result into the workspace
# where RigForge's "already built" check accepts it (binary + commit marker + sha record, the
# exact format rigforge.sh writes). The donate-level source patch is skipped on purpose: the
# default (1%) equals XMRig's own default, and the runtime config carries the value regardless.
RUN set -e \
&& XMRIG_VERSION=$(sed -n 's/^XMRIG_VERSION="${XMRIG_VERSION:-\(.*\)}"$/\1/p' /opt/rigforge/rigforge.sh) \
&& XMRIG_COMMIT=$(sed -n 's/^XMRIG_COMMIT="${XMRIG_COMMIT:-\(.*\)}"$/\1/p' /opt/rigforge/rigforge.sh) \
&& test -n "$XMRIG_VERSION" && test -n "$XMRIG_COMMIT" \
&& git clone --quiet --branch "$XMRIG_VERSION" --depth 1 https://github.com/xmrig/xmrig.git /tmp/xmrig \
&& test "$(git -C /tmp/xmrig rev-parse HEAD)" = "$XMRIG_COMMIT" \
&& cmake -S /tmp/xmrig -B /tmp/xmrig/build -DWITH_HWLOC=ON >/dev/null \
&& make -C /tmp/xmrig/build -j"$(nproc)" >/dev/null \
&& mkdir -p /opt/rigforge/prebuilt/xmrig/build \
&& install -m 755 /tmp/xmrig/build/xmrig /opt/rigforge/prebuilt/xmrig/build/xmrig \
&& printf '%s\n' "$XMRIG_COMMIT" >/opt/rigforge/prebuilt/xmrig/.rigforge-commit \
&& sha256sum /opt/rigforge/prebuilt/xmrig/build/xmrig | awk '{print $1}' \
>/opt/rigforge/prebuilt/xmrig/.rigforge-sha256 \
&& rm -rf /tmp/xmrig

# Appliance identity: the engine pin doctor and the wizard read, hugepages (load-bearing for the
# memory caps — the RandomX dataset must land in hugetlbfs, not the cgroup), persistent journald
# (a headless box that loses its logs on reboot is undebuggable).
Expand Down
Loading