personal_infra/ansible/site.yml
counterweight d208783fbf
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

93 lines
5.6 KiB
YAML

---
# Everything, in the order it has to happen.
#
# This file is a TABLE OF CONTENTS, not a second source of truth. It says what
# runs and in what order. It does NOT say which hosts get what — that stays on
# the `hosts:` line inside each playbook, exactly where it is today. Nothing
# moves; this file only makes the set readable in one place.
#
# What runs on a host? ansible-playbook site.yml --limit <host> --list-hosts
# Who gets thing Y? the `hosts:` line in Y's own playbook
# What is a host? ansible-inventory --graph
#
# Run a slice with --limit, or run one playbook directly as before. Nothing here
# changes how any individual playbook behaves.
# ── Baseline: every managed machine ─────────────────────────────────────────
- import_playbook: infra/01_user_and_access_setup_playbook.yml
- import_playbook: infra/02_firewall_and_fail2ban_playbook.yml
- import_playbook: infra/900_install_rsync.yml
- import_playbook: infra/920_join_headscale_mesh.yml
# Idempotent and kept permanently: guarantees a rebuilt or restored host cannot
# quietly bring the Uptime-Kuma-era monitoring back.
- import_playbook: infra/409_remove_legacy_monitoring.yml
# ── Monitoring ──────────────────────────────────────────────────────────────
# Gatus first: the three plays below register endpoints with it, and registering
# against a host that is not serving yet would simply fail.
- import_playbook: services/gatus/deploy_gatus_playbook.yml
# The Signal transport for Gatus alerts. Shares a docker network with Gatus and
# publishes no ports - the API has no authentication. Needs a one-time manual
# device link; see roles/signal_api/README.md.
- import_playbook: services/signal-api/deploy_signal_api_playbook.yml
- import_playbook: infra/400_host_monitoring.yml
- import_playbook: infra/401_service_monitoring.yml
- import_playbook: infra/402_public_monitoring.yml
# Registers where the per-service probes report. The probes themselves are
# deployed by each service's own playbook further down; the endpoints must exist
# before the first push arrives.
- import_playbook: infra/403_service_probe_registration.yml
# 910_docker says `hosts: managed`, but only 5 of 11 managed hosts have or need
# Docker. Left out until it has a [docker] group — see the note in PLAN_7.
# ── The hypervisor ──────────────────────────────────────────────────────────
- import_playbook: infra/nodito/31_proxmox_community_repos_playbook.yml
- import_playbook: infra/nodito/32_zfs_pool_setup_playbook.yml
- import_playbook: infra/nodito/34_nut_ups_setup_playbook.yml
# ── Reverse proxy, before anything that registers a vhost ───────────────────
- import_playbook: services/caddy_playbook.yml
# ── Services ────────────────────────────────────────────────────────────────
- import_playbook: services/bitcoin-knots/deploy_bitcoin_knots_playbook.yml
- import_playbook: services/fulcrum/deploy_fulcrum_playbook.yml
- import_playbook: services/datum-gateway/deploy_datum_gateway_playbook.yml
- import_playbook: services/mempool/deploy_mempool_playbook.yml
- import_playbook: services/monero/deploy_monero_playbook.yml
- import_playbook: services/xmrblocks/deploy_xmrblocks_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/phoenixd/deploy_phoenixd_playbook.yml
- import_playbook: services/headscale/deploy_headscale_playbook.yml
- import_playbook: services/vaultwarden/deploy_vaultwarden_playbook.yml
- import_playbook: services/forgejo/deploy_forgejo_playbook.yml
- import_playbook: services/lnbits/deploy_lnbits_playbook.yml
- import_playbook: services/ntfy-emergency-app/deploy_ntfy_emergency_app_playbook.yml
- import_playbook: services/personal-blog/deploy_personal_blog_playbook.yml
# ── Backups: each source dumps itself, the box pulls ────────────────────────
- import_playbook: services/headscale/setup_backup_headscale.yml
- import_playbook: services/vaultwarden/setup_backup_vaultwarden.yml
- import_playbook: services/forgejo/setup_backup_forgejo.yml
- import_playbook: services/lnbits/setup_backup_lnbits.yml
- import_playbook: services/memos/setup_backup_memos.yml
- import_playbook: playbooks/backups.yml
# Deliberately not here. Every playbook in the repo is either imported above or
# listed below, so this file accounts for all of them:
#
# infra/910_docker_playbook.yml says `hosts: managed`, but Docker is on 5
# of 11 managed hosts and those 5 are exactly
# the ones that need it. Running it would
# install Docker on the Bitcoin node and the
# hypervisor. Needs a [docker] group first.
#
# infra/nodito/30_proxmox_bootstrap one-shot: bare-metal bootstrap, run once
# infra/nodito/33_..._cloud_template one-shot: builds the VM template
#
#
# services/vaultwarden/disable_ deliberate manual actions, not convergence
# vaultwarden_sign_ups_playbook.yml
# services/personal-blog/setup_
# deploy_alias_lapy.yml