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>
This commit is contained in:
counterweight 2026-09-19 17:50:46 +02:00
parent df4f712a93
commit 3c50cab58d
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
26 changed files with 1381 additions and 163 deletions

View file

@ -0,0 +1,151 @@
# 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.

View file

@ -0,0 +1,96 @@
# Monero node (monerod) configuration variables.
# https://docs.getmonero.org/interacting/monerod-reference/
# --- Version and artefact ---------------------------------------------------
# Bump monero_version and monero_sha256 TOGETHER. The sha256 here is a second,
# in-git assertion; the one that actually matters is the grep against the
# GPG-signed hashes.txt in install.yml. Having both means a version bump cannot
# be done silently - the checksum has to move in the same diff.
monero_version: "0.18.5.1"
monero_platform: "linux-x64"
monero_tarball: "monero-{{ monero_platform }}-v{{ monero_version }}.tar.bz2"
monero_url: "https://downloads.getmonero.org/cli/{{ monero_tarball }}"
monero_sha256: "22a7dda7b0cb699fdd6b7674c3b4a4465b337cc98a54983523b759e1e7cc9958"
# The signed list of checksums for every published Monero artefact.
monero_hashes_url: "https://www.getmonero.org/downloads/hashes.txt"
# binaryFate, the release signing key. The .asc is vendored in files/ rather
# than fetched at deploy time: a trust root downloaded over the network at the
# moment you need it is not a trust root. The fingerprint is asserted after
# import anyway, so a swapped file cannot go unnoticed.
monero_signing_fingerprint: "81AC591FE9C4B65C5806AFC3F0AF4D462A0BDF92"
# --- Service user -----------------------------------------------------------
monero_user: monero
monero_group: monero
# --- Directories ------------------------------------------------------------
monero_conf_dir: /etc/monero
monero_log_dir: /var/log/monero
monero_bin_dir: /usr/local/bin
# The blockchain. This is the mount point of the dedicated 500 GB disk, NOT a
# directory on the 10 GB root filesystem - see storage.yml.
monero_data_dir: /var/lib/monero
# --- The data disk ----------------------------------------------------------
# The whole device, no partition table: this disk has one job and a partition
# table would only add a layer that can disagree with itself.
#
# monero_data_device is used ONLY to create the filesystem, once. Everything
# afterwards - fstab, the mount, the systemd unit - goes by UUID, because
# /dev/sdX ordering is decided by the kernel at boot and is not a stable name
# for anything. This estate has been bitten eight times by an identifier that
# was written down once and then moved.
monero_data_device: /dev/sdb
monero_data_fstype: ext4
# -m 0: the 5% ext4 reserve exists to stop root-owned daemons deadlocking a full
# root filesystem. On a dedicated data disk it is 25 GB thrown away.
monero_data_mkfs_opts: "-m 0 -L monero-data"
# --- Network ----------------------------------------------------------------
# P2P is OUTBOUND ONLY. No inbound port is published from the edge host, so the
# node dials out to peers and never receives. It syncs and serves us completely;
# it just does not relay for strangers. no-igd follows from that: with nothing
# forwarded there is nothing for UPnP to do but tell the router about us.
monero_p2p_port: 18080
# Unrestricted RPC, loopback only: administration and the health check.
monero_rpc_bind_ip: "127.0.0.1"
monero_rpc_port: 18081
# The RESTRICTED RPC is a second server on its own port, reachable over the
# tailnet and published by Caddy on the edge host. This is deliberately
# rpc-restricted-bind-port and NOT `restricted-rpc=1` - the latter would
# restrict the loopback server too, and then we could not ask our own node
# anything useful.
#
# The authoritative value for the live deployment is in
# host_vars/monerod_box_local/main.yml, which the edge play also reads; the
# value here is the upstream default so the role still works standalone.
monero_restricted_rpc_port: 18089
# Peer counts kept modest on purpose: this VM has 2 GB of RAM. See README.
monero_out_peers: 16
monero_in_peers: 8
# --- RPC credentials --------------------------------------------------------
# monerod's --rpc-login is HTTP Digest (contrib/epee/src/http_auth.cpp) and the
# same login guards BOTH the loopback and the restricted server. Feather sends
# these as the node username/password.
monero_rpc_user: ""
monero_rpc_password: ""
# monerod.conf holds the RPC login, so --diff is suppressed by default.
# Pass -e monero_reveal_config=true to see what moved.
monero_reveal_config: false
# --- Health check -----------------------------------------------------------
# WHERE TO REPORT HEALTH — the one place to plug in monitoring. Empty means
# check, exit honestly, report nowhere. Any endpoint accepting an HTTP ping
# works; nothing here is specific to a monitoring product.
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: ""

View file

@ -0,0 +1,87 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
lmE0kDaYQqoBKXNXcybElBID8Q==
=tkJZ
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -0,0 +1,17 @@
---
# Ungated, and that is the whole point. Across this estate the restart handlers
# were written with `when: uptime_kuma_enabled | default(false)`, which made
# them inert: Fulcrum's config changes landed on disk and silently never took
# effect for months. Three tasks notify this handler - the config, the systemd
# unit and the binary itself - and all three need it to actually fire.
- name: Restart monerod
systemd:
name: monerod
state: restarted
daemon_reload: yes
- name: Restart monerod health check timer
systemd:
name: monerod-healthcheck.timer
state: restarted
daemon_reload: yes

View file

@ -0,0 +1,55 @@
---
# Directory layout follows upstream's systemd guide:
# https://docs.getmonero.org/running-node/monerod-systemd/
# The data directory is not here - it is the mount point, handled in storage.yml.
- name: Create the Monero config directory
ansible.builtin.file:
path: "{{ monero_conf_dir }}"
state: directory
owner: root
group: "{{ monero_group }}"
mode: '0750'
- name: Create the Monero log directory
ansible.builtin.file:
path: "{{ monero_log_dir }}"
state: directory
owner: "{{ monero_user }}"
group: "{{ monero_group }}"
mode: '0750'
- name: Assert the RPC credentials are set
ansible.builtin.assert:
that:
- monero_rpc_user | length > 0
- monero_rpc_password | length > 0
quiet: true
fail_msg: >-
monero_rpc_user and monero_rpc_password must be set. They are the only
authentication in front of the restricted RPC that Caddy publishes to the
internet, and monerod started without them would serve it to anyone.
- name: Create monerod.conf
ansible.builtin.template:
src: monerod.conf.j2
dest: "{{ monero_conf_dir }}/monerod.conf"
owner: root
group: "{{ monero_group }}"
mode: '0640'
# monerod.conf carries the RPC login, which is the only authentication in
# front of the endpoint Caddy publishes. `--diff` prints rendered content, so
# running with --diff would put it on the terminal and into any log capturing
# it. Suppressed by default; pass -e monero_reveal_config=true when you
# genuinely need the diff. This is a task keyword, not a module parameter.
diff: "{{ monero_reveal_config | default(false) | bool }}"
notify: Restart monerod
- name: Create the monerod systemd unit
ansible.builtin.template:
src: monerod.service.j2
dest: /etc/systemd/system/monerod.service
owner: root
group: root
mode: '0644'
notify: Restart monerod

View file

@ -0,0 +1,58 @@
---
# "Is monerod actually answering", as opposed to infra/401's "is the unit
# active". A daemon can be perfectly `active` and wedged.
- name: Install the health check's dependencies
apt:
name:
- curl
- jq
state: present
- name: Create the monerod health check script
ansible.builtin.template:
src: healthcheck.sh.j2
dest: /usr/local/bin/monerod-healthcheck-push.sh
owner: root
group: root
mode: '0755'
validate: "bash -n %s"
# 0600: this unit carries both the Gatus bearer token and the RPC password.
# They live here rather than in the script so the script itself stays readable
# and credential-free.
- name: Create the monerod health check systemd service
ansible.builtin.template:
src: healthcheck.service.j2
dest: /etc/systemd/system/monerod-healthcheck.service
owner: root
group: root
mode: '0600'
notify: Restart monerod health check timer
- name: Create the monerod health check systemd timer
ansible.builtin.template:
src: healthcheck.timer.j2
dest: /etc/systemd/system/monerod-healthcheck.timer
owner: root
group: root
mode: '0644'
notify: Restart monerod health check timer
- name: Reload systemd daemon after health check units
systemd:
daemon_reload: yes
# Ungated: enabling a timer is deployment, not monitoring.
- name: Enable and restart the monerod health check timer
systemd:
name: monerod-healthcheck.timer
enabled: yes
state: restarted
daemon_reload: yes
# Arms the timer and smoke-tests the check. Restarting the timer alone is not
# enough with OnBootSec + OnUnitActiveSec - see roles/bitcoin_knots/README.md.
- name: Run the monerod health check once to arm the timer
ansible.builtin.command: systemctl start monerod-healthcheck.service
changed_when: false

View file

@ -0,0 +1,232 @@
---
# Install monerod from the published release, and prove the bytes are the ones
# binaryFate signed before they are allowed anywhere near /usr/local/bin.
#
# These tasks are the security control of this role. Do not "simplify" them.
#
# Why the published binary and not a build from source: the reproducible-build
# system that yields a byte-identical tarball (contrib/guix) exists only on
# monero master. The v0.18.x release branch still uses Gitian, which wants an
# Ubuntu 18.04 host and Python 2. A plain `make release-static` is not
# reproducible, so it would give us a self-built binary whose hash matches
# nothing - neither upstream's nor anyone else's. The signature chain below is
# the strongest claim actually available for this release. See README.md.
- name: Install the tools the verification chain needs
apt:
name:
- gnupg
- curl
- bzip2
state: present
update_cache: yes
- name: Check whether monerod is already installed
ansible.builtin.stat:
path: "{{ monero_bin_dir }}/monerod"
register: monero_binary
- name: Read the installed monerod version
ansible.builtin.command: "{{ monero_bin_dir }}/monerod --version"
register: monero_installed_version
changed_when: false
failed_when: false
check_mode: false
when: monero_binary.stat.exists
- name: Decide whether monerod needs installing
ansible.builtin.set_fact:
monero_needs_install: >-
{{ not monero_binary.stat.exists
or monero_version not in (monero_installed_version.stdout | default('')) }}
# Everything below runs only for a fresh install or a version bump.
# A throwaway keyring. The verification must not depend on, or add to, whatever
# happens to be in root's keyring already.
- name: Create a temporary GnuPG home for the verification
ansible.builtin.tempfile:
state: directory
suffix: monero-gpg
register: monero_gnupghome
when: monero_needs_install | bool
- name: Copy binaryFate's signing key to the host
ansible.builtin.copy:
src: binaryfate.asc
dest: "{{ monero_gnupghome.path }}/binaryfate.asc"
mode: '0644'
when: monero_needs_install | bool
- name: Import binaryFate's signing key
ansible.builtin.command: "gpg --batch --import {{ monero_gnupghome.path }}/binaryfate.asc"
environment:
GNUPGHOME: "{{ monero_gnupghome.path }}"
register: monero_key_import
changed_when: "'imported' in monero_key_import.stderr"
when: monero_needs_install | bool
- name: Read the fingerprints of the imported key
ansible.builtin.shell: >-
set -o pipefail &&
gpg --batch --with-colons --fingerprint | awk -F: '/^fpr:/{print $10}'
args:
executable: /bin/bash
environment:
GNUPGHOME: "{{ monero_gnupghome.path }}"
register: monero_key_fingerprints
changed_when: false
when: monero_needs_install | bool
# The vendored file could be swapped in a bad pull request. The fingerprint is
# the thing being trusted, so it is checked, not assumed.
- name: Assert the imported key is binaryFate's
ansible.builtin.assert:
that:
- monero_signing_fingerprint in monero_key_fingerprints.stdout_lines
quiet: true
fail_msg: >-
files/binaryfate.asc does not contain the expected key
{{ monero_signing_fingerprint }}. Refusing to go on.
when: monero_needs_install | bool
- name: Download the signed hashes.txt
ansible.builtin.get_url:
url: "{{ monero_hashes_url }}"
dest: "{{ monero_gnupghome.path }}/hashes.txt"
mode: '0644'
force: yes
when: monero_needs_install | bool
# One command that both verifies the signature and writes out ONLY the verified
# plaintext. Grepping the clearsigned file directly would search text that has
# not been through the signature check.
- name: Verify hashes.txt and extract the verified plaintext
ansible.builtin.command: >-
gpg --batch --status-fd 2
--output {{ monero_gnupghome.path }}/hashes-verified.txt
--decrypt {{ monero_gnupghome.path }}/hashes.txt
environment:
GNUPGHOME: "{{ monero_gnupghome.path }}"
register: monero_hashes_verify
changed_when: false
failed_when: false
when: monero_needs_install | bool
# Greping for "Good signature" - which is what the bitcoin_knots role does -
# passes for ANY key in the keyring. VALIDSIG names the key, and the line
# carries both the signing subkey and the primary key fingerprint, so checking
# that our fingerprint appears on it survives a subkey rotation.
- name: Assert hashes.txt carries a valid signature from binaryFate
ansible.builtin.assert:
that:
- monero_hashes_verify.rc == 0
- monero_hashes_verify.stderr is search('VALIDSIG[^\n]*' ~ monero_signing_fingerprint)
quiet: true
fail_msg: >-
hashes.txt did not verify against {{ monero_signing_fingerprint }}.
This is the whole security control of this role - aborting.
gpg said: {{ monero_hashes_verify.stderr | default('') }}
when: monero_needs_install | bool
- name: Download the monerod release tarball
ansible.builtin.get_url:
url: "{{ monero_url }}"
dest: "/tmp/{{ monero_tarball }}"
mode: '0644'
checksum: "sha256:{{ monero_sha256 }}"
force: yes
when: monero_needs_install | bool
- name: Compute the SHA256 of the downloaded tarball
ansible.builtin.stat:
path: "/tmp/{{ monero_tarball }}"
checksum_algorithm: sha256
get_checksum: yes
register: monero_tarball_stat
when: monero_needs_install | bool
# The check that actually ties these bytes to binaryFate's signature. The
# get_url checksum above only ties them to a string in our own repo; this ties
# them to the signed list. Both the hash AND the filename must be on one line,
# so a hash belonging to a different artefact cannot satisfy it.
- name: Look up the downloaded tarball's checksum in the signed hashes
ansible.builtin.command:
argv:
- grep
- -F
- -x
- "{{ monero_tarball_stat.stat.checksum }} {{ monero_tarball }}"
- "{{ monero_gnupghome.path }}/hashes-verified.txt"
register: monero_hash_match
changed_when: false
failed_when: false
when: monero_needs_install | bool
- name: Assert the tarball is the one binaryFate signed
ansible.builtin.assert:
that:
- monero_hash_match.rc == 0
quiet: true
fail_msg: >-
{{ monero_tarball }} hashes to {{ monero_tarball_stat.stat.checksum }},
which is not the value signed for that filename in hashes.txt.
Do not install this file.
when: monero_needs_install | bool
- name: Create a temporary extraction directory
ansible.builtin.tempfile:
state: directory
suffix: monero-extract
register: monero_extract_dir
when: monero_needs_install | bool
- name: Extract the monerod tarball
ansible.builtin.unarchive:
src: "/tmp/{{ monero_tarball }}"
dest: "{{ monero_extract_dir.path }}"
remote_src: yes
when: monero_needs_install | bool
# By find rather than by a hardcoded path: the tarball unpacks to
# monero-x86_64-linux-gnu-v<version>/, which is not the name of the tarball.
- name: Locate the extracted monerod binary
ansible.builtin.find:
paths: "{{ monero_extract_dir.path }}"
patterns: monerod
recurse: yes
file_type: file
register: monero_extracted
when: monero_needs_install | bool
- name: Assert the archive contained monerod
ansible.builtin.assert:
that:
- monero_extracted.files | length > 0
quiet: true
fail_msg: "No monerod binary in {{ monero_tarball }}"
when: monero_needs_install | bool
# Only monerod. The wallet and blockchain utilities are in the same tarball if
# they are ever wanted; a node does not need them and every binary installed is
# surface that has to be kept current.
- name: Install monerod
ansible.builtin.copy:
src: "{{ monero_extracted.files[0].path }}"
dest: "{{ monero_bin_dir }}/monerod"
remote_src: yes
owner: root
group: root
mode: '0755'
when: monero_needs_install | bool
notify: Restart monerod
- name: Clean up the download and verification artefacts
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/{{ monero_tarball }}"
- "{{ monero_extract_dir.path | default('/nonexistent') }}"
- "{{ monero_gnupghome.path | default('/nonexistent') }}"
when: monero_needs_install | bool

View file

@ -0,0 +1,15 @@
---
# import_tasks, not include_tasks: static imports stay visible to --list-tasks,
# which is the main verification tool in this repo.
#
# The order is load-bearing:
# user must exist before anything chowns to it
# storage the data directory IS the mount point of the 500 GB disk, so if the
# disk is not there the daemon must not start and quietly begin
# filling a 10 GB root filesystem with a 300 GB blockchain
- ansible.builtin.import_tasks: user.yml
- ansible.builtin.import_tasks: storage.yml
- ansible.builtin.import_tasks: install.yml
- ansible.builtin.import_tasks: configure.yml
- ansible.builtin.import_tasks: service.yml
- ansible.builtin.import_tasks: healthcheck.yml

View file

@ -0,0 +1,35 @@
---
- name: Reload systemd daemon
systemd:
daemon_reload: yes
- name: Enable and start monerod
systemd:
name: monerod
enabled: yes
state: started
- name: Flush handlers so monerod is running before we ask it anything
meta: flush_handlers
# Generous, and non-fatal. On a first start monerod initialises LMDB on a fresh
# 500 GB disk before it opens the RPC port; on a later start it is quick. A
# timeout here is not a deployment failure, and the health check is the thing
# that decides whether the node is actually well.
- name: Wait for the monerod RPC port
ansible.builtin.wait_for:
port: "{{ monero_rpc_port }}"
host: "{{ monero_rpc_bind_ip }}"
delay: 5
timeout: 180
ignore_errors: yes
- name: Check the monerod service status
systemd:
name: monerod
register: monero_service_status
changed_when: false
- name: Display the monerod service status
ansible.builtin.debug:
msg: "monerod is {{ 'running' if monero_service_status.status.ActiveState == 'active' else 'NOT running' }}"

View file

@ -0,0 +1,133 @@
---
# The 500 GB data disk.
#
# knots-box does this by hand: /mnt/knots_data holds ~875 GB of blockchain and
# appears nowhere in Ansible, so a rebuild of that host would come up with the
# chain silently missing. This role does it in code instead.
- name: Check the Monero data device
ansible.builtin.stat:
path: "{{ monero_data_device }}"
register: monero_device
- name: Find the device backing the root filesystem
ansible.builtin.command: findmnt -no SOURCE /
register: monero_root_source
changed_when: false
check_mode: false
# A guard, not ceremony. mkfs on the wrong device is unrecoverable, and
# /dev/sdX names are assigned by the kernel at boot - the whole reason
# everything below this point works by UUID.
- name: Assert the data device is a real block device and is not the root disk
ansible.builtin.assert:
that:
- monero_device.stat.exists
- monero_device.stat.isblk | default(false)
- not monero_root_source.stdout.startswith(monero_data_device)
quiet: true
fail_msg: >-
{{ monero_data_device }} is not a usable data device, or it is the disk
the root filesystem lives on ({{ monero_root_source.stdout }}).
Refusing to touch it. Check `lsblk` on the host.
- name: Read any existing filesystem on the data device
ansible.builtin.command: "blkid -o value -s TYPE {{ monero_data_device }}"
register: monero_existing_fs
changed_when: false
failed_when: false
check_mode: false
# community.general.filesystem is idempotent and will not rebuild a filesystem
# that is already the requested type, but it would happily create one on a disk
# holding something it does not recognise. Say so out loud instead.
- name: Refuse to reformat a disk that already carries a different filesystem
ansible.builtin.assert:
that:
- monero_existing_fs.stdout | trim in ['', monero_data_fstype]
quiet: true
fail_msg: >-
{{ monero_data_device }} already holds a '{{ monero_existing_fs.stdout | trim }}'
filesystem, not '{{ monero_data_fstype }}'. Refusing to overwrite it.
- name: Create the filesystem on the data device
community.general.filesystem:
fstype: "{{ monero_data_fstype }}"
dev: "{{ monero_data_device }}"
opts: "{{ monero_data_mkfs_opts }}"
- name: Read the filesystem UUID
ansible.builtin.command: "blkid -o value -s UUID {{ monero_data_device }}"
register: monero_fs_uuid
changed_when: false
failed_when: false
check_mode: false
# Everything past this point mounts by UUID, so there is nothing sensible to do
# without one. On a --check run against a disk that has no filesystem yet this
# is expected and unavoidable: check mode did not create the filesystem, so
# there is no UUID to read. Say that, rather than leaving a bare blkid rc=2.
- name: Assert the UUID looks like a UUID
ansible.builtin.assert:
that:
- monero_fs_uuid.stdout | trim | length == 36
quiet: true
fail_msg: >-
No filesystem UUID on {{ monero_data_device }}.
{% if ansible_check_mode %}This is expected under --check when the disk
has not been formatted yet - check mode cannot create the filesystem, so
it cannot dry-run the mount either. Run without --check once, then
--check is meaningful from then on.{% else %}mkfs appears to have failed;
check `lsblk` and `blkid` on the host.{% endif %}
# Same owner and mode as the "Own the mounted data directory" task below, and
# that is deliberate rather than duplication. Before the disk is mounted this
# creates the mount point; afterwards it targets the mounted filesystem and is a
# no-op. Giving it root:root 0755 here - the obvious thing to write - made the
# two tasks disagree, and each run flipped the directory back and forth.
- name: Create the Monero data directory as a mount point
ansible.builtin.file:
path: "{{ monero_data_dir }}"
state: directory
owner: "{{ monero_user }}"
group: "{{ monero_group }}"
mode: '0750'
# By UUID, never by /dev/sdb. nofail so a missing disk does not drop a headless
# VM into an emergency shell - monerod is stopped from starting by
# RequiresMountsFor= in its unit instead, which the systemd monitor then reports.
# noatime because LMDB touches a lot of pages and none of it wants an atime write.
- name: Mount the data disk by UUID
ansible.posix.mount:
path: "{{ monero_data_dir }}"
src: "UUID={{ monero_fs_uuid.stdout | trim }}"
fstype: "{{ monero_data_fstype }}"
opts: defaults,noatime,nofail
state: mounted
# AFTER the mount, never before: chowning the mount point first sets permissions
# on the directory the mount then hides, and the blockchain lands on a root-owned
# filesystem that monerod cannot write to.
- name: Own the mounted data directory
ansible.builtin.file:
path: "{{ monero_data_dir }}"
state: directory
owner: "{{ monero_user }}"
group: "{{ monero_group }}"
mode: '0750'
- name: Confirm the data directory is a mount point and not the root filesystem
ansible.builtin.command: "findmnt -no SOURCE --target {{ monero_data_dir }}"
register: monero_mounted_source
changed_when: false
check_mode: false
- name: Assert the blockchain will land on the data disk
ansible.builtin.assert:
that:
- monero_mounted_source.stdout | trim != monero_root_source.stdout | trim
quiet: true
fail_msg: >-
{{ monero_data_dir }} resolves to {{ monero_mounted_source.stdout | trim }},
which is the root filesystem. The 500 GB disk is not mounted and a full
sync would fill the 10 GB root disk.

View file

@ -0,0 +1,24 @@
---
# The service account, created before anything that chowns to it.
#
# This is its own file because two later steps both depend on it and neither
# owns it: storage.yml chowns the mount point to this user, and install.yml
# installs a binary that runs as it. Creating it inside either one makes the
# other's ordering an accident.
- name: Create the monero system group
group:
name: "{{ monero_group }}"
system: yes
state: present
- name: Create the monero system user
user:
name: "{{ monero_user }}"
group: "{{ monero_group }}"
system: yes
shell: /usr/sbin/nologin
home: "{{ monero_data_dir }}"
create_home: no
comment: "Monero node daemon"
state: present

View file

@ -0,0 +1,19 @@
[Unit]
Description=Monero Node Health Check
After=network.target monerod.service
[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/monerod-healthcheck-push.sh
Environment=HEALTHCHECK_PUSH_URL={{ healthcheck_push_url }}
Environment=HEALTHCHECK_PUSH_TOKEN={{ healthcheck_push_token }}
Environment=MONERO_RPC_HOST={{ monero_rpc_bind_ip }}
Environment=MONERO_RPC_PORT={{ monero_rpc_port }}
Environment=MONERO_RPC_USER={{ monero_rpc_user }}
Environment=MONERO_RPC_PASSWORD={{ monero_rpc_password }}
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,70 @@
#!/bin/bash
# Monero node health check — managed by Ansible (roles/monero)
#
# The exit code is the answer and systemd keeps it:
# systemctl is-failed monerod-healthcheck.service
# Reporting anywhere else is optional and generic.
#
# WHAT THIS DELIBERATELY DOES NOT CHECK: whether the node is synchronised.
# A full initial sync takes days. A check that required `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;
# sync progress is reported in the message so it is visible without being
# alertable.
RPC_HOST="${MONERO_RPC_HOST:-127.0.0.1}"
RPC_PORT="${MONERO_RPC_PORT:-18081}"
RPC_USER="${MONERO_RPC_USER:-}"
RPC_PASSWORD="${MONERO_RPC_PASSWORD:-}"
PUSH_URL="${HEALTHCHECK_PUSH_URL:-}"
PUSH_TOKEN="${HEALTHCHECK_PUSH_TOKEN:-}"
report() {
local status=$1
local msg=$2
# No push URL is normal, not an error: the exit code below is still a
# complete answer for anything reading unit state.
[ -n "$PUSH_URL" ] || return 0
local encoded_msg="${msg// /%20}"
# Gatus external endpoint: a POST with a bearer token.
local _ok=false
[ "${status}" = "up" ] && _ok=true
curl -s --max-time 15 --retry 2 -o /dev/null -X POST \
-H "Authorization: Bearer ${PUSH_TOKEN}" \
"${PUSH_URL}?success=${_ok}&error=${encoded_msg}" || true
}
# --digest, not --user: monerod's --rpc-login is HTTP Digest auth, so curl has
# to answer the 401 challenge rather than send credentials up front.
response=$(curl -s --max-time 30 --digest -u "${RPC_USER}:${RPC_PASSWORD}" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"healthcheck","method":"get_info"}' \
"http://${RPC_HOST}:${RPC_PORT}/json_rpc" 2>/dev/null)
if [ -z "$response" ]; then
report "down" "monerod RPC not responding"
exit 1
fi
status=$(echo "$response" | jq -r '.result.status // empty' 2>/dev/null)
if [ "$status" != "OK" ]; then
report "down" "monerod RPC status: ${status:-unparseable}"
exit 1
fi
height=$(echo "$response" | jq -r '.result.height // 0' 2>/dev/null)
target=$(echo "$response" | jq -r '.result.target_height // 0' 2>/dev/null)
synced=$(echo "$response" | jq -r '.result.synchronized // false' 2>/dev/null)
# monerod reports target_height=0 once it has caught up, so show the height we
# actually reached rather than a confusing "of 0".
if [ "$target" = "0" ]; then
report "up" "OK height=${height} synced=${synced}"
else
report "up" "OK height=${height}/${target} synced=${synced}"
fi
exit 0

View file

@ -0,0 +1,15 @@
[Unit]
Description=Monero Node Health Check Timer
# Deliberately NO `Requires=monerod.service`. Requires on a timer stops the
# timer when the required unit stops - "if the thing I am watching goes down,
# stop watching it" - and leaves nothing to re-arm when the service returns.
# Fulcrum's timer carried it and sat active+enabled for seven months without
# firing once. bitcoin_knots' timer still carries it; do not copy that.
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,46 @@
# monerod configuration - managed by Ansible (roles/monero)
#
# Config-file syntax is the command-line options with the leading -- removed;
# valueless flags take =1. https://docs.getmonero.org/interacting/monero-config-file/
data-dir={{ monero_data_dir }}
log-file={{ monero_log_dir }}/monerod.log
log-level=0
max-log-file-size=104850000
# --- P2P: outbound only -----------------------------------------------------
# Nothing forwards {{ monero_p2p_port }} to this box, so the node dials out to
# peers and never receives. It syncs and serves us completely; it simply does
# not relay for strangers. no-igd follows: with nothing forwarded there is
# nothing for UPnP to do except announce us to the router.
p2p-bind-ip=0.0.0.0
p2p-bind-port={{ monero_p2p_port }}
no-igd=1
out-peers={{ monero_out_peers }}
in-peers={{ monero_in_peers }}
# --- Unrestricted RPC: loopback only ----------------------------------------
# Administration and the health check. Never leaves the box.
rpc-bind-ip={{ monero_rpc_bind_ip }}
rpc-bind-port={{ monero_rpc_port }}
# --- Restricted RPC: the only surface anything off-box touches --------------
# A SECOND server, view-only, reached over the tailnet by Caddy on the edge
# host. This is rpc-restricted-bind-port and deliberately NOT `restricted-rpc=1`:
# that flag restricts the loopback server too, and then we could not ask our own
# node anything useful.
#
# Bound to 0.0.0.0 and reachable only over the tailnet - Tailscale's ts-input
# chain sits ahead of UFW, so nothing on the LAN or the internet reaches this
# port without going through Caddy. confirm-external-bind is monerod's
# "yes, I meant to do that" for a non-loopback bind.
rpc-restricted-bind-ip=0.0.0.0
rpc-restricted-bind-port={{ monero_restricted_rpc_port }}
confirm-external-bind=1
# HTTP Digest, and it guards BOTH servers above - core_rpc_server::init passes
# the same login to each. This is what Feather sends as the node credentials.
rpc-login={{ monero_rpc_user }}:{{ monero_rpc_password }}
# Nothing subscribes to ZMQ here, so it is one less listener.
no-zmq=1

View file

@ -0,0 +1,38 @@
[Unit]
Description=Monero Daemon
Documentation=https://docs.getmonero.org/running-node/monerod-systemd/
After=network-online.target
Wants=network-online.target
# The blockchain lives on a separate 500 GB disk mounted at {{ monero_data_dir }}.
# Without this line a failed mount would not stop monerod - it would start, find
# an empty directory, and begin writing a ~300 GB chain onto the 10 GB root
# filesystem. This turns that silent disaster into a unit that refuses to start,
# which the systemd monitor reports within five minutes.
RequiresMountsFor={{ monero_data_dir }}
[Service]
Type=simple
User={{ monero_user }}
Group={{ monero_group }}
ExecStart={{ monero_bin_dir }}/monerod --config-file {{ monero_conf_dir }}/monerod.conf --non-interactive
Restart=always
RestartSec=30
# monerod flushes LMDB on shutdown and that is not instant on a full chain.
# Killing it mid-flush is how the database gets corrupted and resynced from zero.
TimeoutStopSec=300
StandardOutput=journal
StandardError=journal
# Hardening: the daemon only ever writes to its own data and log directories.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths={{ monero_data_dir }} {{ monero_log_dir }}
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target