--- # Navidrome + beets on music-box, published at music.contrapeso.xyz. # # See roles/navidrome/README.md for the exposure and backup decisions. The two # that matter here: no basic_auth in front (the Subsonic API the mobile clients # speak has nowhere to carry a second credential), and the fail2ban jail below # covers the web UI only. - name: Deploy Navidrome on music-box hosts: music become: yes vars: # Vault key is navidrome_encryption_key, not the role's own variable name - # assigning a var to itself is a recursive reference, not an override. navidrome_password_encryption_key: "{{ navidrome_encryption_key }}" healthcheck_push_url: "https://{{ subdomains.gatus }}.{{ root_domain }}/api/v1/endpoints/probe_navidrome/external" healthcheck_push_token: "{{ gatus_push_tokens[inventory_hostname] }}" roles: - navidrome - name: Publish Navidrome from the edge host hosts: edge become: yes vars: navidrome_access_log: /var/log/caddy/music.log tasks: # The Debian package usually creates this, but `caddy validate` passes with # the directory missing and the RELOAD is what fails - so the vhost would # look deployed and serve nothing. Make it explicit. - name: Ensure the Caddy log directory exists ansible.builtin.file: path: "{{ navidrome_access_log | dirname }}" state: directory owner: caddy group: caddy mode: '0750' # The FILE, not just the directory, and this is load-bearing. # # `caddy validate` below runs as root and provisions the logging module, # which CREATES this file root:root 0600. Caddy itself runs as the caddy # user, so the subsequent reload then dies with: # loading new config: setting up custom log 'log0': ... # open /var/log/caddy/music.log: permission denied # and - the nasty part - `systemctl reload` hangs rather than failing, # leaving the unit stuck in "reloading" while the OLD config keeps serving. # So the vhost looks deployed, validate passes, nothing reports an error, # and the site is simply absent. # # Creating it up front with the right owner makes validate's open a no-op. - name: Ensure the Caddy access log file exists and is owned by caddy ansible.builtin.file: path: "{{ navidrome_access_log }}" state: touch owner: caddy group: caddy mode: '0640' access_time: preserve modification_time: preserve # caddy_site_body rather than caddy_site_upstream + caddy_site_resolvers: # this vhost needs a `log` directive so fail2ban has something to read, and # the role's template has no knob for one. The escape hatch is the # documented way to do this - gatus and headscale already use it - and it # leaves the other vhosts untouched. - name: Expose Navidrome over HTTPS ansible.builtin.include_role: name: caddy_site vars: caddy_site_name: music caddy_site_domain: "{{ subdomains.music }}.{{ root_domain }}" caddy_site_body: | log { output file {{ navidrome_access_log }} { roll_size 10MiB roll_keep 5 } format json } reverse_proxy {{ hostvars['music_box_local'].ansible_host }}:{{ hostvars['music_box_local'].navidrome_port }} { # Tailscale MagicDNS: music-box has no public address. transport http { resolvers 100.100.100.100 } } # The per-site validate cannot catch a conflict between sites. - name: Validate the whole Caddyfile ansible.builtin.command: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile changed_when: false # ── fail2ban ──────────────────────────────────────────────────────────── # The estate's first jail: 02_firewall_and_fail2ban_playbook.yml installs # fail2ban everywhere but leaves it on stock config. Kept inline here # because one jail does not justify a role; if a second service ever wants # one, that is the moment to promote this to roles/fail2ban_jail alongside # caddy_site and gatus_endpoint. # # Scope, stated so it is not mistaken for more than it is: this catches web # UI brute force (401 on /auth/login). It does NOT catch Subsonic API brute # force, because Subsonic answers a bad password with HTTP 200 and the error # in the body. See roles/navidrome/README.md. - name: Install the navidrome fail2ban filter ansible.builtin.copy: dest: /etc/fail2ban/filter.d/navidrome-caddy.conf owner: root group: root mode: '0644' content: | # Managed by Ansible - services/navidrome/deploy_navidrome_playbook.yml # # The log file is per-vhost, so any 401 in it is a failed Navidrome # login and needs no path matching. [Definition] failregex = "remote_ip":"".*"status":401 ignoreregex = notify: Restart fail2ban - name: Install the navidrome fail2ban jail ansible.builtin.copy: dest: /etc/fail2ban/jail.d/navidrome.conf owner: root group: root mode: '0644' content: | # Managed by Ansible - services/navidrome/deploy_navidrome_playbook.yml [navidrome] enabled = true filter = navidrome-caddy logpath = {{ navidrome_access_log }} # ufw, not the Debian default iptables-multiport: UFW is what manages # the firewall on every host here (infra/02_firewall_and_fail2ban). banaction = ufw maxretry = 5 findtime = 10m bantime = 1h notify: Restart fail2ban - name: Ensure fail2ban is enabled and running systemd: name: fail2ban enabled: yes state: started - name: Flush handlers so the jail is loaded before we assert on it meta: flush_handlers # A jail that silently failed to load is worse than no jail: it reads as # protection in `ls /etc/fail2ban/jail.d` and bans nothing. - name: Assert the navidrome jail is actually active ansible.builtin.command: fail2ban-client status navidrome register: navidrome_jail_status changed_when: false failed_when: navidrome_jail_status.rc != 0 - name: Display the navidrome jail status ansible.builtin.debug: var: navidrome_jail_status.stdout_lines handlers: - name: Restart fail2ban systemd: name: fail2ban state: restarted