164 lines
6.6 KiB
YAML
164 lines
6.6 KiB
YAML
|
|
---
|
||
|
|
# 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":"<HOST>".*"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
|