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

@ -48,6 +48,11 @@ subdomains:
# DATUM Gateway dashboard (knots-box, proxied via vipy)
datum_gateway: datum
# Monero restricted RPC (monerod-box, proxied via vipy). Not a dashboard:
# this is the JSON-RPC endpoint Feather points at. Caddy terminates TLS and
# monerod's own Digest auth does the authentication.
monero: monero
# Read by plays across several groups, so global rather than group_vars/<group>.
headscale_namespace: counter-net

View file

@ -1,164 +1,191 @@
$ANSIBLE_VAULT;1.1;AES256
34383033613438623432656438313763613639313139626432303637383363643339613166626665
6537663336353661373437303636613364643736663031300a623461353135623537343763383333
37323731646262613436646231656263396532616132316237353762376363383062646132303435
3435316666316235660a323163613932616661303935336633656332353766393536393736356539
61396164386266303464373262373862363361363365363235383363613335663933313062663565
39613466353161356462656539613335613536363532333431393935303430373435373737363537
62383266323339373062313134316464303263313830646161333530383736336637333865373764
31313165396366653763343466643561383236643836616662383439666165343139326232326364
32633133373863616163343365656231353939366435346534383462396134653064663566343566
66343464373865383864313264646635316233336133346365396634626261613561373965643339
30353831393037316334313632376537393361336366326561343832373531303139323937613531
34386164303430316162363536386137316330336638303365393462386465633862663932376563
34323862636435306563333065383134343362333733623066656439613339376666353331373665
32353938663364313036373237323062393430306661633732356434333232376338313835663330
32306536393666323464663238306562613936376236613935616664653865373330626365626330
64613066653632646638306338393331376430383165316363323437373362613866313962663432
62356663636630356239613831353066303530363966396135376134633936313530643262353965
38383330613962313935366633353635363638303937316362303866343433353437363334396431
32393134626133353564643233616632376633303934653065613262353436333533376137303862
35303861383833326537376433336634633730373833313036643365373431666438653033653930
33346230396661636333613031353133353631306331666362386366633062666534303539316238
39366634656333646634643032393738333330636230663366623265666533356534623465316665
35336535326237343231653661613736626239316262386363666264613636326332366232333964
38353635616662663133376534306366643037613732343233373336653166316330643638353539
35353532643534613864333839393365313439343663333337373639633832383630316164306132
63636162373034363839656638353534613733663737626432636436373164303239316436313566
66623466396663353136323565643262383865323830616232373466373431373261306537646463
61353966623062356438626635663461396165343366393132393965383734633632666335313030
37333631373239303231396264393031373162373936303462363736313538343562343737356332
63663762633032356130303964386664366137373432353533386566613037613163383963623732
32343765376535343630656633333362393765666131653632613361363163383038323138333537
37373333316466316562613439356365316163336366623435666166653835616531653563343664
30303464663838646333373837363961356465613234636138633162366636633030383337643131
62636236663066316630396132333934353139383465363034363033656161383663656462313939
62353738396132343734343363636164323163616237613861643862303433633934656165343263
35313737373333386335663731643664393630653839396464376639306231653934386336353630
61393735336662663435613431386361373561363531643232303831363163636139653538393931
66313965646562616237383439343637623835303065333730613865666638383131616261346463
31396464306537613135653830653138323539393731626264326335336432666333333735646534
30616439393464326631633635363466303336613135346231626232313361303666323661616638
64363937343231363536353034363966326132333734386638613737636130646232363666643633
61353563636466356630346232613761636432303430336461663434333636623962643764336639
35303565613861323431393036303534303061666437366538306236373930313439353330313632
61363363303562316664303065613863613339626632643931386438326330373938613762303334
33316430363931393262373661623137633835656136313235613666613932313236343966306331
36343031303632323432366337633637336335343638393564313738386162386164613339343662
62383939663265373265613932633265626263623939303638383838646531343433393864363235
37313761396436623833376435393537376136373162393465343764326533666533393061643965
61306365346562333737313962633764313232323161623861336566343735353737633539656162
34323939306661373662663331653333346264323930663636633134666532623438323537383165
66343033363962633766303331623866306462623235373838616565653066646132633034363535
31613462356361613535383963643662373162363363653334393937313039666266366363653537
64356166396139393262313565303731346534646462333638316661383139346362333364613466
38303561316263636136303431393366343936653161336238613439366266346136636465303337
61373634376464333037303862616335623031656133636165636264386265643261373735663266
39396265366138363430343035356337303438346165366361316230656239326633653537626339
39613334316563376133343336343563363564653237633764393532336437303334363830396635
31333963666462313830333163646465356337643263636462356363613266323630393866626535
35336436663063333364353663616564626535396133386461326433323936323161613633353539
66323935626433353162666335613839346561343264323763303034613366663233613037343538
66376236373462383430313030373235373337653338333033306431616562353435656433356365
37653063363435366532306334373438306632306435653334396630343863623938666335656164
37386634366430383762316435396237313362313965353634613266616532633465646464313662
35303433363236393465396161346431306231666432666531626136623966663864376663373562
37653239633732346536323265643434616136326561666330633435383264333937353366653736
61353430633363323263323163646639663330663632343038623964396433626337353232373334
32343938356539303938666137323831646562393233323733336464316165623232326132316362
61313631626234336164656533616231393663363364623130313439623466396231383537656562
35393163323131383838356130643863633736613635306635306135666261623731613239303962
61373432336638353339646665653561653738353731373032366361313562306466373365653333
35306236336331333961613864346165613763383266613236343066623432303766333434333331
66376263363132616132366165393235626336323665373135653462653533376138346632393363
35396165383966326635333134663138366136336430393532373935373264616530613762386430
37643862316232613261343430616438333831623835663239656531356666613032653837373531
30386232643537306530323936386561646265616339376265353763663833623539653831656634
63626564633964343663656435636635656562666236386639353332356566316631643431353330
32643238623932643732353332633535643738663066383830376432346463393464333236386163
64376165333266323166613564313061643832656239333165393035343836376434626162643061
31373861393534636236383765313834356638613332626666653762343537363339383939666263
61366230623930643534353565316165656132396664393138326139613536343738396330653735
37623463343834626133313231393130646134363331393238663065643930383237326439343831
32393536613361616637373761393361303936363863333838666633343063386235316237323531
62633362316436623761616366383336613765373362393161303765613464643632666462343565
63616430396164346263666436336463623638313130656231643131336663336565616135373433
39326533643733333564373434333738343634383033353036646534646632383730613161393130
62653836376435663435353739373536366466646332653030653332353761626466353631663163
38613734383634663662376464653733306564313761336566336336656366653536643834653631
35333963306562393234623039613338643266333762333236366331333431343830393864623830
34323439386133613035363861623332663933313734313637623739613163663730316134303434
35363239363836666463366334356435666632396132306163303037663430383535393934323537
30353266373864346662333035393761646632313032383738346439343232663666343833333565
35363564623038376666363436663830356165323234653230636264303066666230643632356237
66633734346633366533623966393462313438333965623033343337323135353730613561363236
30636333613034626162393137323762306432663761393766303163393536656338366664643432
34636630323965396166356538623165663335333739323034333237306531393734306138343436
36613937373932633234303336373530663533646635396664343935326334333239646534343336
35396666353938326337646431353965653836323731313861613031356561376262363965623934
65336164623134366365303062393161353965363937646363396564313138323938656330333964
64663862363766613630333531623463616331363533343962653164376665303463336134346662
66376238663632663932656230323638323135653666363736643065336330636236323064386361
37646631303138616565383665663634333338613336346438366333636165366635313337373635
62656236653961393061373262326534393339363236353336613339656462336661663739396261
35363636653237316436656662633462306333313336313865333037636661383731336466666536
36646537653431666261353232653233653930346538383438633466346636656432383963333765
62346363633265306665366630383831303434336139343837616361666539656262633735363064
30376138386531336430613563366666333964663231363465326230316233323532366632663930
63653164373931353861653833326130643365373530663039363166366331303134366336613438
63633336363539636265396331653630633162316366316130326531363463616464376635376538
32376638353337343639633434613663666437313564333264383766623561393933333561663235
37666163626538353736383435373530663438363636643665626266316338336532623235353337
61653762383630616266353438386137653137616532633066623165383663646437343565366237
33333463613539346136613135386466656135616136663036313661323738363066633038656139
38353230313861666463383737303038613731316334333031643438343463666164633536313031
39613663376436643165333362636462303230656336333234363336633262336363643736336231
66316630613762383832613836333263303038306235663662323162633932653733626130356665
65316630346534356261373961353130393334396365376237353439326261336339393336303030
63323439666439313831626639323735653730356565643938376564316337613333616664643865
30633530383865663731383635366566613565393632336337306334663937396135303539363536
65656331386532306438383031313164626237393762303636663831666239336637666465633630
63383961336333663537666561376532376631343132363638633364616531306363623432396332
31343864626335613131653762363338393738343531626330663663376531336230656365363661
64363530333965386439376161313534333435393966656231613161633034303964653564363436
63396131623562303962383137666439373132633330363237353031306561306362666561376435
66383535333233663736363762323662363264393365643135323834366565653932313863353437
38363536323739336165356561613335653464363637636234313637383066633865353239393961
31613532373361383062393238303430353662383930373764373237383035633364323661623661
62653661623234366666616532663563316234663137616338393036333937633963323635366165
61613032363531666338353132346365323661626265626461396362646338373536306362303338
31646564343434633433663039366533656665643235336136333134366231376336343465336539
37363139346239666232313366613432646265626564313838346364306133306434313337626562
32346430316532623037633838303363346130623636386134313566383333613565316135373264
38393261313435323433326130303032393538333963646430646366653363613830613534353832
32643434613964613239363966376138376661636335373130356430336631333461623734383535
35356461326136636234663738343738303064386536336462303632303461303733333666383036
38383563393739343134363038623638393766316533373163336439336238613734656132623163
32626564666430663339316163393934323133306238353562323866633738363737643937376435
66306430623334643766623564363239346361393666663766306637313265313833396435626234
35366239616132623863343365663833363934316362636638303536656631633364646235366566
39386637613735373761643339396132323031323438316633363464336636316534643435323861
62663732366430646538626663313035616235643537643234356434323635373962343633336266
30323031306438656537626430356231393466313334633934623632663661306361313330633339
33396234386536336331616161363132343765306562303932313963623037366633383765663134
62636466636433666530363930633765626531613539363832313361666565323635326334326533
36336439363836663033643262336139613437623633616138656564393032393263393132303031
31626336663039633362366663306662333432643261613464303939326562653562346239383261
37373966383635626433363831653936333964626262363839383936356634633233336365623765
62313031353538636464303234383865613932323164386336316362323731303263346637346534
32353734343130343364646161643431353230336364366330363261326334613936633234646264
39623163346663613431323630303034383761663835373565663166366239303130386139396236
62393565353761636636366462353038366664663430616332326465373364396264323064666536
61653035633562316634336263636332363733663666626131383161656535383133613939623034
66363632613733306336323165336534346337386163656631343332323636353539353737316131
34616336343865656565646338653037333838663736333330376330663834373138363739633064
61646439636365323838376131663266636235333062616532613936616339633661303634323531
63356335386534353639323566376134373565353137333134363761666532366431633634333430
39326662633765626230326130386333393463323433366162363432613234336634663439313339
33653737333830336264343839396563316462613032376335356634383436353962613636333866
32333032623635323430356636623138366635386533646133626164316438393937626462313239
31636635363064363763396566306234363965346438653738333961623435303233396634643763
65343763386464366636383734336335646464306262623363303934393734333666636635356365
65353931303862343765343665373139376530656263356365366333323135383838383030613166
30303963383833643937636539656164336537383337346436303534303035313935323633663765
333464336235373230316337333632326138
61616634393062353739616362333531616534666431633234333539376337313235366337346364
3135643764366262633636353735613233343361363165390a303638313465643332363839326530
66623032363563306135643838626435646639306438393638353133393731386139336661383461
3965353830373536360a653364663734333762616630313563656335333933333566333334393837
64636438386264316333633839623736333932356365643639376262353564363639303438373534
37346139393038643332626337646430376165316133343362636235623838343637343062623431
35333530376430643562656233356165313065633865343439363238663966366332346536363430
37313261363434376438306333373161303465653132353864366562363235376630373262356633
64393830643037346565303435633966303234666638653833353138346464383462383136313433
38356663303231313134623862643762633762303364616433623963326463326232333662643839
35346435643234663838643934376461393631666363333435623965376239353137613533383033
34663662666362613661613833383166343965333939336631363633343332653064653761353063
31356263396434386433393332353532373663396431313539343138623564393434373135616239
39353163363063323064633935346134663165626265633136633833643538616662333663366664
61323866313266626637356532336166643261343662636464346566613531626532663961613238
65353434653466316438343631376332316566376438623835393438313239326339323564623564
37313938396562623865646338336634333637666538656139646539376531656464653136663462
30343537303230306331386139376261396162326437623265373336356163636562623932653562
64353763646636363839613365386233316262356339653332323565383033613561356236666336
39616636353530636434366430643239383164376139343064316430396431363363383166346130
61373735313633313639323933646632393231633935326439316466623438393233623139626139
38353333376164613765656164363436613831663333656239346661623031393238353765356233
65336632633361356432326339613766633938336537306261633236333835646335356365393330
38656236363864613862643365383265633134643338383033643434376530303462336564383066
66663061646432333034316431353236323938623639303263393537313939373162633838343062
32316230613033363931343162386538376435386139396634333061313930373335306663393938
30643466303338316663613563636239343432363964653438666662356437623962653266346434
64616136643835396430306633313736623736316634313466366538663437633437613963386231
39666565366464326132643836366431653535343162336563356664346166313532393664353139
39383037323234323038316632383766383266393663646438653038643566376133636237653733
38623438343432323664303461373930363437356136333235393262336336633266396531663638
30393335396537356635343730376532363261353039396639653966633033383037656536646161
39653531303962656234343634346236373362616634313664613764373439643734306639386461
38663364613365643030616430356139393362386438613838326236643063633538346262626133
33666537353862353461393939656231666330633434316261656134363062353835663833356435
62663037383266353037326431376438313165343231656366306265666464303132613133303662
39646462333439633065356137633932336162333438356337663637353033326362313036303564
38643662633531383539393064356535363631336266316334386465313965376665306163393664
64643434326561613333353134633166613464393537323263656466613239343562653636353764
36343365656636393031626231343033653764393361376532646139613166306561323364356364
62623838316430616636323966616634343732353031393665356137393532383762363565663038
35323930633933626464633966343865346466353465626466663435316137366435373734363661
33663939366165313939643032396562326565343961376366313730333735336333353164623266
30306261363230306562343433656133303662306563646166386531386439633534623632373932
30623361346632346463333661366539613233383337353961383564303438643965636235396163
63363264383762303631613033663039626539336566653062333632383534306230396139613234
62633862643535383536353537393431376431303262323332613731636534643033303635613030
61653234313732643563383639303633343266623761623435373734613235656263353864353434
63353039386665623164626666626330623730616438373832303337633364633735613364623131
35613433366361616239363464343937363566383934313863636231373434636331383939623566
34623933373434346561633331653163323664393130336665393465303162396630376236653262
39393465346632323435636333613361353539643238313239373231376436313631663065386262
34663637386632396137353261613666316564333636326231636137613235623765313137306537
38306161633731343838356635613038616363653833623634633061376364376534323532666337
65366634623331323562323562613735356235393434323063303766653334363236613036353638
35353438623930366336383062303462646338633939393639643832623730633562306338313939
35666334336334653534646662316534376235656364356563316632363130356635333336666130
34616639393834373362323930613863383833663161316339323465616639633531376232303332
33333333643934623538633738643631386232643932303138383861313261306261323532393233
61353431373538313762653839343861313337643633643032333765396466393831393162306436
32393832363638333832666364646266376634663031333932333735643831623361323564386261
61323937393762613531646564363963626461343262373839383938663661626562613837323133
39646632316666396637646664356230356131313062363039656163613238386334636636636636
38363463633733663132366239343766353038383962353639313562343636623261376566653635
35326338663935386132333035373362326235643864336162303063623539656439306466663834
38333362353630363835613464303131306333313865623236633362623832633062333565343762
39336161376661646238313839633165666435346433336337633734353166636566326464666330
37386536613430343139653134343963626466333734356461323631623761663639633532376133
61363632303566336161653430613735316562656334323265303163383939323564356465636530
61626262653461386332646566306666323434613162613033373730303265306231316630346239
39343735363632623561373838316334393166303365363766663965306161643365656132653531
32613662663362653366633237386362623962323230303538633732663061376334393666326535
35653038306233626133363732393964623630383637363937363335663836346564636639383266
32326433316236376363333163346234303938626635363464636563653032656361323138326530
36636163356632613738626136376563656233313034666161323362313533366461326132316162
61646335383662313234666332623932396632333435316466346538313630633038663231373764
39343933613361336439636536356136383535336262613538323837633563633038306231663632
33396366343466383538306638656234383930306539633865373031343030333765663735386438
35363232616533656632396661386566643738353862646535323533323237636137646239353664
32353261366334353933376436336533306531356462343535346664656435626466356662613032
32656666336235326165653861326431653335653131663034386166373434303936646634366635
38356536343664363836616235326465616131636132643331373366656164653638613066333737
38373864333734306234383130376239373238636262376664613865363265363337393031616566
35336436373535613535653330306164373462313839316636326534366163326538653633376337
34643039373461393565653838323061656330346632316133373064613661306365336462646237
62326134623063346431643335313166306461653239303537663132643239386636653363323662
39393262613766343563636561356362653665313234366532626638346264316363343264313435
36386531656534666462383337343835313638376438653165663839393433376332623938363738
35356538383136333534386531376530383936633463363166326133303264373534613066356538
34373637303333333836386238303061303834383331653735336233666164656530656666356533
39343030643236613831663465323735343737623362383734326162356333323864333035366433
63316264613337363136613062663263313731636335383531383933623339666134643539353330
61333330336132636666383561356565346563393937653539373431653561653664616137623133
39336333313330336137346238306130653730326130323163306464376434353736623232656134
33356435663762666266393461363531396334313733333132666363316133346531626564626334
66356436393062373364316439646235666336333039383137353764303763636461663638333362
66306430396530636538353965333862323363323832373033353530306331653935396134323634
61353964303666643163646464613737393863373236613532353761636230336633373461343266
34383031353734346465613030653639656366313530333265383632343465393566386161373633
34313563636265336239323362316432383637303134376331633039333537613461353135303934
66366334663032336330386632656162643933316666643562616564613633303231666536653536
61376534663632316338653231653561643338356661353232633431623834396635383034366438
30376161663339633530646539313132383536303931333366316631356135303933316162373730
38343139653932666335353437613734366530616261343134386261313330303766666136616532
38663635393236376661623862646265626239656665323332633532616536643237333137343763
37663432366337633662303931353939346462633837346638353530636132313436626161633865
30653065666261363238366534656265663039373962333938633335373163383261363434646437
35356538623765343866653065306164363363616137323065333062386435393539303930363239
62653638303766343738346437323536306139303261393863373035336662316566646565316662
30633464663666383135663365636436363132363437633933643464626339643865383066623039
66356338633939386337353135613066343236356262363164666466333462383364663134616433
38626332623530633765656464383136383466623561633666366566316461356437626165316166
65626339383133623439366662373666346539613231306564643731383935633939343865613534
66363934373433363134323165383830643535613139313139316137643532303562393432383330
61376363363939633835383161396464613662383961366634626463346430303631653930613430
66616132616364623261363731343437633032383361356433333531363964633962336338373338
61353134616332663135663737613230666534656232623933613862326366393538373737393838
30343037343432306437663035613831316432336431383938363338316437616164376435376261
66363664333031653962383630656430646238353337616535343535373935663561306536353165
65343235396537366635633638306135383164353562336331376336323633323338353361353061
65623431633731303936303732613964346139306336356461626466343132383162666337333664
38636631636338353339316666643436386537336231353332663636383337343831323939396636
30663634653733623530343537636137623332666666343433643830333737336639313064386333
34656362613863323262326130666361373434336234366337663236663739383366373635343862
62656365366533633263323963346363396237393462653633313133353935343631393032303566
66326266353264306266613936333734643631376637663137373232653962646534643565326366
64646634346534613730646430333432373464653365663666346232373332346231306563636136
38646565366661613161383530326261613537366633323239656461343337363139353164653935
66356636653232643035363564306438396233376465646462316463333832636361363137363364
66653164363836333261386437373864666438383338383963616662386631306530326535303666
37393037313539393535633333663662363164323635333635646638663264383533303032303561
34613563353237633761363435636138616462656263353966636363326533363235663761643033
63613934323330363732353465316261653964656430383532623833653663306632633832653763
32333932663362663664663535363566656665653839386363373066343336353264663136326136
66383461626137353430316266356366393937613234656261646632366130613739353266613163
30383731373137653137353663643866343632383937613136666361626435393331313130386230
33306439633138306331616165386239643765323765366530336337623264343030333234336234
64333333613838333734346361636162346564656334396139373535333830313037383864623561
38336131343532663761326635633531323766363963663763653630643232663631613465333132
35306635636437323835326633616665313133393162623737386538373364343865383634636463
33626165623962656432396237653665346464663066633633633564313266343662336333656166
34646636663864663439356364383031313065663433386530663365353937626366663334346434
35656365316134306161656133653736306537613133386634386638376534376133346237353765
64363964303535653037323566653838633231363132666237353765643137383030623930393931
33313935663366313362656137613865386532303737306437353437336331643435323030663666
66383139313333633538373836396263353337316264656631376236346466353765653830366366
66386131336431336466636166366139396337386135636262316237306561353563623061313266
33343563393031303133393163653638643065636636653766613836386438626332373965383864
36343936613738336132396265663130643739373264356530636363633239346637653136343761
64646137383235636231313736343162353237636566663830373739636536393864646639343564
61323039376537646531633030626665323537366233353939653665326666653263623431356166
34343538613031336561303165323134303862666139633937323331643930383938666138346230
33633537666431643234336134333333633664333564396139363136363434376130323632363833
32363764656133653462633132366630396233613463643833353038316232353962623761373966
35613132306662663937366464373034336531366262643939333137613031666162383434313664
65363332383330623863326465353039306338393230303165656366386161656239316161336633
61616434363637376131316135643336653636313565613035643038386332356263383061643262
63333439303736623835653636353261363532646266663331313566363562343730303436353636
63636237343965316366613137353062393832373133316438656433313132643061633130396662
65346530313962306238373634366266386232613566656337396663626535393964323737323939
66333165356138353362336166376466303965663439636432653862316433376264376563313137
30653565386636363730623630646633633832306131626235613862393239386437386561356663
64303862616530393838383364643266633336326665323961643935356339316564316332633635
32323239333339633838666233376165303061373465376133383863333232663065626462303362
37613638303266356537633965323966363331383130613963363933656531663031643030396632
38343838383731346536616364303531373030643730626165633464646137376439643637633636
62346664633931376438313639613165306363356532346566336336363662353233613134346132
30646633363364363234396435323037306663633863383530383034383338636639326362626639
64313532313939316562353561323534313438346530303536333066393863613136646664353264
65663530366238343732396430633138613664653962366338643738623636373031363839393534
38393439393366396262643461303361613666336631313037306231363537346364346432616539
64616431633730333233616362363638613837356638343131376262363736666532356532653330
64353431663261623033323337373932396436656633396439323162626563653630633239306233
37633939363536396439643663623434316161383938393864653437316535313433336461383065
32396536313835393030313136356530623539646361633935333362373339393662393335353361
37353366666264613563663836666332353561666464623561653238666537316233343838663363
38616463643935376233363031323838393163306465333030393331663231623061646530393237
33356466363261653235336134376166343537643065663235333161383366336132316666383731
39326662373162313861363132653531323165393939313461663262316337333762393134663030
35313762356234396336356336393937316262646264306361363235663334333663363136383430
35366538386335633038326563623833313031383762653266643034373838653863363835383466
37323138316564383262356633646262623439326464633530313639313637326136656139376362
35323866663262373938343863653665373139663565623730666366643630636566393266303261
35666261383464393236353964623234306637643838623535313264356364366661653639303662
37616531626435323664636538396439373064333563303637303562653763323935666138383338
32326630303465366332656165343036336630383163643361306136333361373738306238393932
63363331373434353563353631306361383431636239376464643239326531306130633463653665
64636536383430613538623663363066646437666537306161613265323936663366353864323230
32666333326630636433656166343331396166383438313866306566356238653061343461653035
6236383630643231646264333133343738663663323332336536

View file

@ -0,0 +1,27 @@
# monerod-box: the Monero node.
#
# These ports are read twice: by the role that deploys the service here, and by
# the Caddy play that runs on the EDGE host and publishes the restricted RPC.
# A role default is invisible to that second play, which is why they live in
# host_vars - the edge play reads them as
# hostvars['monerod_box_local'].<name>, and the role picks them up
# automatically because host_vars outranks role defaults.
monero_p2p_port: 18080
monero_restricted_rpc_port: 18089
# The 500 GB disk added to this VM in Proxmox as scsi1. Named here rather than
# left to the role default so that "which disk holds the blockchain" is a
# property of the machine, recorded next to the machine. The role only ever
# uses it to create the filesystem; the mount itself goes by UUID.
monero_data_device: /dev/sdb
# Systemd services deployed on this host, monitored every 5 minutes.
#
# The fact lives with the machine rather than in a central map, for the same
# reason the cross-host ports do: "what runs here" is a property of the host,
# and a central list is one more thing to forget to update when a service moves.
#
# Only units WE deploy belong here. Distro units (ssh, cron) have their own
# supervision and would be noise.
monitored_services:
- monerod

View file

@ -31,6 +31,7 @@
- {sub: "{{ subdomains.memos }}", host: vipy}
- {sub: "{{ subdomains.mempool }}", host: vipy}
- {sub: "{{ subdomains.datum_gateway }}", host: vipy}
- {sub: "{{ subdomains.monero }}", host: vipy}
# A public resolver on purpose: this must test what the internet sees, not
# what a local cache or the tailnet's MagicDNS happens to answer.
@ -50,6 +51,10 @@
- {name: memos, sub: "{{ subdomains.memos }}", path: "/", status: 200}
- {name: mempool, sub: "{{ subdomains.mempool }}", path: "/", status: 200}
- {name: datum, sub: "{{ subdomains.datum_gateway }}", path: "/", status: 401}
# 401 is CORRECT: monerod answers an unauthenticated request with an HTTP
# Digest challenge. Asserting 200 here would go green precisely when
# rpc-login broke and the node started serving the world for free.
- {name: monero, sub: "{{ subdomains.monero }}", path: "/get_info", status: 401}
# Ports published from the edge host by socket_proxy.
public_tcp:

View file

@ -45,6 +45,7 @@
- {name: mempool-mariadb, host: mempool_box_local}
- {name: mempool-backend, host: mempool_box_local}
- {name: mempool-frontend, host: mempool_box_local}
- {name: monerod, host: monerod_box_local}
tasks:
- name: Build the probe endpoint list

View file

@ -15,6 +15,7 @@ memos_box_local ansible_host=memos-box lan_ip=192.168.1.145 ansible_user=counter
forgejo_runner_local ansible_host=forgejo-runner-box lan_ip=192.168.1.132 ansible_user=counterweight ansible_port=22 ansible_ssh_private_key_file=~/.ssh/counterganzua
arbret_staging_local ansible_host=arbret-staging-box lan_ip=192.168.1.147 ansible_user=counterweight ansible_port=22 ansible_ssh_private_key_file=~/.ssh/counterganzua
small_backups_local ansible_host=small-backups-box lan_ip=192.168.1.131 ansible_user=counterweight ansible_port=22 ansible_ssh_private_key_file=~/.ssh/counterganzua
monerod_box_local ansible_host=monerod-box lan_ip=192.168.1.146 ansible_user=counterweight ansible_port=22 ansible_ssh_private_key_file=~/.ssh/counterganzua
# Local connection to laptop: this assumes you're running ansible commands from your personal laptop
[lapy]
@ -48,6 +49,9 @@ fulcrum_box_local
[mempool]
mempool_box_local
[monero]
monerod_box_local
[memos]
memos_box_local

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

View file

@ -42,6 +42,7 @@
"tag:fulcrum-box:50002",
"tag:mempool-box:8080",
"tag:memos-box:5230",
"tag:monerod-box:18089",
"tag:arbret-staging-box:80",
"tag:stalwart-box:25",
"tag:stalwart-box:465",
@ -108,6 +109,9 @@
"tag:mempool-box": [
"{{ headscale_namespace }}@"
],
"tag:monerod-box": [
"{{ headscale_namespace }}@"
],
"tag:nodito": [
"{{ headscale_namespace }}@"
],

View file

@ -0,0 +1,39 @@
---
# Monero: a full node, reachable from Feather over the WAN.
#
# The chain lives on a dedicated 500 GB disk mounted at /var/lib/monero; the
# role creates the filesystem and the fstab entry, and monerod's unit carries
# RequiresMountsFor so it cannot start without it. An initial sync takes days
# and nothing here touches the data directory afterwards.
#
# Exposure is one HTTPS vhost and no new open ports. See roles/monero/README.md.
- name: Deploy the Monero node
hosts: monero
become: yes
vars:
healthcheck_push_url: "https://{{ subdomains.gatus }}.{{ root_domain }}/api/v1/endpoints/probe_monerod/external"
healthcheck_push_token: "{{ gatus_push_tokens[inventory_hostname] }}"
roles:
- monero
- name: Publish the restricted Monero RPC from the edge host
hosts: edge
become: yes
tasks:
# No basic auth here on purpose. monerod's own --rpc-login is HTTP Digest
# and guards this endpoint; Caddy's basic_auth would put a second, Basic
# challenge in front of it that Feather would answer with the wrong scheme.
# Caddy's job is TLS and routing, nothing else.
- name: Expose the restricted Monero RPC over HTTPS
ansible.builtin.include_role:
name: caddy_site
vars:
caddy_site_name: monero
caddy_site_domain: "{{ subdomains.monero }}.{{ root_domain }}"
caddy_site_upstream: "{{ hostvars['monerod_box_local'].ansible_host }}:{{ hostvars['monerod_box_local'].monero_restricted_rpc_port }}"
caddy_site_resolvers: "100.100.100.100"
# The per-site validate cannot catch a conflict between sites.
- name: Validate the whole Caddyfile
ansible.builtin.command: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
changed_when: false

View file

@ -53,6 +53,7 @@
- import_playbook: services/fulcrum/deploy_fulcrum_playbook.yml
- import_playbook: services/datum-gateway/deploy_datum_gateway_playbook.yml
- import_playbook: services/mempool/deploy_mempool_playbook.yml
- import_playbook: services/monero/deploy_monero_playbook.yml
- import_playbook: services/memos/deploy_memos_playbook.yml
- import_playbook: services/forgejo-runner/deploy_forgejo_runner_playbook.yml
- import_playbook: services/phoenixd/deploy_phoenixd_playbook.yml

View file

@ -48,6 +48,20 @@ vms = {
disk_size_gb = 30
ipconfig0 = "ip=dhcp"
},
# disk_size_gb is the ROOT disk only. monerod-box also carries a second
# 500 GB disk (scsi1) for the blockchain, attached in the Proxmox UI and
# therefore invisible here - the vm resource sets ignore_changes = all, so
# tofu neither created it nor would notice if it vanished. It is mounted at
# /var/lib/monero by ansible/roles/monero (tasks/storage.yml), by UUID, and
# monerod's unit refuses to start without it.
"monerod-box" = {
vmid = 2400
name = "monerod-box"
cores = 4
memory_mb = 2048
disk_size_gb = 10
ipconfig0 = "ip=dhcp"
},
"memos-box" = {
vmid = 8100
name = "memos-box"