personal_infra/ansible/roles/xmrblocks/README.md

177 lines
8.5 KiB
Markdown
Raw Normal View History

xmrblocks: the Monero explorer, built off-box because 2 GB cannot compile it The Onion Monero Blockchain Explorer on monerod-box, published at exmrplore.contrapeso.xyz. Ten Gatus endpoints green across the node and the explorer. ── It needs a compiled monero source tree, not a running monerod ─────────── xmrblocks is not an RPC client. cmake/FindMonero.cmake imports 21 STATIC libraries out of a monero build tree and includes its headers, because it opens the LMDB directly. The release tarball we install for the daemon ships only executables - no .a files, no headers - so there is nothing on the box to link against, and building it means compiling monero first. Measured on Debian 13, building only the libraries it links: peak RSS, one cc1plus ~3.2 GB peak RSS at -j4 ~5.5 GB wall time at -j4 167 s source + build tree 195 MB toolchain 1.6 GB That rules the box out at ANY parallelism: 768 MiB and 1800 MiB were both OOM-killed at -j1 and -j2, because a single translation unit over 3 GB is not something lower -j works around. My first estimate of "~10 GB of disk" was wrong by two orders of magnitude - that figure describes upstream's full `make`, which also builds monerod, both wallets and the test suite, none of it linked here. Disk was never the constraint. So scripts/build-xmrblocks.sh does the build in a debian:13 container. An artefact only one machine can produce is one nobody can audit; this way any host with Docker and ~6 GB reproduces it. It verifies the monero source against binaryFate's signature exactly as roles/monero does for the daemon, and the role asserts the resulting sha256 ON THE CONTROL NODE before copying a single byte. ── Three upstream problems, none documented anywhere ─────────────────────── Found by building, not by reading: 1. libasio-dev is absent from upstream's dependency list. That list is Ubuntu-based; Crow wants standalone asio.hpp, not boost/asio.hpp, and on Debian 13 the build dies outright. 2. The explorer does not compile against monero v0.18.5.1. v0.18.5.0 changed get_pruned_transaction_hash from returning the hash to returning bool with an out-param, and src/page.h still calls the old form. Upstream's Dockerfile pins v0.18.4.0, which is why nobody has hit it. Patched from an exact-line anchor, so if upstream fixes it the build STOPS rather than quietly doing something else. We patch instead of building against 0.18.4.0 because the signed hashes.txt only covers the current release - using the older source would mean dropping the provenance chain. LMDB is VERSION 5 in both, so either reads the other's database. 3. FindMonero.cmake hunts for miniupnpc inside the monero build tree with NO_DEFAULT_PATH, but monero stopped vendoring miniupnp as a submodule. Fixed with a cache override rather than patching their cmake. USE_DEVICE_TREZOR=OFF as well - an explorer has no use for hardware wallets and it drops the generated protobuf units. Verified that xmrblocks still links. ── Two things in the unit that look wrong and are not ────────────────────── WorkingDirectory is load-bearing: page.h hardcodes TMPL_DIR as "./templates", resolved relative to cwd at REQUEST time. Get it wrong and the service starts, binds 8081, and 500s every page. That is also why the health check fetches a real page and greps it instead of probing the port. ReadWritePaths must include the blockchain directory even though the database is opened read-only: monero sets mdb_flags = MDB_RDONLY WITHOUT MDB_NOLOCK, so LMDB still registers this reader in lock.mdb. With ProtectSystem=strict and nothing writable the open fails. "It only reads, so grant it nothing" is wrong. ── Timeouts are measured, not guessed ────────────────────────────────────── proxmox-tank-1 is a spinning-disk mirror and xmrblocks says so on startup. First start took 98s to open the LMDB before binding (wait_for now allows 300s), and a COLD index page took 68 SECONDS where the next two took 0.87s and 0.012s (the check now allows 90s, its unit 180s). The first pass used 90s and 25s and produced exactly the false failures you would expect. A stat() bug was caught the same way: `stat` computes a sha1 by default, so checking that data.mdb exists was hashing the entire 40 GB blockchain. --check found it before a real run did. ── Exposure ──────────────────────────────────────────────────────────────── Caddy on vipy terminates TLS; the upstream is reached over the tailnet, which needed a headscale ACL entry for tag:monerod-box:8081 - without it the node is invisible, not merely unreachable, and the symptom is a 502 "no such host". No authentication, deliberately: it is a block explorer over public chain data. That includes --enable-pusher, so a visitor can relay a raw transaction through our node. A choice, not an oversight; flip xmrblocks_enable_pusher to close it. ── Also ──────────────────────────────────────────────────────────────────── monerod-box raised 2048 -> 6144 MiB. Not for the explorer: monerod alone had outgrown 2 GB past ~39% of the chain and was OOM-killed twice in a morning at 1.74 GB anon-rss. Recorded in vms.auto.tfvars because ignore_changes = all means tofu will not reconcile it. The box now idles at ~1.2 GB of 5.9 GB with both services up. No backups, deliberately - binary, templates and chain are all reproducible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:06:30 +02:00
# xmrblocks
The [Onion Monero Blockchain Explorer](https://github.com/moneroexamples/onion-monero-blockchain-explorer),
on `monerod-box` beside the node, published at `exmrplore.contrapeso.xyz`.
## Why this role installs a binary instead of building one
Every other compiled service in this estate builds on its own host —
`bitcoin_knots` and `datum_gateway` both do. This one cannot, and the reason is
worth recording so nobody "fixes" it later.
`xmrblocks` is not a client of monerod. Its `cmake/FindMonero.cmake` imports 21
**static libraries** out of a compiled monero source tree and its headers, because
it opens the LMDB directly rather than going through RPC. Building it therefore
means compiling monero first — and the release tarball we install for the daemon
contains only executables, no `.a` files and no headers, so there is nothing on
the box to link against.
Measured on Debian 13, building only the libraries `xmrblocks` needs:
| | |
|---|---|
| peak RSS, one `cc1plus` | **~3.2 GB** |
| peak RSS, `-j4` concurrent | **~5.5 GB** |
| wall time, `-j4` | 167 s |
| build tree | 125 MB (source 70 MB) |
| toolchain | 1.6 GB |
`monerod-box` had **2 GB** when this was measured (it has since been raised to
6 GB, for monerod's own sake — see below). It was tried at 768 MiB and 1800 MiB,
at `-j1` and `-j2`, and every combination was OOM-killed — a single translation unit
exceeding 3 GB is not something lower parallelism can work around. Disk was
never the constraint; the often-quoted "~10 GB" for a monero build describes
upstream's full `make`, which also produces monerod, both wallets and the test
suite, none of which are linked here.
So the binary is built elsewhere by **`scripts/build-xmrblocks.sh`**, which runs
the whole thing in a `debian:13` container. That matters: an artifact only one
machine can produce is an artifact nobody can audit. Any host with Docker and
~6 GB of RAM reproduces it, and the script verifies the monero source against
binaryFate's GPG signature exactly as `roles/monero` does for the daemon.
The role refuses to ship anything it has not checked: `xmrblocks_sha256` is
asserted **on the control node, before the copy**, against the value the build
script prints.
## Three upstream problems, and the fixes
None of these are documented anywhere. All three were found by building.
1. **`libasio-dev` is missing from upstream's dependency list.** The Dockerfile
is Ubuntu-based and installs `libboost-all-dev`; Crow wants standalone
`asio.hpp`, not `boost/asio.hpp`. On Debian 13 the build dies with
`asio.hpp: No such file or directory`.
2. **The explorer does not compile against monero v0.18.5.1.** v0.18.5.0 changed
```cpp
crypto::hash get_pruned_transaction_hash(const transaction&, const crypto::hash&);
bool get_pruned_transaction_hash(const transaction&, const crypto::hash&, crypto::hash& res);
```
and `src/page.h` still calls the old form. Upstream's Dockerfile pins
`MONERO_BRANCH=v0.18.4.0`, which is why nobody has hit it. The build script
applies a one-line patch, **anchored on the exact source line** so that if
upstream ever fixes it the build stops loudly instead of silently doing
something else.
We patch rather than build against v0.18.4.0 deliberately: the current signed
`hashes.txt` only covers the current release, so using the older source would
mean giving up the provenance chain. The LMDB format is `VERSION 5` in both,
so either version reads the other's database safely.
3. **`FindMonero.cmake` looks for miniupnpc inside the monero build tree** with
`NO_DEFAULT_PATH`, but monero stopped vendoring miniupnp as a submodule. Fixed
with a `-D Xmr_MINIUPNPC_LIBRARY=` cache override pointing at the system
library, rather than patching their cmake.
`USE_DEVICE_TREZOR=OFF` is also passed: an explorer has no use for hardware
wallet support, and it drops the generated protobuf units, which are among the
heaviest to compile. `xmrblocks` was verified to link against the resulting
libraries.
## Two things the unit gets right that look wrong
**`WorkingDirectory` is load-bearing.** `src/page.h` hardcodes
`#define TMPL_DIR "./templates"` — the HTML is resolved relative to the process
working directory, at request time. Get it wrong and the service starts happily,
listens on 8081, and returns 500 for every page. That is also why the health
check fetches a real page and greps it rather than just probing the port.
**`ReadWritePaths` must include the blockchain directory**, even though the
database is opened read-only. `MicroCore::init` sets `DBF_RDONLY`, and monero's
`db_lmdb.cpp` turns that into `mdb_flags = MDB_RDONLY` **without `MDB_NOLOCK`** —
so LMDB still registers this process in the reader table inside `lock.mdb`. With
`ProtectSystem=strict` and nothing writable, the open fails outright. The
intuitive "it only reads, grant it nothing" is wrong. `data.mdb` itself remains
read-only to the process, and writing to monerod's lock table is what any second
reader does.
## Exposure
Published by `caddy_site` on the edge host at `exmrplore.contrapeso.xyz`, with
TLS terminated there and the upstream reached over the tailnet via MagicDNS.
The explorer listens on `0.0.0.0:8081`. Nothing on the LAN or the internet
reaches that directly: Tailscale's `ts-input` chain sits ahead of UFW, so only
tailnet peers can, and the Headscale ACL grants `tag:prd-vipy` exactly
`tag:monerod-box:8081`. A node with no ACL rule is *invisible* rather than
merely unreachable — it never appears in `tailscale status`, MagicDNS does not
resolve it, and the symptom is a Caddy 502 saying "no such host".
**There is no authentication, by choice.** A block explorer serves public chain
data. But note what that includes: `--enable-pusher` is on (upstream's default),
so any visitor can submit a raw transaction and have *our* node relay it. That is
a deliberate decision, not an oversight — drop `xmrblocks_enable_pusher` to
`false` to close it.
`--enable-json-api` and `--enable-autorefresh-option` are likewise upstream's
defaults.
## Monitoring
| Endpoint | Source | Answers |
|---|---|---|
| `services/monerod-box-local/xmrblocks` | `infra/401` via `monitored_services` | is the unit active |
| `probe/xmrblocks` | this role + `infra/403` | does it serve a real page |
| `dns/exmrplore.contrapeso.xyz`, `public/exmrplore` | `infra/402` | can the internet reach it |
`public/exmrplore` asserts **200**, unlike the Monero RPC endpoint next door
which asserts 401 — this one genuinely has no auth in front of it.
The timer carries no `Requires=xmrblocks.service`; see the comment in
`healthcheck.timer.j2` for why that would be backwards.
## Backups: none, deliberately
Everything here is derived or reproducible: the binary comes from
`scripts/build-xmrblocks.sh`, the templates ship with it, and the data it serves
is monerod's blockchain, which is itself excluded for the same reason. There is
no state on this service worth a backup job. Recorded as a decision so it does
not read as an oversight — the same treatment `phoenixd` and `monero` get.
## Upgrading
1. Edit the pins at the top of `scripts/build-xmrblocks.sh` (`EXPLORER_COMMIT`,
and `MONERO_VERSION` + `MONERO_SRC_SHA256` together).
2. Run it. It prints the new artifact sha256.
3. Put that in `xmrblocks_sha256` and `xmrblocks_explorer_commit` here.
4. Converge. The role asserts the checksum before copying anything.
If the patch anchor no longer matches, the script stops — read the diff before
forcing it.
## Runtime cost, and the RAM history
On **2026-09-20 the VM was raised from 2 GB to 6 GB**, and that was not for the
explorer. monerod alone had outgrown 2 GB once the chain passed ~39%: it was
OOM-killed twice in one morning at 1.74 GB anon-rss, restarting every hour. With
6 GB the box sits at ~1.2 GB used of 5.9 GB with both services running
(monerod ~1.0 GB, xmrblocks ~96 MB).
**The disk is the real performance limit, not memory.** `proxmox-tank-1` is a
spinning-disk mirror, and xmrblocks says so on startup: *"The blockchain is on a
rotating drive: this will be very slow, use an SSD if possible"*. Measured
consequences, both of which the role accounts for:
- first start takes ~98 seconds to open the LMDB before it binds, so
`wait_for` allows 300s
- a COLD index page took **68 seconds**, while the two requests after it took
0.87s and 0.012s — so the health check allows 90s and its unit 180s. A short
timeout there does not detect a slow explorer, it manufactures false DOWNs.
Footprint note: xmrblocks mmaps the LMDB, so most of its apparent size is
reclaimable page cache rather than anonymous memory, and `--concurrency` is held
at 2 for the same reason.