From 64aebe8c343c73a514e252b9d4a16aa07a94c31a Mon Sep 17 00:00:00 2001 From: Fabio Scotto di Santolo Date: Thu, 10 Sep 2026 22:27:33 +0200 Subject: [PATCH] Add Atlas media and storage services --- AGENTS.md | 39 +++-- README.it.md | 60 ++++++-- README.md | 65 ++++++-- ansible/inventory/group_vars/server.yml | 29 +++- ansible/inventory/host_vars/atlas.yml | 53 +++++-- ansible/inventory/host_vars/prometheus.yml | 8 + ansible/roles/profile_atlas/defaults/main.yml | 67 ++++++++- ansible/roles/profile_atlas/handlers/main.yml | 12 ++ ansible/roles/profile_atlas/tasks/account.yml | 36 +++++ .../roles/profile_atlas/tasks/containers.yml | 140 ++++++++++++++++++ ansible/roles/profile_atlas/tasks/main.yml | 3 + .../profile_atlas/tasks/samba_account.yml | 64 ++++++++ ansible/roles/profile_atlas/tasks/sharing.yml | 107 ++++--------- ansible/roles/profile_atlas/tasks/storage.yml | 47 +++++- ...atlas-immich-machine-learning.container.j2 | 27 ++++ .../atlas-immich-postgres.container.j2 | 22 +++ .../templates/atlas-immich-redis.container.j2 | 20 +++ .../atlas-immich-server.container.j2 | 29 ++++ .../templates/atlas-media.network.j2 | 3 + .../templates/atlas-npm.container.j2 | 21 +++ .../profile_atlas/templates/atlas.exports.j2 | 2 +- .../profile_atlas/templates/immich.env.j2 | 13 ++ .../roles/profile_atlas/templates/smb.conf.j2 | 5 +- .../roles/profile_server/handlers/main.yml | 26 ++++ ansible/roles/profile_server/tasks/main.yml | 17 ++- .../roles/profile_server/tasks/navidrome.yml | 114 ++++++++++++++ .../profile_server/tasks/rclone-music.yml | 105 +++++++++++++ .../templates/navidrome.container.j2 | 21 +++ .../profile_server/templates/navidrome.env.j2 | 7 + .../templates/rclone-music.service.j2 | 29 ++++ .../profile_server/templates/rclone.conf.j2 | 9 ++ .../templates/server/docker-compose.yml.j2 | 25 +--- secrets/vault.yml | 104 +++++++------ secrets/vault.yml.example | 7 + 34 files changed, 1127 insertions(+), 209 deletions(-) create mode 100644 ansible/roles/profile_atlas/tasks/containers.yml create mode 100644 ansible/roles/profile_atlas/tasks/samba_account.yml create mode 100644 ansible/roles/profile_atlas/templates/atlas-immich-machine-learning.container.j2 create mode 100644 ansible/roles/profile_atlas/templates/atlas-immich-postgres.container.j2 create mode 100644 ansible/roles/profile_atlas/templates/atlas-immich-redis.container.j2 create mode 100644 ansible/roles/profile_atlas/templates/atlas-immich-server.container.j2 create mode 100644 ansible/roles/profile_atlas/templates/atlas-media.network.j2 create mode 100644 ansible/roles/profile_atlas/templates/atlas-npm.container.j2 create mode 100644 ansible/roles/profile_atlas/templates/immich.env.j2 create mode 100644 ansible/roles/profile_server/tasks/navidrome.yml create mode 100644 ansible/roles/profile_server/tasks/rclone-music.yml create mode 100644 ansible/roles/profile_server/templates/navidrome.container.j2 create mode 100644 ansible/roles/profile_server/templates/navidrome.env.j2 create mode 100644 ansible/roles/profile_server/templates/rclone-music.service.j2 create mode 100644 ansible/roles/profile_server/templates/rclone.conf.j2 diff --git a/AGENTS.md b/AGENTS.md index 208f3b3..781bd79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,10 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora - AI coding agents: `ansible-playbook ansible/site.yml --limit --tags ai_agents --check --diff` - Mail bootstrap: `sh -n scripts/bootstrap_mail.sh` and `shellcheck scripts/bootstrap_mail.sh` - Server compose render: `podman-compose -f /opt/docker/server/docker-compose.yml config` and `systemctl status podman-compose-server` + - Atlas media stack: + `ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff` + - Prometheus media mount: + `ansible-playbook ansible/site.yml --limit prometheus --tags rclone,navidrome --check --diff` - DuckDNS config only: `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff` ## Conventions @@ -104,8 +108,12 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i - `rocky_server` is a child of both `platform_rocky` and `server`; `prometheus` is its active target. - The target must already provide `server_username` with local sudo access before the profile runs. - The Rocky profile installs Podman and podman-compose, uses firewalld, preserves SELinux enforcement, and renders the - same server Compose stack with a `podman-compose-server` systemd unit. It does not start, enable, transfer data, - update DNS, or cut over traffic; activating the stack is a manual step. + Nginx Proxy Manager/Gitea/Navidrome-PostgreSQL Compose stack with a `podman-compose-server` systemd unit. It does not + start or enable that Compose stack, transfer data, update DNS, or cut over traffic; activating it remains manual. +- Prometheus has a gated system `rclone-music.service` and rootless Navidrome Quadlet. They remain disabled until the + Atlas WireGuard address, pinned SSH host key and Vault-backed SFTP private key are configured. The rclone mount is + read-only at `/mnt/music_atlas`; Navidrome must not start against the underlying empty mountpoint or while the legacy + rootful Navidrome container is still running. The role never removes that legacy container or its data. - Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes `80/tcp` and `443/tcp`; bind its administration interface only to `127.0.0.1:81` and use `npm-tunnel` from Ikaros or Nymph. Nextcloud remains disabled; do not provision `/srv/nextcloud` directories. @@ -121,17 +129,28 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i subsequent runs use the dedicated Atlas account. - The pool is pre-existing: never add pool creation, disk partitioning, RAIDZ creation, rollback, or destruction to the Atlas profile. -- `atlas_manage_storage` and `atlas_manage_firewall` remain false until their placeholders are - replaced; only then may the profile manage datasets, shares and LAN-restricted firewall rules. +- `atlas_manage_storage`, `atlas_manage_firewall`, and `atlas_manage_media_stack` remain false until their placeholders + and Vault inputs are replaced; only then may the profile manage datasets, shares, LAN-restricted firewall rules, and + rootful media Quadlets. - Atlas requires `vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash` for Cockpit - and, when storage is enabled, `vault_atlas_samba_password`. Never print these values. -- Atlas uses NFSv4 for Linux and SMB for Windows/WSL, restricted to the configured LAN. Snapshot, - Borg/Hetzner offsite backup, Prometheus pull and USB backup automation are intentionally deferred. + and, when the relevant gates are enabled, `vault_atlas_samba_password` and `vault_atlas_immich_db_password`. Never + print these values. +- Atlas creates `archive`, `media/music`, `media/icloud_photos`, and `backups/services` only under the verified + pre-existing pool; `backups/services` has a `500G` refreservation. Existing Work, Syncthing, and + Prometheus-backup datasets remain managed and separate. +- The `immich` system account is fixed to UID/GID `1100`, has no login shell or `wheel` membership, and receives only + the `video` and `render` supplementary groups. Immich's rootful Quadlets run as `1100:1100`; Server and ML receive + `/dev/dri`, while the iCloud Photos external library is read-only. +- Atlas exports iCloud Photos only to the configured Aegis IP with all access squashed to UID/GID `1100`. SMB3 exposes + `Archive` to Vault-backed authorized accounts and admits the configured LAN without host-specific exclusions. +- Atlas NPM and Immich share a rootful Podman network. NPM publishes HTTP/HTTPS, but its administration port remains + bound to `127.0.0.1:81`; do not expose it directly to the LAN or Internet. ## Atlas NAS TODO - Replace every Atlas `CHANGEME` value, provide the required Vault variables and validate the first remote bootstrap on the real Rocky Linux 9 host. Enable `atlas_manage_storage` first and - `atlas_manage_firewall` only after confirming the pool, mountpoints, LAN subnet and firewalld zone. + `atlas_manage_firewall` only after confirming the pool, mountpoints, LAN subnet and firewalld zone. Enable + `atlas_manage_media_stack` last, after validating `/dev/dri`, the container paths and the Immich database secret. - Validate the complete baseline on the target: OpenZFS kmod loading, existing pool import, dataset mounts, SSH reconnect, Cockpit and all selected 45Drives plugins, NFSv4, SMB and Syncthing. - Finalize dataset properties and the shared UID/GID, group and POSIX ACL model; test the same files @@ -140,8 +159,8 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i or manual operations, not as the only source of configuration, and never automate snapshot rollback. - Manage the Syncthing star topology, device IDs, folders, folder modes, ignore rules and protected GUI or API access for the selected clients. -- Add Tailscale or WireGuard and corresponding LAN/VPN-only firewalld rules before enabling remote - services; never expose SSH, Cockpit, NFS, SMB or Syncthing through public port forwarding. +- Validate the existing WireGuard path and add its LAN/VPN-only firewalld rules before enabling remote services; + never expose SSH, Cockpit, NFS, SMB or Syncthing through public port forwarding. - Add the least-privilege Prometheus backup flow: remote dump generation, dedicated SSH identity, pinned host key, atomic pull, verification, retention and an Atlas systemd service/timer. - Add the encrypted offsite backup with Borg to a Hetzner Storage Box: use a dedicated SSH identity, diff --git a/README.it.md b/README.it.md index 664c877..9385bc5 100644 --- a/README.it.md +++ b/README.it.md @@ -180,7 +180,10 @@ Lo stato attuale del profilo server include: - installazione pacchetti Rocky via DNF, EPEL e CRB - installazione di Podman e podman-compose - abilitazione dei servizi systemd dichiarati in inventory/group vars -- copia dei dotfiles server e rendering dei template server, incluso il `docker-compose.yml` dello stack servizi e dell'unit `podman-compose-server` (attivazione manuale) +- copia dei dotfiles server e rendering del `docker-compose.yml` per Nginx Proxy Manager, Gitea e il + database PostgreSQL esistente di Navidrome, piu l'unita `podman-compose-server` (attivazione manuale) +- mount di `/pool/media/music` da Atlas su `/mnt/music_atlas` tramite il servizio di sistema + `rclone-music.service`, e Navidrome tramite Quadlet utente rootless - attivazione di firewalld con SSH, Cockpit (`9090/tcp`), HTTP e HTTPS abilitati - Syncthing escluso dal profilo server Rocky @@ -188,6 +191,18 @@ Nginx Proxy Manager pubblica solo `80/tcp` e `443/tcp`; la sua interfaccia di am associata a `127.0.0.1:81` ed e raggiungibile da Ikaros o Nymph con l'alias Bash `npm-tunnel`. Nextcloud resta disabilitato e il profilo non crea directory `/srv/nextcloud`. +Il mount musicale e protetto da `server_atlas_music_enabled`. Prima di abilitarlo, sostituire +l'indirizzo WireGuard e la chiave host SSH fissata in `host_vars/prometheus.yml`, quindi fornire +`vault_prometheus_atlas_sftp_private_key` tramite Vault cifrato o variabili locali non tracciate. La +chiave pubblica corrispondente deve essere gia presente nelle chiavi autorizzate gestite su Atlas. +Rclone usa il percorso remoto esatto `/pool/media/music` in sola lettura e una cache VFS completa da +`15G`; systemd lingering mantiene disponibile il manager utente per il Quadlet rootless. +Configurare il proxy host NPM di Prometheus per Navidrome come `host.containers.internal:4533`; la +porta Navidrome non viene aperta in firewalld. +Prima della prima attivazione, arrestare il vecchio container rootful `navidrome`. Il ruolo rifiuta +di avviare il sostituto rootless mentre il container precedente e in esecuzione e non rimuove mai +automaticamente il container o i dati esistenti. + ### DuckDNS `profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello @@ -239,9 +254,10 @@ solo i dataset figli e non deve mai creare, partizionare, distruggere, fare roll pool. I client Linux usano NFSv4, quelli Windows/WSL SMB; entrambi restano limitati alla LAN configurata. -Per il primo avvio sostituire i placeholder Atlas e fornire -`vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash` e -`vault_atlas_samba_password`. Eseguire il bootstrap tramite l'amministratore esistente: +Per il primo avvio sostituire i placeholder di host, pool, mount root, LAN e IP di Aegis e +fornire `vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash`, +`vault_atlas_samba_password` e `vault_atlas_immich_db_password`. Eseguire il bootstrap tramite +l'amministratore esistente: ```bash ansible-playbook ansible/site.yml --limit atlas \ @@ -251,11 +267,26 @@ ansible-playbook ansible/site.yml --limit atlas \ `vault_atlas_admin_password_hash` deve essere un hash compatibile con `/etc/shadow`, non una password Cockpit in chiaro. Le esecuzioni successive usano `atlas_admin_username`. Abilitare `atlas_manage_storage` solo dopo aver verificato pool e mountpoint esistenti; abilitare -`atlas_manage_firewall` solo dopo aver verificato subnet LAN e zona firewalld attiva. +`atlas_manage_firewall` solo dopo aver verificato subnet LAN e zona firewalld attiva. Abilitare +`atlas_manage_media_stack` per ultimo, dopo aver verificato `/dev/dri`, i percorsi dei container e il +segreto del database Immich. -Restano da implementare retention delle snapshot, topologia Syncthing, VPN, pull da Prometheus, -backup cifrati con Borg su una Hetzner Storage Box, backup USB, monitoraggio e test di disaster -recovery. Il backlog operativo dettagliato e in `AGENTS.md`. +Con la gestione storage attiva, Atlas crea `archive` (`zstd`), `media/music` (`lz4`), +`media/icloud_photos` (`lz4`) e `backups/services` (`lz4`, `refreservation=500G`) sotto il pool +preesistente. I dataset esistenti Work, Syncthing e backup Prometheus restano gestiti e separati. +SMB3 pubblica `Archive` solo agli account Samba configurati con password in Vault e ammette la LAN +configurata senza esclusioni specifiche per host. NFSv4 esporta soltanto +`media/icloud_photos` all'IP configurato di Aegis con `all_squash` verso UID/GID anonimi `1100`. + +L'account di sistema `immich` usa UID/GID `1100`, shell senza login, nessuna appartenenza a `wheel` e +i gruppi supplementari `video` e `render`. I Quadlet rootful di Immich Server, ML, cache compatibile +Redis, PostgreSQL e NPM condividono una rete Podman. Immich viene eseguito come `1100:1100`; Server e +ML ricevono `/dev/dri` e la libreria iCloud Photos e montata in sola lettura. NPM pubblica `80` e +`443`, mentre l'amministrazione resta vincolata a `127.0.0.1:81` per l'accesso tramite tunnel SSH. + +Restano da completare retention delle snapshot, topologia Syncthing, validazione WireGuard/firewall, +pull di backup da Prometheus, backup cifrati con Borg su una Hetzner Storage Box, backup USB, +monitoraggio e test di disaster recovery. Il backlog operativo dettagliato e in `AGENTS.md`. --- @@ -348,7 +379,8 @@ Questo significa che, allo stato attuale: - `deadalus` riceve il profilo Fedora WSL tramite play dev dedicati - il server Rocky (`prometheus`) e gestito con pacchetti, servizi, dotfiles server e firewalld - il NAS Rocky (`atlas`) usa un pool ZFS gia esistente, condivisioni NFSv4/SMB limitate alla LAN e Cockpit/45Drives -- lo stack container server include `navidrome`, `postgres`, `gitea` e `nginx-proxy-manager` +- lo stack Compose server include `gitea`, `nginx-proxy-manager` e il database PostgreSQL di + Navidrome; Navidrome usa un Quadlet rootless separato e legge il mount rclone di Atlas # Dotfiles @@ -455,6 +487,8 @@ ansible-playbook ansible/site.yml --limit --start-at-task "" - ansible-lint ansible/roles/ yamllint ansible/path/to/file.yml podman-compose -f /opt/docker/server/docker-compose.yml config +ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff +ansible-playbook ansible/site.yml --limit prometheus --tags rclone,navidrome --check --diff ``` ## Tag supportati dal playbook @@ -471,6 +505,8 @@ Allo stato attuale `ansible/site.yml` espone questi tag: | --- | --- | --- | | `always` | pre-task sempre eseguiti, inclusi caricamento vault e validazioni preliminari | common | | `ai_agents` | installazione agenti AI condivisi | Fedora, WSL | +| `atlas` | account, storage, condivisioni e container Atlas | NAS Atlas | +| `containers` | Quadlet rootful Atlas | NAS Atlas | | `dotfiles` | distribuzione/configurazione dotfiles | tutti i profili | | `dotfiles:common` | dotfiles comuni condivisi | common, workstation, server | | `dotfiles:desktop` | dotfiles desktop | desktop Void, Fedora/GNOME | @@ -484,13 +520,19 @@ Allo stato attuale `ansible/site.yml` espone questi tag: | `fzf` | configurazione FZF | dotfiles comuni | | `git` | configurazione Git e GPG desktop | Fedora/GNOME, desktop Void | | `gnome` | configurazione host GNOME | Fedora/GNOME desktop | +| `immich` | account e Quadlet Immich | NAS Atlas | +| `navidrome` | mount rclone e Quadlet Navidrome rootless | Prometheus | | `sway` | sessione/configurazione sway / SwayFX (Wayland) | desktop Void | | `niri` | sessione/configurazione Niri (Wayland) | desktop Void | | `npm` | installazione pacchetti npm globali | Fedora/GNOME, desktop Void, WSL | | `nvidia` | componenti NVIDIA desktop | desktop Void | | `packages` | installazione e aggiornamento pacchetti | tutti i profili | +| `podman` | integrazione Podman Compose e Quadlet rootless | server | +| `rclone` | mount musica Atlas | Prometheus | | `portal` | configurazione xdg-desktop-portal | desktop Void | | `services` | gestione servizi runit/systemd | tutti i profili | +| `sharing` | condivisioni NFSv4 e SMB3 | NAS Atlas | +| `storage` | dataset ZFS figli | NAS Atlas | | `theme` | configurazione del tema GTK/Qt | desktop Void | | `tmux` | configurazione e plugin tmux | desktop Fedora/Void, WSL | | `vim` | configurazione Vim | dotfiles comuni | diff --git a/README.md b/README.md index 8fe62a9..12d93e1 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,24 @@ That gives it Fedora packages through DNF, Docker from the official repository, dotfiles and templates. The profile provisions configuration only: it does not transfer data, start the Compose stack, update DNS, or perform a cutover. -The server profile installs platform-specific packages, Podman and podman-compose, -declared systemd services, the server Compose stack behind the `podman-compose-server` systemd unit, and firewalld. The Rocky server excludes -Syncthing. Rocky bind mounts use private SELinux relabeling for application data while host system -files remain unchanged. +The server profile installs platform-specific packages, Podman and podman-compose, declared systemd +services, and firewalld. The manually activated `podman-compose-server` unit now contains Nginx Proxy +Manager, Gitea, and the existing Navidrome PostgreSQL database. Navidrome itself runs as a rootless +user Quadlet and reads the Atlas music dataset from the system `rclone-music.service` mount at +`/mnt/music_atlas`. The Rocky server excludes Syncthing. +Rocky bind mounts use private SELinux relabeling where supported; the read-only FUSE music mount is +passed to Navidrome without relabeling. + +The Atlas music path is gated by `server_atlas_music_enabled`. Before enabling it, replace the +WireGuard address and pinned SSH host-key placeholders in `host_vars/prometheus.yml`, and provide +`vault_prometheus_atlas_sftp_private_key` through encrypted Vault or untracked local vars. The SFTP +key's public half must already be present in Atlas' managed authorized keys. Rclone mounts the exact +remote path `/pool/media/music` read-only and uses a `15G` full VFS cache; the rootless user manager is +kept alive through systemd lingering. +Configure the Prometheus NPM proxy host for Navidrome as `host.containers.internal:4533`; the +Navidrome port is not opened through firewalld. +Before the first enablement, stop the legacy rootful `navidrome` container. The role refuses to start +the rootless replacement while that container is running and never removes the old container or data. Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes only `80/tcp` and `443/tcp`; its administration interface is bound to `127.0.0.1:81` and can be reached @@ -201,9 +215,10 @@ manages child datasets and must never create, partition, destroy, roll back, or pool itself. Linux clients use NFSv4 and Windows/WSL clients use SMB; both are restricted to the configured LAN. -For the first run, replace the Atlas placeholders and provide -`vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash`, and -`vault_atlas_samba_password`. Bootstrap the host through its existing administrator: +For the first run, replace the Atlas host, pool, mount-root, LAN, and Aegis-IP +placeholders and provide `vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash`, +`vault_atlas_samba_password`, and `vault_atlas_immich_db_password`. Bootstrap the host through its +existing administrator: ```bash ansible-playbook ansible/site.yml --limit atlas \ @@ -211,13 +226,27 @@ ansible-playbook ansible/site.yml --limit atlas \ ``` `vault_atlas_admin_password_hash` must be an `/etc/shadow`-compatible hash, not a clear-text -Cockpit password. Subsequent runs use `atlas_admin_username`. Enable -`atlas_manage_storage` only after checking the existing pool and mountpoints; enable -`atlas_manage_firewall` only after checking the LAN subnet and active firewalld zone. +Cockpit password. Subsequent runs use `atlas_admin_username`. Enable `atlas_manage_storage` only after +checking the existing pool and mountpoints; enable `atlas_manage_firewall` only after checking the LAN +subnet and active firewalld zone. Enable `atlas_manage_media_stack` last, after validating `/dev/dri`, +the container paths and the Immich database secret. -Snapshot retention, Syncthing topology, VPN access, Prometheus pulls, encrypted Borg backups to a -Hetzner Storage Box, USB backup, monitoring, and disaster-recovery tests remain follow-up work. The -detailed operational backlog is kept in `AGENTS.md`. +With storage management enabled, Atlas creates `archive` (`zstd`), `media/music` (`lz4`), +`media/icloud_photos` (`lz4`), and `backups/services` (`lz4`, `refreservation=500G`) beneath the +pre-existing pool. The existing Work, Syncthing, and Prometheus-backup datasets remain managed and +separate. SMB3 exposes `Archive` only to the configured Vault-backed Samba accounts and admits the +configured LAN without host-specific exclusions. NFSv4 exports only +`media/icloud_photos` to the configured Aegis IP, using `all_squash` with anonymous UID/GID `1100`. + +The `immich` system account is fixed to UID/GID `1100`, has no login shell or `wheel` membership, and +receives `video` and `render` access. The rootful Immich Server, ML, Redis-compatible cache, PostgreSQL, +and NPM Quadlets share one Podman network. Immich runs as `1100:1100`; Server and ML receive `/dev/dri`, +and iCloud Photos is mounted read-only as an external library. NPM publishes ports `80` and `443`; its +administration interface remains restricted to `127.0.0.1:81` for SSH-tunnel access. + +Snapshot retention, Syncthing topology, WireGuard/firewall validation, Prometheus backup pulls, +encrypted Borg backups to a Hetzner Storage Box, USB backup, monitoring, and disaster-recovery tests +remain follow-up work. The detailed operational backlog is kept in `AGENTS.md`. ## How layering works @@ -384,6 +413,8 @@ ansible-playbook ansible/site.yml --limit --start-at-task "" - ansible-lint ansible/roles/ yamllint ansible/path/to/file.yml podman-compose -f /opt/docker/server/docker-compose.yml config +ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff +ansible-playbook ansible/site.yml --limit prometheus --tags rclone,navidrome --check --diff ``` ## Tags @@ -398,6 +429,8 @@ ansible-playbook ansible/site.yml --list-tags | --- | --- | | `always` | Common pre-tasks, including optional vault loading. | | `ai_agents` | AI coding-agent install, configuration deployment, and managed-binary removal. | +| `atlas` | Atlas NAS account, storage, sharing, and container configuration. | +| `containers` | Rootful Atlas Quadlets. | | `dotfiles` | User configuration across all profiles. | | `dotfiles:common` | Shared dotfiles. | | `dotfiles:desktop` | Void and Fedora/GNOME desktop dotfiles. | @@ -406,9 +439,15 @@ ansible-playbook ansible/site.yml --list-tags | `dotfiles:workstation` | Personal workstation and WSL dotfiles. | | `emacs` | Shared Emacs setup and authoring dependencies. | | `gnome` | Fedora/GNOME desktop configuration. | +| `immich` | Atlas Immich account and Quadlets. | +| `navidrome` | Prometheus rclone mount and rootless Navidrome Quadlet. | | `npm` | Global npm packages. | | `packages` | Package installation and updates. | +| `podman` | Podman Compose and rootless Quadlet integration. | +| `rclone` | Prometheus Atlas music mount. | | `services` | runit and systemd services. | +| `sharing` | Atlas NFSv4 and SMB3 configuration. | +| `storage` | Atlas child ZFS datasets. | | `tmux` | tmux configuration and plugins. | | `wsl` | WSL bootstrap and configuration. | diff --git a/ansible/inventory/group_vars/server.yml b/ansible/inventory/group_vars/server.yml index 1456571..102942c 100644 --- a/ansible/inventory/group_vars/server.yml +++ b/ansible/inventory/group_vars/server.yml @@ -6,6 +6,29 @@ effective_username: "{{ server_username }}" effective_user_group: "{{ server_user_group }}" effective_user_home: "{{ server_user_home }}" server_container_stack_dir: /opt/docker/server +server_atlas_music_enabled: false +server_atlas_sftp_remote_name: atlas +server_atlas_sftp_host: CHANGEME_ATLAS_WIREGUARD_IP +server_atlas_sftp_port: 22 +server_atlas_sftp_username: admin +server_atlas_sftp_remote_path: /pool/media/music +server_atlas_sftp_private_key: "{{ vault_prometheus_atlas_sftp_private_key | default('') }}" +server_atlas_sftp_private_key_file: /etc/rclone/atlas_ed25519 +server_atlas_sftp_known_host: "" +server_atlas_sftp_known_hosts_file: /etc/rclone/known_hosts +server_atlas_wireguard_unit: "" +server_rclone_config_dir: /etc/rclone +server_rclone_config_file: /etc/rclone/rclone.conf +server_rclone_music_service: rclone-music.service +server_rclone_music_mountpoint: /mnt/music_atlas +server_rclone_music_cache_dir: /var/cache/rclone-music +server_navidrome_data_dir: /opt/navidrome/data +server_navidrome_quadlet_dir: "{{ server_user_home }}/.config/containers/systemd" +server_navidrome_env_dir: "{{ server_user_home }}/.config/navidrome" +server_navidrome_env_file: "{{ server_navidrome_env_dir }}/navidrome.env" +server_navidrome_image: docker.io/deluan/navidrome:latest +server_navidrome_port: 4533 +server_navidrome_db_password: "{{ vault_navidrome_db_password | default('') }}" ai_agents: {} vim_plugins_enabled: false @@ -62,11 +85,7 @@ server_directories: owner: root group: root mode: "0755" - - path: /opt/navidrome/data - owner: "{{ server_username }}" - group: "{{ server_user_group }}" - mode: "0755" - - path: /opt/music + - path: "{{ server_navidrome_data_dir }}" owner: "{{ server_username }}" group: "{{ server_user_group }}" mode: "0755" diff --git a/ansible/inventory/host_vars/atlas.yml b/ansible/inventory/host_vars/atlas.yml index 6f3c86d..e1237ee 100644 --- a/ansible/inventory/host_vars/atlas.yml +++ b/ansible/inventory/host_vars/atlas.yml @@ -1,10 +1,10 @@ --- -# Replace every CHANGEME value before enabling Atlas storage management. +# Keep Atlas management gates disabled until the NAS and required Vault inputs are ready. hostname: atlas -ansible_host: CHANGEME_ATLAS_HOST +ansible_host: 192.168.178.55 ansible_user: "{{ atlas_connection_username }}" ansible_port: 22 -ansible_ssh_private_key_file: CHANGEME_ATLAS_SSH_PRIVATE_KEY_PATH +ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519 atlas_admin_username: admin atlas_connection_username: "{{ atlas_admin_username }}" @@ -15,46 +15,67 @@ atlas_admin_gid: 1000 atlas_admin_ssh_keys: "{{ vault_atlas_authorized_ssh_keys | default([]) }}" atlas_admin_password_hash: "{{ vault_atlas_admin_password_hash | default('!') }}" -atlas_lan_subnet: CHANGEME_LAN_SUBNET +atlas_lan_subnet: 192.168.178.0/24 +atlas_aegis_ip: 192.168.178.54 atlas_manage_firewall: false atlas_firewalld_zone: public -atlas_zfs_pool: CHANGEME_ZFS_POOL +atlas_zfs_pool: zpool atlas_zfs_dataset_work: work +atlas_zfs_dataset_archive: archive +atlas_zfs_dataset_music: media/music atlas_zfs_dataset_syncthing: syncthing atlas_zfs_dataset_backup_prometheus: backup_prometheus -atlas_zfs_dataset_icloud_photos: icloud_photos -atlas_mount_root: /CHANGEME_ATLAS_MOUNT_ROOT +atlas_zfs_dataset_icloud_photos: media/icloud_photos +atlas_zfs_dataset_service_backups: backups/services +atlas_mount_root: /zpool atlas_manage_storage: false +atlas_manage_media_stack: false rocky_manage_openzfs_repo: true rocky_manage_syncthing_binary: true +rocky_manage_podman: true +rocky_podman_packages: + - podman host_packages: - cockpit - nfs-utils - policycoreutils-python-utils - samba + - samba-client + - samba-common-tools - zfs atlas_nfs_exports: - - path: "{{ atlas_work_mountpoint }}" - clients: "{{ atlas_lan_subnet }}(rw,sync,no_subtree_check,root_squash)" + - path: "{{ atlas_icloud_photos_mountpoint }}" + client: "{{ atlas_aegis_ip }}" + options: + - rw + - sync + - no_subtree_check + - all_squash + - "anonuid={{ atlas_immich_uid }}" + - "anongid={{ atlas_immich_gid }}" -atlas_samba_share_name: work +atlas_samba_share_name: Archive atlas_samba_workgroup: WORKGROUP -atlas_samba_valid_users: - - "{{ atlas_admin_username }}" -atlas_samba_password: "{{ vault_atlas_samba_password | default('') }}" +# Append any pre-existing laptop Unix account here and keep its Samba password in Vault. +atlas_samba_accounts: + - username: "{{ atlas_admin_username }}" + password: "{{ vault_atlas_samba_password | default('') }}" +atlas_samba_valid_users: "{{ atlas_samba_accounts | map(attribute='username') | list }}" + +atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}" atlas_firewalld_rich_rules: - 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="ssh" accept' - 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="cockpit" accept' - - 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="2049" protocol="tcp" accept' + - 'rule family="ipv4" source address="{{ atlas_aegis_ip }}" service name="nfs" accept' - 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="samba" accept' + - 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="http" accept' + - 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="https" accept' host_enabled_services: - sshd - cockpit.socket - - nfs-server - - smb - zfs.target diff --git a/ansible/inventory/host_vars/prometheus.yml b/ansible/inventory/host_vars/prometheus.yml index a9a49c9..b0b7c49 100644 --- a/ansible/inventory/host_vars/prometheus.yml +++ b/ansible/inventory/host_vars/prometheus.yml @@ -7,11 +7,19 @@ ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519 server_username: rocky server_duckdns_domain: fscotto +server_atlas_music_enabled: false +server_atlas_sftp_host: CHANGEME_ATLAS_WIREGUARD_IP +server_atlas_sftp_username: admin +server_atlas_sftp_known_host: CHANGEME_ATLAS_SSH_HOST_KEY +server_atlas_wireguard_unit: wg-quick@wg0.service +server_atlas_sftp_private_key: "{{ vault_prometheus_atlas_sftp_private_key | default('') }}" host_packages: - cockpit - cockpit-navigator - cockpit-podman + - fuse3 + - rclone host_enabled_services: - cockpit.socket diff --git a/ansible/roles/profile_atlas/defaults/main.yml b/ansible/roles/profile_atlas/defaults/main.yml index 558889e..2887e39 100644 --- a/ansible/roles/profile_atlas/defaults/main.yml +++ b/ansible/roles/profile_atlas/defaults/main.yml @@ -8,20 +8,36 @@ atlas_admin_gid: 1000 atlas_admin_ssh_keys: [] atlas_admin_password_hash: "!" +atlas_immich_username: immich +atlas_immich_group: immich +atlas_immich_uid: 1100 +atlas_immich_gid: 1100 +atlas_immich_supplementary_groups: + - video + - render + atlas_lan_subnet: CHANGEME_LAN_SUBNET +atlas_aegis_ip: CHANGEME_AEGIS_IP atlas_manage_firewall: false atlas_firewalld_zone: public atlas_zfs_pool: CHANGEME_ZFS_POOL atlas_zfs_dataset_work: work +atlas_zfs_dataset_archive: archive +atlas_zfs_dataset_music: media/music atlas_zfs_dataset_syncthing: syncthing atlas_zfs_dataset_backup_prometheus: backup_prometheus -atlas_zfs_dataset_icloud_photos: icloud_photos +atlas_zfs_dataset_icloud_photos: media/icloud_photos +atlas_zfs_dataset_service_backups: backups/services +atlas_zfs_service_backups_refreservation: 500G atlas_mount_root: /CHANGEME_ATLAS_MOUNT_ROOT +atlas_archive_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_archive }}" +atlas_music_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_music }}" atlas_work_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_work }}" atlas_syncthing_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_syncthing }}" atlas_backup_prometheus_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backup_prometheus }}" atlas_icloud_photos_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_icloud_photos }}" +atlas_service_backups_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_service_backups }}" atlas_syncthing_config_dir: "{{ atlas_admin_home }}/.local/state/syncthing" atlas_syncthing_default_dir: "{{ atlas_syncthing_mountpoint }}/Sync" @@ -34,18 +50,59 @@ atlas_45drives_packages: - cockpit-zfs - cockpit-scheduler -atlas_nfs_exports: [] -atlas_samba_share_name: work +atlas_nfs_exports: + - path: "{{ atlas_icloud_photos_mountpoint }}" + client: "{{ atlas_aegis_ip }}" + options: + - rw + - sync + - no_subtree_check + - all_squash + - "anonuid={{ atlas_immich_uid }}" + - "anongid={{ atlas_immich_gid }}" +atlas_samba_share_name: Archive atlas_samba_workgroup: WORKGROUP atlas_samba_valid_users: [] -atlas_samba_password: "" -atlas_samba_password_marker: /var/lib/samba/private/.atlas-password.sha256 +atlas_samba_accounts: [] +atlas_samba_password_marker_dir: /var/lib/samba/private/ansible-passwords atlas_firewalld_rich_rules: [] atlas_firewalld_restricted_services: - ssh - cockpit - nfs - samba + - http + - https atlas_selinux_booleans: - samba_export_all_rw - nfs_export_all_rw + +atlas_manage_media_stack: false +atlas_container_quadlet_dir: /etc/containers/systemd +atlas_container_data_root: /var/lib/atlas-containers +atlas_npm_data_dir: "{{ atlas_container_data_root }}/npm/data" +atlas_npm_letsencrypt_dir: "{{ atlas_container_data_root }}/npm/letsencrypt" +atlas_npm_admin_bind_address: 127.0.0.1 +atlas_timezone: Europe/Rome +atlas_immich_upload_dir: "{{ atlas_container_data_root }}/immich/upload" +atlas_immich_model_cache_dir: "{{ atlas_container_data_root }}/immich/model-cache" +atlas_immich_machine_learning_config_dir: "{{ atlas_container_data_root }}/immich/machine-learning-config" +atlas_immich_machine_learning_cache_dir: "{{ atlas_container_data_root }}/immich/machine-learning-cache" +atlas_immich_redis_data_dir: "{{ atlas_container_data_root }}/immich/redis" +atlas_immich_postgres_data_dir: "{{ atlas_container_data_root }}/immich/postgres" + +atlas_npm_image: docker.io/jc21/nginx-proxy-manager:latest +atlas_immich_version: release +atlas_immich_server_image: "ghcr.io/immich-app/immich-server:{{ atlas_immich_version }}" +atlas_immich_machine_learning_image: "ghcr.io/immich-app/immich-machine-learning:{{ atlas_immich_version }}-openvino" +atlas_immich_redis_image: docker.io/valkey/valkey:9 +atlas_immich_postgres_image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 +atlas_immich_db_username: postgres +atlas_immich_db_name: immich +atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}" +atlas_media_quadlet_services: + - atlas-immich-redis.service + - atlas-immich-postgres.service + - atlas-immich-machine-learning.service + - atlas-immich-server.service + - atlas-npm.service diff --git a/ansible/roles/profile_atlas/handlers/main.yml b/ansible/roles/profile_atlas/handlers/main.yml index 089f865..3e67ded 100644 --- a/ansible/roles/profile_atlas/handlers/main.yml +++ b/ansible/roles/profile_atlas/handlers/main.yml @@ -32,3 +32,15 @@ ansible.builtin.systemd: name: atlas-syncthing state: restarted + +- name: Restart Atlas media Quadlets + ansible.builtin.systemd: + name: "{{ item }}" + state: restarted + daemon_reload: true + loop: "{{ atlas_media_quadlet_services }}" + loop_control: + label: "{{ item }}" + when: + - atlas_manage_media_stack | bool + - not ansible_check_mode diff --git a/ansible/roles/profile_atlas/tasks/account.yml b/ansible/roles/profile_atlas/tasks/account.yml index 242ab08..483bce2 100644 --- a/ansible/roles/profile_atlas/tasks/account.yml +++ b/ansible/roles/profile_atlas/tasks/account.yml @@ -6,6 +6,7 @@ - atlas_admin_username != 'CHANGEME_ATLAS_ADMIN' - (atlas_admin_ssh_keys | default([])) | length > 0 - atlas_admin_password_hash not in ['', '!', '*'] + - "'wheel' not in atlas_immich_supplementary_groups" fail_msg: >- Define atlas_admin_username, vault_atlas_authorized_ssh_keys and vault_atlas_admin_password_hash before applying the Atlas profile. @@ -31,6 +32,41 @@ state: present no_log: true +- name: Create Immich primary group + tags: [atlas, accounts, containers, immich] + ansible.builtin.group: + name: "{{ atlas_immich_group }}" + gid: "{{ atlas_immich_gid }}" + state: present + +- name: Ensure Immich hardware-access groups exist + tags: [atlas, accounts, containers, immich] + ansible.builtin.group: + name: "{{ item }}" + state: present + loop: "{{ atlas_immich_supplementary_groups }}" + loop_control: + label: "{{ item }}" + +- name: Create unprivileged Immich account + tags: [atlas, accounts, containers, immich] + ansible.builtin.user: + name: "{{ atlas_immich_username }}" + uid: "{{ atlas_immich_uid }}" + group: "{{ atlas_immich_group }}" + groups: "{{ atlas_immich_supplementary_groups }}" + append: false + home: /nonexistent + create_home: false + shell: /sbin/nologin + system: true + state: present + +- name: Read Immich hardware-access group IDs + tags: [atlas, accounts, containers, immich] + ansible.builtin.getent: + database: group + - name: Grant Atlas administrator passwordless sudo tags: [atlas, services] ansible.builtin.copy: diff --git a/ansible/roles/profile_atlas/tasks/containers.yml b/ansible/roles/profile_atlas/tasks/containers.yml new file mode 100644 index 0000000..fd21fbf --- /dev/null +++ b/ansible/roles/profile_atlas/tasks/containers.yml @@ -0,0 +1,140 @@ +--- +- name: Require completed Atlas media-stack configuration + tags: [atlas, containers, immich] + ansible.builtin.assert: + that: + - atlas_manage_storage | bool + - atlas_zfs_pool != 'CHANGEME_ZFS_POOL' + - atlas_mount_root != '/CHANGEME_ATLAS_MOUNT_ROOT' + - atlas_immich_db_password | length > 0 + - atlas_npm_admin_bind_address == '127.0.0.1' + fail_msg: >- + Enable and configure Atlas storage, provide vault_atlas_immich_db_password, + and keep the NPM administration port bound to loopback before enabling the media stack. + no_log: true + when: atlas_manage_media_stack | bool + +- name: Check Atlas Intel graphics device + tags: [atlas, containers, immich] + ansible.builtin.stat: + path: /dev/dri + register: atlas_dri_device + when: atlas_manage_media_stack | bool + +- name: Require Atlas Intel graphics device + tags: [atlas, containers, immich] + ansible.builtin.assert: + that: + - atlas_dri_device.stat.isdir | default(false) + fail_msg: /dev/dri is required for Immich QuickSync and OpenVINO acceleration. + when: atlas_manage_media_stack | bool + +- name: Create Atlas rootful Quadlet directory + tags: [atlas, containers] + ansible.builtin.file: + path: "{{ atlas_container_quadlet_dir }}" + state: directory + owner: root + group: root + mode: "0755" + when: atlas_manage_media_stack | bool + +- name: Create Atlas Immich configuration directory + tags: [atlas, containers, immich] + ansible.builtin.file: + path: /etc/immich + state: directory + owner: root + group: "{{ atlas_immich_group }}" + mode: "0750" + when: atlas_manage_media_stack | bool + +- name: Create Atlas Immich persistent directories + tags: [atlas, containers, immich] + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ atlas_immich_username }}" + group: "{{ atlas_immich_group }}" + mode: "0750" + loop: + - "{{ atlas_immich_upload_dir }}" + - "{{ atlas_immich_model_cache_dir }}" + - "{{ atlas_immich_machine_learning_config_dir }}" + - "{{ atlas_immich_machine_learning_cache_dir }}" + - "{{ atlas_immich_redis_data_dir }}" + - "{{ atlas_immich_postgres_data_dir }}" + loop_control: + label: "{{ item }}" + when: atlas_manage_media_stack | bool + +- name: Create Atlas NPM persistent directories + tags: [atlas, containers, npm] + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0750" + loop: + - "{{ atlas_npm_data_dir }}" + - "{{ atlas_npm_letsencrypt_dir }}" + loop_control: + label: "{{ item }}" + when: atlas_manage_media_stack | bool + +- name: Allow confined containers to use Atlas graphics devices + tags: [atlas, containers, immich] + ansible.posix.seboolean: + name: container_use_devices + state: true + persistent: true + when: + - atlas_manage_media_stack | bool + - (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled' + +- name: Render Vault-backed Immich environment + tags: [atlas, containers, immich] + ansible.builtin.template: + src: immich.env.j2 + dest: /etc/immich/immich.env + owner: root + group: "{{ atlas_immich_group }}" + mode: "0640" + no_log: true + diff: false + notify: Restart Atlas media Quadlets + when: atlas_manage_media_stack | bool + +- name: Render Atlas media Quadlets + tags: [atlas, containers] + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "{{ atlas_container_quadlet_dir }}/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - atlas-media.network + - atlas-immich-redis.container + - atlas-immich-postgres.container + - atlas-immich-machine-learning.container + - atlas-immich-server.container + - atlas-npm.container + loop_control: + label: "{{ item }}" + notify: Restart Atlas media Quadlets + when: atlas_manage_media_stack | bool + +- name: Start Atlas media Quadlet services + tags: [atlas, containers, services] + ansible.builtin.systemd: + name: "{{ item }}" + state: started + daemon_reload: true + loop: "{{ atlas_media_quadlet_services }}" + loop_control: + label: "{{ item }}" + when: + - atlas_manage_media_stack | bool + - not ansible_check_mode diff --git a/ansible/roles/profile_atlas/tasks/main.yml b/ansible/roles/profile_atlas/tasks/main.yml index 0ed92c4..1199b57 100644 --- a/ansible/roles/profile_atlas/tasks/main.yml +++ b/ansible/roles/profile_atlas/tasks/main.yml @@ -11,5 +11,8 @@ - name: Import Atlas file sharing tasks ansible.builtin.import_tasks: sharing.yml +- name: Import Atlas media-container tasks + ansible.builtin.import_tasks: containers.yml + - name: Import Atlas Syncthing tasks ansible.builtin.import_tasks: syncthing.yml diff --git a/ansible/roles/profile_atlas/tasks/samba_account.yml b/ansible/roles/profile_atlas/tasks/samba_account.yml new file mode 100644 index 0000000..4e00e6a --- /dev/null +++ b/ansible/roles/profile_atlas/tasks/samba_account.yml @@ -0,0 +1,64 @@ +--- +- name: Require an existing Unix account for Atlas Samba + ansible.builtin.getent: + database: passwd + key: "{{ atlas_samba_account.username }}" + +- name: Read Atlas Samba account + ansible.builtin.command: + argv: + - pdbedit + - --list + - --user + - "{{ atlas_samba_account.username }}" + register: atlas_samba_account_check + changed_when: false + failed_when: false + +- name: Check Atlas Samba password marker + ansible.builtin.stat: + path: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256" + register: atlas_samba_password_marker_stat + +- name: Read Atlas Samba password marker + ansible.builtin.slurp: + src: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256" + register: atlas_samba_password_marker_content + no_log: true + when: atlas_samba_password_marker_stat.stat.exists + +- name: Determine whether Atlas Samba credentials must change + ansible.builtin.set_fact: + atlas_samba_password_digest: "{{ atlas_samba_account.password | hash('sha256') }}" + atlas_samba_password_update_required: >- + {{ + atlas_samba_account_check.rc != 0 + or not atlas_samba_password_marker_stat.stat.exists + or ( + atlas_samba_password_marker_content.content | default('') + | b64decode | trim + ) != (atlas_samba_account.password | hash('sha256')) + }} + no_log: true + +- name: Set Atlas Samba account password + ansible.builtin.command: + argv: + - smbpasswd + - -s + - -a + - "{{ atlas_samba_account.username }}" + stdin: "{{ atlas_samba_account.password }}\n{{ atlas_samba_account.password }}" + changed_when: true + no_log: true + when: atlas_samba_password_update_required | bool + +- name: Record managed Atlas Samba password digest + ansible.builtin.copy: + content: "{{ atlas_samba_password_digest }}\n" + dest: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256" + owner: root + group: root + mode: "0600" + no_log: true + when: atlas_samba_password_update_required | bool diff --git a/ansible/roles/profile_atlas/tasks/sharing.yml b/ansible/roles/profile_atlas/tasks/sharing.yml index 913fa29..703abef 100644 --- a/ansible/roles/profile_atlas/tasks/sharing.yml +++ b/ansible/roles/profile_atlas/tasks/sharing.yml @@ -10,6 +10,19 @@ notify: Reload NFS exports when: atlas_manage_storage | bool +- name: Enable Atlas file-sharing services + tags: [atlas, sharing, services] + ansible.builtin.systemd: + name: "{{ item }}" + enabled: true + state: started + loop: + - nfs-server.service + - smb.service + loop_control: + label: "{{ item }}" + when: atlas_manage_storage | bool + - name: Configure Atlas NFSv4-only service tags: [atlas, sharing] ansible.builtin.template: @@ -82,106 +95,50 @@ notify: Restart Samba service when: atlas_manage_storage | bool -- name: Require Atlas Samba password +- name: Require Vault-backed Atlas Samba accounts tags: [atlas, sharing] ansible.builtin.assert: that: - - atlas_samba_password | length > 0 - fail_msg: Define vault_atlas_samba_password before enabling Atlas storage. + - atlas_samba_accounts | length > 0 + - >- + atlas_samba_accounts | map(attribute='username') | list + | difference(atlas_samba_valid_users) | length == 0 + - >- + atlas_samba_valid_users + | difference(atlas_samba_accounts | map(attribute='username') | list) | length == 0 + - atlas_samba_accounts | selectattr('password', 'equalto', '') | list | length == 0 + fail_msg: Define every authorized Samba account and its Vault-backed password. no_log: true when: atlas_manage_storage | bool -- name: Read Atlas Samba account - tags: [atlas, sharing] - ansible.builtin.command: - argv: - - pdbedit - - --list - - --user - - "{{ atlas_admin_username }}" - register: atlas_samba_account - changed_when: false - failed_when: false - when: atlas_manage_storage | bool - - name: Ensure Atlas Samba private state directory exists tags: [atlas, sharing] ansible.builtin.file: - path: "{{ atlas_samba_password_marker | dirname }}" + path: "{{ atlas_samba_password_marker_dir }}" state: directory owner: root group: root mode: "0700" when: atlas_manage_storage | bool -- name: Check Atlas Samba password marker +- name: Manage Vault-backed Atlas Samba credentials tags: [atlas, sharing] - ansible.builtin.stat: - path: "{{ atlas_samba_password_marker }}" - register: atlas_samba_password_marker_stat - when: atlas_manage_storage | bool - -- name: Read Atlas Samba password marker - tags: [atlas, sharing] - ansible.builtin.slurp: - src: "{{ atlas_samba_password_marker }}" - register: atlas_samba_password_marker_content - no_log: true - when: - - atlas_manage_storage | bool - - atlas_samba_password_marker_stat.stat.exists - -- name: Determine whether Atlas Samba credentials must change - tags: [atlas, sharing] - ansible.builtin.set_fact: - atlas_samba_password_digest: "{{ atlas_samba_password | hash('sha256') }}" - atlas_samba_password_update_required: >- - {{ - atlas_samba_account.rc != 0 - or not atlas_samba_password_marker_stat.stat.exists - or ( - atlas_samba_password_marker_content.content | default('') - | b64decode | trim - ) != (atlas_samba_password | hash('sha256')) - }} + ansible.builtin.include_tasks: samba_account.yml + loop: "{{ atlas_samba_accounts }}" + loop_control: + loop_var: atlas_samba_account + label: "{{ atlas_samba_account.username }}" no_log: true when: atlas_manage_storage | bool -- name: Set Atlas Samba account password - tags: [atlas, sharing] - ansible.builtin.command: - argv: - - smbpasswd - - -s - - -a - - "{{ atlas_admin_username }}" - stdin: "{{ atlas_samba_password }}\n{{ atlas_samba_password }}" - changed_when: true - no_log: true - when: - - atlas_manage_storage | bool - - atlas_samba_password_update_required | bool - -- name: Record managed Atlas Samba password digest - tags: [atlas, sharing] - ansible.builtin.copy: - content: "{{ atlas_samba_password_digest }}\n" - dest: "{{ atlas_samba_password_marker }}" - owner: root - group: root - mode: "0600" - no_log: true - when: - - atlas_manage_storage | bool - - atlas_samba_password_update_required | bool - - name: Require completed Atlas firewall placeholders tags: [atlas, sharing, services] ansible.builtin.assert: that: - atlas_lan_subnet != 'CHANGEME_LAN_SUBNET' + - atlas_aegis_ip != 'CHANGEME_AEGIS_IP' - atlas_firewalld_zone | length > 0 - fail_msg: Replace the Atlas LAN subnet and firewall zone placeholders. + fail_msg: Replace the Atlas LAN, Aegis and firewall-zone placeholders. when: atlas_manage_firewall | bool - name: Apply Atlas firewalld rich rules diff --git a/ansible/roles/profile_atlas/tasks/storage.yml b/ansible/roles/profile_atlas/tasks/storage.yml index fb783dc..51b0404 100644 --- a/ansible/roles/profile_atlas/tasks/storage.yml +++ b/ansible/roles/profile_atlas/tasks/storage.yml @@ -5,12 +5,52 @@ atlas_zfs_datasets: - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_work }}" mountpoint: "{{ atlas_work_mountpoint }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + properties: + mountpoint: "{{ atlas_work_mountpoint }}" + - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_archive }}" + mountpoint: "{{ atlas_archive_mountpoint }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + properties: + compression: zstd + mountpoint: "{{ atlas_archive_mountpoint }}" + - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_music }}" + mountpoint: "{{ atlas_music_mountpoint }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + properties: + compression: lz4 + mountpoint: "{{ atlas_music_mountpoint }}" - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_syncthing }}" mountpoint: "{{ atlas_syncthing_mountpoint }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + properties: + compression: lz4 + mountpoint: "{{ atlas_syncthing_mountpoint }}" - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backup_prometheus }}" mountpoint: "{{ atlas_backup_prometheus_mountpoint }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + properties: + mountpoint: "{{ atlas_backup_prometheus_mountpoint }}" - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_icloud_photos }}" mountpoint: "{{ atlas_icloud_photos_mountpoint }}" + owner: "{{ atlas_immich_username }}" + group: "{{ atlas_immich_group }}" + properties: + compression: lz4 + mountpoint: "{{ atlas_icloud_photos_mountpoint }}" + - name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_service_backups }}" + mountpoint: "{{ atlas_service_backups_mountpoint }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + properties: + compression: lz4 + mountpoint: "{{ atlas_service_backups_mountpoint }}" + refreservation: "{{ atlas_zfs_service_backups_refreservation }}" - name: Require completed Atlas storage placeholders tags: [atlas, storage] @@ -56,8 +96,7 @@ community.general.zfs: name: "{{ item.name }}" state: present - extra_zfs_properties: - mountpoint: "{{ item.mountpoint }}" + extra_zfs_properties: "{{ item.properties }}" loop: "{{ atlas_zfs_datasets }}" loop_control: label: "{{ item.name }}" @@ -68,8 +107,8 @@ ansible.builtin.file: path: "{{ item.mountpoint }}" state: directory - owner: "{{ atlas_admin_username }}" - group: "{{ atlas_admin_group }}" + owner: "{{ item.owner }}" + group: "{{ item.group }}" mode: "0770" loop: "{{ atlas_zfs_datasets }}" loop_control: diff --git a/ansible/roles/profile_atlas/templates/atlas-immich-machine-learning.container.j2 b/ansible/roles/profile_atlas/templates/atlas-immich-machine-learning.container.j2 new file mode 100644 index 0000000..d61bbda --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-immich-machine-learning.container.j2 @@ -0,0 +1,27 @@ +# Managed by Ansible. Do not edit manually. +[Unit] +Description=Atlas Immich machine learning + +[Container] +ContainerName=atlas-immich-machine-learning +Image={{ atlas_immich_machine_learning_image }} +User={{ atlas_immich_uid }}:{{ atlas_immich_gid }} +{% for group_name in atlas_immich_supplementary_groups %} +GroupAdd={{ ansible_facts['getent_group'][group_name][1] }} +{% endfor %} +AddDevice=/dev/dri +Network=atlas-media.network +NetworkAlias=atlas-immich-machine-learning +Environment=TZ={{ atlas_timezone }} +Volume={{ atlas_immich_model_cache_dir }}:/cache:Z +Volume={{ atlas_immich_machine_learning_config_dir }}:/.config:Z +Volume={{ atlas_immich_machine_learning_cache_dir }}:/.cache:Z +NoNewPrivileges=true +DropCapability=NET_RAW + +[Service] +Restart=always +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/profile_atlas/templates/atlas-immich-postgres.container.j2 b/ansible/roles/profile_atlas/templates/atlas-immich-postgres.container.j2 new file mode 100644 index 0000000..88f46fc --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-immich-postgres.container.j2 @@ -0,0 +1,22 @@ +# Managed by Ansible. Do not edit manually. +[Unit] +Description=Atlas Immich PostgreSQL database + +[Container] +ContainerName=atlas-immich-postgres +Image={{ atlas_immich_postgres_image }} +User={{ atlas_immich_uid }}:{{ atlas_immich_gid }} +Network=atlas-media.network +NetworkAlias=atlas-immich-postgres +EnvironmentFile=/etc/immich/immich.env +Volume={{ atlas_immich_postgres_data_dir }}:/var/lib/postgresql/data:Z +ShmSize=128m +NoNewPrivileges=true +DropCapability=NET_RAW + +[Service] +Restart=always +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/profile_atlas/templates/atlas-immich-redis.container.j2 b/ansible/roles/profile_atlas/templates/atlas-immich-redis.container.j2 new file mode 100644 index 0000000..9ddfaa5 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-immich-redis.container.j2 @@ -0,0 +1,20 @@ +# Managed by Ansible. Do not edit manually. +[Unit] +Description=Atlas Immich Redis-compatible cache + +[Container] +ContainerName=atlas-immich-redis +Image={{ atlas_immich_redis_image }} +User={{ atlas_immich_uid }}:{{ atlas_immich_gid }} +Network=atlas-media.network +NetworkAlias=atlas-immich-redis +Volume={{ atlas_immich_redis_data_dir }}:/data:Z +NoNewPrivileges=true +DropCapability=NET_RAW + +[Service] +Restart=always +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/profile_atlas/templates/atlas-immich-server.container.j2 b/ansible/roles/profile_atlas/templates/atlas-immich-server.container.j2 new file mode 100644 index 0000000..9111a07 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-immich-server.container.j2 @@ -0,0 +1,29 @@ +# Managed by Ansible. Do not edit manually. +[Unit] +Description=Atlas Immich server +Requires=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service +After=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service + +[Container] +ContainerName=atlas-immich-server +Image={{ atlas_immich_server_image }} +User={{ atlas_immich_uid }}:{{ atlas_immich_gid }} +{% for group_name in atlas_immich_supplementary_groups %} +GroupAdd={{ ansible_facts['getent_group'][group_name][1] }} +{% endfor %} +AddDevice=/dev/dri +Network=atlas-media.network +NetworkAlias=atlas-immich-server +EnvironmentFile=/etc/immich/immich.env +Volume={{ atlas_immich_upload_dir }}:/data:Z +Volume={{ atlas_icloud_photos_mountpoint }}:/external/icloud_photos:ro,Z +Volume=/etc/localtime:/etc/localtime:ro +NoNewPrivileges=true +DropCapability=NET_RAW + +[Service] +Restart=always +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/profile_atlas/templates/atlas-media.network.j2 b/ansible/roles/profile_atlas/templates/atlas-media.network.j2 new file mode 100644 index 0000000..d7e0911 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-media.network.j2 @@ -0,0 +1,3 @@ +# Managed by Ansible. Do not edit manually. +[Network] +NetworkName=atlas-media diff --git a/ansible/roles/profile_atlas/templates/atlas-npm.container.j2 b/ansible/roles/profile_atlas/templates/atlas-npm.container.j2 new file mode 100644 index 0000000..cccc55e --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-npm.container.j2 @@ -0,0 +1,21 @@ +# Managed by Ansible. Do not edit manually. +[Unit] +Description=Atlas Nginx Proxy Manager + +[Container] +ContainerName=atlas-npm +Image={{ atlas_npm_image }} +Network=atlas-media.network +NetworkAlias=atlas-npm +PublishPort=80:80 +PublishPort=443:443 +PublishPort={{ atlas_npm_admin_bind_address }}:81:81 +Volume={{ atlas_npm_data_dir }}:/data:Z +Volume={{ atlas_npm_letsencrypt_dir }}:/etc/letsencrypt:Z + +[Service] +Restart=always +TimeoutStartSec=900 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/profile_atlas/templates/atlas.exports.j2 b/ansible/roles/profile_atlas/templates/atlas.exports.j2 index b99caef..48d8210 100644 --- a/ansible/roles/profile_atlas/templates/atlas.exports.j2 +++ b/ansible/roles/profile_atlas/templates/atlas.exports.j2 @@ -1,4 +1,4 @@ # Managed by Ansible. Do not edit manually. {% for export in atlas_nfs_exports %} -{{ export.path }} {{ export.clients }} +{{ export.path }} {{ export.client }}({{ export.options | join(',') }}) {% endfor %} diff --git a/ansible/roles/profile_atlas/templates/immich.env.j2 b/ansible/roles/profile_atlas/templates/immich.env.j2 new file mode 100644 index 0000000..3b4a7aa --- /dev/null +++ b/ansible/roles/profile_atlas/templates/immich.env.j2 @@ -0,0 +1,13 @@ +# Managed by Ansible. Contains a Vault secret; do not edit or copy. +TZ={{ atlas_timezone | to_json }} +DB_HOSTNAME="atlas-immich-postgres" +DB_USERNAME={{ atlas_immich_db_username | to_json }} +DB_PASSWORD={{ atlas_immich_db_password | to_json }} +DB_DATABASE_NAME={{ atlas_immich_db_name | to_json }} +DB_VECTOR_EXTENSION="vectorchord" +POSTGRES_USER={{ atlas_immich_db_username | to_json }} +POSTGRES_PASSWORD={{ atlas_immich_db_password | to_json }} +POSTGRES_DB={{ atlas_immich_db_name | to_json }} +POSTGRES_INITDB_ARGS="--data-checksums" +REDIS_HOSTNAME="atlas-immich-redis" +IMMICH_MACHINE_LEARNING_URL="http://atlas-immich-machine-learning:3003" diff --git a/ansible/roles/profile_atlas/templates/smb.conf.j2 b/ansible/roles/profile_atlas/templates/smb.conf.j2 index 2f9e23a..519cfa3 100644 --- a/ansible/roles/profile_atlas/templates/smb.conf.j2 +++ b/ansible/roles/profile_atlas/templates/smb.conf.j2 @@ -3,11 +3,12 @@ workgroup = {{ atlas_samba_workgroup }} security = user map to guest = Never - server min protocol = SMB2 + server min protocol = SMB3 + hosts allow = {{ atlas_lan_subnet }} include = registry [{{ atlas_samba_share_name }}] - path = {{ atlas_work_mountpoint }} + path = {{ atlas_archive_mountpoint }} browseable = yes read only = no valid users = {{ atlas_samba_valid_users | join(' ') }} diff --git a/ansible/roles/profile_server/handlers/main.yml b/ansible/roles/profile_server/handlers/main.yml index fd614d2..d5852e1 100644 --- a/ansible/roles/profile_server/handlers/main.yml +++ b/ansible/roles/profile_server/handlers/main.yml @@ -9,3 +9,29 @@ tags: [services] ansible.builtin.systemd: daemon_reload: true + +- name: Restart rclone music mount + tags: [services, rclone, navidrome] + ansible.builtin.systemd: + name: "{{ server_rclone_music_service }}" + state: restarted + daemon_reload: true + when: + - server_atlas_music_enabled | bool + - not ansible_check_mode + +- name: Restart rootless Navidrome + tags: [services, podman, rclone, navidrome] + become_user: "{{ server_username }}" + ansible.builtin.systemd: + name: navidrome.service + scope: user + state: restarted + daemon_reload: true + environment: + XDG_RUNTIME_DIR: "/run/user/{{ ansible_facts['getent_passwd'][server_username][1] }}" + DBUS_SESSION_BUS_ADDRESS: >- + unix:path=/run/user/{{ ansible_facts['getent_passwd'][server_username][1] }}/bus + when: + - server_atlas_music_enabled | bool + - not ansible_check_mode diff --git a/ansible/roles/profile_server/tasks/main.yml b/ansible/roles/profile_server/tasks/main.yml index f3149aa..2627faf 100644 --- a/ansible/roles/profile_server/tasks/main.yml +++ b/ansible/roles/profile_server/tasks/main.yml @@ -8,15 +8,16 @@ fail_msg: >- server_firewall_backend must be firewalld for the Rocky server profile. -- name: Require server container secret variables - tags: [dotfiles, dotfiles:server, services] +- name: Require server database secret variables + tags: [dotfiles, dotfiles:server, services, navidrome] ansible.builtin.assert: that: - - (vault_navidrome_db_password | default('')) | length > 0 + - server_navidrome_db_password | length > 0 - (vault_postgres_root_password | default('')) | length > 0 fail_msg: >- - Server container secrets are missing. Define vault_navidrome_db_password and - vault_postgres_root_password in secrets/vault.yml or another vars source. + Define vault_navidrome_db_password and vault_postgres_root_password in Vault + before rendering the Navidrome database configuration. + no_log: true - name: Configure DuckDNS updater tags: [dotfiles, dotfiles:server, duckdns] @@ -71,6 +72,12 @@ tags: [services, podman] ansible.builtin.include_tasks: podman-compose.yml +- name: Configure Atlas music mount + ansible.builtin.import_tasks: rclone-music.yml + +- name: Configure rootless Navidrome + ansible.builtin.import_tasks: navidrome.yml + - name: Disable SSH root login on server tags: [services] ansible.builtin.lineinfile: diff --git a/ansible/roles/profile_server/tasks/navidrome.yml b/ansible/roles/profile_server/tasks/navidrome.yml new file mode 100644 index 0000000..5009007 --- /dev/null +++ b/ansible/roles/profile_server/tasks/navidrome.yml @@ -0,0 +1,114 @@ +--- +- name: Read server account details for rootless Navidrome + tags: [services, podman, rclone, navidrome] + ansible.builtin.getent: + database: passwd + key: "{{ server_username }}" + when: server_atlas_music_enabled | bool + +- name: Check for a running legacy rootful Navidrome container + tags: [services, podman, navidrome] + ansible.builtin.command: + argv: + - podman + - container + - inspect + - --format + - "{{ '{{.State.Running}}' }}" + - navidrome + register: server_legacy_navidrome_container + changed_when: false + failed_when: false + check_mode: false + when: server_atlas_music_enabled | bool + +- name: Refuse concurrent legacy and rootless Navidrome + tags: [services, podman, navidrome] + ansible.builtin.assert: + that: + - >- + server_legacy_navidrome_container.rc != 0 + or server_legacy_navidrome_container.stdout | trim != 'true' + fail_msg: >- + Stop the legacy rootful Navidrome container before enabling the rootless Quadlet. + The playbook does not remove the old container or its data automatically. + when: server_atlas_music_enabled | bool + +- name: Enable lingering for rootless Navidrome + tags: [services, podman, navidrome] + ansible.builtin.command: + argv: + - loginctl + - enable-linger + - "{{ server_username }}" + creates: "/var/lib/systemd/linger/{{ server_username }}" + when: server_atlas_music_enabled | bool + +- name: Start server user systemd manager + tags: [services, podman, navidrome] + ansible.builtin.systemd: + name: "user@{{ ansible_facts['getent_passwd'][server_username][1] }}.service" + state: started + when: + - server_atlas_music_enabled | bool + - not ansible_check_mode + +- name: Create rootless Navidrome Quadlet directory + tags: [services, podman, navidrome] + ansible.builtin.file: + path: "{{ server_navidrome_quadlet_dir }}" + state: directory + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0700" + when: server_atlas_music_enabled | bool + +- name: Create private rootless Navidrome configuration directory + tags: [services, podman, navidrome] + ansible.builtin.file: + path: "{{ server_navidrome_env_dir }}" + state: directory + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0700" + when: server_atlas_music_enabled | bool + +- name: Render Vault-backed rootless Navidrome environment + tags: [services, podman, navidrome] + ansible.builtin.template: + src: navidrome.env.j2 + dest: "{{ server_navidrome_env_file }}" + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0600" + no_log: true + diff: false + notify: Restart rootless Navidrome + when: server_atlas_music_enabled | bool + +- name: Render rootless Navidrome Quadlet + tags: [services, podman, navidrome] + ansible.builtin.template: + src: navidrome.container.j2 + dest: "{{ server_navidrome_quadlet_dir }}/navidrome.container" + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0644" + notify: Restart rootless Navidrome + when: server_atlas_music_enabled | bool + +- name: Start rootless Navidrome Quadlet + tags: [services, podman, navidrome] + become_user: "{{ server_username }}" + ansible.builtin.systemd: + name: navidrome.service + scope: user + state: started + daemon_reload: true + environment: + XDG_RUNTIME_DIR: "/run/user/{{ ansible_facts['getent_passwd'][server_username][1] }}" + DBUS_SESSION_BUS_ADDRESS: >- + unix:path=/run/user/{{ ansible_facts['getent_passwd'][server_username][1] }}/bus + when: + - server_atlas_music_enabled | bool + - not ansible_check_mode diff --git a/ansible/roles/profile_server/tasks/rclone-music.yml b/ansible/roles/profile_server/tasks/rclone-music.yml new file mode 100644 index 0000000..ef6e555 --- /dev/null +++ b/ansible/roles/profile_server/tasks/rclone-music.yml @@ -0,0 +1,105 @@ +--- +- name: Require completed Atlas SFTP mount configuration + tags: [services, rclone, navidrome] + ansible.builtin.assert: + that: + - server_atlas_sftp_host != 'CHANGEME_ATLAS_WIREGUARD_IP' + - server_atlas_sftp_known_host != 'CHANGEME_ATLAS_SSH_HOST_KEY' + - server_atlas_sftp_known_host | length > 0 + - server_atlas_sftp_private_key | length > 0 + - server_atlas_sftp_remote_path == '/pool/media/music' + fail_msg: >- + Define the Atlas WireGuard address, pinned SSH host key and Vault-backed + SFTP private key before enabling the music mount. + no_log: true + when: server_atlas_music_enabled | bool + +- name: Create private rclone configuration directory + tags: [services, rclone, navidrome] + ansible.builtin.file: + path: "{{ server_rclone_config_dir }}" + state: directory + owner: root + group: "{{ server_user_group }}" + mode: "0750" + when: server_atlas_music_enabled | bool + +- name: Install Vault-backed Atlas SFTP private key + tags: [services, rclone, navidrome] + ansible.builtin.copy: + content: "{{ server_atlas_sftp_private_key | trim }}\n" + dest: "{{ server_atlas_sftp_private_key_file }}" + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0600" + no_log: true + diff: false + notify: + - Restart rclone music mount + - Restart rootless Navidrome + when: server_atlas_music_enabled | bool + +- name: Install pinned Atlas SSH host key + tags: [services, rclone, navidrome] + ansible.builtin.copy: + content: "{{ server_atlas_sftp_known_host | trim }}\n" + dest: "{{ server_atlas_sftp_known_hosts_file }}" + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0640" + notify: + - Restart rclone music mount + - Restart rootless Navidrome + when: server_atlas_music_enabled | bool + +- name: Render Atlas SFTP rclone configuration + tags: [services, rclone, navidrome] + ansible.builtin.template: + src: rclone.conf.j2 + dest: "{{ server_rclone_config_file }}" + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0600" + notify: + - Restart rclone music mount + - Restart rootless Navidrome + when: server_atlas_music_enabled | bool + +- name: Create rclone music directories + tags: [services, rclone, navidrome] + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ server_username }}" + group: "{{ server_user_group }}" + mode: "0750" + loop: + - "{{ server_rclone_music_mountpoint }}" + - "{{ server_rclone_music_cache_dir }}" + loop_control: + label: "{{ item }}" + when: server_atlas_music_enabled | bool + +- name: Render rclone music system service + tags: [services, rclone, navidrome] + ansible.builtin.template: + src: rclone-music.service.j2 + dest: "/etc/systemd/system/{{ server_rclone_music_service }}" + owner: root + group: root + mode: "0644" + notify: + - Restart rclone music mount + - Restart rootless Navidrome + when: server_atlas_music_enabled | bool + +- name: Enable and start rclone music mount + tags: [services, rclone, navidrome] + ansible.builtin.systemd: + name: "{{ server_rclone_music_service }}" + enabled: true + state: started + daemon_reload: true + when: + - server_atlas_music_enabled | bool + - not ansible_check_mode diff --git a/ansible/roles/profile_server/templates/navidrome.container.j2 b/ansible/roles/profile_server/templates/navidrome.container.j2 new file mode 100644 index 0000000..0c2e754 --- /dev/null +++ b/ansible/roles/profile_server/templates/navidrome.container.j2 @@ -0,0 +1,21 @@ +# Managed by Ansible. Do not edit manually. +[Unit] +Description=Rootless Navidrome music server + +[Container] +ContainerName=navidrome +Image={{ server_navidrome_image }} +Network=host +EnvironmentFile={{ server_navidrome_env_file }} +Volume={{ server_navidrome_data_dir }}:/data +Volume={{ server_rclone_music_mountpoint }}:/music:ro +SecurityLabelDisable=true + +[Service] +ExecStartPre=/usr/bin/mountpoint -q {{ server_rclone_music_mountpoint }} +Restart=always +RestartSec=10s +TimeoutStartSec=900 + +[Install] +WantedBy=default.target diff --git a/ansible/roles/profile_server/templates/navidrome.env.j2 b/ansible/roles/profile_server/templates/navidrome.env.j2 new file mode 100644 index 0000000..73c2596 --- /dev/null +++ b/ansible/roles/profile_server/templates/navidrome.env.j2 @@ -0,0 +1,7 @@ +# Managed by Ansible. Contains a Vault secret; do not edit or copy. +ND_DATABASE_URL={{ ('postgres://navidrome:' ~ server_navidrome_db_password ~ '@127.0.0.1:5432/navidrome_db?sslmode=disable') | to_json }} +ND_PORT={{ server_navidrome_port }} +ND_SCANSCHEDULE="1h" +ND_SESSIONTIMEOUT="24h" +ND_ENABLETRANSCODING="true" +ND_LOGLEVEL="info" diff --git a/ansible/roles/profile_server/templates/rclone-music.service.j2 b/ansible/roles/profile_server/templates/rclone-music.service.j2 new file mode 100644 index 0000000..6c9a3e0 --- /dev/null +++ b/ansible/roles/profile_server/templates/rclone-music.service.j2 @@ -0,0 +1,29 @@ +[Unit] +Description=Read-only Atlas music mount via rclone SFTP +Wants=network-online.target +After=network-online.target +{% if server_atlas_wireguard_unit | length > 0 %} +Wants={{ server_atlas_wireguard_unit }} +After={{ server_atlas_wireguard_unit }} +{% endif %} + +[Service] +Type=notify +User={{ server_username }} +Group={{ server_user_group }} +ExecStart=/usr/bin/rclone mount \ + {{ server_atlas_sftp_remote_name }}:{{ server_atlas_sftp_remote_path }} \ + {{ server_rclone_music_mountpoint }} \ + --config {{ server_rclone_config_file }} \ + --cache-dir {{ server_rclone_music_cache_dir }} \ + --read-only \ + --vfs-cache-mode full \ + --vfs-cache-max-size 15G \ + --vfs-read-chunk-size 5M \ + --dir-cache-time 24h +ExecStop=-/usr/bin/fusermount3 -uz {{ server_rclone_music_mountpoint }} +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/profile_server/templates/rclone.conf.j2 b/ansible/roles/profile_server/templates/rclone.conf.j2 new file mode 100644 index 0000000..863ebd0 --- /dev/null +++ b/ansible/roles/profile_server/templates/rclone.conf.j2 @@ -0,0 +1,9 @@ +# Managed by Ansible. Authentication uses the separately deployed Vault-backed key. +[{{ server_atlas_sftp_remote_name }}] +type = sftp +host = {{ server_atlas_sftp_host }} +user = {{ server_atlas_sftp_username }} +port = {{ server_atlas_sftp_port }} +key_file = {{ server_atlas_sftp_private_key_file }} +known_hosts_file = {{ server_atlas_sftp_known_hosts_file }} +shell_type = unix diff --git a/ansible/templates/server/docker-compose.yml.j2 b/ansible/templates/server/docker-compose.yml.j2 index f900afc..0ca56c1 100644 --- a/ansible/templates/server/docker-compose.yml.j2 +++ b/ansible/templates/server/docker-compose.yml.j2 @@ -3,25 +3,6 @@ name: server services: - navidrome: - image: docker.io/deluan/navidrome:latest - container_name: navidrome - restart: unless-stopped - expose: - - "4533" - environment: - ND_DATABASE_URL: "postgres://navidrome:{{ vault_navidrome_db_password }}@navidromedb:5432/navidrome_db?sslmode=disable" - ND_SESSIONTIMEOUT: 24h - ND_ENABLETRANSCODING: "true" - - volumes: - - "/opt/navidrome/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" - - "/opt/music:/music:ro{{ ',' ~ selinux_volume_option if selinux_volume_option else '' }}" - user: "1000:1000" - networks: - - web - depends_on: - - navidromedb nginx-proxy-manager: image: docker.io/jc21/nginx-proxy-manager:latest container_name: nginx-proxy-manager @@ -30,6 +11,8 @@ services: - "80:80" - "443:443" - "127.0.0.1:81:81" + extra_hosts: + - "host.containers.internal:host-gateway" volumes: - "/opt/npm/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" - "/opt/npm/letsencrypt:/etc/letsencrypt{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" @@ -64,11 +47,13 @@ services: POSTGRES_DB: "navidrome_db" POSTGRES_USER: "navidrome" POSTGRES_PASSWORD: "{{ vault_postgres_root_password }}" - volumes: - "/opt/postgres/data:/var/lib/postgresql/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}" + ports: + - "127.0.0.1:5432:5432" networks: - web + gitea: image: docker.gitea.com/gitea:1.25.2 container_name: gitea diff --git a/secrets/vault.yml b/secrets/vault.yml index 4db5cca..06dd91f 100644 --- a/secrets/vault.yml +++ b/secrets/vault.yml @@ -1,45 +1,61 @@ $ANSIBLE_VAULT;1.1;AES256 -62633966363435396136666635303362663932366464323231363563373462383134326361346533 -3465353461326565356435633866393230383838356235340a333036383038653134323066663031 -37373135643331356366373831343163363835656462343962636538623937356231376337616330 -3831626261343761310a306133633264613430613165343265303664383439333230663665333231 -30393934323438333066383737663665316634613932353232343334626261636661393434646163 -63316537383866353338353261373161623562343037383032633932383630666339633234326639 -64303735663532376433393464616230616236353134383734623132333061356664613039323265 -35373434336261313438633866643765323330623336343732346465613264613666616631303633 -31333437623437626135623032306434306538323461623161336431353630336539646531313365 -30356330356438353037663262643463363636386162393764323133373261376263663338636333 -33336537383061643234356365653137333639623863303530666665373663376166316533623737 -31616262363132326133666230396636663035343432633830323430336332326261613932633837 -62303232306331663936366435666164383335313335326662663363643563643938623739386663 -65356431353362623634333437346566363166366161313132653131613861386437306562633934 -38306135636161366530303461636665386639396639356265323361623639616265323462333534 -61626439616631356532353437653539333631636265346238653439313130326239633035613130 -65366161356231356638313633393735356465393339343034356138316366656431336435386437 -36366339366463396638303062366263356436666232393437613766623631313264646565316534 -32373534366363373737616563616161346265323936336665653934656365323731633637376331 -31336262356632313533393366346565373434373134653630303431643037363561613830663339 -39313837356362643164386432653030306132643234303162363463376130666239653461623736 -65333734303532356462636538326430353831336562666563336333323764346234643037373064 -63343533386564356232633661346232666135393365323133323365663637373261396264333432 -64616463643931653162373666323731653635616633386231653863623830666637623638353062 -61363339613364343239363336626266386561343032333262396436663736353835303863643865 -35616136363830326661383066316537633537616565343361633036373163316566653638343638 -33323162376537353961383965666165663631396663356533643432356630396435356636366636 -32363264303633656335333335356166323164386338363437663435336635643334646532616539 -66646336393834613337336661336638613463626230336132386265396165656461623533333738 -33353063306336623463396130386335383338383861376330616566643638333230303862356462 -63356464313263666538306230636363643665643933323236363864303634343539326239363134 -65376231623836363730376631303032393330326362383438373434383633656134373539653461 -38306632636463346365396131316234623736626335633361663861316437326635633237346262 -38643730666130656335663232366362333164353433613731363131366138633766623433363031 -37613337383961386661393733623238393062663065663931313231366366626261666336656134 -63646237643864643462653337306462646631663433663461343764306163646466326334323335 -61396162353032633937333066313438333461376161666166336337356430373737313266363238 -63633365613331636234333765633536653639343234396262653438353132623239303431316134 -35663930323535373830313766353630636237343330346232316637336531336466326564306630 -62356639356330396465353432333135313130653366303931616165663638633461613961643766 -62633564666566616665366336396436386330323536356633323965343566396232303434666534 -33333636313136326535303136643265393834613733343362346333336339636437353236326161 -65633230656531633238623830343432663365313532343435623133636239333763633930316666 -34363535313731666432 +38633239386338323332633564613266393665666562353139646234343261303131336663316634 +6535313265356434373464326664303937353132643636630a373966646565663564613463643732 +35646136346564636565616134303064313636643263343765396138653830646664633366656365 +3137643164396265650a663338346161333832643732653539366462323039313563383735626661 +62626461656636366634353731613932653235323463336263363866336466356534636435313831 +64373235383930663232613932623935346465643633666233386461346635353663323638633964 +61366664323631313631663662643437386365616162333161636166666635636633333061323739 +38386330326534303966613165656363633838313366666164656133386263646561306139383032 +66646133643239396436653564323837623031393439303938323138326330636633613864623565 +62616335633563333962653561633665643336303230623934356565363531323237643433306436 +33363238306438333765323034636261333830653836656538636662303363363733356462353764 +34636136666563333138383663643931363039303737313763373765356336656564633665666530 +32346262636530646365636565656139623639346262373963346462626638316162616666373339 +33353031313365356537646237356139613834343737343361636133343837613638643432346134 +64313232646638633035383234303934666139616162356431306161353866316136663830343737 +38646564393936383939373435313363653338653737346333643165626437386366346339303764 +34623735613130643636623733353161393034303964656635366466396362393435376565313832 +32623936646534656166383930666133663132663363623435393434663734363533653036663230 +30663130626237303938613236306465613534346466663333663432316539653836323030643636 +64646564643435373938363430303531333461613438633435356231656666373261636135396534 +66343435663835646161376636633662353337386238316131623737353364383130653334643864 +63353836303230303263343939373436646262346565313039323037383966636636656638366231 +31343431373632363031346635663834393036623166346262393736346361323837303462346130 +66643563306633323433616461343364326162336435363534326237366665316638353365376138 +61633263643637323031613462303138303133623136323637633862623032623465616538643331 +31343134353166383964666431353231636339636366626164346461386339356437386261336365 +36373936356534623035663531353132633837333366663732393661613162316234373137333861 +34656465343231623532343231646565316664373662626134346530653164393966333738656130 +62663238373135643033396334386236313531646131313765323039306431323938386163316434 +64616530343431303865336431666662343766303931653336376131313633306431346165623335 +64383262626332336430373337313462613766613931613337393335393237616463646631623562 +35343938323933663835313334306536343231663866616566653434366564313134393831343362 +63396630366338626133383232666265383266396166306536633233306139666531613031643262 +37356262363466303032336264333864646632373533396239393565653538383161653762313432 +37313230666237626530316133393134303133613463363362323132653932333663313236623639 +65326665626432386132323131353838346439393061343963616565306464636264626630396230 +31373963326134383762373737626238353964383565336132623836333535313635316637653966 +39386563396661663439343463353262656237616633646466323638346362376335306330373161 +37343431316163626365386130383862643461363132636138646634663832383933303937643934 +34326131353262643165336138643835333066663432353263333939633534383137393136633034 +35386538616633336230353332306631353566343831376662306264386330623631653437393666 +30646438666663383334333431336230373635663162626661373539336434646131346238623435 +32303361366565353161646136386237366639343664616637303232376232316334366437636630 +37633866376433633537373166613436303335663766303539336332366231353435646163386538 +66323963303034393237323035366634303736653735623937323763336234376563663563333132 +39646237306334326635353563326536633764306534313535383836633033373832316336383530 +33316432356366363934666264306661373037663563333165633132643834353262376661636463 +63326662663739616139393438393734353833376239363531376261303639376363646532666465 +66356533393331393535393161653131343536353535373432323463346331646663353439383237 +37386262633765326331383335613263653331303561343636626337333265313633373533363331 +32373564646464626633323833663432663562363639373931623535306534646664346165356161 +33306330363239386439646563353865646339323633646535316336363239303564373937636233 +37396434353830386130316237386532333939363330306165633630336662333537663265363736 +61636437623832383032643539626338313932303335333461363566363532663633353631353933 +62376531303638303838663737373462323339633632626439353439396139656639373331656636 +61303531383433343961666336363231353638656137666364323133326233623964363536303065 +33623130653564363261653333343732653539386565326133386165313536613732366463303537 +63616538383536636232636562653863323334633839656366373761626561656462316161323630 +33396566333463326136626231653536336134653232306536396434636438366335633137623433 +31636362366431396364 diff --git a/secrets/vault.yml.example b/secrets/vault.yml.example index 0152974..e91c5a9 100644 --- a/secrets/vault.yml.example +++ b/secrets/vault.yml.example @@ -13,3 +13,10 @@ vault_navidrome_db_password: "REPLACE_ME" vault_postgres_root_password: "REPLACE_ME" vault_ikaros_authorized_ssh_keys: - "ssh-ed25519 REPLACE_ME" +vault_atlas_authorized_ssh_keys: + - "ssh-ed25519 REPLACE_ME atlas-admin" +vault_atlas_admin_password_hash: "REPLACE_WITH_A_SHADOW_COMPATIBLE_HASH" +vault_atlas_samba_password: "REPLACE_ME" +vault_atlas_immich_db_password: "REPLACE_ME" +vault_prometheus_atlas_sftp_private_key: | + REPLACE_WITH_A_DEDICATED_ATLAS_SFTP_PRIVATE_KEY