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

@ -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.