Compare commits

..

35 Commits

Author SHA1 Message Date
Fabio Scotto di Santolo
76f3e10ee4 Verify WireGuard handshakes and restore Podman networking 2026-09-15 22:38:56 +02:00
Fabio Scotto di Santolo
c899bb7192 Harden Rocky storage and sharing configuration 2026-09-15 22:05:48 +02:00
Fabio Scotto di Santolo
2ab5ba6818 Manage SSH authorized key fragments for infrastructure hosts 2026-09-13 15:16:40 +02:00
Fabio Scotto di Santolo
a347880d4c Manage Aegis SSH authorized key fragments 2026-09-13 14:46:08 +02:00
Fabio Scotto di Santolo
b05a2b4e74 Allow configurable Aegis SSH users and authorized keys 2026-09-13 14:35:43 +02:00
Fabio Scotto di Santolo
75d9081a11 Rotate Ansible Vault secrets 2026-09-13 13:53:03 +02:00
Fabio Scotto di Santolo
b4b3bd10b8 Harden Atlas network, SSH, firewall, and sharing 2026-09-13 00:01:48 +02:00
Fabio Scotto di Santolo
db10d1296e Enable Atlas NAS management and document bootstrap workflow 2026-09-12 18:53:21 +02:00
Fabio Scotto di Santolo
8c35ef63c9 Document Atlas backend phase one and WireGuard deployment 2026-09-12 17:19:20 +02:00
Fabio Scotto di Santolo
64aebe8c34 Add Atlas media and storage services 2026-09-10 22:27:33 +02:00
Fabio Scotto di Santolo
569e6ef24e Fix desktop dotfile idempotency 2026-09-08 23:24:53 +02:00
Fabio Scotto di Santolo
ab8c51a57d Migrate Prometheus stack to Podman 2026-09-08 23:12:41 +02:00
Fabio Scotto di Santolo
7203ac6e2d Install cockpit and enable socket on prometheus 2026-09-08 19:17:22 +02:00
Fabio Scotto di Santolo
fc8df74050 Keep podman on rocky server for future Docker migration 2026-09-08 19:17:20 +02:00
Fabio Scotto di Santolo
350fc7ac95 Rename SSH host alias vps to prometheus-ubuntu 2026-09-08 18:56:00 +02:00
Fabio Scotto di Santolo
ab0bbf30ab Document DNS Filter role for Aegis 2026-09-08 18:55:54 +02:00
Fabio Scotto di Santolo
24268938bd Hotfix/duckdns token (#8)
* fix: source DuckDNS token from Vault

* chore: rotate DuckDNS Vault token
2026-09-08 18:48:13 +02:00
Fabio Scotto di Santolo
54e2917062 chore: uninstall GitHub Copilot agent 2026-09-08 10:42:25 +02:00
Fabio Scotto di Santolo
eab66b6d3d Add dormant Rocky server profile (#2)
* Add dormant Rocky server profile

* feat: activate Rocky server profile for prometheus

* fix: complete Rocky server migration tooling

* Remove FreeBSD profile

* feat: add Aegis Fedora CoreOS profile

* docs: define Git Flow branch prefixes

* Add Aegis TPM-backed btrfs storage

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Preserve existing Aegis filesystem tables

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Remove unsupported Aegis TPM storage

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Configure Aegis pi Btrfs storage

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Mount Aegis Btrfs as root

Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>

* Handle Maven on Mise

* Feature/aegis coreos (#7)

* feat(aegis): add ARM image bootstrap workflow

* feat(aegis): manage firewall and SSH hardening

* feat(aegis): harden Fedora IoT services

* chore(aegis): remove local TLS configuration

* fix(aegis): restore supported Fedora IoT bootstrap

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-08 10:36:29 +02:00
Fabio Scotto di Santolo
ae1ca44aa7 Feature/aegis coreos (#7)
* feat(aegis): add ARM image bootstrap workflow

* feat(aegis): manage firewall and SSH hardening

* feat(aegis): harden Fedora IoT services

* chore(aegis): remove local TLS configuration

* fix(aegis): restore supported Fedora IoT bootstrap
2026-09-05 16:58:53 +02:00
Fabio Scotto di Santolo
295f1a62ad Handle Maven on Mise 2026-09-04 09:36:01 +02:00
Copilot
6da430b9e4 Merge pull request #5 from fscotto/copilot/featureadd-pi-user-and-encrypt-filesystem
Configure Aegis with a pi user and Btrfs root filesystem
2026-09-04 01:04:28 +02:00
copilot-swe-agent[bot]
e92ff1c729 Mount Aegis Btrfs as root
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 23:02:42 +00:00
copilot-swe-agent[bot]
8d3f67885c Configure Aegis pi Btrfs storage
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:51:57 +00:00
copilot-swe-agent[bot]
bc9b381525 Remove unsupported Aegis TPM storage
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:48:04 +00:00
copilot-swe-agent[bot]
2004b48cf3 Preserve existing Aegis filesystem tables
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:44:21 +00:00
copilot-swe-agent[bot]
54e1e88a4e Add Aegis TPM-backed btrfs storage
Co-authored-by: fscotto <17803710+fscotto@users.noreply.github.com>
2026-09-03 22:43:54 +00:00
Fabio Scotto di Santolo
cb88e95385 docs: define Git Flow branch prefixes 2026-09-01 22:44:14 +02:00
Fabio Scotto di Santolo
764d329a8f feat: add Aegis Fedora CoreOS profile 2026-09-01 22:38:49 +02:00
Fabio Scotto di Santolo
1517c92866 Merge pull request #3 from fscotto/remove-freebsd-profile
Remove FreeBSD profile
2026-08-31 21:39:44 +02:00
Fabio Scotto di Santolo
6296925f2d Remove FreeBSD profile 2026-08-31 21:38:13 +02:00
Fabio Scotto di Santolo
45340103d0 feat: manage Java 11 with Mise on Fedora WSL 2026-08-31 18:20:52 +02:00
Fabio Scotto di Santolo
67b576d9b3 Fix Git config on Fedora WSL 2026-08-31 17:49:48 +02:00
Fabio Scotto di Santolo
5914ae1557 feat: use BobIDE as diff/merge tool for workstation 2026-08-31 12:51:53 +02:00
Fabio Scotto di Santolo
671581d717 feat: manage coding agents independently 2026-08-31 10:02:32 +02:00
95 changed files with 3630 additions and 916 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# DuckDNS runtime files contain a rendered Vault token and must stay on the host.
/dotfiles/server/duckdns/

134
AGENTS.md
View File

@@ -1,6 +1,6 @@
# AGENTS.md
Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBSD transition targets, WSL, and an Ubuntu server.
Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora IoT, WSL, a Rocky Linux 9 server, and an Atlas NAS.
## Source Of Truth
- Main orchestration: `ansible/site.yml`
@@ -15,8 +15,9 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBS
- Current laptop: `nymph = platform_fedora + graphical_desktop + desktop_gnome`
- Void desktop profile is also the base for other future/reference hosts via `platform_void + graphical_desktop`
- Workstation: `deadalus` is Windows + Fedora WSL.
- Ubuntu server: `prometheus`
- Rocky server: `prometheus` belongs to `rocky_server`.
- NAS: `atlas` (Rocky Linux 9, reached through SSH)
- Always-on LAN node: `aegis` (Fedora IoT on Raspberry Pi 4, reached through SSH)
- Hosts intentionally belong to multiple groups; trust `ansible/site.yml` over hostname assumptions.
- Inventory axes are independent: `platform_*`, `role_*`, and `desktop_*`. Legacy `void` and `desktop` remain compatibility parents.
@@ -24,7 +25,10 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBS
- 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.
- All hosts use `ansible_connection: local`.
- 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/`.
- Desktop and WSL hosts use `ansible_connection: local`; remote infrastructure hosts use SSH.
- Treat `secrets/` as sensitive. Never print secret values.
- Tmux plugins are bootstrapped by TPM on the host; the repo only keeps tmux config and custom helper scripts.
- Read the relevant role tasks, templates, vars, and deployed dotfiles before editing.
@@ -41,11 +45,23 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, FreeBS
- Fedora laptop work: `ansible-playbook ansible/site.yml --limit nymph --check --diff`
- WSL workstation dev: `ansible-playbook ansible/site.yml --limit deadalus --check --diff`
- Server: `ansible-playbook ansible/site.yml --limit prometheus --check --diff`
- Rocky server after activation: `ansible-playbook ansible/site.yml --limit <host> --check --diff`
- Atlas NAS: `ansible-playbook ansible/site.yml --limit atlas --check --diff`
- Aegis IoT: `ansible-playbook ansible/site.yml --limit aegis --check --diff`
- Focused checks:
- 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: `docker compose -f /opt/docker/server/docker-compose.yml config`
- Server compose render: `podman-compose -f /opt/docker/server/docker-compose.yml config` and `systemctl status podman-compose-server`
- Atlas media stack:
`ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff`
- Atlas network/share hardening:
`ansible-playbook ansible/site.yml --limit atlas --tags hardening,sharing --check --diff`
- Atlas phase-one rootless services:
`ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff`
- Prometheus/Atlas WireGuard overlay:
`ansible-playbook ansible/site.yml --limit prometheus,atlas --tags wireguard --check --diff`
- DuckDNS config only: `ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff`
## Conventions
- Use FQCN Ansible modules.
@@ -77,31 +93,84 @@ The Void desktop package lists in `ansible/inventory/group_vars/void.yml` are ke
- `desktop_common_packages` — GUI infrastructure shared by the minimal desktop mode.
- `desktop_minimal_packages` — applications, integration components, and the `emptty` display manager.
- `desktop_sway_packages` — binaries specific to the Sway session.
`profile_packages` in the same file is cross-distro and is overridden by `group_vars/server.yml` and the workstation group vars; do not move desktop-specific Void entries through it.
`profile_packages` remains the shared package bucket for Void and Fedora profiles. Rocky uses
`rocky_profile_packages` so RPM-specific names do not leak back into the other platforms; do not move
desktop-specific Void entries through either bucket.
The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-independent content and `desktop_minimal_dotfiles` carries Thunar, Udiskie, and MIME defaults. `desktop_void_dotfiles` remains reserved for files that need the Void runtime.
## Workstation Notes
- `deadalus` is modeled as Windows + Fedora WSL and is the sole workstation target.
- Fedora WSL belongs to `platform_fedora`, `workstation_dev_fedora`, and the shared WSL layer. It must not receive Flatpak or Snap runtimes.
- Fedora WSL installs Mise from the official `jdxcode/mise` COPR and uses its pinned Temurin Java 11 JDK; update the declared Mise version deliberately.
- 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.
- `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.
- 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.
- 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.
## Atlas NAS Notes
- `atlas` is a remote Rocky Linux 9 NAS. Keep its connection, LAN, pool and mountpoint values in
`host_vars/atlas.yml`. Bootstrap it once with `-e atlas_connection_username=<existing-admin>`;
subsequent runs use the dedicated Atlas account.
- The pool is pre-existing: never add pool creation, disk partitioning, RAIDZ creation, rollback,
or destruction to the Atlas profile.
- `atlas_manage_storage` and `atlas_manage_firewall` remain false until their placeholders are
replaced; only then may the profile manage datasets, shares and LAN-restricted firewall rules.
- Atlas requires `vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash` for Cockpit
and, when storage is enabled, `vault_atlas_samba_password`. Never print these values.
- Atlas uses NFSv4 for Linux and SMB for Windows/WSL, restricted to the configured LAN. Snapshot,
Borg/Hetzner offsite backup, Prometheus pull and USB backup automation are intentionally deferred.
- The pool is normally pre-existing. A one-time bootstrap may create it only when `atlas_create_pool=true`
is explicitly supplied and `atlas_zpool_disks` contains exactly four real `/dev/disk/by-id/...` paths.
Never partition, force, destroy, roll back, or modify the vdev layout of an existing pool.
- `atlas_manage_storage`, `atlas_manage_sharing`, and `atlas_manage_firewall` are enabled in Atlas host vars as
the declared steady state; set one false only for a deliberate suspension. `atlas_manage_media_stack` remains false
until the future rootful Immich stack has its required Vault inputs and target validation.
- Atlas requires `vault_atlas_admin_password_hash` for Cockpit and, while sharing is enabled,
`vault_atlas_samba_password`. The future rootful media stack also requires
`vault_atlas_immich_db_password`. Never print these values.
- Atlas creates the complete declared hierarchy only under the verified existing or explicitly bootstrapped pool: `work`, `archive`,
`archive/app_data`, `archive/app_data/navidrome`, `archive/app_data/syncthing`, `media`, `media/music`,
`media/photobook`, `backups`, `backups/services`, and `backup_prometheus`. `backups/services` has a `500G`
refreservation. There is no separate legacy `zpool/syncthing` dataset.
- The `immich` system account is fixed to UID/GID `1100`, has no login shell or `wheel` membership, and receives only
the `video` and `render` supplementary groups. Immich's rootful Quadlets run as `1100:1100`; Server and ML receive
`/dev/dri`, while the Photobook external library is read-only at `/external/photobook`.
- Atlas applies persistent kernel network hardening: redirects and source routes are rejected, martians logged, reverse-path filtering remains loose for WireGuard, and IPv4 forwarding is disabled. SSH permits only the declared administrator using public-key authentication; root login, passwords, agent and remote forwarding
are disabled, while local forwarding remains available for private administrative tunnels. Photobook is exported only to the configured Aegis IP with all access squashed to UID/GID
`1100`. Targeted SELinux is enforced persistently; a required reboot is reported but never initiated automatically. The primary LAN interface is assigned explicitly to the managed firewalld zone, and firewall rules are applied before NFS or SMB are started; their service state and TCP listeners are then verified. SMB3 exposes `Archive` to Vault-backed authorized accounts on mandatory encrypted, signed SMB3 over TCP/445 only and admits the configured LAN without host-specific exclusions.
- Atlas NPM and Immich share a rootful Podman network. NPM publishes HTTP/HTTPS, but its administration port remains
bound to `127.0.0.1:81`; do not expose it directly to the LAN or Internet.
- `profile_backend_phase1` is limited to rootless Navidrome and Syncthing user Quadlets on Atlas. Official Navidrome
`0.63.2` uses SQLite below `/data` and does not support `ND_DATABASE_URL` or an external PostgreSQL backend; do not
recreate the obsolete Prometheus `navidromedb` service. The role requires the storage role's `zpool/media/music`,
`zpool/archive/app_data`, `zpool/archive/app_data/navidrome`, and `zpool/archive/app_data/syncthing` datasets at
their exact paths. It never creates the pool.
- Keep `backend_phase1_start_services` false until the stopped Prometheus Navidrome data directory has been copied to
Atlas and its SQLite database verified. The playbook renders the target but never migrates or deletes application
data; after cutover, set the flag true to enable and start Navidrome and Syncthing.
- Phase 1 must not change Prometheus' existing NPM deployment. NPM continues to be managed exactly by `profile_server`;
use `10.0.0.2:4533` for Navidrome and `10.0.0.2:8384` for the Syncthing GUI. Syncthing does not use host networking:
its GUI, transfer, QUIC and discovery ports are explicitly published only on `10.0.0.2`; native transfer/discovery does not use the HTTP proxy.
- `wireguard_overlay` manages the required `wg0` path between Prometheus and Atlas, persists private keys only on their
respective hosts, exchanges only derived public keys, and verifies a real peer handshake. The initial run must
include both hosts. Prometheus
opens `51820/udp`; after creating the WireGuard firewalld zone, restore Prometheus' rootful Podman networking with
`podman network reload --all` so the existing proxy stack retains container DNS. The Atlas backend role admits
service ports only in the WireGuard firewalld zone.
## Atlas NAS TODO
- Replace every Atlas `CHANGEME` value, provide the required Vault variables and validate the first
remote bootstrap on the real Rocky Linux 9 host. Enable `atlas_manage_storage` first and
`atlas_manage_firewall` only after confirming the pool, mountpoints, LAN subnet and firewalld zone.
- Provide the required Vault variables and validate the first remote bootstrap on the real Rocky Linux 9 host.
Before the first apply, confirm the pool, mountpoints, LAN subnet and firewalld zone. Keep
`atlas_manage_media_stack` disabled until `/dev/dri`, the container paths and the Immich database secret are validated.
- Validate the complete baseline on the target: OpenZFS kmod loading, existing pool import, dataset
mounts, SSH reconnect, Cockpit and all selected 45Drives plugins, NFSv4, SMB and Syncthing.
- Finalize dataset properties and the shared UID/GID, group and POSIX ACL model; test the same files
@@ -110,10 +179,11 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
or manual operations, not as the only source of configuration, and never automate snapshot rollback.
- Manage the Syncthing star topology, device IDs, folders, folder modes, ignore rules and protected GUI
or API access for the selected clients.
- Add Tailscale or WireGuard and corresponding LAN/VPN-only firewalld rules before enabling remote
services; never expose SSH, Cockpit, NFS, SMB or Syncthing through public port forwarding.
- Add the least-privilege Prometheus backup flow: remote dump generation, dedicated SSH identity,
pinned host key, atomic pull, verification, retention and an Atlas systemd service/timer.
- Validate the managed WireGuard path and its LAN/VPN-only firewalld rules before enabling remote services;
never expose SSH, Cockpit, NFS, SMB or Syncthing through public port forwarding.
- Add the Atlas-initiated least-privilege Prometheus backup pull: Prometheus exposes only prepared
read-only dumps through a dedicated account and Atlas retains the private SSH key, pinned host key,
atomic pull, verification, retention and systemd service/timer.
- Add the encrypted offsite backup with Borg to a Hetzner Storage Box: use a dedicated SSH identity,
pin the host key, keep Borg repository credentials and encryption material in Vault, use
snapshot-consistent sources, and manage retries, logging, pruning, repository checks and restores.
@@ -127,10 +197,12 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
are validated; do not make it a dependency of the Atlas baseline.
## Coding Agent Notes
- Shared agent packages live in `ai_agents_npm_packages` in `ansible/inventory/group_vars/all.yml`.
- Shared agent definitions and lifecycle flags live in `ai_agents` in `ansible/inventory/group_vars/all.yml`.
- Shared agent dotfiles live in `ai_agents_dotfiles`; rendered configs live in `ai_agents_templates`.
- Desktop and WSL profiles consume the shared agent package list; do not duplicate package entries in profile-specific vars.
- `dotfiles_common` copies common dotfiles plus `ai_agents_dotfiles`, then renders `ai_agents_templates`.
- Every `ai_agents.<agent>` entry has independent `install_enabled`, `deploy_enabled`, and `uninstall_enabled` flags. Installation and removal must not both be true for the same agent; the common pre-task fails before changes when they conflict.
- Fedora, Void desktop, and WSL workstation profiles consume the shared agent definitions; do not duplicate package entries in profile-specific vars. IBM Bob on the workstation follows its own flags.
- `dotfiles_common` deploys `ai_agents_dotfiles` and renders `ai_agents_templates` only when deployment is enabled.
- Removal is limited to the managed npm packages and `/usr/local/bin/bob`; never remove agent dotfiles, instructions, credentials, or user data.
- Keep `.config/ai/` as the common instruction source; update agent-specific entrypoints to reference it rather than duplicating instruction text.
## Tooling Notes
@@ -144,3 +216,19 @@ The dotfile vars follow the same split: `desktop_common_dotfiles` carries mode-i
- Keep `README.md` and `AGENTS.md` aligned when workflows materially change.
- If you add a new operational area, also add the narrowest validation command for it.
- Call out checks you could not run and any follow-up verification needed.
## Aegis Fedora IoT Notes
- `aegis` is a remote Fedora IoT Raspberry Pi 4 node. Bootstrap it once with
`ansible/bootstrap/aegis.bu`; the remaining configuration is applied by `profile_aegis` over SSH.
- Fedora IoT is immutable. Do not add it to mutable Fedora package or shared dotfile roles.
- `profile_aegis` owns rootful Podman Quadlets, persistent container state under `/var/lib`, the
Podman auto-update timer, LAN-restricted firewalld rules, and SSH hardening. Keep
`aegis_lan_subnet` and `aegis_adguard_web_port` host-specific; SSH permits only the declared
key-authenticated users, never root or password authentication. Keep Apple IDs and other
credentials in Vault and use `no_log` for their rendering.
- `aegis_adguard_web_port` defaults to `80`. The initial AdGuard Home wizard port `3000` is intentionally unmanaged: open and close it manually only while
completing initial setup. Disable the local systemd-resolved stub through `profile_aegis` before
AdGuard binds port 53; keep
`/etc/resolv.conf` linked to `/run/systemd/resolve/resolv.conf` so Aegis retains router-provided DNS.
- iCloudPD requires post-deployment interactive MFA initialization; its cookie/configuration state is
persisted in `/var/lib/icloudpd/config`.

View File

@@ -33,7 +33,6 @@ infra/
│ ├── common/
│ ├── desktop/
│ ├── fedora/
│ ├── ubuntu/
│ ├── server/
│ ├── workstation/
│ ├── workstation_dev_wsl/
@@ -56,8 +55,8 @@ Il repository è diviso in due componenti principali:
# Macchine gestite
Il repository modella attualmente host Fedora/GNOME, una workstation Fedora WSL, un server Ubuntu e
un NAS Rocky Linux 9. La composizione resta separata in assi indipendenti:
Il repository modella attualmente host Fedora/GNOME, una workstation Fedora WSL, un server Rocky
Linux 9 e un NAS Rocky Linux 9. La composizione resta separata in assi indipendenti:
```text
common user environment
@@ -74,8 +73,8 @@ Matrice target:
| ikaros | Fedora | Personal workstation | GNOME |
| nymph | Fedora | Desktop laptop | GNOME |
| deadalus | Fedora WSL | Workstation dev | — |
| prometheus | Ubuntu | Server | — |
| atlas | Rocky 9 | NAS | — |
| prometheus | Rocky Linux | Server | — |
| atlas | Rocky Linux | NAS | — |
Regola operativa:
@@ -93,7 +92,7 @@ Nota sullo stato attuale del playbook principale:
- `ansible/site.yml` applica oggi in automatico Fedora/GNOME su `ikaros` e `nymph`
- `ansible/site.yml` applica il profilo Fedora WSL alla workstation `deadalus`
- `ansible/site.yml` applica anche il profilo `ubuntu_server` con baseline apt, systemd, dotfiles server e firewall UFW
- `ansible/site.yml` applica il profilo server Rocky a `prometheus` con DNF, systemd, dotfiles server e firewalld
- `ansible/site.yml` applica il profilo NAS Rocky su `atlas` tramite SSH remoto
## Desktop
@@ -144,6 +143,7 @@ Lo stato attuale del profilo workstation include:
- installazione pacchetti base Fedora via dnf
- installazione e configurazione di Docker dal repository ufficiale
- installazione di Mise dal COPR ufficiale con JDK Eclipse Temurin Java 11 fissato
- gestione dei dotfiles workstation e rendering dei template dev condivisi
- preparazione di Fedora WSL con `systemd` per il toolchain di sviluppo
- attivazione del firewall `firewalld` sui target Fedora che dichiarano regole host-specifiche
@@ -163,7 +163,7 @@ Le applicazioni Windows sono installate e gestite manualmente; il profilo WSL no
Sistema operativo:
- Ubuntu LTS
- Rocky Linux 9
Configurazione:
@@ -177,12 +177,54 @@ Profilo orientato a servizi server e gestione di dotfiles dedicati.
Lo stato attuale del profilo server include:
- installazione pacchetti base Ubuntu via apt
- installazione e configurazione di Docker dal repository ufficiale
- installazione pacchetti Rocky via DNF, EPEL e CRB
- installazione di Podman e podman-compose
- abilitazione dei servizi systemd dichiarati in inventory/group vars
- copia dei dotfiles server e rendering dei template server, incluso il `docker-compose.yml` dello stack servizi
- attivazione del firewall UFW con regola SSH esplicita
- apertura delle porte Syncthing `22000/tcp`, `22000/udp` e `21027/udp`, lasciando la GUI non esposta direttamente su UFW
- copia dei dotfiles server e rendering del `docker-compose.yml` per Nginx Proxy Manager e Gitea,
piu l'unita `podman-compose-server` (attivazione manuale)
- 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`.
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.
### 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.
Utente del profilo server:
@@ -204,14 +246,16 @@ ansible-playbook ansible/site.yml --limit prometheus -e server_username=myuser -
## NAS
`atlas` e un NAS Rocky Linux 9 raggiunto tramite SSH. Il pool ZFS esiste gia: il profilo gestisce
solo i dataset figli e non deve mai creare, partizionare, distruggere, fare rollback o modificare il
pool. I client Linux usano NFSv4, quelli Windows/WSL SMB; entrambi restano limitati alla LAN
`atlas` e un NAS Rocky Linux 9 raggiunto tramite SSH. Normalmente il pool ZFS esiste gia e il profilo
gestisce solo i dataset figli. Un bootstrap RAIDZ2 una tantum e disponibile solo con conferma esplicita
(`atlas_create_pool=true`) e quattro percorsi reali e verificati `/dev/disk/by-id/...` in
`atlas_zpool_disks`. Non partiziona, forza, distrugge, esegue rollback o modifica il layout vdev di un
pool esistente. I client Linux usano NFSv4, quelli Windows/WSL SMB; entrambi restano limitati alla LAN
configurata.
Per il primo avvio sostituire i placeholder Atlas e fornire
`vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash` e
`vault_atlas_samba_password`. Eseguire il bootstrap tramite l'amministratore esistente:
Per il primo avvio fornire `vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash`,
`vault_atlas_samba_password` e `vault_atlas_immich_db_password`. Eseguire il bootstrap tramite
l'amministratore esistente:
```bash
ansible-playbook ansible/site.yml --limit atlas \
@@ -219,13 +263,73 @@ ansible-playbook ansible/site.yml --limit atlas \
```
`vault_atlas_admin_password_hash` deve essere un hash compatibile con `/etc/shadow`, non una
password Cockpit in chiaro. Le esecuzioni successive usano `atlas_admin_username`. Abilitare
`atlas_manage_storage` solo dopo aver verificato pool e mountpoint esistenti; abilitare
`atlas_manage_firewall` solo dopo aver verificato subnet LAN e zona firewalld attiva.
password Cockpit in chiaro. Le esecuzioni successive usano `atlas_admin_username`. Atlas dichiara
abilitati storage, condivisioni e regole firewall LAN. Prima della prima applicazione verificare pool e
mountpoint esistenti, subnet LAN e zona firewalld attiva. `atlas_manage_media_stack` resta disabilitato
finche non saranno validati `/dev/dri`, i percorsi dei container e il segreto del database Immich.
Restano da implementare retention delle snapshot, topologia Syncthing, VPN, pull da Prometheus,
backup cifrati con Borg su una Hetzner Storage Box, backup USB, monitoraggio e test di disaster
recovery. Il backlog operativo dettagliato e in `AGENTS.md`.
Con la gestione storage attiva, Atlas crea l'intera gerarchia sotto il pool `zpool` esistente o creato esplicitamente:
`work`, `archive`, `archive/app_data`, i dataset applicativi separati
`archive/app_data/navidrome` e `archive/app_data/syncthing`, `media`, `media/music`,
`media/photobook`, `backups`, `backups/services` e `backup_prometheus`. I dataset applicativi e
di archivio usano `zstd`; media, Syncthing e backup dei servizi usano `lz4`;
`backups/services` mantiene inoltre una `refreservation` di `500G`.
Atlas impone SELinux targeted in modo persistente e segnala, senza avviarlo, l’eventuale reboot necessario per attivarlo. Assegna esplicitamente l’interfaccia LAN primaria alla zona firewalld gestita e applica hardening persistente del kernel di rete: rifiuta redirect e source-route, registra i martian, usa reverse-path filtering loose per WireGuard e disabilita il forwarding IPv4. SSH consente solo l’amministratore dichiarato tramite chiave pubblica; root, password, agent e forwarding
remoto sono disabilitati, mentre il forwarding locale resta disponibile per tunnel amministrativi privati. SMB3 pubblica `Archive` solo agli account Samba configurati con password in Vault e
ammette la LAN configurata su SMB3 cifrato e firmato, esclusivamente su TCP/445. NFSv4 esporta soltanto
`media/photobook` all'IP configurato di Aegis su TCP/2049, con `all_squash` verso UID/GID anonimi `1100`.
L'account di sistema `immich` usa UID/GID `1100`, shell senza login, nessuna appartenenza a `wheel` e
i gruppi supplementari `video` e `render`. I Quadlet rootful di Immich Server, ML, cache compatibile
Redis, PostgreSQL e NPM condividono una rete Podman. Immich viene eseguito come `1100:1100`; Server e
ML ricevono `/dev/dri` e Photobook e montato in sola lettura su `/external/photobook`. NPM pubblica `80` e
`443`, mentre l'amministrazione resta vincolata a `127.0.0.1:81` per l'accesso tramite tunnel SSH.
La fase 1 e limitata ai Quadlet utente rootless di Navidrome e Syncthing su Atlas. E abilitata nella
configurazione host di Atlas e puo essere impostata a `false` solo per una sospensione intenzionale. Navidrome ufficiale `0.63.2` usa il database SQLite sotto `/data` e
non supporta `ND_DATABASE_URL` ne un backend PostgreSQL esterno. Il servizio obsoleto `navidromedb`
e quindi rimosso da Prometheus invece di essere replicato su Atlas. Il ruolo deriva i percorsi dal
pool `zpool`, montato in `/zpool`: musica in sola lettura da `/zpool/media/music`, stato
applicativo Navidrome e `navidrome.db` in `/zpool/archive/app_data/navidrome` e dati Syncthing in
`/zpool/archive/app_data/syncthing`. `profile_atlas` crea questi dataset quando
`atlas_manage_storage` e attivo; il ruolo backend verifica i mountpoint esatti prima di avviare i
container. Il ruolo backend non crea mai il pool. Il ruolo separato `wireguard_overlay`
gestisce `wg0` tra Prometheus (`10.0.0.1`) e Atlas (`10.0.0.2`), genera una sola volta le chiavi
private sui rispettivi host e scambia tramite Ansible soltanto quelle pubbliche. Solo Prometheus apre
pubblicamente `51820/udp`. Le porte backend sono ammesse esclusivamente nella zona firewalld WireGuard.
`backend_phase1_start_services` resta falso durante il trasferimento dello stato applicativo, quindi
la prima esecuzione reale del backend genera i Quadlet senza creare un database Atlas vuoto. Dopo aver
arrestato Navidrome su Prometheus, copiare l'intera directory `/opt/navidrome/data/` in
`/zpool/archive/app_data/navidrome/`, preservando `navidrome.db` e gli eventuali file SQLite laterali.
Impostare quindi questa variabile a vero e rieseguire il ruolo per abilitare e avviare Navidrome e
Syncthing. Il playbook non copia e non elimina mai i dati applicativi.
Validare e generare i servizi Atlas con:
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags storage
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit prometheus,atlas --tags wireguard
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1
```
Per il cutover, arrestare il vecchio Navidrome prima di copiare la sua directory dati, verificare
l'ownership dell'account `admin` su Atlas e confermare la presenza del database SQLite copiato prima
di impostare `backend_phase1_start_services: true` in `host_vars/atlas.yml`. Conservare i dati sorgente
e il container legacy `navidromedb` fermo finche Navidrome su Atlas e una prova di restore non sono
stati validati.
Restano da completare retention delle snapshot, topologia Syncthing, validazione WireGuard/firewall,
pull di backup da Prometheus, backup cifrati con Borg su una Hetzner Storage Box, backup USB,
monitoraggio e test di disaster recovery. Il backlog operativo dettagliato e in `AGENTS.md`.
---
@@ -273,13 +377,10 @@ I principali ruoli attualmente presenti sono:
| ------------------------- | ----------------------------------- |
| base | configurazione base comune |
| packages_void | installazione pacchetti su Void |
| packages_freebsd | installazione pacchetti su FreeBSD via pkg |
| packages_ubuntu | installazione pacchetti su Ubuntu |
| packages_fedora | installazione pacchetti su Fedora |
| packages_rocky | installazione pacchetti su Rocky Linux 9 |
| services_runit | gestione servizi runit |
| services_systemd | gestione servizi systemd |
| services_freebsd | gestione servizi FreeBSD dichiarati per host |
| profile_desktop_common | bootstrap desktop Void condiviso |
| profile_desktop_gnome | dotfiles desktop condivisi per Fedora/GNOME |
| profile_desktop_sway | sessione desktop sway / SwayFX (Wayland) |
@@ -290,6 +391,8 @@ I principali ruoli attualmente presenti sono:
| profile_workstation_dev_wsl | configurazione WSL condivisa per sviluppo |
| profile_server | configurazione server |
| profile_atlas | configurazione NAS Rocky Linux 9 |
| profile_backend_phase1 | Navidrome e Syncthing rootless su Atlas |
| wireguard_overlay | overlay WireGuard Prometheus/Atlas |
| dotfiles_common | distribuzione dotfiles comuni |
| dotfiles | distribuzione configurazioni utente |
@@ -303,15 +406,16 @@ Il playbook `ansible/site.yml` e attualmente composto da blocchi per asse:
all -> dotfiles_common
platform_void -> packages_void + services_runit
platform_void & graphical_desktop -> profile_desktop_common + profile_desktop_sway + profile_desktop_niri + profile_desktop_host
platform_freebsd -> packages_freebsd + services_freebsd
platform_fedora -> packages_fedora + services_systemd
platform_rocky -> packages_rocky + services_systemd
wireguard_overlay -> wireguard_overlay (dopo platform_rocky)
atlas -> profile_atlas
role_backend_phase1 -> profile_backend_phase1 (dopo atlas)
platform_fedora & role_personal_workstation -> profile_personal_workstation
platform_fedora & desktop_gnome -> profile_desktop_gnome
workstation_dev_fedora -> profile_workstation_dev_common
workstation_dev_wsl -> profile_workstation_dev_wsl (dopo platform_fedora + workstation_dev_fedora)
ubuntu_server -> packages_ubuntu + services_systemd + profile_server
rocky_server -> dotfiles_common + profile_server (dopo platform_rocky)
```
Questo significa che, allo stato attuale:
@@ -320,9 +424,10 @@ Questo significa che, allo stato attuale:
- `nymph` riceve Fedora Workstation/GNOME come target laptop
- il profilo Void resta selezionabile tramite `platform_void + graphical_desktop` per host futuri
- `deadalus` riceve il profilo Fedora WSL tramite play dev dedicati
- il server Ubuntu (`prometheus`) e gestito con pacchetti, servizi, dotfiles server e firewall
- il server Rocky (`prometheus`) e gestito con pacchetti, servizi, dotfiles server e firewalld
- il NAS Rocky (`atlas`) usa un pool ZFS gia esistente, condivisioni NFSv4/SMB limitate alla LAN e Cockpit/45Drives
- lo stack container server include `navidrome`, `postgres`, `gitea`, `nginx-proxy-manager` e `syncthing`, con GUI Syncthing raggiungibile tramite la rete Docker `web`
- lo stack Compose server include soltanto `gitea` e `nginx-proxy-manager`; Navidrome e Syncthing
della fase 1 sono Quadlet rootless su Atlas
# Dotfiles
@@ -334,7 +439,6 @@ dotfiles/
├── desktop
├── server
├── fedora
├── ubuntu
├── workstation
├── workstation_dev_wsl
└── nymph
@@ -392,13 +496,11 @@ Allo stato attuale questo comando:
- distribuisce i dotfiles comuni a tutti gli host
- per `platform_void` applica pacchetti Void e servizi runit
- per `platform_void + graphical_desktop` applica bootstrap desktop condiviso, sessioni Sway/Niri e override specifici per host
- per `platform_freebsd` non applica nulla finche il gruppo resta senza host
- per `platform_fedora` applica pacchetti Fedora e servizi systemd a `ikaros`, `nymph` e `deadalus`
- per `platform_fedora & role_personal_workstation` applica il layer personale a `ikaros`
- per `platform_fedora & desktop_gnome` applica il profilo GNOME a `ikaros` e `nymph`
- per `workstation_dev_wsl` applica i tweak WSL dopo il layer Fedora a `deadalus`, escludendo Flatpak e Snap
- per gli host `ubuntu_server` applica pacchetti Ubuntu, servizi systemd, profilo server, UFW, dotfiles e template dedicati
- per `platform_rocky` applica pacchetti Rocky e servizi systemd ad `atlas`, quindi il profilo NAS dedicato
- per `platform_rocky` applica pacchetti Rocky e servizi systemd ad `atlas` e `prometheus`; quindi applica il profilo NAS ad `atlas` e il profilo server a `prometheus`
- non riavvia automaticamente il display manager
- carica `secrets/vault.yml` solo se presente
- carica `secrets/vault.local.yml` solo se presente, dopo `vault.yml`, cosi gli override locali hanno precedenza
@@ -431,7 +533,9 @@ 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
docker compose -f /opt/docker/server/docker-compose.yml config
podman-compose -f /opt/docker/server/docker-compose.yml config
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff
```
## Tag supportati dal playbook
@@ -448,6 +552,9 @@ Allo stato attuale `ansible/site.yml` espone questi tag:
| --- | --- | --- |
| `always` | pre-task sempre eseguiti, inclusi caricamento vault e validazioni preliminari | common |
| `ai_agents` | installazione agenti AI condivisi | Fedora, WSL |
| `atlas` | account, storage, condivisioni e container Atlas | NAS Atlas |
| `backend_phase1` | Quadlet rootless Navidrome e Syncthing | NAS Atlas |
| `containers` | Quadlet rootful Atlas | NAS Atlas |
| `dotfiles` | distribuzione/configurazione dotfiles | tutti i profili |
| `dotfiles:common` | dotfiles comuni condivisi | common, workstation, server |
| `dotfiles:desktop` | dotfiles desktop | desktop Void, Fedora/GNOME |
@@ -461,16 +568,21 @@ Allo stato attuale `ansible/site.yml` espone questi tag:
| `fzf` | configurazione FZF | dotfiles comuni |
| `git` | configurazione Git e GPG desktop | Fedora/GNOME, desktop Void |
| `gnome` | configurazione host GNOME | Fedora/GNOME desktop |
| `immich` | account e Quadlet Immich | NAS Atlas |
| `sway` | sessione/configurazione sway / SwayFX (Wayland) | desktop Void |
| `niri` | sessione/configurazione Niri (Wayland) | desktop Void |
| `npm` | installazione pacchetti npm globali | Fedora/GNOME, desktop Void, WSL |
| `nvidia` | componenti NVIDIA desktop | desktop Void |
| `packages` | installazione e aggiornamento pacchetti | tutti i profili |
| `podman` | integrazione Podman Compose e Quadlet rootless | server |
| `portal` | configurazione xdg-desktop-portal | desktop Void |
| `services` | gestione servizi runit/systemd | tutti i profili |
| `sharing` | condivisioni NFSv4 e SMB3 | NAS Atlas |
| `storage` | dataset ZFS figli | NAS Atlas |
| `theme` | configurazione del tema GTK/Qt | desktop Void |
| `tmux` | configurazione e plugin tmux | desktop Fedora/Void, WSL |
| `vim` | configurazione Vim | dotfiles comuni |
| `wireguard` | overlay WireGuard Prometheus/Atlas | Prometheus, NAS Atlas |
| `wsl` | bootstrap e configurazione WSL | WSL |
Esempi pratici:
@@ -507,10 +619,6 @@ I gruppi legacy `void` e `desktop` sono parent di compatibilita, quindi un host
in `platform_void` e `graphical_desktop` continua a ricevere anche le variabili
Void e desktop esistenti.
Per prove in VM sono disponibili gruppi di esempio in
`ansible/inventory/examples/platform-test-hosts.yml`, da passare esplicitamente
con `-i` insieme all'inventory principale.
Per il flusso mail desktop esiste inoltre uno script dedicato:
```bash

287
README.md
View File

@@ -20,7 +20,6 @@ infra/
│ ├── common/
│ ├── desktop/
│ ├── fedora/
│ ├── ubuntu/
│ ├── server/
│ ├── workstation/
│ ├── workstation_dev_wsl/
@@ -36,8 +35,9 @@ infra/
## Managed machines
The repo currently covers Fedora/GNOME desktops, one Fedora WSL workstation, an Ubuntu server, and
a Rocky Linux 9 NAS. Configuration is layered instead of being tied to host names:
The repo currently covers Fedora/GNOME desktops, one Fedora WSL workstation, a Fedora IoT LAN
node, a Rocky Linux 9 server, and a Rocky Linux 9 NAS. Configuration is layered instead of being tied
to host names:
```text
common user environment
@@ -52,8 +52,9 @@ common user environment
| `ikaros` | Fedora | Personal workstation | GNOME |
| `nymph` | Fedora | Desktop laptop | GNOME |
| `deadalus` | Fedora WSL | Development workstation | — |
| `prometheus` | Ubuntu | Server | — |
| `atlas` | Rocky 9 | NAS | — |
| `aegis` | Fedora IoT | Always-on LAN node | — |
| `prometheus` | Rocky Linux | Server | — |
| `atlas` | Rocky Linux | NAS | — |
```text
ikaros must be boring
@@ -88,7 +89,7 @@ The workstation receives two layers:
- Fedora development setup through `workstation_dev_fedora`.
- WSL setup with `systemd` through `workstation_dev_wsl`.
That gives it Fedora packages through DNF, Docker from the official repository, shared workstation dotfiles and templates, tmux helpers, and WSL systemd configuration. Windows applications are installed manually; the WSL profile does not manage Python remoting components for them.
That gives it Fedora packages through DNF, Docker from the official repository, Mise from its official COPR repository with a pinned Eclipse Temurin Java 11 JDK, shared workstation dotfiles and templates, tmux helpers, and WSL systemd configuration. Windows applications are installed manually; the WSL profile does not manage Python remoting components for them.
### WSL workflow
@@ -99,9 +100,28 @@ That gives it Fedora packages through DNF, Docker from the official repository,
## Server
`prometheus` is the Ubuntu LTS server. It has no graphical environment and gets server-specific dotfiles and templates.
`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.
The server profile installs Ubuntu packages, Docker from the official repository, declared systemd services, UFW rules, and the server Compose stack. Syncthing ports `22000/tcp`, `22000/udp`, and `21027/udp` are opened; the Syncthing GUI is not directly opened in UFW.
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. Navidrome and Syncthing belong to Atlas;
official Navidrome uses SQLite instead. Applying the profile does not stop or remove legacy
containers and does not delete `/opt/postgres/data`.
Firewalld enables SSH, Cockpit (`9090/tcp`), HTTP and HTTPS. Nginx Proxy Manager publishes only
`80/tcp` and `443/tcp`; its administration interface is bound to `127.0.0.1:81` and can be reached
from Ikaros or Nymph with the `npm-tunnel` Bash alias. Nextcloud remains disabled and the profile
does not provision any `/srv/nextcloud` directories.
The Atlas phase-one work does not change this NPM deployment or its persistent data. Once WireGuard
and the Atlas services are active, configure the current NPM proxy hosts with Navidrome upstream
`http://10.0.0.2:4533` and Syncthing GUI upstream `http://10.0.0.2:8384`. Only the Syncthing web GUI
uses NPM; synchronization traffic remains on explicitly published native ports bound only to the Atlas
WireGuard address. Configure both Syncthing authentication and an appropriate NPM access policy before
publishing its GUI.
Server identity comes from `server_username`, `server_user_group`, and `server_user_home` in `ansible/inventory/group_vars/server.yml`. `server_username` defaults to `username`, but it can be overridden, for example:
@@ -112,30 +132,176 @@ ansible-playbook ansible/site.yml --limit prometheus \
-e server_user_home=/srv/myuser
```
## NAS
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.
`atlas` is a Rocky Linux 9 NAS reached through SSH. Its pool already exists: the profile only
manages child datasets and must never create, partition, destroy, roll back, or otherwise alter the
pool itself. Linux clients use NFSv4 and Windows/WSL clients use SMB; both are restricted to the
configured LAN.
### DuckDNS
For the first run, replace the Atlas placeholders and provide
`vault_atlas_authorized_ssh_keys`, `vault_atlas_admin_password_hash`, and
`vault_atlas_samba_password`. Bootstrap the host through its existing administrator:
`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 atlas \
-e atlas_connection_username=<existing-admin>
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns --check --diff
ansible-playbook ansible/site.yml --limit prometheus --tags duckdns
```
`vault_atlas_admin_password_hash` must be an `/etc/shadow`-compatible hash, not a clear-text
Cockpit password. Subsequent runs use `atlas_admin_username`. Enable
`atlas_manage_storage` only after checking the existing pool and mountpoints; enable
`atlas_manage_firewall` only after checking the LAN subnet and active firewalld zone.
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.
Snapshot retention, Syncthing topology, VPN access, Prometheus pulls, encrypted Borg backups to a
Hetzner Storage Box, USB backup, monitoring, and disaster-recovery tests remain follow-up work. The
detailed operational backlog is kept in `AGENTS.md`.
### 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.
## DNS Filter
`aegis` is a Raspberry Pi 4 running Fedora IoT. Generate Ignition from
`ansible/bootstrap/aegis.bu` with the included Podman/Butane helper, then write the SD card with
`arm-image-installer`:
```bash
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
firewalld rules, SSH key-only access for `pi`, and `wake-ikaros`. Set the host-local
`aegis_lan_subnet` and `aegis_adguard_web_port` values before applying it. The playbook permits
AdGuard Home HTTP on port `80`; the initial wizard port `3000` is intentionally unmanaged and must be
opened and closed manually during initial setup. The profile disables the local systemd-resolved DNS
stub and points `/etc/resolv.conf` to its full resolver data, freeing port 53
for AdGuard while retaining DNS learned from the router. Define
`vault_aegis_icloudpd_apple_id` in Vault before applying it. iCloudPD still requires interactive MFA
initialization after its first deployment.
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
account. Keep the inventory on `pi` until the existing node has been replaced.
Validate the profile before deployment:
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit aegis --check --diff --ask-become-pass
```
## NAS
`atlas` is a Rocky Linux 9 NAS reached through SSH. Normally its pool already exists and the profile
only manages child datasets. A one-time RAIDZ2 bootstrap is available only with explicit confirmation
(`atlas_create_pool=true`) and exactly four verified `/dev/disk/by-id/...` paths in `atlas_zpool_disks`.
It never partitions, forces, destroys, rolls back, or changes the vdev layout of an existing pool. Linux
clients use NFSv4 and Windows/WSL clients use SMB; both are restricted to the configured LAN.
For the first run, provide `vault_atlas_admin_password_hash`, `vault_atlas_samba_password`, and
`vault_atlas_immich_db_password`. Bootstrap the host through its
existing administrator. Open `51820/udp` towards Prometheus in the provider firewall first, then
include both WireGuard peers in the same idempotent playbook run:
```bash
ansible-playbook ansible/site.yml --limit prometheus,atlas \
-e atlas_connection_username=<existing-admin> \
-e atlas_create_pool=true
```
The explicit pool gate is safe to repeat: the role creates the RAIDZ2 pool only when it is absent.
WireGuard waits for a real peer handshake before the play continues.
`vault_atlas_admin_password_hash` must be an `/etc/shadow`-compatible hash, not a clear-text
Cockpit password. Subsequent runs use `atlas_admin_username`. Atlas declares storage, sharing, and its
LAN firewall rules enabled. Before the first apply, check the existing pool and mountpoints, LAN subnet,
and active firewalld zone. `atlas_manage_media_stack` remains disabled until `/dev/dri`, the container
paths, and the Immich database secret are validated. Atlas reads its declared SSH public keys from
separate files below `~/.ssh/authorized_keys.d/`.
With storage management enabled, Atlas creates the complete dataset hierarchy below the existing or
explicitly bootstrapped `zpool`: `work`, `archive`, `archive/app_data`, the separate `archive/app_data/navidrome` and
`archive/app_data/syncthing` application datasets, `media`, `media/music`, `media/photobook`,
`backups`, `backups/services`, and `backup_prometheus`. Application/archive datasets use `zstd`,
while media, Syncthing and service-backup datasets use `lz4`; `backups/services` also has a `500G`
refreservation. Atlas enforces targeted SELinux persistently and reports, without initiating, any reboot required to activate it. It assigns its primary LAN interface explicitly to the managed firewalld zone and applies persistent kernel network hardening: redirects and source routes are rejected, martians logged, reverse-path filtering remains loose for WireGuard, and IPv4 forwarding is disabled. SSH permits only the declared administrator using public-key authentication; root login, passwords,
agent and remote forwarding are disabled, while local forwarding remains available for private administrative tunnels. SMB3 exposes `Archive` only to the configured Vault-backed
Samba accounts on encrypted, signed SMB3 over TCP/445 only and admits the configured LAN without host-specific
exclusions. NFSv4 exports only `media/photobook` to the configured Aegis IP over TCP/2049, using
`all_squash` with anonymous UID/GID `1100`.
The `immich` system account is fixed to UID/GID `1100`, has no login shell or `wheel` membership, and
receives `video` and `render` access. The rootful Immich Server, ML, Redis-compatible cache, PostgreSQL,
and NPM Quadlets share one Podman network. Immich runs as `1100:1100`; Server and ML receive `/dev/dri`,
and Photobook is mounted read-only at `/external/photobook`. NPM publishes ports `80` and `443`; its
administration interface remains restricted to `127.0.0.1:81` for SSH-tunnel access.
Phase 1 is limited to rootless Navidrome and Syncthing user Quadlets on Atlas. It is enabled in the
Atlas host configuration and can be set to `false` only for a deliberate suspension. Official Navidrome `0.63.2` uses its SQLite database below `/data`; it does
not support `ND_DATABASE_URL` or an external PostgreSQL backend. The obsolete `navidromedb` service
was therefore removed from Prometheus instead of being reproduced on Atlas. The role derives all
storage paths from the `zpool` mounted at `/zpool`: music is read-only at
`/zpool/media/music`, Navidrome application state and `navidrome.db` are stored at
`/zpool/archive/app_data/navidrome`, and Syncthing persists at
`/zpool/archive/app_data/syncthing`. `profile_atlas` creates these datasets when
`atlas_manage_storage` is enabled; the backend role verifies their exact mountpoints before starting
containers. The backend role never creates the pool. The separate `wireguard_overlay` role manages `wg0`
between Prometheus (`10.0.0.1`) and Atlas (`10.0.0.2`), generating private keys once
on their respective hosts and exchanging only public keys through Ansible. Prometheus alone opens
`51820/udp` publicly. When the WireGuard zone is created, Ansible reloads firewalld and immediately
reloads Prometheus' rootful Podman networks so the existing proxy stack retains container DNS and
connectivity. Backend ports are admitted only in the WireGuard firewalld zone.
`backend_phase1_start_services` stays false during the application-state transfer, so the first real
backend run renders the Quadlets without creating an empty Atlas database. After stopping Navidrome
on Prometheus, copy the complete `/opt/navidrome/data/` directory into
`/zpool/archive/app_data/navidrome/`, preserving `navidrome.db` and any SQLite sidecar files. Then set
this variable to true and rerun the role to enable and start Navidrome and Syncthing. The playbook
never copies or deletes application data.
Validate and render the Atlas services with:
```bash
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags storage
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit prometheus,atlas --tags wireguard
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1 --check --diff
ANSIBLE_LOCAL_TEMP=/tmp/ansible-local \
ansible-playbook ansible/site.yml --limit atlas --tags backend_phase1
```
For the cutover, stop the old Navidrome writer before copying its data directory, verify ownership by
the Atlas `admin` account and confirm that the copied SQLite database is present before changing
`backend_phase1_start_services: true` in `host_vars/atlas.yml`. Keep the source data and the stopped
legacy `navidromedb` container until Navidrome on Atlas and a restore test have been validated.
Snapshot retention, Syncthing topology, WireGuard/firewall validation, Prometheus backup pulls,
encrypted Borg backups to a Hetzner Storage Box, USB backup, monitoring, and disaster-recovery tests
remain follow-up work. The detailed operational backlog is kept in `AGENTS.md`.
## How layering works
@@ -165,18 +331,52 @@ Emacs is enabled on Fedora/GNOME and workstation profiles. `dotfiles_common` dep
ansible-playbook ansible/site.yml --limit <host> --tags emacs -e emacs_enabled=true
```
## AI coding agents
The shared npm-managed agents are OpenCode, Claude Code, Codex, Gemini CLI, and
GitHub Copilot; IBM Bob is also managed on `deadalus`. Each agent has its own
lifecycle flags in `ansible/inventory/group_vars/all.yml`, so one agent can be
installed, configured, or removed without affecting the others:
```yaml
ai_agents:
opencode:
npm_package: opencode-ai
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
```
Installation uses the npm `latest` state; deployment copies/renders only the
configuration belonging to each enabled agent. Removal deletes only the selected
managed npm package or, for IBM Bob, `/usr/local/bin/bob`; it preserves dotfiles,
instructions, credentials, and user data. Installation and removal are mutually
exclusive per agent: the playbook fails before making changes when both flags are
true for the same agent. Servers set `ai_agents: {}` and therefore manage none.
Run a focused dry run with:
```bash
ansible-playbook ansible/site.yml --limit ikaros --tags ai_agents --check --diff
ansible-playbook ansible/site.yml --limit deadalus --tags ai_agents --check --diff
```
To preview removal, set `install_enabled: false` and `uninstall_enabled: true`
only in the entry for the agent being removed, then run:
```bash
ansible-playbook ansible/site.yml --limit deadalus --tags ai_agents --check --diff
```
## Main roles
| Role | What it does |
| --- | --- |
| `packages_void` | Installs packages on Void. |
| `packages_freebsd` | Installs packages on FreeBSD with pkg. |
| `packages_ubuntu` | Installs packages on Ubuntu. |
| `packages_fedora` | Installs packages on Fedora. |
| `packages_rocky` | Installs packages on Rocky Linux 9. |
| `services_runit` | Manages runit services. |
| `services_systemd` | Manages systemd services. |
| `services_freebsd` | Manages declared FreeBSD rc services. |
| `profile_desktop_common` | Shared Void desktop bootstrap. |
| `profile_desktop_gnome` | Shared Fedora/GNOME desktop dotfiles. |
| `profile_desktop_sway` | Sway / SwayFX Wayland session. |
@@ -187,32 +387,39 @@ ansible-playbook ansible/site.yml --limit <host> --tags emacs -e emacs_enabled=t
| `profile_workstation_dev_wsl` | WSL development setup. |
| `profile_server` | Server setup. |
| `profile_atlas` | Rocky Linux 9 NAS setup. |
| `profile_backend_phase1` | Rootless Navidrome and Syncthing on Atlas. |
| `wireguard_overlay` | Prometheus/Atlas WireGuard overlay. |
| `profile_aegis` | Fedora IoT always-on LAN node. |
| `dotfiles_common` | Shared user dotfiles. |
## What `site.yml` runs
```text
all -> dotfiles_common
all except platform_rocky -> dotfiles_common
platform_void -> packages_void + services_runit
platform_void & graphical_desktop -> profile_desktop_common + profile_desktop_sway + profile_desktop_niri + profile_desktop_host
platform_freebsd -> packages_freebsd + services_freebsd
platform_fedora -> packages_fedora + services_systemd
platform_rocky -> packages_rocky + services_systemd
wireguard_overlay -> wireguard_overlay (after platform_rocky)
role_aegis -> profile_aegis
atlas -> profile_atlas
role_backend_phase1 -> profile_backend_phase1 (after atlas)
rocky_server -> dotfiles_common + profile_server (after platform_rocky)
platform_fedora & role_personal_workstation -> profile_personal_workstation
platform_fedora & desktop_gnome -> profile_desktop_gnome
workstation_dev_fedora -> profile_workstation_dev_common
workstation_dev_wsl -> profile_workstation_dev_wsl (after platform_fedora + workstation_dev_fedora)
ubuntu_server -> packages_ubuntu + services_systemd + profile_server
```
So, in practice:
- `platform_fedora` configures `ikaros`, `nymph`, and `deadalus`.
- `deadalus` gets the Fedora development layer followed by the WSL layer.
- `ubuntu_server` configures `prometheus`.
- `rocky_server` configures the Rocky 9 server, `prometheus`.
- `atlas` receives the Rocky platform layer and the NAS profile through SSH.
- Empty `platform_void` and `platform_freebsd` groups do nothing until they get a host.
- `aegis` receives only the immutable Fedora IoT profile through SSH; it does not receive
mutable Fedora package or common dotfile roles.
- Empty `platform_void` groups do nothing until they get a host.
- The playbook never restarts the display manager during a run.
- `secrets/vault.yml` and then `secrets/vault.local.yml` are loaded only when present.
@@ -251,6 +458,7 @@ ansible-playbook ansible/site.yml --limit nymph --check --diff
ansible-playbook ansible/site.yml --limit deadalus --check --diff
ansible-playbook ansible/site.yml --limit prometheus --check --diff
ansible-playbook ansible/site.yml --limit atlas --check --diff
ansible-playbook ansible/site.yml --limit aegis --check --diff
ansible-lint ansible/site.yml
ansible-lint ansible/roles
yamllint ansible/
@@ -263,7 +471,8 @@ 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
docker compose -f /opt/docker/server/docker-compose.yml config
podman-compose -f /opt/docker/server/docker-compose.yml config
ansible-playbook ansible/site.yml --limit atlas --tags storage,sharing,containers --check --diff
```
## Tags
@@ -277,7 +486,10 @@ ansible-playbook ansible/site.yml --list-tags
| Tag | Main scope |
| --- | --- |
| `always` | Common pre-tasks, including optional vault loading. |
| `ai_agents` | Shared AI agent installation on Fedora and WSL. |
| `ai_agents` | AI coding-agent install, configuration deployment, and managed-binary removal. |
| `atlas` | Atlas NAS account, storage, sharing, and container configuration. |
| `backend_phase1` | Rootless Atlas Navidrome and Syncthing Quadlets. |
| `containers` | Rootful Atlas Quadlets. |
| `dotfiles` | User configuration across all profiles. |
| `dotfiles:common` | Shared dotfiles. |
| `dotfiles:desktop` | Void and Fedora/GNOME desktop dotfiles. |
@@ -286,10 +498,15 @@ ansible-playbook ansible/site.yml --list-tags
| `dotfiles:workstation` | Personal workstation and WSL dotfiles. |
| `emacs` | Shared Emacs setup and authoring dependencies. |
| `gnome` | Fedora/GNOME desktop configuration. |
| `immich` | Atlas Immich account and Quadlets. |
| `npm` | Global npm packages. |
| `packages` | Package installation and updates. |
| `podman` | Podman Compose and rootless Quadlet integration. |
| `services` | runit and systemd services. |
| `sharing` | Atlas NFSv4 and SMB3 configuration. |
| `storage` | Atlas child ZFS datasets. |
| `tmux` | tmux configuration and plugins. |
| `wireguard` | Prometheus/Atlas WireGuard overlay. |
| `wsl` | WSL bootstrap and configuration. |
## Bootstrapping a new machine

View File

@@ -0,0 +1,57 @@
# One-time bootstrap for Fedora IoT on Aegis (Raspberry Pi 4).
# Generate only: ./generate-aegis-ign.sh
# Generate and write an SD card: ./generate-aegis-ign.sh --write IMAGE DEVICE
# The write mode uses arm-image-installer with the RPi4 target and embeds
# config.ign; it prompts for Wi-Fi credentials unless supplied through its
# WIFI_SSID and WIFI_PASS environment variables.
# For WiFi, the UEFI 'System Table Selection' must be DeviceTree (Esc at boot).
variant: fiot
version: 1.0.0
passwd:
users:
- name: admin
password_hash: "$6$bNDsU0XC5BxNJS5U$ENDGpdOUPJM3wcXBgNESCQkOqqQ9gN72/xEQoJsAI6QdsaY6mD4G5DBVIv7nHwHQOP35IH1oGRl.Uy3R2iUYQ1"
groups:
- wheel
ssh_authorized_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
systemd:
units:
- name: sshd.service
enabled: true
- name: rpm-ostree-install-wifi.service
enabled: true
contents: |
[Unit]
Description=Layer WiFi packages (driver, firmware, NetworkManager-wifi)
Wants=network-online.target
After=network-online.target
Before=zincati.service
ConditionPathExists=!/var/lib/%N.stamp
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/rpm-ostree install -y --allow-inactive NetworkManager-wifi NetworkManager-wwan wpa_supplicant wireless-regdb iw brcmfmac-firmware
ExecStart=/bin/touch /var/lib/%N.stamp
ExecStart=/bin/systemctl --no-block reboot
[Install]
WantedBy=multi-user.target
storage:
files:
- path: /etc/hostname
mode: 0644
contents:
inline: |
aegis
- path: /etc/sysctl.d/99-ip-forward.conf
mode: 0644
contents:
inline: |
net.ipv4.ip_forward = 1
links:
- path: /etc/localtime
target: ../usr/share/zoneinfo/Europe/Rome

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
BUTANE_IMAGE=${BUTANE_IMAGE:-quay.io/coreos/butane:release}
BUTANE_SOURCE=${BUTANE_SOURCE:-"$SCRIPT_DIR/aegis.bu"}
IGNITION_OUTPUT=${IGNITION_OUTPUT:-"$SCRIPT_DIR/config.ign"}
SSH_PUBLIC_KEY=${SSH_PUBLIC_KEY:-"$HOME/.ssh/id_ed25519.pub"}
WIFI_SECURITY=${WIFI_SECURITY:-wpa-psk}
usage() {
cat <<'USAGE'
Usage:
generate-aegis-ign.sh
generate-aegis-ign.sh --write IMAGE DEVICE
Environment overrides:
BUTANE_IMAGE Butane container image (default: quay.io/coreos/butane:release)
BUTANE_SOURCE Butane source path (default: aegis.bu beside this script)
IGNITION_OUTPUT Ignition output path (default: config.ign beside this script)
SSH_PUBLIC_KEY SSH public key passed to arm-image-installer
WIFI_SSID Wi-Fi SSID; prompted if unset in --write mode
WIFI_PASS Wi-Fi password; prompted if unset in --write mode
WIFI_SECURITY Wi-Fi security type (default: wpa-psk)
USAGE
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Error: required command not found: %s\n' "$1" >&2
exit 1
fi
}
read_required() {
prompt=$1
value=$2
if [ -z "$value" ]; then
printf '%s' "$prompt" >&2
IFS= read -r value
fi
if [ -z "$value" ]; then
printf '%s\n' 'Error: a value is required.' >&2
exit 1
fi
printf '%s' "$value"
}
read_secret() {
value=$1
if [ -z "$value" ]; then
printf '%s' 'Wi-Fi password: ' >&2
stty -echo
IFS= read -r value
stty echo
printf '\n' >&2
fi
if [ -z "$value" ]; then
printf '%s\n' 'Error: a value is required.' >&2
exit 1
fi
printf '%s' "$value"
}
write_image=false
case $# in
0)
;;
3)
if [ "$1" != '--write' ]; then
usage >&2
exit 2
fi
write_image=true
IMAGE=$2
DEVICE=$3
;;
*)
usage >&2
exit 2
;;
esac
require_command podman
if [ ! -f "$BUTANE_SOURCE" ]; then
printf 'Error: Butane source not found: %s\n' "$BUTANE_SOURCE" >&2
exit 1
fi
OUTPUT_DIR=$(dirname "$IGNITION_OUTPUT")
if [ ! -d "$OUTPUT_DIR" ]; then
printf 'Error: output directory not found: %s\n' "$OUTPUT_DIR" >&2
exit 1
fi
umask 077
TEMP_OUTPUT=$(mktemp "$OUTPUT_DIR/.config.ign.XXXXXX")
trap 'rm -f "$TEMP_OUTPUT"' EXIT HUP INT TERM
podman run --rm -i "$BUTANE_IMAGE" --strict < "$BUTANE_SOURCE" > "$TEMP_OUTPUT"
mv "$TEMP_OUTPUT" "$IGNITION_OUTPUT"
trap - EXIT HUP INT TERM
printf 'Generated Ignition config: %s\n' "$IGNITION_OUTPUT"
if [ "$write_image" = false ]; then
exit 0
fi
if [ ! -f "$IMAGE" ]; then
printf 'Error: image not found: %s\n' "$IMAGE" >&2
exit 1
fi
if [ ! -b "$DEVICE" ]; then
printf 'Error: target is not a block device: %s\n' "$DEVICE" >&2
exit 1
fi
if [ ! -f "$SSH_PUBLIC_KEY" ]; then
printf 'Error: SSH public key not found: %s\n' "$SSH_PUBLIC_KEY" >&2
exit 1
fi
require_command arm-image-installer
WIFI_SSID=$(read_required 'Wi-Fi SSID: ' "${WIFI_SSID:-}")
WIFI_PASS=$(read_secret "${WIFI_PASS:-}")
printf 'Writing %s to %s.\n' "$IMAGE" "$DEVICE" >&2
sudo arm-image-installer \
--image="$IMAGE" \
--target=rpi4 \
--media="$DEVICE" \
--ignition="$IGNITION_OUTPUT" \
--addkey="$SSH_PUBLIC_KEY" \
--resizefs \
--wifi-ssid="$WIFI_SSID" \
--wifi-pass="$WIFI_PASS" \
--wifi-security="$WIFI_SECURITY"

View File

@@ -1,8 +0,0 @@
---
# Optional inventory overlay for VM tests. It is not loaded by ansible.cfg.
all:
children:
platform_freebsd:
hosts:
freebsdtest:
ansible_connection: local

View File

@@ -90,40 +90,75 @@ common_dotfiles:
dest: .config/bat/
mode: preserve
ai_agents_npm_packages:
- name: "opencode-ai"
state: latest
- name: "@anthropic-ai/claude-code"
state: latest
- name: "@openai/codex"
state: latest
- name: "@google/gemini-cli"
state: latest
- name: "@github/copilot"
state: latest
# AI coding-agent lifecycle controls are independent for every agent.
# Installation and removal are mutually exclusive per agent; the common
# pre-task enforces this before any changes are made.
ai_agents:
opencode:
npm_package: opencode-ai
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
claude_code:
npm_package: "@anthropic-ai/claude-code"
install_enabled: false
deploy_enabled: false
uninstall_enabled: true
codex:
npm_package: "@openai/codex"
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
gemini_cli:
npm_package: "@google/gemini-cli"
install_enabled: true
deploy_enabled: true
uninstall_enabled: false
github_copilot:
npm_package: "@github/copilot"
install_enabled: false
deploy_enabled: false
uninstall_enabled: true
ibm_bob:
install_enabled: true
deploy_enabled: false
uninstall_enabled: false
ai_agents_enabled: true
ai_agents_deploy_enabled_names: >-
{{ ai_agents | dict2items | selectattr('value.deploy_enabled') | map(attribute='key') | list }}
ai_agents_dotfiles:
- name: AI common config
src: .config/ai/
dest: .config/ai/
mode: preserve
agents:
- opencode
- codex
- gemini_cli
- name: Gemini CLI config
src: .gemini/
dest: .gemini/
mode: preserve
agents:
- gemini_cli
- name: OpenCode config
src: .config/opencode/
dest: .config/opencode/
mode: preserve
agents:
- opencode
- name: Claude Code memory
src: .claude/
dest: .claude/
mode: preserve
agents:
- claude_code
ai_agents_templates:
- name: Codex config
src: .codex/config.toml.j2
dest: .codex/config.toml
mode: "0644"
agents:
- codex

View File

@@ -31,8 +31,7 @@ desktop_binary_tools: []
desktop_npm_packages: >-
{{
ai_agents_npm_packages
+ [
[
{'name': '@mermaid-js/mermaid-cli', 'state': 'latest'},
{'name': 'vscode-langservers-extracted', 'state': 'latest'}
]

View File

@@ -11,6 +11,7 @@ fedora_desktop_packages:
# Fedora equivalents of the development/tooling packages previously pulled by
# the Void desktop profile.
- 7zip
- arm-image-installer
- nodejs-bash-language-server
- bluez
- bridge-utils
@@ -85,5 +86,3 @@ fedora_flatpak_packages:
- com.spotify.Client
- org.onlyoffice.desktopeditors
- org.telegram.desktop
fedora_npm_packages: "{{ ai_agents_npm_packages }}"

View File

@@ -0,0 +1,2 @@
---
# Fedora IoT is immutable: do not attach the mutable Fedora package roles.

View File

@@ -1,55 +0,0 @@
---
platform_profile: freebsd
platform_family: freebsd
platform_package_manager: pkg
platform_service_manager: rc
ansible_python_interpreter: /usr/local/bin/python3
vim_plugin_distro_packages:
- fzf
vim_plugin_source_plugins:
- name: fzf
repo: https://github.com/junegunn/fzf.git
version: v0.72.0
- name: fzf.vim
repo: https://github.com/junegunn/fzf.vim.git
version: 34a564c81f36047f50e593c1656f4580ff75ccca
- name: vim-fugitive
repo: https://github.com/tpope/vim-fugitive.git
version: 3b753cf8c6a4dcde6edee8827d464ba9b8c4a6f0
effective_user_group: "{{ user_group }}"
effective_user_home: "/home/{{ effective_username }}"
user_home: "/home/{{ username }}"
freebsd_packages_base:
- bash
- bash-completion
- bat
- curl
- fzf
- git
- jq
- python3
- ripgrep
- rsync
- tmux
- tree
- unzip
- vim
- wget
- zip
- zoxide
freebsd_development_packages:
- clang
- cmake
- gdb
- go
- gmake
- node
- npm
- pkgconf
- py311-pip
- rust
- shellcheck

View File

@@ -5,6 +5,9 @@ platform_package_manager: dnf
platform_service_manager: systemd
rocky_openzfs_release_rpm: https://zfsonlinux.org/epel/zfs-release-3-0.el9.noarch.rpm
rocky_openzfs_gpg_key_url: >-
https://raw.githubusercontent.com/zfsonlinux/zfsonlinux.github.com/master/zfs-release/RPM-GPG-KEY-openzfs-key2
rocky_openzfs_gpg_key_fingerprint: 7DC7 299D CF7C 7FD9 CD87 701B A599 FD5E 9DB8 4141
rocky_syncthing_version: 2.1.3
rocky_syncthing_archive_checksum: sha256:f929eb8e5b72a85543eeeefb2c38f34a68e0c530e70758a2905b78840c76602c
rocky_syncthing_archive_url: >-
@@ -16,17 +19,37 @@ rocky_syncthing_archive_url: >-
~ '.tar.gz'
}}
rocky_manage_openzfs_repo: false
rocky_manage_syncthing_binary: false
rocky_manage_podman: false
rocky_manage_github_cli_repo: false
rocky_github_cli_repo_url: https://cli.github.com/packages/rpm/gh-cli.repo
rocky_github_cli_repo_file: /etc/yum.repos.d/gh-cli.repo
enabled_services:
- firewalld
rocky_common_packages:
- bash-completion
- bat
- fzf
- git
- jq
- tree
- unzip
- vim-enhanced
- wget
- zip
- zoxide
rocky_packages_base:
- cockpit
- dnf-plugins-core
- firewalld
- nfs-utils
- openssh-server
- policycoreutils-python-utils
- rsync
- samba
- selinux-policy-targeted
- sudo
- zfs
rocky_profile_packages: []
rocky_podman_packages: []

View File

@@ -0,0 +1,43 @@
---
rocky_manage_podman: true
rocky_manage_github_cli_repo: true
rocky_profile_packages:
- avahi
- curl
- dmidecode
- dosfstools
- gcc
- gcc-c++
- git-delta
- gh
- gnupg2
- htop
- make
- nmap-ncat
- nodejs
- parted
- pciutils
- pinentry
- ranger
- ripgrep
rocky_podman_packages:
- podman
- podman-compose
enabled_services:
- firewalld
- podman.socket
server_firewall_backend: firewalld
server_firewalld_zone: public
server_firewalld_services:
- ssh
- cockpit
- http
- https
server_firewalld_ports: []
server_sshd_service_name: sshd
server_compose_selinux_mount_option: Z
server_syncthing_enabled: false

View File

@@ -13,12 +13,4 @@ personal_workstation_directories:
- path: "{{ user_home }}/Remotes"
mode: "0755"
personal_workstation_dotfiles:
- src: .gitignore_global
dest: .gitignore_global
mode: "0644"
- src: .themes.gitignore
dest: .themes.gitignore
mode: "0644"
personal_workstation_flatpak_packages: []

View File

@@ -6,7 +6,7 @@ effective_username: "{{ server_username }}"
effective_user_group: "{{ server_user_group }}"
effective_user_home: "{{ server_user_home }}"
server_container_stack_dir: /opt/docker/server
ai_agents_enabled: false
ai_agents: {}
vim_plugins_enabled: false
profile_packages:
@@ -25,6 +25,9 @@ profile_packages:
- rsync
server_dotfiles:
- src: .bashrc.d/20-editor-server.sh
dest: .bashrc.d/20-editor-server.sh
mode: "0644"
- src: .gnupg/gpg-agent.conf
dest: .gnupg/gpg-agent.conf
mode: "0600"
@@ -34,9 +37,6 @@ server_dotfiles:
- src: .themes.gitignore
dest: .themes.gitignore
mode: "0644"
- src: duckdns/
dest: duckdns/
mode: preserve
server_templates:
- src: server/.gitconfig.j2
@@ -50,6 +50,10 @@ server_templates:
no_log: true
server_directories:
- path: "{{ server_user_home }}/.bashrc.d"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0755"
- path: "{{ server_user_home }}/.gnupg"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
@@ -58,14 +62,6 @@ server_directories:
owner: root
group: root
mode: "0755"
- path: /opt/navidrome/data
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0755"
- path: /opt/music
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0755"
- path: /opt/npm/data
owner: root
group: root
@@ -74,14 +70,13 @@ server_directories:
owner: root
group: root
mode: "0755"
- path: /opt/postgres/data
owner: root
group: root
mode: "0755"
- path: /opt/gitea/data
owner: root
group: root
mode: "0755"
server_syncthing_enabled: true
server_syncthing_directories:
- path: /opt/syncthing/config
owner: root
group: root
@@ -94,33 +89,11 @@ server_directories:
owner: "1000"
group: "1000"
mode: "0755"
- path: /srv/nextcloud
owner: root
group: root
mode: "0755"
- path: /srv/nextcloud/data
owner: root
group: root
mode: "0755"
server_ufw_rules:
- rule: allow
name: OpenSSH
- rule: allow
port: "22000"
proto: tcp
comment: Syncthing sync traffic
- rule: allow
port: "22000"
proto: udp
comment: Syncthing QUIC sync traffic
- rule: allow
port: "21027"
proto: udp
comment: Syncthing local discovery
server_sshd_settings:
PermitRootLogin: "no"
server_sshd_allow_users:
- "{{ server_username }}"
server_ssh_authorized_keys: []
server_ssh_authorized_key_directory: "{{ server_user_home }}/.ssh/authorized_keys.d"

View File

@@ -1,37 +0,0 @@
---
ubuntu_packages_base:
- curl
- ufw
- htop
- build-essential
- nodejs
- git-delta
- jq
- ripgrep
ubuntu_manage_docker_repo: true
ubuntu_emacs_packages:
- emacs
- pandoc
- latexmk
- texlive-luatex
- texlive-latex-base
- texlive-latex-recommended
- texlive-fonts-recommended
ubuntu_docker_packages:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
enabled_services:
- ufw
- docker
ubuntu_dotfiles:
- src: .bashrc.d/
dest: .bashrc.d/
mode: preserve

View File

@@ -1,5 +1,3 @@
---
workstation_manage_opencode: true
workstation_manage_ibm_bob: true
workstation_ibm_bob_install_url: "https://bob.ibm.com/download/bobshell.sh"
workstation_npm_packages: "{{ ai_agents_npm_packages + [] }}"

View File

@@ -3,12 +3,25 @@ enabled_services:
- docker
workstation_dev_wsl_packages:
- mise
- pinentry-curses
- python3-pip
- tmux
# Java 11 and Maven are managed by Mise on this Fedora WSL profile. Keep their
# versions pinned; update them deliberately.
workstation_mise_java_version: temurin-11.0.31+11
workstation_mise_maven_version: 3.9.16
workstation_is_wsl: true
workstation_wsl_systemd_enabled: true
workstation_dev_wsl_dotfiles:
- src: .bashrc.d/30-mise.sh
dest: .bashrc.d/30-mise.sh
mode: "0644"
- src: .config/mise/
dest: .config/mise/
mode: preserve
- src: .gnupg/gpg-agent.conf
dest: .gnupg/gpg-agent.conf
mode: "0600"

View File

@@ -0,0 +1,20 @@
---
ansible_host: aegis
ansible_connection: ssh
ansible_user: pi
ansible_become: true
ansible_python_interpreter: /usr/bin/python3
# Avoid PTY framing around module JSON on this remote Fedora IoT host.
ansible_ssh_use_tty: false
aegis_lan_subnet: 192.168.178.0/24
aegis_adguard_web_port: 80
aegis_ssh_authorized_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- name: nymph
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

@@ -1,10 +1,10 @@
---
# Replace every CHANGEME value before enabling Atlas storage management.
# Atlas declares its intended steady state; set a feature flag false only for a deliberate suspension.
hostname: atlas
ansible_host: CHANGEME_ATLAS_HOST
ansible_host: 192.168.178.55
ansible_user: "{{ atlas_connection_username }}"
ansible_port: 22
ansible_ssh_private_key_file: CHANGEME_ATLAS_SSH_PRIVATE_KEY_PATH
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
atlas_admin_username: admin
atlas_connection_username: "{{ atlas_admin_username }}"
@@ -12,39 +12,105 @@ atlas_admin_group: "{{ atlas_admin_username }}"
atlas_admin_home: "/home/{{ atlas_admin_username }}"
atlas_admin_uid: 1000
atlas_admin_gid: 1000
atlas_admin_ssh_keys: "{{ vault_atlas_authorized_ssh_keys | default([]) }}"
atlas_admin_ssh_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- name: nymph
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
atlas_admin_password_hash: "{{ vault_atlas_admin_password_hash | default('!') }}"
atlas_ssh_allow_tcp_forwarding: local
atlas_lan_subnet: CHANGEME_LAN_SUBNET
atlas_manage_firewall: false
atlas_lan_subnet: 192.168.178.0/24
atlas_aegis_ip: 192.168.178.54
atlas_manage_firewall: true
atlas_firewalld_zone: public
atlas_zfs_pool: CHANGEME_ZFS_POOL
atlas_zfs_pool: zpool
# Populate only for the first pool bootstrap with four real persistent disk paths.
# Confirmed empty 4 TB IronWolf data disks; the NVMe system disk is intentionally excluded.
atlas_zpool_disks:
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6AC1XM
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6A73T2
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6A6VJK
- /dev/disk/by-id/ata-ST4000VN006-3CW104_WW6AC1LM
atlas_create_pool: false
atlas_zfs_dataset_work: work
atlas_zfs_dataset_syncthing: syncthing
atlas_zfs_dataset_archive: archive
atlas_zfs_dataset_app_data: archive/app_data
atlas_zfs_dataset_navidrome: archive/app_data/navidrome
atlas_zfs_dataset_syncthing: archive/app_data/syncthing
atlas_zfs_dataset_media: media
atlas_zfs_dataset_music: media/music
atlas_zfs_dataset_backup_prometheus: backup_prometheus
atlas_zfs_dataset_icloud_photos: icloud_photos
atlas_mount_root: /CHANGEME_ATLAS_MOUNT_ROOT
atlas_manage_storage: false
atlas_zfs_dataset_photobook: media/photobook
atlas_zfs_dataset_backups: backups
atlas_zfs_dataset_service_backups: backups/services
atlas_mount_root: /zpool
atlas_manage_storage: true
atlas_manage_sharing: true
atlas_manage_media_stack: false
wireguard_overlay_enabled: true
wireguard_address: 10.0.0.2/24
wireguard_peers:
- name: prometheus
host: prometheus
endpoint: "{{ hostvars['prometheus']['ansible_host'] }}:{{ hostvars['prometheus']['wireguard_listen_port'] }}"
allowed_ips:
- 10.0.0.0/24
persistent_keepalive: 25
backend_phase1_enabled: true
backend_phase1_start_services: false
backend_phase1_wireguard_address: 10.0.0.2
rocky_manage_openzfs_repo: true
rocky_manage_syncthing_binary: false
rocky_manage_podman: true
rocky_podman_packages:
- podman
host_packages:
- cockpit
- nfs-utils
- policycoreutils
- policycoreutils-python-utils
- python3-libselinux
- samba
- samba-client
- samba-common-tools
- zfs
atlas_nfs_exports:
- path: "{{ atlas_work_mountpoint }}"
clients: "{{ atlas_lan_subnet }}(rw,sync,no_subtree_check,root_squash)"
- path: "{{ atlas_photobook_mountpoint }}"
client: "{{ atlas_aegis_ip }}"
options:
- rw
- sync
- no_subtree_check
- all_squash
- "anonuid={{ atlas_immich_uid }}"
- "anongid={{ atlas_immich_gid }}"
atlas_samba_share_name: work
atlas_samba_share_name: Archive
atlas_samba_workgroup: WORKGROUP
atlas_samba_valid_users:
- "{{ atlas_admin_username }}"
atlas_samba_password: "{{ vault_atlas_samba_password | default('') }}"
atlas_samba_encryption: required
# Append any pre-existing laptop Unix account here and keep its Samba password in Vault.
atlas_samba_accounts:
- username: "{{ atlas_admin_username }}"
password: "{{ vault_atlas_samba_password | default('') }}"
atlas_samba_valid_users: "{{ atlas_samba_accounts | map(attribute='username') | list }}"
atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}"
atlas_firewalld_rich_rules:
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="ssh" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="cockpit" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="2049" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" service name="samba" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="22" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="9090" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_aegis_ip }}" port port="2049" protocol="tcp" accept'
- 'rule family="ipv4" source address="{{ atlas_lan_subnet }}" port port="445" protocol="tcp" accept'
host_enabled_services:
- sshd
- cockpit.socket
- nfs-server
- smb
- zfs.target

View File

@@ -1,5 +1,35 @@
---
hostname: prometheus
ansible_host: 179.237.102.172
ansible_user: rocky
ansible_port: 22
ansible_ssh_private_key_file: /home/fscotto/.ssh/id_ed25519
host_packages: []
host_enabled_services: []
server_username: rocky
server_duckdns_domain: fscotto
server_ssh_authorized_keys:
- name: ikaros
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrIxXjA3ffPwziKGR5gzc4gAoBehQPlnEMcXF4Wl0ZS ikaros"
- name: nymph
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEH/7GJfGt0ZVmKeEzceoFkFkeCXFryKK9vAbaip+HCx nymph"
- name: siren
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA95wYlzpfN3rjUhpMeP4KHn8I6ZrjQXoDTgwgRIa++b siren"
wireguard_overlay_enabled: true
wireguard_address: 10.0.0.1/24
wireguard_listen_port: 51820
wireguard_enable_ipv4_forwarding: true
wireguard_reload_rootful_podman_networks: true
wireguard_peers:
- name: atlas
host: atlas
allowed_ips:
- 10.0.0.2/32
host_packages:
- cockpit
- cockpit-navigator
- cockpit-podman
host_enabled_services:
- cockpit.socket

View File

@@ -13,12 +13,15 @@ all:
deadalus:
ansible_connection: local
platform_freebsd:
hosts: {}
platform_fedora_iot:
hosts:
aegis:
platform_rocky:
hosts:
atlas:
children:
rocky_server:
void:
children:
@@ -27,6 +30,7 @@ all:
fedora:
children:
platform_fedora:
platform_fedora_iot:
rocky:
children:
@@ -48,6 +52,10 @@ all:
ikaros:
ansible_connection: local
role_aegis:
hosts:
aegis:
desktop_sway:
hosts: {}
@@ -61,10 +69,6 @@ all:
nymph:
ansible_connection: local
ubuntu:
children:
ubuntu_server:
workstation:
children:
workstation_dev:
@@ -76,7 +80,7 @@ all:
server:
children:
ubuntu_server:
rocky_server:
workstation_dev_fedora:
hosts:
@@ -88,7 +92,15 @@ all:
deadalus:
ansible_connection: local
ubuntu_server:
rocky_server:
hosts:
prometheus:
ansible_connection: local
wireguard_overlay:
hosts:
prometheus:
atlas:
role_backend_phase1:
hosts:
atlas:

View File

@@ -23,6 +23,7 @@
dest: "{{ effective_user_home }}/Templates"
owner: "{{ effective_username }}"
group: "{{ effective_user_group }}"
creates: "{{ effective_user_home }}/Templates/code/main.c"
when: "'desktop' in group_names"
- name: Ensure Emacs authoring directories exist
@@ -86,13 +87,25 @@
group: "{{ effective_user_group }}"
mode: "{{ item.mode }}"
loop: >-
{{
(common_dotfiles | default([]))
+ ((ai_agents_dotfiles | default([])) if (ai_agents_enabled | default(false)) else [])
}}
{{ common_dotfiles | default([]) }}
loop_control:
label: "{{ item.dest }}"
- name: Deploy AI coding-agent dotfiles
tags: [dotfiles, dotfiles:common, ai_agents]
ansible.builtin.copy:
src: "{{ playbook_dir }}/../dotfiles/common/{{ item.src }}"
dest: "{{ effective_user_home }}/{{ item.dest }}"
owner: "{{ effective_username }}"
group: "{{ effective_user_group }}"
mode: "{{ item.mode }}"
loop: "{{ ai_agents_dotfiles | default([]) }}"
loop_control:
label: "{{ item.dest }}"
when:
- (item.agents | intersect(ai_agents_deploy_enabled_names)) | length > 0
- (ai_agents_dotfiles | default([])) | length > 0
- name: Install distro packages for Vim plugins
tags: [packages, vim, fzf]
ansible.builtin.package:
@@ -141,7 +154,7 @@
- (vim_plugin_source_plugins | default([])) | length > 0
- name: Ensure AI config directories exist
tags: [dotfiles, dotfiles:common]
tags: [dotfiles, dotfiles:common, ai_agents]
ansible.builtin.file:
path: "{{ effective_user_home }}/{{ item }}"
state: directory
@@ -151,11 +164,11 @@
loop:
- .codex
when:
- ai_agents_enabled | default(false)
- "'codex' in ai_agents_deploy_enabled_names"
- (ai_agents_templates | default([])) | length > 0
- name: Render AI agent templates
tags: [dotfiles, dotfiles:common]
- name: Render AI coding-agent templates
tags: [dotfiles, dotfiles:common, ai_agents]
ansible.builtin.template:
src: "{{ playbook_dir }}/../dotfiles/common/{{ item.src }}"
dest: "{{ effective_user_home }}/{{ item.dest }}"
@@ -165,7 +178,9 @@
loop: "{{ ai_agents_templates | default([]) }}"
loop_control:
label: "{{ item.dest }}"
when: ai_agents_enabled | default(false)
when:
- (item.agents | intersect(ai_agents_deploy_enabled_names)) | length > 0
- (ai_agents_templates | default([])) | length > 0
- name: Refresh bat cache
tags: [dotfiles, dotfiles:common]

View File

@@ -25,6 +25,20 @@
- "'workstation_dev_fedora' in group_names"
- fedora_manage_docker_repo | default(false)
- name: Install COPR support on Fedora WSL
tags: [packages, mise]
ansible.builtin.dnf:
name: dnf-plugins-core
state: present
when: "'workstation_dev_wsl' in group_names"
- name: Configure Mise COPR repository on Fedora WSL
tags: [packages, mise]
community.general.copr:
name: jdxcode/mise
state: enabled
when: "'workstation_dev_wsl' in group_names"
- name: Refresh dnf package metadata
tags: [packages]
ansible.builtin.dnf:
@@ -90,16 +104,31 @@
- "'workstation_dev_fedora' in group_names"
- (fedora_docker_packages | default([])) | length > 0
- name: Install Fedora npm packages
- name: Install shared AI coding agents on Fedora
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.name }}"
name: "{{ item.value.npm_package }}"
global: true
state: "{{ item.state | default('present') }}"
loop: "{{ fedora_npm_packages | default([]) }}"
when: (fedora_npm_packages | default([])) | length > 0
state: latest
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.install_enabled | bool
- "'workstation_dev_fedora' not in group_names"
loop_control:
label: "{{ item.name }}"
label: "{{ item.key }}"
- name: Uninstall shared AI coding agents on Fedora
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.value.npm_package }}"
global: true
state: absent
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.uninstall_enabled | bool
- "'workstation_dev_fedora' not in group_names"
loop_control:
label: "{{ item.key }}"
- name: Define Fedora Flatpak applications
tags: [packages]

View File

@@ -1,14 +0,0 @@
---
- name: Install packages on FreeBSD
tags: [packages]
community.general.pkgng:
name: >-
{{
(
(freebsd_packages_base | default([]))
+ (freebsd_development_packages | default([]))
+ (host_packages | default([]))
)
| unique
}}
state: present

View File

@@ -27,11 +27,20 @@
name: epel-release
state: present
- name: Import official OpenZFS EL9+ signing key
tags: [packages, storage]
ansible.builtin.rpm_key:
state: present
key: "{{ rocky_openzfs_gpg_key_url }}"
fingerprint: "{{ rocky_openzfs_gpg_key_fingerprint }}"
when: rocky_manage_openzfs_repo | bool
- name: Install official OpenZFS repository package
tags: [packages, storage]
ansible.builtin.dnf:
name: "{{ rocky_openzfs_release_rpm }}"
state: present
when: rocky_manage_openzfs_repo | bool
- name: Disable OpenZFS DKMS repository
tags: [packages, storage]
@@ -39,6 +48,7 @@
name:
- zfs
state: disabled
when: rocky_manage_openzfs_repo | bool
- name: Enable OpenZFS kmod repository
tags: [packages, storage]
@@ -46,6 +56,17 @@
name:
- zfs-kmod
state: enabled
when: rocky_manage_openzfs_repo | bool
- name: Configure official GitHub CLI RPM repository
tags: [packages]
ansible.builtin.get_url:
url: "{{ rocky_github_cli_repo_url }}"
dest: "{{ rocky_github_cli_repo_file }}"
owner: root
group: root
mode: "0644"
when: rocky_manage_github_cli_repo | bool
- name: Refresh Rocky package metadata
tags: [packages]
@@ -58,20 +79,32 @@
name: >-
{{
(
(common_packages | default([]))
(rocky_common_packages | default([]))
+ (rocky_packages_base | default([]))
+ (profile_packages | default([]))
+ (rocky_profile_packages | default([]))
+ (rocky_podman_packages | default([]))
+ (host_packages | default([]))
) | unique
}}
state: present
- name: Ensure Podman runtime socket directory exists
tags: [packages, podman]
ansible.builtin.file:
path: /run/podman
state: directory
owner: root
group: root
mode: "0700"
when: rocky_manage_podman | bool
- name: Require supported architecture for Syncthing
tags: [packages, syncthing]
ansible.builtin.assert:
that:
- ansible_facts['architecture'] == 'x86_64'
fail_msg: The pinned Atlas Syncthing binary currently supports x86_64 only.
when: rocky_manage_syncthing_binary | bool
- name: Read installed Syncthing version
tags: [packages, syncthing]
@@ -83,6 +116,7 @@
changed_when: false
failed_when: false
check_mode: false
when: rocky_manage_syncthing_binary | bool
- name: Determine whether Syncthing must be installed
tags: [packages, syncthing]
@@ -92,6 +126,7 @@
('syncthing v' ~ rocky_syncthing_version ~ ' ')
not in (rocky_syncthing_version_check.stdout | default(''))
}}
when: rocky_manage_syncthing_binary | bool
- name: Create temporary Syncthing extraction directory
tags: [packages, syncthing]
@@ -101,7 +136,9 @@
owner: root
group: root
mode: "0755"
when: rocky_syncthing_install_required | bool
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Download pinned Syncthing release
tags: [packages, syncthing]
@@ -112,7 +149,9 @@
owner: root
group: root
mode: "0644"
when: rocky_syncthing_install_required | bool
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Extract pinned Syncthing release
tags: [packages, syncthing]
@@ -120,7 +159,9 @@
src: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
dest: "/tmp/syncthing-{{ rocky_syncthing_version }}"
remote_src: true
when: rocky_syncthing_install_required | bool
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Install pinned Syncthing binary
tags: [packages, syncthing]
@@ -136,16 +177,20 @@
owner: root
group: root
mode: "0755"
when: rocky_syncthing_install_required | bool
when:
- rocky_manage_syncthing_binary | bool
- rocky_syncthing_install_required | bool
- name: Remove Syncthing release archive
tags: [packages, syncthing]
ansible.builtin.file:
path: "/tmp/syncthing-{{ rocky_syncthing_version }}.tar.gz"
state: absent
when: rocky_manage_syncthing_binary | bool
- name: Remove Syncthing extraction directory
tags: [packages, syncthing]
ansible.builtin.file:
path: "/tmp/syncthing-{{ rocky_syncthing_version }}"
state: absent
when: rocky_manage_syncthing_binary | bool

View File

@@ -1,82 +0,0 @@
---
- name: Copy Ubuntu dotfiles
tags: [dotfiles, dotfiles:common]
ansible.builtin.copy:
src: "{{ playbook_dir }}/../dotfiles/ubuntu/{{ item.src }}"
dest: "{{ effective_user_home }}/{{ item.dest }}"
owner: "{{ effective_username }}"
group: "{{ effective_user_group }}"
mode: "{{ item.mode }}"
loop: "{{ ubuntu_dotfiles | default([]) }}"
loop_control:
label: "{{ item.dest }}"
- name: Ensure Docker apt keyrings directory exists
tags: [packages]
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
when: ubuntu_manage_docker_repo | default(false)
- name: Download Docker apt repository signing key
tags: [packages]
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
owner: root
group: root
mode: "0644"
when: ubuntu_manage_docker_repo | default(false)
- name: Configure Docker apt repository
tags: [packages]
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ 'amd64' if ansible_facts['architecture'] == 'x86_64'
else 'arm64' if ansible_facts['architecture'] in ['aarch64', 'arm64']
else ansible_facts['architecture'] }}
signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/ubuntu
{{ ansible_facts['distribution_release'] }} stable
filename: docker
state: present
update_cache: true
when: ubuntu_manage_docker_repo | default(false)
- name: Refresh apt package cache
tags: [packages]
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install packages on Ubuntu
tags: [packages]
ansible.builtin.apt:
name: >-
{{
(
(common_packages | default([]))
+ (ubuntu_packages_base | default([]))
+ (ubuntu_docker_packages | default([]))
+ (profile_packages | default([]))
+ (
(ubuntu_emacs_packages | default([]))
if (emacs_enabled | default(false) | bool)
else []
)
+ (host_packages | default([]))
)
| unique
}}
state: present
- name: Add user to docker group
tags: [packages]
ansible.builtin.user:
name: "{{ effective_username }}"
groups: docker
append: true
when: (ubuntu_docker_packages | default([])) | length > 0

View File

@@ -0,0 +1,17 @@
---
aegis_hostname: aegis
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
aegis_lan_subnet: CHANGEME_LAN_SUBNET
aegis_firewalld_zone: public
aegis_adguard_web_port: 80
aegis_ssh_allowed_users:
- "{{ ansible_user }}"
aegis_ssh_user_home: "/var/home/{{ ansible_user }}"
aegis_ssh_authorized_keys: []

View File

@@ -0,0 +1,21 @@
---
- name: Restart Aegis systemd-resolved
ansible.builtin.systemd:
name: systemd-resolved.service
state: restarted
- name: Reload Aegis SSH
ansible.builtin.systemd:
name: sshd.service
state: reloaded
- name: Restart Aegis Quadlet services
ansible.builtin.systemd:
name: "{{ item }}"
state: restarted
daemon_reload: true
loop:
- adguardhome.service
- icloudpd.service
loop_control:
label: "{{ item }}"

View File

@@ -0,0 +1,291 @@
---
- 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, firewall, services]
ansible.builtin.assert:
that:
- aegis_lan_subnet != 'CHANGEME_LAN_SUBNET'
- aegis_firewalld_zone | length > 0
- aegis_adguard_web_port | int > 0
- aegis_adguard_web_port | int < 65536
- aegis_ssh_allowed_users | length > 0
fail_msg: Define the Aegis LAN subnet, firewalld zone, AdGuard web port, and SSH users.
- name: Set Aegis hostname
tags: [aegis, services]
ansible.builtin.hostname:
name: "{{ aegis_hostname }}"
- name: Create Aegis persistent container directories
tags: [aegis, containers]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
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]
ansible.builtin.file:
path: /etc/containers/systemd
state: directory
owner: root
group: root
mode: "0755"
- name: Render Aegis Quadlet units
tags: [aegis, containers]
ansible.builtin.template:
src: "{{ item.src }}"
dest: "/etc/containers/systemd/{{ item.dest }}"
owner: root
group: root
mode: "0644"
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
- name: Create Aegis systemd-resolved configuration directory
tags: [aegis, adguard, dns, services]
ansible.builtin.file:
path: /etc/systemd/resolved.conf.d
state: directory
owner: root
group: root
mode: "0755"
- name: Disable Aegis systemd-resolved DNS stub listener
tags: [aegis, adguard, dns, services]
ansible.builtin.template:
src: 10-adguard-dns.conf.j2
dest: /etc/systemd/resolved.conf.d/10-adguard-dns.conf
owner: root
group: root
mode: "0644"
notify:
- Restart Aegis systemd-resolved
- Restart Aegis Quadlet services
- name: Point Aegis resolver at the full systemd-resolved configuration
tags: [aegis, adguard, dns, services]
ansible.builtin.file:
src: ../run/systemd/resolve/resolv.conf
dest: /etc/resolv.conf
state: link
force: true
notify: Restart Aegis systemd-resolved
- name: Enable Aegis firewalld
tags: [aegis, firewall, services]
ansible.builtin.systemd:
name: firewalld.service
enabled: true
state: started
- name: Render Aegis AdGuard web firewalld service
tags: [aegis, firewall]
ansible.builtin.template:
src: aegis-adguard-web.xml.j2
dest: /etc/firewalld/services/aegis-adguard-web.xml
owner: root
group: root
mode: "0644"
- name: Reload firewalld custom services
tags: [aegis, firewall]
ansible.builtin.command:
argv:
- firewall-cmd
- --reload
changed_when: false
when: not ansible_check_mode
- name: Remove unrestricted Aegis services from firewalld zone
tags: [aegis, firewall]
ansible.posix.firewalld:
service: "{{ item }}"
permanent: true
immediate: true
state: disabled
zone: "{{ aegis_firewalld_zone }}"
loop:
- ssh
- dns
- http
- aegis-adguard-web
loop_control:
label: "{{ item }}"
- name: Remove retired Aegis TLS access rule
tags: [aegis, firewall]
ansible.posix.firewalld:
rich_rule: 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" port port="853" protocol="tcp" accept'
permanent: true
immediate: true
state: disabled
zone: "{{ aegis_firewalld_zone }}"
- name: Allow Aegis services from the LAN only
tags: [aegis, firewall]
ansible.posix.firewalld:
rich_rule: "{{ item }}"
permanent: true
immediate: true
state: enabled
zone: "{{ aegis_firewalld_zone }}"
loop:
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="ssh" accept'
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="dns" accept'
- 'rule family="ipv4" source address="{{ aegis_lan_subnet }}" service name="aegis-adguard-web" accept'
loop_control:
label: "{{ item }}"
- name: Check the standard Aegis SSH authorized keys file
tags: [aegis, ssh, services]
ansible.builtin.stat:
path: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys"
register: aegis_authorized_keys
- name: Ensure Aegis SSH authorized key fragments directory exists
tags: [aegis, ssh, services]
ansible.builtin.file:
path: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0700"
when: aegis_ssh_authorized_keys | length > 0
- name: Manage Aegis SSH authorized key fragments
tags: [aegis, ssh, services]
ansible.builtin.copy:
content: "{{ item.key }}\n"
dest: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d/{{ item.name }}"
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0600"
loop: "{{ aegis_ssh_authorized_keys }}"
loop_control:
label: "{{ item.name }}"
when: aegis_ssh_authorized_keys | length > 0
- name: Find Aegis SSH authorized key fragments
tags: [aegis, ssh, services]
ansible.builtin.find:
paths: "{{ aegis_ssh_user_home }}/.ssh/authorized_keys.d"
file_type: file
recurse: false
register: aegis_authorized_key_fragments
- name: Require an Aegis SSH authorized key before hardening
tags: [aegis, ssh, services]
ansible.builtin.assert:
that:
- >-
(aegis_ssh_authorized_keys | length > 0)
or (aegis_authorized_keys.stat.exists and aegis_authorized_keys.stat.size | int > 0)
or aegis_authorized_key_fragments.matched | int > 0
fail_msg: Add a public key for the Ansible SSH user before disabling password authentication.
- name: Ensure Aegis SSH configuration drop-in directory exists
tags: [aegis, ssh, services]
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
- name: Ensure Aegis SSH drop-ins are loaded before other settings
tags: [aegis, ssh, services]
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^\s*Include\s+/etc/ssh/sshd_config\.d/\*\.conf\s*$'
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
state: present
validate: "sshd -t -f %s"
- name: Render Aegis SSH hardening drop-in
tags: [aegis, ssh, services]
ansible.builtin.template:
src: 00-aegis-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/00-aegis-hardening.conf
owner: root
group: root
mode: "0600"
notify: Reload Aegis SSH
- name: Validate Aegis SSH configuration
tags: [aegis, ssh, services]
ansible.builtin.command:
argv:
- sshd
- -t
changed_when: false
when: not ansible_check_mode
- name: Read effective Aegis SSH daemon configuration
tags: [aegis, ssh, services]
ansible.builtin.command:
argv:
- sshd
- -T
- -C
- "user={{ ansible_user }},host={{ aegis_hostname }},addr=127.0.0.1"
register: aegis_sshd_effective_configuration
changed_when: false
when: not ansible_check_mode
- name: Verify effective Aegis SSH hardening
tags: [aegis, ssh, services]
ansible.builtin.assert:
that:
- "'permitrootlogin no' in aegis_sshd_effective_configuration.stdout_lines"
- "'pubkeyauthentication yes' in aegis_sshd_effective_configuration.stdout_lines"
- "'passwordauthentication no' in aegis_sshd_effective_configuration.stdout_lines"
- "'kbdinteractiveauthentication no' in aegis_sshd_effective_configuration.stdout_lines"
- "'allowusers ' + (aegis_ssh_allowed_users | join(' ')) in aegis_sshd_effective_configuration.stdout_lines"
when: not ansible_check_mode
- name: Install Wake-on-LAN helper for Ikaros
tags: [aegis, wol]
ansible.builtin.template:
src: wake-ikaros.j2
dest: /usr/local/bin/wake-ikaros
owner: root
group: root
mode: "0755"
- name: Enable Aegis Quadlet services and automatic updates
tags: [aegis, containers, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
daemon_reload: true
loop:
- adguardhome.service
- icloudpd.service
- podman-auto-update.timer
loop_control:
label: "{{ item }}"

View File

@@ -0,0 +1,6 @@
# Managed by Ansible. Do not edit manually.
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AllowUsers {{ aegis_ssh_allowed_users | join(' ') }}

View File

@@ -0,0 +1,3 @@
# Managed by Ansible. Do not edit manually.
[Resolve]
DNSStubListener=no

View File

@@ -0,0 +1,18 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=AdGuard Home DNS sinkhole
Wants=network-online.target
After=network-online.target
[Container]
Image={{ aegis_adguard_image }}
Network=host
Volume=/var/lib/adguard/work:/opt/adguardhome/work:Z
Volume=/var/lib/adguard/conf:/opt/adguardhome/conf:Z
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>Aegis AdGuard Home web interface</short>
<description>AdGuard Home administrative web interface on Aegis.</description>
<port protocol="tcp" port="{{ aegis_adguard_web_port }}"/>
</service>

View File

@@ -0,0 +1,20 @@
# 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

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Managed by Ansible. Do not edit manually.
set -euo pipefail
mac='{{ aegis_ikaros_mac_address }}'
port='{{ aegis_wol_port }}'
mac_hex="${mac//:/}"
if [[ ! $mac_hex =~ ^[[:xdigit:]]{12}$ ]]; then
printf 'Indirizzo MAC non valido: %s\n' "$mac" >&2
exit 1
fi
packet='\xFF\xFF\xFF\xFF\xFF\xFF'
for _ in {1..16}; do
packet+="$(printf '\\x%s\\x%s\\x%s\\x%s\\x%s\\x%s' \
"${mac_hex:0:2}" "${mac_hex:2:2}" "${mac_hex:4:2}" \
"${mac_hex:6:2}" "${mac_hex:8:2}" "${mac_hex:10:2}")"
done
printf '%b' "$packet" > "/dev/udp/255.255.255.255/${port}"

View File

@@ -1,29 +1,77 @@
---
atlas_manage_storage: false
atlas_manage_sharing: false
# Destructive first-boot action; normally false once the pool exists.
atlas_create_pool: false
atlas_zpool_disks: []
atlas_admin_username: CHANGEME_ATLAS_ADMIN
atlas_admin_group: "{{ atlas_admin_username }}"
atlas_admin_home: "/home/{{ atlas_admin_username }}"
atlas_admin_uid: 1000
atlas_admin_gid: 1000
atlas_admin_ssh_keys: []
atlas_admin_ssh_key_directory: "{{ atlas_admin_home }}/.ssh/authorized_keys.d"
atlas_admin_password_hash: "!"
# Local forwarding permits an administrator to tunnel a private service without allowing remote forwards.
atlas_ssh_allow_tcp_forwarding: local
atlas_immich_username: immich
atlas_immich_group: immich
atlas_immich_uid: 1100
atlas_immich_gid: 1100
atlas_immich_supplementary_groups:
- video
- render
atlas_lan_subnet: CHANGEME_LAN_SUBNET
atlas_aegis_ip: CHANGEME_AEGIS_IP
atlas_manage_firewall: false
atlas_firewalld_zone: public
atlas_hardening_sysctls:
net.ipv4.conf.all.accept_redirects: "0"
net.ipv4.conf.default.accept_redirects: "0"
net.ipv4.conf.all.send_redirects: "0"
net.ipv4.conf.default.send_redirects: "0"
net.ipv4.conf.all.accept_source_route: "0"
net.ipv4.conf.default.accept_source_route: "0"
net.ipv4.conf.all.log_martians: "1"
net.ipv4.conf.default.log_martians: "1"
net.ipv4.conf.all.rp_filter: "2"
net.ipv4.conf.default.rp_filter: "2"
net.ipv4.tcp_syncookies: "1"
net.ipv4.icmp_echo_ignore_broadcasts: "1"
net.ipv4.icmp_ignore_bogus_error_responses: "1"
net.ipv4.ip_forward: "0"
net.ipv6.conf.all.accept_redirects: "0"
net.ipv6.conf.default.accept_redirects: "0"
net.ipv6.conf.all.accept_source_route: "0"
net.ipv6.conf.default.accept_source_route: "0"
atlas_zfs_pool: CHANGEME_ZFS_POOL
atlas_zfs_dataset_work: work
atlas_zfs_dataset_syncthing: syncthing
atlas_zfs_dataset_archive: archive
atlas_zfs_dataset_app_data: archive/app_data
atlas_zfs_dataset_navidrome: archive/app_data/navidrome
atlas_zfs_dataset_syncthing: archive/app_data/syncthing
atlas_zfs_dataset_media: media
atlas_zfs_dataset_music: media/music
atlas_zfs_dataset_backup_prometheus: backup_prometheus
atlas_zfs_dataset_icloud_photos: icloud_photos
atlas_zfs_dataset_photobook: media/photobook
atlas_zfs_dataset_backups: backups
atlas_zfs_dataset_service_backups: backups/services
atlas_zfs_service_backups_refreservation: 500G
atlas_mount_root: /CHANGEME_ATLAS_MOUNT_ROOT
atlas_work_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_work }}"
atlas_archive_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_archive }}"
atlas_app_data_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_app_data }}"
atlas_navidrome_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_navidrome }}"
atlas_syncthing_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_syncthing }}"
atlas_media_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_media }}"
atlas_music_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_music }}"
atlas_work_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_work }}"
atlas_backup_prometheus_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backup_prometheus }}"
atlas_icloud_photos_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_icloud_photos }}"
atlas_syncthing_config_dir: "{{ atlas_admin_home }}/.local/state/syncthing"
atlas_syncthing_default_dir: "{{ atlas_syncthing_mountpoint }}/Sync"
atlas_photobook_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_photobook }}"
atlas_backups_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_backups }}"
atlas_service_backups_mountpoint: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_service_backups }}"
atlas_45drives_repo_url: https://repo.45drives.com/repofiles/rocky/45drives-enterprise.repo
atlas_45drives_repo_file: /etc/yum.repos.d/45drives-enterprise.repo
@@ -34,18 +82,61 @@ atlas_45drives_packages:
- cockpit-zfs
- cockpit-scheduler
atlas_nfs_exports: []
atlas_samba_share_name: work
atlas_nfs_exports:
- path: "{{ atlas_photobook_mountpoint }}"
client: "{{ atlas_aegis_ip }}"
options:
- rw
- sync
- no_subtree_check
- all_squash
- "anonuid={{ atlas_immich_uid }}"
- "anongid={{ atlas_immich_gid }}"
atlas_samba_share_name: Archive
atlas_samba_workgroup: WORKGROUP
# Keep encryption mandatory unless a verified client-compatibility exception is explicitly required.
atlas_samba_encryption: required
atlas_samba_valid_users: []
atlas_samba_password: ""
atlas_samba_password_marker: /var/lib/samba/private/.atlas-password.sha256
atlas_samba_accounts: []
atlas_samba_password_marker_dir: /var/lib/samba/private/ansible-passwords
atlas_firewalld_rich_rules: []
atlas_firewalld_restricted_services:
- ssh
- cockpit
- nfs
- samba
- http
- https
atlas_selinux_booleans:
- samba_export_all_rw
- nfs_export_all_rw
atlas_manage_media_stack: false
atlas_container_quadlet_dir: /etc/containers/systemd
atlas_container_data_root: /var/lib/atlas-containers
atlas_npm_data_dir: "{{ atlas_container_data_root }}/npm/data"
atlas_npm_letsencrypt_dir: "{{ atlas_container_data_root }}/npm/letsencrypt"
atlas_npm_admin_bind_address: 127.0.0.1
atlas_timezone: Europe/Rome
atlas_immich_upload_dir: "{{ atlas_container_data_root }}/immich/upload"
atlas_immich_model_cache_dir: "{{ atlas_container_data_root }}/immich/model-cache"
atlas_immich_machine_learning_config_dir: "{{ atlas_container_data_root }}/immich/machine-learning-config"
atlas_immich_machine_learning_cache_dir: "{{ atlas_container_data_root }}/immich/machine-learning-cache"
atlas_immich_redis_data_dir: "{{ atlas_container_data_root }}/immich/redis"
atlas_immich_postgres_data_dir: "{{ atlas_container_data_root }}/immich/postgres"
atlas_npm_image: docker.io/jc21/nginx-proxy-manager:latest
atlas_immich_version: release
atlas_immich_server_image: "ghcr.io/immich-app/immich-server:{{ atlas_immich_version }}"
atlas_immich_machine_learning_image: "ghcr.io/immich-app/immich-machine-learning:{{ atlas_immich_version }}-openvino"
atlas_immich_redis_image: docker.io/valkey/valkey:9
atlas_immich_postgres_image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
atlas_immich_db_username: postgres
atlas_immich_db_name: immich
atlas_immich_db_password: "{{ vault_atlas_immich_db_password | default('') }}"
atlas_media_quadlet_services:
- atlas-immich-redis.service
- atlas-immich-postgres.service
- atlas-immich-machine-learning.service
- atlas-immich-server.service
- atlas-npm.service

View File

@@ -28,7 +28,14 @@
name: smb
state: restarted
- name: Restart Atlas Syncthing service
- name: Restart Atlas media Quadlets
ansible.builtin.systemd:
name: atlas-syncthing
name: "{{ item }}"
state: restarted
daemon_reload: true
loop: "{{ atlas_media_quadlet_services }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_media_stack | bool
- not ansible_check_mode

View File

@@ -6,9 +6,11 @@
- atlas_admin_username != 'CHANGEME_ATLAS_ADMIN'
- (atlas_admin_ssh_keys | default([])) | length > 0
- atlas_admin_password_hash not in ['', '!', '*']
- atlas_ssh_allow_tcp_forwarding in ['no', 'yes', 'local', 'remote', 'all']
- "'wheel' not in atlas_immich_supplementary_groups"
fail_msg: >-
Define atlas_admin_username, vault_atlas_authorized_ssh_keys and
vault_atlas_admin_password_hash before applying the Atlas profile.
Define atlas_admin_username, atlas_admin_ssh_keys and vault_atlas_admin_password_hash
before applying the Atlas profile.
no_log: true
- name: Create Atlas administrator group
@@ -31,6 +33,41 @@
state: present
no_log: true
- name: Create Immich primary group
tags: [atlas, accounts, containers, immich]
ansible.builtin.group:
name: "{{ atlas_immich_group }}"
gid: "{{ atlas_immich_gid }}"
state: present
- name: Ensure Immich hardware-access groups exist
tags: [atlas, accounts, containers, immich]
ansible.builtin.group:
name: "{{ item }}"
state: present
loop: "{{ atlas_immich_supplementary_groups }}"
loop_control:
label: "{{ item }}"
- name: Create unprivileged Immich account
tags: [atlas, accounts, containers, immich]
ansible.builtin.user:
name: "{{ atlas_immich_username }}"
uid: "{{ atlas_immich_uid }}"
group: "{{ atlas_immich_group }}"
groups: "{{ atlas_immich_supplementary_groups }}"
append: false
home: /nonexistent
create_home: false
shell: /sbin/nologin
system: true
state: present
- name: Read Immich hardware-access group IDs
tags: [atlas, accounts, containers, immich]
ansible.builtin.getent:
database: group
- name: Grant Atlas administrator passwordless sudo
tags: [atlas, services]
ansible.builtin.copy:
@@ -41,13 +78,26 @@
mode: "0440"
validate: "visudo -cf %s"
- name: Manage Atlas administrator authorized SSH keys exclusively
- name: Ensure Atlas administrator SSH authorized key fragments directory exists
tags: [atlas, services]
ansible.posix.authorized_key:
user: "{{ atlas_admin_username }}"
key: "{{ atlas_admin_ssh_keys | join('\n') }}"
state: present
exclusive: true
ansible.builtin.file:
path: "{{ atlas_admin_ssh_key_directory }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
- name: Manage Atlas administrator SSH authorized key fragments
tags: [atlas, services]
ansible.builtin.copy:
content: "{{ item.key }}\n"
dest: "{{ atlas_admin_ssh_key_directory }}/{{ item.name }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0600"
loop: "{{ atlas_admin_ssh_keys }}"
loop_control:
label: "{{ item.name }}"
- name: Check whether the Atlas SSH host key exists
tags: [atlas, services]
@@ -113,6 +163,18 @@
- "'pubkeyauthentication yes' in atlas_sshd_effective_configuration.stdout_lines"
- "'passwordauthentication no' in atlas_sshd_effective_configuration.stdout_lines"
- "'kbdinteractiveauthentication no' in atlas_sshd_effective_configuration.stdout_lines"
- "'authenticationmethods publickey' in atlas_sshd_effective_configuration.stdout_lines"
- "'x11forwarding no' in atlas_sshd_effective_configuration.stdout_lines"
- "('allowtcpforwarding ' ~ atlas_ssh_allow_tcp_forwarding) in atlas_sshd_effective_configuration.stdout_lines"
- "'allowagentforwarding no' in atlas_sshd_effective_configuration.stdout_lines"
- "'gatewayports no' in atlas_sshd_effective_configuration.stdout_lines"
- "'permittunnel no' in atlas_sshd_effective_configuration.stdout_lines"
- "'permituserenvironment no' in atlas_sshd_effective_configuration.stdout_lines"
- "'maxauthtries 3' in atlas_sshd_effective_configuration.stdout_lines"
- "'logingracetime 30' in atlas_sshd_effective_configuration.stdout_lines"
- "'clientaliveinterval 300' in atlas_sshd_effective_configuration.stdout_lines"
- "'clientalivecountmax 2' in atlas_sshd_effective_configuration.stdout_lines"
- "'loglevel VERBOSE' in atlas_sshd_effective_configuration.stdout_lines"
- "('allowusers ' ~ atlas_admin_username) in atlas_sshd_effective_configuration.stdout_lines"
fail_msg: The effective Atlas SSH configuration does not match the required hardening.
when: not ansible_check_mode

View File

@@ -0,0 +1,140 @@
---
- name: Require completed Atlas media-stack configuration
tags: [atlas, containers, immich]
ansible.builtin.assert:
that:
- atlas_manage_storage | bool
- atlas_zfs_pool != 'CHANGEME_ZFS_POOL'
- atlas_mount_root != '/CHANGEME_ATLAS_MOUNT_ROOT'
- atlas_immich_db_password | length > 0
- atlas_npm_admin_bind_address == '127.0.0.1'
fail_msg: >-
Enable and configure Atlas storage, provide vault_atlas_immich_db_password,
and keep the NPM administration port bound to loopback before enabling the media stack.
no_log: true
when: atlas_manage_media_stack | bool
- name: Check Atlas Intel graphics device
tags: [atlas, containers, immich]
ansible.builtin.stat:
path: /dev/dri
register: atlas_dri_device
when: atlas_manage_media_stack | bool
- name: Require Atlas Intel graphics device
tags: [atlas, containers, immich]
ansible.builtin.assert:
that:
- atlas_dri_device.stat.isdir | default(false)
fail_msg: /dev/dri is required for Immich QuickSync and OpenVINO acceleration.
when: atlas_manage_media_stack | bool
- name: Create Atlas rootful Quadlet directory
tags: [atlas, containers]
ansible.builtin.file:
path: "{{ atlas_container_quadlet_dir }}"
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_media_stack | bool
- name: Create Atlas Immich configuration directory
tags: [atlas, containers, immich]
ansible.builtin.file:
path: /etc/immich
state: directory
owner: root
group: "{{ atlas_immich_group }}"
mode: "0750"
when: atlas_manage_media_stack | bool
- name: Create Atlas Immich persistent directories
tags: [atlas, containers, immich]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ atlas_immich_username }}"
group: "{{ atlas_immich_group }}"
mode: "0750"
loop:
- "{{ atlas_immich_upload_dir }}"
- "{{ atlas_immich_model_cache_dir }}"
- "{{ atlas_immich_machine_learning_config_dir }}"
- "{{ atlas_immich_machine_learning_cache_dir }}"
- "{{ atlas_immich_redis_data_dir }}"
- "{{ atlas_immich_postgres_data_dir }}"
loop_control:
label: "{{ item }}"
when: atlas_manage_media_stack | bool
- name: Create Atlas NPM persistent directories
tags: [atlas, containers, npm]
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ atlas_npm_data_dir }}"
- "{{ atlas_npm_letsencrypt_dir }}"
loop_control:
label: "{{ item }}"
when: atlas_manage_media_stack | bool
- name: Allow confined containers to use Atlas graphics devices
tags: [atlas, containers, immich]
ansible.posix.seboolean:
name: container_use_devices
state: true
persistent: true
when:
- atlas_manage_media_stack | bool
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
- name: Render Vault-backed Immich environment
tags: [atlas, containers, immich]
ansible.builtin.template:
src: immich.env.j2
dest: /etc/immich/immich.env
owner: root
group: "{{ atlas_immich_group }}"
mode: "0640"
no_log: true
diff: false
notify: Restart Atlas media Quadlets
when: atlas_manage_media_stack | bool
- name: Render Atlas media Quadlets
tags: [atlas, containers]
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "{{ atlas_container_quadlet_dir }}/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- atlas-media.network
- atlas-immich-redis.container
- atlas-immich-postgres.container
- atlas-immich-machine-learning.container
- atlas-immich-server.container
- atlas-npm.container
loop_control:
label: "{{ item }}"
notify: Restart Atlas media Quadlets
when: atlas_manage_media_stack | bool
- name: Start Atlas media Quadlet services
tags: [atlas, containers, services]
ansible.builtin.systemd:
name: "{{ item }}"
state: started
daemon_reload: true
loop: "{{ atlas_media_quadlet_services }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_media_stack | bool
- not ansible_check_mode

View File

@@ -0,0 +1,45 @@
---
- name: Enforce targeted SELinux on Atlas
tags: [atlas, hardening, security]
ansible.posix.selinux:
policy: targeted
state: enforcing
update_kernel_param: true
register: atlas_selinux_enforcement
- name: Report when Atlas requires a reboot for SELinux enforcement
tags: [atlas, hardening, security]
ansible.builtin.debug:
msg: Reboot Atlas before relying on SELinux enforcement.
when: atlas_selinux_enforcement.reboot_required | default(false)
- name: Apply Atlas network-kernel hardening
tags: [atlas, hardening, security]
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
state: present
sysctl_set: true
reload: true
loop: "{{ atlas_hardening_sysctls | dict2items }}"
loop_control:
label: "{{ item.key }}={{ item.value }}"
- name: Read active Atlas SELinux mode
tags: [atlas, hardening, security]
ansible.builtin.command:
argv:
- getenforce
register: atlas_selinux_mode
changed_when: false
when: not ansible_check_mode
- name: Require active SELinux enforcement on Atlas
tags: [atlas, hardening, security]
ansible.builtin.assert:
that:
- atlas_selinux_mode.stdout == 'Enforcing'
fail_msg: >-
Atlas SELinux is not enforcing. Reboot if requested by the preceding
SELinux task, then rerun the hardening role before enabling services.
when: not ansible_check_mode

View File

@@ -5,11 +5,17 @@
- name: Import Atlas 45Drives tasks
ansible.builtin.import_tasks: cockpit.yml
- name: Import Atlas network hardening tasks
ansible.builtin.import_tasks: hardening.yml
- name: Import Atlas pool bootstrap tasks
ansible.builtin.import_tasks: pool.yml
- name: Import Atlas storage tasks
ansible.builtin.import_tasks: storage.yml
- name: Import Atlas file sharing tasks
ansible.builtin.import_tasks: sharing.yml
- name: Import Atlas Syncthing tasks
ansible.builtin.import_tasks: syncthing.yml
- name: Import Atlas media-container tasks
ansible.builtin.import_tasks: containers.yml

View File

@@ -0,0 +1,57 @@
---
- name: Bootstrap Atlas ZFS pool
tags: [atlas, storage, pool]
when: atlas_create_pool | bool
block:
- name: Validate Atlas pool bootstrap inputs
ansible.builtin.assert:
that:
- atlas_zpool_disks | length == 4
- atlas_zpool_disks | unique | length == 4
- atlas_zpool_disks | select('match', '^/dev/disk/by-id/') | list | length == 4
fail_msg: >-
Set exactly four distinct persistent /dev/disk/by-id paths in
atlas_zpool_disks before creating the RAIDZ2 pool.
- name: Inspect declared Atlas pool disks
ansible.builtin.stat:
path: "{{ item }}"
follow: true
loop: "{{ atlas_zpool_disks }}"
loop_control:
label: "{{ item }}"
register: atlas_zpool_disk_stats
- name: Require every declared Atlas pool disk
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isblk | default(false)
fail_msg: "Declared Atlas pool disk is unavailable or is not a block device: {{ item.item }}"
loop: "{{ atlas_zpool_disk_stats.results }}"
loop_control:
label: "{{ item.item }}"
- name: Check whether the Atlas ZFS pool already exists
ansible.builtin.command:
argv:
- zpool
- list
- -H
- -o
- name
- "{{ atlas_zfs_pool }}"
register: atlas_zpool_bootstrap_check
changed_when: false
failed_when: atlas_zpool_bootstrap_check.rc not in [0, 1]
- name: Create the Atlas RAIDZ2 pool when absent
community.general.zpool:
name: "{{ atlas_zfs_pool }}"
state: present
mountpoint: "{{ atlas_mount_root }}"
force: false
vdevs:
- type: raidz2
disks: "{{ atlas_zpool_disks }}"
when: atlas_zpool_bootstrap_check.rc == 1

View File

@@ -0,0 +1,64 @@
---
- name: Require an existing Unix account for Atlas Samba
ansible.builtin.getent:
database: passwd
key: "{{ atlas_samba_account.username }}"
- name: Read Atlas Samba account
ansible.builtin.command:
argv:
- pdbedit
- --list
- --user
- "{{ atlas_samba_account.username }}"
register: atlas_samba_account_check
changed_when: false
failed_when: false
- name: Check Atlas Samba password marker
ansible.builtin.stat:
path: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
register: atlas_samba_password_marker_stat
- name: Read Atlas Samba password marker
ansible.builtin.slurp:
src: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
register: atlas_samba_password_marker_content
no_log: true
when: atlas_samba_password_marker_stat.stat.exists
- name: Determine whether Atlas Samba credentials must change
ansible.builtin.set_fact:
atlas_samba_password_digest: "{{ atlas_samba_account.password | hash('sha256') }}"
atlas_samba_password_update_required: >-
{{
atlas_samba_account_check.rc != 0
or not atlas_samba_password_marker_stat.stat.exists
or (
atlas_samba_password_marker_content.content | default('')
| b64decode | trim
) != (atlas_samba_account.password | hash('sha256'))
}}
no_log: true
- name: Set Atlas Samba account password
ansible.builtin.command:
argv:
- smbpasswd
- -s
- -a
- "{{ atlas_samba_account.username }}"
stdin: "{{ atlas_samba_account.password }}\n{{ atlas_samba_account.password }}"
changed_when: true
no_log: true
when: atlas_samba_password_update_required | bool
- name: Record managed Atlas Samba password digest
ansible.builtin.copy:
content: "{{ atlas_samba_password_digest }}\n"
dest: "{{ atlas_samba_password_marker_dir }}/{{ atlas_samba_account.username }}.sha256"
owner: root
group: root
mode: "0600"
no_log: true
when: atlas_samba_password_update_required | bool

View File

@@ -1,187 +1,12 @@
---
- name: Render Atlas NFS exports
tags: [atlas, sharing]
ansible.builtin.template:
src: atlas.exports.j2
dest: /etc/exports.d/atlas.exports
owner: root
group: root
mode: "0644"
notify: Reload NFS exports
when: atlas_manage_storage | bool
- name: Configure Atlas NFSv4-only service
tags: [atlas, sharing]
ansible.builtin.template:
src: atlas-nfs.conf.j2
dest: /etc/nfs.conf.d/atlas.conf
owner: root
group: root
mode: "0644"
notify: Restart NFS server
when: atlas_manage_storage | bool
- name: Mask Atlas NFSv3 RPC services
tags: [atlas, sharing, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
state: stopped
masked: true
loop:
- rpc-statd.service
- rpcbind.service
- rpcbind.socket
loop_control:
label: "{{ item }}"
when: atlas_manage_storage | bool
- name: Ensure Atlas NFS mount daemon drop-in directory exists
tags: [atlas, sharing, services]
ansible.builtin.file:
path: /etc/systemd/system/nfs-mountd.service.d
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_storage | bool
- name: Disable Atlas NFSv3 mount daemon listeners
tags: [atlas, sharing, services]
ansible.builtin.template:
src: nfs-mountd-v4only.conf.j2
dest: /etc/systemd/system/nfs-mountd.service.d/v4only.conf
owner: root
group: root
mode: "0644"
notify: Restart NFS mount daemon
when: atlas_manage_storage | bool
- name: Enable SELinux access for Atlas file sharing
tags: [atlas, sharing, services]
ansible.posix.seboolean:
name: "{{ item }}"
state: true
persistent: true
loop: "{{ atlas_selinux_booleans }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_storage | bool
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
- name: Render Atlas Samba configuration
tags: [atlas, sharing]
ansible.builtin.template:
src: smb.conf.j2
dest: /etc/samba/smb.conf
owner: root
group: root
mode: "0644"
validate: "testparm --suppress-prompt %s"
notify: Restart Samba service
when: atlas_manage_storage | bool
- name: Require Atlas Samba password
tags: [atlas, sharing]
ansible.builtin.assert:
that:
- atlas_samba_password | length > 0
fail_msg: Define vault_atlas_samba_password before enabling Atlas storage.
no_log: true
when: atlas_manage_storage | bool
- name: Read Atlas Samba account
tags: [atlas, sharing]
ansible.builtin.command:
argv:
- pdbedit
- --list
- --user
- "{{ atlas_admin_username }}"
register: atlas_samba_account
changed_when: false
failed_when: false
when: atlas_manage_storage | bool
- name: Ensure Atlas Samba private state directory exists
tags: [atlas, sharing]
ansible.builtin.file:
path: "{{ atlas_samba_password_marker | dirname }}"
state: directory
owner: root
group: root
mode: "0700"
when: atlas_manage_storage | bool
- name: Check Atlas Samba password marker
tags: [atlas, sharing]
ansible.builtin.stat:
path: "{{ atlas_samba_password_marker }}"
register: atlas_samba_password_marker_stat
when: atlas_manage_storage | bool
- name: Read Atlas Samba password marker
tags: [atlas, sharing]
ansible.builtin.slurp:
src: "{{ atlas_samba_password_marker }}"
register: atlas_samba_password_marker_content
no_log: true
when:
- atlas_manage_storage | bool
- atlas_samba_password_marker_stat.stat.exists
- name: Determine whether Atlas Samba credentials must change
tags: [atlas, sharing]
ansible.builtin.set_fact:
atlas_samba_password_digest: "{{ atlas_samba_password | hash('sha256') }}"
atlas_samba_password_update_required: >-
{{
atlas_samba_account.rc != 0
or not atlas_samba_password_marker_stat.stat.exists
or (
atlas_samba_password_marker_content.content | default('')
| b64decode | trim
) != (atlas_samba_password | hash('sha256'))
}}
no_log: true
when: atlas_manage_storage | bool
- name: Set Atlas Samba account password
tags: [atlas, sharing]
ansible.builtin.command:
argv:
- smbpasswd
- -s
- -a
- "{{ atlas_admin_username }}"
stdin: "{{ atlas_samba_password }}\n{{ atlas_samba_password }}"
changed_when: true
no_log: true
when:
- atlas_manage_storage | bool
- atlas_samba_password_update_required | bool
- name: Record managed Atlas Samba password digest
tags: [atlas, sharing]
ansible.builtin.copy:
content: "{{ atlas_samba_password_digest }}\n"
dest: "{{ atlas_samba_password_marker }}"
owner: root
group: root
mode: "0600"
no_log: true
when:
- atlas_manage_storage | bool
- atlas_samba_password_update_required | bool
- name: Require completed Atlas firewall placeholders
tags: [atlas, sharing, services]
ansible.builtin.assert:
that:
- atlas_lan_subnet != 'CHANGEME_LAN_SUBNET'
- atlas_aegis_ip != 'CHANGEME_AEGIS_IP'
- atlas_firewalld_zone | length > 0
fail_msg: Replace the Atlas LAN subnet and firewall zone placeholders.
- ansible_facts.default_ipv4.interface | default('') | length > 0
fail_msg: Replace the Atlas LAN, Aegis and firewall-zone placeholders.
when: atlas_manage_firewall | bool
- name: Apply Atlas firewalld rich rules
@@ -197,6 +22,16 @@
label: "{{ item }}"
when: atlas_manage_firewall | bool
- name: Assign primary Atlas LAN interface to managed firewalld zone
tags: [atlas, sharing, services]
ansible.posix.firewalld:
interface: "{{ ansible_facts.default_ipv4.interface }}"
zone: "{{ atlas_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
when: atlas_manage_firewall | bool
- name: Remove unrestricted Atlas services from firewalld zone
tags: [atlas, sharing, services]
ansible.posix.firewalld:
@@ -209,3 +44,228 @@
loop_control:
label: "{{ item }}"
when: atlas_manage_firewall | bool
- name: Render Atlas NFS exports
tags: [atlas, sharing]
ansible.builtin.template:
src: atlas.exports.j2
dest: /etc/exports.d/atlas.exports
owner: root
group: root
mode: "0644"
notify: Reload NFS exports
when: atlas_manage_sharing | bool
- name: Ensure Atlas NFS configuration drop-in directory exists
tags: [atlas, sharing]
ansible.builtin.file:
path: /etc/nfs.conf.d
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_sharing | bool
- name: Configure Atlas NFSv4-only service
tags: [atlas, sharing]
ansible.builtin.template:
src: atlas-nfs.conf.j2
dest: /etc/nfs.conf.d/atlas.conf
owner: root
group: root
mode: "0644"
notify: Restart NFS server
when: atlas_manage_sharing | bool
- name: Mask Atlas NFSv3 RPC services
tags: [atlas, sharing, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
state: stopped
masked: true
loop:
- rpc-statd.service
- rpcbind.service
- rpcbind.socket
loop_control:
label: "{{ item }}"
when: atlas_manage_sharing | bool
- name: Ensure Atlas NFS mount daemon drop-in directory exists
tags: [atlas, sharing, services]
ansible.builtin.file:
path: /etc/systemd/system/nfs-mountd.service.d
state: directory
owner: root
group: root
mode: "0755"
when: atlas_manage_sharing | bool
- name: Disable Atlas NFSv3 mount daemon listeners
tags: [atlas, sharing, services]
ansible.builtin.template:
src: nfs-mountd-v4only.conf.j2
dest: /etc/systemd/system/nfs-mountd.service.d/v4only.conf
owner: root
group: root
mode: "0644"
notify: Restart NFS mount daemon
when: atlas_manage_sharing | bool
- name: Enable SELinux access for Atlas file sharing
tags: [atlas, sharing, services]
ansible.posix.seboolean:
name: "{{ item }}"
state: true
persistent: true
loop: "{{ atlas_selinux_booleans }}"
loop_control:
label: "{{ item }}"
when:
- atlas_manage_sharing | bool
- (ansible_facts['selinux'] | default({})).get('status', 'disabled') == 'enabled'
- name: Render Atlas Samba configuration
tags: [atlas, sharing]
ansible.builtin.template:
src: smb.conf.j2
dest: /etc/samba/smb.conf
owner: root
group: root
mode: "0644"
validate: "testparm --suppress-prompt %s"
notify: Restart Samba service
when: atlas_manage_sharing | bool
- name: Require Vault-backed Atlas Samba accounts
tags: [atlas, sharing]
ansible.builtin.assert:
that:
- atlas_samba_encryption in ['required', 'desired']
- atlas_samba_accounts | length > 0
- >-
atlas_samba_accounts | map(attribute='username') | list
| difference(atlas_samba_valid_users) | length == 0
- >-
atlas_samba_valid_users
| difference(atlas_samba_accounts | map(attribute='username') | list) | length == 0
- atlas_samba_accounts | selectattr('password', 'equalto', '') | list | length == 0
fail_msg: Define every authorized Samba account and its Vault-backed password.
no_log: true
when: atlas_manage_sharing | bool
- name: Ensure Atlas Samba private state directory exists
tags: [atlas, sharing]
ansible.builtin.file:
path: "{{ atlas_samba_password_marker_dir }}"
state: directory
owner: root
group: root
mode: "0700"
when: atlas_manage_sharing | bool
- name: Manage Vault-backed Atlas Samba credentials
tags: [atlas, sharing]
ansible.builtin.include_tasks: samba_account.yml
loop: "{{ atlas_samba_accounts }}"
loop_control:
loop_var: atlas_samba_account
label: "{{ atlas_samba_account.username }}"
no_log: true
when: atlas_manage_sharing | bool
- name: Enable Atlas file-sharing services
tags: [atlas, sharing, services]
ansible.builtin.systemd:
name: "{{ item }}"
enabled: true
state: started
loop:
- nfs-server.service
- smb.service
loop_control:
label: "{{ item }}"
when: atlas_manage_sharing | bool
- name: Gather effective Atlas firewalld zone configuration
tags: [atlas, sharing, services, security]
ansible.posix.firewalld_info:
zones:
- "{{ atlas_firewalld_zone }}"
register: atlas_firewalld_info
when:
- atlas_manage_firewall | bool
- not ansible_check_mode
- name: Verify effective Atlas firewalld restrictions
tags: [atlas, sharing, services, security]
ansible.builtin.assert:
that:
- atlas_firewalld_zone in atlas_firewalld_info.firewalld_info.zones
- >-
ansible_facts.default_ipv4.interface
in atlas_firewalld_info.firewalld_info.zones[atlas_firewalld_zone].interfaces
- >-
atlas_firewalld_restricted_services
| intersect(atlas_firewalld_info.firewalld_info.zones[atlas_firewalld_zone].services)
| length == 0
fail_msg: >-
The effective Atlas firewalld zone does not restrict the primary LAN
interface and unmanaged service exposure as declared.
when:
- atlas_manage_firewall | bool
- not ansible_check_mode
- name: Apply pending Atlas sharing handlers before verification
tags: [atlas, sharing, services, security]
ansible.builtin.meta: flush_handlers
when:
- atlas_manage_sharing | bool
- not ansible_check_mode
- name: Check active Atlas file-sharing services
tags: [atlas, sharing, services, security]
ansible.builtin.command:
argv:
- systemctl
- is-active
- --quiet
- "{{ item }}"
loop:
- nfs-server.service
- smb.service
loop_control:
label: "{{ item }}"
register: atlas_file_sharing_service_activity
changed_when: false
failed_when: false
when:
- atlas_manage_sharing | bool
- not ansible_check_mode
- name: Require active Atlas file-sharing services
tags: [atlas, sharing, services, security]
ansible.builtin.assert:
that:
- atlas_file_sharing_service_activity.results | map(attribute='rc') | list == [0, 0]
fail_msg: Atlas NFSv4 or SMB3 did not start after its managed configuration was applied.
when:
- atlas_manage_sharing | bool
- not ansible_check_mode
- name: Require Atlas file-sharing TCP listeners
tags: [atlas, sharing, services, security]
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: started
timeout: 15
loop:
- 2049
- 445
loop_control:
label: "{{ item }}/tcp"
when:
- atlas_manage_sharing | bool
- not ansible_check_mode

View File

@@ -5,12 +5,80 @@
atlas_zfs_datasets:
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_work }}"
mountpoint: "{{ atlas_work_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
mountpoint: "{{ atlas_work_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_archive }}"
mountpoint: "{{ atlas_archive_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_archive_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
mountpoint: "{{ atlas_app_data_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_app_data_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_navidrome }}"
mountpoint: "{{ atlas_navidrome_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: zstd
mountpoint: "{{ atlas_navidrome_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_syncthing }}"
mountpoint: "{{ atlas_syncthing_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_syncthing_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_media }}"
mountpoint: "{{ atlas_media_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_media_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_music }}"
mountpoint: "{{ atlas_music_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_music_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backup_prometheus }}"
mountpoint: "{{ atlas_backup_prometheus_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_icloud_photos }}"
mountpoint: "{{ atlas_icloud_photos_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
mountpoint: "{{ atlas_backup_prometheus_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_photobook }}"
mountpoint: "{{ atlas_photobook_mountpoint }}"
owner: "{{ atlas_immich_username }}"
group: "{{ atlas_immich_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_photobook_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_backups }}"
mountpoint: "{{ atlas_backups_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_backups_mountpoint }}"
- name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_service_backups }}"
mountpoint: "{{ atlas_service_backups_mountpoint }}"
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
properties:
compression: lz4
mountpoint: "{{ atlas_service_backups_mountpoint }}"
refreservation: "{{ atlas_zfs_service_backups_refreservation }}"
- name: Require completed Atlas storage placeholders
tags: [atlas, storage]
@@ -56,8 +124,7 @@
community.general.zfs:
name: "{{ item.name }}"
state: present
extra_zfs_properties:
mountpoint: "{{ item.mountpoint }}"
extra_zfs_properties: "{{ item.properties }}"
loop: "{{ atlas_zfs_datasets }}"
loop_control:
label: "{{ item.name }}"
@@ -68,8 +135,8 @@
ansible.builtin.file:
path: "{{ item.mountpoint }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "0770"
loop: "{{ atlas_zfs_datasets }}"
loop_control:

View File

@@ -1,40 +0,0 @@
---
- name: Create Atlas Syncthing configuration directory
tags: [atlas, syncthing]
ansible.builtin.file:
path: "{{ atlas_syncthing_config_dir }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0700"
when: atlas_manage_storage | bool
- name: Create Atlas Syncthing default data directory
tags: [atlas, syncthing]
ansible.builtin.file:
path: "{{ atlas_syncthing_default_dir }}"
state: directory
owner: "{{ atlas_admin_username }}"
group: "{{ atlas_admin_group }}"
mode: "0770"
when: atlas_manage_storage | bool
- name: Render Atlas Syncthing systemd service
tags: [atlas, syncthing]
ansible.builtin.template:
src: atlas-syncthing.service.j2
dest: /etc/systemd/system/atlas-syncthing.service
owner: root
group: root
mode: "0644"
notify: Restart Atlas Syncthing service
when: atlas_manage_storage | bool
- name: Enable Atlas Syncthing service
tags: [atlas, syncthing]
ansible.builtin.systemd:
name: atlas-syncthing
enabled: true
state: started
daemon_reload: true
when: atlas_manage_storage | bool

View File

@@ -3,4 +3,22 @@ PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
AuthorizedKeysFile {{
atlas_admin_ssh_keys
| map(attribute='name')
| map('regex_replace', '^', '%h/.ssh/authorized_keys.d/')
| join(' ')
}}
X11Forwarding no
AllowTcpForwarding {{ atlas_ssh_allow_tcp_forwarding }}
AllowAgentForwarding no
GatewayPorts no
PermitTunnel no
PermitUserEnvironment no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
AllowUsers {{ atlas_admin_username }}

View File

@@ -0,0 +1,27 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich machine learning
[Container]
ContainerName=atlas-immich-machine-learning
Image={{ atlas_immich_machine_learning_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
{% for group_name in atlas_immich_supplementary_groups %}
GroupAdd={{ ansible_facts['getent_group'][group_name][1] }}
{% endfor %}
AddDevice=/dev/dri
Network=atlas-media.network
NetworkAlias=atlas-immich-machine-learning
Environment=TZ={{ atlas_timezone }}
Volume={{ atlas_immich_model_cache_dir }}:/cache:Z
Volume={{ atlas_immich_machine_learning_config_dir }}:/.config:Z
Volume={{ atlas_immich_machine_learning_cache_dir }}:/.cache:Z
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,22 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich PostgreSQL database
[Container]
ContainerName=atlas-immich-postgres
Image={{ atlas_immich_postgres_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
Network=atlas-media.network
NetworkAlias=atlas-immich-postgres
EnvironmentFile=/etc/immich/immich.env
Volume={{ atlas_immich_postgres_data_dir }}:/var/lib/postgresql/data:Z
ShmSize=128m
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,20 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich Redis-compatible cache
[Container]
ContainerName=atlas-immich-redis
Image={{ atlas_immich_redis_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
Network=atlas-media.network
NetworkAlias=atlas-immich-redis
Volume={{ atlas_immich_redis_data_dir }}:/data:Z
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,29 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Immich server
Requires=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service
After=atlas-immich-redis.service atlas-immich-postgres.service atlas-immich-machine-learning.service
[Container]
ContainerName=atlas-immich-server
Image={{ atlas_immich_server_image }}
User={{ atlas_immich_uid }}:{{ atlas_immich_gid }}
{% for group_name in atlas_immich_supplementary_groups %}
GroupAdd={{ ansible_facts['getent_group'][group_name][1] }}
{% endfor %}
AddDevice=/dev/dri
Network=atlas-media.network
NetworkAlias=atlas-immich-server
EnvironmentFile=/etc/immich/immich.env
Volume={{ atlas_immich_upload_dir }}:/data:Z
Volume={{ atlas_photobook_mountpoint }}:/external/photobook:ro,Z
Volume=/etc/localtime:/etc/localtime:ro
NoNewPrivileges=true
DropCapability=NET_RAW
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
# Managed by Ansible. Do not edit manually.
[Network]
NetworkName=atlas-media

View File

@@ -0,0 +1,21 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas Nginx Proxy Manager
[Container]
ContainerName=atlas-npm
Image={{ atlas_npm_image }}
Network=atlas-media.network
NetworkAlias=atlas-npm
PublishPort=80:80
PublishPort=443:443
PublishPort={{ atlas_npm_admin_bind_address }}:81:81
Volume={{ atlas_npm_data_dir }}:/data:Z
Volume={{ atlas_npm_letsencrypt_dir }}:/etc/letsencrypt:Z
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target

View File

@@ -1,16 +0,0 @@
[Unit]
Description=Atlas Syncthing service
After=network-online.target
Wants=network-online.target
[Service]
User={{ atlas_admin_username }}
Group={{ atlas_admin_group }}
WorkingDirectory={{ atlas_syncthing_mountpoint }}
Environment=HOME={{ atlas_syncthing_mountpoint }}
ExecStart=/usr/local/bin/syncthing serve --no-browser --no-restart --no-upgrade --home={{ atlas_syncthing_config_dir }}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -1,4 +1,4 @@
# Managed by Ansible. Do not edit manually.
{% for export in atlas_nfs_exports %}
{{ export.path }} {{ export.clients }}
{{ export.path }} {{ export.client }}({{ export.options | join(',') }})
{% endfor %}

View File

@@ -0,0 +1,13 @@
# Managed by Ansible. Contains a Vault secret; do not edit or copy.
TZ={{ atlas_timezone | to_json }}
DB_HOSTNAME="atlas-immich-postgres"
DB_USERNAME={{ atlas_immich_db_username | to_json }}
DB_PASSWORD={{ atlas_immich_db_password | to_json }}
DB_DATABASE_NAME={{ atlas_immich_db_name | to_json }}
DB_VECTOR_EXTENSION="vectorchord"
POSTGRES_USER={{ atlas_immich_db_username | to_json }}
POSTGRES_PASSWORD={{ atlas_immich_db_password | to_json }}
POSTGRES_DB={{ atlas_immich_db_name | to_json }}
POSTGRES_INITDB_ARGS="--data-checksums"
REDIS_HOSTNAME="atlas-immich-redis"
IMMICH_MACHINE_LEARNING_URL="http://atlas-immich-machine-learning:3003"

View File

@@ -3,11 +3,16 @@
workgroup = {{ atlas_samba_workgroup }}
security = user
map to guest = Never
server min protocol = SMB2
server min protocol = SMB3
server signing = mandatory
smb encrypt = {{ atlas_samba_encryption }}
disable netbios = yes
smb ports = 445
hosts allow = {{ atlas_lan_subnet }}
include = registry
[{{ atlas_samba_share_name }}]
path = {{ atlas_work_mountpoint }}
path = {{ atlas_archive_mountpoint }}
browseable = yes
read only = no
valid users = {{ atlas_samba_valid_users | join(' ') }}

View File

@@ -0,0 +1,28 @@
---
backend_phase1_enabled: false
backend_phase1_start_services: false
backend_phase1_username: "{{ atlas_admin_username }}"
backend_phase1_user_group: "{{ atlas_admin_group }}"
backend_phase1_user_home: "{{ atlas_admin_home }}"
backend_phase1_quadlet_dir: "{{ backend_phase1_user_home }}/.config/containers/systemd"
backend_phase1_wireguard_interface: wg0
backend_phase1_wireguard_address: CHANGEME_ATLAS_WIREGUARD_ADDRESS
backend_phase1_wireguard_firewalld_zone: wireguard
backend_phase1_music_dir: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_music }}"
backend_phase1_app_data_root: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_app_data }}"
backend_phase1_navidrome_data_dir: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_navidrome }}"
backend_phase1_syncthing_root: "{{ atlas_mount_root }}/{{ atlas_zfs_dataset_syncthing }}"
backend_phase1_music_dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_music }}"
backend_phase1_app_data_dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}"
backend_phase1_navidrome_dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_navidrome }}"
backend_phase1_syncthing_dataset: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_syncthing }}"
backend_phase1_navidrome_image: docker.io/deluan/navidrome:0.63.2
backend_phase1_syncthing_image: docker.io/syncthing/syncthing:2.1.3
backend_phase1_navidrome_port: 4533
backend_phase1_syncthing_gui_port: 8384
backend_phase1_syncthing_transfer_port: 22000
backend_phase1_syncthing_discovery_port: 21027
backend_phase1_timezone: Europe/Rome
backend_phase1_services:
- atlas-navidrome.service
- atlas-syncthing.service

View File

@@ -0,0 +1,32 @@
---
- name: Restart Atlas Navidrome Quadlet
tags: [backend_phase1, navidrome, podman, services]
become_user: "{{ backend_phase1_username }}"
ansible.builtin.systemd:
name: atlas-navidrome.service
scope: user
state: restarted
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus"
when:
- backend_phase1_enabled | bool
- backend_phase1_start_services | bool
- not ansible_check_mode
- name: Restart Atlas Syncthing Quadlet
tags: [backend_phase1, syncthing, podman, services]
become_user: "{{ backend_phase1_username }}"
ansible.builtin.systemd:
name: atlas-syncthing.service
scope: user
state: restarted
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus"
when:
- backend_phase1_enabled | bool
- backend_phase1_start_services | bool
- not ansible_check_mode

View File

@@ -0,0 +1,163 @@
---
- name: Configure Atlas phase-one backend services
tags: [backend_phase1, podman]
when: backend_phase1_enabled | bool
block:
- name: Validate phase-one backend inputs
ansible.builtin.assert:
that:
- not (atlas_manage_media_stack | bool)
- backend_phase1_wireguard_interface in ansible_facts.interfaces
- backend_phase1_wireguard_address != 'CHANGEME_ATLAS_WIREGUARD_ADDRESS'
- backend_phase1_music_dir.startswith('/')
- backend_phase1_app_data_root.startswith('/')
- backend_phase1_navidrome_data_dir.startswith(backend_phase1_app_data_root + '/')
- backend_phase1_syncthing_root.startswith(backend_phase1_app_data_root + '/')
fail_msg: >-
Disable the rootful media-stack gate and provide the active
WireGuard interface/address and absolute ZFS-backed paths before
enabling phase one. This role does not manage Prometheus or migrate
Navidrome application data.
- name: Read the rootless service account
ansible.builtin.getent:
database: passwd
key: "{{ backend_phase1_username }}"
- name: Record rootless service account IDs
ansible.builtin.set_fact:
backend_phase1_uid: "{{ ansible_facts['getent_passwd'][backend_phase1_username][1] }}"
backend_phase1_gid: "{{ ansible_facts['getent_passwd'][backend_phase1_username][2] }}"
- name: Read system service state before starting rootless Syncthing
ansible.builtin.service_facts:
- name: Refuse to overlap a system-level Atlas Syncthing service
ansible.builtin.assert:
that:
- >-
ansible_facts.services['atlas-syncthing.service'] is not defined
or ansible_facts.services['atlas-syncthing.service'].state != 'running'
fail_msg: >-
Remove or stop the system-level atlas-syncthing.service before
enabling the rootless Syncthing Quadlet.
- name: Inspect required ZFS datasets
community.general.zfs_facts:
name: "{{ item.dataset }}"
properties: name,mounted,mountpoint
loop:
- dataset: "{{ backend_phase1_music_dataset }}"
mountpoint: "{{ backend_phase1_music_dir }}"
- dataset: "{{ backend_phase1_app_data_dataset }}"
mountpoint: "{{ backend_phase1_app_data_root }}"
- dataset: "{{ backend_phase1_navidrome_dataset }}"
mountpoint: "{{ backend_phase1_navidrome_data_dir }}"
- dataset: "{{ backend_phase1_syncthing_dataset }}"
mountpoint: "{{ backend_phase1_syncthing_root }}"
loop_control:
label: "{{ item.dataset }}"
register: backend_phase1_zfs_facts
- name: Require mounted datasets at the declared paths
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
fail_msg: >-
{{ item.item.dataset }} must already be mounted at
{{ item.item.mountpoint }}. The phase-one role never creates or
imports the pool.
loop: "{{ backend_phase1_zfs_facts.results }}"
loop_control:
label: "{{ item.item.dataset }}"
- name: Enable lingering for the rootless service account
ansible.builtin.command:
argv:
- loginctl
- enable-linger
- "{{ backend_phase1_username }}"
creates: "/var/lib/systemd/linger/{{ backend_phase1_username }}"
- name: Start the rootless user systemd manager
ansible.builtin.systemd:
name: "user@{{ backend_phase1_uid }}.service"
state: started
when: not ansible_check_mode
- name: Create rootless Quadlet and application directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ backend_phase1_username }}"
group: "{{ backend_phase1_user_group }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ backend_phase1_quadlet_dir }}"
mode: "0700"
- path: "{{ backend_phase1_navidrome_data_dir }}"
mode: "0750"
- path: "{{ backend_phase1_syncthing_root }}"
mode: "0750"
loop_control:
label: "{{ item.path }}"
- name: Render the rootless Navidrome Quadlet
ansible.builtin.template:
src: atlas-navidrome.container.j2
dest: "{{ backend_phase1_quadlet_dir }}/atlas-navidrome.container"
owner: "{{ backend_phase1_username }}"
group: "{{ backend_phase1_user_group }}"
mode: "0644"
notify: Restart Atlas Navidrome Quadlet
- name: Render the rootless Syncthing Quadlet
ansible.builtin.template:
src: atlas-syncthing.container.j2
dest: "{{ backend_phase1_quadlet_dir }}/atlas-syncthing.container"
owner: "{{ backend_phase1_username }}"
group: "{{ backend_phase1_user_group }}"
mode: "0644"
notify: Restart Atlas Syncthing Quadlet
- name: Reload the rootless user systemd manager
become_user: "{{ backend_phase1_username }}"
ansible.builtin.systemd:
scope: user
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus"
when: not ansible_check_mode
- name: Permit phase-one services only through the WireGuard zone
ansible.posix.firewalld:
port: "{{ item }}"
zone: "{{ backend_phase1_wireguard_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
loop:
- "{{ backend_phase1_navidrome_port }}/tcp"
- "{{ backend_phase1_syncthing_gui_port }}/tcp"
- "{{ backend_phase1_syncthing_transfer_port }}/tcp"
- "{{ backend_phase1_syncthing_transfer_port }}/udp"
- "{{ backend_phase1_syncthing_discovery_port }}/udp"
- name: Start rootless phase-one Quadlets
become_user: "{{ backend_phase1_username }}"
ansible.builtin.systemd:
name: "{{ item }}"
scope: user
state: started
enabled: true
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ backend_phase1_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ backend_phase1_uid }}/bus"
loop: "{{ backend_phase1_services }}"
when:
- backend_phase1_start_services | bool
- not ansible_check_mode

View File

@@ -0,0 +1,28 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas rootless Navidrome backend
[Container]
ContainerName=atlas-navidrome
Image={{ backend_phase1_navidrome_image }}
UserNS=keep-id
User={{ backend_phase1_uid }}
Group={{ backend_phase1_gid }}
PublishPort={{ backend_phase1_wireguard_address }}:{{ backend_phase1_navidrome_port }}:4533
Environment=ND_LOGLEVEL=info
Environment=ND_SCANSCHEDULE=1h
Environment=ND_SESSIONTIMEOUT=24h
Environment=ND_ENABLETRANSCODING=true
Environment=TZ={{ backend_phase1_timezone }}
Volume={{ backend_phase1_navidrome_data_dir }}:/data:Z
Volume={{ backend_phase1_music_dir }}:/music:ro,z
NoNewPrivileges=true
DropCapability=all
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,30 @@
# Managed by Ansible. Do not edit manually.
[Unit]
Description=Atlas rootless Syncthing backend
[Container]
ContainerName=atlas-syncthing
Image={{ backend_phase1_syncthing_image }}
HostName=atlas-syncthing
UserNS=keep-id
User={{ backend_phase1_uid }}
Group={{ backend_phase1_gid }}
PublishPort={{ backend_phase1_wireguard_address }}:{{ backend_phase1_syncthing_gui_port }}:{{ backend_phase1_syncthing_gui_port }}
PublishPort={{ backend_phase1_wireguard_address }}:{{ backend_phase1_syncthing_transfer_port }}:{{ backend_phase1_syncthing_transfer_port }}
PublishPort={{ backend_phase1_wireguard_address }}:{{ backend_phase1_syncthing_transfer_port }}:{{ backend_phase1_syncthing_transfer_port }}/udp
PublishPort={{ backend_phase1_wireguard_address }}:{{ backend_phase1_syncthing_discovery_port }}:{{ backend_phase1_syncthing_discovery_port }}/udp
Environment=HOME=/var/syncthing
Environment=STHOMEDIR=/var/syncthing/config
Environment=STGUIADDRESS=0.0.0.0:{{ backend_phase1_syncthing_gui_port }}
Environment=TZ={{ backend_phase1_timezone }}
Volume={{ backend_phase1_syncthing_root }}:/var/syncthing:Z
NoNewPrivileges=true
DropCapability=all
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=900
[Install]
WantedBy=default.target

View File

@@ -556,6 +556,30 @@
loop_control:
label: "{{ item.name }}"
- name: Install shared AI coding agents on Void desktop
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.value.npm_package }}"
global: true
state: latest
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.install_enabled | bool
loop_control:
label: "{{ item.key }}"
- name: Uninstall shared AI coding agents on Void desktop
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.value.npm_package }}"
global: true
state: absent
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.uninstall_enabled | bool
loop_control:
label: "{{ item.key }}"
- name: Install desktop Python tools with uv
tags: [packages]
ansible.builtin.command:

View File

@@ -10,15 +10,3 @@
loop: "{{ personal_workstation_directories | default([]) }}"
loop_control:
label: "{{ item.path }}"
- name: Copy personal workstation dotfiles
tags: [dotfiles, dotfiles:workstation]
ansible.builtin.copy:
src: "{{ playbook_dir }}/../dotfiles/workstation/{{ item.src }}"
dest: "{{ user_home }}/{{ item.dest }}"
owner: "{{ username }}"
group: "{{ user_group }}"
mode: "{{ item.mode }}"
loop: "{{ personal_workstation_dotfiles | default([]) }}"
loop_control:
label: "{{ item.dest }}"

View File

@@ -2,5 +2,10 @@
- name: Reload SSH service
tags: [services]
ansible.builtin.service:
name: ssh
name: "{{ server_sshd_service_name }}"
state: reloaded
- name: Reload systemd for Podman Compose
tags: [services]
ansible.builtin.systemd:
daemon_reload: true

View File

@@ -0,0 +1,33 @@
---
- 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,5 +1,37 @@
---
- name: Require supported server firewall backend
tags: [services, packages]
ansible.builtin.assert:
that:
- server_firewall_backend == 'firewalld'
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:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop: >-
{{
(server_directories | default([]))
+ (
server_syncthing_directories | default([])
if server_syncthing_enabled | default(true) | bool
else []
)
}}
loop_control:
label: "{{ item.path }}"
- name: Copy server dotfiles
tags: [dotfiles, dotfiles:server]
ansible.builtin.copy:
@@ -12,28 +44,6 @@
loop_control:
label: "{{ item.dest }}"
- name: Require server container secret variables
tags: [dotfiles, dotfiles:server, services]
ansible.builtin.assert:
that:
- (vault_navidrome_db_password | default('')) | length > 0
- (vault_postgres_root_password | default('')) | length > 0
fail_msg: >-
Server container secrets are missing. Define vault_navidrome_db_password and
vault_postgres_root_password in secrets/vault.yml or another vars source.
- name: Ensure server directories exist
tags: [dotfiles, services]
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ item.owner }}"
group: "{{ item.group }}"
mode: "{{ item.mode }}"
loop: "{{ server_directories | default([]) }}"
loop_control:
label: "{{ item.path }}"
- name: Render server templates
tags: [dotfiles, dotfiles:server]
ansible.builtin.template:
@@ -47,6 +57,46 @@
label: "{{ item.dest }}"
no_log: "{{ item.no_log | default(false) }}"
- name: Manage Podman Compose stack
tags: [services, podman]
ansible.builtin.include_tasks: podman-compose.yml
- name: Ensure server SSH authorized key fragments directory exists
tags: [services, ssh]
ansible.builtin.file:
path: "{{ server_ssh_authorized_key_directory }}"
state: directory
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0700"
when: server_ssh_authorized_keys | length > 0
- name: Manage server SSH authorized key fragments
tags: [services, ssh]
ansible.builtin.copy:
content: "{{ item.key }}\n"
dest: "{{ server_ssh_authorized_key_directory }}/{{ item.name }}"
owner: "{{ server_username }}"
group: "{{ server_user_group }}"
mode: "0600"
loop: "{{ server_ssh_authorized_keys }}"
loop_control:
label: "{{ item.name }}"
when: server_ssh_authorized_keys | length > 0
- name: Configure server SSH authorized key fragments
tags: [services, ssh]
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^\s*AuthorizedKeysFile\s+'
line: >-
AuthorizedKeysFile {{ server_ssh_authorized_keys | map(attribute='name')
| map('regex_replace', '^', '%h/.ssh/authorized_keys.d/') | join(' ') }}
state: present
validate: "sshd -t -f %s"
notify: Reload SSH service
when: server_ssh_authorized_keys | length > 0
- name: Disable SSH root login on server
tags: [services]
ansible.builtin.lineinfile:
@@ -68,33 +118,28 @@
notify: Reload SSH service
when: (server_sshd_allow_users | default([])) | length > 0
- name: Define effective server UFW rules
- name: Allow server services through firewalld
tags: [services, packages]
ansible.builtin.set_fact:
server_ufw_rules_effective: "{{ server_ufw_rules | default([]) }}"
- name: Apply server UFW rules
tags: [services, packages]
community.general.ufw:
rule: "{{ item.rule }}"
name: "{{ item.name | default(omit) }}"
port: "{{ item.port | default(omit) }}"
proto: "{{ item.proto | default(omit) }}"
from_ip: "{{ item.src | default(omit) }}"
to_ip: "{{ item.dest | default(omit) }}"
from_port: "{{ item.from_port | default(omit) }}"
direction: "{{ item.direction | default(omit) }}"
interface: "{{ item.interface | default(omit) }}"
interface_in: "{{ item.interface_in | default(omit) }}"
interface_out: "{{ item.interface_out | default(omit) }}"
route: "{{ item.route | default(omit) }}"
comment: "{{ item.comment | default(omit) }}"
loop: "{{ server_ufw_rules_effective }}"
loop_control:
label: "{{ item.name | default(item.port) }}"
- name: Enable UFW firewall on server
tags: [services, packages]
community.general.ufw:
ansible.posix.firewalld:
service: "{{ item }}"
zone: "{{ server_firewalld_zone }}"
state: enabled
when: (server_ufw_rules_effective | default([])) | length > 0
permanent: true
immediate: true
loop: "{{ server_firewalld_services | default([]) }}"
loop_control:
label: "{{ item }}"
when: server_firewall_backend == 'firewalld'
- name: Allow server ports through firewalld
tags: [services, packages]
ansible.posix.firewalld:
port: "{{ item }}"
zone: "{{ server_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
loop: "{{ server_firewalld_ports | default([]) }}"
loop_control:
label: "{{ item }}"
when: server_firewall_backend == 'firewalld'

View File

@@ -0,0 +1,10 @@
---
- name: Render Podman Compose systemd unit
tags: [services, podman]
ansible.builtin.template:
src: podman-compose-server.service.j2
dest: /etc/systemd/system/podman-compose-server.service
owner: root
group: root
mode: "0644"
notify: Reload systemd for Podman Compose

View File

@@ -0,0 +1,24 @@
#!/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

@@ -0,0 +1,15 @@
[Unit]
Description=Podman Compose stack for {{ server_username }}
Requires=network-online.target
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ server_container_stack_dir }}
ExecStart=/usr/bin/podman-compose up -d
ExecStop=/usr/bin/podman-compose down
ExecReload=/usr/bin/podman-compose up -d
[Install]
WantedBy=multi-user.target

View File

@@ -35,19 +35,31 @@
loop_control:
label: "{{ item.dest }}"
- name: Install workstation npm packages
tags: [packages, npm]
- name: Install shared AI coding agents on workstation
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.name }}"
name: "{{ item.value.npm_package }}"
global: true
state: "{{ item.state | default('present') }}"
state: latest
become: true
loop: "{{ workstation_npm_packages | default([]) }}"
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- workstation_manage_opencode | default(false)
- workstation_npm_packages | length > 0
- item.value.install_enabled | bool
loop_control:
label: "{{ item.name }}"
label: "{{ item.key }}"
- name: Uninstall shared AI coding agents on workstation
tags: [packages, npm, ai_agents]
community.general.npm:
name: "{{ item.value.npm_package }}"
global: true
state: absent
become: true
loop: "{{ ai_agents | dict2items | selectattr('value.npm_package', 'defined') | list }}"
when:
- item.value.uninstall_enabled | bool
loop_control:
label: "{{ item.key }}"
- name: Install IBM Bob coding agent
tags: [packages, ai_agents]
@@ -59,4 +71,15 @@
become: true
when:
- workstation_manage_ibm_bob | default(false)
- ai_agents.ibm_bob.install_enabled | bool
- workstation_ibm_bob_install_url | length > 0
- name: Uninstall IBM Bob coding agent binary
tags: [packages, ai_agents]
ansible.builtin.file:
path: /usr/local/bin/bob
state: absent
become: true
when:
- workstation_manage_ibm_bob | default(false)
- ai_agents.ibm_bob.uninstall_enabled | bool

View File

@@ -55,6 +55,39 @@
loop_control:
label: "{{ item.dest }}"
- name: Check whether the pinned Java version is installed with Mise
tags: [packages, mise, java, wsl]
ansible.builtin.command:
cmd: "mise where java@{{ workstation_mise_java_version }}"
become_user: "{{ username }}"
environment:
HOME: "{{ user_home }}"
register: workstation_mise_java_where
changed_when: false
failed_when: false
- name: Check whether the pinned Maven version is installed with Mise
tags: [packages, mise, maven, wsl]
ansible.builtin.command:
cmd: "mise where maven@{{ workstation_mise_maven_version }}"
become_user: "{{ username }}"
environment:
HOME: "{{ user_home }}"
register: workstation_mise_maven_where
changed_when: false
failed_when: false
- name: Install the pinned Mise tools
tags: [packages, mise, java, maven, wsl]
ansible.builtin.command:
cmd: mise install
become_user: "{{ username }}"
environment:
HOME: "{{ user_home }}"
when: >-
workstation_mise_java_where.rc != 0 or
workstation_mise_maven_where.rc != 0
- name: Ensure WSL boot configuration file exists
tags: [packages, services]
ansible.builtin.file:

View File

@@ -1,10 +0,0 @@
---
- name: Enable FreeBSD rc services
tags: [services, packages]
ansible.builtin.service:
name: "{{ item }}"
enabled: true
state: started
loop: "{{ host_enabled_services | default([]) }}"
loop_control:
label: "{{ item }}"

View File

@@ -0,0 +1,18 @@
---
wireguard_overlay_enabled: false
wireguard_interface: wg0
wireguard_config_dir: /etc/wireguard
wireguard_private_key_path: "{{ wireguard_config_dir }}/{{ wireguard_interface }}.key"
wireguard_config_path: "{{ wireguard_config_dir }}/{{ wireguard_interface }}.conf"
wireguard_address: CHANGEME_WIREGUARD_ADDRESS
wireguard_listen_port: 0
wireguard_mtu: 1420
wireguard_firewalld_zone: wireguard
wireguard_public_firewalld_zone: public
wireguard_enable_ipv4_forwarding: false
wireguard_reload_rootful_podman_networks: false
wireguard_handshake_retries: 12
wireguard_handshake_delay: 5
wireguard_peers: []
wireguard_packages:
- wireguard-tools

View File

@@ -0,0 +1,10 @@
---
- name: Restart WireGuard interface
tags: [wireguard, services]
ansible.builtin.systemd:
name: "wg-quick@{{ wireguard_interface }}.service"
state: restarted
daemon_reload: true
when:
- wireguard_overlay_enabled | bool
- not ansible_check_mode

View File

@@ -0,0 +1,192 @@
---
- name: Configure WireGuard overlay
tags: [wireguard, services]
when: wireguard_overlay_enabled | bool
block:
- name: Validate WireGuard host configuration
ansible.builtin.assert:
that:
- wireguard_address != 'CHANGEME_WIREGUARD_ADDRESS'
- wireguard_address is match('^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$')
- wireguard_peers | length > 0
- wireguard_peers | map(attribute='host') | difference(ansible_play_hosts_all) | length == 0
fail_msg: >-
Configure this host's WireGuard address and peers, and run the first
key bootstrap against every peer in the same play.
- name: Install WireGuard userspace tools
ansible.builtin.dnf:
name: "{{ wireguard_packages }}"
state: present
- name: Create private WireGuard configuration directory
ansible.builtin.file:
path: "{{ wireguard_config_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Check for an existing WireGuard private key
ansible.builtin.stat:
path: "{{ wireguard_private_key_path }}"
register: wireguard_private_key_stat
- name: Generate a missing WireGuard private key
ansible.builtin.command:
argv:
- wg
- genkey
register: wireguard_generated_private_key
changed_when: true
no_log: true
when:
- not wireguard_private_key_stat.stat.exists
- not ansible_check_mode
- name: Persist the generated WireGuard private key
ansible.builtin.copy:
content: "{{ wireguard_generated_private_key.stdout }}\n"
dest: "{{ wireguard_private_key_path }}"
owner: root
group: root
mode: "0600"
no_log: true
when:
- not wireguard_private_key_stat.stat.exists
- not ansible_check_mode
- name: Require a private key during check mode
ansible.builtin.assert:
that:
- wireguard_private_key_stat.stat.exists
fail_msg: >-
The initial WireGuard key generation cannot be simulated safely in
check mode. Run the gated WireGuard play once without --check.
when: ansible_check_mode
- name: Read the persisted WireGuard private key
ansible.builtin.slurp:
src: "{{ wireguard_private_key_path }}"
register: wireguard_private_key_material
no_log: true
- name: Derive this host's WireGuard public key
ansible.builtin.command:
argv:
- wg
- pubkey
stdin: "{{ wireguard_private_key_material.content | b64decode | trim }}"
register: wireguard_derived_public_key
changed_when: false
no_log: true
- name: Publish this host's WireGuard public key
ansible.builtin.set_fact:
wireguard_public_key: "{{ wireguard_derived_public_key.stdout }}"
- name: Require every peer's generated public key
ansible.builtin.assert:
that:
- hostvars[item.host].wireguard_public_key is defined
- hostvars[item.host].wireguard_public_key | length > 0
fail_msg: >-
The public key for {{ item.host }} is unavailable. The first
WireGuard run must include every overlay host.
loop: "{{ wireguard_peers }}"
loop_control:
label: "{{ item.name }}"
- name: Render the private WireGuard interface configuration
ansible.builtin.template:
src: wg.conf.j2
dest: "{{ wireguard_config_path }}"
owner: root
group: root
mode: "0600"
diff: false
no_log: true
notify: Restart WireGuard interface
- name: Enable IPv4 forwarding for the ingress host
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
sysctl_set: true
reload: true
when: wireguard_enable_ipv4_forwarding | bool
- name: Create the WireGuard firewalld zone
ansible.posix.firewalld:
zone: "{{ wireguard_firewalld_zone }}"
state: present
permanent: true
register: wireguard_firewalld_zone_result
- name: Reload firewalld after creating the WireGuard zone
ansible.builtin.systemd:
name: firewalld.service
state: reloaded
when:
- wireguard_firewalld_zone_result is changed
- not ansible_check_mode
- name: Restore rootful Podman networking after firewalld reload
ansible.builtin.command:
argv:
- podman
- network
- reload
- --all
register: wireguard_podman_network_reload
changed_when: wireguard_podman_network_reload.stdout_lines | length > 0
when:
- wireguard_firewalld_zone_result is changed
- wireguard_reload_rootful_podman_networks | bool
- not ansible_check_mode
- name: Assign the WireGuard interface to its firewalld zone
ansible.posix.firewalld:
interface: "{{ wireguard_interface }}"
zone: "{{ wireguard_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
- name: Permit this host's public WireGuard listener
ansible.posix.firewalld:
port: "{{ wireguard_listen_port }}/udp"
zone: "{{ wireguard_public_firewalld_zone }}"
state: enabled
permanent: true
immediate: true
when: wireguard_listen_port | int > 0
- name: Enable the WireGuard interface
ansible.builtin.systemd:
name: "wg-quick@{{ wireguard_interface }}.service"
enabled: true
state: started
daemon_reload: true
when: not ansible_check_mode
- name: Apply pending WireGuard handlers before verification
ansible.builtin.meta: flush_handlers
when: not ansible_check_mode
- name: Wait for every WireGuard peer handshake
ansible.builtin.command:
argv:
- wg
- show
- "{{ wireguard_interface }}"
- latest-handshakes
register: wireguard_latest_handshakes
changed_when: false
retries: "{{ wireguard_handshake_retries }}"
delay: "{{ wireguard_handshake_delay }}"
until:
- wireguard_latest_handshakes.stdout_lines | length == wireguard_peers | length
- wireguard_latest_handshakes.stdout_lines | select('search', '\t0$') | list | length == 0
when: not ansible_check_mode

View File

@@ -0,0 +1,25 @@
# Managed by Ansible. Do not edit manually.
[Interface]
Address = {{ wireguard_address }}
PrivateKey = {{ wireguard_private_key_material.content | b64decode | trim }}
MTU = {{ wireguard_mtu }}
SaveConfig = false
{% if wireguard_listen_port | int > 0 %}
ListenPort = {{ wireguard_listen_port }}
{% endif %}
{% for peer in wireguard_peers %}
[Peer]
# {{ peer.name }}
PublicKey = {{ hostvars[peer.host].wireguard_public_key }}
AllowedIPs = {{ peer.allowed_ips | join(', ') }}
{% if peer.endpoint is defined %}
Endpoint = {{ peer.endpoint }}
{% endif %}
{% if peer.persistent_keepalive | default(0) | int > 0 %}
PersistentKeepalive = {{ peer.persistent_keepalive }}
{% endif %}
{% if not loop.last %}
{% endif %}
{% endfor %}

View File

@@ -7,6 +7,7 @@
tags: [always]
ansible.builtin.include_vars:
file: "{{ playbook_dir }}/../secrets/vault.yml"
no_log: true
when: >-
lookup('ansible.builtin.fileglob', playbook_dir + '/../secrets/vault.yml',
errors='ignore', wantlist=True) | length > 0
@@ -15,13 +16,28 @@
tags: [always]
ansible.builtin.include_vars:
file: "{{ playbook_dir }}/../secrets/vault.local.yml"
no_log: true
when: >-
lookup('ansible.builtin.fileglob', playbook_dir + '/../secrets/vault.local.yml',
errors='ignore', wantlist=True) | length > 0
- name: Reject conflicting AI coding-agent lifecycle flags
tags: [always, ai_agents]
ansible.builtin.assert:
that:
- not (item.value.install_enabled | bool and item.value.uninstall_enabled | bool)
fail_msg: >-
{{ item.key }} has both install_enabled and uninstall_enabled set to true.
Choose either installation or removal before running the playbook.
loop: "{{ ai_agents | dict2items }}"
loop_control:
label: "{{ item.key }}"
roles:
- role: dotfiles_common
when: "'platform_rocky' not in group_names"
when:
- "'platform_rocky' not in group_names"
- "'platform_fedora_iot' not in group_names"
- name: Configure Void platform
hosts: platform_void
@@ -41,14 +57,6 @@
- profile_desktop_niri
- profile_desktop_host
- name: Configure FreeBSD platform
hosts: platform_freebsd
become: true
roles:
- packages_freebsd
- services_freebsd
- name: Configure Fedora platform
hosts: platform_fedora
become: true
@@ -65,6 +73,20 @@
- packages_rocky
- services_systemd
- name: Configure WireGuard overlay
hosts: wireguard_overlay
become: true
roles:
- wireguard_overlay
- name: Configure Aegis Fedora IoT profile
hosts: role_aegis
become: true
roles:
- profile_aegis
- name: Configure Atlas NAS profile
hosts: atlas
become: true
@@ -72,6 +94,21 @@
roles:
- profile_atlas
- name: Configure Atlas phase-one backend services
hosts: role_backend_phase1
become: true
roles:
- profile_backend_phase1
- name: Configure Rocky Linux server
hosts: rocky_server
become: true
roles:
- dotfiles_common
- profile_server
- name: Configure personal workstation role on Fedora
hosts: platform_fedora:&role_personal_workstation
become: true
@@ -99,12 +136,3 @@
roles:
- profile_workstation_dev_wsl
- name: Configure Ubuntu server
hosts: ubuntu_server
become: true
roles:
- packages_ubuntu
- services_systemd
- profile_server

View File

@@ -1,40 +1,23 @@
---
version: "3.8"
{% set selinux_volume_option = server_compose_selinux_mount_option | default('') %}
name: server
services:
navidrome:
image: deluan/navidrome:latest
container_name: navidrome
restart: unless-stopped
expose:
- "4533"
environment:
ND_DATABASE_URL: "postgres://navidrome:{{ vault_navidrome_db_password }}@navidromedb:5432/navidrome_db?sslmode=disable"
ND_SESSIONTIMEOUT: 24h
ND_ENABLETRANSCODING: "true"
volumes:
- "/opt/navidrome/data:/data"
- "/opt/music:/music:ro"
user: "1000:1000"
networks:
- web
depends_on:
- navidromedb
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
image: docker.io/jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81"
- "127.0.0.1:81:81"
extra_hosts:
- "host.containers.internal:host-gateway"
volumes:
- "/opt/npm/data:/data"
- "/opt/npm/letsencrypt:/etc/letsencrypt"
- "/opt/npm/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}"
- "/opt/npm/letsencrypt:/etc/letsencrypt{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}"
networks:
- web
- gitea
# Disabled: prometheus does not have enough resources to run Nextcloud AIO.
# nextcloud-aio-mastercontainer:
@@ -55,20 +38,6 @@ services:
# networks:
# - web
navidromedb:
image: postgres:13
container_name: navidromedb
restart: unless-stopped
mem_limit: 2048m
environment:
POSTGRES_DB: "navidrome_db"
POSTGRES_USER: "navidrome"
POSTGRES_PASSWORD: "{{ vault_postgres_root_password }}"
volumes:
- "/opt/postgres/data:/var/lib/postgresql/data"
networks:
- web
gitea:
image: docker.gitea.com/gitea:1.25.2
container_name: gitea
@@ -77,39 +46,39 @@ services:
- USER_GID=1100
restart: always
networks:
- gitea
- web
volumes:
- /opt/gitea/data:/data
- /opt/gitea/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- /home/git/.ssh:/data/git/.ssh
- /home/git/.ssh:/data/git/.ssh{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}
ports:
- "3000:3000"
- "127.0.0.1:222:22"
{% if server_syncthing_enabled | default(true) | bool %}
syncthing:
image: syncthing/syncthing:2
image: docker.io/syncthing/syncthing:2
container_name: syncthing
hostname: syncthing
restart: unless-stopped
expose:
- "8384"
volumes:
- "/opt/syncthing/config:/var/syncthing"
- "/srv/syncthing/data:/data"
- "/opt/syncthing/config:/var/syncthing{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}"
- "/srv/syncthing/data:/data{{ ':' ~ selinux_volume_option if selinux_volume_option else '' }}"
ports:
- "22000:22000/tcp"
- "22000:22000/udp"
- "21027:21027/udp"
networks:
- web
{% endif %}
networks:
web:
name: server_web
external: false
gitea:
external: false
# volumes:
# nextcloud_aio_mastercontainer:

View File

@@ -24,19 +24,19 @@
[merge]
conflictstyle = diff3
tool = vscode
tool = bobide
[mergetool]
prompt = false
[mergetool "vscode"]
cmd = code --reuse-window --wait $MERGED
[mergetool "bobide"]
cmd = bobide --reuse-window --wait $MERGED
[diff]
tool = vscode
tool = bobide
[difftool "vscode"]
cmd = code --reuse-window --wait --diff $LOCAL $REMOTE
[difftool "bobide"]
cmd = bobide --reuse-window --wait --diff $LOCAL $REMOTE
[color]
ui = always
@@ -89,11 +89,11 @@
side-by-side = true
[http "https://gitlab.ecaas.datacenter.comune.roma"]
sslBackend = gnutls
sslBackend = openssl
sslCAInfo = ~/.local/share/certs/ecaas.datacenter.comune.roma.crt
[http "https://gitlab.datacenter.comune.roma"]
sslBackend = gnutls
sslBackend = openssl
sslCAInfo = ~/.local/share/certs/datacenter.comune.roma.crt
[credential]

View File

@@ -1,20 +1,10 @@
# =========================
# Portable Bash config
# Target: Void Linux + FreeBSD
# =========================
# Exit if not interactive
[[ $- != *i* ]] && return
# --- environment detection
case "$(uname -s)" in
Linux) PLATFORM="linux" ;;
FreeBSD) PLATFORM="freebsd" ;;
*) PLATFORM="other" ;;
esac
export PLATFORM
# --- history
HISTSIZE=10000
HISTFILESIZE=20000
@@ -438,15 +428,10 @@ extract() {
}
# =========================
# OS-specific small touches
# Small touches
# =========================
if [ "$PLATFORM" = "freebsd" ]; then
alias df='df -h'
alias du='du -h'
elif [ "$PLATFORM" = "linux" ]; then
alias df='df -h'
alias du='du -h'
fi
alias df='df -h'
alias du='du -h'
[ -r "$HOME/.bashrc.aliases" ] && . "$HOME/.bashrc.aliases"

View File

@@ -1,2 +1,5 @@
# Additional Bash aliases.
# Keep this file as the extension point for shared aliases.
# Nginx Proxy Manager administration tunnel on Prometheus.
alias npm-tunnel='ssh -N -o ExitOnForwardFailure=yes -L 8181:127.0.0.1:81 rocky@179.237.102.172'

View File

@@ -1,5 +1,5 @@
# Server
- Ubuntu minimal server
- Rocky Linux 9 server
- Self-hosting services
- Low attack surface

View File

@@ -1,4 +1,4 @@
Host vps
Host prometheus-ubuntu
IdentityFile ~/.ssh/id_rsa_vps
Host *

View File

@@ -1 +0,0 @@
OK

View File

@@ -1 +0,0 @@
echo url="https://www.duckdns.org/update?domains=fscotto&token=d173a037-8a10-4e40-8d9d-bebedbdde086&ip=" | curl -k -o ~/duckdns/duck.log -K -

View File

@@ -1,17 +0,0 @@
case "$(uname -r 2>/dev/null)" in
*[Mm]icrosoft*) ;;
*) return ;;
esac
command -v gpgconf >/dev/null 2>&1 || return
if tty -s; then
export GPG_TTY="$(tty)"
fi
gpgconf --launch gpg-agent >/dev/null 2>&1
export SSH_AUTH_SOCK="$(gpgconf --list-dirs agent-ssh-socket)"
if [ -n "${GPG_TTY-}" ]; then
gpg-connect-agent updatestartuptty /bye >/dev/null 2>&1
fi

View File

@@ -0,0 +1,4 @@
# Activate Mise for interactive Bash sessions, including JAVA_HOME for its JDKs.
if command -v mise >/dev/null 2>&1; then
eval "$(mise activate bash)"
fi

View File

@@ -0,0 +1,3 @@
[tools]
java = "temurin-11.0.31+11"
maven = "3.9.16"

View File

@@ -0,0 +1,161 @@
#!/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,37 +1,78 @@
$ANSIBLE_VAULT;1.1;AES256
36346638633530653262306466326434346237313965643039643837623233313037363163346338
3165386461353334393464616362333033613231313865340a313731623439626633303566333239
63363166666663376365613034643663373937613162643935366333313438373365346539663161
6663343762336130350a306336613962353931306230363564626538346163623936363064383964
61333734633635326630383339306539643537316665666666643533333364613030613265313032
62643363383464303836376266633962313666396630313361303039343133316237643164616666
30376433323330363033643231643861383234336362383432633665313566336537633631303133
66656363613165333666383432623435626339666364643936653037663839643262393664653564
37373465373663376537616532656434316435623734656265363536336333633537626435303635
65623036613631386464646335653862396232326163623130396133643935363135393934333638
37626664353131613336353130366566383238623031636366333036643161626564613965613233
63336138393161306565623466383763663664323864633430626561313939336434363462656361
33343433636562653138613233343030333362383661633536616564313662353231343966386466
63633032643432343337303131656465373039646235653335353239633865353533626632336366
32386430623035626533346463333230663735353632356530643635386336393235646461303064
37316265613233303537373631613265636435343631363930323234366332613966346262336531
36646566393036636363333733613433353561363639616139626331336234353334303739643061
32333062383164373435666166386263663438666364636239383731313863386361363035313333
30656363306565623035303462363637353839343665393934613733613532313237613134313466
61336335616631666430393364363864306632663462393733656539386431313864333265393265
33626630643863306632363835353238376631626335313566666264343664656136316337376432
36653534336232303361663966663366343637663432316533643766636339313339643430336531
34303430336333663364306432363461326465396533376263336131323639393737666632623363
36656438616230663566633061383135633937613530346636396630656135663639396235373965
31396536376666333839336166653734303362633263643039353838393431393463303337313263
62653036346666393930646536653235326566303465333666646534643935663734643063613762
63336334646336343537386133383138363932616662376131393530633565353935613566306563
30643338613065393731623534656431373235353837383963383262323939343061326531303536
38303731633265376334393465656334353265656633336231326636636339626461616533323533
39326663336431353633313265353736356435393761613163386665623865346132333537666336
32366564363739386339343835393966623161656165643232643535616536633762666530623466
63636336386563373032663939373237383639623938616336653235663430613863616537326666
65346464326138646435353033303831383732623133393666663533303564613835323334333632
65366138613266313261646134303165333265366134373362333433653734653038646538363566
31373137643331373734353038643833333966663161633561653533333338353230393838336434
35346533356463666466
31386434333363613930316363393564373332303236306233643365386639346631336232373361
3732633931376438313835373537666438383664306266380a633531316432343166323361303465
39306136386664356163346266303963373839373763343136346135633236333333326331313063
6264306265373865310a373735393632373861333433326632303931633732636535326264346537
39633766326361666432396432623132616666363462373237623664363664373362346366383230
65326665303762356233356531306331333231376163353638363936626562623937323166623065
62633165383033303362336238343037353930396430336537363565333132666532323662653262
65373531313264353938666161373064393239653165666462336665323962336134343432316134
32373361613539356262313333663964623034313230356439626139383539326261663765373034
36633933366662656464306561393130373238313730336638663932633935313037363133636266
36663935303735623132663464376633306431396662306166393831313566323238363865303162
66303732376530653435613966373832333161333137366230613166303061333433336461386234
63393030616662373666643164663862653037383336323766646330653131623930353265336231
39646335353635616465663763306262353931316562616437313362616136623735646535616431
33363439623361343231363663363535663265366166346439623935323632366336363135373462
62646662316538323734333334393566333463653833326162663065646634363336373865623932
33646533636638386534366561663930613536663935663638313137646537626431393035653466
36623435613966323166363630383531656436373366343364346439343034333934623836336636
38653534343563393434646430306662343435653465363439326261373537666233353731306436
35323739336361343665633239366633393530346335396635316238323435663466316235376536
38303839313433643038353236616632626363653339346334303136306138336461383831626536
39303966393034333739363061363865326165303236656438316537396463383935393764346135
66323632323833353833643434316566326366633562666262386232653730303038613336663264
62636332383834393861373665393364653362636339346632303463386565633830336363393065
31383530613161366436323163393366386635316562633436383134623061353937326363396439
38636438396637616362373937666361303536376164636533653536306338356263313965623336
32633461623861643138613734313164633562613932386163643062353636376266633166373838
39613438653531316333663736366161313832613830353566356461393435656234383037353335
32343661376266353538643531313239306432646335383734623233373063316430383362643531
35393263343534363936373361316265653934383735663933663365363564663966646335353337
63366365383466393765336533363130643236313331633537346237353631623334633330376164
32616530623032663761643437336536323332343130393339313232623364656334613233323833
64346637343738386335343631333035636337633732333662663763326362636432646136353064
30393061326435323837633632333765623931663265326137373135303035623464333366653566
66613133396339376264313964353932396136613538383264363865633536373839663465333437
37613765626131343330653063663764346364316264363635653438396331363263623562333735
30646563303439663639376430336336383761363365353838613036333032306434623661636138
63616439636437663732386164346365643834376631303263376563323662373734653631613730
31373039656238303462643930303531613931373065316435303661343862623034653364653736
37613835616630316133333130633631653639336266313438633166316539373433333661613839
32356139366232313336343062323265366563633735383661393335386163653133313664613264
63303739323863333439316461366137656434666366306466356633306663623730653939663430
39313764663534316562326435316264633236373834323665356266346532323565323532333438
33306237623430613463393164383332356533303433343465343930636563363862373330613632
38643937613263313435626166313464316133373338373261666331653436373063393162636339
66356434663239646334383433626566336433653265336332323866633666646132663836313537
66663766306438396131623533613834626331653731323137303539303835306632663132616363
64303438353836613136623562626664326364656133383865383730373762666539383036396337
66393636343561333435313032353939316138306336316337353861383238326136373265663433
62366566343866626336646466386561353061343735306565383437333931613635393034363430
37633765663239643435623066313331353862393966306563393838386334393162306562363062
61633363386564373834383432323861323364623365306439353631323463326461383039356466
34396464653233626435656463326439303665633532656639376633353932666630626564616564
34326638383634353033326232646339393638663637313136653763336265616635326666336530
33663261376262626136346265656130653831636662306132393837306135643831353534626636
34383762386665666363313932336632326230646439663366663037323562633630373137333232
37393164656137303131313738396131613561306332356436303436636338623233343637363332
36613137316337666461333237373266326238303531396432383461616239316630346230303735
63636535383766353338643932366339333130663632386337643932636630316432313465393766
39613065623631316165313134386231616165366266323634643632626230303861376461323433
35346137326563646332303334313530383464373838373633363635373766333362386466323836
32613236646561343365656239346239353866663336393930356238653463336361333033643331
61323430663031383239353363616666336637666230663633616464616638303966343631386632
32333735643936353638666336303133356435396338653465323234316234626536666635333238
38313661303466333464666538363938656631396666643566343763396638336663376430646532
38666234653330646262633062356238343536336637356432313137313561363937363936383364
30643937383833663339643862363234643765386164316138636565643434373734383339363138
36323163633837326132393365333236633264386664373234313061373835346634663137383837
62666561353532303663346365343131316233633163323938623066656332383030393864363536
38383939383935613432613837333863313239653831333438383133343763633838353964353161
61323462343835613937653465633563306462613631323762656437626133336638396663646362
30323661383134653336366234663333336261353162373030626266656336356233316265636661
34303865313433633138363936373561636537353831373033303163646436303932626138356633
63656364353163313037613262396338636230646330666331616534313466306361363433656132
66633231626665303165346339373764666264313838313063323732653837383736633235363064
37353632336238623366313432376163653535656134633634313065356533343933666135396633
30613134646132613637656461303431613064393438363231383464663765316638

View File

@@ -1,4 +1,5 @@
---
vault_duckdns_token: "CHANGEME"
vault_personal_full_name: "REPLACE_ME"
vault_git_email: "REPLACE_ME"
vault_git_signing_key: "REPLACE_ME"
@@ -8,7 +9,8 @@ vault_icloud_mail_password: "REPLACE_ME"
vault_git_work_email: "REPLACE_ME"
vault_git_work_gpg: "REPLACE_ME"
vault_openai_api_key: "REPLACE_ME"
vault_navidrome_db_password: "REPLACE_ME"
vault_postgres_root_password: "REPLACE_ME"
vault_ikaros_authorized_ssh_keys:
- "ssh-ed25519 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"