Compare commits

..

No commits in common. "d208783fbf9db2355fec4929eba5b06203890814" and "defb2f6333cb16d71250e7515008ca974c417d7f" have entirely different histories.

32 changed files with 204 additions and 1569 deletions

View file

@ -57,11 +57,6 @@ subdomains:
# unauthenticated, like any block explorer - the data is the public chain.
monero_explorer: exmrplore
# Navidrome music server (music-box, proxied via vipy). No basic auth:
# Navidrome has its own login, and the Subsonic API the mobile clients speak
# has nowhere to carry a second credential. Same call made for monero above.
music: music
# Read by plays across several groups, so global rather than group_vars/<group>.
headscale_namespace: counter-net

View file

@ -1,214 +1,191 @@
$ANSIBLE_VAULT;1.1;AES256
66356262646437623239383039643762363139653565356263623438303833663431396566353163
3631323563343638346464386661346235656130383238370a353236623366313561646334363639
35346232376138666430363437346638326264646562386436323962633630663136626462343762
3862333139646636650a656337306533313436333239653561333231363030633339303835396637
61633763313464353933306333383061346563623637383166646332303237653065346233623134
39386130613866386235376266643062653833643132633235393931636366376164306331303762
33393562303738393364323465653163633730633636343764646337383733643362396132303063
32336461396561663063633137613234356535383863346334666465643437613962376439356433
66353532333933343462636163353637613637356530333331636363616265376263636538656566
62643137623933363237343865613131383766633561636664616434393538646332346132336630
62333361626339626638333666393735373834396431343965386532313834353837336664306530
36303138373734343735653637663466353435306166316661353738353035366535333265626330
36396538353566316634376662383564323861303063386163663061323435656261316439336539
65323063343961646435373134303039373139333265633838313636373463316231633832306261
36623934343731343938306332356261646264613063393834336332666230626534653464363335
66356331666434623735613333303466656263313964363030316231303330303735373135653763
30323635303435656639653037366430316330333033306339366361353765656337316561366263
38613265363565373832636239396362616435303536646465326132626239353361613137626632
30353764323430656631366134373263343861376666346166363131333137376466393438346461
31613435363637306432343437653636356665373838626361306332316664343266356666353238
35303464326633326438353730393462373437313864326638323262323034346464643038386462
34613435326432663566386331333430663336656239353131633261626637613035636662346136
33626131626435316566393831333534393663326465633531373932626631613063666165613766
63636463656435386333323037656533306331646561363061613537663738326162656332393161
62313338376630643031666630663337646439623862306663303335303232306161353336643334
30386537336265383334336431653637303836376261303038653739396263666161653365356133
62636638393465363239616264316331316139363063313431323165313237646437643361653539
63393932623934313062376137626266363330623766646231353561646332353565643638356633
39656565363962653038393430363666643361343131323231663566343035643631646433306464
64343735393937333036623139303531343338373162396635613863316466393235343865333065
63353662313737353737353736316261346466366666666536653064356130326334326137383933
64306331663430313838376266613763343537613965303937393364396238663266363539313531
33616133646365396336613234636630396239316633333832653138393964616261643531663836
36666265386134383430626637643766636439393636336436366265396239303335306563653962
62323763626265373037343766386433646637633838643933633738333037633336646663316335
36373534663439306137353236343565636538636333313239373936653263366637613335663233
32306536643866656461343666396565373661353561393665643162613539633634666138626266
35613663376465626363373864626266393335396461316338653262316131643633666361333537
64366533633535313633306535393732383436333762616539646463366665623866323463336263
38313365326338386664363538633232343636303135356230393065613238386366323435633931
37353537313330396463383233616532653139376263346264623366383339306139386231636466
35383761373733386133386436316431613265306264656630353561333736613734653164333031
32643237366534643062653065333632343563633034663266393961353763663534353466383561
38373332323135353932343231353433366637333437636238623235663836393735346230623438
37616639643231396564373033636162616234366262333461343139363539316332333431636565
64396639383462386239613035303565376163633637653137373631383864353532373465626630
63636531653063306131623735393963623836313566643565386336636565633130383264316533
33343030316365373164663661306161363062646361646332393935373533626331623163323038
61316666366263313531373530366639333364336563623162323938356662626565383363303533
61323730313161633035646435346234366661386339346663363438663862636361343033306134
66396365653634373235623837666537323230616334663263633135316433313734663863333561
34326134613335346661316632353764343235326630316535306666326662613436383764646366
33616135333038333834656439383733656436663433303536313961333662366564376338326661
35393839643663333130383965663538393931363662336165636435643132353461343266363265
66346635313337346661646461386336386461346133323435643939616234666665616261313731
38383265643830646364373164656133396132666236313265346337643939613831303366393530
63333330346131656134366237323661316534323330306330616564393130353136306461666361
62376637646230623264363437313161396130616231346133346665303834623833663062663436
30643736376638303065356161366561353565343139343731313263393933313239363363636638
32663932626332626366333635616335316235363561373539623265663733373030373439373831
65333763653635613964396437333833626535643666643433393031336461366562333934666234
62383632303436633965643430363763353361613762306361613066356136383131383232313961
30626531353933336563656139316338616565303261363933306164393966343037353431653233
32636631373966323838333666313964323933306638653162646166623237343662646134383337
37383039383531656337626334386638613133343133633637343738343731326663356666333161
61323765636135306563323064346465633537613361383761393361353363396237333438306166
35386338356135633365396661356535623232613765653931303063353538643130323439366438
39363936316263333562646133646464346362343761336662353764613261396364626164313664
31383839303361323461663662393736616363646232666633323134653138306161323735386638
30333365663064366535656134323338663566616163333634353939363533356364393563363333
65623838323034336530326363383237373563326334343066323738663833353930663930373965
30363766633137396561376636636566363862353062663038653534623430643766633631356562
63623762343433626363353239376539633136333038623338303961373938396632313966666532
33633865626462636137343436386661353561623035656435623566613530623163316465383664
37346261616239653138623262623930316432633531313037353938396164646534316564613238
36643137303965316238346134323639386261633338363130323134336632636564636638653861
38346566643966386465366535393166626463313039613236393831393732613537336530393936
31613234333836333732323165653931336565316564346239336663363736323763623962383261
65626332626565636162336264303964323139303831656262373161303861393366353237393136
62303361636131646335633733373966626135396630623133623733373565306234646165656138
35383065613365363438396332306236646432663731326666613838643434393866623163316631
33633931373135336438643735646266383462386431666330363066386538386335383231653334
64323366363032386662333662613562333864356238626161366566356564343339343838373437
30363133363136663036656461343534666636343932313336336637376665393564313234373430
39356362616539366237646363646364326664653232313235653831663539353530393261346532
38653134623732313466386635313730303639613561323665356633653836393237333737323864
36323736326239636565346536646566656138666630396366353930383237333338626532663131
64366339303230663530626632623066613030663533366539313866646466373439663831363936
36366636653032343064383661383664636463343165326532653861663537633238323937363839
65333030653566306465643035633162393534323731346330333861353236393732346130313034
64303736323030643533633539623638643232306435663238393566376564313162633334613462
33363931383664633433363032643536356135626662653733303339656339373735643732626535
35316131356135393933393462336163373062653133356133393830393638313838396361336635
62323463333464353939346233333462646161633036643332313337353065376561336630383163
38306666666634393766366436653436623138363830343636323833353365333762343533633835
66363730383932303661313831663566613830393530616164323963636339393339623065316534
38373662636330336632653335316463623935653961363566396132616433633831636334306236
34393761663962623563333534383739613965353033646235653532303330343961656639333337
31643462663538313930383038343964326463363935396365343466643236653738626639616434
62343337356239316162383564666131663535313633373062353030336439396533376235386131
31653865643931323364356566316435333336316166643063613164643266313834323235353863
34623936613366303161353165386662376465656366656561613466383830326162336238656236
30646632396231653263373631346330376461303231373733646231373462623131303633616166
37366435353830303234393962663334656335356661333437363637663664323430623135346262
33303233343362336261633663336635366233313466636233333836663464633637383964313133
31363461336237373337353438396132616134303432336533653737333538383534363735316334
62303666363532383131383833383536333733306163366339653038313930646531653565386335
38313561303062336232333634343866393763636131333665396430306432373734613834316636
61306138626530616230343462613036323166656334656637616139663030623932386132393762
36363364343737613165376365323361363233616532643866303632653063343431613338373633
63303263626434326539316439323239616632653632346538336535373336623739323931623462
35656365336630323035616161656237353135373366376466326264323863336166633438663834
63646562386339363235396163646334333238323932383230643964366466323064313138323464
62636239663635336332633334656631383261306437333963363464303430666462633333666638
37633830663439396133323861303735346561376434386131363864316332623137376139376231
30303765646132306261363335366565306564373261663336613336336632616231363366316135
65303966616439643863326662656439623938373833323738646535636230663136653266653533
63326235376633333366373436333534336436366434363461393763633437616262326133623563
37613161303161356632316335616639373564306161623339353066383163303431313333306635
36326435376661343237303633316462613532313232643461313065376134393338613130336161
38326463303562666662613361656161306139653431386630303561626666613037393238313463
62623533396532326332326636346134616236653638363837643131343166333564323538653638
63383261383265353331663864643933373562396565346163396434383765343962336363353261
38353034643436626632623235396261326235323937646135326532313561396361363062333366
32343666343163636233646466613633383034653438646262666133643830333834383233343136
33646437396137613466643031323538626332613066333065643237353263636435393964356464
34383265303364663233326432313230336436373866303435336666343832383737303030306534
36383036363937393965386431343561333961663565333565333261323033616630343130613934
32383166333063623235333464343864326531333165346261643131393264346534393035393431
64636139383232383332623866323363623666326361386134353030376366323039393033653061
63383338303734323637386366646365663032636463336261643533663638343661613462373736
32663566666133656165396233643334343335333731333938376236633330366262366139326133
32653163343963333739303061643936363861313938646164316564393261663765393462613837
31646636326330663464363034376230393739343961653130303166366233396262656634313061
65646664373161356134616165383265346633376136376132643234623334353563323539333065
61623265343730326164373063383937323766363466343466316364373935626138343938323261
38323739663131346138623663393832346236356631633133313436363364663536316561623631
32393562646666343537663039363165646337653564363065353433643031353865386361613534
37633639303636313261613563353136346465326163623361323230306364396362373966336262
31353565623239343564333533656236656666316638646666376138373164663138633835366162
37633036346562663261343731366161613361656534343839303062373232636339643961666432
61396665656638333435386338383638323664313139656331336131326563373235646539633835
30393235316564656131303932386336366661373737393264613364663030363862656462646463
33303766303166336132626265653431313263363131653835336634356632666230666235383262
31653837356536666266383562633437376161653664353533656331303563626539626161653562
64333337626430616330346233613566353330383361353234376236656336653735663838343333
37643162376630373531396138643861326562323133303363363837306138373434306364356364
64623037643737663138626262326630323638376336663062393930663566316632323862653135
66363664336431373966663133353030393863313538366632643932363438613035326563323163
63343565373862316230643166336438393464323032666334653631393761303337303861666134
36663637346136623564613233343432383930616339626161623933323565623861653939326165
37383436353533613736323237666433316130613464396333343965393031333138396461376632
61373166643766376234646436653566313637326434363261333831336136306137333061323035
66303335363035666366386364303861383231366339643666313636623733616562333539643465
32353566326262326637393434656133313963646435333135613630313865373430366265666632
37316539363439663131323835313761313765653564663337346462653332613430373037333064
64323731623566326664613634626265383661346462323463303638346464643366663333336339
65626265663230346338633764633436653339373031623438633638326137613635633239343834
35373132376133363861373062386235353836633030323236343666323134326135336231613566
34326263643163633935643065306165643564663831633561353061653331326566633664376432
33353835366265346266663836326465356166626432356663366566313962663862363930336334
65323337386231376361343933353865633330613466323337306137633762653931313331313339
36663965353832386662666433383934303737656336383432633632646135383835336631373861
63646164373137316662373330333465343134633466396566333334663036663039373934333832
35343661303565646464373834646234623837363562386463383237373533316638666663613633
30313437626231343062636437363836386635333862373362643532646335353863323936663034
30316461373330623130353563643035303561303666393464636533626562633165393236663936
31313061303465633332646363643866306439383636356366623565663437313365376331656666
34333930333235306636646664326561613165633233353937666238646536323131313232626366
36623237613635343134313366346661656433643136353433393532303165326564646331333035
35353365633039353161663337306463303535643062366232336239656162653532306539333035
65373735373832326337333962643330363338656134303637313337393466366263363264343631
30656433643562393537326361323733643639396537303065623936336230346664616534323262
36313864396364353836373538333130303731383437376137383938383633373063323936613464
37613166323432363232313834326565663265336138396335313230303935396266646438323833
65306666636338373239346332613464373235613137643133626261313031326462356138356632
66363465346165306533613636383938636531656631306139666237396632646634333937623032
33613439656637383537356364336265653931613835333734383836343930313838626564333065
35363735313162616238643438316535356663396339316331633232393232316131396432653639
36303335643534376536383331323562353139613130383330636334636261326462656565643138
63326365363163383932653562363833643761653839306131343737353633313737353732633463
62326563336339383661376665353230613366646132653364396338663337326136333263363838
35626537666238383435613431666365303133373166386136353061373765313632343665306439
34356139363636623639626633623030363933303539626665383733363638303832333561653130
64393437386362333731643131663263626434666661326533386362306362653732376366626139
65626662323232636134653334366639386164653535323435613863353332366630303636663339
34333633373737333732363664353437613762383064316134393139393539306564396237343332
64636339643030353365356136366264353935643830633237316532316265316635393664326533
64343030396334326135336465336637623832363065353163316465303537643861306632336535
31366338323263313965643565656430366566393130326333383862313239356437623232613865
63383863316639346632323834633739356464313865376463323633393365653033376431373138
61363035653233373036666531303331383337353839363563386333313730363664666463333735
61383638363164336265383664376463616536383533666265383961353961303833353265366633
32646330333336613631633830633837346332653437356536363532396438326364623465383133
36656231343734643830383335623930343132386336666366623161666637623862363033373236
32666332316266623230666231663162643262653231313965323137383532633663323239396139
37656533373065623161623766333335353965373731346639613566343236363239313537636239
34626533346138613764346161383337623265353563343066303236333534316137356636396565
32616136306665346566616565346162613736333836623363346232373932366365663531653435
37623539366233366539396361386163643161653731353232313034613135373039653339346637
31623138326133366333656661393837653939343064656638616630346462363065623139323131
65363036383433663566626336353432613535363239373264356237323032323634383662613763
39646161363639326330346332353965366330346632303939623136303238323431346361656462
61623838626139636163353430636461666239343337373132643334616234616339313761353331
36643165373834373437363165303861306135383236303030626463373235393234313765343563
37353932366430666539623230373161303935633565323366363232336264643961383835333066
32623366663965316662383036306462616465353139336333643936363838313538363832363330
33313861313636373831656431383434363664663734626363646365666135376331366335653064
30386235313037363365363232316338613634643235383037393635333166346632356435373039
66663862373939346434333130383065386135306661353066343330666563386136376562316134
63346435643132316434363666343964303335663131383831373430653632663638396333393932
36383664376666393761643730306336333538376637633135616166346432636464336434323031
3966
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

@ -29,24 +29,3 @@ monero_data_device: /dev/sdb
monitored_services:
- monerod
- xmrblocks
# ...but on THIS host, once a day rather than every five minutes.
#
# Both units are a Monero node in the middle of a multi-week initial sync on a
# rotating disk. There is nothing a 5-minute poll reports that a daily one does
# not, and every failing poll was another Signal message - 83 of them in six
# hours while the explorer was wedged.
#
# This is safe to scope to the host because monerod-box runs ONLY Monero units.
# Do not copy it to a box with a mixed workload: the timer is one per host and
# iterates every unit in monitored_services, so slowing it slows all of them.
#
# 26h, not 24h: the window must EXCEED the push interval, or a check reporting
# exactly on schedule races its own deadline and alerts on jitter alone. The
# 900s randomized delay puts the real worst case at ~24h15m.
#
# Host liveness and disk are deliberately NOT slowed - they answer "is the box
# alive", which is still wanted within minutes. See infra/400.
service_health_on_calendar: "*-*-* 05:15:00"
service_health_randomized_delay: "900"
service_health_heartbeat: "26h"

View file

@ -1,11 +0,0 @@
---
# Navidrome's HTTP port. Read TWICE: by roles/navidrome on this box, and by the
# Caddy play on the edge host, which reads it as
# hostvars['music_box_local'].navidrome_port. A role default cannot reach a
# second play, which is why this lives here and not in defaults/main.yml.
navidrome_port: 4533
# Systemd units monitored every 5 minutes by infra/401_service_monitoring.yml.
# beets is not here: it is an interactive CLI, not a service.
monitored_services:
- navidrome

View file

@ -64,7 +64,7 @@
'name': (item.0.host | lower | regex_replace('[/_.,# +&]', '-')) ~ '/' ~ item.1,
'group': 'services',
'token': gatus_push_tokens[item.0.host],
'heartbeat': hostvars[item.0.host].service_health_heartbeat | default('11m')}] }}"
'heartbeat': '11m'}] }}"
loop: "{{ host_units | subelements('units') }}"
- name: Register the service endpoints
@ -98,16 +98,7 @@
healthcheck_check: systemd-units
healthcheck_units: "{{ monitored_services }}"
healthcheck_units_key_prefix: "services_{{ host_key }}"
# Cadence and heartbeat window are ONE decision, and both are a property
# of the host - monerod-box runs a multi-week sync nobody needs polled
# every five minutes, so it declares a daily schedule in its host_vars
# and a matching window above. Everything else keeps 5min/11m.
#
# An empty healthcheck_on_calendar means "use healthcheck_interval" -
# see the timer template in roles/healthcheck.
healthcheck_interval: "5min"
healthcheck_on_calendar: "{{ service_health_on_calendar | default('') }}"
healthcheck_randomized_delay: "{{ service_health_randomized_delay | default('30') }}"
healthcheck_boot_delay: "2min"
# The per-unit results go to keys under this collection; the role's own
# single-result push is unused here, so only the base is set.

View file

@ -31,12 +31,8 @@
- {sub: "{{ subdomains.memos }}", host: vipy}
- {sub: "{{ subdomains.mempool }}", host: vipy}
- {sub: "{{ subdomains.datum_gateway }}", host: vipy}
# Daily rather than the 6h default - see the Monero note under
# public_sites. A record that has not moved in a year does not need
# four looks a day while the node behind it is mid-sync.
- {sub: "{{ subdomains.monero }}", host: vipy, interval: '24h'}
- {sub: "{{ subdomains.monero_explorer }}", host: vipy, interval: '24h'}
- {sub: "{{ subdomains.music }}", host: vipy}
- {sub: "{{ subdomains.monero }}", host: vipy}
- {sub: "{{ subdomains.monero_explorer }}", 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.
@ -59,22 +55,8 @@
# 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.
#
# Both Monero sites are polled ONCE A DAY, not every 5 minutes. The node
# is a multi-week initial sync on a rotating disk and the explorer reads
# the same LMDB, so a hard poll measures the disk more than the service -
# and a wedged explorer turned that cadence into 83 Signal messages in
# six hours. `threshold` drops with it: 3 failures at 5m is 15 minutes of
# tolerance, but at 24h it would be THREE DAYS before anyone heard. A
# daily check has no room for a blip, so it alerts on the first one.
- {name: monero, sub: "{{ subdomains.monero }}", path: "/get_info", status: 401, interval: '24h', threshold: 1}
- {name: exmrplore, sub: "{{ subdomains.monero_explorer }}", path: "/", status: 200, interval: '24h', threshold: 1}
# /app/, not "/": checked live, "/" answers 302 (Navidrome redirects to
# the SPA) and only /app/ returns 200 with the real UI. 200 rather than
# 401 because Navidrome has no basic auth in front of it - the Subsonic
# API the mobile clients speak cannot carry a proxy credential, so its
# own login is the gate. See roles/navidrome/README.md.
- {name: music, sub: "{{ subdomains.music }}", path: "/app/", status: 200}
- {name: monero, sub: "{{ subdomains.monero }}", path: "/get_info", status: 401}
- {name: exmrplore, sub: "{{ subdomains.monero_explorer }}", path: "/", status: 200}
# Ports published from the edge host by socket_proxy.
public_tcp:
@ -117,7 +99,7 @@
'name': item.sub ~ '.' ~ root_domain,
'group': 'dns',
'url': dns_resolver,
'interval': item.interval | default('6h'),
'interval': '6h',
'dns': {'query-type': 'A', 'query-name': item.sub ~ '.' ~ root_domain},
'conditions': ['[DNS_RCODE] == NOERROR',
'[BODY] == ' ~ hostvars[item.host].ansible_host],
@ -136,10 +118,10 @@
'name': item.name,
'group': 'public',
'url': 'https://' ~ item.sub ~ '.' ~ root_domain ~ item.path,
'interval': item.interval | default('5m'),
'interval': '5m',
'conditions': ['[STATUS] == ' ~ item.status,
'[CERTIFICATE_EXPIRATION] > 168h'],
'alerts': [{'type': 'signal', 'failure-threshold': item.threshold | default(3),
'alerts': [{'type': 'signal', 'failure-threshold': 3,
'success-threshold': 2, 'send-on-resolved': true,
'minimum-reminder-interval': '6h'}]}] }}"
loop: "{{ public_sites }}"

View file

@ -45,16 +45,8 @@
- {name: mempool-mariadb, host: mempool_box_local}
- {name: mempool-backend, host: mempool_box_local}
- {name: mempool-frontend, host: mempool_box_local}
# The two Monero probes push ONCE A DAY, not every 5 minutes, so they
# need a window wider than a day. 26h, not 24h: the window must exceed
# the push interval or a check that reports exactly on time races its own
# deadline and alerts on jitter. The timers are OnCalendar 05:00 and
# 05:30 with RandomizedDelaySec=900, so the true worst case is ~24h15m.
# Cadence lives in roles/{monero,xmrblocks}/defaults/main.yml; the two
# are one decision and must move together.
- {name: monerod, host: monerod_box_local, heartbeat: '26h'}
- {name: xmrblocks, host: monerod_box_local, heartbeat: '26h'}
- {name: navidrome, host: music_box_local}
- {name: monerod, host: monerod_box_local}
- {name: xmrblocks, host: monerod_box_local}
tasks:
- name: Build the probe endpoint list
@ -63,7 +55,7 @@
'name': item.name,
'group': 'probe',
'token': gatus_push_tokens[item.host],
'heartbeat': item.heartbeat | default('11m')}] }}"
'heartbeat': '11m'}] }}"
loop: "{{ probes }}"
- name: Register the probe endpoints

View file

@ -16,11 +16,6 @@ forgejo_runner_local ansible_host=forgejo-runner-box lan_ip=192.168.1.132 ansibl
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
# lan_ip read off nodito's ARP table for the VM's own MAC (bc:24:11:89:fc:1f),
# not guessed. Note .149 previously belonged to another machine - its stale host
# key was still in known_hosts and SSH refused the connection, which is the
# designed behaviour described in group_vars/nodito_vms.yml.
music_box_local ansible_host=music-box lan_ip=192.168.1.149 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]
@ -60,9 +55,6 @@ monerod_box_local
[memos]
memos_box_local
[music]
music_box_local
[ci_runner]
forgejo_runner_local

View file

@ -94,16 +94,3 @@ 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: ""
# WHEN TO CHECK. Daily, at a fixed hour, rather than an OnUnitActiveSec
# interval that drifts with every restart.
#
# Prefixed because the generic `healthcheck` role owns the unprefixed
# healthcheck_on_calendar; an unprefixed default here would shadow it for any
# play that includes both.
#
# Paired with the 26h Gatus heartbeat in
# infra/403_service_probe_registration.yml. Change one and you must change the
# other: the window has to stay above the interval or the check alerts on its
# own jitter.
monero_healthcheck_on_calendar: "*-*-* 05:00:00"

View file

@ -8,17 +8,7 @@ Description=Monero Node Health Check Timer
[Timer]
OnBootSec=2min
# ONCE A DAY, not every 5 minutes.
#
# A 5-minute poll of monerod buys nothing a daily one does not: the node is a
# multi-week initial sync on a rotating disk, and every failing poll is another
# Signal message. The cadence and the Gatus heartbeat window are ONE decision -
# the window MUST exceed this interval, and it is set to 26h in
# infra/403_service_probe_registration.yml. A 24h push under a 24h window races
# itself and alerts on jitter alone.
OnCalendar={{ monero_healthcheck_on_calendar }}
# Spread against the other daily pushes leaving this box.
RandomizedDelaySec=900
OnUnitActiveSec=5min
Persistent=true
[Install]

View file

@ -1,179 +0,0 @@
# navidrome
Navidrome — a music server — plus [beets](https://beets.io), the CLI that fixes
tags before Navidrome ever indexes them. Both live on `music-box`.
Navidrome is a single Go binary with a SQLite database and no external
dependencies beyond `ffmpeg` for transcoding. It speaks two protocols on one
port: its own web UI, and the **Subsonic API** (`/rest/*`), which is what every
mobile client in the ecosystem — Symfonium, Amperfy, Tempo, substreamer —
actually talks to.
| | |
|---|---|
| Host | `music-box` (nodito VM 8200, 1 core / 1 GB / 50 GB) |
| Port | 4533 |
| Public at | `music.contrapeso.xyz`, proxied by Caddy on `vipy` |
| Library | `/srv/music` — beets-managed, Navidrome reads only |
| Staging | `/srv/incoming` — unsorted, Navidrome never looks here |
| State | `/var/lib/navidrome` — SQLite, cache, transcode scratch |
| Backups | none, deliberately — see below |
## Exposure
Public, authenticated by **Navidrome's own login**. There is no `basic_auth` in
front of it, and that is a decision rather than an omission.
The reason is the Subsonic API. A Subsonic client authenticates with
`?u=<user>&t=<md5(password+salt)>&s=<salt>` in the query string and has nowhere
to put a second set of credentials. HTTP Basic Auth in front of the whole site
is invisible to most of them, so the choice is between a proxy credential and a
working phone. This estate has made the same call before, for the same reason:
`monerod`'s restricted RPC is proxied bare so Feather can reach it, and its own
Digest auth does the authenticating.
The cost is stated plainly: Navidrome's login page and the whole `/rest/*`
surface are reachable by anyone who finds the hostname. Navidrome has **no rate
limiting and no 2FA**. The fail2ban jail below covers part of that gap.
### The fail2ban jail on `vipy`, and what it does not cover
Installed by the edge play in `services/navidrome/deploy_navidrome_playbook.yml`,
reading a per-vhost Caddy access log at `/var/log/caddy/music.log`.
It catches **web UI brute force**: a bad login on `/auth/login` returns HTTP
401, which lands in the access log and trips the filter.
It very likely does **not** catch Subsonic API brute force, because the
Subsonic protocol answers a bad password with **HTTP 200** and puts the failure
in the body (`<subsonic-response status="failed"><error code="40" .../>`).
Caddy never sees response bodies, so a status-code filter is blind to it.
The obvious fix does not work either. Navidrome's own journal logs those
failures, but the request arrives from `vipy` over the tailnet, so the only
address fail2ban could ban on this box is `vipy`'s — which would take down every
service behind the edge. Banning has to happen at the edge; the edge cannot see
the failure.
So: `/rest/*` is protected by password strength alone. That is the reason the
Navidrome password must be long, random, and used nowhere else.
To re-check whether this is still true after a Navidrome upgrade:
```bash
curl -s -o /dev/null -w '%{http_code}\n' \
'https://music.contrapeso.xyz/rest/ping.view?u=x&p=wrong&v=1.16.1&c=test'
```
`200` means the caveat holds. `401` means it no longer does — widen the jail
and delete this section.
## Passwords are stored reversibly
Navidrome **cannot** hash user passwords one-way. The Subsonic API requires the
server to compute `md5(password + salt)` on every request, so it must be able to
recover the plaintext. It encrypts passwords at rest with
`PasswordEncryptionKey` instead (`navidrome_password_encryption_key`, from
`group_vars/all/vault.yml`).
Two consequences, both real:
- Root on this box means every Navidrome password in plaintext. **Use a password
unique to this service.**
- **Changing the key invalidates every stored password.** Set it before the first
account exists and leave it alone.
## Backups: none, deliberately
Recorded as a decision so it does not read as an oversight — the same treatment
`phoenixd`, `monero` and `xmrblocks` get.
The music files are large and would swamp the age-encrypted nightly path that
`backup_source` uses. `navidrome.db` is mostly a rebuildable index: delete it and
a rescan reconstructs the library.
What is **not** rebuildable is the curation on top — playlists, play counts,
ratings, starred items, user accounts. Today there is none of it, which is why
this is a fair trade. **If that changes, this is the decision to revisit**, and
the fix is small: `navidrome.db` is a single SQLite file and would fit the
`backup_source` pattern in about fifteen lines.
The beets database (`/var/lib/beets/library.db`) is in the same position — it
can be rebuilt from the tags beets itself wrote into the files.
## beets
Installed and configured by this role; **not** a service, and deliberately so.
`beet import` is interactive and it *moves and renames files*. A timer that
reorganises a music library unattended is a way to lose one.
Config at `~counterweight/.config/beets/config.yaml`, library DB in
`/var/lib/beets`.
```bash
# the usual loop
rsync -avP ~/Music/newstuff/ music-box:/srv/incoming/
ssh music-box
beet import /srv/incoming
```
Navidrome picks up the result on its next scan.
The single most important line in that config is
`paths.default: $albumartist/...`. Organising by `$artist` instead scatters
every compilation and every "feat." track into a phantom artist, which is how a
library ends up unusable and Navidrome gets blamed for it.
ReplayGain and lyrics are set `auto: no` — on one core they turn a two-minute
import into a twenty-minute one. Run `beet replaygain` and `beet lyrics`
afterwards, separately.
### Ownership, which is the fiddly part
`/srv/music` is `counterweight:navidrome` mode **2750**. The setgid bit is
load-bearing: without it, files beets creates land in the curator's own group,
Navidrome cannot read them, and the symptom is an **empty library rather than a
permission error**.
## Monitoring
| Layer | What it answers | Where |
|---|---|---|
| `infra/400` | is the host alive, is the disk filling | automatic, needs `gatus_push_tokens.music_box_local` in the vault |
| `infra/401` | is the `navidrome` unit active | `monitored_services` in `host_vars/music_box_local/main.yml` |
| `infra/403` + this role | does Navidrome actually serve its own UI (`/app/`) | `probe_navidrome` |
| `infra/402` | does `music.contrapeso.xyz` answer from outside, is the cert valid | `public_sites` |
The role's own check fetches `/app/` and greps for `navidrome`. A unit can be
perfectly `active` while serving nothing useful.
`/app/` was chosen after testing all three candidates against 0.64.0:
`/ping` returns a bare `.` (proves only that something listens);
`/rest/ping.view` identifies the server but logs two warnings per call,
~576 a day; `/app/` returns `<title>Navidrome</title>` and logs nothing.
## Upgrading
```bash
curl -sL https://github.com/navidrome/navidrome/releases/download/v<ver>/navidrome_checksums.txt \
| grep linux_amd64.tar.gz
```
Bump `navidrome_version` and `navidrome_sha256` **together** in
`defaults/main.yml`, then re-run the playbook. The install is version-gated, so
an unchanged version is a no-op.
## Sizing
1 core / 1 GB / 50 GB. Two things to know:
- **50 GB is the library ceiling**, not just the OS — `/srv/music` is on the root
disk. Roughly 300–450 albums in FLAC. Growing it means a Proxmox resize plus
`growpart`/`resize2fs` (see `03_vm_disk_enlargement.md`), because
`ignore_changes = all` in the tofu config means tofu will not do it.
- **1 GB is fine for serving, tight for scanning.** The initial scan of a large
library is the memory peak. If it gets OOM-killed, do what `monerod-box`
needed: raise it in the Proxmox UI and record it back in `vms.auto.tfvars`.
Transcoding is the CPU cost, and it is avoidable: clients that direct-play
(Symfonium, Amperfy) make the server a file shipper. Roku and browsers transcode.

View file

@ -1,85 +0,0 @@
# Navidrome Configuration Variables
# https://www.navidrome.org/docs/usage/configuration-options/
# --- Version ----------------------------------------------------------------
# Bump navidrome_version and navidrome_sha256 TOGETHER. The hash is the one
# for the linux_amd64 tarball in that release's navidrome_checksums.txt:
# curl -sL https://github.com/navidrome/navidrome/releases/download/v<ver>/navidrome_checksums.txt \
# | grep linux_amd64.tar.gz
navidrome_version: "0.64.0"
navidrome_arch: "linux_amd64"
navidrome_sha256: "efd94d11253234035b5d3cf284da917111736e72daeeda4f45a58251c60553ac"
navidrome_url: "https://github.com/navidrome/navidrome/releases/download/v{{ navidrome_version }}/navidrome_{{ navidrome_version }}_{{ navidrome_arch }}.tar.gz"
# --- Service user -----------------------------------------------------------
navidrome_user: navidrome
navidrome_group: navidrome
# --- Paths ------------------------------------------------------------------
navidrome_bin_path: /usr/local/bin/navidrome
navidrome_config_dir: /etc/navidrome
navidrome_config_file: "{{ navidrome_config_dir }}/navidrome.toml"
# SQLite database, cache, transcoding scratch. The only thing Navidrome writes.
navidrome_data_dir: /var/lib/navidrome
# The canonical library. Navidrome READS this and never writes it, which is why
# it is absent from ReadWritePaths in the unit. beets is what writes here.
navidrome_music_dir: /srv/music
# Staging area. Whatever lands here is unsorted and has NOT been through beets;
# Navidrome never looks at it.
navidrome_incoming_dir: /srv/incoming
# The human who curates the library with beets. Owns both directories above;
# navidrome only needs group read.
navidrome_curator_user: "{{ new_user }}"
# --- HTTP -------------------------------------------------------------------
# 0.0.0.0, not loopback: the edge host reaches this across the tailnet. UFW
# denies the port from the LAN and the internet, and tailnet traffic bypasses
# UFW entirely (-A INPUT -j ts-input sits ahead of every ufw-* chain), so
# binding wide here does not widen exposure. Confirmed on mempool-box.
navidrome_bind_address: "0.0.0.0"
navidrome_port: 4533
navidrome_log_level: info
# Navidrome's telemetry. Off.
navidrome_enable_insights: false
# --- Secrets ----------------------------------------------------------------
# Navidrome CANNOT store user passwords as a one-way hash: the Subsonic API
# authenticates with md5(password + salt), so the server has to be able to
# recover the plaintext. It encrypts them at rest with this key instead.
#
# Consequences, both real:
# - root on this box means every Navidrome password in plaintext. Use a
# password unique to this service and nowhere else.
# - CHANGING THIS KEY INVALIDATES EVERY STORED PASSWORD. Set it before the
# first account exists and then leave it alone.
# Injected by the playbook from group_vars/all/vault.yml; empty here so the
# role stays self-contained and asserts loudly rather than starting insecure.
navidrome_password_encryption_key: ""
# Set true to see the rendered config in --diff. It carries the key above, so
# the template is diff-suppressed by default.
navidrome_reveal_config: false
# --- beets ------------------------------------------------------------------
# beets is NOT a service. It is an interactive CLI the curator runs over SSH to
# fix tags before Navidrome ever indexes them; there is no unit and no timer.
navidrome_beets_enabled: true
navidrome_beets_dir: /var/lib/beets
navidrome_beets_config: "{{ '/home/' ~ navidrome_curator_user ~ '/.config/beets/config.yaml' }}"
# --- Health check -----------------------------------------------------------
# "Does Navidrome actually answer", as opposed to infra/401's "is the unit
# active". The exit code is the answer and systemd keeps it:
# systemctl is-failed navidrome-healthcheck.service
navidrome_healthcheck_script_path: /usr/local/bin/navidrome-healthcheck-push.sh
navidrome_healthcheck_service_name: navidrome-healthcheck
# WHERE TO REPORT HEALTH — the one place to plug in monitoring.
# Empty means check, log, exit honestly, report nowhere.
healthcheck_push_url: ""
# Bearer token for the Gatus external endpoint. Required whenever a push URL
# is set: Gatus rejects an unauthenticated push with 401.
healthcheck_push_token: ""

View file

@ -1,16 +0,0 @@
---
# NO `when:` on either of these, ever. The uptime_kuma_enabled banner was once
# applied to contiguous blocks and took Fulcrum's entire restart handler with
# it: config changes landed on disk and silently never took effect for months.
# A handler that can be gated off is a handler that will be.
- name: Restart navidrome
systemd:
name: navidrome
state: restarted
daemon_reload: yes
- name: Restart navidrome health check timer
systemd:
name: navidrome-healthcheck.timer
state: restarted
daemon_reload: yes

View file

@ -1,70 +0,0 @@
---
# beets: the tag fixer that runs BEFORE Navidrome sees anything.
#
# Navidrome has no metadata fetcher and no tag editor by design - it trusts the
# tags on disk completely. That is the right call, but it means a library with
# bad tags renders as a bad library and there is nothing in Navidrome to fix it
# with. beets is the fix, and it belongs on this box because that is where the
# files are.
#
# There is no unit and no timer here on purpose: `beet import` is interactive,
# it MOVES and RENAMES files, and a cron job that reorganises a music library
# unattended is a way to lose one.
- name: Install beets and its fingerprinting dependencies
apt:
name:
- beets
# fpcalc. Without it the chroma plugin cannot acoustically identify a
# file whose tags are too mangled to match, which is exactly the case
# beets is most useful for.
- libchromaprint-tools
- python3-acoustid
# lastgenre imports pylast, and Debian's beets package does NOT depend on
# it. Without this the plugin fails to load and every single `beet`
# invocation prints a ModuleNotFoundError traceback while genres are
# silently never fetched - configured, listed in `plugins:`, and inert.
- python3-pylast
state: present
update_cache: true
when: navidrome_beets_enabled | bool
- name: Create the beets state directory
ansible.builtin.file:
path: "{{ navidrome_beets_dir }}"
state: directory
owner: "{{ navidrome_curator_user }}"
group: "{{ navidrome_curator_user }}"
mode: '0750'
when: navidrome_beets_enabled | bool
# Both levels explicitly, parent first. The file module creates missing parents
# like `mkdir -p`, but it creates them owned by ROOT - which would leave
# ~/.config root-owned on a fresh cloud-init box and break every other
# user-level tool that wants to write there later.
- name: Create the curator's XDG config directory
ansible.builtin.file:
path: "{{ navidrome_beets_config | dirname | dirname }}"
state: directory
owner: "{{ navidrome_curator_user }}"
group: "{{ navidrome_curator_user }}"
mode: '0755'
when: navidrome_beets_enabled | bool
- name: Create the beets config directory
ansible.builtin.file:
path: "{{ navidrome_beets_config | dirname }}"
state: directory
owner: "{{ navidrome_curator_user }}"
group: "{{ navidrome_curator_user }}"
mode: '0755'
when: navidrome_beets_enabled | bool
- name: Write the beets configuration
ansible.builtin.template:
src: beets-config.yaml.j2
dest: "{{ navidrome_beets_config }}"
owner: "{{ navidrome_curator_user }}"
group: "{{ navidrome_curator_user }}"
mode: '0644'
when: navidrome_beets_enabled | bool

View file

@ -1,48 +0,0 @@
---
# "Does Navidrome actually answer", as opposed to infra/401's "is the unit
# active". Navidrome can be perfectly `active` and serving 500s - a music
# folder it cannot read does exactly that, and presents as an empty library.
- name: Create the navidrome health check script
ansible.builtin.template:
src: healthcheck.sh.j2
dest: "{{ navidrome_healthcheck_script_path }}"
owner: root
group: root
mode: '0755'
validate: "bash -n %s"
# 0600: carries the Gatus bearer token.
- name: Create the navidrome health check systemd service
ansible.builtin.template:
src: healthcheck.service.j2
dest: "/etc/systemd/system/{{ navidrome_healthcheck_service_name }}.service"
owner: root
group: root
mode: '0600'
notify: Restart navidrome health check timer
- name: Create the navidrome health check systemd timer
ansible.builtin.template:
src: healthcheck.timer.j2
dest: "/etc/systemd/system/{{ navidrome_healthcheck_service_name }}.timer"
owner: root
group: root
mode: '0644'
notify: Restart navidrome 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 navidrome health check timer
systemd:
name: "{{ navidrome_healthcheck_service_name }}.timer"
enabled: yes
state: restarted
daemon_reload: yes
- name: Run the navidrome health check once to arm the timer
ansible.builtin.command: "systemctl start {{ navidrome_healthcheck_service_name }}.service"
changed_when: false

View file

@ -1,134 +0,0 @@
---
# The user, the directories, and the binary.
- name: Install Navidrome's runtime dependencies
apt:
name:
# Navidrome shells out to ffmpeg for on-the-fly transcoding. Without it
# the server starts fine and every format the client cannot play natively
# simply fails, which looks like a client bug.
- ffmpeg
- curl
state: present
update_cache: true
- name: Create the navidrome system group
group:
name: "{{ navidrome_group }}"
system: yes
state: present
- name: Create the navidrome system user
user:
name: "{{ navidrome_user }}"
group: "{{ navidrome_group }}"
system: yes
shell: /usr/sbin/nologin
home: "{{ navidrome_data_dir }}"
create_home: no
comment: "Navidrome Music Server"
state: present
- name: Create the navidrome data directory
ansible.builtin.file:
path: "{{ navidrome_data_dir }}"
state: directory
owner: "{{ navidrome_user }}"
group: "{{ navidrome_group }}"
mode: '0750'
- name: Create the navidrome config directory
ansible.builtin.file:
path: "{{ navidrome_config_dir }}"
state: directory
owner: root
group: "{{ navidrome_group }}"
mode: '0750'
# Two principals share this tree and neither is root: the curator writes it
# through beets, Navidrome only reads it. setgid (the 2) is load-bearing -
# without it, files beets creates land in the curator's own group and Navidrome
# cannot read them, which presents as an empty library rather than an error.
- name: Create the music library directory
ansible.builtin.file:
path: "{{ navidrome_music_dir }}"
state: directory
owner: "{{ navidrome_curator_user }}"
group: "{{ navidrome_group }}"
mode: '2750'
- name: Create the incoming staging directory
ansible.builtin.file:
path: "{{ navidrome_incoming_dir }}"
state: directory
owner: "{{ navidrome_curator_user }}"
group: "{{ navidrome_curator_user }}"
mode: '0755'
# check_mode: no on the next two - this repo verifies with `--check --diff`, so
# the role has to survive a dry run. A read-only version probe and a temp
# directory are both harmless to perform for real, and without them every task
# below evaluates against an undefined navidrome_tmp.path and the check aborts.
- name: Check the installed navidrome version
ansible.builtin.command: "{{ navidrome_bin_path }} --version"
register: navidrome_installed_version
changed_when: false
failed_when: false
check_mode: no
- name: Decide whether navidrome needs installing
ansible.builtin.set_fact:
navidrome_needs_install: "{{ navidrome_version not in (navidrome_installed_version.stdout | default('')) }}"
- name: Create a temporary directory for the navidrome download
ansible.builtin.tempfile:
state: directory
suffix: navidrome
register: navidrome_tmp
when: navidrome_needs_install | bool
check_mode: no
# checksum: is the verification. Upstream publishes navidrome_checksums.txt per
# release but signs nothing, so there is no signature to check the way
# roles/monero checks binaryfate's - this pins the exact artefact and no more.
- name: Download the navidrome release tarball
ansible.builtin.get_url:
url: "{{ navidrome_url }}"
dest: "{{ navidrome_tmp.path }}/navidrome.tar.gz"
checksum: "sha256:{{ navidrome_sha256 }}"
mode: '0644'
when: navidrome_needs_install | bool and not ansible_check_mode
- name: Unpack the navidrome tarball
ansible.builtin.unarchive:
src: "{{ navidrome_tmp.path }}/navidrome.tar.gz"
dest: "{{ navidrome_tmp.path }}"
remote_src: yes
when: navidrome_needs_install | bool and not ansible_check_mode
- name: Install the navidrome binary
ansible.builtin.copy:
src: "{{ navidrome_tmp.path }}/navidrome"
dest: "{{ navidrome_bin_path }}"
remote_src: yes
owner: root
group: root
mode: '0755'
when: navidrome_needs_install | bool and not ansible_check_mode
notify: Restart navidrome
- name: Remove the navidrome download directory
ansible.builtin.file:
path: "{{ navidrome_tmp.path }}"
state: absent
when: navidrome_needs_install | bool
check_mode: no
# Check mode skips the three tasks above, so without this a dry run is silent
# about the single most important thing it would do.
- name: Report what a real run would install
ansible.builtin.debug:
msg: >-
Would install navidrome {{ navidrome_version }} to {{ navidrome_bin_path }}
(currently: {{ navidrome_installed_version.stdout | default('not installed') | trim }})
when: navidrome_needs_install | bool and ansible_check_mode

View file

@ -1,7 +0,0 @@
---
# import_tasks, not include_tasks: static imports stay visible to --list-tasks,
# which is the main verification tool in this repo.
- ansible.builtin.import_tasks: install.yml
- ansible.builtin.import_tasks: beets.yml
- ansible.builtin.import_tasks: service.yml
- ansible.builtin.import_tasks: healthcheck.yml

View file

@ -1,67 +0,0 @@
---
# Config, unit, start, prove it is up.
# The key encrypts every stored password. Starting without it would come up
# fine and quietly use a built-in default, so this fails the run instead.
- name: Assert the password encryption key arrived from the vault
ansible.builtin.assert:
that:
- navidrome_password_encryption_key | length > 0
fail_msg: >-
navidrome_password_encryption_key is empty. It is injected by
services/navidrome/deploy_navidrome_playbook.yml from
group_vars/all/vault.yml. Navidrome stores user passwords REVERSIBLY
(the Subsonic API needs the plaintext to compute md5(password+salt)),
and this key is what encrypts them at rest. Set it before the first
account exists - changing it later invalidates every stored password.
quiet: yes
- name: Write the navidrome configuration
ansible.builtin.template:
src: navidrome.toml.j2
dest: "{{ navidrome_config_file }}"
owner: root
group: "{{ navidrome_group }}"
mode: '0640'
diff: "{{ navidrome_reveal_config | default(false) | bool }}"
notify: Restart navidrome
- name: Create the navidrome systemd unit
ansible.builtin.template:
src: navidrome.service.j2
dest: /etc/systemd/system/navidrome.service
owner: root
group: root
mode: '0644'
notify: Restart navidrome
- name: Reload systemd daemon
systemd:
daemon_reload: yes
- name: Enable and start navidrome
systemd:
name: navidrome
enabled: yes
state: started
- name: Flush handlers so navidrome is running before we probe it
meta: flush_handlers
- name: Wait for the navidrome HTTP port
ansible.builtin.wait_for:
port: "{{ navidrome_port }}"
host: 127.0.0.1
delay: 5
timeout: 300
ignore_errors: yes
- name: Check the navidrome service status
systemd:
name: navidrome
register: navidrome_status
changed_when: false
- name: Display the navidrome service status
ansible.builtin.debug:
msg: "navidrome is {{ 'running' if navidrome_status.status.ActiveState == 'active' else 'NOT running' }}"

View file

@ -1,81 +0,0 @@
# beets configuration — managed by Ansible (roles/navidrome)
# https://beets.readthedocs.io/
#
# Run it by hand, over SSH, when material has accumulated in {{ navidrome_incoming_dir }}:
# beet import {{ navidrome_incoming_dir }}
#
# It is interactive on purpose. `beet import -q` auto-applies only strong
# matches and leaves the rest where they are, which makes a usable review queue
# if the batches ever get boring.
directory: {{ navidrome_music_dir }}
library: {{ navidrome_beets_dir }}/library.db
# $albumartist, NOT $artist. This is the single most important line in the
# file: organising by $artist scatters every compilation and every "feat."
# track into its own phantom artist, which is how a library ends up with four
# hundred one-album artists and Navidrome gets blamed for it.
paths:
default: $albumartist/$album%aunique{}/$track $title
singleton: Non-Album/$artist/$title
comp: Compilations/$album%aunique{}/$track $title
albumtype:soundtrack: Soundtracks/$album/$track $title
import:
# MOVES files out of {{ navidrome_incoming_dir }} into the library. Set
# `copy: yes` and `move: no` instead if you would rather keep originals
# until you trust it.
move: yes
write: yes
languages: en es
log: {{ navidrome_beets_dir }}/import.log
plugins: chroma fetchart embedart scrub duplicates mbsync missing info lastgenre replaygain lyrics permissions
# THE trap this library is exposed to, and the fix for it.
#
# beets MOVES files into place, and rename(2) preserves the original group and
# mode - so the setgid bit on {{ navidrome_music_dir }} does NOT apply to them
# (it only affects files CREATED there, such as the album directories beets
# makes, which do come out group-{{ navidrome_group }}). A track that arrives
# mode 0600 therefore lands mode 0600 owned by the curator's own group, and
# Navidrome cannot read it. It does not error: the track is simply ABSENT from
# the library, which is indistinguishable from never having imported it.
#
# Measured on this box: a 0600 file was unreadable by the navidrome user; the
# same file at 0644 was fine. So normalise every imported file.
# NO leading zeros. The plugin does int(str(value), 8), and YAML would parse
# `0644` as octal -> decimal 420, which the plugin then re-reads as octal 0o420.
# Verified on the box: `0644` silently left a 0600 file at 0600; `644` fixes it.
permissions:
file: 644
dir: 755
# Acoustic fingerprinting: identifies a file from the audio itself when the
# tags are too mangled to match. Needs fpcalc, installed by the role.
chroma:
auto: yes
fetchart:
auto: yes
embedart:
auto: yes
# Strip junk and stale tags before writing clean ones.
scrub:
auto: yes
lastgenre:
auto: yes
source: album
# auto: no on both of these deliberately. This box has one core; ReplayGain
# analysis and lyrics fetching during an import turn a two-minute job into a
# twenty-minute one. Run them separately, after the import, when nothing is
# waiting on you:
# beet replaygain
# beet lyrics
replaygain:
auto: no
backend: ffmpeg
lyrics:
auto: no

View file

@ -1,18 +0,0 @@
[Unit]
Description=Navidrome Health Check
After=network.target navidrome.service
[Service]
Type=oneshot
User=root
ExecStart={{ navidrome_healthcheck_script_path }}
# Comfortably longer than the script's 30s curl plus its push retries.
TimeoutStartSec=120
Environment=HEALTHCHECK_PUSH_URL={{ healthcheck_push_url }}
Environment=HEALTHCHECK_PUSH_TOKEN={{ healthcheck_push_token }}
Environment=NAVIDROME_URL=http://127.0.0.1:{{ navidrome_port }}
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View file

@ -1,65 +0,0 @@
#!/bin/bash
# Navidrome health check — managed by Ansible (roles/navidrome)
#
# The exit code is the answer and systemd keeps it:
# systemctl is-failed {{ navidrome_healthcheck_service_name }}.service
# Reporting anywhere else is optional and generic.
#
# /app/ rather than /ping, established by testing against 0.64.0 on the box:
#
# /ping 200, Content-Type text/plain, body is a single "." - it
# proves something is listening and nothing more.
# /rest/ping.view identifies itself ("type":"navidrome") but logs TWO
# warning lines per call. At one call per 5 minutes that is
# ~576 spurious warnings a day, which is how you train
# yourself to ignore the warning level.
# /app/ 200, 2.8 KB, <title>Navidrome</title>, and SILENT.
#
# So /app/ is the only one that proves it is really Navidrome serving its real
# UI without polluting the journal. Same reasoning as the xmrblocks check,
# which fetches a real page because a missing templates dir serves 200s of
# nothing.
URL="${NAVIDROME_URL:-http://127.0.0.1:{{ navidrome_port }}}"
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}"
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
}
# 30s: Navidrome answers /ping in milliseconds normally, but this box has one
# core and a library scan will happily eat it. A tight timeout here would
# manufacture false DOWNs every time a scan runs.
body=$(curl -s --max-time 30 -w '\n%{http_code}' "${URL}/app/" 2>/dev/null)
code=$(printf '%s' "$body" | tail -1)
if [ -z "$code" ] || [ "$code" = "000" ]; then
report "down" "navidrome not responding"
exit 1
fi
if [ "$code" != "200" ]; then
report "down" "navidrome HTTP ${code}"
exit 1
fi
if ! printf '%s' "$body" | grep -qi "navidrome"; then
report "down" "navidrome served 200 but not its own UI (something else on the port?)"
exit 1
fi
report "up" "OK"
exit 0

View file

@ -1,14 +0,0 @@
[Unit]
Description=Navidrome Health Check Timer
# Deliberately NO `Requires=navidrome.service`. Requires on a timer stops the
# timer when the watched unit stops - "if the thing I am watching goes down,
# stop watching it" - leaving nothing to re-arm when it returns. Fulcrum's
# timer carried it and sat active+enabled for seven months without firing.
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -1,33 +0,0 @@
[Unit]
Description=Navidrome Music Server
Documentation=https://www.navidrome.org/docs/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ navidrome_user }}
Group={{ navidrome_group }}
WorkingDirectory={{ navidrome_data_dir }}
ExecStart={{ navidrome_bin_path }} --configfile {{ navidrome_config_file }}
Restart=always
RestartSec=20
TimeoutStopSec=60
StandardOutput=journal
StandardError=journal
# Hardening: Navidrome writes its database and cache, and nothing else.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
# {{ navidrome_music_dir }} is deliberately absent: ProtectSystem=strict leaves
# it read-only, which is exactly right. Navidrome indexes the library; beets
# writes it. If Navidrome ever appears to need write access here, something is
# wrong with the request, not with this line.
ReadWritePaths={{ navidrome_data_dir }}
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target

View file

@ -1,24 +0,0 @@
# Navidrome configuration — managed by Ansible (roles/navidrome)
# https://www.navidrome.org/docs/usage/configuration-options/
#
# Mode 0640 root:{{ navidrome_group }}: PasswordEncryptionKey is below.
MusicFolder = "{{ navidrome_music_dir }}"
DataFolder = "{{ navidrome_data_dir }}"
Address = "{{ navidrome_bind_address }}"
Port = {{ navidrome_port }}
LogLevel = "{{ navidrome_log_level }}"
# See the long note in defaults/main.yml. Changing this invalidates every
# stored password.
PasswordEncryptionKey = "{{ navidrome_password_encryption_key }}"
EnableInsightsCollector = {{ navidrome_enable_insights | bool | lower }}
# Scan schedule is left at Navidrome's own default deliberately. The option has
# moved between releases (top-level ScanSchedule vs a [Scanner] section), and
# pinning the wrong spelling here is silently ignored rather than rejected -
# you would get the default anyway and believe you had configured something.
# Check `navidrome config` on the box before adding it.

View file

@ -73,9 +73,3 @@ 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: ""
# WHEN TO CHECK. Daily, 30 minutes after monerod's own check so the two do not
# contend for the same cold LMDB pages. See roles/monero/defaults for why the
# name is prefixed and why this is paired with the 26h heartbeat in
# infra/403_service_probe_registration.yml.
xmrblocks_healthcheck_on_calendar: "*-*-* 05:30:00"

View file

@ -7,14 +7,7 @@ Description=Onion Monero Blockchain Explorer Health Check Timer
[Timer]
OnBootSec=2min
# ONCE A DAY, not every 5 minutes. See the note in roles/monero's timer: the
# explorer follows the node it reads from, and this check costs a full page
# render out of the LMDB (a cold one measured 68 seconds), so polling it hard
# is not free either. The Gatus heartbeat window is 26h, set in
# infra/403_service_probe_registration.yml, and MUST stay above this interval.
OnCalendar={{ xmrblocks_healthcheck_on_calendar }}
# Spread against the other daily pushes leaving this box.
RandomizedDelaySec=900
OnUnitActiveSec=5min
Persistent=true
[Install]

View file

@ -22,21 +22,6 @@
number: "{{ signal_number }}"
recipients: "{{ signal_recipients }}"
default-alert:
# ── SIGNAL ALERTING IS OFF ──────────────────────────────────────
# Flip to true to turn the phone back on. Nothing else needs to
# change: this ONE key disables every Signal alert in the estate.
#
# It works because `enabled` is the only alert field no registration
# playbook sets. Gatus merges a provider's default-alert into each
# endpoint's alert for every field the endpoint left unset, so this
# reaches all ~90 of them without editing a single endpoint - and
# without unregistering anything. The dashboard keeps checking and
# keeps showing red; it just stops sending.
#
# Deliberately NOT done by deleting the `signal:` provider block:
# that would strand ~90 endpoints referencing a provider that no
# longer exists, and lose the number and recipients.
enabled: false
# Overridden per group by the registration playbooks; these are the
# values that apply if a caller sets nothing.
failure-threshold: 1

View file

@ -42,7 +42,6 @@
"tag:fulcrum-box:50002",
"tag:mempool-box:8080",
"tag:memos-box:5230",
"tag:music-box:4533",
"tag:monerod-box:18089",
"tag:monerod-box:8081",
"tag:arbret-staging-box:80",
@ -114,9 +113,6 @@
"tag:monerod-box": [
"{{ headscale_namespace }}@"
],
"tag:music-box": [
"{{ headscale_namespace }}@"
],
"tag:nodito": [
"{{ headscale_namespace }}@"
],

View file

@ -1,163 +0,0 @@
---
# Navidrome + beets on music-box, published at music.contrapeso.xyz.
#
# See roles/navidrome/README.md for the exposure and backup decisions. The two
# that matter here: no basic_auth in front (the Subsonic API the mobile clients
# speak has nowhere to carry a second credential), and the fail2ban jail below
# covers the web UI only.
- name: Deploy Navidrome on music-box
hosts: music
become: yes
vars:
# Vault key is navidrome_encryption_key, not the role's own variable name -
# assigning a var to itself is a recursive reference, not an override.
navidrome_password_encryption_key: "{{ navidrome_encryption_key }}"
healthcheck_push_url: "https://{{ subdomains.gatus }}.{{ root_domain }}/api/v1/endpoints/probe_navidrome/external"
healthcheck_push_token: "{{ gatus_push_tokens[inventory_hostname] }}"
roles:
- navidrome
- name: Publish Navidrome from the edge host
hosts: edge
become: yes
vars:
navidrome_access_log: /var/log/caddy/music.log
tasks:
# The Debian package usually creates this, but `caddy validate` passes with
# the directory missing and the RELOAD is what fails - so the vhost would
# look deployed and serve nothing. Make it explicit.
- name: Ensure the Caddy log directory exists
ansible.builtin.file:
path: "{{ navidrome_access_log | dirname }}"
state: directory
owner: caddy
group: caddy
mode: '0750'
# The FILE, not just the directory, and this is load-bearing.
#
# `caddy validate` below runs as root and provisions the logging module,
# which CREATES this file root:root 0600. Caddy itself runs as the caddy
# user, so the subsequent reload then dies with:
# loading new config: setting up custom log 'log0': ...
# open /var/log/caddy/music.log: permission denied
# and - the nasty part - `systemctl reload` hangs rather than failing,
# leaving the unit stuck in "reloading" while the OLD config keeps serving.
# So the vhost looks deployed, validate passes, nothing reports an error,
# and the site is simply absent.
#
# Creating it up front with the right owner makes validate's open a no-op.
- name: Ensure the Caddy access log file exists and is owned by caddy
ansible.builtin.file:
path: "{{ navidrome_access_log }}"
state: touch
owner: caddy
group: caddy
mode: '0640'
access_time: preserve
modification_time: preserve
# caddy_site_body rather than caddy_site_upstream + caddy_site_resolvers:
# this vhost needs a `log` directive so fail2ban has something to read, and
# the role's template has no knob for one. The escape hatch is the
# documented way to do this - gatus and headscale already use it - and it
# leaves the other vhosts untouched.
- name: Expose Navidrome over HTTPS
ansible.builtin.include_role:
name: caddy_site
vars:
caddy_site_name: music
caddy_site_domain: "{{ subdomains.music }}.{{ root_domain }}"
caddy_site_body: |
log {
output file {{ navidrome_access_log }} {
roll_size 10MiB
roll_keep 5
}
format json
}
reverse_proxy {{ hostvars['music_box_local'].ansible_host }}:{{ hostvars['music_box_local'].navidrome_port }} {
# Tailscale MagicDNS: music-box has no public address.
transport http {
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
# ── fail2ban ────────────────────────────────────────────────────────────
# The estate's first jail: 02_firewall_and_fail2ban_playbook.yml installs
# fail2ban everywhere but leaves it on stock config. Kept inline here
# because one jail does not justify a role; if a second service ever wants
# one, that is the moment to promote this to roles/fail2ban_jail alongside
# caddy_site and gatus_endpoint.
#
# Scope, stated so it is not mistaken for more than it is: this catches web
# UI brute force (401 on /auth/login). It does NOT catch Subsonic API brute
# force, because Subsonic answers a bad password with HTTP 200 and the error
# in the body. See roles/navidrome/README.md.
- name: Install the navidrome fail2ban filter
ansible.builtin.copy:
dest: /etc/fail2ban/filter.d/navidrome-caddy.conf
owner: root
group: root
mode: '0644'
content: |
# Managed by Ansible - services/navidrome/deploy_navidrome_playbook.yml
#
# The log file is per-vhost, so any 401 in it is a failed Navidrome
# login and needs no path matching.
[Definition]
failregex = "remote_ip":"<HOST>".*"status":401
ignoreregex =
notify: Restart fail2ban
- name: Install the navidrome fail2ban jail
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/navidrome.conf
owner: root
group: root
mode: '0644'
content: |
# Managed by Ansible - services/navidrome/deploy_navidrome_playbook.yml
[navidrome]
enabled = true
filter = navidrome-caddy
logpath = {{ navidrome_access_log }}
# ufw, not the Debian default iptables-multiport: UFW is what manages
# the firewall on every host here (infra/02_firewall_and_fail2ban).
banaction = ufw
maxretry = 5
findtime = 10m
bantime = 1h
notify: Restart fail2ban
- name: Ensure fail2ban is enabled and running
systemd:
name: fail2ban
enabled: yes
state: started
- name: Flush handlers so the jail is loaded before we assert on it
meta: flush_handlers
# A jail that silently failed to load is worse than no jail: it reads as
# protection in `ls /etc/fail2ban/jail.d` and bans nothing.
- name: Assert the navidrome jail is actually active
ansible.builtin.command: fail2ban-client status navidrome
register: navidrome_jail_status
changed_when: false
failed_when: navidrome_jail_status.rc != 0
- name: Display the navidrome jail status
ansible.builtin.debug:
var: navidrome_jail_status.stdout_lines
handlers:
- name: Restart fail2ban
systemd:
name: fail2ban
state: restarted

View file

@ -56,7 +56,6 @@
- import_playbook: services/monero/deploy_monero_playbook.yml
- import_playbook: services/xmrblocks/deploy_xmrblocks_playbook.yml
- import_playbook: services/memos/deploy_memos_playbook.yml
- import_playbook: services/navidrome/deploy_navidrome_playbook.yml
- import_playbook: services/forgejo-runner/deploy_forgejo_runner_playbook.yml
- import_playbook: services/phoenixd/deploy_phoenixd_playbook.yml
- import_playbook: services/headscale/deploy_headscale_playbook.yml

View file

@ -1,189 +0,0 @@
#!/usr/bin/env bash
#
# Add music to music-box: push it, tag it with beets, let Navidrome see it.
#
# WHY THIS SCRIPT EXISTS
# ----------------------
# The three steps are simple individually and easy to get subtly wrong together:
#
# 1. rsync to /srv/incoming, NOT to /srv/music. Navidrome reads /srv/music and
# beets is the only thing that should ever write there. Dropping files
# straight into the library skips tagging entirely, which is the whole
# point of having beets.
# 2. `beet import` is INTERACTIVE, so the ssh needs a TTY (-t). Without it the
# prompt never renders and the import appears to hang.
# 3. beets must run as counterweight. Under sudo it finds no config (the
# config lives in ~counterweight), silently falls back to its defaults and
# writes to a SECOND library database in root's home - so you end up with
# files in the wrong place and two competing indexes.
#
# WHAT IT DOES NOT DO
# It does not touch your local files. rsync copies; the `move: yes` in the
# beets config only moves things around ON THE BOX, from /srv/incoming into
# /srv/music. Your originals on this machine are never modified or deleted.
#
# Usage: scripts/add-music.sh [path] (prompts for the path if omitted)
# Needs: rsync and ssh here, tailnet up (music-box resolves via MagicDNS)
set -euo pipefail
MUSIC_HOST="${MUSIC_HOST:-music-box}"
REMOTE_USER="${MUSIC_USER:-counterweight}"
INCOMING="/srv/incoming"
# Pre-filled into the prompt, editable in place. $HOME rather than a literal
# /home/counterweight so the script still behaves if it is ever run as anyone
# else; for the usual user it resolves to exactly /home/counterweight/music.
DEFAULT_SRC="${MUSIC_LOCAL_DIR:-$HOME/music}"
SSH_KEY="${MUSIC_SSH_KEY:-$HOME/.ssh/counterganzua}"
TARGET="${REMOTE_USER}@${MUSIC_HOST}"
# Colours, but only when we are actually on a terminal.
if [ -t 1 ]; then
B=$'\033[1m'; DIM=$'\033[2m'; R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; N=$'\033[0m'
else
B=""; DIM=""; R=""; G=""; Y=""; N=""
fi
say() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
warn() { printf '%s==>%s %s\n' "$Y" "$N" "$*" >&2; }
die() { printf '%sERROR:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
# -n is load-bearing: without it ssh reads OUR stdin and swallows the answers
# meant for the read prompts below, so "Continue? [y/N]" gets EOF instead of
# your keypress. Under `set -e` that aborts the script with no message at all.
ssh_run() { ssh -n -i "$SSH_KEY" -o ConnectTimeout=15 "$TARGET" "$@"; }
# -t for anything that talks to a human (beets prompts, sudo password). No -n
# here - these deliberately DO want your terminal.
ssh_tty() { ssh -t -i "$SSH_KEY" -o ConnectTimeout=15 "$TARGET" "$@"; }
# --- 0. Preflight ------------------------------------------------------------
command -v rsync >/dev/null || die "rsync is not installed here."
[ -f "$SSH_KEY" ] || die "SSH key not found: $SSH_KEY (override with MUSIC_SSH_KEY=)"
say "Checking ${MUSIC_HOST} is reachable..."
if ! ssh_run -o BatchMode=yes true 2>/dev/null; then
die "Cannot reach ${TARGET}.
Is the tailnet up? tailscale status | grep ${MUSIC_HOST}
MagicDNS resolving? getent hosts ${MUSIC_HOST}"
fi
# --- 1. Where is the music? --------------------------------------------------
SRC="${1:-}"
if [ -z "$SRC" ]; then
printf '\n%sPath to the album or folder to add%s %s(edit or tab-complete)%s\n' "$B" "$N" "$DIM" "$N"
# -e gives readline (tab completion, arrow keys); -i pre-fills the buffer
# with the default so it is already typed and editable in place.
# -i is ignored when stdin is not a terminal, hence the ${SRC:-} fallback:
# piping into this script, or just pressing Enter, still yields the default.
read -e -i "$DEFAULT_SRC" -r -p "> " SRC || true
SRC="${SRC:-$DEFAULT_SRC}"
fi
# People paste paths with surrounding quotes (drag-and-drop does this) and with
# a trailing slash. Both change rsync's meaning, so normalise before using it.
SRC="${SRC%\"}"; SRC="${SRC#\"}"
SRC="${SRC%\'}"; SRC="${SRC#\'}"
SRC="${SRC/#\~/$HOME}"
while [ "${SRC}" != "/" ] && [ "${SRC: -1}" = "/" ]; do SRC="${SRC%/}"; done
[ -n "$SRC" ] || die "No path given."
[ "$SRC" != "/" ] && [ "$SRC" != "$HOME" ] || die "Refusing to sync '$SRC' - that is almost certainly not what you meant."
[ -e "$SRC" ] || die "Not found: $SRC"
[ -r "$SRC" ] || die "Not readable: $SRC"
BASENAME="$(basename -- "$SRC")"
if [ -d "$SRC" ]; then
COUNT=$(find "$SRC" -type f | wc -l | tr -d ' ')
SIZE=$(du -sh "$SRC" 2>/dev/null | cut -f1)
[ "$COUNT" -gt 0 ] || die "$SRC contains no files."
else
COUNT=1
SIZE=$(du -h "$SRC" 2>/dev/null | cut -f1)
fi
# --- 2. Does it fit? ---------------------------------------------------------
# /srv/incoming and /srv/music share one filesystem, so the beets move is a
# rename and costs nothing extra - but the transfer itself still needs room.
AVAIL=$(ssh_run "df -Pk ${INCOMING} | awk 'NR==2 {print \$4}'" 2>/dev/null || echo 0)
NEED=$(du -sk "$SRC" 2>/dev/null | cut -f1)
if [ "${AVAIL:-0}" -gt 0 ] && [ "${NEED:-0}" -gt "${AVAIL}" ]; then
die "Not enough space on ${MUSIC_HOST}: need $((NEED/1024)) MB, have $((AVAIL/1024)) MB.
The library shares the 50 GB root disk - see roles/navidrome/README.md."
fi
printf '\n'
say "About to add:"
printf ' source %s\n' "$SRC"
printf ' files %s (%s)\n' "$COUNT" "${SIZE:-?}"
printf ' lands in %s:%s/%s\n' "$MUSIC_HOST" "$INCOMING" "$BASENAME"
printf ' free %s MB on the box\n\n' "$((${AVAIL:-0}/1024))"
printf '%sYour local files are only read, never moved or deleted.%s\n\n' "$DIM" "$N"
read -r -p "Continue? [y/N] " REPLY
case "$REPLY" in [yY]*) ;; *) say "Nothing done."; exit 0 ;; esac
# --- 3. Push -----------------------------------------------------------------
printf '\n'
say "Pushing to ${MUSIC_HOST}:${INCOMING}/ ..."
# No trailing slash on "$SRC": that copies the DIRECTORY, not its contents, so
# the album arrives as its own folder and beets sees one album per directory.
# --chmod is not cosmetic. `rsync -a` preserves the SOURCE mode, and beets
# moves files into the library with rename(2), which keeps their group and
# mode. A track that leaves this machine mode 0600 therefore arrives in
# /srv/music mode 0600 owned by the curator's group - and Navidrome cannot read
# it. It does not error; the track is simply absent from the library. The beets
# `permissions` plugin also normalises this on the box; this is the same fix
# applied at the door so /srv/incoming is sane too.
rsync -av --info=progress2 --human-readable --chmod=D755,F644 \
-e "ssh -i $SSH_KEY -o ConnectTimeout=15" \
"$SRC" "${TARGET}:${INCOMING}/"
# --- 4. Tag ------------------------------------------------------------------
printf '\n'
say "Running beets. This is interactive - see the key guide below."
printf '%s A apply M more candidates E enter search I paste MB id%s\n' "$DIM" "$N"
printf '%s U use tags as-is S skip T as tracks G group albums%s\n\n' "$DIM" "$N"
# As counterweight (NOT sudo) so it finds ~/.config/beets/config.yaml.
# `|| true` because beets exits non-zero when you skip or abort an album, and
# that is a normal outcome, not a script failure.
ssh_tty "beet import ${INCOMING}/$(printf '%q' "$BASENAME")" || true
# --- 5. What is left behind? -------------------------------------------------
printf '\n'
LEFT=$(ssh_run "find ${INCOMING} -type f 2>/dev/null | wc -l" | tr -d ' \r')
if [ "${LEFT:-0}" -gt 0 ]; then
warn "${LEFT} file(s) still in ${INCOMING} - skipped, or beets could not match them."
warn "Re-run later with: ssh ${MUSIC_HOST} 'beet import ${INCOMING}'"
warn "Details: ssh ${MUSIC_HOST} 'tail /var/lib/beets/import.log'"
else
printf '%s==>%s %sEverything imported; %s is empty.%s\n' "$B" "$N" "$G" "$INCOMING" "$N"
fi
# --- 6. Optional post-processing ---------------------------------------------
# replaygain and lyrics are auto:no in the beets config on purpose - on a single
# core they turn a 2 minute import into a 20 minute one. Offered here so they do
# not get forgotten entirely, but off by default.
printf '\n'
read -r -p "Run replaygain now? (slow on 1 core) [y/N] " REPLY
case "$REPLY" in [yY]*) say "Running beet replaygain..."; ssh_tty "beet replaygain" || warn "replaygain reported a problem." ;; esac
# --- 7. Let Navidrome see it -------------------------------------------------
printf '\n'
say "Restarting Navidrome so it picks up the new files..."
ssh_tty "sudo systemctl restart navidrome"
say "Waiting for it to come back..."
for i in $(seq 1 20); do
if ssh_run "curl -sf -m 5 -o /dev/null http://127.0.0.1:4533/app/" 2>/dev/null; then
printf '%s==>%s %sNavidrome is up.%s\n' "$B" "$N" "$G" "$N"
break
fi
[ "$i" -eq 20 ] && warn "Navidrome did not answer within ~60s. Check: ssh ${MUSIC_HOST} 'systemctl status navidrome'"
sleep 3
done
# --- 8. Summary --------------------------------------------------------------
printf '\n'
say "Library now holds:"
ssh_run "beet stats" 2>/dev/null | sed 's/^/ /' || warn "could not read beet stats"
printf '\n%s==>%s %sDone.%s https://music.contrapeso.xyz\n' "$B" "$N" "$G" "$N"

View file

@ -89,19 +89,6 @@ vms = {
memory_mb = 2048
disk_size_gb = 20
ipconfig0 = "ip=dhcp"
},
# disk_size_gb here is the music library's ceiling, not just the OS: the
# library lives on the root disk at /srv/music. ~45 GB is usable after
# Debian, so roughly 300-450 albums in FLAC. Growing it means a Proxmox
# resize + growpart/resize2fs (03_vm_disk_enlargement.md), because
# ignore_changes = all means tofu will not do it.
"music-box" = {
vmid = 8200
name = "music-box"
cores = 1
memory_mb = 1024
disk_size_gb = 50
ipconfig0 = "ip=dhcp"
}
}