personal_infra/ansible/roles/navidrome/defaults/main.yml

86 lines
4.2 KiB
YAML
Raw Permalink Normal View History

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>
2026-09-27 11:09:40 +02:00
# 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: ""