diff --git a/SECURITY.md b/SECURITY.md index 4157177a..6b5c0a6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 @@ -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 diff --git a/dashboard/mining_dashboard/service/control_service.py b/dashboard/mining_dashboard/service/control_service.py index 4cd0bf6e..346eafbd 100644 --- a/dashboard/mining_dashboard/service/control_service.py +++ b/dashboard/mining_dashboard/service/control_service.py @@ -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): @@ -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 diff --git a/dashboard/tests/service/test_env_key_perimeter.py b/dashboard/tests/service/test_env_key_perimeter.py index d997223b..43688e45 100644 --- a/dashboard/tests/service/test_env_key_perimeter.py +++ b/dashboard/tests/service/test_env_key_perimeter.py @@ -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", @@ -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 + diff --git a/docs/appliance.md b/docs/appliance.md index 647458c0..41c63f45 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index e5b308e7..1251f051 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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)). | @@ -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. | diff --git a/docs/dashboard.md b/docs/dashboard.md index 0b2a7f6f..ab546f1c 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -967,8 +967,8 @@ the config tab now behave identically.) The pieces: read-only, with a tooltip ("Host-only — edit `config.json` and run `./pithead apply`") instead of letting you edit it and finding out only at Save. A smaller set of operationally-disruptive fields — the four service data directories, the stratum port, the clearnet initial-sync toggles, - enabling Monero pruning, and the Monero outbound-peer count — render **editable but - confirm-gated** + enabling Monero pruning, the Monero outbound-peer count, and the remote Monero and Tari node + endpoints — render **editable but confirm-gated** ([#719](https://github.com/p2pool-starter-stack/pithead/issues/719)): editable, tooltipped "you'll type `APPLY` to confirm at Save". Both sets are derived from the same allowlists the gate enforces (see below) and surfaced on `GET /api/config` as `_editable_keys` and `_confirm_keys`, @@ -1019,9 +1019,10 @@ direction, to anything else. A second, confirm-gated allowlist ([#719](https://github.com/p2pool-starter-stack/pithead/issues/719)) adds the operationally-disruptive-but-recoverable settings — a data-directory move (re-sync), a stratum-port change (rigs repoint), a clearnet initial-sync enable (host IP exposed during IBD, auto-reverts), -enabling Monero pruning, and the Monero outbound-peer count (bounded, but the biggest -steady-state knob on the shared Tor daemon's load) — which commit only behind the typed -`APPLY`. Type-to-confirm here is +enabling Monero pruning, the Monero outbound-peer count (bounded, but the biggest +steady-state knob on the shared Tor daemon's load), and the remote Monero and Tari **node +endpoints** ([#1888](https://github.com/p2pool-starter-stack/pithead/issues/1888)) — which commit +only behind the typed `APPLY`. Type-to-confirm here is friction, not a security control: a compromised dashboard that can set a field can also fill the confirm box, so the boundary stays where a breach would happen. Form mode's grey-out and confirm-gating (above) are those SAME allowlists surfaced to the browser up front, not a separate @@ -1030,10 +1031,26 @@ allowlists gate BOTH edit modes identically regardless — JSON mode is a differ the candidate config, not a different validation path, so it can't smuggle a change the form couldn't make. The **security perimeter stays host-only** in every direction: wallets and view keys, the dashboard login and onion settings, the control channel itself, the Tor egress firewall, -the stratum password, node endpoints and credentials, and the per-rig hosts and tokens. The gate +the stratum password, the node RPC credentials, and the per-rig hosts and tokens. The gate also refuses the heavier direction of a confirm-gated key (disabling pruning forces a full re-sync, so it stays host-only). Apply those from the host with `./pithead apply`. +A node-endpoint change is the one confirm-gated setting with a second gate behind the typed +`APPLY`: before the commit is accepted, the host dials the endpoint you staged and refuses one it +cannot reach, reporting which check failed +([#1889](https://github.com/p2pool-starter-stack/pithead/issues/1889)) — a TCP connect on each +port, and for Monero's ZMQ port a protocol greeting, because a published container port with no +publisher behind it answers a reachability check exactly like a live node does. The probe runs on +the staged config, host-side, and only when an endpoint key actually changed, so an unrelated +commit is never held up by a node that happens to be down. It is what makes the endpoints +committable at all: the typed token is friction, but the probe means a dashboard cannot park a +chain on a node that is not there. The remote node's RPC username and password did not move with +the endpoints — those are secrets, and they stay host-only. + +On an appliance a refusal never tells you to open a shell you do not have: where a DIY host is +told to edit `config.json` and run `./pithead apply`, the appliance is told the setting is fixed at +setup and pointed at **Set up again**. + A dashboard-confirmed data-directory move ([#728](https://github.com/p2pool-starter-stack/pithead/issues/728)) is held to a tighter rule than the same move from the host CLI. The host guard is a blocklist — it refuses the catastrophic roots diff --git a/docs/dev/file-budget.tsv b/docs/dev/file-budget.tsv index dbc3c35c..4f096b99 100644 --- a/docs/dev/file-budget.tsv +++ b/docs/dev/file-budget.tsv @@ -82,6 +82,7 @@ tests/stack/test-backup.sh 523 tests/stack/test-cli.sh 410 tests/stack/test_compose.sh 462 tests/stack/test-config.sh 753 +tests/stack/test-control-add-only-ssrf.sh 448 tests/stack/test-control-upgrade.sh 795 tests/stack/test-dashboard-onion.sh 422 tests/stack/test-dashboard.sh 592 diff --git a/lib/pithead/39-describe-change.sh b/lib/pithead/39-describe-change.sh index 2075ff32..bfc4ebba 100644 --- a/lib/pithead/39-describe-change.sh +++ b/lib/pithead/39-describe-change.sh @@ -149,8 +149,8 @@ describe_change() { P2POOL_FLAGS | P2POOL_PORT) msg="P2Pool sidechain changing ($key: '$old' → '$new') — p2pool re-syncs the new sidechain and your PPLNS window resets." ;; - MONERO_NODE_HOST | MONERO_RPC_PORT | MONERO_ZMQ_PORT) - msg="Monero node endpoint ($key): $old → $new." + MONERO_NODE_HOST | MONERO_RPC_PORT | MONERO_ZMQ_PORT | TARI_GRPC_ADDRESS) + flag=CONFIRM msg="${key%%_*} node endpoint ($key): ${old:-unset} → $new — the stack points its RPC client THERE and trusts the chain data, block templates and share heights that address returns. Confirm-gated, not free-commit, because it moves TRUST rather than disk; the host probes the new endpoint before accepting it, and putting the old address back reverses it." ;; MONERO_NODE_USERNAME | MONERO_NODE_PASSWORD) msg="Monero node RPC credential updated ($key)." diff --git a/lib/pithead/42-control-policy-and-host-checks.sh b/lib/pithead/42-control-policy-and-host-checks.sh index 2d76680b..e8f775b5 100644 --- a/lib/pithead/42-control-policy-and-host-checks.sh +++ b/lib/pithead/42-control-policy-and-host-checks.sh @@ -40,7 +40,7 @@ # The env keys committable from the dashboard: operational tuning only, and only keys whose value # is derived from a validated enum, boolean, or number — never a free-form string that reaches a # command line, URL, or credential. Everything else — wallets, auth, onion exposure, the control -# channel itself, Tor egress/clearnet toggles, binds and ports, node endpoints, the XvB pool URL +# channel itself, Tor egress/clearnet toggles, binds, node RPC credentials, the XvB pool URL # and donor id, tokens and passwords, the #381 payout-confirmation secrets (MONERO_VIEW_KEY, # WALLET_RPC_PASSWORD) plus PAYOUT_CONFIRM_ENABLED, and their #462 Tari siblings (TARI_VIEW_KEY, # TARI_WALLET_PASSWORD, TARI_SPEND_PUBLIC_KEY) plus TARI_PAYOUT_CONFIRM_ENABLED / @@ -95,9 +95,30 @@ CONTROL_DASHBOARD_EDITABLE_KEYS='P2POOL_FLAGS P2POOL_PORT # data-dir move, etc. still emit DEST and stay refused); this list is the static allowlist the # gate's default-deny pass consults and the UI mirrors (control_service.CONFIRM_ENV_KEY_PATHS, # drift-guarded like CONTROL_DASHBOARD_EDITABLE_KEYS). +# The four node-endpoint keys (#1888) are the 2026-09 addition, on the operator's ruling, and they +# are the reason to read this tier's boundary carefully rather than by analogy. They are NOT a +# data-dir move: repointing monerod's or the Tari base node's address moves TRUST, not disk — the +# stack believes the chain data, block templates and share heights whatever answers there. They are +# not free-commit either, for exactly that reason. They sit here because the change is INSTANTLY +# REVERSIBLE by the same route (type APPLY, put the old address back) and because it is the one +# perimeter entry an appliance operator must be able to make: there is no host shell on an +# appliance, so "edit config.json and run apply" is not a remedy, it is a dead end (#786/#1821). +# The compensating control is not the typed token — that is friction, as this comment says above — +# it is the host-side REACHABILITY PROBE the approval gate runs on the STAGED endpoint before it +# accepts one (43-control-approval-and-preview.sh, #1889's preflight_remote_nodes): a dashboard +# cannot silently park a chain on a node that is not there. The RPC LOGIN CREDENTIALS for a remote +# node (MONERO_NODE_USERNAME / MONERO_NODE_PASSWORD) are deliberately NOT here — those are secrets, +# not address identity, and they stay host-only DEST with the rest of the credentials above. CONTROL_DASHBOARD_CONFIRM_KEYS='MONERO_DATA_DIR TARI_DATA_DIR P2POOL_DATA_DIR DASHBOARD_DATA_DIR STRATUM_PORT MONERO_CLEARNET_SYNC TARI_CLEARNET_SYNC MONERO_PRUNE - MONERO_OUT_PEERS' + MONERO_OUT_PEERS + MONERO_NODE_HOST MONERO_RPC_PORT MONERO_ZMQ_PORT TARI_GRPC_ADDRESS' + +# The node-endpoint subset of the confirm set, named ONCE (#1888) so the approval gate's probe +# trigger is not a fourth hand-kept copy of these key names. Every key here must also be in +# CONTROL_DASHBOARD_CONFIRM_KEYS above — a key here but not there is unreachable; a node key there +# but not here would be committable with NO reachability probe, which is the failure that matters. +CONTROL_NODE_ENDPOINT_KEYS='MONERO_NODE_HOST MONERO_RPC_PORT MONERO_ZMQ_PORT TARI_GRPC_ADDRESS' # True if $1 is EXACTLY a canonical dotted-decimal IPv4 literal — four decimal octets 0-255, none # with a leading zero (a bare "0" is fine; "010"/"0177" are not). curl/glibc's numeric-address diff --git a/lib/pithead/43-control-approval-and-preview.sh b/lib/pithead/43-control-approval-and-preview.sh index e69d463f..15a40884 100644 --- a/lib/pithead/43-control-approval-and-preview.sh +++ b/lib/pithead/43-control-approval-and-preview.sh @@ -1,3 +1,13 @@ +# The remedy half of a control-channel refusal (#1888, the #1821 class): "edit config.json and run +# apply" is a real remedy on a DIY host and a DEAD END on a shell-less appliance (#786). +_control_host_remedy() { + if is_appliance; then + printf 'That setting is not changeable from the dashboard on an appliance; it is fixed when the machine is set up, so use "Set up again" if you need to change it.' + else + printf 'Edit config.json on the host and run `%s apply`.' "$0" + fi +} + control_approval_gate() { # [confirm-token] local staged="$1" confirm="${2:-}" porcelain # Fail closed if we cannot re-derive the change set (the staged config was validated at @@ -33,7 +43,7 @@ control_approval_gate() { # [confirm-token] ((($live[0].workers.list // []) | length) as $n | (.workers.list // [])[0:$n] == ($live[0].workers.list // [])) ' "$staged" >/dev/null 2>&1; then - printf 'this change alters an existing per-worker descriptor (workers.list[], a per-rig host/token) rather than only adding a new one, which is not committable from the dashboard. Edit config.json on the host and run `%s apply`.' "$0" + printf 'this change alters an existing per-worker descriptor (workers.list[], a per-rig host/token) rather than only adding a new one, which is not committable from the dashboard. %s' "$(_control_host_remedy)" return 1 fi # SSRF floor on what an add-only append may point at (see _control_host_is_internal): every @@ -78,7 +88,7 @@ control_approval_gate() { # [confirm-token] return 1 fi if [ -n "$unknown" ]; then - printf 'this change adds config keys not in the schema (%s) — refusing to commit. Edit config.json on the host and run `%s apply`.' "$unknown" "$0" + printf 'this change adds config keys not in the schema (%s) — refusing to commit. %s' "$unknown" "$(_control_host_remedy)" return 1 fi # Default-deny: refuse if any changed env key is NOT on the editable allowlist, whatever its @@ -93,14 +103,14 @@ control_approval_gate() { # [confirm-token] bad=$(printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -cvxE "$editable_re" || true) if [ "${bad:-0}" -gt 0 ]; then hit=$(printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -m1 -vxE "$editable_re" || true) - printf 'this change alters a security-sensitive setting (%s) that is not committable from the dashboard. Edit config.json on the host and run `%s apply`.' "${hit:-unparseable change row}" "$0" + printf 'this change alters a security-sensitive setting (%s) that is not committable from the dashboard. %s' "${hit:-unparseable change row}" "$(_control_host_remedy)" return 1 fi # Perimeter: any DEST row is refused outright — the confirm-gate never covers a destructive # host-only change. A data-dir MOVE is CONFIRM (below); a prune DISABLE or a TOR data-dir move # still emits DEST and is caught here even though its key is on the confirm allowlist. if printf '%s\n' "$porcelain" | grep -qE $'^DEST\t'; then - printf 'this change is destructive and cannot be committed from the dashboard. Edit config.json on the host and run `%s apply`.' "$0" + printf 'this change is destructive and cannot be committed from the dashboard. %s' "$(_control_host_remedy)" return 1 fi # Data-dir destination allowlist (#728). #719 made the four *_DATA_DIR moves confirm-gated, so a @@ -140,7 +150,7 @@ control_approval_gate() { # [confirm-token] case "$dest/" in "$root"/*) ok_root=1 && break ;; esac done if [ "$ok_root" -eq 0 ]; then - printf 'this move sends %s to %s, which is outside the stack data root(s) — a dashboard-confirmed data-dir move must stay under the stack data directory (%s) or a parent it already uses. Apply it from the host with `%s apply`.' "$ddpath" "$dest" "$PWD/data" "$0" + printf 'this move sends %s to %s, which is outside the stack data root(s) — a dashboard-confirmed data-dir move must stay under the stack data directory (%s) or a parent it already uses. %s' "$ddpath" "$dest" "$PWD/data" "$(_control_host_remedy)" return 1 fi done @@ -157,6 +167,20 @@ control_approval_gate() { # [confirm-token] fi touch "${staged}.confirmed" 2>/dev/null || true fi + # Reachability probe (#1888) — the compensating control the confirm tier rests on for these keys + # (42-): the typed token is friction, but a chain cannot be parked on a node that is not there. + # Host-side, on the STAGED config, through the same preflight the wizard uses; nothing is + # trusted from the container. Fires only when a node-endpoint key really changed (so an + # unrelated commit is never blocked by a node that is down) and only after the typed + # confirmation (so an unconfirmed attempt never pays the dial timeouts). + local probe_err endpoint_re + endpoint_re=$(printf '%s' "$CONTROL_NODE_ENDPOINT_KEYS" | tr -s ' \n' '|') + if printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -qxE "$endpoint_re"; then + if ! probe_err=$(preflight_remote_nodes "$staged" 2>/dev/null); then + printf 'this change points the stack at a node the host cannot use: %s' "$probe_err" + return 1 + fi + fi # Approved: echo the changed key NAMES so the commit's audit entry can record WHAT changed # (#349) without a third dry-run. Names only, never values. dashboard.energy (#504) is # config.json-only, so it never appears in the env porcelain — fold a synthetic DASHBOARD_ENERGY diff --git a/pithead b/pithead index 59257aa0..1ff4192d 100755 --- a/pithead +++ b/pithead @@ -9789,8 +9789,8 @@ describe_change() { P2POOL_FLAGS | P2POOL_PORT) msg="P2Pool sidechain changing ($key: '$old' → '$new') — p2pool re-syncs the new sidechain and your PPLNS window resets." ;; - MONERO_NODE_HOST | MONERO_RPC_PORT | MONERO_ZMQ_PORT) - msg="Monero node endpoint ($key): $old → $new." + MONERO_NODE_HOST | MONERO_RPC_PORT | MONERO_ZMQ_PORT | TARI_GRPC_ADDRESS) + flag=CONFIRM msg="${key%%_*} node endpoint ($key): ${old:-unset} → $new — the stack points its RPC client THERE and trusts the chain data, block templates and share heights that address returns. Confirm-gated, not free-commit, because it moves TRUST rather than disk; the host probes the new endpoint before accepting it, and putting the old address back reverses it." ;; MONERO_NODE_USERNAME | MONERO_NODE_PASSWORD) msg="Monero node RPC credential updated ($key)." @@ -10486,7 +10486,7 @@ run_chain() { # The env keys committable from the dashboard: operational tuning only, and only keys whose value # is derived from a validated enum, boolean, or number — never a free-form string that reaches a # command line, URL, or credential. Everything else — wallets, auth, onion exposure, the control -# channel itself, Tor egress/clearnet toggles, binds and ports, node endpoints, the XvB pool URL +# channel itself, Tor egress/clearnet toggles, binds, node RPC credentials, the XvB pool URL # and donor id, tokens and passwords, the #381 payout-confirmation secrets (MONERO_VIEW_KEY, # WALLET_RPC_PASSWORD) plus PAYOUT_CONFIRM_ENABLED, and their #462 Tari siblings (TARI_VIEW_KEY, # TARI_WALLET_PASSWORD, TARI_SPEND_PUBLIC_KEY) plus TARI_PAYOUT_CONFIRM_ENABLED / @@ -10541,9 +10541,30 @@ CONTROL_DASHBOARD_EDITABLE_KEYS='P2POOL_FLAGS P2POOL_PORT # data-dir move, etc. still emit DEST and stay refused); this list is the static allowlist the # gate's default-deny pass consults and the UI mirrors (control_service.CONFIRM_ENV_KEY_PATHS, # drift-guarded like CONTROL_DASHBOARD_EDITABLE_KEYS). +# The four node-endpoint keys (#1888) are the 2026-09 addition, on the operator's ruling, and they +# are the reason to read this tier's boundary carefully rather than by analogy. They are NOT a +# data-dir move: repointing monerod's or the Tari base node's address moves TRUST, not disk — the +# stack believes the chain data, block templates and share heights whatever answers there. They are +# not free-commit either, for exactly that reason. They sit here because the change is INSTANTLY +# REVERSIBLE by the same route (type APPLY, put the old address back) and because it is the one +# perimeter entry an appliance operator must be able to make: there is no host shell on an +# appliance, so "edit config.json and run apply" is not a remedy, it is a dead end (#786/#1821). +# The compensating control is not the typed token — that is friction, as this comment says above — +# it is the host-side REACHABILITY PROBE the approval gate runs on the STAGED endpoint before it +# accepts one (43-control-approval-and-preview.sh, #1889's preflight_remote_nodes): a dashboard +# cannot silently park a chain on a node that is not there. The RPC LOGIN CREDENTIALS for a remote +# node (MONERO_NODE_USERNAME / MONERO_NODE_PASSWORD) are deliberately NOT here — those are secrets, +# not address identity, and they stay host-only DEST with the rest of the credentials above. CONTROL_DASHBOARD_CONFIRM_KEYS='MONERO_DATA_DIR TARI_DATA_DIR P2POOL_DATA_DIR DASHBOARD_DATA_DIR STRATUM_PORT MONERO_CLEARNET_SYNC TARI_CLEARNET_SYNC MONERO_PRUNE - MONERO_OUT_PEERS' + MONERO_OUT_PEERS + MONERO_NODE_HOST MONERO_RPC_PORT MONERO_ZMQ_PORT TARI_GRPC_ADDRESS' + +# The node-endpoint subset of the confirm set, named ONCE (#1888) so the approval gate's probe +# trigger is not a fourth hand-kept copy of these key names. Every key here must also be in +# CONTROL_DASHBOARD_CONFIRM_KEYS above — a key here but not there is unreachable; a node key there +# but not here would be committable with NO reachability probe, which is the failure that matters. +CONTROL_NODE_ENDPOINT_KEYS='MONERO_NODE_HOST MONERO_RPC_PORT MONERO_ZMQ_PORT TARI_GRPC_ADDRESS' # True if $1 is EXACTLY a canonical dotted-decimal IPv4 literal — four decimal octets 0-255, none # with a leading zero (a bare "0" is fine; "010"/"0177" are not). curl/glibc's numeric-address @@ -10698,6 +10719,16 @@ _control_host_is_internal() { return 1 } +# The remedy half of a control-channel refusal (#1888, the #1821 class): "edit config.json and run +# apply" is a real remedy on a DIY host and a DEAD END on a shell-less appliance (#786). +_control_host_remedy() { + if is_appliance; then + printf 'That setting is not changeable from the dashboard on an appliance; it is fixed when the machine is set up, so use "Set up again" if you need to change it.' + else + printf 'Edit config.json on the host and run `%s apply`.' "$0" + fi +} + control_approval_gate() { # [confirm-token] local staged="$1" confirm="${2:-}" porcelain # Fail closed if we cannot re-derive the change set (the staged config was validated at @@ -10733,7 +10764,7 @@ control_approval_gate() { # [confirm-token] ((($live[0].workers.list // []) | length) as $n | (.workers.list // [])[0:$n] == ($live[0].workers.list // [])) ' "$staged" >/dev/null 2>&1; then - printf 'this change alters an existing per-worker descriptor (workers.list[], a per-rig host/token) rather than only adding a new one, which is not committable from the dashboard. Edit config.json on the host and run `%s apply`.' "$0" + printf 'this change alters an existing per-worker descriptor (workers.list[], a per-rig host/token) rather than only adding a new one, which is not committable from the dashboard. %s' "$(_control_host_remedy)" return 1 fi # SSRF floor on what an add-only append may point at (see _control_host_is_internal): every @@ -10778,7 +10809,7 @@ control_approval_gate() { # [confirm-token] return 1 fi if [ -n "$unknown" ]; then - printf 'this change adds config keys not in the schema (%s) — refusing to commit. Edit config.json on the host and run `%s apply`.' "$unknown" "$0" + printf 'this change adds config keys not in the schema (%s) — refusing to commit. %s' "$unknown" "$(_control_host_remedy)" return 1 fi # Default-deny: refuse if any changed env key is NOT on the editable allowlist, whatever its @@ -10793,14 +10824,14 @@ control_approval_gate() { # [confirm-token] bad=$(printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -cvxE "$editable_re" || true) if [ "${bad:-0}" -gt 0 ]; then hit=$(printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -m1 -vxE "$editable_re" || true) - printf 'this change alters a security-sensitive setting (%s) that is not committable from the dashboard. Edit config.json on the host and run `%s apply`.' "${hit:-unparseable change row}" "$0" + printf 'this change alters a security-sensitive setting (%s) that is not committable from the dashboard. %s' "${hit:-unparseable change row}" "$(_control_host_remedy)" return 1 fi # Perimeter: any DEST row is refused outright — the confirm-gate never covers a destructive # host-only change. A data-dir MOVE is CONFIRM (below); a prune DISABLE or a TOR data-dir move # still emits DEST and is caught here even though its key is on the confirm allowlist. if printf '%s\n' "$porcelain" | grep -qE $'^DEST\t'; then - printf 'this change is destructive and cannot be committed from the dashboard. Edit config.json on the host and run `%s apply`.' "$0" + printf 'this change is destructive and cannot be committed from the dashboard. %s' "$(_control_host_remedy)" return 1 fi # Data-dir destination allowlist (#728). #719 made the four *_DATA_DIR moves confirm-gated, so a @@ -10840,7 +10871,7 @@ control_approval_gate() { # [confirm-token] case "$dest/" in "$root"/*) ok_root=1 && break ;; esac done if [ "$ok_root" -eq 0 ]; then - printf 'this move sends %s to %s, which is outside the stack data root(s) — a dashboard-confirmed data-dir move must stay under the stack data directory (%s) or a parent it already uses. Apply it from the host with `%s apply`.' "$ddpath" "$dest" "$PWD/data" "$0" + printf 'this move sends %s to %s, which is outside the stack data root(s) — a dashboard-confirmed data-dir move must stay under the stack data directory (%s) or a parent it already uses. %s' "$ddpath" "$dest" "$PWD/data" "$(_control_host_remedy)" return 1 fi done @@ -10857,6 +10888,20 @@ control_approval_gate() { # [confirm-token] fi touch "${staged}.confirmed" 2>/dev/null || true fi + # Reachability probe (#1888) — the compensating control the confirm tier rests on for these keys + # (42-): the typed token is friction, but a chain cannot be parked on a node that is not there. + # Host-side, on the STAGED config, through the same preflight the wizard uses; nothing is + # trusted from the container. Fires only when a node-endpoint key really changed (so an + # unrelated commit is never blocked by a node that is down) and only after the typed + # confirmation (so an unconfirmed attempt never pays the dial timeouts). + local probe_err endpoint_re + endpoint_re=$(printf '%s' "$CONTROL_NODE_ENDPOINT_KEYS" | tr -s ' \n' '|') + if printf '%s' "$porcelain" | awk -F'\t' 'NF' | cut -f2 | grep -qxE "$endpoint_re"; then + if ! probe_err=$(preflight_remote_nodes "$staged" 2>/dev/null); then + printf 'this change points the stack at a node the host cannot use: %s' "$probe_err" + return 1 + fi + fi # Approved: echo the changed key NAMES so the commit's audit entry can record WHAT changed # (#349) without a third dry-run. Names only, never values. dashboard.energy (#504) is # config.json-only, so it never appears in the env porcelain — fold a synthetic DASHBOARD_ENERGY diff --git a/tests/stack/test-config.sh b/tests/stack/test-config.sh index e4cbf891..118ffe21 100644 --- a/tests/stack/test-config.sh +++ b/tests/stack/test-config.sh @@ -64,9 +64,7 @@ case "$stratum_pw_appliance" in *"./pithead"* | *".env"*) bad "appliance stratum pw enable names no CLI verb or .env" "still says: $stratum_pw_appliance" ;; *) ok "appliance stratum pw enable names no CLI verb or .env" ;; esac -assert_contains "DIY stratum pw enable advice is unchanged" \ - "$(PITHEAD_APPLIANCE=0 run_sourced "$SANDBOX" describe_change PROXY_STRATUM_PASSWORD '' s3cr3t)" \ - "find it in .env / './pithead status'" +assert_contains "DIY stratum pw enable advice is unchanged" "$(PITHEAD_APPLIANCE=0 run_sourced "$SANDBOX" describe_change PROXY_STRATUM_PASSWORD '' s3cr3t)" "find it in .env / './pithead status'" case "$(run_sourced "$SANDBOX" describe_change PROXY_STRATUM_PASSWORD oldpw newpw)" in *oldpw* | *newpw*) bad "stratum pw change hides the secret" "value leaked into the change preview" ;; *DEST*) ok "stratum pw change hides the secret (DEST, no value shown)" ;; @@ -79,19 +77,14 @@ assert_contains "tor auto-heal disable names the manual fix" "$(run_sourced "$SA # Appliance (#1139): 'doctor' and a scoped tor restart are both CLI-only, and no dashboard control # restarts tor alone — the appliance-lane message states the fact instead of naming a remedy that # does not exist on that lane. -# -# MUTATION PROOF: drop the is_appliance branch (always emit the DIY message) and the "names no CLI -# verb" assertion below goes red; force the appliance branch unconditionally and the unchanged-DIY -# assertion right above (checked again explicitly below) goes red — neither direction passes both. +# MUTATION PROOF: the same pair as the stratum-password block above, same mechanism — neither forced branch passes both assertions. tor_heal_appliance="$(PITHEAD_APPLIANCE=1 run_sourced "$SANDBOX" describe_change TOR_AUTO_HEAL true false)" assert_contains "appliance tor auto-heal disable is still INFO" "$tor_heal_appliance" "INFO" case "$tor_heal_appliance" in *"./pithead"*) bad "appliance tor auto-heal disable names no CLI verb" "still says: $tor_heal_appliance" ;; *) ok "appliance tor auto-heal disable names no CLI verb" ;; esac -assert_contains "DIY tor auto-heal disable advice is unchanged" \ - "$(PITHEAD_APPLIANCE=0 run_sourced "$SANDBOX" describe_change TOR_AUTO_HEAL true false)" \ - "'./pithead doctor', fix with './pithead restart tor'" +assert_contains "DIY tor auto-heal disable advice is unchanged" "$(PITHEAD_APPLIANCE=0 run_sourced "$SANDBOX" describe_change TOR_AUTO_HEAL true false)" "'./pithead doctor', fix with './pithead restart tor'" # Fail-closed miner hold (#490): INFO either way (like TARI_REQUIRED) — it's on the dashboard # control-channel allowlist, so a DEST flag here would make control_approval_gate refuse every # commit that touches it, defeating the allowlisting. @@ -179,6 +172,13 @@ assert_contains "tari clearnet enable warns exposure" "$(run_sourced "$SANDBOX" # restore points and proxy.donate_level host-only — a future-dated restore point silently defeats # payout-confirmation tamper evidence, and donate traffic bypasses the Tor socks5. assert_contains "monero outbound-peer change is CONFIRM" "$(run_sourced "$SANDBOX" describe_change MONERO_OUT_PEERS 12 64)" "CONFIRM" +# 2026-09 operator ruling (#1888): the remote node endpoints joined that tier — they move TRUST, not +# disk — while the RPC LOGIN CREDENTIALS for the same node did NOT. That row is the control: it is what makes this set able to say NO. +node_ep="$(run_sourced "$SANDBOX" describe_change MONERO_NODE_HOST 10.0.0.9 10.0.0.11)" +assert_contains "monero node endpoint is CONFIRM (#1888)" "$node_ep" "CONFIRM" +assert_contains "monero node endpoint preview names old -> new" "$node_ep" "10.0.0.9 → 10.0.0.11" +assert_contains "tari node endpoint is CONFIRM (#1888)" "$(run_sourced "$SANDBOX" describe_change TARI_GRPC_ADDRESS a.lan:18142 b.lan:18142)" "CONFIRM" +assert_not_contains "a remote node's RPC password is NOT confirm-gated" "$(run_sourced "$SANDBOX" describe_change MONERO_NODE_PASSWORD old new)" "CONFIRM" echo "== unit: explain_subnet_collision (#180) ==" ov="$(run_sourced "$SANDBOX" explain_subnet_collision "invalid pool request: Pool overlaps with other one on this address space" 2>&1)" diff --git a/tests/stack/test-control-add-only-ssrf.sh b/tests/stack/test-control-add-only-ssrf.sh index 2ee9f945..03ee2f8c 100644 --- a/tests/stack/test-control-add-only-ssrf.sh +++ b/tests/stack/test-control-add-only-ssrf.sh @@ -398,3 +398,51 @@ jq '.p2pool.pool="mini"' "$C/config.json" >"$C/cand.json" gate_try "$C/cand.json" assert_eq "non-security change on a security-laden config still applies" "$(jq -r '.status' "$RESULTS/$UUID5.json" 2>/dev/null)" "applied" assert_eq "pool tier change landed in config.json" "$(jq -r '.p2pool.pool' "$C/config.json")" "mini" + +# The node-endpoint tier (#1888), end to end through the real spool. The four endpoint keys left +# the never-committable perimeter for the confirm tier on the operator's ruling, and the host-side +# REACHABILITY PROBE is what was traded for that perimeter entry — so what has to be proven here is +# that the gate CALLS it. One variable moves per case: no token (the tier), token + an endpoint +# nothing answers on (the probe refuses), token + one that answers (it commits). Case 2 is the +# teeth — WITHOUT the probe that same commit applies — and case 3 is what stops a probe that +# refuses everything from reading as a pass. +cp "$C/config.json" "$C/nep-keep.json" +# A kernel-chosen port: two lanes may run this suite at once, so a fixed one would collide. The +# Tari leg of the probe is a bare TCP connect, so an accept()ing socket is all it needs to pass. +python3 -c 'import socket, sys, time +s = socket.socket() +s.bind(("127.0.0.1", 0)) +s.listen(8) +sys.stdout.write("%d\n" % s.getsockname()[1]) +sys.stdout.flush() +time.sleep(120)' >"$C/nep.port" & +nep_pid=$! +nep_port_ready() { [ -s "$C/nep.port" ]; } +wait_while_alive "$nep_pid" nep_port_ready +nep_live=$(tr -dc '0-9' <"$C/nep.port") +timeout 5 bash -c "/dev/null +assert_rc "the fixture's own port really accepts (control on the fixture, not on the gate)" "$?" "0" +# Baseline: Tari on a REMOTE node that is NOT up. Monero stays local, so only the Tari leg is ever +# dialled, and `apply` itself never probes — the wizard and this gate are the only callers. +jq '.tari.mode="remote" | .tari.remote={host:"127.0.0.1",grpc_port:1}' "$C/config.json" >"$C/cand.json" && mv "$C/cand.json" "$C/config.json" +(cd "$C" && DOCKER_LOG="$CTRL_LOG" PATH="$C/bin:$PATH" ./pithead apply -y >/dev/null 2>&1) +assert_contains "remote-Tari baseline applied" "$(cat "$C/.env")" "TARI_GRPC_ADDRESS=127.0.0.1:1" +# 1. No token: the endpoint IS committable now, but only behind the typed confirmation. +jq '.tari.remote.grpc_port=2' "$C/config.json" >"$C/cand.json" +gate_try "$C/cand.json" +assert_eq "a node-endpoint change without the token is refused" "$(jq -r '.status' "$RESULTS/$UUID5.json" 2>/dev/null)" "rejected" +assert_contains "the token-less refusal asks for the confirmation" "$(jq -r '.error' "$RESULTS/$UUID5.json" 2>/dev/null)" "type APPLY" +# 2. Token + an endpoint nothing answers on: the PROBE refuses. Without it this commit applies. +gate_try "$C/cand.json" APPLY +assert_eq "an unreachable node endpoint is refused even with the token" "$(jq -r '.status' "$RESULTS/$UUID5.json" 2>/dev/null)" "rejected" +assert_contains "the refusal comes from the reachability probe" "$(jq -r '.error' "$RESULTS/$UUID5.json" 2>/dev/null)" "cannot use" +assert_eq "config.json keeps the old endpoint" "$(jq -r '.tari.remote.grpc_port' "$C/config.json")" "1" +# 3. Token + an endpoint that answers: committed. #1888's whole point — changeable on a live machine. +jq --argjson p "$nep_live" '.tari.remote.grpc_port=$p' "$C/config.json" >"$C/cand.json" +gate_try "$C/cand.json" APPLY +assert_eq "a reachable node endpoint commits with the token" "$(jq -r '.status' "$RESULTS/$UUID5.json" 2>/dev/null)" "applied" +assert_eq "the new endpoint landed in config.json" "$(jq -r '.tari.remote.grpc_port' "$C/config.json")" "$nep_live" +kill "$nep_pid" 2>/dev/null +cp "$C/nep-keep.json" "$C/config.json" +(cd "$C" && DOCKER_LOG="$CTRL_LOG" PATH="$C/bin:$PATH" ./pithead apply -y >/dev/null 2>&1) +unset nep_pid nep_live