navidrome: a music server on its own box, with beets in front of it

Navidrome 0.64.0 on music-box (nodito VM 8200, 1 core / 1 GB / 50 GB), public
at music.contrapeso.xyz. A single Go binary with a SQLite database; the only
external dependency is ffmpeg for transcoding. Plus beets, the CLI that fixes
tags before Navidrome ever indexes them.

── Exposed without basic auth, on purpose ─────────────────────────────────
Navidrome speaks two protocols on one port: its web UI, and the Subsonic API
(/rest/*) that every mobile client actually uses. A Subsonic client
authenticates with ?u=&t=md5(password+salt)&s= in the query string and has
nowhere to carry a second credential, so Basic Auth in front of the site is
invisible to most of them. The choice is a proxy credential or a working phone.

Same call this estate already made for monerod's restricted RPC, for the same
reason: proxied bare so Feather can reach it, with the service's own auth doing
the authenticating.

The cost is stated rather than glossed: the login page and the whole /rest/*
surface are reachable by anyone who finds the hostname, and Navidrome has no
rate limiting and no 2FA.

A fail2ban jail on vipy reads a per-vhost Caddy access log and catches WEB UI
brute force, where a bad login returns 401. It very likely does NOT catch
Subsonic brute force, because that protocol answers a bad password with HTTP
200 and puts the failure in the body - which Caddy never sees. Nor can it be
fixed on the music box: the request arrives from vipy over the tailnet, so the
only address fail2ban could ban there 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, and the README
carries a one-line curl to re-check that after every upgrade.

── Passwords are stored reversibly, and cannot not be ─────────────────────
The Subsonic API requires the server to compute md5(password+salt) per request,
so Navidrome cannot hash passwords one-way. It encrypts them at rest with
PasswordEncryptionKey instead. Two real consequences, both in the README: root
on that box means every Navidrome password in plaintext, so the password must
be unique to this service; and changing the key invalidates every stored
password, so it is set before the first account and left alone.

── beets is not a service, deliberately ───────────────────────────────────
`beet import` is interactive and it MOVES AND RENAMES FILES. A timer that
reorganises a music library unattended is a way to lose one. Installed and
configured, run by hand.

/srv/music is counterweight:navidrome mode 2750 and the setgid bit is
load-bearing: without it beets' files land in the curator's own group,
Navidrome cannot read them, and the symptom is an EMPTY LIBRARY rather than a
permission error. ReplayGain and lyrics are auto: no - on one core they turn a
two-minute import into a twenty-minute one.

── Monitoring ─────────────────────────────────────────────────────────────
Host liveness and disk (400), the systemd unit (401), a probe that fetches
/app/ and greps it (403), and public reachability plus cert expiry (402).

/app/ was chosen by testing all three candidates against 0.64.0: /ping returns
a bare "." and 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. 402 asserts 200 on /app/ and not on
"/", because "/" answers 302 - checked live rather than assumed.

── Also ───────────────────────────────────────────────────────────────────
The headscale ACL grants tag:prd-vipy exactly tag:music-box:4533. Without a
rule the node is invisible rather than unreachable, which surfaces as a Caddy
502 "no such host".

inventory lan_ip was read off nodito's ARP table for the VM's own MAC, not
guessed - and .149 had belonged to another machine, whose stale host key made
SSH refuse the connection. That is the designed behaviour, not a fault.

50 GB is the LIBRARY ceiling, not just the OS: /srv/music is on the root disk,
so roughly 300-450 albums in FLAC. 1 GB is fine for serving and tight for the
initial scan, which is the memory peak.

No backups, deliberately. The music files would swamp the age-encrypted nightly
path, and navidrome.db is a rebuildable index. What is NOT rebuildable is
playlists, play counts, ratings and accounts - there are none today, which is
what makes the trade fair, and the README names this as the decision to revisit
if that changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
counterweight 2026-09-27 11:09:40 +02:00
parent 7e4a7c850d
commit d208783fbf
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
25 changed files with 1457 additions and 191 deletions

View file

@ -57,6 +57,11 @@ subdomains:
# unauthenticated, like any block explorer - the data is the public chain. # unauthenticated, like any block explorer - the data is the public chain.
monero_explorer: exmrplore 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>. # Read by plays across several groups, so global rather than group_vars/<group>.
headscale_namespace: counter-net headscale_namespace: counter-net

View file

@ -1,191 +1,214 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
61616634393062353739616362333531616534666431633234333539376337313235366337346364 66356262646437623239383039643762363139653565356263623438303833663431396566353163
3135643764366262633636353735613233343361363165390a303638313465643332363839326530 3631323563343638346464386661346235656130383238370a353236623366313561646334363639
66623032363563306135643838626435646639306438393638353133393731386139336661383461 35346232376138666430363437346638326264646562386436323962633630663136626462343762
3965353830373536360a653364663734333762616630313563656335333933333566333334393837 3862333139646636650a656337306533313436333239653561333231363030633339303835396637
64636438386264316333633839623736333932356365643639376262353564363639303438373534 61633763313464353933306333383061346563623637383166646332303237653065346233623134
37346139393038643332626337646430376165316133343362636235623838343637343062623431 39386130613866386235376266643062653833643132633235393931636366376164306331303762
35333530376430643562656233356165313065633865343439363238663966366332346536363430 33393562303738393364323465653163633730633636343764646337383733643362396132303063
37313261363434376438306333373161303465653132353864366562363235376630373262356633 32336461396561663063633137613234356535383863346334666465643437613962376439356433
64393830643037346565303435633966303234666638653833353138346464383462383136313433 66353532333933343462636163353637613637356530333331636363616265376263636538656566
38356663303231313134623862643762633762303364616433623963326463326232333662643839 62643137623933363237343865613131383766633561636664616434393538646332346132336630
35346435643234663838643934376461393631666363333435623965376239353137613533383033 62333361626339626638333666393735373834396431343965386532313834353837336664306530
34663662666362613661613833383166343965333939336631363633343332653064653761353063 36303138373734343735653637663466353435306166316661353738353035366535333265626330
31356263396434386433393332353532373663396431313539343138623564393434373135616239 36396538353566316634376662383564323861303063386163663061323435656261316439336539
39353163363063323064633935346134663165626265633136633833643538616662333663366664 65323063343961646435373134303039373139333265633838313636373463316231633832306261
61323866313266626637356532336166643261343662636464346566613531626532663961613238 36623934343731343938306332356261646264613063393834336332666230626534653464363335
65353434653466316438343631376332316566376438623835393438313239326339323564623564 66356331666434623735613333303466656263313964363030316231303330303735373135653763
37313938396562623865646338336634333637666538656139646539376531656464653136663462 30323635303435656639653037366430316330333033306339366361353765656337316561366263
30343537303230306331386139376261396162326437623265373336356163636562623932653562 38613265363565373832636239396362616435303536646465326132626239353361613137626632
64353763646636363839613365386233316262356339653332323565383033613561356236666336 30353764323430656631366134373263343861376666346166363131333137376466393438346461
39616636353530636434366430643239383164376139343064316430396431363363383166346130 31613435363637306432343437653636356665373838626361306332316664343266356666353238
61373735313633313639323933646632393231633935326439316466623438393233623139626139 35303464326633326438353730393462373437313864326638323262323034346464643038386462
38353333376164613765656164363436613831663333656239346661623031393238353765356233 34613435326432663566386331333430663336656239353131633261626637613035636662346136
65336632633361356432326339613766633938336537306261633236333835646335356365393330 33626131626435316566393831333534393663326465633531373932626631613063666165613766
38656236363864613862643365383265633134643338383033643434376530303462336564383066 63636463656435386333323037656533306331646561363061613537663738326162656332393161
66663061646432333034316431353236323938623639303263393537313939373162633838343062 62313338376630643031666630663337646439623862306663303335303232306161353336643334
32316230613033363931343162386538376435386139396634333061313930373335306663393938 30386537336265383334336431653637303836376261303038653739396263666161653365356133
30643466303338316663613563636239343432363964653438666662356437623962653266346434 62636638393465363239616264316331316139363063313431323165313237646437643361653539
64616136643835396430306633313736623736316634313466366538663437633437613963386231 63393932623934313062376137626266363330623766646231353561646332353565643638356633
39666565366464326132643836366431653535343162336563356664346166313532393664353139 39656565363962653038393430363666643361343131323231663566343035643631646433306464
39383037323234323038316632383766383266393663646438653038643566376133636237653733 64343735393937333036623139303531343338373162396635613863316466393235343865333065
38623438343432323664303461373930363437356136333235393262336336633266396531663638 63353662313737353737353736316261346466366666666536653064356130326334326137383933
30393335396537356635343730376532363261353039396639653966633033383037656536646161 64306331663430313838376266613763343537613965303937393364396238663266363539313531
39653531303962656234343634346236373362616634313664613764373439643734306639386461 33616133646365396336613234636630396239316633333832653138393964616261643531663836
38663364613365643030616430356139393362386438613838326236643063633538346262626133 36666265386134383430626637643766636439393636336436366265396239303335306563653962
33666537353862353461393939656231666330633434316261656134363062353835663833356435 62323763626265373037343766386433646637633838643933633738333037633336646663316335
62663037383266353037326431376438313165343231656366306265666464303132613133303662 36373534663439306137353236343565636538636333313239373936653263366637613335663233
39646462333439633065356137633932336162333438356337663637353033326362313036303564 32306536643866656461343666396565373661353561393665643162613539633634666138626266
38643662633531383539393064356535363631336266316334386465313965376665306163393664 35613663376465626363373864626266393335396461316338653262316131643633666361333537
64643434326561613333353134633166613464393537323263656466613239343562653636353764 64366533633535313633306535393732383436333762616539646463366665623866323463336263
36343365656636393031626231343033653764393361376532646139613166306561323364356364 38313365326338386664363538633232343636303135356230393065613238386366323435633931
62623838316430616636323966616634343732353031393665356137393532383762363565663038 37353537313330396463383233616532653139376263346264623366383339306139386231636466
35323930633933626464633966343865346466353465626466663435316137366435373734363661 35383761373733386133386436316431613265306264656630353561333736613734653164333031
33663939366165313939643032396562326565343961376366313730333735336333353164623266 32643237366534643062653065333632343563633034663266393961353763663534353466383561
30306261363230306562343433656133303662306563646166386531386439633534623632373932 38373332323135353932343231353433366637333437636238623235663836393735346230623438
30623361346632346463333661366539613233383337353961383564303438643965636235396163 37616639643231396564373033636162616234366262333461343139363539316332333431636565
63363264383762303631613033663039626539336566653062333632383534306230396139613234 64396639383462386239613035303565376163633637653137373631383864353532373465626630
62633862643535383536353537393431376431303262323332613731636534643033303635613030 63636531653063306131623735393963623836313566643565386336636565633130383264316533
61653234313732643563383639303633343266623761623435373734613235656263353864353434 33343030316365373164663661306161363062646361646332393935373533626331623163323038
63353039386665623164626666626330623730616438373832303337633364633735613364623131 61316666366263313531373530366639333364336563623162323938356662626565383363303533
35613433366361616239363464343937363566383934313863636231373434636331383939623566 61323730313161633035646435346234366661386339346663363438663862636361343033306134
34623933373434346561633331653163323664393130336665393465303162396630376236653262 66396365653634373235623837666537323230616334663263633135316433313734663863333561
39393465346632323435636333613361353539643238313239373231376436313631663065386262 34326134613335346661316632353764343235326630316535306666326662613436383764646366
34663637386632396137353261613666316564333636326231636137613235623765313137306537 33616135333038333834656439383733656436663433303536313961333662366564376338326661
38306161633731343838356635613038616363653833623634633061376364376534323532666337 35393839643663333130383965663538393931363662336165636435643132353461343266363265
65366634623331323562323562613735356235393434323063303766653334363236613036353638 66346635313337346661646461386336386461346133323435643939616234666665616261313731
35353438623930366336383062303462646338633939393639643832623730633562306338313939 38383265643830646364373164656133396132666236313265346337643939613831303366393530
35666334336334653534646662316534376235656364356563316632363130356635333336666130 63333330346131656134366237323661316534323330306330616564393130353136306461666361
34616639393834373362323930613863383833663161316339323465616639633531376232303332 62376637646230623264363437313161396130616231346133346665303834623833663062663436
33333333643934623538633738643631386232643932303138383861313261306261323532393233 30643736376638303065356161366561353565343139343731313263393933313239363363636638
61353431373538313762653839343861313337643633643032333765396466393831393162306436 32663932626332626366333635616335316235363561373539623265663733373030373439373831
32393832363638333832666364646266376634663031333932333735643831623361323564386261 65333763653635613964396437333833626535643666643433393031336461366562333934666234
61323937393762613531646564363963626461343262373839383938663661626562613837323133 62383632303436633965643430363763353361613762306361613066356136383131383232313961
39646632316666396637646664356230356131313062363039656163613238386334636636636636 30626531353933336563656139316338616565303261363933306164393966343037353431653233
38363463633733663132366239343766353038383962353639313562343636623261376566653635 32636631373966323838333666313964323933306638653162646166623237343662646134383337
35326338663935386132333035373362326235643864336162303063623539656439306466663834 37383039383531656337626334386638613133343133633637343738343731326663356666333161
38333362353630363835613464303131306333313865623236633362623832633062333565343762 61323765636135306563323064346465633537613361383761393361353363396237333438306166
39336161376661646238313839633165666435346433336337633734353166636566326464666330 35386338356135633365396661356535623232613765653931303063353538643130323439366438
37386536613430343139653134343963626466333734356461323631623761663639633532376133 39363936316263333562646133646464346362343761336662353764613261396364626164313664
61363632303566336161653430613735316562656334323265303163383939323564356465636530 31383839303361323461663662393736616363646232666633323134653138306161323735386638
61626262653461386332646566306666323434613162613033373730303265306231316630346239 30333365663064366535656134323338663566616163333634353939363533356364393563363333
39343735363632623561373838316334393166303365363766663965306161643365656132653531 65623838323034336530326363383237373563326334343066323738663833353930663930373965
32613662663362653366633237386362623962323230303538633732663061376334393666326535 30363766633137396561376636636566363862353062663038653534623430643766633631356562
35653038306233626133363732393964623630383637363937363335663836346564636639383266 63623762343433626363353239376539633136333038623338303961373938396632313966666532
32326433316236376363333163346234303938626635363464636563653032656361323138326530 33633865626462636137343436386661353561623035656435623566613530623163316465383664
36636163356632613738626136376563656233313034666161323362313533366461326132316162 37346261616239653138623262623930316432633531313037353938396164646534316564613238
61646335383662313234666332623932396632333435316466346538313630633038663231373764 36643137303965316238346134323639386261633338363130323134336632636564636638653861
39343933613361336439636536356136383535336262613538323837633563633038306231663632 38346566643966386465366535393166626463313039613236393831393732613537336530393936
33396366343466383538306638656234383930306539633865373031343030333765663735386438 31613234333836333732323165653931336565316564346239336663363736323763623962383261
35363232616533656632396661386566643738353862646535323533323237636137646239353664 65626332626565636162336264303964323139303831656262373161303861393366353237393136
32353261366334353933376436336533306531356462343535346664656435626466356662613032 62303361636131646335633733373966626135396630623133623733373565306234646165656138
32656666336235326165653861326431653335653131663034386166373434303936646634366635 35383065613365363438396332306236646432663731326666613838643434393866623163316631
38356536343664363836616235326465616131636132643331373366656164653638613066333737 33633931373135336438643735646266383462386431666330363066386538386335383231653334
38373864333734306234383130376239373238636262376664613865363265363337393031616566 64323366363032386662333662613562333864356238626161366566356564343339343838373437
35336436373535613535653330306164373462313839316636326534366163326538653633376337 30363133363136663036656461343534666636343932313336336637376665393564313234373430
34643039373461393565653838323061656330346632316133373064613661306365336462646237 39356362616539366237646363646364326664653232313235653831663539353530393261346532
62326134623063346431643335313166306461653239303537663132643239386636653363323662 38653134623732313466386635313730303639613561323665356633653836393237333737323864
39393262613766343563636561356362653665313234366532626638346264316363343264313435 36323736326239636565346536646566656138666630396366353930383237333338626532663131
36386531656534666462383337343835313638376438653165663839393433376332623938363738 64366339303230663530626632623066613030663533366539313866646466373439663831363936
35356538383136333534386531376530383936633463363166326133303264373534613066356538 36366636653032343064383661383664636463343165326532653861663537633238323937363839
34373637303333333836386238303061303834383331653735336233666164656530656666356533 65333030653566306465643035633162393534323731346330333861353236393732346130313034
39343030643236613831663465323735343737623362383734326162356333323864333035366433 64303736323030643533633539623638643232306435663238393566376564313162633334613462
63316264613337363136613062663263313731636335383531383933623339666134643539353330 33363931383664633433363032643536356135626662653733303339656339373735643732626535
61333330336132636666383561356565346563393937653539373431653561653664616137623133 35316131356135393933393462336163373062653133356133393830393638313838396361336635
39336333313330336137346238306130653730326130323163306464376434353736623232656134 62323463333464353939346233333462646161633036643332313337353065376561336630383163
33356435663762666266393461363531396334313733333132666363316133346531626564626334 38306666666634393766366436653436623138363830343636323833353365333762343533633835
66356436393062373364316439646235666336333039383137353764303763636461663638333362 66363730383932303661313831663566613830393530616164323963636339393339623065316534
66306430396530636538353965333862323363323832373033353530306331653935396134323634 38373662636330336632653335316463623935653961363566396132616433633831636334306236
61353964303666643163646464613737393863373236613532353761636230336633373461343266 34393761663962623563333534383739613965353033646235653532303330343961656639333337
34383031353734346465613030653639656366313530333265383632343465393566386161373633 31643462663538313930383038343964326463363935396365343466643236653738626639616434
34313563636265336239323362316432383637303134376331633039333537613461353135303934 62343337356239316162383564666131663535313633373062353030336439396533376235386131
66366334663032336330386632656162643933316666643562616564613633303231666536653536 31653865643931323364356566316435333336316166643063613164643266313834323235353863
61376534663632316338653231653561643338356661353232633431623834396635383034366438 34623936613366303161353165386662376465656366656561613466383830326162336238656236
30376161663339633530646539313132383536303931333366316631356135303933316162373730 30646632396231653263373631346330376461303231373733646231373462623131303633616166
38343139653932666335353437613734366530616261343134386261313330303766666136616532 37366435353830303234393962663334656335356661333437363637663664323430623135346262
38663635393236376661623862646265626239656665323332633532616536643237333137343763 33303233343362336261633663336635366233313466636233333836663464633637383964313133
37663432366337633662303931353939346462633837346638353530636132313436626161633865 31363461336237373337353438396132616134303432336533653737333538383534363735316334
30653065666261363238366534656265663039373962333938633335373163383261363434646437 62303666363532383131383833383536333733306163366339653038313930646531653565386335
35356538623765343866653065306164363363616137323065333062386435393539303930363239 38313561303062336232333634343866393763636131333665396430306432373734613834316636
62653638303766343738346437323536306139303261393863373035336662316566646565316662 61306138626530616230343462613036323166656334656637616139663030623932386132393762
30633464663666383135663365636436363132363437633933643464626339643865383066623039 36363364343737613165376365323361363233616532643866303632653063343431613338373633
66356338633939386337353135613066343236356262363164666466333462383364663134616433 63303263626434326539316439323239616632653632346538336535373336623739323931623462
38626332623530633765656464383136383466623561633666366566316461356437626165316166 35656365336630323035616161656237353135373366376466326264323863336166633438663834
65626339383133623439366662373666346539613231306564643731383935633939343865613534 63646562386339363235396163646334333238323932383230643964366466323064313138323464
66363934373433363134323165383830643535613139313139316137643532303562393432383330 62636239663635336332633334656631383261306437333963363464303430666462633333666638
61376363363939633835383161396464613662383961366634626463346430303631653930613430 37633830663439396133323861303735346561376434386131363864316332623137376139376231
66616132616364623261363731343437633032383361356433333531363964633962336338373338 30303765646132306261363335366565306564373261663336613336336632616231363366316135
61353134616332663135663737613230666534656232623933613862326366393538373737393838 65303966616439643863326662656439623938373833323738646535636230663136653266653533
30343037343432306437663035613831316432336431383938363338316437616164376435376261 63326235376633333366373436333534336436366434363461393763633437616262326133623563
66363664333031653962383630656430646238353337616535343535373935663561306536353165 37613161303161356632316335616639373564306161623339353066383163303431313333306635
65343235396537366635633638306135383164353562336331376336323633323338353361353061 36326435376661343237303633316462613532313232643461313065376134393338613130336161
65623431633731303936303732613964346139306336356461626466343132383162666337333664 38326463303562666662613361656161306139653431386630303561626666613037393238313463
38636631636338353339316666643436386537336231353332663636383337343831323939396636 62623533396532326332326636346134616236653638363837643131343166333564323538653638
30663634653733623530343537636137623332666666343433643830333737336639313064386333 63383261383265353331663864643933373562396565346163396434383765343962336363353261
34656362613863323262326130666361373434336234366337663236663739383366373635343862 38353034643436626632623235396261326235323937646135326532313561396361363062333366
62656365366533633263323963346363396237393462653633313133353935343631393032303566 32343666343163636233646466613633383034653438646262666133643830333834383233343136
66326266353264306266613936333734643631376637663137373232653962646534643565326366 33646437396137613466643031323538626332613066333065643237353263636435393964356464
64646634346534613730646430333432373464653365663666346232373332346231306563636136 34383265303364663233326432313230336436373866303435336666343832383737303030306534
38646565366661613161383530326261613537366633323239656461343337363139353164653935 36383036363937393965386431343561333961663565333565333261323033616630343130613934
66356636653232643035363564306438396233376465646462316463333832636361363137363364 32383166333063623235333464343864326531333165346261643131393264346534393035393431
66653164363836333261386437373864666438383338383963616662386631306530326535303666 64636139383232383332623866323363623666326361386134353030376366323039393033653061
37393037313539393535633333663662363164323635333635646638663264383533303032303561 63383338303734323637386366646365663032636463336261643533663638343661613462373736
34613563353237633761363435636138616462656263353966636363326533363235663761643033 32663566666133656165396233643334343335333731333938376236633330366262366139326133
63613934323330363732353465316261653964656430383532623833653663306632633832653763 32653163343963333739303061643936363861313938646164316564393261663765393462613837
32333932663362663664663535363566656665653839386363373066343336353264663136326136 31646636326330663464363034376230393739343961653130303166366233396262656634313061
66383461626137353430316266356366393937613234656261646632366130613739353266613163 65646664373161356134616165383265346633376136376132643234623334353563323539333065
30383731373137653137353663643866343632383937613136666361626435393331313130386230 61623265343730326164373063383937323766363466343466316364373935626138343938323261
33306439633138306331616165386239643765323765366530336337623264343030333234336234 38323739663131346138623663393832346236356631633133313436363364663536316561623631
64333333613838333734346361636162346564656334396139373535333830313037383864623561 32393562646666343537663039363165646337653564363065353433643031353865386361613534
38336131343532663761326635633531323766363963663763653630643232663631613465333132 37633639303636313261613563353136346465326163623361323230306364396362373966336262
35306635636437323835326633616665313133393162623737386538373364343865383634636463 31353565623239343564333533656236656666316638646666376138373164663138633835366162
33626165623962656432396237653665346464663066633633633564313266343662336333656166 37633036346562663261343731366161613361656534343839303062373232636339643961666432
34646636663864663439356364383031313065663433386530663365353937626366663334346434 61396665656638333435386338383638323664313139656331336131326563373235646539633835
35656365316134306161656133653736306537613133386634386638376534376133346237353765 30393235316564656131303932386336366661373737393264613364663030363862656462646463
64363964303535653037323566653838633231363132666237353765643137383030623930393931 33303766303166336132626265653431313263363131653835336634356632666230666235383262
33313935663366313362656137613865386532303737306437353437336331643435323030663666 31653837356536666266383562633437376161653664353533656331303563626539626161653562
66383139313333633538373836396263353337316264656631376236346466353765653830366366 64333337626430616330346233613566353330383361353234376236656336653735663838343333
66386131336431336466636166366139396337386135636262316237306561353563623061313266 37643162376630373531396138643861326562323133303363363837306138373434306364356364
33343563393031303133393163653638643065636636653766613836386438626332373965383864 64623037643737663138626262326630323638376336663062393930663566316632323862653135
36343936613738336132396265663130643739373264356530636363633239346637653136343761 66363664336431373966663133353030393863313538366632643932363438613035326563323163
64646137383235636231313736343162353237636566663830373739636536393864646639343564 63343565373862316230643166336438393464323032666334653631393761303337303861666134
61323039376537646531633030626665323537366233353939653665326666653263623431356166 36663637346136623564613233343432383930616339626161623933323565623861653939326165
34343538613031336561303165323134303862666139633937323331643930383938666138346230 37383436353533613736323237666433316130613464396333343965393031333138396461376632
33633537666431643234336134333333633664333564396139363136363434376130323632363833 61373166643766376234646436653566313637326434363261333831336136306137333061323035
32363764656133653462633132366630396233613463643833353038316232353962623761373966 66303335363035666366386364303861383231366339643666313636623733616562333539643465
35613132306662663937366464373034336531366262643939333137613031666162383434313664 32353566326262326637393434656133313963646435333135613630313865373430366265666632
65363332383330623863326465353039306338393230303165656366386161656239316161336633 37316539363439663131323835313761313765653564663337346462653332613430373037333064
61616434363637376131316135643336653636313565613035643038386332356263383061643262 64323731623566326664613634626265383661346462323463303638346464643366663333336339
63333439303736623835653636353261363532646266663331313566363562343730303436353636 65626265663230346338633764633436653339373031623438633638326137613635633239343834
63636237343965316366613137353062393832373133316438656433313132643061633130396662 35373132376133363861373062386235353836633030323236343666323134326135336231613566
65346530313962306238373634366266386232613566656337396663626535393964323737323939 34326263643163633935643065306165643564663831633561353061653331326566633664376432
66333165356138353362336166376466303965663439636432653862316433376264376563313137 33353835366265346266663836326465356166626432356663366566313962663862363930336334
30653565386636363730623630646633633832306131626235613862393239386437386561356663 65323337386231376361343933353865633330613466323337306137633762653931313331313339
64303862616530393838383364643266633336326665323961643935356339316564316332633635 36663965353832386662666433383934303737656336383432633632646135383835336631373861
32323239333339633838666233376165303061373465376133383863333232663065626462303362 63646164373137316662373330333465343134633466396566333334663036663039373934333832
37613638303266356537633965323966363331383130613963363933656531663031643030396632 35343661303565646464373834646234623837363562386463383237373533316638666663613633
38343838383731346536616364303531373030643730626165633464646137376439643637633636 30313437626231343062636437363836386635333862373362643532646335353863323936663034
62346664633931376438313639613165306363356532346566336336363662353233613134346132 30316461373330623130353563643035303561303666393464636533626562633165393236663936
30646633363364363234396435323037306663633863383530383034383338636639326362626639 31313061303465633332646363643866306439383636356366623565663437313365376331656666
64313532313939316562353561323534313438346530303536333066393863613136646664353264 34333930333235306636646664326561613165633233353937666238646536323131313232626366
65663530366238343732396430633138613664653962366338643738623636373031363839393534 36623237613635343134313366346661656433643136353433393532303165326564646331333035
38393439393366396262643461303361613666336631313037306231363537346364346432616539 35353365633039353161663337306463303535643062366232336239656162653532306539333035
64616431633730333233616362363638613837356638343131376262363736666532356532653330 65373735373832326337333962643330363338656134303637313337393466366263363264343631
64353431663261623033323337373932396436656633396439323162626563653630633239306233 30656433643562393537326361323733643639396537303065623936336230346664616534323262
37633939363536396439643663623434316161383938393864653437316535313433336461383065 36313864396364353836373538333130303731383437376137383938383633373063323936613464
32396536313835393030313136356530623539646361633935333362373339393662393335353361 37613166323432363232313834326565663265336138396335313230303935396266646438323833
37353366666264613563663836666332353561666464623561653238666537316233343838663363 65306666636338373239346332613464373235613137643133626261313031326462356138356632
38616463643935376233363031323838393163306465333030393331663231623061646530393237 66363465346165306533613636383938636531656631306139666237396632646634333937623032
33356466363261653235336134376166343537643065663235333161383366336132316666383731 33613439656637383537356364336265653931613835333734383836343930313838626564333065
39326662373162313861363132653531323165393939313461663262316337333762393134663030 35363735313162616238643438316535356663396339316331633232393232316131396432653639
35313762356234396336356336393937316262646264306361363235663334333663363136383430 36303335643534376536383331323562353139613130383330636334636261326462656565643138
35366538386335633038326563623833313031383762653266643034373838653863363835383466 63326365363163383932653562363833643761653839306131343737353633313737353732633463
37323138316564383262356633646262623439326464633530313639313637326136656139376362 62326563336339383661376665353230613366646132653364396338663337326136333263363838
35323866663262373938343863653665373139663565623730666366643630636566393266303261 35626537666238383435613431666365303133373166386136353061373765313632343665306439
35666261383464393236353964623234306637643838623535313264356364366661653639303662 34356139363636623639626633623030363933303539626665383733363638303832333561653130
37616531626435323664636538396439373064333563303637303562653763323935666138383338 64393437386362333731643131663263626434666661326533386362306362653732376366626139
32326630303465366332656165343036336630383163643361306136333361373738306238393932 65626662323232636134653334366639386164653535323435613863353332366630303636663339
63363331373434353563353631306361383431636239376464643239326531306130633463653665 34333633373737333732363664353437613762383064316134393139393539306564396237343332
64636536383430613538623663363066646437666537306161613265323936663366353864323230 64636339643030353365356136366264353935643830633237316532316265316635393664326533
32666333326630636433656166343331396166383438313866306566356238653061343461653035 64343030396334326135336465336637623832363065353163316465303537643861306632336535
6236383630643231646264333133343738663663323332336536 31366338323263313965643565656430366566393130326333383862313239356437623232613865
63383863316639346632323834633739356464313865376463323633393365653033376431373138
61363035653233373036666531303331383337353839363563386333313730363664666463333735
61383638363164336265383664376463616536383533666265383961353961303833353265366633
32646330333336613631633830633837346332653437356536363532396438326364623465383133
36656231343734643830383335623930343132386336666366623161666637623862363033373236
32666332316266623230666231663162643262653231313965323137383532633663323239396139
37656533373065623161623766333335353965373731346639613566343236363239313537636239
34626533346138613764346161383337623265353563343066303236333534316137356636396565
32616136306665346566616565346162613736333836623363346232373932366365663531653435
37623539366233366539396361386163643161653731353232313034613135373039653339346637
31623138326133366333656661393837653939343064656638616630346462363065623139323131
65363036383433663566626336353432613535363239373264356237323032323634383662613763
39646161363639326330346332353965366330346632303939623136303238323431346361656462
61623838626139636163353430636461666239343337373132643334616234616339313761353331
36643165373834373437363165303861306135383236303030626463373235393234313765343563
37353932366430666539623230373161303935633565323366363232336264643961383835333066
32623366663965316662383036306462616465353139336333643936363838313538363832363330
33313861313636373831656431383434363664663734626363646365666135376331366335653064
30386235313037363365363232316338613634643235383037393635333166346632356435373039
66663862373939346434333130383065386135306661353066343330666563386136376562316134
63346435643132316434363666343964303335663131383831373430653632663638396333393932
36383664376666393761643730306336333538376637633135616166346432636464336434323031
3966

View file

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

@ -36,6 +36,7 @@
# four looks a day while the node behind it is mid-sync. # four looks a day while the node behind it is mid-sync.
- {sub: "{{ subdomains.monero }}", host: vipy, interval: '24h'} - {sub: "{{ subdomains.monero }}", host: vipy, interval: '24h'}
- {sub: "{{ subdomains.monero_explorer }}", host: vipy, interval: '24h'} - {sub: "{{ subdomains.monero_explorer }}", host: vipy, interval: '24h'}
- {sub: "{{ subdomains.music }}", host: vipy}
# A public resolver on purpose: this must test what the internet sees, not # 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. # what a local cache or the tailnet's MagicDNS happens to answer.
@ -68,6 +69,12 @@
# daily check has no room for a blip, so it alerts on the first one. # 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: 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} - {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}
# Ports published from the edge host by socket_proxy. # Ports published from the edge host by socket_proxy.
public_tcp: public_tcp:

View file

@ -54,6 +54,7 @@
# are one decision and must move together. # are one decision and must move together.
- {name: monerod, host: monerod_box_local, heartbeat: '26h'} - {name: monerod, host: monerod_box_local, heartbeat: '26h'}
- {name: xmrblocks, host: monerod_box_local, heartbeat: '26h'} - {name: xmrblocks, host: monerod_box_local, heartbeat: '26h'}
- {name: navidrome, host: music_box_local}
tasks: tasks:
- name: Build the probe endpoint list - name: Build the probe endpoint list

View file

@ -16,6 +16,11 @@ 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 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 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 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 # Local connection to laptop: this assumes you're running ansible commands from your personal laptop
[lapy] [lapy]
@ -55,6 +60,9 @@ monerod_box_local
[memos] [memos]
memos_box_local memos_box_local
[music]
music_box_local
[ci_runner] [ci_runner]
forgejo_runner_local forgejo_runner_local

View file

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

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

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

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

@ -0,0 +1,48 @@
---
# "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

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

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

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

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

@ -0,0 +1,18 @@
[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

@ -0,0 +1,65 @@
#!/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

@ -0,0 +1,14 @@
[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

@ -0,0 +1,33 @@
[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

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

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

View file

@ -0,0 +1,163 @@
---
# 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,6 +56,7 @@
- import_playbook: services/monero/deploy_monero_playbook.yml - import_playbook: services/monero/deploy_monero_playbook.yml
- import_playbook: services/xmrblocks/deploy_xmrblocks_playbook.yml - import_playbook: services/xmrblocks/deploy_xmrblocks_playbook.yml
- import_playbook: services/memos/deploy_memos_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/forgejo-runner/deploy_forgejo_runner_playbook.yml
- import_playbook: services/phoenixd/deploy_phoenixd_playbook.yml - import_playbook: services/phoenixd/deploy_phoenixd_playbook.yml
- import_playbook: services/headscale/deploy_headscale_playbook.yml - import_playbook: services/headscale/deploy_headscale_playbook.yml

189
scripts/add-music.sh Executable file
View file

@ -0,0 +1,189 @@
#!/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

@ -85,10 +85,23 @@ vms = {
"arbret-staging-box" = { "arbret-staging-box" = {
vmid = 3200 vmid = 3200
name = "arbret-staging-box" name = "arbret-staging-box"
cores = 2 cores = 2
memory_mb = 2048 memory_mb = 2048
disk_size_gb = 20 disk_size_gb = 20
ipconfig0 = "ip=dhcp" 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"
} }
} }