Compare commits

...

10 Commits

Author SHA1 Message Date
Fabio Scotto di Santolo
def3dbf313 Deploy temporary Atlas Nextcloud and ONLYOFFICE stack 2026-10-03 17:42:16 +02:00
Fabio Scotto di Santolo
a00602973c Remove completed Atlas Gitea migration tooling 2026-10-03 15:48:41 +02:00
Fabio Scotto di Santolo
18eb2d2eb2 Document confirmed Gitea domain transition completion 2026-10-03 15:15:21 +02:00
Fabio Scotto di Santolo
269fb13665 Document Gitea domain and retire Prometheus DuckDNS 2026-10-03 15:09:34 +02:00
Fabio Scotto di Santolo
2dfe766b7b Enable boot startup for Atlas iCloudPD 2026-10-03 13:59:05 +02:00
Fabio Scotto di Santolo
755f24bc72 Retire Prometheus Compose stack and document cleanup 2026-10-03 13:44:57 +02:00
Fabio Scotto di Santolo
7bc7f0e645 Feature/prometheus npm quadlet (#15)
* Stage Prometheus NPM Quadlet with backup-safe cutover

* Complete Prometheus NPM Quadlet cutover
2026-10-03 11:53:12 +02:00
Fabio Scotto di Santolo
1577eec19d Merge branch 'feature/gitea-https-validation' 2026-10-03 10:19:09 +02:00
Fabio Scotto di Santolo
e30683c3d1 Record Gitea HTTPS validation 2026-10-03 10:18:25 +02:00
Fabio Scotto di Santolo
4bd6aafb53 Feature/atlas icloudpd migration (#14)
* Design gated Atlas iCloudPD migration target

* Target Atlas iCloudPD photos to Photobook

* Record isolated iCloudPD Photobook ACL validation

* Record Aegis iCloudPD source audit gap

* Verify iCloudPD backup source scope and Borg access

* Record Atlas iCloudPD deployment gate checks

* Pin iCloudPD photo file and directory modes

* Validate inactive iCloudPD Quadlet on Atlas generator

* Keep iCloudPD in Archive and reserve Photobook for Immich

* Prepare guarded Aegis iCloudPD retirement

* Declare inactive Atlas iCloudPD storage and Quadlet

* Retire Aegis iCloudPD from desired state

* Clear retired Aegis iCloudPD failed-unit state

* Remove completed iCloudPD retirement tasks from Aegis

* Record initial Atlas iCloudPD service start

* Manage Atlas iCloudPD config from Vault

* Fix Atlas iCloudPD traceroute startup and config drift

* Use Atlas Vault key for iCloudPD Apple ID

* Add HEIC decoding to Fedora desktops

* Record completed iCloudPD ingestion and remaining recovery checks
2026-10-03 09:59:59 +02:00
62 changed files with 2531 additions and 1303 deletions

171
AGENTS.md
View File

@@ -25,6 +25,9 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
- Preserve layering `all -> platform -> role -> desktop -> host`.
- Keep `ansible/site.yml` small; orchestration belongs there, implementation belongs in roles.
- Prefer minimal, targeted edits. Preserve idempotency and existing ordering.
- Keep completed one-time cleanup operations out of the playbook. Execute them directly
with explicit authorization; retain only the ongoing desired-state configuration and
historical documentation, not permanent cleanup flags or tasks.
- Use Git Flow branch prefixes: `feature/` for new functionality, `bugfix/` for non-urgent fixes,
`hotfix/` for urgent production fixes, `release/` for release preparation, and `support/` for
maintained release lines. Do not use abbreviated prefixes such as `feat/`.
@@ -54,20 +57,20 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
- Emacs is disabled by default; temporary Emacs check: `ansible-playbook ansible/site.yml --limit <host> --tags emacs --check --diff -e emacs_enabled=true`
- AI coding agents: `ansible-playbook ansible/site.yml --limit <host> --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`
- Server NPM Quadlet: `systemctl status prometheus-npm.service`; the Compose fallback is retired.
- Explicit Prometheus legacy cleanup (destructive only without check mode):
`ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true`
- Atlas media stack:
`ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff`
- Atlas rootless Gitea staging (does not start Gitea):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas explicit Gitea host-owner migration (live outage; never a normal run):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_owner_migration -e atlas_gitea_owner_migration=true`
- Atlas explicit isolated Gitea restore rehearsal (not part of normal runs):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_restore -e atlas_gitea_restore_test=true`
- Atlas final Gitea replacement gate (dry-run only until a stopped-source export is pulled):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_final_restore --check --diff -e atlas_gitea_final_restore=true`
- Prometheus final Gitea export helper (dry-run installs only; outage action remains opt-in):
`ansible-playbook ansible/site.yml --limit prometheus --tags gitea_final_export --check --diff`
- Gitea cutover network configuration before activation:
- Atlas canonical Gitea domain (restarts only Gitea on a real configuration change):
`ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --check --diff`
- Atlas Nextcloud/ONLYOFFICE steady state:
`ansible-playbook ansible/site.yml --limit atlas --tags nextcloud --check --diff`
- Atlas iCloudPD storage and boot-started Quadlet:
`ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff`
- Ongoing Gitea proxy configuration:
`ansible-playbook ansible/site.yml --limit prometheus --tags gitea_cutover,prometheus_backup --check --diff -e server_gitea_on_atlas=true`
and `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff`
- Atlas daily Navidrome music copy:
@@ -88,7 +91,8 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora
`ansible-playbook ansible/site.yml --limit atlas --tags restorecon --check -e '{"atlas_restorecon_paths":["/zpool/archive"]}'`
- Prometheus/Aegis WireGuard gateway:
`ansible-playbook ansible/site.yml --limit prometheus,aegis --tags wireguard --check --diff`
- DuckDNS config only: `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff`
- Prometheus NPM Quadlet steady state (does not perform a cutover):
`ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff`
## Conventions
- Use FQCN Ansible modules.
@@ -132,21 +136,29 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
- Windows applications are installed manually and are not managed from the WSL profile.
## Rocky Server Notes
- DuckDNS is rendered by `profile_server` from host-local `server_duckdns_domain` and
`vault_duckdns_token`. Keep the rotated token in encrypted Vault or untracked local vars, never in
dotfiles. The private `~/duckdns/duck.sh` keeps the existing entrypoint; rendering uses `no_log`
and disables diffs. Provisioning does not execute the updater or change its external schedule.
- DuckDNS support is removed from the server profile, not feature-gated. No updater tasks,
templates or enablement variables remain. Prometheus uses its static IP and `fscotto.co`;
the local updater, log and cron job were already retired. External DuckDNS account/name
and existing encrypted token are outside this removal and remain untouched.
- `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
existing Nginx Proxy Manager/Gitea Compose stack with a `podman-compose-server` systemd unit. PostgreSQL and
Navidrome are no longer part of the desired Prometheus configuration. The role does not stop or remove legacy
containers, delete `/opt/postgres/data`, start the Compose stack, update DNS, or cut over traffic.
- The Rocky profile installs Podman and podman-compose. Prometheus explicitly retires the legacy
Compose unit, files and final-export helper with `server_legacy_stack_retired: true`.
Its approved opt-in cleanup removed old application data on 2026-10-03; normal runs do not
delete data or recreate the retired files. On Prometheus, Nginx Proxy Manager is now the rootful
`prometheus-npm.service` Quadlet with a pinned image digest and the existing `/opt/npm/data` and
`/opt/npm/letsencrypt` bind mounts. The rootful `server_web` bridge remains `10.89.0.0/24`.
Gitea runs on Atlas; PostgreSQL and Navidrome are absent from the desired Prometheus stack.
Normal runs do not delete legacy data, update DNS, or perform an implicit cutover;
destructive cleanup requires its explicit tag and opt-in extra-var.
- 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.
- `scripts/migrate_prometheus_data.sh` is the separate, source-host-run NPM/Gitea migration path. It dry-runs by
default and requires explicit source-stack quiescing before copying persistent Docker data with rsync.
- The completed Ubuntu-to-Rocky data migration script and its operational instructions
have been removed; current provisioning does not provide that one-time migration path.
- Completed Gitea owner-migration, migration-restore and final-export tasks, helpers and flags
are removed. Current Gitea marker/ownership checks, recurring backups and proxy configuration
remain intact. `server_gitea_proxy_enabled` controls ongoing proxy management only.
- Atlas-only OpenZFS, NFS, Samba, and Syncthing stay selected through Atlas host variables and must not
leak into `rocky_server`. Cockpit plus its Navigator and Podman extensions are selected explicitly for
Prometheus through its host variables.
@@ -350,23 +362,110 @@ successfully. The first monthly scrub remains a runtime check.
- [x] Validate authenticated SSH pull and push. On 2026-10-02 the operator reported both
operations working through the public SSH endpoint; the earlier agent-run `git ls-remote`
remains the independent read-only check. The agent did not perform a test push.
- [ ] Validate HTTPS write/login before declaring the full cutover complete. The
secondary NPM hostname `git.ov-ad3410.infomaniak.ch` did not resolve from Ikaros and had
no generated NPM config file at the previous inspection. Do not restart the stale source
Gitea after Atlas has accepted writes.
- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it
separate persistent application, database, and cache storage; keep credentials in Vault; publish it only
through NPM over the Prometheus--Aegis gateway; and define backup, upgrade, and eventual Uranus-migration
procedures before exposing user data. Do not deploy Nextcloud before the data-protection checklist is complete.
- [x] Validate Gitea login and write via HTTPS. On 2026-10-03 the operator confirmed
authenticated web login and Git clone/pull/push through the public HTTPS endpoint. Do not
restart the stale source Gitea after Atlas has accepted writes.
- [x] Design and deploy the empty temporary Atlas Nextcloud/ONLYOFFICE stack on 2026-10-03.
The operator explicitly authorized empty internal service startup before the first scrub;
this does not close the scrub or protection checks. Four rootless Quadlets, separate
component datasets, pinned images/apps, Vault secrets, standard fabio/chiara users, a
separate application admin and the Famiglia folder are deployed. Cron and internal Office
connection checks succeeded; repeat deployment changed nothing. See `docs/atlas-nextcloud.md`.
- [x] Complete the authorized empty-stack public cutover on 2026-10-03 after operator
DNS/NPM configuration. Both hostnames passed TLS and HTTPS redirects; authenticated
web login, WebDAV, private-file isolation, Famiglia cross-user create/read/update/delete and
CalDAV/CardDAV discovery passed. The Office connector and public health/API asset passed.
Temporary test files were removed; no iCloud data was imported.
- [ ] Complete Nextcloud desktop/mobile editing and synchronization acceptance, and
application-consistent backup/restore validation. Close the first actual scrub and
protection checks before importing family data.
iCloud migration and future Uranus transfer remain separate operations, not playbook flags.
- [x] Move Gitea canonical HTTPS and SSH hostname to `git.fscotto.co` on
2026-10-03 through Ansible. Only Gitea restarted; second run changed nothing.
HTTPS and authenticated SSH reads returned the same repository HEAD.
The new NPM hostnames passed TLS/HTTP checks; old DuckDNS Proxy Hosts were
observed disabled. Details are in `docs/domain-fscotto-co.md`.
- [x] Confirm login on the new Gitea hostname and update remaining client remotes/integrations.
The operator confirmed completion on 2026-10-03; the agent did not perform a test push.
- [x] Remove obsolete DuckDNS NPM Proxy Hosts, unused certificates and the old upstream override.
The operator confirmed completion on 2026-10-03; no new agent runtime check was performed.
- [x] Review and remove completed one-time procedures from the playbook.
The operator confirmed completion on 2026-10-03.
- [x] Retire Prometheus' local DuckDNS updater on 2026-10-03 through Ansible:
the five-minute cron entry and private updater/log directory were removed.
Provisioning support was subsequently removed entirely; repeat cleanup changed nothing. HTTPS services, private NPM
administration and the export timer stayed healthy. The external name and Vault token
remain untouched for possible future use on a local host.
- [ ] Keep `atlas_manage_media_stack` disabled until the future Immich deployment has validated `/dev/dri`,
container paths, and the required Vault database secret.
### Priority 4 - Optional workflows
- [ ] After data protection is validated, move iCloudPD photo ingestion from Aegis to Atlas as a
temporary service until Uranus is ready. Plan to store photos in `/zpool/archive/Pictures` and
persistent application/MFA state outside `Archive`; validate permissions, SELinux, backups and
recovery before cutover. Keep the current Aegis service and Photobook NFS export unchanged until
the Atlas workflow is tested, then retire them explicitly if no longer needed.
- [x] Deploy the declared Atlas iCloudPD state dataset and inactive rootless `admin` Quadlet.
Photos belong under `/zpool/archive/Pictures/iCloudPD`; private config/MFA state belongs in
`zpool/services/data/icloudpd`. Photobook remains reserved for Immich. Ansible now renders
`icloudpd.conf` with the Apple ID from the existing Vault key, but does not store the password
or manage MFA. Automatic startup was approved on 2026-10-03; the Quadlet now
uses `WantedBy=default.target` and Ansible keeps the service running.
The isolated no-network layout test is documented in
`docs/atlas-icloudpd-migration.md`. On 2026-10-02 Atlas deployment and a second idempotent run
passed; no app config existed at deployment. A manual first start on 2026-10-02 generated
`icloudpd.conf`; an Ansible run then replaced it with a private mode-0600 Vault-backed template
and an idempotent second run. The image later expanded the config, so Ansible now seeds it
only when absent and maintains the declared fields. Its launcher requires `traceroute`; the
rootless Quadlet grants only `NET_RAW`, tested in isolation and after restart. The service
was subsequently initialized interactively; initial ingestion is tracked below.
- [x] Retire Aegis iCloudPD completely. The operator authorized deleting its Quadlet,
`/var/lib/icloudpd` data, and MFA state despite an unaudited container overlay. After two
interactive-sudo runs on 2026-10-02, the unit is `not-found`/`inactive`, the Quadlet and state
directory are absent, and AdGuard remains active. The temporary retirement tasks have since
been removed from the Aegis role; it no longer manages iCloudPD.
- [x] Validate Atlas iCloudPD authentication and initial ingestion. On 2026-10-03 the active
rootless service logged `All photos and videos have been downloaded` at 02:16 and reported
completion for the user. The destination held 11,658 files (86,020,430,015 bytes); the preceding 24h
logs showed download activity without authentication failures or errors. A later read-only check
found the service still active. This confirms the initial download, not the next daily cycle.
- [x] Declare HEIC decoding for Fedora graphical desktops without converting the originals on Atlas.
The Fedora role installs RPM Fusion Free with a pinned signing-key fingerprint and
`libheif-freeworld` on Ikaros and Nymph. The package was confirmed installed on Ikaros on
2026-10-03; Nymph deployment and an actual image-opening test were not observed.
- [ ] Validate Atlas iCloudPD filesystem/SELinux/SMB access, the next daily sync, ZFS/Borg/USB
backup inclusion, and isolated restore of photos and private state. A recursive hourly snapshot
of `zpool/archive` exists after ingestion, but no iCloudPD-specific backup version or restore
has been verified. The first monthly scrub remains a separate open data-protection check.
## Prometheus NPM Quadlet cutover
- [x] Stage a rootful NPM Quadlet using the exact running image and the existing data/certificate
mounts, bridge subnet, public HTTP/HTTPS ports, and loopback-only administration port.
The generated service depends on `server-web-network.service` and is wanted by `multi-user.target`.
- [x] Take and verify the stopped-source export before switching owners. Version
`20261003T091009Z` was pulled to Atlas and its NPM SQLite database checked in isolation.
- [x] Cut over NPM to `prometheus-npm.service` on 2026-10-03. The legacy Compose unit is inactive
and disabled; the Quadlet is active with zero recorded restarts. Public Gitea and Syncthing
HTTPS returned 200 with valid TLS, while public TCP/81 remained unreachable.
- [x] Validate the post-cutover backup path. The export and Atlas pull published
`20261003T091633Z`; checksum, SQLite `quick_check`, ten proxy hosts, six certificate records,
both Quadlet files were present, and the complete Let's Encrypt tree (70 regular files plus
12 symlinks) matched the live data. A targeted normal Ansible run changed nothing. Details and rollback
boundaries are in `docs/prometheus-npm-quadlet.md`.
- [x] Remove only unused Gitea, Navidrome and PostgreSQL images with opt-in
Ansible tasks on 2026-10-03. Second run changed nothing; NPM stayed active
with zero restarts, HTTP/HTTPS passed, backup timer and SSH proxy stayed active.
This image-only step preserved data and fallback; the later approved deletion is tracked below. Validation:
`ansible-playbook ansible/site.yml --limit prometheus --tags server_image_cleanup --check --diff -e server_legacy_image_cleanup=true`
- [x] Complete explicitly approved old-data and Compose fallback removal on 2026-10-03.
Backup paths and mount dependencies were reconciled before deletion; repeat cleanup changed
nothing. The normal Compose/template/helper check did not recreate retired files.
A separately approved manual export/pull published `20261003T112906Z`; checksum and isolated
SQLite restore passed with ten proxy hosts and both Quadlet definitions. NPM, primary HTTPS,
WireGuard, SSH proxy and backup timer remained healthy; existing backup archives were preserved.
- [x] Retire the unused secondary Gitea hostname `git.ov-ad3410.infomaniak.ch`
on 2026-10-03. Its NPM Proxy Host was already soft-deleted and had no
associated certificate. Its Ansible domain and runtime override were removed;
nginx -t and reload passed without restarting NPM. Primary HTTPS returned 200
with valid TLS. At that step only `git.fscotto.duckdns.org` remained declared;
the subsequent domain transition and operator-confirmed cleanup are tracked above.
- [ ] Observe the first scheduled export and Atlas pull after the cutover; the manual end-to-end
cycle passed, but the next unattended cycle has not yet occurred.
## Cerberus Management Node (Deferred)
`cerberus` is postponed until the office in the new house is physically set up. It is not an inventory
@@ -442,5 +541,5 @@ validated exports of older historical data will use a dedicated Atlas NFS datase
`/etc/resolv.conf` linked to `/run/systemd/resolve/resolv.conf`. LAN clients may use AdGuard, but
Aegis must use the independent upstream DNS declared by `aegis_host_dns_servers` so Greenboot does
not depend on the AdGuard container during startup.
- iCloudPD requires post-deployment interactive MFA initialization; its cookie/configuration state is
persisted in `/var/lib/icloudpd/config`.
- Aegis iCloudPD has been retired and is no longer managed by this role. Its service, Quadlet,
data, and MFA state were removed with the operator's explicit authorization.

View File

@@ -182,6 +182,10 @@ Le applicazioni Windows sono installate e gestite manualmente; il profilo WSL no
## Server
La migrazione dei servizi pubblici a `fscotto.co`, la gestione Ansible
degli URL Gitea e i passaggi ancora aperti per ritirare DuckDNS sono in
[`docs/domain-fscotto-co.md`](docs/domain-fscotto-co.md).
Sistema operativo:
- Rocky Linux 9
@@ -201,51 +205,36 @@ 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 del `docker-compose.yml` per Nginx Proxy Manager e Gitea,
piu l'unita `podman-compose-server` (attivazione manuale)
- copia dei dotfiles server e rendering del Quadlet rootful `prometheus-npm.service` per Nginx Proxy
Manager; il vecchio fallback Compose è stato rimosso con autorizzazione esplicita
- attivazione di firewalld con SSH, Cockpit (`9090/tcp`), HTTP e HTTPS abilitati
- Syncthing escluso dal profilo server Rocky
Il Compose desiderato su Prometheus non include piu Navidrome ne il database PostgreSQL obsoleto.
Navidrome e Syncthing appartengono ad Atlas; Navidrome ufficiale usa invece SQLite. Il profilo non
arresta o rimuove automaticamente eventuali container legacy e non elimina `/opt/postgres/data`.
Il 2026-10-03 la pulizia opt-in autorizzata ha rimosso dati e immagini precedenti di Gitea,
Navidrome e PostgreSQL, directory obsolete vuote, helper finale Gitea e fallback Compose NPM.
I servizi migrati restano su Atlas. `server_legacy_stack_retired: true` evita che i normali task
ricreino i residui; la cancellazione richiede `--tags server_legacy_cleanup` e
`-e server_legacy_cleanup=true`. NPM attivo e archivi di backup restano intatti.
Export, pull Atlas e restore SQLite isolato post-pulizia sono riusciti; il primo ciclo automatico
resta da osservare. Evidenze e confini del recovery:
[`docs/prometheus-npm-quadlet.md`](docs/prometheus-npm-quadlet.md).
Nginx Proxy Manager pubblica solo `80/tcp` e `443/tcp`; la sua interfaccia di amministrazione e
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`.
La fase 1 su Atlas non modifica questo deployment NPM ne i suoi dati persistenti. Dopo aver attivato
WireGuard e i servizi Atlas, configurare i proxy host NPM correnti con upstream Navidrome
`http://10.0.0.2:4533` e upstream per la GUI Syncthing `http://10.0.0.2:8384`. Solo la GUI web di
Syncthing usa NPM; il traffico di sincronizzazione resta sulle porte native pubblicate esplicitamente solo
sull'indirizzo WireGuard di Atlas. Configurare l'autenticazione Syncthing e una policy di accesso NPM adeguata prima di pubblicare la GUI.
La fase 1 su Atlas non modifica i dati persistenti NPM. I proxy host NPM usano gli upstream LAN
`http://192.168.178.55:4533` per Navidrome e `http://192.168.178.55:8384` per la GUI Syncthing;
Prometheus li raggiunge attraverso Aegis come gateway WireGuard. Solo la GUI web di Syncthing usa
NPM; il traffico di sincronizzazione resta sulle porte native esposte sulla LAN dichiarata.
Mantenere l'autenticazione Syncthing e una policy di accesso NPM adeguata.
### DuckDNS
### Rimozione DuckDNS
`profile_server` genera `~/duckdns/duck.sh` con permessi `0700`, mantenendo il percorso dello
script e `duck.log`. Definire `server_duckdns_domain` negli host vars del server e salvare il
**nuovo token rigenerato** in `vault_duckdns_token`, nel Vault cifrato `secrets/vault.yml`
(`ansible-vault edit secrets/vault.yml`) oppure negli override non versionati `secrets/vault.local.yml`.
Non committare lo script generato e non passare il token sulla riga di comando. Il rendering
nasconde output e diff sensibili; lo script verifica TLS e passa il token a curl tramite stdin.
Il playbook non esegue lo script e non modifica la sua schedulazione esterna.
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
```
La cancellazione dalla cronologia non revoca il token: rigenerarlo sul pannello DuckDNS.
Dopo la bonifica, riclonare gli altri checkout senza unire nuovamente la vecchia storia;
salvare separatamente eventuali modifiche non committate senza copiare segreti.
### Migrazione dati
Dopo il provisioning Rocky, eseguire `scripts/migrate_prometheus_data.sh` **sul server Ubuntu
sorgente**. Lo script usa rsync, e in dry-run di default; richiede `--quiesce-source --execute` per
fermare lo stack sorgente e copiare in modo consistente soltanto i dati di Nginx Proxy Manager e
Gitea. Non sposta Navidrome o Syncthing, non avvia container, non cancella dati e non esegue il
cutover.
Il supporto DuckDNS è stato rimosso dal profilo server: non restano task, template,
variabili o flag di abilitazione. Prometheus usa IP statico e `fscotto.co`.
Updater locale, log e cron erano già stati rimossi. Nome/account DuckDNS esterni
ed eventuale token cifrato esistente restano invariati per un possibile uso futuro.
Utente del profilo server:
@@ -332,7 +321,7 @@ La migrazione Gitea da Prometheus ad Atlas è descritta in
di `admin` su un dataset dedicato; l'immagine derivata mantiene UID/GID 1000 ma chiama l'utente
interno `gitea`. NPM resta su Prometheus e l'HTTPS pubblico primario serve Atlas. L'SSH pubblico
su TCP/2222 autentica la chiave `ikaros` e un `git ls-remote` è riuscito; l'operatore ha
confermato pull e push SSH. Resta da provare la scrittura via HTTPS. I dati sorgente restano
confermato pull e push SSH. Login e scrittura Git via HTTPS sono stati confermati il 2026-10-03. I dati sorgente restano
conservati su Prometheus senza avviarne il vecchio container.
Validare il gateway con:
@@ -517,8 +506,9 @@ viene recuperato quando il timer torna attivo.
`atlas-usb-backup.service` **non ha timer** e va avviato manualmente. Il timer del fornitore
`zfs-scrub-weekly@zpool.timer` è disabilitato a favore dello scrub mensile. Il timer di preparazione
su Prometheus è attivo alle 02:00 Europe/Rome; export, pull e ripristino temporaneo manuali sono
riusciti il 2026-09-30, ma il primo ciclo pianificato va ancora verificato. Durante un backup Borg attivo,
su Prometheus è attivo alle 02:00 Europe/Rome; il primo ciclo pianificato è riuscito il 2026-10-01.
Un export, pull e ripristino temporaneo post-cutover NPM Quadlet sono riusciti il 2026-10-03;
il primo ciclo pianificato dopo quel cutover resta da osservare. Durante un backup Borg attivo,
`systemctl list-timers` può mostrare `-` per il prossimo evento senza che il timer sia disabilitato.
Per vedere la pianificazione corrente: `systemctl list-timers --all` su Atlas.
@@ -527,12 +517,15 @@ della protezione dei dati: richiede storage applicativo, database e cache separa
pubblicazione solo tramite NPM e Aegis, procedure di backup, aggiornamento e migrazione. Non
distribuirlo prima di completare la checklist di protezione dei dati.
La destinazione futura per l'importazione foto iCloud è Atlas, non Aegis. Dopo la validazione dei
backup, pianificare una migrazione esplicita di iCloudPD con foto sotto `/zpool/archive/Pictures` e
stato applicativo/MFA fuori da `Archive`; testare permessi, SELinux, backup e restore prima del
cutover. L'attuale iCloudPD su Aegis e l'export NFS Photobook restano configurati fino
all'approvazione e alla verifica di questa migrazione separata. Anche il servizio Atlas sarà
temporaneo in attesa di Uranus.
Atlas è la destinazione dichiarata per iCloudPD. Ansible gestisce dataset, Quadlet rootless e
`icloudpd.conf` privato con Apple ID dal Vault: foto in `/zpool/archive/Pictures/iCloudPD`,
stato in `zpool/services/data/icloudpd`. Il primo avvio è stato manuale; password e MFA restano
gestiti interattivamente, senza avvio automatico al boot. L'inizializzazione è stata completata e
il download iniziale di foto e video è terminato il 2026-10-03. Su Aegis
il servizio, il Quadlet e `/var/lib/icloudpd` sono stati rimossi e verificati; il playbook Aegis
non contiene più task iCloudPD. L'accesso SMB e il ripristino dai backup dei nuovi dati restano
da verificare. L'export NFS Photobook resta
invariato. Dettagli in [`docs/atlas-icloudpd-migration.md`](docs/atlas-icloudpd-migration.md).
Il primo ciclo pianificato del backup di Prometheus e una prova di disaster recovery a dimensione reale
restano da verificare. Il 2026-09-30 una VM Rocky isolata ha superato ricostruzione OS con Ansible,
@@ -635,8 +628,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 Compose server include soltanto `gitea` e `nginx-proxy-manager`; Navidrome e Syncthing
della fase 1 sono Quadlet rootless su Atlas
- NPM è un Quadlet rootful su Prometheus, mentre Gitea, Navidrome e Syncthing sono Quadlet
rootless su Atlas; il fallback Compose server è stato rimosso
# Dotfiles
@@ -742,7 +735,7 @@ ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
ansible-lint ansible/roles/<role>
yamllint ansible/path/to/file.yml
podman-compose -f /opt/docker/server/docker-compose.yml config
ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags music_sync --check --diff

102
README.md
View File

@@ -125,16 +125,26 @@ That gives it Fedora packages through DNF, Docker from the official repository,
## Server
The public service domain transition to `fscotto.co`, Gitea canonical URL
management, and remaining DuckDNS retirement steps are documented in
[`docs/domain-fscotto-co.md`](docs/domain-fscotto-co.md).
`prometheus` is the Rocky Linux 9 server. It has no graphical environment and gets server-specific
dotfiles and templates. The profile provisions configuration only: it does not transfer data, start
the Compose stack, update DNS, or perform a cutover.
dotfiles and templates. The profile does not transfer application data, update DNS, or perform an
implicit service cutover.
The server profile installs platform-specific packages, Podman and podman-compose, declared systemd
services, and firewalld. The manually activated `podman-compose-server` unit contains the existing
Nginx Proxy Manager and Gitea services. The desired Compose file no longer includes Navidrome,
Syncthing, or the obsolete Navidrome PostgreSQL database; their temporary Atlas deployment is managed
by `profile_backend_phase1`. Applying the profile does not stop or remove legacy containers and does
not delete `/opt/postgres/data`.
services, and firewalld. Nginx Proxy Manager runs as the rootful `prometheus-npm.service` Quadlet.
On 2026-10-03 the operator-approved opt-in cleanup removed old Gitea, Navidrome and PostgreSQL
data/images, empty legacy directories, the Gitea final-export helper and the Compose rollback files.
The migrated services stay on Atlas. `server_legacy_stack_retired: true` prevents normal runs from
recreating retired files. Data deletion requires `--tags server_legacy_cleanup` and
`-e server_legacy_cleanup=true`; image-only cleanup has its own `server_image_cleanup` tag and flag.
Active NPM resources and existing backup archives remain preserved.
The post-cleanup export/pull and isolated SQLite restore passed; the first unattended cycle remains
pending. Evidence and recovery boundaries:
[`docs/prometheus-npm-quadlet.md`](docs/prometheus-npm-quadlet.md).
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
@@ -159,45 +169,12 @@ The target must already provide `server_username` with local sudo access.
Prometheus authorizes its declared SSH public keys through separate files below
`~/.ssh/authorized_keys.d/`, while `sshd` is configured to read those files directly.
### DuckDNS
### DuckDNS retirement
`profile_server` renders `~/duckdns/duck.sh` with mode `0700`, keeping the existing updater path
and `duck.log`. Set `server_duckdns_domain` in the server's host vars and store the **rotated**
`vault_duckdns_token` in encrypted `secrets/vault.yml` (using `ansible-vault edit secrets/vault.yml`)
or untracked `secrets/vault.local.yml`. Never commit the rendered script or put the token on a
command line. Rendering hides secret output/diffs; the updater verifies TLS and passes the token
to curl through stdin. The playbook neither runs the updater nor changes its external schedule.
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
```
An exposed token must be revoked/regenerated on DuckDNS: deleting it from Git history does not
revoke it. After a history cleanup, re-clone other checkouts rather than merging the old history
back in; preserve any uncommitted work separately without copying secrets.
### Data migration
Provision Rocky first, then run the migration script **on the retired Ubuntu source host**. It is
dry-run by default and requires an explicit source-stack stop before it can copy application data:
```bash
sudo ./scripts/migrate_prometheus_data.sh \
--destination rocky@179.237.102.172 \
--identity /root/.ssh/id_ed25519
sudo ./scripts/migrate_prometheus_data.sh \
--destination rocky@179.237.102.172 \
--identity /root/.ssh/id_ed25519 \
--quiesce-source --execute
```
The script copies only Nginx Proxy Manager and Gitea data. It does not delete data, move
Navidrome/Syncthing, copy `/home/git/.ssh`, start containers, update DNS, or perform a cutover. The
destination SSH host key must already be trusted and the destination account needs passwordless sudo
for `rsync`. It preserves ACLs but not extended attributes, so source SELinux labels are not
transferred; the Rocky Compose bind mounts apply their own `:Z` labels when containers start.
DuckDNS support has been removed from the server profile: no tasks, templates,
variables or enablement flags remain. Prometheus uses its static IP and `fscotto.co`.
The local updater, log and cron job were already removed. The external DuckDNS
name/account and existing encrypted token remain untouched for possible future use.
## DNS Filter
@@ -210,8 +187,8 @@ ansible/bootstrap/generate-aegis-ign.sh --write IMAGE DEVICE
```
The controller manages it remotely as `pi@aegis`; unlike local desktop profiles, Aegis is
intentionally an SSH inventory target. `profile_aegis` manages rootful Podman Quadlets for AdGuard
Home and iCloudPD, persistent data under `/var/lib`, the Podman auto-update timer, LAN-restricted
intentionally an SSH inventory target. `profile_aegis` manages a rootful Podman Quadlet for AdGuard
Home, its persistent data under `/var/lib`, the Podman auto-update timer, LAN-restricted
firewalld rules, SSH key-only access for `pi`, the `nfs-utils` and `wireguard-tools` rpm-ostree layers,
and `wake-ikaros`. `wireguard_overlay` makes Aegis the internal endpoint and LAN gateway for Prometheus:
it enables persistent IPv4 forwarding, installs a scoped WireGuard-to-LAN firewalld policy, and source-NATs
@@ -224,9 +201,8 @@ opened and closed manually during initial setup. The profile disables the local
stub and points `/etc/resolv.conf` to its full resolver data, freeing port 53 for AdGuard. LAN clients
may use AdGuard on Aegis, while Aegis itself uses the independent upstream DNS declared by
`aegis_host_dns_servers`; this prevents Greenboot from depending on the AdGuard container during
startup. Reboot Aegis after changing its NetworkManager DNS profile. Define
`vault_aegis_icloudpd_apple_id` in Vault before applying it. iCloudPD still requires interactive MFA
initialization after its first deployment.
startup. Reboot Aegis after changing its NetworkManager DNS profile. iCloudPD was retired from Aegis;
the Aegis role no longer contains iCloudPD tasks. Atlas iCloudPD config is Vault-backed; MFA is manual.
New Aegis images create the `admin` account in Butane. Before configuring a newly imaged node, run its
first playbook execution with `-e ansible_user=admin`; the SSH hardening role then permits that same
@@ -306,9 +282,9 @@ The Gitea move from Prometheus to Atlas is tracked in
[`docs/atlas-gitea-migration.md`](docs/atlas-gitea-migration.md). The final consistent copy runs in
Atlas' dedicated dataset under `admin`'s rootless user Quadlet. Its pinned derived image uses an
internal Unix user named `gitea` (UID/GID 1000), while clone URLs keep `git@`. NPM remains on Prometheus and the primary
public HTTPS route serves Atlas. Public SSH/2222 now authenticates the `ikaros` key and serves
read-only `git ls-remote`; the operator also confirmed SSH pull and push. HTTPS writes remain
untested. The old Gitea data remains on Prometheus, but its container
public HTTPS route serves Atlas. Public SSH/2222 authenticates the `ikaros` key and serves
`git ls-remote`; the operator also confirmed SSH pull and push. HTTPS login and Git writes were
confirmed on 2026-10-03. The old Gitea data remains on Prometheus, but its container
is absent from the desired stack.
The separate `wireguard_overlay` role manages `wg0` between Prometheus (`10.0.0.1`) and Aegis
@@ -532,8 +508,9 @@ scheduled after the timer becomes active again.
`atlas-usb-backup.service` has **no timer**: the encrypted USB backup must be started manually.
The vendor's `zfs-scrub-weekly@zpool.timer` is intentionally disabled in favor of the monthly scrub.
The Prometheus export timer runs at 02:00 Europe/Rome; its first scheduled run and the Atlas pull
remain to be observed. A manual export, pull, and temporary restore passed. While a
The Prometheus export timer runs at 02:00 Europe/Rome. Its first scheduled export and Atlas pull
passed on 2026-10-01; a manual post-NPM-Quadlet export, pull, and temporary restore passed on
2026-10-03. The first scheduled cycle after that cutover remains to be observed. While a
Borg backup is still running, `systemctl list-timers` may show `-` for its next trigger; this does not
mean the timer has been disabled. Inspect the current schedule on Atlas with
`systemctl list-timers --all`.
@@ -543,11 +520,14 @@ declared persistent application, database, and cache storage, Vault-backed crede
publishing through Aegis, and defined backup, upgrade, and eventual migration procedures. Do not deploy
it before the data-protection checklist is complete.
The desired future iCloud photo-ingestion host is Atlas, not Aegis. After data-protection validation,
plan an explicit iCloudPD migration with photos under `/zpool/archive/Pictures` and application/MFA
state outside `Archive`, then test permissions, SELinux, backups and recovery before cutting over.
The current Aegis iCloudPD service and Atlas Photobook NFS export remain configured until that
separate migration is approved and validated; the eventual Atlas service is temporary until Uranus.
Atlas is the declared iCloud photo-ingestion host. Ansible manages the rootless Quadlet, a private
Vault-backed `icloudpd.conf`, photos under `/zpool/archive/Pictures/iCloudPD`, and separate state in
`zpool/services/data/icloudpd`. The service was started manually; Ansible does not enable automatic
startup or manage the password and MFA keyring. The operator initialized MFA interactively; on
2026-10-03 the initial photo/video download completed. Aegis iCloudPD, including its service data,
has been removed and verified; the Aegis role no longer manages it. Backup/restore and SMB access
for the new data remain unverified. The Photobook NFS export remains untouched. See
[`docs/atlas-icloudpd-migration.md`](docs/atlas-icloudpd-migration.md).
The first scheduled Prometheus backup runs and production-size disaster-recovery tests remain follow-up work. The prioritized
operational backlog is kept in `AGENTS.md`.
@@ -733,7 +713,7 @@ ansible-playbook ansible/site.yml --limit <host> --tags <tag1>,<tag2> --check --
ansible-playbook ansible/site.yml --limit <host> --start-at-task "<task name>" --check --diff
ansible-lint ansible/roles/<role>
yamllint ansible/path/to/file.yml
podman-compose -f /opt/docker/server/docker-compose.yml config
ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags music_sync --check --diff
```

View File

@@ -6,6 +6,10 @@ effective_username: "{{ server_username }}"
effective_user_group: "{{ server_user_group }}"
effective_user_home: "{{ server_user_home }}"
server_container_stack_dir: /opt/docker/server
server_npm_quadlet_stage: false
server_npm_quadlet_cutover: false
server_legacy_stack_retired: false
server_legacy_cleanup: false
ai_agents: {}
vim_plugins_enabled: false
@@ -87,9 +91,8 @@ server_backup_export_root: /var/lib/prometheus-backup-export
server_backup_rrsync_path: /usr/share/doc/rsync/support/rrsync
server_backup_export_calendar: "*-*-* 02:00:00 Europe/Rome"
server_backup_export_start_timer: false
# Explicit Gitea cutover helper: installed separately from any outage action.
server_gitea_cutover_tools_enabled: false
server_gitea_final_export: false
# Ongoing public Gitea proxy configuration.
server_gitea_proxy_enabled: false
server_gitea_on_atlas: false
server_gitea_atlas_address: "{{ hostvars['atlas'].ansible_host }}"
server_gitea_npm_domains: []
@@ -99,9 +102,13 @@ server_backup_export_source_keep: 3
server_backup_export_paths: >-
{{ ['opt/npm/data', 'opt/npm/letsencrypt']
+ ([] if server_gitea_on_atlas | bool else ['opt/gitea/data', 'home/git/.ssh'])
+ ['opt/docker/server/docker-compose.yml',
'etc/systemd/system/podman-compose-server.service',
'etc/ssh/sshd_config', 'etc/ssh/sshd_config.d',
+ ([] if server_legacy_stack_retired | bool else
['opt/docker/server/docker-compose.yml',
'etc/systemd/system/podman-compose-server.service'])
+ (['etc/containers/systemd/prometheus-npm.container',
'etc/containers/systemd/server-web.network']
if server_npm_quadlet_stage | bool else [])
+ ['etc/ssh/sshd_config', 'etc/ssh/sshd_config.d',
'etc/firewalld', 'etc/wireguard/wg0.conf'] }}
server_backup_export_excludes: >-
{{ ['opt/npm/data/logs']

View File

@@ -42,5 +42,3 @@ aegis_ssh_authorized_keys:
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
aegis_icloudpd_apple_id: "{{ vault_aegis_icloudpd_apple_id | default('') }}"

View File

@@ -49,9 +49,42 @@ atlas_zfs_backup_reservation: 500G
atlas_zfs_dataset_photobook: media/photobook
atlas_mount_root: /zpool
atlas_manage_storage: true
atlas_manage_nextcloud: true
atlas_nextcloud_domain: cloud.fscotto.co
atlas_onlyoffice_domain: office.fscotto.co
# Resolved official amd64 images on 2026-10-03; updates are deliberate.
atlas_nextcloud_image: docker.io/library/nextcloud:33.0.9-apache@sha256:a97666d6ae931bde78a80cfba8abdf46d436d7b540f31895803f6fb0a012d689
atlas_nextcloud_postgres_image: docker.io/library/postgres:17-bookworm@sha256:639ab7ceb90e13123085b741fb31ef493fba25463002f6da665352e7b534b652
atlas_nextcloud_redis_image: docker.io/library/redis:7.4-bookworm@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f
atlas_onlyoffice_image: docker.io/onlyoffice/documentserver:9.4.0.1@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212
atlas_nextcloud_users:
- username: fabio
display_name: Fabio
password: "{{ vault_nextcloud_fabio_password }}"
- username: chiara
display_name: Chiara
password: "{{ vault_nextcloud_chiara_password }}"
atlas_nextcloud_apps:
- id: groupfolders
version: 21.0.9
url: https://github.com/nextcloud-releases/groupfolders/releases/download/v21.0.9/groupfolders-v21.0.9.tar.gz
checksum: sha256:d8b95f0778425f646f2311ba5b42d8e2fcfdf37dc2fd35fcac8d3f01bde38a21
- id: onlyoffice
version: 10.2.1
url: https://github.com/ONLYOFFICE/onlyoffice-nextcloud/releases/download/v10.2.1/onlyoffice.tar.gz
checksum: sha256:144998af0610ccd17ee8d7025e2f8001472da03f6dab90ff38039247825e3a9b
- id: contacts
version: 8.9.1
url: https://github.com/nextcloud-releases/contacts/releases/download/v8.9.1/contacts-v8.9.1.tar.gz
checksum: sha256:a25cdf448b192631b8e8eb7addc31b40382b33871b10521f4308ac5a6e0457bf
- id: calendar
version: 6.6.2
url: https://github.com/nextcloud-releases/calendar/releases/download/v6.6.2/calendar-v6.6.2.tar.gz
checksum: sha256:7e83632d4436d3037a34d1c73cbc06d5ccb6e8fc10f096a86a43a0515588529c
# Rootless Gitea was restored from the stopped-source export before production activation.
atlas_manage_gitea: true
atlas_gitea_production_enabled: true
atlas_gitea_public_domain: git.fscotto.co
atlas_prometheus_pull_start_timer: true
atlas_manage_zfs_snapshots: true
atlas_zfs_snapshot_prefix: atlas-auto
@@ -140,8 +173,8 @@ atlas_monitor_remote_capacity:
atlas_manage_sharing: true
atlas_manage_media_stack: false
# Planned after data-protection validation: move iCloudPD photo ingestion from
# Aegis to Atlas, with photos under /zpool/archive/Pictures and persistent
# application/MFA state outside Archive. Do not deploy or cut over yet.
# Aegis to Atlas, with photos under /zpool/archive/Pictures/iCloudPD and
# application/MFA state in a separate dataset. Do not deploy or cut over yet.
# WireGuard is retired on Atlas. These rootless services are a temporary home
# until Uranus replaces them.

View File

@@ -6,15 +6,26 @@ ansible_port: 22
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
server_username: rocky
server_legacy_stack_retired: true
# Destructive deletion runs only with an explicit extra-var and cleanup tag.
server_legacy_cleanup: false
# Explicit opt-in cleanup; no data, volumes, networks or NPM images are removed.
server_legacy_image_cleanup: false
server_legacy_images:
- docker.gitea.com/gitea:1.25.2
- docker.io/deluan/navidrome:latest
- docker.io/library/postgres:13
server_npm_quadlet_stage: true
server_npm_quadlet_image: docker.io/jc21/nginx-proxy-manager@sha256:52b2c59994f3d36acfcf70a1626f29734df0ed8c71bacc0269f78b6f939858bb
# The stopped-source export and live Quadlet cutover passed on 2026-10-03.
server_npm_quadlet_cutover: true
server_backup_export_enabled: true
server_backup_export_start_timer: true
# Install the final-copy helper only; it is never run by a normal playbook invocation.
server_gitea_cutover_tools_enabled: true
server_gitea_proxy_enabled: true
server_gitea_on_atlas: true
server_gitea_npm_domains:
- git.fscotto.duckdns.org
- git.ov-ad3410.infomaniak.ch
server_duckdns_domain: fscotto
server_ssh_authorized_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"

View File

@@ -39,11 +39,41 @@
state: enabled
when: "'workstation_dev_wsl' in group_names"
- name: Install distribution signing keys for Fedora desktop codecs
tags: [packages, heic]
ansible.builtin.dnf:
name: distribution-gpg-keys
state: present
when: "'graphical_desktop' in group_names"
- name: Import RPM Fusion Free signing key for Fedora desktop codecs
tags: [packages, heic]
ansible.builtin.rpm_key:
key: /usr/share/distribution-gpg-keys/rpmfusion/RPM-GPG-KEY-rpmfusion-free-fedora-2020
fingerprint: E9A491A3DE247814E7E067EAE06F8ECDD651FF2E
state: present
when: "'graphical_desktop' in group_names"
- name: Enable RPM Fusion Free for Fedora desktop codecs
tags: [packages, heic]
ansible.builtin.dnf:
name: "https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-{{ ansible_facts['distribution_major_version'] }}.noarch.rpm"
state: present
when: "'graphical_desktop' in group_names"
- name: Refresh dnf package metadata
tags: [packages]
ansible.builtin.dnf:
update_cache: true
- name: Install HEIC decoder on Fedora desktops
tags: [packages, heic]
ansible.builtin.dnf:
name: libheif-freeworld
state: present
update_cache: true
when: "'graphical_desktop' in group_names"
- name: Install packages on Fedora
tags: [packages]
ansible.builtin.dnf:

View File

@@ -8,10 +8,6 @@ aegis_network_connection_uuid: ""
aegis_host_dns_servers: []
aegis_host_dns_search_domains: []
aegis_adguard_image: docker.io/adguard/adguardhome:latest
aegis_icloudpd_image: docker.io/boredazfcuk/icloudpd:latest
aegis_icloudpd_folder_structure: '{:%Y/%m/%d}'
aegis_icloudpd_synchronisation_interval: 86400
aegis_icloudpd_apple_id: ""
aegis_ikaros_mac_address: aa:bb:cc:dd:ee:ff
aegis_wol_port: 9

View File

@@ -9,13 +9,12 @@
name: sshd.service
state: reloaded
- name: Restart Aegis Quadlet services
- name: Restart Aegis AdGuard Quadlet
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
daemon_reload: true
loop:
- adguardhome.service
- icloudpd.service
loop_control:
label: "{{ item }}"

View File

@@ -13,14 +13,6 @@
msg: Reboot Aegis to activate the newly layered packages, then rerun the playbook.
when: aegis_layered_packages_result.needs_reboot | default(false)
- name: Require Aegis iCloudPD Apple ID
tags: [aegis, icloudpd]
ansible.builtin.assert:
that:
- aegis_icloudpd_apple_id | length > 0
fail_msg: Define vault_aegis_icloudpd_apple_id before applying the Aegis profile.
no_log: true
- name: Require completed Aegis network placeholders
tags: [aegis, dns, firewall, network, services]
ansible.builtin.assert:
@@ -119,8 +111,6 @@
loop:
- /var/lib/adguard/work
- /var/lib/adguard/conf
- /var/lib/icloudpd/data
- /var/lib/icloudpd/config
- name: Create Quadlet configuration directory
tags: [aegis, containers]
@@ -142,12 +132,9 @@
loop:
- src: adguardhome.container.j2
dest: adguardhome.container
- src: icloudpd.container.j2
dest: icloudpd.container
loop_control:
label: "{{ item.dest }}"
no_log: "{{ item.dest == 'icloudpd.container' }}"
notify: Restart Aegis Quadlet services
notify: Restart Aegis AdGuard Quadlet
- name: Create Aegis systemd-resolved configuration directory
tags: [aegis, adguard, dns, services]
@@ -168,7 +155,7 @@
mode: "0644"
notify:
- Restart Aegis systemd-resolved
- Restart Aegis Quadlet services
- Restart Aegis AdGuard Quadlet
- name: Point Aegis resolver at the full systemd-resolved configuration
tags: [aegis, adguard, dns, services]
@@ -361,7 +348,7 @@
group: root
mode: "0755"
- name: Enable Aegis Quadlet services and automatic updates
- name: Enable Aegis AdGuard Quadlet and automatic updates
tags: [aegis, containers, services]
ansible.builtin.systemd:
name: "{{ item }}"
@@ -370,7 +357,6 @@
daemon_reload: true
loop:
- adguardhome.service
- icloudpd.service
- podman-auto-update.timer
loop_control:
label: "{{ item }}"

View File

@@ -1,20 +0,0 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=iCloud Photos Downloader
Wants=network-online.target
After=network-online.target
[Container]
Image={{ aegis_icloudpd_image }}
Environment=apple_id={{ aegis_icloudpd_apple_id }}
Environment=folder_structure={{ aegis_icloudpd_folder_structure }}
Environment=synchronisation_interval={{ aegis_icloudpd_synchronisation_interval }}
Volume=/var/lib/icloudpd/data:/home/root/iCloud:Z
Volume=/var/lib/icloudpd/config:/config:Z
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +1,29 @@
---
atlas_manage_storage: false
atlas_manage_nextcloud: false
atlas_nextcloud_root: "{{ atlas_app_data_mountpoint }}/nextcloud"
atlas_nextcloud_dataset: "{{ atlas_zfs_pool }}/services/data/nextcloud"
atlas_nextcloud_domain: ""
atlas_onlyoffice_domain: ""
atlas_nextcloud_http_port: 8080
atlas_onlyoffice_http_port: 8081
atlas_nextcloud_network_subnet: 10.90.10.0/24
atlas_nextcloud_network_gateway: 10.90.10.1
atlas_nextcloud_quadlet_dir: "{{ atlas_admin_home }}/.config/containers/systemd"
atlas_nextcloud_private_dir: "{{ atlas_admin_home }}/.config/atlas-nextcloud"
atlas_nextcloud_app_cache: "{{ atlas_admin_home }}/.cache/atlas-nextcloud-apps"
atlas_nextcloud_image: ""
atlas_nextcloud_postgres_image: ""
atlas_nextcloud_redis_image: ""
atlas_onlyoffice_image: ""
atlas_nextcloud_admin: admin
atlas_nextcloud_users: []
atlas_nextcloud_apps: []
atlas_nextcloud_services:
- atlas-nextcloud-db.service
- atlas-nextcloud-redis.service
- atlas-nextcloud.service
- atlas-onlyoffice.service
atlas_manage_sharing: false
# Destructive first-boot action; normally false once the pool exists.
atlas_create_pool: false
@@ -175,24 +199,30 @@ atlas_gitea_home: "{{ atlas_admin_home }}"
atlas_gitea_container_uid: 1000
atlas_gitea_container_gid: 1000
atlas_gitea_legacy_username: gitea
atlas_gitea_legacy_uid: 1101
atlas_gitea_legacy_home: /var/lib/atlas-gitea
atlas_gitea_owner_migration: false
atlas_gitea_dataset: "{{ atlas_zfs_pool }}/services/data/gitea"
atlas_gitea_mountpoint: "{{ atlas_app_data_mountpoint }}/gitea"
atlas_gitea_quadlet_dir: "{{ atlas_gitea_home }}/.config/containers/systemd"
atlas_gitea_image: localhost/atlas-gitea:1.25.2-user-gitea-v1
atlas_gitea_image_build_dir: "{{ atlas_gitea_home }}/.local/share/atlas-gitea-image"
atlas_gitea_production_enabled: false
atlas_gitea_public_domain: ""
atlas_gitea_bind_address: "{{ ansible_host }}"
atlas_gitea_http_port: 3000
atlas_gitea_ssh_port: 2222
atlas_gitea_staging_bind_address: 127.0.0.1
atlas_gitea_staging_http_port: 3001
atlas_gitea_staging_ssh_port: 2223
atlas_gitea_restore_test: false
atlas_gitea_final_restore: false
atlas_gitea_restore_helper: /usr/local/libexec/atlas-gitea-restore-test
# Declare storage and an inactive Quadlet only. The operator supplies the
# private configuration, handles MFA, and starts the user service manually.
atlas_icloudpd_dataset: "{{ atlas_zfs_pool }}/services/data/icloudpd"
atlas_icloudpd_state_dir: "{{ atlas_app_data_mountpoint }}/icloudpd"
atlas_icloudpd_config_dir: "{{ atlas_icloudpd_state_dir }}/config"
atlas_icloudpd_photos_dir: "{{ atlas_archive_mountpoint }}/Pictures/iCloudPD"
atlas_icloudpd_image: >-
docker.io/boredazfcuk/icloudpd@sha256:9966c31ddf0b5b306ac2410b4edd5d626806d96e80c92b83cbb689972dc9389f
atlas_icloudpd_quadlet_dir: "{{ atlas_admin_home }}/.config/containers/systemd"
atlas_icloudpd_timezone: Europe/Rome
atlas_45drives_repo_url: https://repo.45drives.com/repofiles/rocky/45drives-enterprise.repo
atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo

View File

@@ -1,249 +0,0 @@
#!/usr/bin/python3
"""Rehearse a selective rootful-to-rootless Gitea restore, never a cutover."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import sqlite3
import tarfile
import tempfile
SOURCE_PREFIX = PurePosixPath("opt/gitea/data")
HOST_KEYS = (
"ssh_host_ed25519_key",
"ssh_host_rsa_key",
"ssh_host_ecdsa_key",
)
SERVER_SETTINGS = {
"START_SSH_SERVER": "true",
"BUILTIN_SSH_SERVER_USER": "git",
"SSH_USER": "git",
"SSH_PORT": "2222",
"SSH_LISTEN_PORT": "2222",
"SSH_SERVER_HOST_KEYS": ", ".join(
f"/var/lib/gitea/ssh/{key}" for key in HOST_KEYS
),
}
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def expected_digest(backup):
checksum = (backup / "payload.sha256").read_text().strip().split()
if len(checksum) != 2 or checksum[1] != "payload.tar":
raise ValueError("Unexpected Prometheus backup checksum manifest")
if not re.fullmatch(r"[0-9a-f]{64}", checksum[0]):
raise ValueError("Invalid Prometheus backup SHA-256")
return checksum[0]
def convert_config(config):
original = config.read_text()
output = []
section = ""
server_seen = set()
server_found = False
run_user_seen = False
def append_missing_server_settings():
for key, value in SERVER_SETTINGS.items():
if key not in server_seen:
output.append(f"{key} = {value}\n")
for line in original.splitlines(keepends=True):
match = re.match(r"^\s*\[([^]]+)\]\s*$", line)
if match:
if not run_user_seen:
output.append("RUN_USER = gitea\n")
run_user_seen = True
if section == "server":
append_missing_server_settings()
section = match.group(1).lower()
server_found |= section == "server"
output.append(line)
continue
setting = re.match(r"^(\s*)([A-Z_]+)(\s*=\s*)(.*?)(\r?\n?)$", line)
if setting and section == "" and setting.group(2) == "RUN_USER":
run_user_seen = True
line = f"{setting.group(1)}RUN_USER{setting.group(3)}gitea{setting.group(5)}"
elif setting and section == "server" and setting.group(2) in SERVER_SETTINGS:
key = setting.group(2)
server_seen.add(key)
line = f"{setting.group(1)}{key}{setting.group(3)}{SERVER_SETTINGS[key]}{setting.group(5)}"
else:
line = line.replace("/data/", "/var/lib/gitea/")
output.append(line)
if section == "server":
append_missing_server_settings()
if not server_found:
raise ValueError("Gitea server configuration missing")
config.write_text("".join(output))
config.chmod(0o600)
def extract_gitea(tar_path, staged_data):
count = 0
with tarfile.open(tar_path, mode="r") as archive:
for member in archive:
name = PurePosixPath(member.name)
if name == SOURCE_PREFIX:
continue
if SOURCE_PREFIX not in name.parents:
continue
relative = name.relative_to(SOURCE_PREFIX)
if not relative.parts or any(part in (".", "..") for part in relative.parts):
raise ValueError("Unsafe Gitea backup path")
if not (member.isdir() or member.isfile()):
raise ValueError("Unexpected Gitea backup member type")
destination = staged_data.joinpath(*relative.parts)
if member.isdir():
destination.mkdir(parents=True, exist_ok=True)
destination.chmod(0o700)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as source, destination.open("xb") as target:
shutil.copyfileobj(source, target)
destination.chmod(member.mode & 0o777)
count += 1
if count == 0:
raise ValueError("No Gitea files in backup")
def validate(staged_data, staged_config):
database = staged_data / "gitea/gitea.db"
repositories = staged_data / "git/repositories"
if not database.is_file() or not repositories.is_dir():
raise ValueError("Missing SQLite database or Git repositories")
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ValueError("Gitea SQLite quick_check failed")
if connection.execute("SELECT count(*) FROM repository").fetchone()[0] < 1:
raise ValueError("Gitea backup contains no repository records")
if not any(repositories.rglob("*.git")):
raise ValueError("Gitea backup contains no Git repository directories")
if not (staged_config / "app.ini").is_file():
raise ValueError("Gitea app.ini missing")
for name in HOST_KEYS:
if not (staged_data / "ssh" / name).is_file():
raise ValueError("Gitea SSH host key missing")
def chown_tree(root, uid, gid):
for directory, dirs, files in os.walk(root):
os.chown(directory, uid, gid)
for name in dirs + files:
os.chown(os.path.join(directory, name), uid, gid)
def replace_rehearsal(target, stage, digest, uid, gid):
previous_data = target / ".previous-rehearsal-data"
previous_config = target / ".previous-rehearsal-config"
if previous_data.exists() or previous_config.exists():
raise ValueError("An interrupted Gitea replacement needs manual recovery")
os.rename(target / "data", previous_data)
try:
os.rename(target / "config", previous_config)
os.rename(stage / "data", target / "data")
os.rename(stage / "config", target / "config")
final_marker = target / ".final-sha256"
final_marker.write_text(digest + "\n")
final_marker.chmod(0o600)
os.chown(final_marker, uid, gid)
(target / ".rehearsal-sha256").unlink()
except Exception:
for name, previous in (("data", previous_data), ("config", previous_config)):
current = target / name
if previous.exists():
if current.exists():
shutil.rmtree(current)
os.rename(previous, current)
(target / ".final-sha256").unlink(missing_ok=True)
raise
shutil.rmtree(previous_data)
shutil.rmtree(previous_config)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--backup", type=Path, required=True)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("--uid", type=int, required=True)
parser.add_argument("--gid", type=int, required=True)
parser.add_argument("--replace-rehearsal", action="store_true")
args = parser.parse_args()
backup = args.backup.resolve(strict=True)
target = args.target.resolve(strict=True)
if not str(backup).startswith("/zpool/backup/hosts/prometheus/snapshots/"):
raise ValueError("Refusing backup outside the Atlas Prometheus snapshots")
if str(target) != "/zpool/services/data/gitea":
raise ValueError("Refusing target outside the dedicated Gitea dataset")
if args.uid != 1000 or args.gid != 1000:
raise ValueError("Unexpected admin-owned Gitea account IDs")
expected = expected_digest(backup)
if sha256(backup / "payload.tar") != expected:
raise ValueError("Prometheus backup SHA-256 mismatch")
marker = target / (".final-sha256" if args.replace_rehearsal else ".rehearsal-sha256")
if marker.exists():
if marker.read_text().strip() != expected:
raise ValueError("A different Gitea restore already occupies this dataset")
validate(target / "data", target / "config")
print("unchanged")
return
if args.replace_rehearsal:
metadata = json.loads((backup / "metadata.json").read_text())
if metadata.get("purpose") != "gitea-cutover":
raise ValueError("Final restore requires an explicit Gitea cutover export")
if not (target / ".rehearsal-sha256").is_file():
raise ValueError("Only a marked rehearsal may be replaced")
if not all((target / name).is_dir() for name in ("data", "config")):
raise ValueError("Prepared Gitea volume paths are missing")
else:
if (target / ".final-sha256").exists():
raise ValueError("Refusing a rehearsal restore over final Gitea data")
for name in ("data", "config"):
directory = target / name
if not directory.is_dir() or any(directory.iterdir()):
raise ValueError("Gitea target is not empty; refusing overwrite")
with tempfile.TemporaryDirectory(prefix=".rehearsal-", dir=target) as temporary:
stage = Path(temporary)
staged_data = stage / "data"
staged_config = stage / "config"
staged_data.mkdir()
staged_config.mkdir()
extract_gitea(backup / "payload.tar", staged_data)
source_config = staged_data / "gitea/conf/app.ini"
if not source_config.is_file():
raise ValueError("Source Gitea app.ini missing")
shutil.copy2(source_config, staged_config / "app.ini")
source_config.unlink()
convert_config(staged_config / "app.ini")
validate(staged_data, staged_config)
chown_tree(stage, args.uid, args.gid)
if args.replace_rehearsal:
replace_rehearsal(target, stage, expected, args.uid, args.gid)
else:
for name in ("data", "config"):
(target / name).rmdir()
os.rename(stage / name, target / name)
marker.write_text(expected + "\n")
marker.chmod(0o600)
os.chown(marker, args.uid, args.gid)
print("restored")
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,14 @@
---
- name: Reload Atlas admin user manager
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when: not ansible_check_mode
- name: Reload SSH service
ansible.builtin.systemd:
name: sshd

View File

@@ -47,8 +47,8 @@
- atlas_gitea_dataset_owner.stat.uid | int == atlas_admin_uid | int
- atlas_gitea_dataset_owner.stat.gid | int == atlas_admin_gid | int
fail_msg: >-
Run the explicit Gitea owner migration before enabling the admin
Quadlet; never chown an active legacy service in a normal run.
The production dataset must already belong to admin before enabling
the Quadlet; normal provisioning must not chown an active legacy service.
when: atlas_gitea_production_enabled | bool
- name: Remove the retired account's parent-dataset traverse ACL

View File

@@ -1,275 +0,0 @@
---
# Run only in an approved outage with -e atlas_gitea_owner_migration=true.
- name: Move live Gitea from the legacy host account to admin
tags: [atlas, gitea_owner_migration]
when: atlas_gitea_owner_migration | bool
block:
- name: Refuse a check-mode owner migration
ansible.builtin.assert:
that: not ansible_check_mode
fail_msg: The owner migration requires an explicit live outage.
- name: Inspect the Gitea dataset owner
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}"
register: atlas_gitea_migration_owner
- name: Require either the legacy owner or an already migrated dataset
ansible.builtin.assert:
that:
- atlas_gitea_migration_owner.stat.isdir | default(false)
- atlas_gitea_migration_owner.stat.uid | int in [atlas_gitea_legacy_uid | int, atlas_admin_uid | int]
fail_msg: Refusing to modify a Gitea dataset with an unexpected owner.
- name: Migrate only a legacy-owned Gitea dataset
when: atlas_gitea_migration_owner.stat.uid | int == atlas_gitea_legacy_uid | int
block:
- name: Require the final cutover marker and configuration
ansible.builtin.stat:
path: "{{ item }}"
loop:
- "{{ atlas_gitea_mountpoint }}/.final-sha256"
- "{{ atlas_gitea_mountpoint }}/config/app.ini"
register: atlas_gitea_migration_files
- name: Refuse migration without both final data and configuration
ansible.builtin.assert:
that: atlas_gitea_migration_files.results | map(attribute='stat.isreg') | min
- name: Check that admin has no existing Gitea Quadlet
ansible.builtin.stat:
path: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
register: atlas_gitea_admin_quadlet
- name: Refuse to overwrite an existing admin Quadlet
ansible.builtin.assert:
that: not atlas_gitea_admin_quadlet.stat.exists
- name: Check pool health before the outage
ansible.builtin.command:
argv: [zpool, status, -x, "{{ atlas_zfs_pool }}"]
register: atlas_gitea_pool_before
changed_when: false
failed_when: "'is healthy' not in atlas_gitea_pool_before.stdout"
- name: Ensure the admin Gitea image is available before stopping the source
ansible.builtin.import_tasks: gitea_image.yml
- name: Stop, snapshot and test the admin-owned staging service
block:
- name: Stop and disable the legacy Gitea user service
become_user: "{{ atlas_gitea_legacy_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: stopped
enabled: false
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_legacy_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_legacy_uid }}/bus"
- name: Record the migration snapshot name
ansible.builtin.set_fact:
atlas_gitea_migration_snapshot: >-
{{ atlas_gitea_dataset }}@gitea-owner-migration-{{ ansible_facts.date_time.iso8601_basic_short }}
- name: Snapshot the stopped Gitea dataset for manual recovery
ansible.builtin.command:
argv: [zfs, snapshot, "{{ atlas_gitea_migration_snapshot }}"]
- name: Transfer only the Gitea dataset to admin
ansible.builtin.file:
path: "{{ atlas_gitea_mountpoint }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
recurse: true
- name: Set the actual internal Unix process user
ansible.builtin.lineinfile:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
regexp: '^RUN_USER\s*='
line: RUN_USER = gitea
mode: "0600"
no_log: true
diff: false
- name: Preserve public git clone URLs independently of the Unix user
community.general.ini_file:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
section: server
option: "{{ item }}"
value: git
mode: "0600"
no_extra_spaces: false
loop: [BUILTIN_SSH_SERVER_USER, SSH_USER]
no_log: true
diff: false
- name: Render admin's loopback-only staging Quadlet
ansible.builtin.template:
src: atlas-gitea.container.j2
dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
vars:
atlas_gitea_production_enabled: false
- name: Reload the admin user manager for staging
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Start admin's loopback-only staging service
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Verify staging HTTP before promotion
ansible.builtin.uri:
url: "http://127.0.0.1:{{ atlas_gitea_staging_http_port }}/"
status_code: 200
register: atlas_gitea_staging_http
retries: 30
delay: 2
until: atlas_gitea_staging_http is succeeded
- name: Verify the container really runs as internal gitea
become_user: "{{ atlas_admin_username }}"
ansible.builtin.command:
argv: [podman, exec, atlas-gitea, id, -un]
environment:
HOME: "{{ atlas_admin_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
register: atlas_gitea_internal_user
changed_when: false
failed_when: atlas_gitea_internal_user.stdout != 'gitea'
- name: Verify the migrated SQLite database
ansible.builtin.command:
argv:
- sqlite3
- "{{ atlas_gitea_mountpoint }}/data/gitea/gitea.db"
- PRAGMA quick_check;
register: atlas_gitea_migration_sqlite
changed_when: false
failed_when: atlas_gitea_migration_sqlite.stdout != 'ok'
rescue:
- name: Stop admin's failed staging service
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: stopped
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
failed_when: false
- name: Restore the original Gitea configuration from the safety snapshot
ansible.builtin.command:
argv:
- cp
- -a
- "{{ atlas_gitea_mountpoint }}/.zfs/snapshot/{{ atlas_gitea_migration_snapshot.split('@')[1] }}/config/app.ini"
- "{{ atlas_gitea_mountpoint }}/config/app.ini"
when: atlas_gitea_migration_snapshot is defined
- name: Return the Gitea dataset to the legacy account
ansible.builtin.file:
path: "{{ atlas_gitea_mountpoint }}"
state: directory
owner: "{{ atlas_gitea_legacy_username }}"
group: "{{ atlas_gitea_legacy_username }}"
recurse: true
- name: Restart the legacy Gitea service
become_user: "{{ atlas_gitea_legacy_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
enabled: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_legacy_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_legacy_uid }}/bus"
- name: Report the failed migration and preserved snapshot
ansible.builtin.fail:
msg: >-
Admin staging failed; legacy Gitea was restarted. Inspect
{{ atlas_gitea_migration_snapshot | default('the host journal') }}.
- name: Stop admin's validated staging service
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: stopped
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Render admin's production Gitea Quadlet
ansible.builtin.template:
src: atlas-gitea.container.j2
dest: "{{ atlas_gitea_quadlet_dir }}/atlas-gitea.container"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
vars:
atlas_gitea_production_enabled: true
- name: Reload admin's production user manager
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Enable and start admin's production Gitea
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: started
enabled: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
- name: Verify production HTTP before retiring the old Quadlet
ansible.builtin.uri:
url: "http://{{ atlas_gitea_bind_address }}:{{ atlas_gitea_http_port }}/"
status_code: 200
register: atlas_gitea_production_http
retries: 30
delay: 2
until: atlas_gitea_production_http is succeeded
- name: Remove only the disabled legacy Quadlet
ansible.builtin.file:
path: "{{ atlas_gitea_legacy_home }}/.config/containers/systemd/atlas-gitea.container"
state: absent
- name: Reload the legacy user manager after Quadlet removal
become_user: "{{ atlas_gitea_legacy_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_legacy_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_legacy_uid }}/bus"

View File

@@ -0,0 +1,58 @@
---
- name: Manage the public domain of the restored production Gitea
tags: [atlas, gitea, gitea_public_domain]
when:
- atlas_manage_gitea | bool
- atlas_gitea_production_enabled | bool
- atlas_gitea_public_domain | length > 0
block:
- name: Require an explicit public Gitea hostname
ansible.builtin.assert:
that:
- atlas_gitea_public_domain is match('^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$')
- name: Inspect the restored private Gitea configuration
ansible.builtin.stat:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
follow: false
register: atlas_gitea_public_config
- name: Refuse to create or replace an unprepared Gitea configuration
ansible.builtin.assert:
that:
- atlas_gitea_public_config.stat.isreg | default(false)
- atlas_gitea_public_config.stat.uid | int == atlas_gitea_uid | int
- atlas_gitea_public_config.stat.mode == '0600'
# app.ini contains secrets: preserve all unrelated settings and suppress diffs.
- name: Set only the declared public Gitea server fields
community.general.ini_file:
path: "{{ atlas_gitea_mountpoint }}/config/app.ini"
section: server
option: "{{ item.option }}"
value: "{{ item.value }}"
create: false
backup: true
owner: "{{ atlas_gitea_username }}"
group: "{{ atlas_gitea_group }}"
mode: "0600"
loop:
- { option: DOMAIN, value: "{{ atlas_gitea_public_domain }}" }
- { option: ROOT_URL, value: "https://{{ atlas_gitea_public_domain }}/" }
- { option: SSH_DOMAIN, value: "{{ atlas_gitea_public_domain }}" }
register: atlas_gitea_public_domain_update
no_log: true
diff: false
- name: Restart only Gitea when its public configuration changes
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.systemd:
name: atlas-gitea.service
scope: user
state: restarted
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
when:
- atlas_gitea_public_domain_update is changed
- not ansible_check_mode

View File

@@ -1,107 +0,0 @@
---
- name: Restore Gitea from a verified Prometheus backup only on explicit request
tags: [atlas, gitea_restore, gitea_final_restore]
when: atlas_gitea_restore_test | bool or atlas_gitea_final_restore | bool
block:
- name: Require the prepared rootless Gitea target
ansible.builtin.assert:
that:
- atlas_manage_gitea | bool
- not (atlas_gitea_restore_test | bool and atlas_gitea_final_restore | bool)
- atlas_gitea_staging_bind_address == '127.0.0.1'
- atlas_gitea_mountpoint == atlas_app_data_mountpoint ~ '/gitea'
fail_msg: Prepare the isolated, loopback-only rootless Gitea target first.
- name: Confirm the rootless Gitea service is inactive
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.command:
argv:
- systemctl
- --user
- is-active
- atlas-gitea.service
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_gitea_uid }}/bus"
register: atlas_gitea_restore_service_state
changed_when: false
failed_when: false
when: not ansible_check_mode
- name: Refuse to overwrite an active rootless Gitea service
ansible.builtin.assert:
that:
- atlas_gitea_restore_service_state.stdout == 'inactive'
fail_msg: The rootless Gitea user service must be known and inactive before restoring data.
when: not ansible_check_mode
- name: Check for a manually running rootless Gitea container
become_user: "{{ atlas_gitea_username }}"
ansible.builtin.command:
argv:
- podman
- ps
- --quiet
- --filter
- name=atlas-gitea
args:
chdir: "{{ atlas_gitea_home }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_gitea_uid }}"
register: atlas_gitea_restore_container_state
changed_when: false
when: not ansible_check_mode
- name: Refuse to overwrite a running rootless Gitea container
ansible.builtin.assert:
that:
- atlas_gitea_restore_container_state.stdout | length == 0
fail_msg: Stop every rootless Atlas Gitea container before restoring data.
when: not ansible_check_mode
- name: Install the selective rootless Gitea restore helper
ansible.builtin.copy:
src: atlas-gitea-restore-test.py
dest: "{{ atlas_gitea_restore_helper }}"
owner: root
group: root
mode: "0700"
- name: Restore only Gitea data into the isolated target
ansible.builtin.command:
argv:
- "{{ atlas_gitea_restore_helper }}"
- --backup
- "{{ atlas_backup_prometheus_mountpoint }}/latest"
- --target
- "{{ atlas_gitea_mountpoint }}"
- --uid
- "{{ atlas_gitea_uid | string }}"
- --gid
- "{{ atlas_gitea_gid | string }}"
register: atlas_gitea_restore_result
changed_when: atlas_gitea_restore_result.stdout == 'restored'
no_log: true
when:
- atlas_gitea_restore_test | bool
- not ansible_check_mode
- name: Replace the marked rehearsal with the final consistent Gitea export
ansible.builtin.command:
argv:
- "{{ atlas_gitea_restore_helper }}"
- --backup
- "{{ atlas_backup_prometheus_mountpoint }}/latest"
- --target
- "{{ atlas_gitea_mountpoint }}"
- --uid
- "{{ atlas_gitea_uid | string }}"
- --gid
- "{{ atlas_gitea_gid | string }}"
- --replace-rehearsal
register: atlas_gitea_final_restore_result
changed_when: atlas_gitea_final_restore_result.stdout == 'restored'
no_log: true
when:
- atlas_gitea_final_restore | bool
- not ansible_check_mode

View File

@@ -0,0 +1,188 @@
---
- name: Require exact Atlas iCloudPD paths and rootless identity
tags: [atlas, icloudpd]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_icloudpd_dataset == atlas_zfs_pool ~ '/services/data/icloudpd'
- atlas_icloudpd_state_dir == atlas_app_data_mountpoint ~ '/icloudpd'
- atlas_icloudpd_config_dir == atlas_icloudpd_state_dir ~ '/config'
- atlas_icloudpd_photos_dir == atlas_archive_mountpoint ~ '/Pictures/iCloudPD'
- atlas_admin_uid | int == 1000
- atlas_admin_gid | int == 1000
- atlas_icloudpd_image is search('@sha256:[0-9a-f]{64}$')
fail_msg: Verify the fixed, separate Atlas iCloudPD photo and state paths.
- name: Declare rootless Atlas iCloudPD storage and boot-started Quadlet
tags: [atlas, icloudpd]
block:
- name: Inspect the existing Archive and application-data datasets
community.general.zfs_facts:
name: "{{ item.dataset }}"
properties: name,mounted,mountpoint
loop:
- dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_archive }}"
mountpoint: "{{ atlas_archive_mountpoint }}"
- dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
mountpoint: "{{ atlas_app_data_mountpoint }}"
loop_control:
label: "{{ item.dataset }}"
register: atlas_icloudpd_parent_datasets
- name: Refuse missing or unmounted iCloudPD parent datasets
ansible.builtin.assert:
that:
- item.ansible_facts.ansible_zfs_datasets | length == 1
- item.ansible_facts.ansible_zfs_datasets[0].mounted == 'yes'
- item.ansible_facts.ansible_zfs_datasets[0].mountpoint == item.item.mountpoint
loop: "{{ atlas_icloudpd_parent_datasets.results }}"
loop_control:
label: "{{ item.item.dataset }}"
- name: Inspect the existing Pictures namespace and proposed target
ansible.builtin.stat:
path: "{{ item }}"
follow: false
loop:
- "{{ atlas_archive_mountpoint }}/Pictures"
- "{{ atlas_icloudpd_photos_dir }}"
- "{{ atlas_icloudpd_photos_dir }}/.atlas-icloudpd-managed"
register: atlas_icloudpd_photo_paths
- name: Refuse to adopt unrelated Pictures data or a symlink
ansible.builtin.assert:
that:
- atlas_icloudpd_photo_paths.results[0].stat.isdir | default(false)
- atlas_icloudpd_photo_paths.results[0].stat.uid | int == atlas_admin_uid | int
- >-
not atlas_icloudpd_photo_paths.results[1].stat.exists or
(atlas_icloudpd_photo_paths.results[1].stat.isdir | default(false) and
atlas_icloudpd_photo_paths.results[2].stat.isreg | default(false))
fail_msg: >-
Pictures must exist and be admin-owned; an existing iCloudPD target
must carry its managed marker. Never adopt or replace unrelated data.
- name: Create a dedicated ZFS dataset for iCloudPD configuration and MFA
community.general.zfs:
name: "{{ atlas_icloudpd_dataset }}"
state: present
extra_zfs_properties:
compression: zstd
mountpoint: "{{ atlas_icloudpd_state_dir }}"
- name: Restrict iCloudPD state and the new photo subtree
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ atlas_icloudpd_state_dir }}"
mode: "0700"
- path: "{{ atlas_icloudpd_config_dir }}"
mode: "0700"
- path: "{{ atlas_icloudpd_photos_dir }}"
mode: "0750"
- path: "{{ atlas_icloudpd_quadlet_dir }}"
mode: "0700"
loop_control:
label: "{{ item.path }}"
- name: Mark only the newly managed iCloudPD photo subtree
ansible.builtin.copy:
content: "Atlas iCloudPD photo subtree; do not remove source photos.\n"
dest: "{{ atlas_icloudpd_photos_dir }}/.atlas-icloudpd-managed"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
force: false
- name: Install the image's required mounted-filesystem failsafe
ansible.builtin.copy:
content: ""
dest: "{{ atlas_icloudpd_photos_dir }}/.mounted"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
force: false
- name: Require the Vault-backed iCloudPD Apple ID
ansible.builtin.assert:
that:
- vault_atlas_icloudpd_apple_id is defined
- vault_atlas_icloudpd_apple_id | length > 0
- vault_atlas_icloudpd_apple_id != 'REPLACE_ME'
- vault_atlas_icloudpd_apple_id.splitlines() | length == 1
fail_msg: Configure the existing iCloudPD Apple ID in Vault.
no_log: true
- name: Seed private Atlas iCloudPD configuration when absent
ansible.builtin.template:
src: atlas-icloudpd.conf.j2
dest: "{{ atlas_icloudpd_config_dir }}/icloudpd.conf"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
force: false
no_log: true
diff: false
- name: Keep declared iCloudPD options in the image-managed configuration
ansible.builtin.lineinfile:
path: "{{ atlas_icloudpd_config_dir }}/icloudpd.conf"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
loop:
- {key: apple_id, value: "{{ vault_atlas_icloudpd_apple_id }}"}
- {key: authentication_type, value: MFA}
- {key: user, value: user}
- {key: user_id, value: "1000"}
- {key: group, value: group}
- {key: group_id, value: "1000"}
- {key: download_path, value: /home/user/iCloud}
- {key: folder_structure, value: "{:%Y/%m/%d}"}
- {key: directory_permissions, value: "750"}
- {key: file_permissions, value: "640"}
- {key: download_interval, value: "86400"}
- {key: auto_delete, value: "false"}
- {key: delete_after_download, value: "false"}
loop_control:
label: "{{ item.key }}"
no_log: true
diff: false
- name: Render the rootless Atlas iCloudPD Quadlet
ansible.builtin.template:
src: atlas-icloudpd.container.j2
dest: "{{ atlas_icloudpd_quadlet_dir }}/atlas-icloudpd.container"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
register: atlas_icloudpd_quadlet
- name: Reload the Atlas admin user manager after iCloudPD Quadlet changes
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when:
- atlas_icloudpd_quadlet.changed
- not ansible_check_mode
- name: Keep the rootless Atlas iCloudPD service running
become_user: "{{ atlas_admin_username }}"
ansible.builtin.systemd:
name: atlas-icloudpd.service
scope: user
state: started
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when: not ansible_check_mode

View File

@@ -14,14 +14,17 @@
- name: Import Atlas storage tasks
ansible.builtin.import_tasks: storage.yml
- name: Import explicit Atlas Gitea owner migration
ansible.builtin.import_tasks: gitea_owner_migration.yml
- name: Import staged Atlas rootless Gitea tasks
ansible.builtin.import_tasks: gitea.yml
- name: Import explicit Atlas Gitea restore rehearsal tasks
ansible.builtin.import_tasks: gitea_restore.yml
- name: Import the declared Atlas Gitea public domain
ansible.builtin.import_tasks: gitea_public_domain.yml
- name: Import Atlas Nextcloud steady-state stack
ansible.builtin.import_tasks: nextcloud.yml
- name: Import Atlas iCloudPD storage and boot-started Quadlet tasks
ansible.builtin.import_tasks: icloudpd.yml
- name: Import Atlas ZFS maintenance tasks
ansible.builtin.import_tasks: zfs_maintenance.yml

View File

@@ -0,0 +1,309 @@
---
- name: Manage the empty Atlas Nextcloud and ONLYOFFICE stack
tags: [atlas, nextcloud]
when: atlas_manage_nextcloud | bool
block:
- name: Validate dedicated paths, domains and pinned images
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_manage_firewall | bool
- atlas_nextcloud_root == atlas_app_data_mountpoint ~ '/nextcloud'
- atlas_nextcloud_dataset == atlas_zfs_pool ~ '/services/data/nextcloud'
- atlas_nextcloud_domain is match('^[a-z0-9.-]+$')
- atlas_onlyoffice_domain is match('^[a-z0-9.-]+$')
- atlas_nextcloud_domain != atlas_onlyoffice_domain
- atlas_nextcloud_http_port | int > 1024
- atlas_onlyoffice_http_port | int > 1024
- atlas_nextcloud_http_port != atlas_onlyoffice_http_port
- "['calendar', 'contacts', 'onlyoffice', 'groupfolders'] | difference(atlas_nextcloud_apps | map(attribute='id') | list) | length == 0"
- atlas_nextcloud_users | length > 0
- atlas_nextcloud_admin not in (atlas_nextcloud_users | map(attribute='username') | list)
- atlas_nextcloud_users | map(attribute='username') | unique | list | length == atlas_nextcloud_users | length
- item is search('@sha256:[0-9a-f]{64}$')
loop:
- "{{ atlas_nextcloud_image }}"
- "{{ atlas_nextcloud_postgres_image }}"
- "{{ atlas_nextcloud_redis_image }}"
- "{{ atlas_onlyoffice_image }}"
- name: Require dedicated Vault secrets without exposing them
ansible.builtin.assert:
that:
- item | default('') is match('^[a-zA-Z0-9]{32,}$')
loop: >-
{{ [vault_nextcloud_database_password | default(''),
vault_nextcloud_redis_password | default(''),
vault_nextcloud_admin_password | default(''),
vault_nextcloud_onlyoffice_jwt | default('')] +
(atlas_nextcloud_users | map(attribute='password') | list) }}
no_log: true
- name: Verify the existing application-data parent is mounted
community.general.zfs_facts:
name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
properties: name,mounted,mountpoint
register: atlas_nextcloud_parent
- name: Require the verified application-data parent
ansible.builtin.assert:
that:
- atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets | length == 1
- atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets[0].mounted == 'yes'
- atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets[0].mountpoint == atlas_app_data_mountpoint
- name: Create the dedicated Nextcloud namespace and component datasets
community.general.zfs:
name: "{{ atlas_nextcloud_dataset }}{{ item }}"
state: present
extra_zfs_properties:
compression: zstd
mountpoint: "{{ atlas_nextcloud_root }}{{ item }}"
loop: ['', /app, /files, /database, /cache, /office]
- name: Inspect component directories before seeding ownership
ansible.builtin.stat:
path: "{{ atlas_nextcloud_root }}{{ item }}"
follow: false
get_checksum: false
loop: [/app, /files, /database, /cache, /office]
register: atlas_nextcloud_component_paths
- name: Seed only root-owned new dataset roots without recursive ownership changes
ansible.builtin.file:
path: "{{ item.stat.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
loop: "{{ atlas_nextcloud_component_paths.results }}"
loop_control:
label: "{{ item.item }}"
when:
- item.stat.exists
- item.stat.uid | default(-1) | int == 0
- name: Ensure private rootless stack configuration directories exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "{{ item.mode }}"
loop:
- {path: "{{ atlas_nextcloud_private_dir }}", mode: "0700"}
- {path: "{{ atlas_nextcloud_app_cache }}", mode: "0755"}
- {path: "{{ atlas_nextcloud_quadlet_dir }}", mode: "0700"}
- {path: "{{ atlas_admin_home }}/.config/systemd/user", mode: "0700"}
- name: Inspect the dedicated ONLYOFFICE bind directories
ansible.builtin.stat:
path: "{{ atlas_nextcloud_root }}/office/{{ item }}"
follow: false
get_checksum: false
loop: [data, lib, logs, database]
register: atlas_onlyoffice_bind_paths
- name: Create ONLYOFFICE bind directories only when absent
ansible.builtin.file:
path: "{{ item.invocation.module_args.path }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
loop: "{{ atlas_onlyoffice_bind_paths.results }}"
loop_control:
label: "{{ item.item }}"
when: not item.stat.exists
- name: Store private mounted password files inside a restricted host directory
ansible.builtin.copy:
content: "{{ item.value }}\n"
dest: "{{ atlas_nextcloud_private_dir }}/{{ item.name }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop:
- {name: postgres-password, value: "{{ vault_nextcloud_database_password }}"}
- {name: redis-password, value: "{{ vault_nextcloud_redis_password }}"}
- {name: admin-password, value: "{{ vault_nextcloud_admin_password }}"}
- {name: onlyoffice-jwt, value: "{{ vault_nextcloud_onlyoffice_jwt }}"}
no_log: true
diff: false
register: atlas_nextcloud_secret_files
- name: Render private Redis and ONLYOFFICE configuration
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ atlas_nextcloud_private_dir }}/{{ item.dest }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "{{ item.mode }}"
loop:
- {src: atlas-nextcloud-redis.conf.j2, dest: redis.conf, mode: "0644"}
- {src: atlas-onlyoffice.env.j2, dest: onlyoffice.env, mode: "0600"}
no_log: true
diff: false
register: atlas_nextcloud_private_configuration
- name: Download checksum-pinned compatible application releases
ansible.builtin.get_url:
url: "{{ item.url }}"
dest: "{{ atlas_nextcloud_app_cache }}/{{ item.id }}-{{ item.version }}.tar.gz"
checksum: "{{ item.checksum }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }} {{ item.version }}"
when: not ansible_check_mode
- name: Admit only the Aegis gateway to the Nextcloud and Office HTTP listeners
ansible.posix.firewalld:
rich_rule: >-
rule family="ipv4" source address="{{ atlas_aegis_ip }}"
port port="{{ item }}" protocol="tcp" accept
zone: "{{ atlas_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
loop: ["{{ atlas_nextcloud_http_port }}", "{{ atlas_onlyoffice_http_port }}"]
- name: Enable lingering for the declared rootless owner
ansible.builtin.command:
argv: [loginctl, enable-linger, "{{ atlas_admin_username }}"]
creates: "/var/lib/systemd/linger/{{ atlas_admin_username }}"
- name: Render Nextcloud component and network Quadlets
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ atlas_nextcloud_quadlet_dir }}/{{ item }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop:
- atlas-nextcloud.network
- atlas-nextcloud-db.container
- atlas-nextcloud-redis.container
- atlas-nextcloud.container
- atlas-onlyoffice.container
register: atlas_nextcloud_quadlets
- name: Render recurring Nextcloud cron user units
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ atlas_admin_home }}/.config/systemd/user/{{ item }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0644"
loop: [atlas-nextcloud-cron.service, atlas-nextcloud-cron.timer]
register: atlas_nextcloud_cron_units
- name: Manage and verify rootless Nextcloud services
become_user: "{{ atlas_admin_username }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus"
when: not ansible_check_mode
block:
- name: Pull the pinned images before starting services
containers.podman.podman_image:
name: "{{ item }}"
state: present
loop:
- "{{ atlas_nextcloud_image }}"
- "{{ atlas_nextcloud_postgres_image }}"
- "{{ atlas_nextcloud_redis_image }}"
- "{{ atlas_onlyoffice_image }}"
- name: Reload the user manager to generate component units
ansible.builtin.systemd:
scope: user
daemon_reload: true
- name: Start the declared Nextcloud and ONLYOFFICE services
ansible.builtin.systemd:
scope: user
name: "{{ item }}"
state: >-
{{ 'restarted' if (atlas_nextcloud_quadlets is changed or
atlas_nextcloud_private_configuration is changed or
atlas_nextcloud_secret_files is changed) else 'started' }}
loop: "{{ atlas_nextcloud_services }}"
- name: Wait for the application configuration directory to be initialized
become: true
become_user: root
ansible.builtin.wait_for:
path: "{{ atlas_nextcloud_root }}/app/config/config.php"
timeout: 600
- name: Derive container web-user host IDs from the actual rootless maps
ansible.builtin.command:
argv:
- podman
- unshare
- python3
- -c
- >-
import json;
print(json.dumps({k: next(int(b)+33-int(a) for a,b,n in
(l.split() for l in open('/proc/self/'+k+'_map'))
if int(a)<=33<int(a)+int(n)) for k in ['uid','gid']}))
register: atlas_nextcloud_web_mapping
changed_when: false
- name: Read the current application SELinux label without changing it
become: true
become_user: root
ansible.builtin.command:
argv: [stat, -c, '%C', "{{ atlas_nextcloud_root }}/app/config"]
register: atlas_nextcloud_config_label
changed_when: false
- name: Maintain the managed Nextcloud configuration include
become: true
become_user: root
ansible.builtin.template:
src: atlas-nextcloud.config.php.j2
dest: "{{ atlas_nextcloud_root }}/app/config/atlas.config.php"
owner: "{{ (atlas_nextcloud_web_mapping.stdout | from_json).uid }}"
group: "{{ (atlas_nextcloud_web_mapping.stdout | from_json).gid }}"
mode: "0640"
seuser: "{{ atlas_nextcloud_config_label.stdout.split(':')[0] }}"
serole: "{{ atlas_nextcloud_config_label.stdout.split(':')[1] }}"
setype: "{{ atlas_nextcloud_config_label.stdout.split(':')[2] }}"
selevel: "{{ atlas_nextcloud_config_label.stdout.split(':')[3:] | join(':') }}"
diff: false
- name: Wait for Nextcloud to complete its initial installation
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, status, --output=json]
register: atlas_nextcloud_status
changed_when: false
retries: 60
delay: 10
until: >-
atlas_nextcloud_status.rc == 0 and
atlas_nextcloud_status.stdout.startswith('{') and
(atlas_nextcloud_status.stdout | from_json).installed | default(false)
- name: Import declared ongoing application and account configuration
ansible.builtin.include_tasks: nextcloud_application.yml
- name: Enable and start the recurring Nextcloud cron timer
ansible.builtin.systemd:
scope: user
name: atlas-nextcloud-cron.timer
state: "{{ 'restarted' if atlas_nextcloud_cron_units is changed else 'started' }}"
enabled: true
- name: Verify ONLYOFFICE local health without publishing the domain
ansible.builtin.uri:
url: "http://127.0.0.1:{{ atlas_onlyoffice_http_port }}/healthcheck"
return_content: true
register: atlas_onlyoffice_health
retries: 60
delay: 10
until: atlas_onlyoffice_health.status | default(0) == 200 and atlas_onlyoffice_health.content | default('') | trim == 'true'

View File

@@ -0,0 +1,171 @@
---
- name: Inspect installed application state
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, app:list, --output=json]
register: atlas_nextcloud_current_apps
changed_when: false
- name: Record enabled and disabled application versions
ansible.builtin.set_fact:
atlas_nextcloud_installed_apps: >-
{{ (atlas_nextcloud_current_apps.stdout | from_json).enabled |
combine((atlas_nextcloud_current_apps.stdout | from_json).disabled) }}
- name: Refuse implicit application upgrades or downgrades
ansible.builtin.assert:
that:
- item.id not in atlas_nextcloud_installed_apps or atlas_nextcloud_installed_apps[item.id] == item.version
fail_msg: Application versions must be changed in a deliberate upgrade window.
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }}"
- name: Install only absent checksum-verified application archives
ansible.builtin.command:
argv:
- podman
- exec
- --user
- '33'
- atlas-nextcloud
- tar
- -xzf
- "/mnt/atlas-apps/{{ item.id }}-{{ item.version }}.tar.gz"
- -C
- /var/www/html/custom_apps
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }}"
when: item.id not in atlas_nextcloud_installed_apps
changed_when: true
- name: Enable the declared applications
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, app:enable, "{{ item.id }}"]
loop: "{{ atlas_nextcloud_apps }}"
loop_control:
label: "{{ item.id }}"
when: item.id not in (atlas_nextcloud_current_apps.stdout | from_json).enabled
changed_when: true
- name: Inspect existing application users without exposing passwords
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:list, --output=json]
register: atlas_nextcloud_current_users
changed_when: false
- name: Ensure the two standard users exist without resetting existing passwords
ansible.builtin.command:
argv:
- podman
- exec
- --user
- '33'
- --env
- OC_PASS
- atlas-nextcloud
- php
- occ
- user:add
- --password-from-env
- --display-name
- "{{ item.display_name }}"
- "{{ item.username }}"
environment:
OC_PASS: "{{ item.password }}"
loop: "{{ atlas_nextcloud_users }}"
when: item.username not in (atlas_nextcloud_current_users.stdout | from_json)
changed_when: true
no_log: true
diff: false
- name: Inspect standard-user group membership and quota
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:info, --output=json, "{{ item.username }}"]
loop: "{{ atlas_nextcloud_users }}"
loop_control:
label: "{{ item.username }}"
register: atlas_nextcloud_user_info
changed_when: false
no_log: true
- name: Require that family users are not administrators
ansible.builtin.assert:
that:
- "'admin' not in (item.stdout | from_json).groups"
loop: "{{ atlas_nextcloud_user_info.results }}"
no_log: true
- name: Maintain unlimited initial standard-user quotas
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:setting, "{{ item.item.username }}", files, quota, none]
loop: "{{ atlas_nextcloud_user_info.results }}"
when: (item.stdout | from_json).quota != 'none'
changed_when: true
no_log: true
- name: Inspect the family group
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:list, --output=json]
register: atlas_nextcloud_groups
changed_when: false
- name: Ensure the family group exists
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:add, famiglia]
when: "'famiglia' not in (atlas_nextcloud_groups.stdout | from_json)"
changed_when: true
- name: Ensure both standard users belong to the family group
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:adduser, famiglia, "{{ item.username }}"]
loop: "{{ atlas_nextcloud_users }}"
when: item.username not in ((atlas_nextcloud_groups.stdout | from_json).get('famiglia', []))
changed_when: true
no_log: true
- name: Inspect configured family folders
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:list, --output=json]
register: atlas_nextcloud_folders_before
changed_when: false
- name: Ensure a shared Famiglia folder exists
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:create, Famiglia]
when: >-
(atlas_nextcloud_folders_before.stdout | from_json |
selectattr('mountPoint', 'equalto', 'Famiglia') | list | length) == 0
changed_when: true
- name: Inspect the resulting family folder
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:list, --output=json]
register: atlas_nextcloud_folders_after
changed_when: false
- name: Select the existing family folder without changing unrelated folders
ansible.builtin.set_fact:
atlas_nextcloud_family_folder: >-
{{ atlas_nextcloud_folders_after.stdout | from_json |
selectattr('mountPoint', 'equalto', 'Famiglia') | first }}
- name: Maintain family read, create, write and delete permissions
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:group,
"{{ atlas_nextcloud_family_folder.id }}", famiglia, write, delete]
when: (atlas_nextcloud_family_folder.groups_list | default({}, true)).get('famiglia', 0) | int != 15
changed_when: true
- name: Inspect the background job mode
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, config:app:get, core, backgroundjobs_mode]
register: atlas_nextcloud_background_mode
changed_when: false
failed_when: atlas_nextcloud_background_mode.rc not in [0, 1]
- name: Maintain cron background processing
ansible.builtin.command:
argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, background:cron]
when: atlas_nextcloud_background_mode.stdout | trim != 'cron'
changed_when: true

View File

@@ -0,0 +1,14 @@
# Managed by Ansible. Password, keyring and MFA cookies are stored separately in /config.
apple_id={{ vault_atlas_icloudpd_apple_id }}
authentication_type=MFA
user=user
user_id=1000
group=group
group_id=1000
download_path=/home/user/iCloud
folder_structure={:%Y/%m/%d}
directory_permissions=750
file_permissions=640
download_interval=86400
auto_delete=false
delete_after_download=false

View File

@@ -0,0 +1,25 @@
# Managed by Ansible. Start automatically with the lingering admin user manager.
[Unit]
Description=Atlas rootless iCloud Photos Downloader
RequiresMountsFor={{ atlas_icloudpd_state_dir }} {{ atlas_icloudpd_photos_dir }}
[Container]
ContainerName=atlas-icloudpd
Image={{ atlas_icloudpd_image }}
UserNS=keep-id:uid=1000,gid=1000
# The image initialises its unprivileged UID 1000 account as container root.
User=0
# Upstream launcher requires traceroute for its iCloud reachability check.
AddCapability=NET_RAW
Environment=TZ={{ atlas_icloudpd_timezone }}
Volume={{ atlas_icloudpd_photos_dir }}:/home/user/iCloud:z
Volume={{ atlas_icloudpd_config_dir }}:/config:Z
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=300
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Atlas recurring Nextcloud background jobs
Requires=atlas-nextcloud.service
After=atlas-nextcloud.service
[Service]
Type=oneshot
ExecStart=/usr/bin/podman exec --user 33 atlas-nextcloud php -f /var/www/html/cron.php
TimeoutStartSec=15min
NoNewPrivileges=true

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run Nextcloud background jobs every five minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Unit=atlas-nextcloud-cron.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,27 @@
[Unit]
Description=Atlas Nextcloud PostgreSQL
RequiresMountsFor={{ atlas_nextcloud_root }}/database
[Container]
ContainerName=atlas-nextcloud-db
Image={{ atlas_nextcloud_postgres_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-nextcloud-db
Environment=POSTGRES_DB=nextcloud
Environment=POSTGRES_USER=nextcloud
Environment=POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password
Volume={{ atlas_nextcloud_private_dir }}/postgres-password:/run/secrets/postgres-password:ro,z
Volume={{ atlas_nextcloud_root }}/database:/var/lib/postgresql/data:Z
PodmanArgs=--memory=1g
HealthCmd=pg_isready -U nextcloud -d nextcloud
HealthInterval=30s
HealthStartPeriod=60s
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,8 @@
bind 0.0.0.0
protected-mode yes
port 6379
requirepass {{ vault_nextcloud_redis_password }}
maxmemory 128mb
maxmemory-policy noeviction
save ""
appendonly no

View File

@@ -0,0 +1,22 @@
[Unit]
Description=Atlas Nextcloud private Redis
RequiresMountsFor={{ atlas_nextcloud_root }}/cache
[Container]
ContainerName=atlas-nextcloud-redis
Image={{ atlas_nextcloud_redis_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-nextcloud-redis
Volume={{ atlas_nextcloud_private_dir }}/redis.conf:/usr/local/etc/redis/atlas.conf:ro,z
Volume={{ atlas_nextcloud_root }}/cache:/data:Z
Exec=redis-server /usr/local/etc/redis/atlas.conf
PodmanArgs=--memory=256m
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,23 @@
<?php
// Managed ongoing application settings; never import or migrate user data.
$CONFIG = [
'trusted_domains' => ['{{ atlas_nextcloud_domain }}', 'atlas-nextcloud'],
'trusted_proxies' => ['{{ atlas_aegis_ip }}', '{{ atlas_nextcloud_network_gateway }}'],
'overwrite.cli.url' => 'https://{{ atlas_nextcloud_domain }}',
'overwritehost' => '{{ atlas_nextcloud_domain }}',
'overwriteprotocol' => 'https',
'allow_local_remote_servers' => true,
'default_quota' => 'none',
'skeletondirectory' => '',
'maintenance_window_start' => 1,
'default_phone_region' => 'IT',
'twofactor_enforced' => false,
'onlyoffice' => [
'DocumentServerUrl' => 'https://{{ atlas_onlyoffice_domain }}/',
'DocumentServerInternalUrl' => 'http://atlas-onlyoffice/',
'StorageUrl' => 'http://atlas-nextcloud/',
'jwt_secret' => trim(file_get_contents('/run/secrets/onlyoffice-jwt')),
'jwt_header' => 'AuthorizationJwt',
'allow_local_address' => true,
],
];

View File

@@ -0,0 +1,42 @@
[Unit]
Description=Atlas Nextcloud
Requires=atlas-nextcloud-db.service atlas-nextcloud-redis.service
After=atlas-nextcloud-db.service atlas-nextcloud-redis.service
RequiresMountsFor={{ atlas_nextcloud_root }}/app {{ atlas_nextcloud_root }}/files
[Container]
ContainerName=atlas-nextcloud
Image={{ atlas_nextcloud_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-nextcloud
PublishPort={{ ansible_host }}:{{ atlas_nextcloud_http_port }}:80
PublishPort=127.0.0.1:{{ atlas_nextcloud_http_port }}:80
Environment=POSTGRES_HOST=atlas-nextcloud-db
Environment=POSTGRES_DB=nextcloud
Environment=POSTGRES_USER=nextcloud
Environment=POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password
Environment=NEXTCLOUD_ADMIN_USER={{ atlas_nextcloud_admin }}
Environment=NEXTCLOUD_ADMIN_PASSWORD_FILE=/run/secrets/admin-password
Environment="NEXTCLOUD_TRUSTED_DOMAINS={{ atlas_nextcloud_domain }} atlas-nextcloud"
Environment=REDIS_HOST=atlas-nextcloud-redis
Environment=REDIS_HOST_PASSWORD_FILE=/run/secrets/redis-password
Environment=APACHE_DISABLE_REWRITE_IP=1
Environment=PHP_MEMORY_LIMIT=512M
Environment=PHP_UPLOAD_LIMIT=2G
Volume={{ atlas_nextcloud_root }}/app:/var/www/html:Z
Volume={{ atlas_nextcloud_root }}/files:/var/www/html/data:Z
Volume={{ atlas_nextcloud_app_cache }}:/mnt/atlas-apps:ro,z
Volume={{ atlas_nextcloud_private_dir }}/postgres-password:/run/secrets/postgres-password:ro,z
Volume={{ atlas_nextcloud_private_dir }}/admin-password:/run/secrets/admin-password:ro,z
Volume={{ atlas_nextcloud_private_dir }}/redis-password:/run/secrets/redis-password:ro,z
Volume={{ atlas_nextcloud_private_dir }}/onlyoffice-jwt:/run/secrets/onlyoffice-jwt:ro,z
PodmanArgs=--memory=2g
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,5 @@
# Managed by Ansible: private rootless application network, no host services.
[Network]
NetworkName=atlas-nextcloud
Subnet={{ atlas_nextcloud_network_subnet }}
Gateway={{ atlas_nextcloud_network_gateway }}

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Atlas ONLYOFFICE Docs Community
RequiresMountsFor={{ atlas_nextcloud_root }}/office
[Container]
ContainerName=atlas-onlyoffice
Image={{ atlas_onlyoffice_image }}
Network=atlas-nextcloud.network
NetworkAlias=atlas-onlyoffice
PublishPort={{ ansible_host }}:{{ atlas_onlyoffice_http_port }}:80
PublishPort=127.0.0.1:{{ atlas_onlyoffice_http_port }}:80
EnvironmentFile={{ atlas_nextcloud_private_dir }}/onlyoffice.env
Volume={{ atlas_nextcloud_root }}/office/data:/var/www/onlyoffice/Data:Z
Volume={{ atlas_nextcloud_root }}/office/lib:/var/lib/onlyoffice:Z
Volume={{ atlas_nextcloud_root }}/office/logs:/var/log/onlyoffice:Z
Volume={{ atlas_nextcloud_root }}/office/database:/var/lib/postgresql:Z
PodmanArgs=--memory=4g --shm-size=256m
NoNewPrivileges=true
[Service]
Restart=on-failure
RestartSec=10
TimeoutStartSec=1200
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,7 @@
JWT_ENABLED=true
JWT_SECRET={{ vault_nextcloud_onlyoffice_jwt }}
JWT_HEADER=AuthorizationJwt
ALLOW_PRIVATE_IP_ADDRESS=true
ALLOW_META_IP_ADDRESS=false
USE_UNAUTHORIZED_STORAGE=false
WOPI_ENABLED=false

View File

@@ -44,13 +44,14 @@
when: server_backup_export_enabled | bool
- name: Install Prometheus backup export helper
tags: [services, backup, prometheus_backup, gitea_cutover]
tags: [services, backup, prometheus_backup, gitea_cutover, npm_quadlet_backup]
ansible.builtin.template:
src: prometheus-backup-export.sh.j2
dest: /usr/local/sbin/prometheus-backup-export
owner: root
group: root
mode: "0750"
validate: "bash -n %s"
when: server_backup_export_enabled | bool
- name: Install Prometheus backup export systemd units

View File

@@ -1,33 +0,0 @@
---
- name: Require DuckDNS domain and Vault token before deployment
ansible.builtin.assert:
that:
- >-
server_duckdns_domain | default('') is
regex('[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', match_type='fullmatch')
- >-
vault_duckdns_token | default('') is
regex('[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', match_type='fullmatch')
fail_msg: >-
Define server_duckdns_domain in host_vars and the rotated vault_duckdns_token
in encrypted Vault or an untracked local vars file before deploying DuckDNS.
no_log: true
- name: Ensure private DuckDNS directory exists
ansible.builtin.file:
path: "{{ server_user_home }}/duckdns"
state: directory
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0700"
- name: Render DuckDNS updater with the Vault token
ansible.builtin.template:
src: duck.sh.j2
dest: "{{ server_user_home }}/duckdns/duck.sh"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0700"
validate: /bin/sh -n %s
no_log: true
diff: false

View File

@@ -1,34 +0,0 @@
---
- name: Install the explicit Gitea final-export helper
tags: [services, gitea_final_export]
ansible.builtin.template:
src: prometheus-gitea-final-export.sh.j2
dest: /usr/local/sbin/prometheus-gitea-final-export
owner: root
group: root
mode: "0750"
when: server_gitea_cutover_tools_enabled | bool
- name: Require the prepared source and explicit final-export approval
tags: [services, gitea_final_export]
ansible.builtin.assert:
that:
- server_gitea_cutover_tools_enabled | bool
- server_backup_export_enabled | bool
- not ansible_check_mode
fail_msg: >-
Install the cutover helper and perform an explicit non-check-mode run
only after the Gitea outage gate has been approved.
when: server_gitea_final_export | bool
- name: Stop source Gitea and publish the final consistent export
tags: [services, gitea_final_export]
ansible.builtin.command:
argv:
- /usr/local/sbin/prometheus-gitea-final-export
register: server_gitea_final_export_result
changed_when: server_gitea_final_export_result.rc == 0
no_log: true
when:
- server_gitea_final_export | bool
- not ansible_check_mode

View File

@@ -3,7 +3,7 @@
tags: [services, gitea_cutover]
ansible.builtin.assert:
that:
- server_gitea_cutover_tools_enabled | bool
- server_gitea_proxy_enabled | bool
- server_gitea_npm_domains | length > 0
- server_gitea_npm_domains | select('match', '^[a-zA-Z0-9.-]+$') | list | length == server_gitea_npm_domains | length
fail_msg: Declare the exact NPM Gitea hostnames before enabling the Atlas upstream.
@@ -17,7 +17,7 @@
owner: root
group: root
mode: "0755"
when: server_gitea_cutover_tools_enabled | bool
when: server_gitea_proxy_enabled | bool
- name: Render the Gitea-only NPM runtime upstream override
tags: [services, gitea_cutover]
@@ -36,7 +36,7 @@
path: /opt/npm/data/nginx/custom/server_proxy.conf
state: absent
when:
- server_gitea_cutover_tools_enabled | bool
- server_gitea_proxy_enabled | bool
- not server_gitea_on_atlas | bool
- name: Validate NPM configuration after a Gitea upstream change

View File

@@ -3,7 +3,7 @@
tags: [services, gitea_cutover]
ansible.builtin.assert:
that:
- server_gitea_cutover_tools_enabled | bool
- server_gitea_proxy_enabled | bool
- server_gitea_atlas_address is match('^[0-9]{1,3}(\.[0-9]{1,3}){3}$')
- server_gitea_ssh_public_port | int > 1024
- server_gitea_ssh_public_port | int < 65536
@@ -27,14 +27,14 @@
loop_control:
label: "{{ item }}"
register: server_gitea_ssh_proxy_units
when: server_gitea_cutover_tools_enabled | bool
when: server_gitea_proxy_enabled | bool
- name: Reload systemd after Gitea SSH proxy unit changes
tags: [services, gitea_cutover]
ansible.builtin.systemd:
daemon_reload: true
when:
- server_gitea_cutover_tools_enabled | bool
- server_gitea_proxy_enabled | bool
- server_gitea_ssh_proxy_units is changed
- not ansible_check_mode
@@ -45,7 +45,7 @@
state: "{{ 'started' if server_gitea_on_atlas | bool else 'stopped' }}"
enabled: "{{ server_gitea_on_atlas | bool }}"
when:
- server_gitea_cutover_tools_enabled | bool
- server_gitea_proxy_enabled | bool
- not ansible_check_mode
- name: Open only the public Gitea SSH port after cutover
@@ -57,5 +57,5 @@
permanent: true
immediate: true
when:
- server_gitea_cutover_tools_enabled | bool
- server_gitea_proxy_enabled | bool
- server_firewall_backend == 'firewalld'

View File

@@ -0,0 +1,112 @@
---
- name: Require explicit retirement of the migrated Prometheus source
ansible.builtin.assert:
that:
- inventory_hostname == 'prometheus'
- server_legacy_stack_retired | bool
- server_gitea_on_atlas | bool
- server_npm_quadlet_cutover | bool
- server_backup_export_enabled | bool
- name: Verify legacy paths have no mounts or container users
ansible.builtin.command:
argv:
- python3
- -c
- |
import json, os, pathlib, subprocess
def run(*args):
return subprocess.check_output(args, text=True).strip()
paths = ['/opt/gitea', '/home/git/.ssh', '/opt/navidrome',
'/opt/postgres', '/opt/music', '/opt/containerd', '/opt/docker']
mounts = json.loads(run('findmnt', '--json', '--list', '-o', 'TARGET'))['filesystems']
for path in paths:
assert os.path.realpath(path) == path, 'Symlink in cleanup path: ' + path
for mount in mounts:
target = mount['target']
assert target != path and not target.startswith(path + '/'), 'Mounted cleanup path: ' + path
ids = run('podman', 'ps', '-aq').split()
containers = json.loads(run('podman', 'inspect', *ids)) if ids else []
for container in containers:
assert container['Name'].lstrip('/') == 'nginx-proxy-manager', 'Unexpected container; review before cleanup'
for mount in container.get('Mounts', []):
source = os.path.realpath(mount['Source'])
for path in paths:
assert source != path and not source.startswith(path + '/'), 'Container uses cleanup path: ' + path
for path in ['/opt/music', '/opt/containerd']:
if os.path.isdir(path):
for entry in pathlib.Path(path).rglob('*'):
assert entry.is_dir() and not entry.is_symlink(), 'Unexpected file in empty legacy path: ' + str(entry)
if os.path.isdir('/opt/docker'):
allowed = {'/opt/docker/server', '/opt/docker/server/docker-compose.yml'}
for entry in pathlib.Path('/opt/docker').rglob('*'):
assert str(entry) in allowed and not entry.is_symlink(), 'Unexpected legacy Docker content: ' + str(entry)
assert run('systemctl', 'is-active', 'prometheus-npm.service') == 'active'
assert subprocess.run(['systemctl', 'is-active', '--quiet', 'podman-compose-server.service']).returncode != 0
assert subprocess.run(['systemctl', 'is-active', '--quiet', 'prometheus-backup-export.service']).returncode != 0
print('Legacy cleanup preflight passed')
changed_when: false
check_mode: false
- name: Require the updated backup configuration before deleting fallback files
ansible.builtin.command:
argv:
- python3
- -c
- |
import pathlib, subprocess
unit = subprocess.check_output(['systemctl', 'show', 'prometheus-backup-export.service',
'-p', 'RequiresMountsFor', '--value'], text=True)
assert '/opt/gitea' not in unit, 'Backup unit still depends on legacy Gitea'
helper = pathlib.Path('/usr/local/sbin/prometheus-backup-export').read_text()
assert 'podman-compose-server' not in helper and 'opt/docker/server' not in helper
subprocess.run(['bash', '-n', '/usr/local/sbin/prometheus-backup-export'], check=True)
changed_when: false
when: not ansible_check_mode
- name: Delete only the explicitly approved legacy data and fallback files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /opt/gitea
- /home/git/.ssh
- /opt/navidrome
- /opt/postgres
- /opt/music
- /opt/containerd
- /opt/docker
- /usr/local/sbin/prometheus-gitea-final-export
- /etc/systemd/system/podman-compose-server.service
register: server_legacy_deleted
diff: false
- name: Reload systemd after removing the inactive legacy unit
ansible.builtin.systemd:
daemon_reload: true
when:
- server_legacy_deleted is changed
- not ansible_check_mode
- name: Inspect the obsolete Git home without following symlinks
ansible.builtin.stat:
path: /home/git
follow: false
register: server_legacy_git_home
- name: Require the obsolete Git account to be absent before removing its empty home
ansible.builtin.command:
argv: [getent, passwd, git]
register: server_legacy_git_account
changed_when: false
failed_when: server_legacy_git_account.rc != 2
check_mode: false
when: server_legacy_git_home.stat.exists
# rmdir refuses any nonempty directory; never recursively delete this parent.
- name: Remove only the empty obsolete Git home
ansible.builtin.command:
argv: [rmdir, /home/git]
register: server_legacy_git_home_removed
changed_when: server_legacy_git_home_removed.rc == 0
when: server_legacy_git_home.stat.exists

View File

@@ -0,0 +1,33 @@
---
- name: Require the migrated Prometheus topology for image cleanup
ansible.builtin.assert:
that:
- inventory_hostname == 'prometheus'
- server_gitea_on_atlas | bool
- server_npm_quadlet_cutover | bool
- server_legacy_images | default([]) | length > 0
- >-
server_legacy_images | difference([
'docker.gitea.com/gitea:1.25.2',
'docker.io/deluan/navidrome:latest',
'docker.io/library/postgres:13']) | length == 0
- name: Check whether the explicitly selected legacy images exist
ansible.builtin.command:
argv: [podman, image, exists, "{{ item }}"]
loop: "{{ server_legacy_images }}"
register: server_legacy_image_presence
changed_when: false
failed_when: server_legacy_image_presence.rc not in [0, 1]
check_mode: false
# No --force: Podman must refuse images referenced by any existing container.
- name: Remove only unused explicitly selected legacy images
ansible.builtin.command:
argv: [podman, image, rm, "{{ item.item }}"]
loop: "{{ server_legacy_image_presence.results }}"
loop_control:
label: "{{ item.item }}"
when: item.rc == 0
register: server_legacy_image_removal
changed_when: server_legacy_image_removal.rc == 0

View File

@@ -8,10 +8,6 @@
fail_msg: >-
server_firewall_backend must be firewalld for the Rocky server profile.
- name: Configure DuckDNS updater
tags: [dotfiles, dotfiles:server, duckdns]
ansible.builtin.import_tasks: duckdns.yml
- name: Ensure server directories exist
tags: [dotfiles, services]
ansible.builtin.file:
@@ -23,6 +19,9 @@
loop: "{{ server_directories | default([]) }}"
loop_control:
label: "{{ item.path }}"
when:
- item.path != '/opt/gitea/data' or not server_gitea_on_atlas | bool
- item.path != server_container_stack_dir or not server_legacy_stack_retired | bool
- name: Copy server dotfiles
tags: [dotfiles, dotfiles:server]
@@ -48,19 +47,32 @@
loop_control:
label: "{{ item.dest }}"
no_log: "{{ item.no_log | default(false) }}"
when: item.src != 'server/docker-compose.yml.j2' or not server_legacy_stack_retired | bool
- name: Manage Podman Compose stack
tags: [services, podman]
ansible.builtin.include_tasks: podman-compose.yml
when: not server_legacy_stack_retired | bool
- name: Import staged NPM Quadlet tasks
ansible.builtin.import_tasks: npm_quadlet.yml
- name: Import explicit legacy server image cleanup
ansible.builtin.import_tasks: legacy_image_cleanup.yml
tags: [never, server_image_cleanup]
when: server_legacy_image_cleanup | default(false) | bool
- name: Import Prometheus backup export identity tasks
ansible.builtin.import_tasks: backup_export_identity.yml
- name: Import Prometheus backup export job tasks
ansible.builtin.import_tasks: backup_export_job.yml
tags: [server_legacy_cleanup]
- name: Import explicit Prometheus Gitea final-export tasks
ansible.builtin.import_tasks: gitea_final_export.yml
- name: Import explicitly approved legacy server data cleanup
ansible.builtin.import_tasks: legacy_cleanup.yml
tags: [never, server_legacy_cleanup]
when: server_legacy_cleanup | bool
- name: Import Prometheus Gitea SSH proxy tasks
ansible.builtin.import_tasks: gitea_ssh_proxy.yml

View File

@@ -0,0 +1,71 @@
---
- name: Require staged NPM Quadlet for an active cutover
tags: [services, npm_quadlet]
ansible.builtin.assert:
that:
- not server_npm_quadlet_cutover | bool or server_npm_quadlet_stage | bool
fail_msg: The NPM Quadlet cutover requires the staged container and network.
- name: Validate staged NPM Quadlet inputs
tags: [services, npm_quadlet]
ansible.builtin.assert:
that:
- server_npm_quadlet_image is defined
- server_npm_quadlet_image is match('^docker\.io/jc21/nginx-proxy-manager@sha256:[a-f0-9]{64}$')
- server_gitea_on_atlas | bool
fail_msg: Stage the exact running NPM image only after Gitea has left Compose.
when: server_npm_quadlet_stage | bool
- name: Ensure rootful Quadlet directory exists for NPM
tags: [services, npm_quadlet]
ansible.builtin.file:
path: /etc/containers/systemd
state: directory
owner: root
group: root
mode: "0755"
when: server_npm_quadlet_stage | bool
- name: Render staged NPM container and network Quadlets
tags: [services, npm_quadlet]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/containers/systemd/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- prometheus-npm.container
- server-web.network
loop_control:
label: "{{ item }}"
register: server_npm_quadlet_units
when: server_npm_quadlet_stage | bool
- name: Reload systemd after staging NPM Quadlets
tags: [services, npm_quadlet]
ansible.builtin.systemd:
daemon_reload: true
when:
- server_npm_quadlet_stage | bool
- server_npm_quadlet_units is changed
- not ansible_check_mode
- name: Verify the staged NPM Quadlet was generated
tags: [services, npm_quadlet]
ansible.builtin.command:
argv: [systemctl, show, prometheus-npm.service, --property=LoadState, --value]
register: server_npm_quadlet_load_state
changed_when: false
when:
- server_npm_quadlet_stage | bool
- not ansible_check_mode
- name: Reject an invalid staged NPM Quadlet
tags: [services, npm_quadlet]
ansible.builtin.assert:
that: server_npm_quadlet_load_state.stdout == 'loaded'
fail_msg: Quadlet generator did not produce prometheus-npm.service.
when:
- server_npm_quadlet_stage | bool
- not ansible_check_mode

View File

@@ -1,24 +0,0 @@
#!/bin/sh
# Managed by Ansible. Contains a Vault token; never copy this file into Git.
set -eu
umask 077
log_file={{ (server_user_home ~ '/duckdns/duck.log') | quote }}
# Keep the token out of process arguments and verify the HTTPS certificate.
if ! response=$(curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --config - <<'DUCKDNS_CONFIG'
url = "https://www.duckdns.org/update?domains={{ server_duckdns_domain }}&token={{ vault_duckdns_token }}&ip="
DUCKDNS_CONFIG
); then
printf 'ERROR\n' > "$log_file"
exit 1
fi
case "$response" in
OK) printf 'OK\n' > "$log_file" ;;
*)
printf 'KO\n' > "$log_file"
printf 'DuckDNS update failed.\n' >&2
exit 1
;;
esac

View File

@@ -1,6 +1,6 @@
[Unit]
Description=Prepare a read-only Prometheus application backup for Atlas
RequiresMountsFor=/opt/npm /opt/gitea {{ server_backup_export_root }}
RequiresMountsFor=/opt/npm {% if not server_gitea_on_atlas | bool %}/opt/gitea {% endif %}{{ server_backup_export_root }}
ConditionFileIsExecutable=/usr/local/sbin/prometheus-backup-export
[Service]

View File

@@ -4,7 +4,24 @@ umask 077
export_root={{ server_backup_export_root | quote }}
versions="$export_root/versions"
stack_unit=podman-compose-server.service
{% if server_legacy_stack_retired | bool %}
stack_unit=prometheus-npm.service
{% else %}
stack_unit=''
compose_active=false
quadlet_active=false
systemctl is-active --quiet podman-compose-server.service && compose_active=true
systemctl is-active --quiet prometheus-npm.service && quadlet_active=true
if [[ "$compose_active" == "$quadlet_active" ]]; then
echo 'Expected exactly one active NPM service (Compose or Quadlet)' >&2
exit 1
fi
if "$quadlet_active"; then
stack_unit=prometheus-npm.service
else
stack_unit=podman-compose-server.service
fi
{% endif %}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
stage=''
stack_stopped=false
@@ -33,7 +50,7 @@ trap 'exit 130' INT
trap 'exit 143' TERM
systemctl is-active --quiet "$stack_unit" || {
echo 'The managed Compose stack must be active before preparing a backup' >&2
echo "The managed NPM unit $stack_unit must be active before preparing a backup" >&2
exit 1
}
@@ -70,6 +87,15 @@ for container in nginx-proxy-manager{% if not server_gitea_on_atlas | bool %} gi
done
"$running" || { echo "Container did not restart: $container" >&2; exit 1; }
done
ready=false
for _ in {1..60}; do
if curl -fsS --connect-timeout 2 --max-time 3 -o /dev/null http://127.0.0.1:81/; then
ready=true
break
fi
sleep 2
done
"$ready" || { echo 'NPM administration did not become ready after backup' >&2; exit 1; }
stack_stopped=false
tar -tf "$stage/payload.tar" >/dev/null

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
export_root={{ server_backup_export_root | quote }}
versions="$export_root/versions"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
stage=''
gitea_stopped=false
exec 9>/run/lock/prometheus-backup-export.lock
flock -n 9 || { echo 'A Prometheus backup export is already running' >&2; exit 1; }
cleanup() {
local rc=$?
trap - EXIT
if (( rc != 0 )) && "$gitea_stopped"; then
podman start gitea >/dev/null || rc=1
fi
if (( rc != 0 )) && [[ -n "$stage" && -d "$stage" ]]; then
rm -rf -- "$stage"
fi
exit "$rc"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
systemctl is-active --quiet podman-compose-server.service || {
echo 'Prometheus Compose stack is not active' >&2; exit 1;
}
if systemctl is-active --quiet prometheus-backup-export.timer; then
echo 'Stop the scheduled export timer for the cutover first' >&2
exit 1
fi
[[ $(podman inspect --format '{{ '{{.State.Running}}' }}' gitea) == true ]] || {
echo 'Source Gitea must be running before the final export' >&2; exit 1;
}
[[ -d /opt/gitea/data && -d /home/git/.ssh ]] || {
echo 'Required source Gitea paths are missing' >&2; exit 1;
}
[[ ! -e "$versions/$stamp" ]] || {
echo 'Final export timestamp already exists' >&2; exit 1;
}
gitea_stopped=true
podman stop --time 30 gitea >/dev/null
[[ $(podman inspect --format '{{ '{{.State.Running}}' }}' gitea) == false ]] || {
echo 'Source Gitea did not stop' >&2; exit 1;
}
python3 - <<'PY'
import sqlite3
path = '/opt/gitea/data/gitea/gitea.db'
with sqlite3.connect(f'file:{path}?mode=ro', uri=True) as database:
if database.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise SystemExit('Source Gitea SQLite quick_check failed')
PY
stage=$(mktemp -d "$export_root/.staging.XXXXXXXX")
tar --acls --xattrs --selinux -C / -cf "$stage/payload.tar" \
opt/gitea/data home/git/.ssh
tar -tf "$stage/payload.tar" >/dev/null
(cd "$stage" && sha256sum payload.tar >payload.sha256)
printf '{"schema":1,"host":"prometheus","purpose":"gitea-cutover","created_utc":"%s"}\n' \
"$stamp" >"$stage/metadata.json"
[[ $(podman inspect --format '{{ '{{.State.Running}}' }}' gitea) == false ]] || {
echo 'Source Gitea restarted during final export' >&2; exit 1;
}
chown root:{{ server_backup_username }} "$stage" "$stage/payload.tar" \
"$stage/payload.sha256" "$stage/metadata.json"
chmod 0750 "$stage"
chmod 0640 "$stage/payload.tar" "$stage/payload.sha256" "$stage/metadata.json"
mv -- "$stage" "$versions/$stamp"
stage=''
ln -s "$stamp" "$versions/.current.new"
mv -Tf -- "$versions/.current.new" "$versions/current"
echo "Prepared final Gitea export $stamp; source Gitea remains stopped"

View File

@@ -0,0 +1,26 @@
[Unit]
Description=Nginx Proxy Manager on Prometheus
RequiresMountsFor=/opt/npm/data /opt/npm/letsencrypt
[Container]
Image={{ server_npm_quadlet_image }}
ContainerName=nginx-proxy-manager
Network=server-web.network
NetworkAlias=nginx-proxy-manager
AddHost=host.containers.internal:host-gateway
PublishPort=80:80
PublishPort=443:443
PublishPort=127.0.0.1:81:81
Volume=/opt/npm/data:/data
Volume=/opt/npm/letsencrypt:/etc/letsencrypt
Pull=missing
[Service]
Restart=always
TimeoutStartSec=180
TimeoutStopSec=120
{% if server_npm_quadlet_cutover | bool %}
[Install]
WantedBy=multi-user.target
{% endif %}

View File

@@ -0,0 +1,5 @@
[Network]
NetworkName=server_web
Driver=bridge
Subnet=10.89.0.0/24
Gateway=10.89.0.1

View File

@@ -4,7 +4,7 @@ name: server
services:
nginx-proxy-manager:
image: docker.io/jc21/nginx-proxy-manager:latest
image: {{ server_npm_quadlet_image if server_npm_quadlet_stage | bool else 'docker.io/jc21/nginx-proxy-manager:latest' }}
container_name: nginx-proxy-manager
restart: unless-stopped
ports:

View File

@@ -1,8 +1,23 @@
# Gitea migration from Prometheus to Atlas
Historical record: the completed owner-migration, migration-restore and final-export
tasks, helpers and flags have been removed from the repository. Commands below
record past execution, not currently supported migration entry points. Current
service safety checks, recurring backups and proxy configuration remain managed.
The later 2026-10-03 canonical-domain change to `git.fscotto.co` is recorded
in `docs/domain-fscotto-co.md`. Public SSH remains on TCP/2222; the old
DuckDNS Proxy Host was observed disabled. Earlier domain references below
describe migration evidence, not the current canonical URL.
This records the staged migration and its observed partial cutover. Gitea is
temporary on Atlas until Uranus; NPM remains on Prometheus. Preserve the old
Prometheus data, but do not restart its stale Gitea after Atlas accepts writes.
temporary on Atlas until Uranus; NPM remains on Prometheus. On 2026-10-03
the operator explicitly approved removal of the old Prometheus Gitea data,
SSH fragment and final-export helper. NPM now uses a rootful Quadlet with no
installed Compose fallback. The source-retention and rollback steps below
are historical migration gates, not current recovery instructions.
Existing backup archives were preserved; use current Atlas data and verified
backups for recovery. Do not recreate or restart stale source Gitea.
## Observed source before cutover and chosen topology (2026-10-01)
@@ -139,6 +154,10 @@ or credentials were changed. The
secondary hostname `git.ov-ad3410.infomaniak.ch` did not resolve from Ikaros
and had no generated NPM config file at the time of inspection.
On 2026-10-03 the operator retired this unused secondary hostname. Its NPM
Proxy Host was already soft-deleted; Ansible now declares only
`git.fscotto.duckdns.org` and removes the secondary runtime override.
Prometheus' public TCP/2222 socket proxies to Atlas without changing admin
SSH/22. The local socket presents the preserved Gitea ED25519 host key, but
an external TCP/2222 connection from Ikaros initially timed out. During that

View File

@@ -0,0 +1,173 @@
# iCloudPD: Aegis to Atlas
Atlas is the temporary ingestion host until Uranus. Aegis iCloudPD and its
state were retired. Ansible declares Atlas storage, the rootless Quadlet,
and a private `icloudpd.conf` with the Apple ID from the existing Vault key.
The password, keyring and MFA cookies remain application-managed; initialization
is interactive.
Do not place cookies, keyring files, passwords, or the Apple ID in this document,
unencrypted repository content, or a terminal transcript.
## Historical source and current destination (2026-10-02)
- Before retirement, Aegis' rootful `icloudpd.service` was active (no reported restarts, running
since 2026-07-25), but its declared data bind `/var/lib/icloudpd/data`
has **zero top-level entries** and is 4 KiB as observed on 2026-10-02.
Its persistent config has two top-level entries. `pi` cannot run passwordless
sudo, so the container's internal filesystem and root-only state have **not**
been audited. Do not conclude there are no photos to preserve: they could be
inside the container overlay because the declared bind targets the wrong
home. The current
Quadlet mounts that data directory at `/home/root/iCloud`; the image's
documented default is `/home/user/iCloud` with its default `user=user`.
- The non-secret `folder_structure` value in the persisted Aegis config is a
systemd generator path, **not** `{:%Y/%m/%d}`. The Quadlet passes percent
characters in `Environment=` without systemd escaping; that is the likely
cause. A running unit therefore does not prove that Aegis ingests photos.
Do not copy this config or assume that its MFA state is usable on Atlas.
- Atlas' `zpool` is healthy. `/zpool/archive/Pictures` already contains about
25 GiB of unrelated data; iCloudPD gets only a new managed
`/zpool/archive/Pictures/iCloudPD` subtree. Both that subtree and
`zpool/services/data/icloudpd` were created on 2026-10-02. Never rsync with `--delete` into
Pictures or adopt its existing contents. `/zpool/media/photobook` is reserved
for Immich and remains untouched, including its Aegis-only NFS export.
The upstream image documents `/config/icloudpd.conf` as its primary
configuration (environment configuration is deprecated), an exact
`/home/${user}/iCloud/.mounted` failsafe, and an interactive `--Initialise`
step for keyring and MFA cookies. The configuration must use the same download
path, user/UID, and folder format as the bind mounts. References:
[image configuration](https://github.com/boredazfcuk/docker-icloudpd/blob/master/CONFIGURATION.md),
[Podman user namespaces](https://docs.podman.io/en/latest/markdown/podman-pod.unit.5.html).
## Declared Atlas target
| Item | Location or policy |
| --- | --- |
| Downloaded photos | `/zpool/archive/Pictures/iCloudPD`, a new managed subtree of the SMB `Archive` dataset |
| Config, keyring, MFA cookies | `zpool/services/data/icloudpd` at `/zpool/services/data/icloudpd/config`, outside Archive |
| Host service owner | `admin` rootless user manager; no rootful Quadlet or published port |
| Container identity | Entry process root in its user namespace; downloader UID/GID 1000 maps to host `admin` |
| Image | Digest-pinned `docker.io/boredazfcuk/icloudpd`, with no registry auto-update |
| SELinux | Private `:Z` config bind; shared `:z` photo bind because Archive is also exposed through SMB and used by Syncthing. The label and SMB behavior require runtime testing. |
| Access | The new subtree is `admin:admin` mode 0750. No Photobook ownership, ACL, or export changes. |
| Sync policy | Daily interval; explicit directory/file modes 750/640; no iCloud deletion and no deletion of destination-only files |
The photo subtree receives a managed marker and the image's `.mounted` file.
An existing unmarked path is refused rather than taken over. The existing
Pictures tree is not chowned or emptied. The Quadlet now has `[Install]` with
`WantedBy=default.target`, so the lingering admin user manager starts it at boot.
Ansible keeps the service running. Ansible renders a mode-0600
`icloudpd.conf` with `no_log` and no diff, but does not pull the image,
initialize MFA, or run a cutover task. Boot startup was approved on 2026-10-03
after a reboot left the previously manual-started service inactive.
The previous gated check-mode tests and isolated Quadlet-generator test proved
only the proposed layout; they predate the simplified declarative role. They
were not a production deployment or an authentication test.
## Evidence already gathered without production writes
The digest-pinned image was pulled into **admin's** Atlas Podman store. An
isolated `/var/tmp` test ran with no network, a fake Apple ID, private temporary
config/photo mounts, `keep-id:uid=1000,gid=1000`, and no new privileges. Both
container root and UID 1000 wrote to the mounts; UID
1000's files mapped to host `admin`. A short-lived container remained running,
retained the intended `/home/user/iCloud` and literal `{:%Y/%m/%d}` config,
and saw an admin-owned `.mounted` marker. The container and temporary files
were removed. A second isolated test showed that dropping **all** container
capabilities prevents its root entrypoint from reading an admin-owned 0600
config; with the default rootless user-namespace capabilities it could read
and write that file. The Quadlet retains `NoNewPrivileges=true` but does not
drop every capability. This proves only the container layout and namespace mapping,
**not** Apple authentication, a real download, SMB visibility, scheduled
operation, backup coverage, or recovery.
The earlier disposable Photobook ACL test is superseded by the operator's
clarification that Photobook belongs to Immich. It is not evidence for the
current Archive destination, and the proposed Photobook ACL change was never
deployed.
Backup path review on 2026-10-02: the managed Borg and USB scripts snapshot
the pool recursively and bind every mounted child dataset, so both
`archive` and the proposed `services/data/icloudpd` fall within their
declared source scope. Borg's runner switches to the dedicated `borg` account
with only `CAP_DAC_READ_SEARCH`; a read-only check using those exact `setpriv`
capability flags could traverse/read Archive, whereas plain
`sudo -u borg` could not. USB copies as root and preserves POSIX ACLs, but not
generic xattrs/SELinux labels. **This was scope and permission evidence, not a
completed backup or restore of iCloudPD data**, which did not exist at the time.
## Validation status and remaining checks
- Aegis retirement is complete: `icloudpd.service` is `not-found`/`inactive`,
the rootful Quadlet and `/var/lib/icloudpd` are absent, and AdGuard is active.
The temporary retirement tasks are no longer in the Aegis role. The Podman
image cache may remain; it is not service data.
- Atlas storage and the `admin` Quadlet are deployed. The second Ansible
run changed nothing and did not start the service; a later manual start
generated the config. `/zpool/media/photobook` was unchanged.
- The image generated `/zpool/services/data/icloudpd/config/icloudpd.conf`
on first start. Ansible replaced that default file with a private template
using the Apple ID already in Vault. The operator initialized password
and MFA interactively; never put credentials or codes in the repository,
chat, or Ansible extra-vars. Automatic boot startup was separately approved
on 2026-10-03; this does not change the interactive MFA procedure.
- Initial ingestion completed on 2026-10-03. Still check folder structure,
ownership, SELinux and SMB access, no unintended deletions, the next daily
cycle, completed Borg and USB versions, and isolated restore of photos and
private state. A recursive hourly `zpool/archive` snapshot exists after
ingestion, but no iCloudPD-specific backup restore has passed. The first
real scrub and measured recovery targets are separate open items.
On 2026-10-02 Atlas storage and the inactive Quadlet were deployed; a second
Ansible run made zero changes. The generated service was inactive, and no
`icloudpd.conf` existed. Two interactive-sudo Aegis runs removed its service,
Quadlet and `/var/lib/icloudpd`, then cleared the failed-unit record left by a
SIGKILL during shutdown. Read-only verification found `LoadState=not-found`,
`ActiveState=inactive`, both paths absent, and AdGuard active.
On 2026-10-02 the operator requested the first manual start. The rootless
service stayed active, and the image generated `icloudpd.conf` under the
private config dataset. Its mode was tightened from 0644 to 0600. The generated
`apple_id` field is empty; no MFA or download is verified. The service has no
boot-time install target, so it is not configured for automatic startup.
The 2026-10-02 Atlas `icloudpd` run rendered the Vault-backed template without
printing its contents; the second run made zero changes. File owner is
`admin:admin`, mode 0600, and the Apple ID field is nonempty. The rootless
service remained active with zero restarts. At that point keyring initialization,
cookie creation and a real download were unverified. The template now reads
`vault_atlas_icloudpd_apple_id`, which is already present in the encrypted
Vault; no password or MFA code was added to the template.
The attempted interactive initialization then lost its container. Diagnosis
found that the image launcher requires `traceroute` to pass its iCloud
reachability check. Rootless Podman without `NET_RAW` returned `Operation not
permitted` despite working Atlas/container DNS and host HTTPS. An isolated
container with only `CAP_NET_RAW` passed the same check. The Quadlet now grants
that single capability while keeping `NoNewPrivileges=true`; a manual restart
passed `traceroute`, and the app stayed running. Logs then showed only the missing
keyring and a wait for `--Initialise` again. The app expanded the generated config
on startup, so Ansible now seeds it only when absent and idempotently maintains
only its declared options. A second live Ansible run made zero changes. At
that point MFA, actual ingestion, and backup/restore were unverified.
On 2026-10-03, after interactive initialization, the rootless service was
active and the previous 24h of logs showed download activity with no
authentication failures or errors. At 02:16 the application reported `All
photos and videos have been downloaded` and `Download complete for user`.
The destination contained 11,658 files totaling 86,020,430,015 bytes; this
is a filesystem file count, not a count of distinct iCloud assets. A later
read-only check found the service still active. This closes initial
authentication and ingestion only: a subsequent daily cycle and end-to-end
recovery of the new photos and private state remain untested.
On 2026-10-03 Atlas rebooted at 10:17 CEST; iCloudPD stayed inactive because
its Quadlet had no install target. A manual start restored the running service
and the application began listing iCloud files. The operator then approved
persistent boot startup. The managed Quadlet now declares
`WantedBy=default.target`; the live generator created
`default.target.wants/atlas-icloudpd.service`, admin has `Linger=yes`, and the
service remained active with zero restarts. No NAS reboot was performed to
test this change; actual post-reboot startup remains untested.

View File

@@ -0,0 +1,105 @@
# Nextcloud on Atlas — design draft
Status: the empty stack was deployed on 2026-10-03, explicitly before the first
scrub. The operator configured DNS/NPM and authorized public cutover; public TLS,
DAV and cross-user file checks passed. Client editing/sync acceptance and consistent
backup/restore validation remain open before family data. iCloud import remains a
separate operation. See `docs/atlas-nextcloud.md` for observed runtime state.
## Confirmed requirements
- Three family members are the eventual scope; provision only two standard user
accounts initially, `fabio` and `chiara`, with the third family user deferred.
Each initial user has a private file space and no administrator privileges.
- Add a separate Nextcloud application administrator account named `admin`, for
administration rather than daily document use. This is distinct from Atlas'
host account of the same name; credentials must not be reused.
- 2FA is optional, not enforced for the accounts. Offer enrollment and recovery
codes; encourage it for the administrator without silently imposing it.
- The three application accounts have been created in the empty deployment.
- No SMTP service is available. Initial deployment will not configure outbound
email or provision a mail server. Email notifications and email-based password
recovery are unavailable until SMTP is explicitly added. Document administrator-
assisted recovery for standard users and a private host-side admin recovery
procedure; do not expose a recovery endpoint or store plaintext passwords.
- Both initial users may add, edit and delete files in the shared `Famiglia`
folder. This does not imply sharing personal calendars or contacts.
- No initial per-user Nextcloud storage quota for `fabio` or `chiara`. Available
space is still bounded by the physical pool and any separately approved dataset
limits; monitor capacity and do not describe this as unlimited physical storage.
- Files, calendars, contacts and Office document editing in the browser.
- iPhone/iPad, Windows and Linux clients.
- Migrate iCloud Drive files, calendars and contacts. The operator estimates
approximately 50 GB of iCloud Drive files, excluding iCloudPD photos; this is
an estimate, not a measured inventory. The files include a mix of Fabio's and
Chiara's data. Migration is explicitly deferred to a separate later operation;
initial deployment must not import iCloud files, calendars or contacts.
Per-account mapping will be decided at migration time. Do not assume ongoing
two-way synchronization with iCloud or extend this scope to iCloud Photos.
- ONLYOFFICE is the chosen editor: browser editing on desktop and the existing
ONLYOFFICE app on iPhone/iPad. Mobile browser editing is not required.
- Temporary Atlas hosting, with eventual migration to Uranus.
- Completed one-time imports/migrations stay outside the steady-state playbook.
## Implemented architecture — public acceptance pending
- Nextcloud application with Files, Calendar, Contacts and an Office connector.
- PostgreSQL database and Redis for locking/cache; deployed versions and pinned
image digests are declared in Atlas host vars and documented in the runbook.
- Dedicated ONLYOFFICE Docs service and its Nextcloud connector. Test real
DOCX/XLSX/PPTX files in desktop browsers and opening/editing/saving through
the mobile ONLYOFFICE app before acceptance. Community Edition is deployed;
internal connector checks passed, but browser/mobile acceptance is still pending.
- Explicit Podman Quadlets managed by Ansible, preferably rootless like existing
Atlas services, subject to image/user namespace/SELinux validation.
- Separate persistent application/configuration, user files, database and cache
storage in the service namespace. Do not expose the managed Nextcloud data
directory as a writable SMB share or let Syncthing modify it directly.
- Approved names: `cloud.fscotto.co` for Nextcloud and `office.fscotto.co` for
ONLYOFFICE Docs. The operator configured DNS, certificates and NPM hosts;
public endpoint and routing checks passed on 2026-10-03.
- Public HTTPS through Prometheus NPM and the existing Aegis gateway only.
No public database/cache ports or directly exposed administrative interfaces.
- Office/Nextcloud callback routing, WebSockets, trusted proxies, JWT authentication
and upload limits must be tested end to end before publication.
- Credentials remain in Vault; never enter passwords or private keys in chat.
## Office decision
The operator already uses ONLYOFFICE on mobile and desktop and selected it for
this project. Desktop browser editing will use ONLYOFFICE Docs integrated with
Nextcloud; mobile editing will use the existing ONLYOFFICE app. The limitation
on Community mobile web editors does not conflict with that requirement.
App integration, permissions, document fidelity and reliable saves still require
acceptance tests; the app is not treated as proof of server-side compatibility.
## Data protection and rollout gates
- The operator explicitly authorized this empty deployment before the first scrub.
Close the data-protection checks before accepting live family data; this limited
exception does not mark the scrub or recovery checks complete.
- Re-check free RAM/CPU/storage and existing workload before choosing limits or quotas.
- Design consistent backups covering configuration, custom apps/themes, user files
and the database. ZFS snapshots alone do not establish application consistency.
- Define a coordinated maintenance/background-job pause and database dump/snapshot
procedure for recurring backups, with failure cleanup and monitoring.
- Confirm ZFS/Borg/USB coverage and independently restore into an isolated environment
before importing family data.
- Define deliberate upgrades and rollback boundaries; do not roll back a database
independently of its matching application/data backup.
- Start with a test account and representative documents; migrate iCloud content
explicitly only after client, sharing, Office and recovery tests pass.
- Plan Uranus transfer separately; do not add permanent one-time migration flags.
## Next decisions, one at a time
1. Validate desktop Office editing/saving, calendar/contact synchronization and
mobile ONLYOFFICE app integration; public empty-stack cutover is verified.
2. Complete protection gates and application-consistent backup/recovery tests.
3. Plan the deferred iCloud migration when explicitly requested.
## Primary references
- [Nextcloud Office installation](https://docs.nextcloud.com/server/stable/admin_manual/office/installation.html)
- [ONLYOFFICE mobile web editor restrictions](https://helpcenter.onlyoffice.com/mobile/android/mobile-web-editors/overview.aspx)
- [Nextcloud backup requirements](https://docs.nextcloud.com/server/stable/admin_manual/maintenance/backup.html)

127
docs/atlas-nextcloud.md Normal file
View File

@@ -0,0 +1,127 @@
# Atlas Nextcloud — public empty-stack cutover
## Observed state, 2026-10-03
The operator explicitly approved an empty deployment before the first monthly
scrub, and subsequently authorized public cutover. No iCloud files, calendars
or contacts have been imported. Public empty-stack validation is not acceptance
of production data before the outstanding protection and recovery checks.
Ansible manages the steady state through `profile_atlas` and the host-local
`atlas_manage_nextcloud: true` declaration. No migration/import flags or helpers
were added. An actual repeat run returned `changed=0`, with no failures.
- Rootless `admin` Quadlets: Nextcloud 33.0.9, PostgreSQL 17.11, Redis 7.4.11 and
ONLYOFFICE Docs Community 9.4.0.129 (image tag 9.4.0.1), on a dedicated network.
- Images are pinned by digest; Calendar 6.6.2, Contacts 8.9.1, ONLYOFFICE connector
10.2.1 and Team Folders 21.0.9 archives are pinned by version and SHA-256.
- Dedicated ZFS namespace: `zpool/services/data/nextcloud`, with separate `app`,
`files`, `database`, `cache` and `office` datasets. No writable SMB/Syncthing
access to the Nextcloud-managed file namespace is provided.
- The `admin` Nextcloud account is an application administrator, distinct from
the host account. `fabio` and `chiara` are standard users in `famiglia`, each
with no initial quota. Team folder `Famiglia` has unlimited quota and group
permission mask 15 (read/create/update/delete, not additional re-sharing).
- Optional TOTP is available; 2FA is not enforced. SMTP is not configured.
- The five-minute user cron timer is active; a manual service run succeeded.
Its `Type=oneshot` means a recurring short-lived job, not a one-time migration.
- Component memory ceilings are Nextcloud 2 GiB, ONLYOFFICE 4 GiB, PostgreSQL
1 GiB and Redis 256 MiB; these are ceilings, not reserved memory or load-test results.
Nextcloud reported installed, no maintenance mode and no pending DB upgrade.
PostgreSQL was healthy; ONLYOFFICE `/healthcheck` returned `true`. The connector's
`onlyoffice:documentserver --check` succeeded using internal routing. JWT is
enabled and matches the dedicated secret; neither privileged containers nor
container-engine socket mounts are used.
NPM on Prometheus reached both upstreams through the Aegis gateway. Direct LAN
connections from Ikaros to 8080/8081 were blocked, and PostgreSQL/Redis had no
published host ports. Existing Git, Music and Syncthing HTTPS returned 200 with
valid TLS. NPM and its backup export timer stayed active; the pool remained healthy.
After operator DNS/NPM configuration, both public hostnames resolved to the VPS.
HTTPS and HTTP-to-HTTPS redirects passed with valid certificates. Both Proxy Hosts
were enabled with Force SSL and WebSocket support. Public Office health and its
browser API asset returned 200; the connector check also passed. Actual browser
editing/saving and native mobile client use remain operator acceptance tests.
Public session-based web login and authenticated WebDAV succeeded for admin,
fabio and chiara. CalDAV/CardDAV
discovery redirected to the DAV endpoint; Fabio's calendar/address-book collections
answered PROPFIND. A uniquely named private test file was inaccessible to Chiara.
Fabio created a test file in Famiglia; Chiara read, edited and deleted it, and Fabio
read the updated contents. All temporary test files were removed. These are HTTP
protocol checks, not device synchronization or large-upload acceptance evidence.
## Operator DNS and NPM configuration
Namecheap: add CNAMEs `cloud` and `office` to `fscotto.co`. Do not change the blog,
mail records or apex IP.
| NPM hostname | Scheme | Upstream | Port |
| --- | --- | --- | --- |
| cloud.fscotto.co | http | 192.168.178.55 | 8080 |
| office.fscotto.co | http | 192.168.178.55 | 8081 |
For each host, obtain a certificate for its hostname, enable Force SSL and
WebSocket support. Keep NPM administration loopback-only; do not expose port 81.
Nextcloud's declared upload ceiling is 2 GiB; align the proxy request-size and
timeout settings rather than claiming large uploads work before testing them.
Verify CalDAV/CardDAV `.well-known` redirects to `/remote.php/dav/` through NPM.
Never disable certificate verification to make Office work.
The browser-facing Office URL is `https://office.fscotto.co/`; server-side routes
use `http://atlas-onlyoffice/` and `http://atlas-nextcloud/` on the private network.
These internal routes require explicit local-address permission in the connector
and ONLYOFFICE. Metadata-address access remains disabled. Nextcloud trusts only
the declared Aegis address and rootless network gateway, not arbitrary proxies.
## Secrets and administration
Six unique secrets were generated into the existing encrypted `secrets/vault.yml`:
database, Redis, Office JWT and initial passwords for `admin`, `fabio`, `chiara`.
Use the local Vault editor to retrieve them; do not paste them in chat.
Account provisioning never resets an existing user's password. After a user
changes it, the initial Vault password is not necessarily their current password.
Database secret rotation needs a coordinated role-password update, not just an
edited initialization file. Image/app upgrades likewise require a deliberate window.
Host configuration lives below `/home/admin/.config/atlas-nextcloud` with a 0700
parent. Mounted individual secret files are readable by their container consumers,
but a different host user was verified unable to read them through the parent.
Nextcloud's managed PHP include inherits the live container SELinux category;
neither global relabeling nor disabling SELinux is used.
```bash
ansible-playbook ansible/site.yml --limit atlas --tags nextcloud --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags nextcloud
```
Dry-run skips initial downloads, image pulls and runtime account/app commands;
it is not proof of an installed or healthy stack. The deployed repeat run is
the current idempotence evidence.
## Gates before family data and full client acceptance
- Verify the first actual scrub and the outstanding protection checks.
- Public TLS, redirects, web login and WebDAV passed. Complete calendar/contact
synchronization and Office editing/saving from a desktop.
- Test opening, editing and saving from the iPhone/iPad ONLYOFFICE app; mobile
browser editing is not a requirement. No such client test is claimed yet.
- Private-space isolation and cross-user shared writes/deletes passed the public
smoke test above; complete normal client acceptance as well.
- Integrate and test application-consistent database/files backups before import.
The new datasets fall beneath existing recursive snapshot/backup scope, but
that alone does not verify a new Borg/USB version or a consistent Nextcloud restore.
- For a consistent backup, coordinate pending Office saves, pause cron and writes,
take a verified PostgreSQL dump and matching application/files snapshot, and
resume services promptly even on failure. Extend recurring backup procedures,
not the steady-state playbook with one-time migration tasks. Restore into an
isolated environment using matching image/app versions, config, files and DB.
- Confirm encrypted Vault/recovery material is available offline. Without SMTP,
recovery for standard accounts is administrator-assisted; a forgotten admin
password can be reset through the private host-side `occ` CLI.
- Select versions deliberately for upgrades. Do not downgrade the application
against an upgraded database; use matching tested backups for recovery.
- Future Uranus migration and iCloud import are separate, explicitly authorized
operations. No source data deletion or automatic cross-system cutover is provided.

82
docs/domain-fscotto-co.md Normal file
View File

@@ -0,0 +1,82 @@
# fscotto.co domain transition
## Observed state (2026-10-03)
Namecheap remains the DNS provider. The operator moved GitHub Pages to
`blog.fscotto.co` in `fscotto/fscotto.github.io`, aligned Hugo and Pages
settings, and changed the apex A record to `179.237.102.172`. The blog
remains a CNAME to `fscotto.github.io`; mail records were left unchanged.
A new Hugo deployment and cache clearing resolved the initial stale DNS
and generated URLs. Blog HTTPS returned 200 with valid TLS.
The `git`, `music` and `syncthing` subdomains are CNAMEs to `fscotto.co`.
The operator added NPM Proxy Hosts with certificates, WebSocket support
and Force SSL:
| Hostname | HTTP upstream |
| --- | --- |
| git.fscotto.co | 192.168.178.55:3000 |
| music.fscotto.co | 192.168.178.55:4533 |
| syncthing.fscotto.co | 192.168.178.55:8384 |
All three redirected HTTP to HTTPS and returned final HTTPS 200 with valid
TLS. Only the Syncthing GUI uses NPM; native synchronization is unchanged.
NPM administration remains loopback-only on port 81 via SSH tunnel.
## Gitea canonical hostname
Atlas declares `atlas_gitea_public_domain: git.fscotto.co`. Ansible manages
only `[server] DOMAIN`, `ROOT_URL` and `SSH_DOMAIN` in the existing private
app.ini, preserving unrelated settings and mode 0600. Private configuration
backups are created; diffs and secret-bearing results are suppressed.
Only Gitea restarts when these fields change; a repeat run changed nothing.
HTTPS uses `https://git.fscotto.co/`; public SSH remains TCP/2222.
Agent read-only checks returned the same HEAD from `fscotto/infra.git`
over HTTPS and authenticated SSH. SSH host identity was checked against
the already-trusted old endpoint key. No test push or user-authenticated
web login was performed by the agent.
```bash
ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --check --diff
```
Client remotes do not update automatically. Update them deliberately after
checking repository paths; integrations and webhooks are separate operations.
For the verified infrastructure repository only:
```bash
git remote set-url origin ssh://git@git.fscotto.co:2222/fscotto/infra.git
```
Do not copy this path into unrelated clones. Verify Gitea's known SSH key
before accepting the new hostname's identity.
## Local DuckDNS retirement
DuckDNS support has been removed entirely from the server profile. On 2026-10-03 the explicit
Ansible cleanup removed the five-minute rocky cron entry and the private
`~/duckdns` directory containing only `duck.sh` and `duck.log`. The temporary
cleanup tasks and flag were subsequently removed from the playbook at the
operator's request. The updater provisioning tasks, template, variables and
enablement flag were also removed; there is no retained opt-in support.
The external DuckDNS name, Vault token, disabled NPM hosts and certificates
remain untouched for a separate future decision.
Before removing the temporary cleanup tasks, the repeat cleanup changed nothing.
The cron table had no remaining entries, NPM and the export timer were active,
and NPM administration still listened only on `127.0.0.1:81`.
## Operator-confirmed transition completion
On 2026-10-03 the operator confirmed completion of:
- Web login on the new Gitea hostname.
- Updates to remaining Git remotes, webhooks and integrations.
- Removal of obsolete DuckDNS NPM Proxy Hosts, unused certificates and the old upstream override.
- Review and removal of completed one-time procedures from the playbook.
These are operator confirmations, not new agent runtime checks or a test push.
At the earlier inspection the three old DuckDNS Proxy Hosts were disabled,
not deleted; that observation predates the confirmed cleanup. Existing backup
archives remain preserved. DNS/Pages/NPM changes were operator actions;
the Gitea application configuration change was deployed through Ansible.

View File

@@ -2,19 +2,23 @@
The playbook and both hosts have the dedicated identity, restricted SSH
access, helpers, and systemd units. A manual export, pull, and temporary
restore passed on 2026-09-30. Both timers are enabled; their first scheduled
runs are pending, so daily operation is not yet verified.
restore passed on 2026-09-30. The first scheduled export and pull passed on
2026-10-01. After NPM moved to its Quadlet, another manual export, pull, and
isolated restore passed on 2026-10-03. The first scheduled cycle after that
cutover is still pending. See `docs/prometheus-npm-quadlet.md`.
## Declared design
- Prometheus prepares a tar archive of Nginx Proxy Manager and Gitea data,
their managed Compose configuration, SSH/firewalld/WireGuard configuration,
and the Gitea SSH path. NPM access logs and regenerable Gitea logs, sessions,
temporary files, and indexers are excluded. The archive contains credentials,
certificates, and the WireGuard private key: protect both copies accordingly.
- The approved consistency mode stops the managed Compose stack for the local
tar creation at 02:00 Europe/Rome, then restarts it even if archiving fails.
A manual test outside that window requires separate approval.
- Prometheus prepares a tar archive of Nginx Proxy Manager data and certificates,
its active Quadlet and network definitions,
and SSH/firewalld/WireGuard configuration. Gitea now runs on Atlas and is no
longer included in new Prometheus exports. NPM access logs are excluded.
The archive contains credentials, certificates, and the WireGuard private
key: protect both copies accordingly.
- The approved consistency mode stops the NPM Quadlet for local tar creation
at 02:00 Europe/Rome, then restarts it even if archiving fails. After the
approved legacy cleanup, the helper requires the Quadlet active and has
no Compose dependency. A manual test outside that window requires separate approval.
- Prometheus publishes the archive with its checksum as a versioned, read-only
source under `/var/lib/prometheus-backup-export`. A locked service account
has no sudo or supplementary groups. Its only authorized SSH key is forced
@@ -51,18 +55,20 @@ runs are pending, so daily operation is not yet verified.
account's key, and verify `sshd -T -C user=prometheus-backup,...` plus
read-only SSH denial tests after any SSH configuration change.
3. During an agreed window, start the Prometheus export service manually.
Confirm Compose is healthy afterward, inspect the archive without exposing
file contents, and verify the checksum/metadata.
Confirm the active NPM service is healthy afterward, inspect the archive
without exposing file contents, and verify the checksum/metadata.
4. Start the Atlas pull service manually. Confirm the SSH host pin, source
freshness, checksum, tar listing, published `latest`, retention behavior,
clean temporary directories, and healthy pool.
5. Independently restore the selected archive to an empty staging directory
(never `/`) and compare the SQLite databases, Git repositories, NPM data,
Compose file, permissions, and representative files. Test application
startup only in an isolated environment or an approved restore window.
6. The two timers were enabled after the manual test. Verify their calendars
and the next actual run. A successful manual test is not proof of scheduled
operation.
(never `/`) and compare NPM SQLite, data, active Quadlet files, certificates,
permissions, and representative files. Historical pre-Gitea-cutover
versions also include Gitea repositories; current versions do not. Test
application startup only in an isolated environment or an approved restore
window.
6. Both timers are enabled. Verify their calendars and the next actual run
after any service-ownership change. A successful manual test is not proof
of a later scheduled cycle.
Narrow static validation:
@@ -100,7 +106,26 @@ repository passed `git fsck`. The temporary restore directory was removed.
This did not test application startup on an isolated host.
After these checks, Ansible enabled the Prometheus 02:00 Europe/Rome export
timer and Atlas 03:00 Europe/Rome pull timer. The next scheduled occurrences
were displayed for 2026-10-01. Atlas' health monitor now includes the pull
timer. Check both actual service results after the first scheduled run before
claiming unattended operation.
timer and Atlas 03:00 Europe/Rome pull timer. Their first scheduled run passed
on 2026-10-01; Atlas verified and published `20261001T000001Z` as `latest`.
Atlas' health monitor includes the pull timer.
On 2026-10-03 the stopped-source version `20261003T091009Z` was verified and
pulled before the NPM cutover. The post-cutover version `20261003T091633Z`
was exported by the Quadlet-aware helper, checksum-verified, pulled to Atlas,
and restored to an isolated temporary directory. NPM SQLite `quick_check`
passed with ten proxy hosts and six certificate records. The archive contains
both Quadlet definitions. A manifest of all 70 regular Let's Encrypt files
and 12 symlinks, including content hashes and link targets, matched the live
Prometheus tree. No private key or secret content was printed. The next
scheduled export/pull is still pending observation.
## Post-cleanup validation (2026-10-03)
The operator-approved removal of legacy data and Compose fallback also
removed those backup input paths and the obsolete Gitea mount dependency.
A separately approved export and Atlas pull published `20261003T112906Z`.
Both SHA-256 checks passed; an isolated SQLite restore passed `quick_check`
and contained ten proxy hosts. Both active Quadlet definitions were present;
retired paths were absent. Existing backup archives were not deleted by cleanup.
The first scheduled cycle after these changes remains unverified.

View File

@@ -0,0 +1,145 @@
# Prometheus NPM Quadlet cutover
## Current state (2026-10-03)
Nginx Proxy Manager runs as the **rootful** generated
`prometheus-npm.service` on Prometheus. The Quadlet files are
`/etc/containers/systemd/prometheus-npm.container` and
`/etc/containers/systemd/server-web.network`; the image is pinned by digest
in `ansible/inventory/host_vars/prometheus.yml`. The generated service is
wanted by `multi-user.target` and requires the generated network service.
The old Compose unit, Compose file and Gitea final-export helper were
removed by the operator-approved cleanup on 2026-10-03. The retired
application data and empty legacy directories were also removed.
Prometheus host vars set `server_legacy_stack_retired: true` so normal runs
do not recreate those files. Destructive deletion still requires a separate
cleanup tag and explicit extra-var.
There was **no data copy** in this cutover. The Quadlet reuses the existing
`/opt/npm/data:/data` and `/opt/npm/letsencrypt:/etc/letsencrypt` bind mounts
with the same container name and `server_web` bridge (`10.89.0.0/24`). Ports
80 and 443 remain public; administration port 81 remains bound to
`127.0.0.1`. Gitea stays on Atlas, and NPM remains on Prometheus. The
Compose fallback is no longer installed. The Quadlet uses `Pull=missing`,
not an automatic floating-tag update.
## Cutover and recovery boundaries
The separate `scripts/cutover_prometheus_npm_quadlet.sh` was run **once** in
the approved outage window, after source backup version
`20261003T091009Z` was checksum-verified and pulled to Atlas. Its preflight
required exactly the Compose owner, an inactive generated Quadlet, the
expected image, and the current backup version. The execution held the
backup-export lock, stopped the export timer, stopped and disabled Compose,
started the Quadlet, checked the exact image ID, SQLite database counts,
certificate content, Nginx configuration, and local Gitea/Syncthing HTTPS,
then restarted the timer. Its failure trap would have restarted Compose.
**Do not rerun that forward-cutover script after success**: its preconditions
intentionally reject an active Quadlet.
Recovery is now a Quadlet rebuild and restoration from a verified Atlas
backup, with an explicit outage decision before replacing live NPM state.
The old Compose owner is no longer installed; reintroducing it would require
a separately reviewed configuration and outage plan. The historical
in-window rollback trap is not a supported post-cleanup rollback procedure.
Do not restore an old database over a live instance or remove NPM bind mounts.
## Verified evidence
- Immediately after cutover, `prometheus-npm.service` was active with zero
recorded restarts; Compose was inactive/disabled. The generated
`multi-user.target.wants` link and network dependency were present. An
actual reboot has not been performed solely for this test.
- The running image ID matched the prior Compose image. Podman showed the
original two bind mounts, `server_web`, public 80/443, and loopback-only 81.
External HTTPS to Gitea and Syncthing returned 200 with TLS verification
result 0. External access to TCP/81 timed out.
- The first **manual post-cutover** export `20261003T091633Z` succeeded with
the Quadlet as its active owner. The Atlas pull published that version;
its SHA-256 payload check passed. An isolated restore passed NPM SQLite
`quick_check` with ten proxy hosts and six certificate records. Both
Quadlet definitions were present in the tar archive.
- A path/content manifest of all 70 regular Let's Encrypt files and the
path/target manifest of all 12 symlinks in the Atlas archive exactly
matched the live Prometheus tree (aggregate SHA-256
`ce0965fbd3ff44bb8502ed9f314e0131edd86d822039de115b39f6a2273c2da8`).
The earlier apparent 70-vs-82 count was only a regular-file-versus-symlink
counting difference, not missing certificate data. No certificate key
contents were exposed during comparison.
- The targeted `--tags npm_quadlet` normal Ansible run completed with
`changed=0`, and the backup export timer remained active/enabled.
The first unattended 02:00 Europe/Rome export and 03:00 Atlas pull **after**
this cutover have not yet occurred. Check their service results and the
published version after the next cycle; the successful manual cycle proves
the new path works but not its next scheduled execution.
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit prometheus --tags npm_quadlet --check --diff
sudo systemctl status prometheus-npm.service prometheus-backup-export.timer
sudo systemctl show podman-compose-server.service -p LoadState # expected: not-found
```
The backup archive includes credentials, certificates, and WireGuard
configuration. Do not publish it or print its contents in diagnostics; see
`docs/prometheus-backup.md` for the restricted pull and restore procedure.
## Selective legacy image cleanup
On 2026-10-03 opt-in Ansible tasks removed only the unused Gitea 1.25.2,
Navidrome latest and PostgreSQL 13 rootful images, without force or global
prune. Podman refuses images referenced by existing containers. The second
run changed nothing. NPM remained active with zero restarts; local admin
and public Gitea HTTPS returned 200. Backup timer and SSH proxy stayed active.
Validation:
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags server_image_cleanup --check --diff -e server_legacy_image_cleanup=true
```
The image cleanup defaults to disabled and carries the `never` tag.
Check mode probes image presence but skips removal; it does not prove
Podman would accept deletion. It never removes NPM resources.
## Approved legacy data and fallback cleanup
The operator explicitly approved deletion on 2026-10-03. The separate
`server_legacy_cleanup` tasks removed `/opt/gitea`, `/home/git/.ssh`,
`/opt/navidrome`, `/opt/postgres`, `/opt/music`, `/opt/containerd`,
`/opt/docker`, the old Compose unit and the final Gitea export helper.
The empty `/home/git` parent is removed only with `rmdir`, after confirming
the Git account is absent. Guards reject symlinked paths, nested mounts,
unexpected containers, container users of these paths, unexpected content
in the empty legacy trees, and an active Compose or export service.
The second cleanup run changed nothing.
Before deletion, Ansible removed obsolete backup input paths and the
Gitea mount dependency. Normal Compose/template/final-export task checks
changed nothing and did not recreate the retired files. Deletion is opt-in:
```bash
ansible-playbook ansible/site.yml --limit prometheus --tags server_legacy_cleanup --check --diff -e server_legacy_cleanup=true
```
Remove check mode only for approved deletion. No active NPM data, certificate,
image, network, volume, SSH proxy, WireGuard configuration or backup archive
is removed. No services were restarted by the cleanup.
After separate approval for the brief managed NPM pause, the new export
`20261003T112906Z` completed successfully and was pulled to Atlas. SHA-256
passed on both hosts; an isolated SQLite restore passed `quick_check` and
contained ten proxy hosts. Both Quadlet definitions were present, and
retired paths were absent. Temporary restore files were removed.
NPM was active with zero automatic restarts; primary public Gitea HTTPS
returned 200 with valid TLS. Backup timer, SSH proxy and WireGuard stayed active.
The first scheduled post-cleanup cycle remains unverified.
After separate operator approval on 2026-10-03, the unused secondary hostname
`git.ov-ad3410.infomaniak.ch` was removed from the declared domains and
the managed NPM runtime override. Its Proxy Host (id 10) was already
soft-deleted, with no generated config or associated certificate. Historical
deleted records and backup archives are preserved; no DNS changes were made.
Only `git.fscotto.duckdns.org` remains declared for the Gitea override.
Nginx validation and reload passed without restarting NPM; the primary
public HTTPS endpoint returned 200 with valid TLS.

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Run on Prometheus as root with the exact verified source-export version.
set -Eeuo pipefail
expected_export=${1:?Pass the verified Prometheus backup export version}
mode=${2:---preflight}
[[ $expected_export =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || exit 2
[[ $mode == --preflight || $mode == --execute ]] || exit 2
[[ $EUID -eq 0 ]] || { echo 'Run as root on Prometheus' >&2; exit 2; }
compose_unit=podman-compose-server.service
quadlet_unit=prometheus-npm.service
backup_timer=prometheus-backup-export.timer
versions=/var/lib/prometheus-backup-export/versions
quadlet_file=/etc/containers/systemd/prometheus-npm.container
exec 9>/run/lock/prometheus-backup-export.lock
flock -n 9 || { echo 'Backup/export lock is busy' >&2; exit 1; }
systemctl is-active --quiet "$compose_unit"
if systemctl is-active --quiet "$quadlet_unit"; then
echo 'NPM Quadlet is already active; refusing overlapping cutover' >&2
exit 1
fi
[[ $(systemctl show "$quadlet_unit" -p LoadState --value) == loaded ]]
[[ $(systemctl is-enabled "$compose_unit") == enabled ]]
[[ $(readlink "$versions/current") == "$expected_export" ]]
image=$(sed -n 's/^Image=//p' "$quadlet_file")
[[ $image =~ ^docker\.io/jc21/nginx-proxy-manager@sha256:[a-f0-9]{64}$ ]]
podman image exists "$image"
(cd "$versions/current" && sha256sum -c payload.sha256 && tar -tf payload.tar >/dev/null)
curl -fsS --connect-timeout 2 --max-time 5 -o /dev/null http://127.0.0.1:81/
old_image=$(podman inspect nginx-proxy-manager --format '{{.Image}}')
data_signature() {
python3 - <<'PY'
import hashlib, os, sqlite3
db = sqlite3.connect('file:/opt/npm/data/database.sqlite?mode=ro', uri=True)
assert db.execute('pragma quick_check').fetchone()[0] == 'ok'
counts = [db.execute('select count(*) from ' + table).fetchone()[0]
for table in ('proxy_host', 'certificate', 'user')]
db.close()
digest = hashlib.sha256()
for root, dirs, files in os.walk('/opt/npm/letsencrypt'):
dirs.sort()
for name in sorted(files):
path = os.path.join(root, name)
with open(path, 'rb') as stream:
digest.update(path.encode() + b'\0' + stream.read())
print(*counts, digest.hexdigest())
PY
}
before=$(data_signature)
if [[ $mode == --preflight ]]; then
echo 'NPM Quadlet cutover preflight passed; no service was changed'
exit 0
fi
stopped_old=false
rollback() {
rc=$?
trap - EXIT
if (( rc != 0 )) && "$stopped_old"; then
echo 'NPM Quadlet cutover failed; restoring Compose' >&2
systemctl stop "$quadlet_unit" || true
systemctl enable "$compose_unit" || true
systemctl start "$compose_unit" || true
systemctl start "$backup_timer" || true
curl -fsS --connect-timeout 2 --max-time 10 -o /dev/null http://127.0.0.1:81/ || true
fi
exit "$rc"
}
trap rollback EXIT
stopped_old=true
systemctl stop "$backup_timer"
systemctl stop "$compose_unit"
if podman container exists nginx-proxy-manager; then
echo 'Compose left the NPM container behind; refusing duplicate ownership' >&2
exit 1
fi
systemctl disable "$compose_unit"
systemctl start "$quadlet_unit"
ready=false
for _ in {1..60}; do
if curl -fsS --connect-timeout 2 --max-time 3 -o /dev/null http://127.0.0.1:81/; then
ready=true
break
fi
sleep 2
done
"$ready"
systemctl is-active --quiet "$quadlet_unit"
[[ $(podman inspect nginx-proxy-manager --format '{{.Image}}') == "$old_image" ]]
podman exec nginx-proxy-manager nginx -t
[[ $(data_signature) == "$before" ]]
for hostname in git.fscotto.duckdns.org syncthing.fscotto.duckdns.org; do
status=$(curl -ksS --connect-timeout 3 --max-time 10 \
--resolve "$hostname:443:127.0.0.1" -o /dev/null -w '%{http_code}' \
"https://$hostname/")
[[ $status == 200 ]]
done
systemctl start "$backup_timer"
stopped_old=false
echo 'NPM Quadlet cutover passed local application and data checks'

View File

@@ -1,161 +0,0 @@
#!/usr/bin/env sh
# Copy the persistent NPM and Gitea data from the retired Ubuntu server to the Rocky
# replacement. Run this script on the Ubuntu source as root. It is a dry run
# unless --execute and --quiesce-source are both supplied. Extended attributes
# are deliberately not copied: Rocky must assign its own SELinux labels.
set -eu
SOURCE_COMPOSE_FILE=/opt/docker/server/docker-compose.yml
DESTINATION=
IDENTITY_FILE=
EXECUTE=false
QUIESCE_SOURCE=false
DATA_PATHS='
/opt/npm/data
/opt/npm/letsencrypt
/opt/gitea/data
'
usage() {
cat <<'EOF'
Usage: sudo ./scripts/migrate_prometheus_data.sh --destination USER@HOST [options]
Copies persistent Nginx Proxy Manager and Gitea data to the Rocky server with
rsync. The destination Docker containers must be stopped.
Options:
--destination USER@HOST Rocky SSH destination (required).
--identity PATH SSH private key readable by root on the source host.
--source-compose PATH Source Compose file (default: /opt/docker/server/docker-compose.yml).
--quiesce-source Stop the source Compose stack before copying.
--execute Perform the transfer; otherwise only show changes.
-h, --help Show this help.
The script never deletes source data, destination-only files, containers, or
volumes. It intentionally excludes Syncthing and /home/git/.ssh.
EOF
}
fail() {
printf 'Error: %s\n' "$1" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--destination)
[ "$#" -ge 2 ] || fail '--destination requires USER@HOST'
DESTINATION=$2
shift 2
;;
--identity)
[ "$#" -ge 2 ] || fail '--identity requires a path'
IDENTITY_FILE=$2
shift 2
;;
--source-compose)
[ "$#" -ge 2 ] || fail '--source-compose requires a path'
SOURCE_COMPOSE_FILE=$2
shift 2
;;
--quiesce-source)
QUIESCE_SOURCE=true
shift
;;
--execute)
EXECUTE=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown option: $1"
;;
esac
done
[ "$(id -u)" -eq 0 ] || fail 'run this script with sudo on the Ubuntu source host'
[ -n "$DESTINATION" ] || fail '--destination is required'
if [ -n "$IDENTITY_FILE" ]; then
[ -r "$IDENTITY_FILE" ] || fail "SSH identity is not readable: $IDENTITY_FILE"
case "$IDENTITY_FILE" in
*' '*|*"$(printf '\t')"*) fail 'SSH identity paths must not contain whitespace' ;;
esac
fi
if [ "$EXECUTE" = true ] && [ "$QUIESCE_SOURCE" != true ]; then
fail '--execute requires --quiesce-source to keep application data consistent'
fi
require_command rsync
require_command ssh
SSH_COMMAND='ssh -o BatchMode=yes'
if [ -n "$IDENTITY_FILE" ]; then
SSH_COMMAND="$SSH_COMMAND -i $IDENTITY_FILE"
fi
run_ssh() {
# shellcheck disable=SC2086
$SSH_COMMAND "$DESTINATION" "$@"
}
printf 'Destination: %s\n' "$DESTINATION"
printf 'Mode: %s\n' "$( [ "$EXECUTE" = true ] && printf execute || printf dry-run )"
printf 'Data paths:\n%s\n' "$DATA_PATHS"
run_ssh 'sudo -n true' || fail 'destination sudo must be passwordless for this transfer'
run_ssh 'sudo -n docker info >/dev/null' \
|| fail 'destination Docker daemon is unavailable'
if run_ssh 'sudo -n docker ps -q | grep -q .'; then
fail 'destination Docker containers must be stopped before migration'
fi
for path in $DATA_PATHS; do
[ -d "$path" ] || fail "source directory is missing: $path"
run_ssh "sudo -n test -d $path" || fail "destination directory is missing: $path"
done
if [ "$QUIESCE_SOURCE" = true ]; then
require_command docker
[ -f "$SOURCE_COMPOSE_FILE" ] || fail "source Compose file is missing: $SOURCE_COMPOSE_FILE"
if [ "$EXECUTE" = true ]; then
printf 'Stopping source Compose stack...\n'
docker compose -f "$SOURCE_COMPOSE_FILE" stop
else
printf 'Dry-run: source Compose stack would be stopped.\n'
fi
fi
for path in $DATA_PATHS; do
printf '\nSyncing %s\n' "$path"
if [ "$EXECUTE" = true ]; then
rsync -aHA --numeric-ids --itemize-changes --human-readable --partial \
--rsync-path='sudo -n rsync' -e "$SSH_COMMAND" "$path/" "$DESTINATION:$path/"
else
rsync -aHA --numeric-ids --itemize-changes --human-readable --partial --dry-run \
--rsync-path='sudo -n rsync' -e "$SSH_COMMAND" "$path/" "$DESTINATION:$path/"
fi
done
if [ "$EXECUTE" = true ]; then
printf '\nVerifying source-to-destination parity...\n'
for path in $DATA_PATHS; do
rsync -aHA --numeric-ids --itemize-changes --dry-run \
--rsync-path='sudo -n rsync' -e "$SSH_COMMAND" "$path/" "$DESTINATION:$path/"
done
printf '\nTransfer completed. Keep the source stack stopped until application validation on Rocky succeeds.\n'
else
printf '\nDry-run completed. Re-run with --quiesce-source --execute after reviewing the changes.\n'
fi

View File

@@ -1,83 +1,101 @@
$ANSIBLE_VAULT;1.1;AES256
61353065386233646137323235306631353635663530363237636231316265643562353465323430
6165646466623962313835313537633137633766373930380a316335323962616265643136346666
63336133336131346336383534356637623831363138323165633262386333363535393365383233
6234393835653439370a313963313365373633323464343263383661383336363662633133643232
34366634383862363635653034313531623330396639616462343630326162316535643465653532
36326534333637376462353561343964633636366331363833313263353133383636623537303663
35393032316439336666343161653439643638376134363535656262343963393365623432336433
35383934313762313037326430316666363731666231336534326661353034333063643364343230
65333739303566366263333565333465613136646237623937393733623438613832393634663463
39376131313234333039633735613233373931613232653036663665316636303961653834366339
36353730316132316233303964303839363161346564396163336137663134353062363733656430
37643339326661653031376265646132623162373562393437373437313732396537383939333666
62353036316633306666313461663033303830393765396131643035353730383931646239663935
32626461316364386135303761383837613063336466363162323332663764616464373565383231
61346463336566346533326535376439643133613762383633396131323632356533636139336365
62393838316634623932643034376631333539343965383436613364643962363834346337353334
32656439366439313734353963343133333533653839613632323338336131373566613835393536
31663433616334373432376531346435336530303936356461303163646463613661643161313661
66663866343565616631616338353737356164353562366164383736346131666662623132333466
39383865653631373232393433663430643961646265386166333137643966303834363262373636
62396434373363353636376133666133663162653265313139313732353639336232333862643036
64386231336561396537326139346566306434633934343038663165396665363032383466633662
62336163633964363435386630343966333162333730336138333239646631633132663931376462
33663139356261313065376636613930353735396131306538306664646135636336643032623131
38346264333331353633326535326431626563323036313665643337353563333339646430386564
31613435383036313430316366323636663735326336393338353835323861333564363832656462
35336435623261326363633033316130393062616339353263643062633331646137376135656365
35636139336564346164616235616431326531333433646330386134323932373339646536356464
66343533326534326165323564663533653666633035343163633832393361336462343937623165
62383931326630363036396333313931393836366439653433623165666166356338653364336534
35333936653833386163633738326164386166613561333530633937343230363366333662666539
39333361633933663735303438663239303536363433313962643137386533633539326365383765
37636538386339333935386132353265353031643662616330316463623661663738353433313830
36373963633166333464653338343830373063323536383364393033393235326639613662343737
38663362636331343061646465313237313431373433353361353265333766633463353632646536
31323231306138323031396630656538363930373439336234343963616334363632653738316465
63653938373830336362313238656266613362636634616537653863336132343931616262396130
66393239303866656232653832343132366537333537343635666563343639323433383163613335
39613533376634316133633430303535306266656333626264343733666335393661666561396633
39346265316137326465326635396362333565393133623637633132616232326263663662343137
33363733306135363361643031306265363733656362386666306334333035393839636533343363
35396638616636633639343930373136376339346162393061393765363837646365383866636131
33653465666239393133616232636231333332396138376332393664343364643835306530393238
34663237303530303837663535646263393931373531393039356336316561653130356262636562
38336362326639653237626634376334666565653036353236313634376364626338646538386536
38626636386466373566646166393963643164343536373236396138303532393161363335386638
32633032393737626363613463323366366637616361313537356136626661626633613739323338
35383963666431343566356562333234663936376562616638636261303466633539376334303331
39303834663234663063356233313962326664383839393832303462643636393034383434303465
64333635376135326333356435373734643430623736373234643335343130383066326436356664
63346663326364343634303930343338336139313864316165366232643537366635653764353763
31363863633261643263303433373330366161323166366462336332313135366338393334653764
66353733653137663835663731373364613030373334663061313433373861613665363236633130
65613965366636343465336533613438373466383737373366653965633437323562643966396431
39303033643438633762633263326132663466643438656366363431616237633031333936313831
30323930383233313032323638356333626230333764363662313662646536643839353032353462
30326166653937353130623133303533343934633565393831623033303234316330353432313266
30636536633933376365623665616262663236383731633633346232613366333137396139306363
35633336643266326335303261666666653536666630613639376336373237646134306462616537
33343561373162666332613634643837343566646161373065366637653135613632353334636363
63363232303963646530333366663862323264326536643337323266396566316233613630303637
66646366376466373931613734363931316230323063373666653062373364396433633762633762
38613933323733653238383935623230383562646563363833653838636165626365646537383639
33666535656363393562316336633439636138373365623431393965653765306138646234663938
65653133663663393731646337386535333261643932336132396237323930306136643534353930
65636438396432623034626561613137336138623265393064383034623863303166356138393564
37373164626634653662326234333539663735323464613334616130643937373730363263633366
31393937326432386165343338313031376565313866363731643534313233303064373935303538
31343832336230393636653432653162336361383963633766343461653466316337353931333363
63313137303564336630343937356564643763383764613362366634373362666465626334336539
64366533376165306532343461613265366266383862323032333465336161663161376630316465
30306562666163646235656664653635366461366435663961623635383437663564356563346462
31636234663765623838333237393239373564366262613637363938653463396530613963643837
38636634376637366332623035313465393762653865623130336263343663303066366135616639
63333964356466613038303263366462346261353030646532366361393965306435613131316463
65366266376637323764643239323730366565633335666638666334663635373961303637383861
35313431646434656562333937663837393038386361616630626532636339306432353434656165
33663261383166386432383465666136376237346565303164363461666663346130346162316338
62373061353034316234303835663439396434343738303764376665336239626238386436386234
61306166383637366266393730323732386163366261393630336431633862353761343763363665
61323039396234393835303633363339373633653334343766653032313230343464326664356566
3462623830666664626633373966363866333337383730313066
36616436366637373963326235323736623235633666353235383933663230616532613131636466
3132633562663861353835633633653764376634636638620a636662316234316164626635646539
63343233373531373833626437656630363330363932353136653834313830646431343961386237
3166323135376665620a383334616634356361326134313930613266333136393238366566343233
33366335353639316164346239336539636335393130663261333065363733323163613437396332
35663339623338363737383332396238346430353730356632623964323134663434336363613564
63306464623331643738666234343162643630353061363231313933633733626165333763653461
39663066376637623939383964333663306137663433313334313132323465623534666133393533
36633036376538623764363165663861383135663437343230366165636530663165643538376161
62653335666463653538356635333339353165336333306462373233316438386539613361383039
34663336636565343035626238633139356638636535373239386463663738633036383861633062
35313735333530306666313966333061326338393533333936633634633136353237643464376563
35626266363237613037663934666538356639366637643037386336316131343965616137336330
64623432663033653066353661613366313065366264663138643965346363626562366433326461
65316661346631343330633033326630306536633831366231363066323861366662363861666364
38623438633235646430613935363932386237303132343236303439633939373862366565313864
35306332636562636466333739343663343762343163343738646234353638303134643763636639
36613662633135303733376333613235333637646661326235373732306139363363623632666262
38366436613733316465623438343334333861313161363131376132613232376663623230623533
38303061386639616631383636303966666338353865626464363434353661393665613862303130
62303664653362336433356239626661353864363537346234613331376331313038633138363565
31616232653265343430646537373835643163396530353832366337663363386635306665643432
66393838363266383230363633313235376130356436633137636637666562383165643862313931
63346633343334333662363334373865653232623938363162363362646361383961376532626339
30643537356436346161353161626232303962396463323037653235343633643261396134373061
61323463653962363639373531366130326431353635346463396434393336313730373431316334
37313032666231383536363535326239383363346137363037653930373261326338303936663234
36326635346465316233363266383337343335653239393830356262346530363734383532303936
38633065633135666438333832333336636365326430656534313332356662356165616563333035
35363833363636346430356461306337396561366536326139623131303638333733616663653336
65623062626366386364343036386633626236363638393565323163623936663930363864656264
61323566376464356532316366633663623031613439653635323339363730366231326531303163
62313638393962653064303934663436376335663763333965366230323466646463653665656466
62643164616331636464613934376335353437653662363433363533613633633536346662656339
62353565303464373438383234353237636239313062643036383161303735386539613533383334
63383065613236316633623936383130353466383865376336393733663434663636333463336334
37356233306333366463303839643363393463636630306632326339646661643162323334633331
66613731313733646362396534356236363361363330383230303731356261333336653930303161
35353336326438376563616534616361353233373232303034623465656261326664393962326632
36373936393261616338396630383034323462646664623566663064316438363065646330353362
32633566666263333863383264363762323964356430336539623633643537336538353037396566
63396537626465363531393161653939633461366231326234663161646364616338636236313332
35646664333763623532306637383961623538643164633939303561316262316463646665353633
66313164646134646132653338356531303435623130343864326236353939356433396164336236
36623066396435323532356663326163636637346463626235616132353932326438303233393830
64326563303365646664376337303539643032363537633139623665346130636631386662373762
66336565303334303561386134343730306566303036313933613134366238303636316238363165
66373531633430363730376236353939626137323862623538356233616363376330366433633032
37363538393331376665623230623233343065653139313431323966326238636663633030393734
32643635326266646636663539353537623062633130386532373638396366353038663861333033
63343031383238306139653932366433346564643233323937316134306666623030623137313736
39623537346564623236353131656465326632303038366261626661333931323265396262636661
35313739306432323034643832623831373831666231643862613736393135383561386365323835
35623863396339653038316262303263313262616361666666343331393666663530363764643639
31353564633835323031303835636261613839353031373334366335323465326536323762626633
37343633376666323963336436623533346261396438343336663630366434383961393738383263
65383036333031643336393835303835363733663634653463313639313939636539386634663464
32643034383835333533343434656234343134313934323462643631653337383536363165613835
63393437653261653966313237633939626330316631633335386235346465663332336337613865
30626332353130326430316266363062353636356663663439346662313461393835663864656561
62633131323937656239383531373863393865386265663038346535616463326630646565663463
64393861366635656130386434633431393661656438333832633366333730643639333036613935
31363764396466333964343136363630386530343662656362316137306634383032363962616530
38393731346236353530626263336366376466343430316235653363396565656435323531393438
61356464333539636637363632626661663634333331643734316230663736333134383664376231
37613339613266663831613030633466326439323635626638343430383230333639333561646431
66313634373365373137613134373635333535333164353134623937633066613330393430633438
35366261633739623963623331373262313865326264386334633630653263343637343633313366
33303036623333633365373830333465333931633761636366323939363463303239363461333139
33396663376335646137393436323463383461336233646236306331636361653964346536666637
36376133326431333234613435613535316263313364396362386537343565393533356564633338
64363261323838343531326234343138303133626636653732313234383662326131313431323332
32636539323033376434323939666437373936383763323762636439323836656432303833363362
35646235653839303838363936613166643662393131373438633265316136663264316332663638
32613535643565303566376530373065646333356136613462666465396566313933323261663736
66396565396261306139396364393962393361326363666439333566376561366466626335363461
34356232353664346234316330363962656332396236383136353461333234313662633234346565
34376365356133396239383333643163386133316461633032373035323131663139336339346633
32373633663231373361393762396632383738616330333038646439336532303461663430613133
37633833393762303035353566633736353130663136626666613061383732326233303831386466
38313162623836373533326361313031303636393564656634393263306262376336303663363933
30643266626632366333323434383063356363646264363133306566316533356438633135336130
62663335386335636364313234633965373961353135373339316337626665323761336133653364
33393733383330656432356231313236646163666565373666633637373765346636336235316534
64613536333433626261646333373539383862366334376137373862323232653362346431386164
36643536613133396162653132616134663538393566323363353038383464663638303865376336
30333833313764643130366533646234343339356562663036373137356565643762306261316632
33323134633562303263383931623565383766653536353565303266353862643234346637653132
63646130646339663035333963323366373331616462613236623133646239363134333165646133
64643433373134656161653130323537613361643731653938623036383331633861666332376361
39373962653630326561323662303664636161386461383833363865663935303132353637386633
37346566626439323863393064643765636337616231363066636539306439356632633032663065
66363839616430666233373033376362623862383066396565633632306534623036626335393039
32343132616465383961373432336233376339393863663136663435303266333038333566313665
61303561376366306331633730616265343662333833633533643465373663663634636632666234
31373332323234376430306538386138316431623133626636633034333735303337663335646461
61653439653663653930666332313334623264323539613037323534666137616165373865306531
36306137663164316534373738383865363333316363323538356139646139363064383666626536
66653363393433316335623063633436353761313065636631623366646633353735326362366162
64613736343363333834

View File

@@ -1,5 +1,4 @@
---
vault_duckdns_token: "CHANGEME"
vault_personal_full_name: "REPLACE_ME"
vault_git_email: "REPLACE_ME"
vault_git_signing_key: "REPLACE_ME"
@@ -8,8 +7,14 @@ vault_git_work_email: "REPLACE_ME"
vault_git_work_gpg: "REPLACE_ME"
vault_ikaros_authorized_ssh_keys:
- "ssh-ed25519 REPLACE_ME"
vault_aegis_icloudpd_apple_id: "REPLACE_ME"
vault_atlas_icloudpd_apple_id: "REPLACE_ME"
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_nextcloud_database_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_redis_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_onlyoffice_jwt: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_admin_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_fabio_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_nextcloud_chiara_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET"
vault_atlas_borg_passphrase: "REPLACE_WITH_A_STRONG_UNIQUE_PASSPHRASE"