180 lines
7.7 KiB
Markdown
180 lines
7.7 KiB
Markdown
|
|
# 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.
|