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
|
|
|
|
|
#
|
|
|
|
|
# This role INSTALLS a prebuilt binary; it does not compile one. xmrblocks links
|
|
|
|
|
# 21 static libraries out of a compiled monero source tree, and building that
|
|
|
|
|
# needs ~3.2 GB of RAM for a single compiler (~5.5 GB at -j4). monerod-box has
|
|
|
|
|
# 2 GB, so it cannot build this at any -j. See README.md.
|
|
|
|
|
#
|
|
|
|
|
# The artifact is produced by scripts/build-xmrblocks.sh, which runs the whole
|
|
|
|
|
# build in a debian:13 container - so it is reproducible on any machine with
|
|
|
|
|
# Docker, not just the one that happened to build it.
|
|
|
|
|
|
|
|
|
|
# --- The artifact ------------------------------------------------------------
|
|
|
|
|
# A directory on the CONTROL NODE holding `xmrblocks`, `templates/` and MANIFEST.
|
|
|
|
|
xmrblocks_artifact_dir: "{{ lookup('env', 'HOME') }}/personal_infra_artifacts/xmrblocks"
|
|
|
|
|
|
|
|
|
|
# Pinned checksum of the binary we intend to ship. Asserted on the control node
|
|
|
|
|
# BEFORE anything is copied: shipping an unverified binary to a host is the one
|
|
|
|
|
# thing this role must never do. Update it together with the build pins in
|
|
|
|
|
# scripts/build-xmrblocks.sh.
|
|
|
|
|
xmrblocks_sha256: "a40c495e73a44fbb3b666fcd8a01466a2aef12ed1906558f3d5b7e458c256af6"
|
|
|
|
|
|
|
|
|
|
# Recorded so the deployed host can be matched back to a build.
|
|
|
|
|
xmrblocks_explorer_commit: "29593b709edeb23daba035009ccd134a5862543a"
|
|
|
|
|
xmrblocks_monero_version: "0.18.5.1"
|
|
|
|
|
|
|
|
|
|
# --- Install layout ----------------------------------------------------------
|
|
|
|
|
# The binary hardcodes TMPL_DIR as "./templates", relative to the working
|
|
|
|
|
# directory - so templates MUST sit beside the binary and the unit MUST set
|
|
|
|
|
# WorkingDirectory. Get that wrong and it starts fine, then fails per-request.
|
|
|
|
|
xmrblocks_dir: /opt/xmrblocks
|
|
|
|
|
|
|
|
|
|
# Runs as the monero user: the explorer opens monerod's LMDB directly, and
|
|
|
|
|
# /var/lib/monero is 0750 monero:monero. It opens it READ-ONLY (DBF_RDONLY in
|
|
|
|
|
# MicroCore::init), so it is safe alongside a running monerod.
|
|
|
|
|
xmrblocks_user: monero
|
|
|
|
|
xmrblocks_group: monero
|
|
|
|
|
|
|
|
|
|
# --- Runtime -----------------------------------------------------------------
|
|
|
|
|
# The LMDB directory, not the data directory: monerod's data-dir plus /lmdb.
|
|
|
|
|
xmrblocks_bc_path: /var/lib/monero/lmdb
|
|
|
|
|
|
|
|
|
|
# 0.0.0.0 so the edge host can reach it over the tailnet. Nothing on the LAN or
|
|
|
|
|
# the internet can: Tailscale's ts-input chain sits ahead of UFW, and the only
|
|
|
|
|
# public route is the Caddy vhost on vipy.
|
|
|
|
|
xmrblocks_bind: "0.0.0.0"
|
|
|
|
|
xmrblocks_port: 8081
|
|
|
|
|
|
|
|
|
|
# The unrestricted RPC on loopback. Used for the mempool view and tx pushing.
|
|
|
|
|
xmrblocks_daemon_url: "127.0.0.1:18081"
|
|
|
|
|
# monerod's --rpc-login guards BOTH its RPC servers, so the explorer needs it.
|
|
|
|
|
xmrblocks_rpc_user: "{{ monero_rpc_user | default('') }}"
|
|
|
|
|
xmrblocks_rpc_password: "{{ monero_rpc_password | default('') }}"
|
|
|
|
|
|
|
|
|
|
# Upstream's own defaults (the Dockerfile CMD).
|
|
|
|
|
# NOTE: enable-pusher lets a visitor submit a raw transaction through YOUR node.
|
|
|
|
|
# It is a deliberate choice here, not an oversight.
|
|
|
|
|
xmrblocks_enable_json_api: true
|
|
|
|
|
xmrblocks_enable_autorefresh: true
|
|
|
|
|
xmrblocks_enable_pusher: true
|
|
|
|
|
|
|
|
|
|
# Crow's thread count. Held low on purpose: this box has 2 GB and monerod
|
|
|
|
|
# already uses ~1.3 GB of it.
|
|
|
|
|
xmrblocks_concurrency: 2
|
|
|
|
|
|
|
|
|
|
# The unit file carries the RPC login, so --diff is suppressed by default.
|
|
|
|
|
xmrblocks_reveal_config: false
|
|
|
|
|
|
|
|
|
|
# --- Health check ------------------------------------------------------------
|
|
|
|
|
# WHERE TO REPORT HEALTH — the one place to plug in monitoring. Empty means
|
|
|
|
|
# check, exit honestly, report nowhere.
|
|
|
|
|
healthcheck_push_url: ""
|
|
|
|
|
# Bearer token for the Gatus external endpoint. Required whenever a push URL
|
|
|
|
|
# is set: Gatus rejects an unauthenticated push with 401.
|
|
|
|
|
healthcheck_push_token: ""
|
monero: check the node once a day, not every five minutes
A wedged explorer produced 83 Signal messages in six hours. Nothing about that
was a monitoring success: the checks were right, the cadence was wrong.
monerod-box is a Monero node in the middle of a multi-week initial sync on a
rotating disk. A 5-minute poll tells you nothing a daily one does not, and when
something breaks it turns one fault into a message every five minutes until
somebody silences the phone - which is the failure mode that makes people stop
reading alerts entirely.
So both Monero probes move to OnCalendar:
monerod *-*-* 05:00:00 RandomizedDelaySec=900
xmrblocks *-*-* 05:30:00 RandomizedDelaySec=900
systemd units (infra/401 per-host timer) *-*-* 05:15:00
30 minutes apart on purpose: both read the same LMDB, and the explorer's check
renders a full page out of it - a cold one measured 68 seconds. Running them
together would have each paying the other's cache misses.
OnCalendar rather than OnUnitActiveSec: an interval timer re-bases on every
restart, so "daily" silently becomes "a day after whenever it last came up".
── The window and the cadence are ONE decision ─────────────────────────────
The Gatus heartbeat moves with them, to 26h. Not 24h: the window must EXCEED
the push interval, or a check reporting exactly on schedule races its own
deadline and alerts on jitter alone. With a 900s randomized delay the true
worst case is ~24h15m, so 26h leaves real headroom.
The same logic drops the pulled public-site checks to a 24h interval - and
their failure-threshold from 3 to 1 with it. Three failures at 5m is 15 minutes
of tolerance; three at 24h would be THREE DAYS before anyone heard. A daily
check has no room for a blip, so it alerts on the first.
── Scoping ────────────────────────────────────────────────────────────────
infra/401's timer is ONE PER HOST and iterates every unit in
monitored_services, so slowing it slows all of them. That is safe here only
because monerod-box runs nothing but Monero units; the knobs are therefore in
its host_vars rather than the role, and both 401 and 403 now take a per-host
(resp. per-probe) override with the old 11m as the default. Every other host
is untouched.
Host liveness and disk are deliberately NOT slowed. They answer "is the box
alive", which is still wanted within minutes.
The role-level OnCalendar defaults are prefixed (monero_healthcheck_on_calendar,
xmrblocks_healthcheck_on_calendar) because the generic `healthcheck` role owns
the unprefixed name, and an unprefixed default here would shadow it for any
play that includes both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:08:36 +02:00
|
|
|
|
2026-10-19 22:10:24 +02:00
|
|
|
# WHEN TO CHECK. Every 5 minutes, offset 2 minutes from monerod's own check so
|
|
|
|
|
# the two do not land together. On NVMe a cold page render measured 29s and a
|
|
|
|
|
# warm one 0.2s (2026-10-17); a 5-minute poll is what keeps it warm. See
|
|
|
|
|
# roles/monero/defaults for why the name is prefixed and why this is paired
|
|
|
|
|
# with the 11m heartbeat in infra/403_service_probe_registration.yml.
|
|
|
|
|
xmrblocks_healthcheck_on_calendar: "*:2/5"
|