personal_infra/ansible/roles/monero
counterweight 3c50cab58d
monero: a full node on its own box, reachable from Feather over the WAN
monerod v0.18.5.1 on monerod-box (VMID 2400), Debian 13, systemd, no Docker.
Syncing at 24% as of this commit, 0 restarts, 0 OOM kills.

── The build-from-source requirement could not be met ──────────────────────
The intent was to build it ourselves and check the result against the published
checksums. That is not achievable for this release, and the role's README says
so at length so nobody re-derives it:

  contrib/guix - the reproducible build that yields a byte-identical tarball -
  exists ONLY on monero master. At tag v0.18.5.1 contrib/ contains `gitian`,
  whose README wants an Ubuntu 18.04 host (EOL 2023) and python-cheetah.
  A plain `make release-static` is not reproducible at all, so it produces a
  binary whose hash matches nothing - failing both halves of the ask.

So we install the published binary and prove its provenance instead:

  1. binaryFate's key is VENDORED at files/binaryfate.asc. A trust root fetched
     over the network at the moment you need it is not a trust root. Its
     fingerprint is asserted after import regardless.
  2. hashes.txt is verified, and the assertion is on gpg's `VALIDSIG <fpr>`
     status line - NOT on the string "Good signature", which is what
     bitcoin_knots greps for and which passes for any key in the keyring.
  3. The hash is looked up in the output of `gpg --decrypt`, not in the raw
     clearsigned file, so nothing unsigned is ever searched.
  4. The lookup requires hash AND filename on one line.

Tested with a positive and a negative control before deploying: the real
tarball matches, a corrupted hash correctly fails. monero_sha256 in defaults/
is a second in-git assertion so a version bump cannot happen without the
checksum moving in the same diff.

When v0.19 ships with guix on the release branch this gets easier, and the
README records why: the published monero-linux-x64 tarball unpacks to
monero-x86_64-linux-gnu-v0.18.5.1/, i.e. it IS the guix DISTNAME renamed, so
sha256(guix output) will equal the hashes.txt entry exactly.

── The 500 GB disk is provisioned in code ──────────────────────────────────
knots-box mounts /mnt/knots_data (~875 GB of chain) entirely by hand; that
mount appears nowhere in Ansible and a rebuild of that host would come up with
the blockchain silently missing. tasks/storage.yml does not repeat it.

Whole device, no partition table. Everything after mkfs goes by UUID, because
/dev/sdX is assigned by the kernel at boot and this estate has been bitten
eight times by an identifier written down once whose thing then moved.
monero_data_device is used ONLY to create the filesystem. Guards refuse to
touch the root disk or to overwrite an unrecognised filesystem, and a final
assert confirms the data directory is not still on /.

fstab gets `nofail` so a missing disk cannot drop a headless VM into an
emergency shell - and the unit gets `RequiresMountsFor=`, without which monerod
would then start, find an empty directory, and write a 300 GB chain onto a
10 GB root disk. With it the unit refuses to start and infra/401 says so within
five minutes.

The mount-point task and the ownership task deliberately share owner and mode.
Writing the obvious root:root 0755 on the first made them disagree and every
run flipped the directory back and forth; caught by re-running --check.

── Exposure: one HTTPS vhost, zero new open ports ──────────────────────────
Two RPC servers, not one: unrestricted on 127.0.0.1:18081 for administration
and the health check, restricted on :18089 for everything else. That is
`rpc-restricted-bind-port` and deliberately NOT `restricted-rpc=1`, which would
restrict the loopback server too. Verified live: the restricted port reports
restricted=true and blanks version and start_time.

--rpc-login is HTTP Digest (contrib/epee/src/http_auth.cpp) and
core_rpc_server::init hands the same login to both servers. Caddy on vipy does
TLS and routing only - no caddy_site_basic_auth, because a Basic challenge
would collide with the Digest challenge Feather answers. Verified end to end
from the WAN: 401 unauthenticated, get_block_count over /json_rpc when
authenticated, 401 again with a wrong password.

No UFW rule, and that is correct: tailnet traffic bypasses UFW entirely because
`-A INPUT -j ts-input` sits ahead of every ufw-* chain. Confirmed on mempool-box
before relying on it here.

P2P is outbound only. Nothing forwards 18080, so the node dials out, syncs and
serves us without relaying for strangers; no-igd follows from that.

── Monitoring: infra, systemd, public. No backups ──────────────────────────
Six Gatus endpoints, all green: liveness, disk, services/monerod-box-local/
monerod, probe/monerod, dns/monero.contrapeso.xyz, public/monero.

The probe does NOT assert `synchronized`. A full sync takes days and a check
requiring it would report DOWN for every one of them - an alarm with the
batteries taken out. Height and sync state go in the message instead.

The timer carries no `Requires=monerod.service`. Fulcrum's did, and sat
active+enabled for seven months without firing once. public/monero asserts 401,
not 200, so it goes red if rpc-login ever stops being enforced.

No backups, deliberately, following the phoenixd precedent: the chain is
re-syncable public data and there is no wallet on this node.

── Known constraint ────────────────────────────────────────────────────────
The VM has 2 GB and no swap; monerod's RSS is ~1.3 GB mid-sync. It is holding
(0 OOM kills, ~1 GB available, kernel reclaiming cache) but 8 GB is the
recommendation once the initial sync is done. out-peers/in-peers are held at
16/8 partly for this reason. Bumping it is a Proxmox resize plus memory_mb
here, since the vm resource sets ignore_changes = all.

The 500 GB data disk is likewise invisible to tofu - it was attached in the UI
and the vm resource ignores it - so vms.auto.tfvars now carries a comment
saying where it is and what mounts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 17:50:46 +02:00
..
defaults monero: a full node on its own box, reachable from Feather over the WAN 2026-09-19 17:50:46 +02:00
files monero: a full node on its own box, reachable from Feather over the WAN 2026-09-19 17:50:46 +02:00
handlers monero: a full node on its own box, reachable from Feather over the WAN 2026-09-19 17:50:46 +02:00
tasks monero: a full node on its own box, reachable from Feather over the WAN 2026-09-19 17:50:46 +02:00
templates monero: a full node on its own box, reachable from Feather over the WAN 2026-09-19 17:50:46 +02:00
README.md monero: a full node on its own box, reachable from Feather over the WAN 2026-09-19 17:50:46 +02:00

monero

A Monero full node (monerod) on Debian 13, bare metal, systemd, no Docker. Runs on monerod-box (Proxmox VMID 2400), reachable from Feather over the WAN.

Why the published binary and not a build from source

The intent was to build from source and check the result against the published binary checksums. That is not achievable for the current release, and it is worth writing down why so nobody re-litigates it:

  • contrib/guix, the reproducible-build system that produces a byte-identical tarball, exists only on monero master. At tag v0.18.5.1 contrib/ contains gitian, not guix.
  • Reproducing v0.18.5.1 therefore means Gitian, whose README requires an Ubuntu 18.04 host (EOL 2023), LXC or Docker, and python-cheetah (Python 2).
  • A plain make release-static is not reproducible. It would give us a self-built binary whose hash matches nothing - not upstream's, not anyone else's - so it satisfies neither half of "build it ourselves AND check the checksum".

So the role installs the published binary and proves its provenance instead. That chain is real, and it is the strongest claim available for this release:

  1. binaryFate's public key is vendored at files/binaryfate.asc. A trust root fetched over the network at the moment you need it is not a trust root. Its fingerprint is asserted after import anyway (81AC591FE9C4B65C5806AFC3F0AF4D462A0BDF92), so a swapped file is caught.
  2. hashes.txt is downloaded and its clearsigned signature verified. The assertion is on gpg's VALIDSIG <fingerprint> status line, not on the string "Good signature" - bitcoin_knots greps for the latter, which passes for any key in the keyring.
  3. The verified plaintext is written out by gpg --decrypt, and the tarball's SHA256 is looked up in that, not in the raw clearsigned file.
  4. The lookup requires the hash and the filename on one line, so a hash belonging to a different artefact cannot satisfy it.

monero_sha256 in defaults/ is a second, in-git assertion passed to get_url. It is not the security control - step 4 is - but it means a version bump cannot happen without the checksum moving in the same diff.

Bumping the version: change monero_version and monero_sha256 together.

The forward path

The published monero-linux-x64-v0.18.5.1.tar.bz2 unpacks to monero-x86_64-linux-gnu-v0.18.5.1/ - that is the Guix DISTNAME, renamed at publish time. So once v0.19 ships with Guix on the release branch, sha256(guix output) == hashes.txt entry holds exactly, and a guix install method can be added here without changing anything else.

The data disk

The blockchain is on a dedicated 500 GB disk (scsi1), not on the 10 GB root filesystem. tasks/storage.yml creates the filesystem and the fstab entry.

This is done in code on purpose. knots-box mounts /mnt/knots_data (~875 GB of chain) entirely by hand, and that mount appears nowhere in Ansible - a rebuild of that host would come up with the chain silently missing.

Three things there are load-bearing:

  • The whole device, no partition table. One disk, one job; a partition table is just another layer that can disagree with itself.
  • Everything after mkfs goes by UUID. /dev/sdX ordering is decided by the kernel at boot. This estate has been bitten eight times by an identifier written down once whose thing then moved; monero_data_device is used only to create the filesystem.
  • RequiresMountsFor= in the unit. nofail in fstab means a missing disk does not drop a headless VM into an emergency shell - but without RequiresMountsFor monerod would then start, find an empty directory, and begin writing a ~300 GB chain onto a 10 GB root disk. With it, the unit refuses to start and infra/401 reports it within five minutes.

The mount-point task and the post-mount ownership task deliberately use the same owner and mode. Writing the obvious root:root 0755 on the first made the two disagree, and every run flipped the directory back and forth.

Exposure: one HTTPS vhost, no new open ports

  • P2P is outbound only. Nothing forwards 18080 to this box. The node dials out, syncs fully and serves us; it just does not relay for strangers. no-igd=1 follows - with nothing forwarded there is nothing for UPnP to do except announce us to the router.

  • Two RPC servers, not one.

    • unrestricted on 127.0.0.1:18081 - administration and the health check.
    • restricted on :18089 - the only surface anything off-box touches.

    This is rpc-restricted-bind-port, deliberately not restricted-rpc=1. That flag restricts the loopback server too, and then we could not ask our own node anything useful. Verified live: the restricted port reports restricted=true and blanks version and start_time.

  • rpc-login is HTTP Digest (contrib/epee/src/http_auth.cpp), and core_rpc_server::init hands the same login to both servers. Both ports answer an unauthenticated request with 401.

  • Caddy on the edge host terminates TLS and nothing else. No caddy_site_basic_auth: Caddy's Basic challenge would collide with the Digest challenge Feather expects. monerod's own auth is the authentication.

No UFW rule, and that is correct

Traffic arriving over the tailnet bypasses UFW entirely. Verified on mempool-box: UFW is active with default-deny-incoming and 22/tcp as its only allow rule, yet the edge host reaches its frontend port - because iptables -S INPUT puts -A INPUT -j ts-input ahead of every ufw-* chain. Adding a UFW allow here would be cargo cult.

Monitoring

Four Gatus endpoints, all verified passing:

Endpoint Source Answers
liveness/monerod_box_local infra/400 is the host up
disk/monerod_box_local infra/400 is it running out of space
services/monerod-box-local/monerod infra/401 via monitored_services is the unit active
probe/monerod this role + infra/403 does monerod actually answer RPC

The probe deliberately does not check synchronized. A full initial sync takes days. A check requiring synchronized: true would report DOWN for every one of those days, which is not a monitor - it is an alarm with the batteries taken out. Answering RPC is the health question; height and sync state go in the message so they are visible without being alertable.

The timer carries no Requires=monerod.service. Requires on a timer stops the timer when the watched unit stops - "if the thing I am watching goes down, stop watching it" - and leaves nothing to re-arm it. Fulcrum's timer carried it and sat active/enabled for seven months without firing once. bitcoin_knots still carries it; do not copy that.

Enable and restart the monerod health check timer reports changed on every run. That is intentional and matches datum_gateway: state: restarted is how a timer gets re-armed, and started is a no-op on an already-active timer.

Backups: none, deliberately

The blockchain is re-syncable public data and there is no wallet on this node, so a backup job would move hundreds of GB around to protect nothing. This follows the phoenixd precedent (Plan 5, Model C) of recording an exclusion as a decision rather than leaving it to look like an oversight.

Known constraint: RAM

The VM has 2 GB and no swap. Mid-sync, monerod's RSS was already 1,333 MiB of 1,973 MiB with ~100 MiB free. No OOM kill has happened yet, but the working set grows with the database and there is no swap to absorb a spike. out-peers and in-peers are held down (16/8) partly for this reason.

8 GB is the recommendation. Bumping it means editing memory_mb in tofu/nodito/vms.auto.tfvars and resizing the VM in the Proxmox UI - the vm resource sets ignore_changes = all, so tofu will not do it for you.