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: 7 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,12 @@ The stack's defaults:
channel without a dashboard password is a validation error, on a published onion it additionally
requires Tor client authorization, and every mutation is audited host-side. Commits are default-denied against an explicit allowlist. Low-risk
operational settings commit directly; a small set of operationally-disruptive ones — data-directory
moves, the stratum port, enabling clearnet initial sync, and enabling pruning — commit only behind
a typed confirmation in the dashboard, and only in that direction. A dashboard-confirmed
moves, the stratum port, enabling clearnet initial sync, enabling pruning, and the remote Monero
and Tari **node endpoints** (#1888) — commit only behind a typed confirmation in the dashboard,
and only in that direction. A node-endpoint change carries a second, non-cosmetic gate: the host
probes the staged endpoint and refuses one it cannot reach, so a dashboard cannot park a chain on
a node that is not there. The endpoints are address identity, not secrets — the remote node's RPC
username and password stay in the never-committable set below. A dashboard-confirmed
data-directory move is further held to an **allowlist** (#728): the new location must sit under the
stack's own data root (the install dir's `data/`) or a parent the stack already keeps data in;
a move to any other absolute path is refused even with the typed confirmation and stays host-CLI
Expand All @@ -97,7 +101,7 @@ The stack's defaults:
every direction, as is anything the change preview flags destructive (including the heavy direction
of a confirm-gated key, e.g. disabling pruning, which forces a full re-sync). The security
perimeter — wallets and view keys, dashboard auth and onion exposure, the control channel itself,
the Tor egress firewall, node endpoints, binds, every credential, and the per-rig hosts and tokens —
the Tor egress firewall, binds, every credential, and the per-rig hosts and tokens —
is never dashboard-committable, with or without the typed confirmation. A key added in the
future stays un-committable until deliberately listed. Those edits must be applied from the host CLI.
- Attack visibility (#349): Caddy writes a JSON access log for every dashboard vhost (LAN and
Expand Down
18 changes: 14 additions & 4 deletions dashboard/mining_dashboard/service/control_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,22 @@ def _editable_paths():
# bypasses the Tor socks5 per docs/privacy.md; a future-dated restore point silently defeats
# payout-confirmation tamper evidence).
"MONERO_OUT_PEERS": ("monero.out_peers",),
# Node endpoints (#1888): confirm-gated, not free-commit, and paired with the approval gate's
# host-side reachability probe. 42-control-policy-and-host-checks.sh carries the reasoning.
"MONERO_NODE_HOST": ("monero.remote.host",),
"MONERO_RPC_PORT": ("monero.remote.rpc_port",),
"MONERO_ZMQ_PORT": ("monero.remote.zmq_port",),
"TARI_GRPC_ADDRESS": ("tari.remote.host", "tari.remote.grpc_port"),
}


def _confirm_paths():
"""Every config path the control gate will commit behind a type-to-confirm (#719)."""
return sorted({p for target in CONFIRM_ENV_KEY_PATHS.values() for p in target})
def _confirm_paths(cfg=None):
"""Every config path the control gate commits behind a type-to-confirm (#719), minus a chain's
node endpoint (#1888) while that chain is not on a REMOTE node: a local (or, after #1855, an
"off") chain derives its endpoint from the stack, so offering the field would edit nothing."""
live = {c for c in ("monero", "tari") if (cfg or {}).get(c, {}).get("mode") == "remote"}
paths = {p for target in CONFIRM_ENV_KEY_PATHS.values() for p in target}
return sorted(p for p in paths if ".remote." not in p or p.split(".")[0] in live)


def env_key_config_paths(env_key):
Expand Down Expand Up @@ -288,7 +298,7 @@ def read_config():
mask_secrets(cfg)
cfg["_core_keys"] = _load_core_keys()
cfg["_editable_keys"] = _editable_paths()
cfg["_confirm_keys"] = _confirm_paths()
cfg["_confirm_keys"] = _confirm_paths(cfg)
return cfg


Expand Down
83 changes: 77 additions & 6 deletions dashboard/tests/service/test_env_key_perimeter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,17 @@
"MONERO_NODE_PASSWORD",
"WALLET_RPC_PASSWORD",
"TARI_VIEW_KEY",
# Node endpoints (SECURITY.md's "node endpoints"): where the stack points its Monero/Tari
# RPC clients. Dashboard-committable, this repoints mining traffic to an attacker's node.
"MONERO_NODE_HOST",
"MONERO_RPC_PORT",
"MONERO_ZMQ_PORT",
"TARI_GRPC_ADDRESS",
# NODE ENDPOINTS LEFT THIS LIST ON 2026-09-06 (#1888, operator ruling) — MONERO_NODE_HOST,
# MONERO_RPC_PORT, MONERO_ZMQ_PORT and TARI_GRPC_ADDRESS. The threat they were listed for is
# unchanged and still real: "where the stack points its Monero/Tari RPC clients —
# dashboard-committable, this repoints mining traffic to an attacker's node." What changed
# is that refusing them outright was not a defence on an appliance, it was a dead end: there
# is no host shell there, so the setting became unchangeable for the life of the machine
# (#786/#1821). They are now the confirm-gated tier, behind the control channel's own auth
# plus a host-side reachability probe on the staged endpoint (43-control-approval-and-
# preview.sh). Their RPC LOGIN CREDENTIALS — MONERO_NODE_USERNAME / MONERO_NODE_PASSWORD,
# still above — did NOT move, and neither did the binds below: address identity is not a
# secret, and a listen address is not an endpoint.
# Binds (SECURITY.md's "binds"): the RPC/gRPC listen addresses. DASHBOARD_HOST (above)
# covers the dashboard's own bind; these are the merge-mined services' local listeners.
"MONERO_RPC_BIND",
Expand All @@ -55,6 +60,72 @@
)


def _pithead_key_sets():
"""pithead's three hand-kept key lists, read out of the BUILT CLI. Skips where the CLI is not in
the tree at all (the dashboard-only image), the same degradation the perimeter test has always
made — a missing CLI is not a passing perimeter."""
import re
from pathlib import Path

here = Path(__file__).resolve()
pithead_path = next((p / "pithead" for p in here.parents if (p / "pithead").is_file()), None)
if pithead_path is None:
pytest.skip("pithead CLI not present in this test context (dashboard-only image)")
pithead = pithead_path.read_text()
found = {}
for name in ("EDITABLE", "CONFIRM"):
m = re.search(rf"CONTROL_DASHBOARD_{name}_KEYS='([^']*)'", pithead)
assert m, f"could not find pithead's {name.lower()} allowlist"
found[name.lower()] = set(m.group(1).split())
m = re.search(r"CONTROL_NODE_ENDPOINT_KEYS='([^']*)'", pithead)
assert m, "could not find pithead's CONTROL_NODE_ENDPOINT_KEYS (#1888)"
found["node_endpoints"] = set(m.group(1).split())
return pithead, found


def test_node_endpoint_keys_are_confirm_gated_and_probed():
"""#1888: the node endpoints left the never-committable perimeter for the confirm tier, and the
approval gate's reachability probe fires on CONTROL_NODE_ENDPOINT_KEYS. Those are two separate
hand-kept lists, so the failure this guards is not hypothetical: a node key added to the confirm
allowlist but NOT to the endpoint list would be dashboard-committable with NO probe behind it —
the one thing the operator ruling traded the perimeter entry for. The Python copy is checked the
same way, because the browser renders its fields from that one."""
pithead, keys = _pithead_key_sets()
assert keys["node_endpoints"], "the node-endpoint list is empty — nothing would ever be probed"
for key in keys["node_endpoints"]:
assert key in keys["confirm"], f"{key} is probed but not confirm-gated in pithead"
assert key not in keys["editable"], f"{key} is free-commit in pithead — it must be CONFIRM"
assert key in control_service.CONFIRM_ENV_KEY_PATHS, (
f"{key} missing from CONFIRM_ENV_KEY_PATHS"
)
# The half that matters, and it needs a source the endpoint list itself cannot supply, or the
# check is a tautology: a confirm key whose CONFIG PATH lives under a chain's `remote.` block IS
# a node endpoint, whatever any hand-kept list says. Derived from the paths, compared to the
# list — so a node key added to the confirm allowlist and forgotten here reds instead of
# shipping committable with no probe behind it.
by_path = {
k
for k, target in control_service.CONFIRM_ENV_KEY_PATHS.items()
if any(".remote." in p for p in target)
}
assert by_path == keys["node_endpoints"], (
f"confirm keys pointing at a remote node endpoint {sorted(by_path)} do not match the probe "
f"list {sorted(keys['node_endpoints'])} — one of them would commit with no reachability probe"
)


def test_confirm_paths_offers_a_node_endpoint_only_while_that_chain_is_remote():
"""#1888: a local (or, after #1855, an "off") chain derives its endpoint from the stack, so the
field would edit nothing — do not render it. The MIXED config is the row that discriminates: a
rule keyed on "any chain is remote" would pass a both-remote and a both-local check alike."""
mixed = {"monero": {"mode": "remote"}, "tari": {"mode": "local"}}
offered = control_service._confirm_paths(mixed)
assert "monero.remote.host" in offered
assert "tari.remote.host" not in offered
assert "p2pool.stratum_port" in offered, "a non-endpoint confirm path must be unaffected"
assert "monero.remote.host" not in control_service._confirm_paths({})


def test_perimeter_env_keys_never_committable_from_either_copy():
"""#1094 / #1069 W9: names the security perimeter directly (SECURITY.md:99-100) and checks each
of the four allowlists (pithead's editable + confirm sets, EDITABLE_ENV_KEY_PATHS +
Expand Down
6 changes: 6 additions & 0 deletions docs/appliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ when everything passes does it show you, on this page, the things you must save:
- the **dashboard address** (`https://pithead.local`)
- where to **point your miners** (`stratum+tcp://pithead.local:3333`)

A remote node's address is not a one-time answer. If the node you point at goes away, moves, or
you want to try another one, the dashboard's Configuration view changes it on a running machine:
type `APPLY` to confirm, and the machine dials the new endpoint and refuses it if nothing answers
there ([#1888](https://github.com/p2pool-starter-stack/pithead/issues/1888)). The node's RPC
username and password are the exception and stay fixed at setup.

**Copy the login somewhere safe, then press "I saved these — erase the disk and install."**
Nothing touches the disk until that press. The install takes a few minutes, and when it
finishes **the machine switches itself off.** That is the end of the install, not a crash.
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ control channel will commit, are unaffected either way.
| `monero.out_peers` | `48` | monerod's outbound peer target (8–1024). Over Tor each outbound peer is roughly one long-lived circuit, so this is the main steady-state lever on Tor's CPU (#595). Keep the default while syncing (more peers = more download bandwidth over Tor); once synced, `32` — the count P2Pool recommends for clearnet — cuts monerod's circuit maintenance by a third. |
| `monero.rpc_lan_access` | `false` | `true` publishes the node's RPC on the LAN (`0.0.0.0`) for wallets on other machines; default is localhost-only. |
| `monero.zmq_lan_access` | `false` | `true` publishes the node's ZMQ block-notification feed (`18083`) on the LAN. With `rpc_lan_access`, this is the serving side of `monero.mode: remote` — a remote P2Pool needs both RPC and ZMQ. ZMQ has no authentication: trusted networks only. In `local` mode the port is published either way — on `127.0.0.1` while this is `false` — so free `18083` on the host if something else holds it, or the container won't start. Ignored in `remote` mode (no bundled node runs). |
| `monero.remote.host` / `rpc_port` / `zmq_port` | — / `18081` / `18083` | Remote node connection details (used when `mode` is `remote`). |
| `monero.remote.host` / `rpc_port` / `zmq_port` | — / `18081` / `18083` | Remote node connection details (used when `mode` is `remote`). Changeable from the dashboard's Configuration view behind a typed `APPLY`, and only if the host can reach the new endpoint ([#1888](https://github.com/p2pool-starter-stack/pithead/issues/1888)); `node_username` / `node_password` above stay host-only. |
| `monero.data_dir` | `auto` | Where the Monero blockchain lives on the host. `auto` = `./data/monero`. Point this at an existing `.bitmonero` directory to reuse a synced node. See [Reusing an existing node](#reusing-an-existing-node). |
| `monero.mem_limit` | `auto` | Upper limit on the monerod container's memory, so a leak/runaway OOM-restarts monerod alone instead of the host's OOM-killer picking a victim. `auto` is a generous ceiling (6 GB) that won't trip during normal operation or initial sync. monerod's OOM-triggering memory is small (~0.1 GiB at rest, ~1–3 GiB during sync) while its multi-GB blockchain DB is reclaimable, memory-mapped page cache that the kernel evicts under pressure rather than OOM-killing. Lower it only to free RAM. Raise it for a full (unpruned) node doing a heavy initial sync on a fast disk, or if a low-RAM host ever OOMs monerod during IBD (it restarts and resumes; the on-disk chain is transactional, no data loss). Accepts any Docker memory value, e.g. `"8g"`. (Tari has its own `tari.mem_limit`; the dashboard, P2Pool, Tor, and the proxies are small and carry fixed conservative ceilings in `docker-compose.yml`.) |
| `tari.mode` | `local` | `local` runs the bundled Tari base node; `remote` merge-mines against an external one (see `tari.remote` and [Remote Tari node](#remote-tari-node)). |
Expand All @@ -133,7 +133,7 @@ control channel will commit, are unaffected either way.
| `tari.spend_public_key` | _empty_ | The **public** spend key for the Tari payout address, exported alongside the view key (`minotari_console_wallet ... export-view-key-and-spend-key`; see [Dashboard › Exporting your keys](dashboard.md#exporting-your-keys)). Required whenever `tari.view_key` is set — a view-only Tari wallet is built from the private view key plus this public spend key. Public, not a secret. |
| `tari.payout_scan_birthday` | `auto` | Where the view-only Tari wallet starts scanning on first creation (#462). Unlike Monero's block-height restore point, a Tari birthday is **days since the Unix epoch** (a u16, 0–65535). `auto` = today when the wallet is first made, so it tracks payouts forward without rescanning from genesis. Set an earlier day to backfill older payouts (slower first scan). Only affects the first wallet creation; ignored once the wallet exists. |
| `tari.clearnet_initial_sync` | `false` | Privacy-relevant, default off. `true` makes the Tari base node sync over clearnet instead of Tor: it switches the P2P transport to TCP, re-enables the `seeds.tari.com` DNS seed (the bundled onion `peer_seeds` are unreachable without Tor), and stops advertising its onion. Your node's IP becomes visible to the Tari P2P network while it's on, plus one DNS lookup of `seeds.tari.com`. `pithead` warns loudly (apply/status/doctor/up). The dashboard switches Tari back to Tor automatically once it's synced (#234), so you can leave this `true`. Applies to the bundled node only — with `tari.mode: remote` nothing here acts on it, so set it back to `false` before switching. Full threat model: [Privacy › Optional clearnet initial sync](privacy.md#optional-clearnet-initial-sync-off-by-default). |
| `tari.remote.host` / `grpc_port` | — / `18142` | Remote Tari base node connection details (used when `tari.mode` is `remote`). See [Remote Tari node](#remote-tari-node). |
| `tari.remote.host` / `grpc_port` | — / `18142` | Remote Tari base node connection details (used when `tari.mode` is `remote`). Changeable from the dashboard's Configuration view behind a typed `APPLY`, and only if the host can reach the new endpoint ([#1888](https://github.com/p2pool-starter-stack/pithead/issues/1888)). See [Remote Tari node](#remote-tari-node). |
| `tari.grpc_lan_access` | `false` | `true` publishes the local Tari base node's gRPC (`18142`) on the LAN — the serving side of `tari.mode: remote`, so other stacks can merge-mine against this node. The gRPC is plaintext and unauthenticated: trusted networks only (see [Remote Tari node](#remote-tari-node)). Ignored in `remote` mode (no bundled node runs). In `local` mode the port is published either way — on `127.0.0.1` while this is `false` — so free `18142` on the host if something else holds it, or the container won't start. |
| `tari.data_dir` | `auto` | Where the Tari node data lives on the host. `auto` = `./data/tari`. Unused with `tari.mode: remote` — no local node runs, and `setup`/`doctor` drop Tari's ~200 GB from this host's disk budget. See [Hardware › Running a node elsewhere](hardware.md#running-a-node-elsewhere). |
| `tari.mem_limit` | `auto` | Upper limit on the Tari container's memory, so a runaway Tari restarts cleanly on its own instead of dragging down the whole host. `auto` picks a safe size for your machine. Leave it unless you want to give Tari less RAM (to free it for other apps) or more (if it ever restarts too often). Accepts any Docker memory value, e.g. `"8g"`. Local mode only: with `tari.mode: remote` there is no container to cap and the key is ignored. |
Expand Down
Loading