# 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=&t=&s=` 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 (``). 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 `Navidrome` and logs nothing. ## Upgrading ```bash curl -sL https://github.com/navidrome/navidrome/releases/download/v/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.