From b38cd94fc8ca447f8e654d49bb4fff3358599e7d Mon Sep 17 00:00:00 2001 From: Matthias Johnson Date: Sat, 28 Feb 2026 00:51:16 -0700 Subject: [PATCH] Fix fresh-deploy blockers and clean up architecture - Seed postfix-accounts.cf before mailserver start to satisfy Dovecot's requirement for at least one account on first boot - Add failed_when: false to mail user/alias list tasks (files don't exist on first run) - Add forgejo_runner_version (was undefined); default to 12 - Create /srv/forgejo/data/gitea/conf before deploying app.ini - Decouple goaccess sync from restic: new enable_goaccess_sync flag with its own goaccess_sync_* variables - Move Docker installation to bootstrap exclusively; rename docker.yml to networks.yml (runs docker_network role only) - Add radicale_password to vault template and setup.sh - Fix goaccess sync tasks gated on enable_goaccess_sync - Add upstream bug comment to authorized_key deprecation warning - Update CLAUDE.md and README.md throughout Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 41 ++- README.md | 357 +++++++------------ inventory/group_vars/all/config.yml.setup | 13 +- inventory/group_vars/all/dns.yml.setup | 65 ++-- inventory/group_vars/all/vault.yml.setup | 15 +- playbooks/bootstrap.yml | 22 +- playbooks/{docker.yml => networks.yml} | 3 +- playbooks/site.yml | 4 +- requirements.yml | 1 + roles/caddy/templates/Caddyfile.j2 | 5 + roles/caddy/templates/compose.yml.j2 | 1 + roles/dns/tasks/extra_mail_domain.yml | 56 +++ roles/dns/tasks/main.yml | 35 ++ roles/forgejo/defaults/main.yml | 1 + roles/forgejo/tasks/main.yml | 7 +- roles/goaccess/tasks/main.yml | 17 +- roles/goaccess/templates/goaccess-sync.sh.j2 | 4 +- roles/mail/tasks/aliases.yml | 2 + roles/mail/tasks/main.yml | 32 +- roles/mail/tasks/users.yml | 2 + roles/provision/defaults/main.yml | 3 +- roles/provision/tasks/hetzner.yml | 9 +- setup.sh | 12 +- 23 files changed, 400 insertions(+), 307 deletions(-) rename playbooks/{docker.yml => networks.yml} (51%) create mode 100644 roles/dns/tasks/extra_mail_domain.yml diff --git a/CLAUDE.md b/CLAUDE.md index 7c53f20..bef5a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Island is an Ansible-based self-hosting infrastructure stack that deploys email, web server, git hosting, Matrix homeserver, monitoring, and backup services using Docker Compose on Ubuntu servers. +Linderhof is an Ansible-based self-hosting infrastructure stack that deploys email, web server, git hosting, Matrix homeserver, monitoring, and backup services using Docker Compose on Ubuntu servers. ## Common Commands @@ -37,23 +37,31 @@ Note: Inventory and vault password are set via `ANSIBLE_INVENTORY` and `ANSIBLE_ **Deployment Pattern:** Each service is deployed to `/srv//` on the target host with a `compose.yml` and environment files. **Standalone Playbooks** (not in `site.yml`): -- `provision.yml` - Provision a cloud VM (Hetzner). Usage: `ansible-playbook playbooks/provision.yml` +- `provision.yml` - Provision a cloud VM (Hetzner) - `dns.yml` - Manage DNS zones/records via Hetzner DNS API +- `bootstrap.yml` - First-time server setup (run once as root before site.yml) + +**Full deployment order** (fresh server): +1. `provision.yml` - create server, auto-writes IP to hosts.yml and config.yml +2. `dns.yml` - create DNS records +3. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root) +4. `site.yml` - deploy all services **Playbook Execution Order** (via `site.yml`): -1. bootstrap.yml - SSH, sudo, users, base packages (manual only) -2. docker.yml - Docker engine installation -3. docker_network.yml - Pre-create all Docker networks (must run before any service) -4. nebula.yml - Overlay network (Nebula) -5. caddy.yml - Web server / reverse proxy -6. mail.yml - Email (docker-mailserver + rainloop) -7. forgejo.yml - Git server -8. tuwunel.yml - Matrix homeserver (Tuwunel) -9. monitoring.yml - Prometheus, Grafana, Loki, Alloy -10. goaccess.yml - Web analytics -11. diun.yml - Docker image update notifications -12. restic.yml - Encrypted backups -13. fail2ban.yml - Intrusion prevention +1. networks.yml - Pre-create all Docker networks (must run before any service) +2. nebula.yml - Overlay network (Nebula) +3. caddy.yml - Web server / reverse proxy +4. mail.yml - Email (docker-mailserver + rainloop) +5. forgejo.yml - Git server +6. tuwunel.yml - Matrix homeserver (Tuwunel) +7. radicale.yml - CalDAV/CardDAV +8. monitoring.yml - Prometheus, Grafana, Loki, Alloy +9. goaccess.yml - Web analytics +10. diun.yml - Docker image update notifications +11. restic.yml - Encrypted backups +12. fail2ban.yml - Intrusion prevention + +**Mail TLS:** on first deployment, the mail role stops Caddy, runs certbot standalone to acquire a Let's Encrypt cert for `mail_hostname`, then restarts Caddy. subsequent runs skip this (cert already exists). Caddy owns port 80 so standalone is the only viable approach without a DNS challenge plugin. **Role Structure:** Each role in `roles/` contains: - `tasks/main.yml` - Core provisioning tasks @@ -90,6 +98,9 @@ caddy_sites: - `enable_forgejo` - `enable_tuwunel` - `enable_monitoring` +- `enable_goaccess` +- `enable_goaccess_sync` +- `enable_radicale` - `enable_restic` - `enable_fail2ban` - `enable_nebula` diff --git a/README.md b/README.md index c25809d..71f0851 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ a self-hosting stack based on ansible and docker compose that comes with - [loki](https://github.com/grafana/loki) - web analytics - [goaccess](https://goaccess.io/) +- calendar & contacts + - [radicale](https://radicale.org/) - backups - [restic](https://github.com/restic/restic) - overlay network @@ -29,36 +31,34 @@ a self-hosting stack based on ansible and docker compose that comes with - intrusion prevention - [fail2ban](https://github.com/fail2ban/fail2ban) - other features include: - runs on opensource - no databases / no external services +## what you need + +- **a domain name** — from any registrar. you'll point its nameservers at Hetzner DNS, or manage DNS yourself +- **a [Hetzner Cloud](https://console.hetzner.cloud/) account** with an API token (Read & Write) — used to provision the server and manage DNS records +- **local tools:** + - `ansible` and `ansible-galaxy` + - `direnv` (optional but recommended — loads `.envrc` automatically) + - `ssh-keygen`, `openssl`, `envsubst` (standard on most systems) + +if you already have a server with SSH access and passwordless sudo, you can skip provisioning and jump straight to [deploy](#deploy). + + ## setup -### prerequisites - -- [direnv](https://direnv.net/) (optional, loads `.envrc` automatically) -- a [Hetzner Cloud](https://console.hetzner.cloud/) account with an API token (Read & Write) -- a [Hetzner Storage Box](https://www.hetzner.com/storage/storage-box/) (for restic backups, optional) - -install python dependencies and ansible collections: - -```bash -pip install -r requirements.txt -ansible-galaxy collection install -r requirements.yml -``` - -### quickstart +run the interactive setup wizard: ```bash ./setup.sh ``` -the setup script walks you through everything interactively: stack name, SSH key, vault password, server details, domain, and secrets. it writes all generated config outside the repo to `$XDG_CONFIG_HOME/linderhof//` and won't overwrite existing files. +it walks you through: stack name, SSH key, admin username, server hostname, domain, Hetzner API token, and generates all secrets. config is written to `$XDG_CONFIG_HOME/linderhof//` and won't overwrite existing files. -after setup, activate the stack and review the generated config: +activate the stack and review the generated config: ```bash direnv allow # reads .stack file — or: export LINDERHOF_STACK= @@ -67,15 +67,63 @@ vi $LINDERHOF_DIR/group_vars/all/config.yml ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml ``` -then provision and deploy: +install ansible collections: + +```bash +ansible-galaxy collection install -r requirements.yml +``` + + +## deploy + +### provision a server (Hetzner) ```bash ansible-playbook playbooks/provision.yml +``` + +creates the server, registers your SSH key, and writes the IP to your stack config automatically. default type is `cx23` (2 vCPU, 4 GB); override with `-e hcloud_server_type=cx33`. + +### update DNS + +```bash ansible-playbook playbooks/dns.yml +``` + +creates all DNS zones and records for your domain. records are conditional on your `enable_*` settings — disabled services won't get DNS entries. + +### bootstrap the server + +first-time setup of the server (users, SSH hardening, packages, Docker): + +```bash +ansible-playbook playbooks/bootstrap.yml +``` + +this connects as `root` (the only user on a fresh server), creates your admin user with passwordless sudo, sets passwords for `root` and the admin user, hardens SSH, and installs base packages. + +### deploy services + +```bash ansible-playbook playbooks/site.yml ``` -### multiple stacks +deploys all enabled services. subsequent runs are idempotent — safe to re-run to apply config changes. + +> **note:** on first deployment, the mail role briefly stops Caddy to acquire a Let's Encrypt certificate for the mail hostname via certbot standalone. Caddy is restarted immediately after. this only happens once — subsequent runs detect the existing certificate and skip it. + + +## bring your own server + +if you already have an Ubuntu server with SSH access: + +1. run `./setup.sh` — enter the server's existing hostname and IP when prompted +2. ensure your SSH key is authorized for the admin user and they have passwordless sudo — or run `bootstrap.yml` first if starting from root access +3. skip `provision.yml` and `dns.yml` if you're managing DNS elsewhere +4. run `ansible-playbook playbooks/site.yml` + + +## multiple stacks each stack is an independent deployment with its own inventory, vault, and secrets. to create a second stack: @@ -106,9 +154,29 @@ stack config lives at `$XDG_CONFIG_HOME/linderhof//`: overrides.yml # optional: variable overrides ``` -### overriding variables -to override any variable without editing `config.yml`, create `overrides.yml` in the stack's `group_vars/all/`. ansible loads all files there automatically, so any key here wins over `config.yml`: +## service toggles + +set `enable_: false` in `config.yml` to disable a service. DNS records, Docker networks, and deployment tasks for that service will all be skipped automatically. + +| variable | service | +|---|---| +| `enable_mail` | email (docker-mailserver + rainloop) | +| `enable_forgejo` | git hosting | +| `enable_tuwunel` | Matrix homeserver | +| `enable_monitoring` | Prometheus, Grafana, Loki, Alloy | +| `enable_goaccess` | web analytics | +| `enable_goaccess_sync` | rsync analytics reports to a remote host (off by default) | +| `enable_radicale` | CalDAV/CardDAV | +| `enable_restic` | encrypted backups (requires a Hetzner Storage Box — off by default) | +| `enable_nebula` | overlay network | +| `enable_diun` | Docker image update notifications | +| `enable_fail2ban` | intrusion prevention | + + +## overriding variables + +to override any variable without editing `config.yml`, create `overrides.yml` in the stack's `group_vars/all/`. ansible loads all files there automatically: ```bash vi $LINDERHOF_DIR/group_vars/all/overrides.yml @@ -127,288 +195,139 @@ caddy_sites: mail_domains: - example.com - example2.com - - example3.com ``` -## upstream documentation - -- [docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/latest/) -- [rainloop](https://www.rainloop.net/docs/configuration/) -- [caddy](https://caddyserver.com/docs/) -- [forgejo](https://forgejo.org/docs/latest/) -- [tuwunel](https://github.com/matrix-construct/tuwunel) -- [alloy (Grafana Alloy)](https://grafana.com/docs/alloy/latest/) -- [grafana](https://grafana.com/docs/grafana/latest/) -- [prometheus](https://prometheus.io/docs/) -- [loki](https://grafana.com/docs/loki/latest/) -- [goaccess](https://goaccess.io/man) -- [restic](https://restic.readthedocs.io/) -- [nebula](https://nebula.defined.net/docs/) -- [diun](https://crazymax.dev/diun/) -- [fail2ban](https://fail2ban.readthedocs.io/) ## secrets -sensitive data like passwords and DKIM keys is stored in `$LINDERHOF_DIR/group_vars/all/vault.yml` and encrypted with ansible-vault. see the [setup](#setup) section for what goes in there. +sensitive data is stored in `$LINDERHOF_DIR/group_vars/all/vault.yml`, encrypted with ansible-vault. generated by `setup.sh` and never committed to the repo. -after first mail deployment, retrieve and add the DKIM public key: +```bash +# view secrets +ansible-vault view $LINDERHOF_DIR/group_vars/all/vault.yml + +# edit secrets (decrypts, opens editor, re-encrypts on save) +ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml +``` + + +## after first mail deployment — DKIM + +retrieve the generated DKIM public key and add it to the vault: ```bash docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim//mail.pub ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml -# add: dkim_keys: -# example.com: "v=DKIM1; k=rsa; p=..." ``` -then uncomment the `mail._domainkey` record in `dns.yml` and re-run `ansible-playbook playbooks/dns.yml`. - -```bash -# edit secrets (decrypts in place, opens editor, re-encrypts on save) -ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml - -# decrypt for manual editing -ansible-vault decrypt $LINDERHOF_DIR/group_vars/all/vault.yml - -# re-encrypt after editing -ansible-vault encrypt $LINDERHOF_DIR/group_vars/all/vault.yml +add under `dkim_keys`: +```yaml +dkim_keys: + example.com: "v=DKIM1; k=rsa; p=..." ``` -## provisioning - -provision a new cloud VM (currently supports Hetzner): - -```bash -# provision with defaults (server_name and cloud_provider from config.yml) -ansible-playbook playbooks/provision.yml - -# override server name or type -ansible-playbook playbooks/provision.yml -e server_name=aspen -e hcloud_server_type=cpx21 -``` - -this registers your SSH key, creates the server, waits for SSH, and updates `$LINDERHOF_DIR/hosts.yml` with the new IP. after provisioning, update DNS and run the stack: +then re-run DNS — the `mail._domainkey` record is created automatically: ```bash ansible-playbook playbooks/dns.yml -ansible-playbook playbooks/site.yml --tags bootstrap -ansible-playbook playbooks/site.yml ``` -## ansible playbooks - -Run everything: - -```bash -ansible-playbook playbooks/site.yml -``` - -Run playbooks individually for initial setup (in this order): - -```bash -# 1. Bootstrap the server (users, packages, ssh, etc.) -ansible-playbook playbooks/bootstrap.yml - -# 2. Install docker -ansible-playbook playbooks/docker.yml - -# 3. Set up nebula overlay network -ansible-playbook playbooks/nebula.yml - -# 4. Set up the web server -ansible-playbook playbooks/caddy.yml - -# 5. Set up the mail server -ansible-playbook playbooks/mail.yml - -# 6. Set up forgejo (git server) -ansible-playbook playbooks/forgejo.yml - -# 7. Set up tuwunel (matrix homeserver) -ansible-playbook playbooks/tuwunel.yml - -# 8. Set up monitoring (prometheus, grafana, loki, alloy) -ansible-playbook playbooks/monitoring.yml - -# 9. Set up goaccess (web analytics) -ansible-playbook playbooks/goaccess.yml - -# 10. Set up diun (docker image update notifier) -ansible-playbook playbooks/diun.yml - -# 11. Set up restic backups -ansible-playbook playbooks/restic.yml - -# 12. Set up fail2ban -ansible-playbook playbooks/fail2ban.yml -``` - -Run only specific tags: - -```bash -ansible-playbook playbooks/site.yml --tags mail,monitoring -``` ## common operations -Services are deployed to `/srv/`. Each has a `compose.yml` and can be managed with docker compose. +services are deployed to `/srv/`. each has a `compose.yml` and can be managed with docker compose. -### running docker compose commands +### docker compose ```bash -# Always cd to the service directory first cd /srv/mail && docker compose logs -f cd /srv/caddy && docker compose restart cd /srv/forgejo && docker compose ps -cd /srv/tuwunel && docker compose up -d -cd /srv/monitoring && docker compose up -d ``` ### reloading caddy ```bash -# Reload caddy configuration without downtime cd /srv/caddy && docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile ``` ### managing email users ```bash -# List all email accounts +# list accounts docker exec mailserver setup email list -# Add a new email account +# add account docker exec mailserver setup email add user@domain.com password -# Delete an email account +# delete account docker exec mailserver setup email del user@domain.com -# Update password +# update password docker exec mailserver setup email update user@domain.com newpassword ``` -To add users via ansible, add them to `mail_users` in the vault and run: +to manage users via ansible, edit `mail_users` in the vault and run: ```bash -ansible-playbook --tags users playbooks/mail.yml +ansible-playbook playbooks/mail.yml --tags users ``` ### managing email aliases ```bash -# List aliases docker exec mailserver setup alias list - -# Add an alias docker exec mailserver setup alias add alias@domain.com target@domain.com - -# Delete an alias docker exec mailserver setup alias del alias@domain.com ``` ### managing forgejo ```bash -# Access the forgejo CLI -docker exec -it forgejo forgejo - -# List users docker exec forgejo forgejo admin user list - -# Create a new user docker exec forgejo forgejo admin user create --username myuser --password mypassword --email user@domain.com - -# Reset a user's password docker exec forgejo forgejo admin user change-password --username myuser --password newpassword - -# Delete a user -docker exec forgejo forgejo admin user delete --username myuser ``` -### managing tuwunel (matrix) +### monitoring ```bash -# View tuwunel logs -cd /srv/tuwunel && docker compose logs -f - -# Restart tuwunel -cd /srv/tuwunel && docker compose restart - -# Check federation status -curl https://chat.example.com/_matrix/federation/v1/version - -# Check well-known delegation -curl https://example.com/.well-known/matrix/server -curl https://example.com/.well-known/matrix/client -``` - -### monitoring stack - -```bash -# Reload prometheus configuration +# reload prometheus config docker exec prometheus kill -HUP 1 -# Restart alloy to pick up config changes +# restart alloy cd /srv/monitoring && docker compose restart alloy - -# Check prometheus targets -curl -s localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job: .labels.job, health: .health}' - -# Check alloy status -curl -s localhost:12345/-/ready ``` -### viewing logs +### nebula overlay network + +nebula runs directly on the host (not in Docker). certificates live in `/etc/nebula/`. ```bash -cd /srv/mail && docker compose logs -f mailserver -cd /srv/caddy && docker compose logs -f caddy -cd /srv/forgejo && docker compose logs -f forgejo -cd /srv/tuwunel && docker compose logs -f tuwunel -cd /srv/monitoring && docker compose logs -f grafana -cd /srv/monitoring && docker compose logs -f prometheus -cd /srv/monitoring && docker compose logs -f loki -cd /srv/monitoring && docker compose logs -f alloy -``` - -### managing nebula - -Nebula runs directly on the host (not in Docker). The CA key and certificates are stored in `/etc/nebula/`. - -```bash -# Sign a client certificate -ssh server +# sign a client certificate cd /etc/nebula nebula-cert sign -name "laptop" -ip "192.168.100.2/24" -# Copy laptop.crt, laptop.key, and ca.crt to client device -``` - -On the client, install Nebula and create a config with `am_lighthouse: false` and a `static_host_map` pointing to the server's public IP: - -```yaml -static_host_map: - "192.168.100.1": ["YOUR_SERVER_PUBLIC_IP:4242"] - -lighthouse: - am_lighthouse: false - hosts: - - "192.168.100.1" -``` - -### dns management - -DNS records are managed via the Hetzner DNS API: - -```bash -ansible-playbook playbooks/dns.yml +# copy laptop.crt, laptop.key, ca.crt to client ``` ### backups ```bash -# Check backup status docker exec restic restic snapshots - -# Run a manual backup docker exec restic restic backup /data - -# Restore from backup docker exec restic restic restore latest --target /restore ``` + + +## upstream documentation + +- [docker-mailserver](https://docker-mailserver.github.io/docker-mailserver/latest/) +- [caddy](https://caddyserver.com/docs/) +- [forgejo](https://forgejo.org/docs/latest/) +- [tuwunel](https://github.com/matrix-construct/tuwunel) +- [grafana](https://grafana.com/docs/grafana/latest/) · [prometheus](https://prometheus.io/docs/) · [loki](https://grafana.com/docs/loki/latest/) · [alloy](https://grafana.com/docs/alloy/latest/) +- [goaccess](https://goaccess.io/man) +- [radicale](https://radicale.org/v3.html) +- [restic](https://restic.readthedocs.io/) +- [nebula](https://nebula.defined.net/docs/) +- [diun](https://crazymax.dev/diun/) +- [fail2ban](https://fail2ban.readthedocs.io/) diff --git a/inventory/group_vars/all/config.yml.setup b/inventory/group_vars/all/config.yml.setup index 566a259..ef052ee 100644 --- a/inventory/group_vars/all/config.yml.setup +++ b/inventory/group_vars/all/config.yml.setup @@ -20,12 +20,14 @@ enable_mail: true enable_forgejo: true enable_monitoring: true -enable_restic: true +enable_restic: false enable_fail2ban: true enable_tuwunel: true enable_nebula: true enable_diun: true enable_goaccess: true +enable_goaccess_sync: false +enable_radicale: true # ============================================================ # System @@ -34,6 +36,7 @@ domain: $domain server_name: $server_name server_ip: $server_ip admin_user: $admin_user +admin_shell: /bin/zsh admin_ssh_key: "{{ lookup('file', '$ssh_key_pub') }}" timezone: UTC @@ -44,6 +47,7 @@ caddy_version: "2" mailserver_version: "latest" rainloop_version: "latest" forgejo_version: "11" +forgejo_runner_version: "12" prometheus_version: "latest" alloy_version: "latest" grafana_version: "latest" @@ -143,6 +147,13 @@ goaccess_sites: - rspamd.$domain goaccess_user: admin +# Sync reports to a remote host via rsync over SSH (enable_goaccess_sync: true to activate) +# goaccess_sync_host: "uXXXXXX.your-storagebox.de" +# goaccess_sync_user: uXXXXXX +# goaccess_sync_ssh_port: 23 +# goaccess_sync_ssh_key: "/root/.ssh/goaccess_sync" +# goaccess_sync_remote_path: "analytics" + # ============================================================ # Diun (Docker Image Update Notifier) # ============================================================ diff --git a/inventory/group_vars/all/dns.yml.setup b/inventory/group_vars/all/dns.yml.setup index 43f0a9f..7fd6d02 100644 --- a/inventory/group_vars/all/dns.yml.setup +++ b/inventory/group_vars/all/dns.yml.setup @@ -7,7 +7,10 @@ # # After first mail deployment, retrieve DKIM keys with: # docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub -# Add them to vault.yml and uncomment the mail._domainkey records below. +# Then add them to vault.yml under dkim_keys: +# dkim_keys: +# $domain: "v=DKIM1; k=rsa; p=..." +# The mail._domainkey record will be created automatically on next dns.yml run. # ============================================================ dns_zones: @@ -17,15 +20,17 @@ dns_zones: - name: "@" type: A records: - - value: $server_ip + - value: "{{ server_ip }}" - name: "@" type: MX + when: "{{ enable_mail | default(false) }}" records: - value: "10 {{ mail_hostname }}." - name: "@" type: TXT + when: "{{ enable_mail | default(false) }}" records: - value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}" @@ -33,96 +38,66 @@ dns_zones: - name: $server_name type: A records: - - value: $server_ip + - value: "{{ server_ip }}" - name: www type: A records: - - value: $server_ip + - value: "{{ server_ip }}" - # Mail subdomain A record (for the mail hostname itself) + # Mail subdomain A record - name: "{{ mail_hostname.split('.')[0] }}" type: A + when: "{{ enable_mail | default(false) }}" records: - - value: $server_ip + - value: "{{ server_ip }}" # Service CNAMEs - name: webmail type: CNAME + when: "{{ enable_mail | default(false) }}" records: - value: $server_name.$domain. - name: code type: CNAME + when: "{{ enable_forgejo | default(false) }}" records: - value: $server_name.$domain. - name: watch type: CNAME + when: "{{ enable_monitoring | default(false) }}" records: - value: $server_name.$domain. - name: rspamd type: CNAME + when: "{{ enable_mail | default(false) }}" records: - value: $server_name.$domain. - name: stats type: CNAME + when: "{{ enable_goaccess | default(false) }}" records: - value: $server_name.$domain. - name: chat type: CNAME + when: "{{ enable_tuwunel | default(false) }}" records: - value: $server_name.$domain. - name: cal type: CNAME + when: "{{ enable_radicale | default(false) }}" records: - value: $server_name.$domain. # DMARC - name: _dmarc type: TXT + when: "{{ enable_mail | default(false) }}" records: - value: "{{ 'v=DMARC1; p=none; rua=mailto:dmarc@$domain' | hetzner.hcloud.txt_record }}" - - # DKIM — uncomment after first mail deployment and add key to vault.yml - # - name: mail._domainkey - # type: TXT - # records: - # - value: "{{ dkim_keys['$domain'] | hetzner.hcloud.txt_record }}" - -# Extra domains (additional mail-hosted domains) — add as needed: -# - zone: example2.com -# records: -# - name: "@" -# type: A -# records: -# - value: $server_ip -# -# - name: "@" -# type: MX -# records: -# - value: "10 {{ mail_hostname }}." -# -# - name: "@" -# type: TXT -# records: -# - value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}" -# -# - name: www -# type: CNAME -# records: -# - value: example2.com. -# -# - name: _dmarc -# type: TXT -# records: -# - value: "{{ 'v=DMARC1; p=none; rua=mailto:dmarc@example2.com' | hetzner.hcloud.txt_record }}" -# -# # - name: mail._domainkey -# # type: TXT -# # records: -# # - value: "{{ dkim_keys['example2.com'] | hetzner.hcloud.txt_record }}" diff --git a/inventory/group_vars/all/vault.yml.setup b/inventory/group_vars/all/vault.yml.setup index 49e3784..d08713f 100644 --- a/inventory/group_vars/all/vault.yml.setup +++ b/inventory/group_vars/all/vault.yml.setup @@ -6,6 +6,10 @@ # Edit with: ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml # ============================================================ +# system +root_password: "$root_password" +admin_password: "$admin_password" + # hetzner hcloud_token: "$hcloud_token" @@ -33,6 +37,10 @@ grafana_admin_password: "$grafana_admin_password" # token generated with: openssl rand -base64 32 tuwunel_registration_token: "$tuwunel_registration_token" +# radicale +# password generated with: openssl rand -base64 32 +radicale_password: "$radicale_password" + # goaccess # password generated with: openssl rand -base64 32 goaccess_password: "$goaccess_password" @@ -47,9 +55,8 @@ restic_password: "$restic_password" # fail2ban (optional — IPs/CIDRs to whitelist) # fail2ban_ignoreip: "your-home-ip/32" -# DKIM public keys — one entry per domain -# Retrieve after first mail deployment: +# DKIM public keys — add after first mail deployment: # docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub # Format: "v=DKIM1; k=rsa; p=" -dkim_keys: - $domain: "" +# dkim_keys: +# $domain: "v=DKIM1; k=rsa; p=..." diff --git a/playbooks/bootstrap.yml b/playbooks/bootstrap.yml index 75559fe..ba2fe15 100644 --- a/playbooks/bootstrap.yml +++ b/playbooks/bootstrap.yml @@ -2,6 +2,8 @@ - name: Bootstrap Ubuntu server hosts: all become: true + vars: + ansible_user: root pre_tasks: - name: Ensure apt cache is up to date @@ -14,16 +16,32 @@ timezone: name: "{{ timezone }}" + - name: Set root password + ansible.builtin.user: + name: root + password: "{{ root_password | password_hash('sha512') }}" + - name: Create admin user - user: + ansible.builtin.user: name: "{{ admin_user }}" + password: "{{ admin_password | password_hash('sha512') }}" groups: sudo shell: "{{ admin_shell }}" append: true create_home: true + - name: Grant admin user passwordless sudo + lineinfile: + path: /etc/sudoers.d/{{ admin_user }} + line: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL" + create: true + mode: "0440" + validate: visudo -cf %s + + # BUG: ansible.posix.authorized_key emits a deprecation warning — this is a known upstream bug: + # https://github.com/ansible-collections/ansible.posix/issues/695 - name: Authorize SSH key for admin user - authorized_key: + ansible.posix.authorized_key: user: "{{ admin_user }}" key: "{{ admin_ssh_key }}" diff --git a/playbooks/docker.yml b/playbooks/networks.yml similarity index 51% rename from playbooks/docker.yml rename to playbooks/networks.yml index 4494567..26b1674 100644 --- a/playbooks/docker.yml +++ b/playbooks/networks.yml @@ -1,8 +1,7 @@ --- -- name: Install Docker and prepare filesystem +- name: Create Docker networks hosts: all become: true roles: - - docker - docker_network diff --git a/playbooks/site.yml b/playbooks/site.yml index d726b36..fb099db 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -9,8 +9,8 @@ - import_playbook: bootstrap.yml tags: [bootstrap, never] # only runs when explicitly tagged -- import_playbook: docker.yml - tags: [docker] +- import_playbook: networks.yml + tags: [networks] - import_playbook: nebula.yml tags: [nebula] diff --git a/requirements.yml b/requirements.yml index ca82f8a..1ae0048 100644 --- a/requirements.yml +++ b/requirements.yml @@ -2,3 +2,4 @@ collections: - name: hetzner.hcloud version: ">=6.0.0" + - name: ansible.posix diff --git a/roles/caddy/templates/Caddyfile.j2 b/roles/caddy/templates/Caddyfile.j2 index ad7f861..80a9098 100644 --- a/roles/caddy/templates/Caddyfile.j2 +++ b/roles/caddy/templates/Caddyfile.j2 @@ -52,6 +52,11 @@ www.{{ site }} { {% endfor %} {% if enable_mail | default(false) %} +http://{{ mail_hostname }} { + root * /var/www/acme + file_server +} + {{ webmail_domain }} { import access_log reverse_proxy rainloop:{{ rainloop_port }} diff --git a/roles/caddy/templates/compose.yml.j2 b/roles/caddy/templates/compose.yml.j2 index 9f37ab6..7f40b12 100644 --- a/roles/caddy/templates/compose.yml.j2 +++ b/roles/caddy/templates/compose.yml.j2 @@ -19,6 +19,7 @@ services: - /srv/caddy/config:/config - /srv/caddy/sites:/srv/sites:ro - /srv/goaccess/reports:/srv/goaccess/reports:ro + - /var/www/acme:/var/www/acme:ro environment: {% if enable_goaccess | default(true) %} GOACCESS_USER: "{{ goaccess_user }}" diff --git a/roles/dns/tasks/extra_mail_domain.yml b/roles/dns/tasks/extra_mail_domain.yml new file mode 100644 index 0000000..c2bbc41 --- /dev/null +++ b/roles/dns/tasks/extra_mail_domain.yml @@ -0,0 +1,56 @@ +--- +- name: "{{ extra_domain }} A record" + hetzner.hcloud.zone_rrset: + zone: "{{ extra_domain }}" + name: "@" + type: A + ttl: 300 + records: + - value: "{{ server_ip }}" + api_token: "{{ hcloud_token }}" + state: present + +- name: "{{ extra_domain }} MX record" + hetzner.hcloud.zone_rrset: + zone: "{{ extra_domain }}" + name: "@" + type: MX + ttl: 300 + records: + - value: "10 {{ mail_hostname }}." + api_token: "{{ hcloud_token }}" + state: present + +- name: "{{ extra_domain }} SPF record" + hetzner.hcloud.zone_rrset: + zone: "{{ extra_domain }}" + name: "@" + type: TXT + ttl: 300 + records: + - value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}" + api_token: "{{ hcloud_token }}" + state: present + +- name: "{{ extra_domain }} DMARC record" + hetzner.hcloud.zone_rrset: + zone: "{{ extra_domain }}" + name: _dmarc + type: TXT + ttl: 300 + records: + - value: "{{ ('v=DMARC1; p=none; rua=mailto:dmarc@' + extra_domain) | hetzner.hcloud.txt_record }}" + api_token: "{{ hcloud_token }}" + state: present + +- name: "{{ extra_domain }} DKIM record" + hetzner.hcloud.zone_rrset: + zone: "{{ extra_domain }}" + name: mail._domainkey + type: TXT + ttl: 300 + records: + - value: "{{ dkim_keys[extra_domain] | hetzner.hcloud.txt_record }}" + api_token: "{{ hcloud_token }}" + state: present + when: dkim_keys is defined and extra_domain in dkim_keys diff --git a/roles/dns/tasks/main.yml b/roles/dns/tasks/main.yml index 93886ca..003f616 100644 --- a/roles/dns/tasks/main.yml +++ b/roles/dns/tasks/main.yml @@ -22,4 +22,39 @@ loop: "{{ dns_zones | subelements('records') }}" loop_control: label: "{{ item.0.zone }} {{ item.1.name }} {{ item.1.type }}" + when: item.1.when | default(true) | bool + tags: dns + +- name: Ensure extra mail domain zones exist + hetzner.hcloud.zone: + name: "{{ item }}" + mode: primary + api_token: "{{ hcloud_token }}" + state: present + loop: "{{ mail_domains | difference([domain]) }}" + when: enable_mail + tags: dns + +- name: Configure extra mail domain DNS records + ansible.builtin.include_tasks: extra_mail_domain.yml + vars: + extra_domain: "{{ item }}" + loop: "{{ mail_domains | difference([domain]) }}" + when: enable_mail + tags: dns + +- name: Manage DKIM records + hetzner.hcloud.zone_rrset: + zone: "{{ item.key }}" + name: mail._domainkey + type: TXT + ttl: 300 + records: + - value: "{{ item.value | hetzner.hcloud.txt_record }}" + api_token: "{{ hcloud_token }}" + state: present + loop: "{{ dkim_keys | default({}) | dict2items }}" + loop_control: + label: "{{ item.key }} mail._domainkey TXT" + when: enable_mail | default(false) and item.value | length > 0 tags: dns diff --git a/roles/forgejo/defaults/main.yml b/roles/forgejo/defaults/main.yml index 3eeaad0..46d4302 100644 --- a/roles/forgejo/defaults/main.yml +++ b/roles/forgejo/defaults/main.yml @@ -22,5 +22,6 @@ forgejo_mailer_enabled: false # forgejo_smtp_password: defined in vault.yml # Actions runner +forgejo_runner_version: "12" forgejo_runner_name: default-runner forgejo_runner_labels: "docker:docker://node:20-bookworm,ubuntu-latest:docker://ubuntu:latest,ubuntu-22.04:docker://ubuntu:22.04" diff --git a/roles/forgejo/tasks/main.yml b/roles/forgejo/tasks/main.yml index 38d9177..434c9d6 100644 --- a/roles/forgejo/tasks/main.yml +++ b/roles/forgejo/tasks/main.yml @@ -13,11 +13,14 @@ loop: - /srv/forgejo -- name: Create Forgejo data directory +- name: Create Forgejo data directories ansible.builtin.file: - path: /srv/forgejo/data + path: "{{ item }}" state: directory mode: '0755' + loop: + - /srv/forgejo/data + - /srv/forgejo/data/gitea/conf # stat+chown: avoids UID/GID lookup warnings for container-internal UIDs not present on host - name: Stat Forgejo data directory diff --git a/roles/goaccess/tasks/main.yml b/roles/goaccess/tasks/main.yml index 5470b44..2e92cf7 100644 --- a/roles/goaccess/tasks/main.yml +++ b/roles/goaccess/tasks/main.yml @@ -59,6 +59,7 @@ owner: root group: root mode: "0755" + when: enable_goaccess_sync | default(false) - name: Deploy sync systemd service ansible.builtin.template: @@ -68,6 +69,7 @@ group: root mode: "0644" notify: Reload systemd + when: enable_goaccess_sync | default(false) - name: Deploy sync systemd timer ansible.builtin.template: @@ -77,15 +79,20 @@ group: root mode: "0644" notify: Reload systemd + when: enable_goaccess_sync | default(false) - name: Flush handlers to reload systemd ansible.builtin.meta: flush_handlers -- name: Enable and start GoAccess timers +- name: Enable and start GoAccess report timer ansible.builtin.systemd: - name: "{{ item }}" + name: goaccess-report.timer enabled: true state: started - loop: - - goaccess-report.timer - - goaccess-sync.timer + +- name: Enable and start GoAccess sync timer + ansible.builtin.systemd: + name: goaccess-sync.timer + enabled: true + state: started + when: enable_goaccess_sync | default(false) diff --git a/roles/goaccess/templates/goaccess-sync.sh.j2 b/roles/goaccess/templates/goaccess-sync.sh.j2 index 5193a80..ae7cb4e 100644 --- a/roles/goaccess/templates/goaccess-sync.sh.j2 +++ b/roles/goaccess/templates/goaccess-sync.sh.j2 @@ -2,6 +2,6 @@ set -euo pipefail rsync -az --delete \ - -e "ssh -i {{ restic_ssh_key }} -p {{ restic_ssh_port }} -o StrictHostKeyChecking=no -o BatchMode=yes" \ + -e "ssh -i {{ goaccess_sync_ssh_key }} -p {{ goaccess_sync_ssh_port }} -o StrictHostKeyChecking=no -o BatchMode=yes" \ /srv/goaccess/reports/ \ - {{ restic_user }}@{{ restic_host }}:analytics/ + {{ goaccess_sync_user }}@{{ goaccess_sync_host }}:{{ goaccess_sync_remote_path }}/ diff --git a/roles/mail/tasks/aliases.yml b/roles/mail/tasks/aliases.yml index a8fcaf7..7b8dd92 100644 --- a/roles/mail/tasks/aliases.yml +++ b/roles/mail/tasks/aliases.yml @@ -1,8 +1,10 @@ # read-only docker exec always reports changed; changed_when: false suppresses spurious output +# failed_when: false — postfix-virtual.cf may not exist on first run - name: List existing mail aliases command: docker exec mailserver setup alias list register: mail_alias_list changed_when: false + failed_when: false tags: - users diff --git a/roles/mail/tasks/main.yml b/roles/mail/tasks/main.yml index ead7274..45ffc5d 100644 --- a/roles/mail/tasks/main.yml +++ b/roles/mail/tasks/main.yml @@ -92,15 +92,32 @@ name: certbot state: present +- name: Check if mail TLS certificate already exists + ansible.builtin.stat: + path: /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem + register: mail_cert + +- name: Stop Caddy to free port 80 for certbot + community.docker.docker_compose_v2: + project_src: /srv/caddy + state: stopped + when: not mail_cert.stat.exists + - name: Obtain a Let's Encrypt certificate for {{ mail_hostname }} command: > certbot certonly --standalone -d {{ mail_hostname }} --non-interactive --agree-tos -m postmaster@{{ domain }} - args: - creates: /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem + when: not mail_cert.stat.exists tags: config +- name: Restart Caddy after certbot + community.docker.docker_compose_v2: + project_src: /srv/caddy + state: present + build: never + when: not mail_cert.stat.exists + - name: Deploy mail compose file template: src: compose.yml.j2 @@ -126,6 +143,17 @@ notify: Restart mailserver tags: config +- name: Seed mail accounts into postfix-accounts.cf before first start + ansible.builtin.shell: | + grep -qF "{{ item.address }}" /srv/mail/config/postfix-accounts.cf 2>/dev/null && exit 0 + hash=$(openssl passwd -6 {{ item.password | quote }}) + printf '%s|{SHA512-CRYPT}%s\n' "{{ item.address }}" "${hash}" >> /srv/mail/config/postfix-accounts.cf + loop: "{{ mail_users }}" + no_log: true + args: + executable: /bin/bash + tags: users + - name: Start mailserver community.docker.docker_compose_v2: project_src: /srv/mail diff --git a/roles/mail/tasks/users.yml b/roles/mail/tasks/users.yml index 945392d..e232b87 100644 --- a/roles/mail/tasks/users.yml +++ b/roles/mail/tasks/users.yml @@ -1,8 +1,10 @@ # read-only docker exec always reports changed; changed_when: false suppresses spurious output +# failed_when: false — postfix-accounts.cf may not exist on first run (seeded separately) - name: Check if mail user exists command: docker exec mailserver setup email list register: mail_user_list changed_when: false + failed_when: false tags: - users diff --git a/roles/provision/defaults/main.yml b/roles/provision/defaults/main.yml index 48ff453..f542660 100644 --- a/roles/provision/defaults/main.yml +++ b/roles/provision/defaults/main.yml @@ -1,4 +1,5 @@ --- -hcloud_server_type: cx22 +cloud_provider: hetzner +hcloud_server_type: cx23 hcloud_image: ubuntu-24.04 hcloud_location: fsn1 diff --git a/roles/provision/tasks/hetzner.yml b/roles/provision/tasks/hetzner.yml index 52f1dd9..a54ae54 100644 --- a/roles/provision/tasks/hetzner.yml +++ b/roles/provision/tasks/hetzner.yml @@ -30,11 +30,18 @@ - name: Update inventory with new IP ansible.builtin.lineinfile: - path: "{{ inventory_dir }}/hosts.yml" + path: "{{ lookup('env', 'LINDERHOF_DIR') }}/hosts.yml" regexp: '^\s+ansible_host:' line: " ansible_host: {{ server_ip }}" delegate_to: localhost +- name: Update config with new IP + ansible.builtin.lineinfile: + path: "{{ lookup('env', 'LINDERHOF_DIR') }}/group_vars/all/config.yml" + regexp: '^server_ip:' + line: "server_ip: {{ server_ip }}" + delegate_to: localhost + - name: Print server IP ansible.builtin.debug: msg: "Server '{{ server_name }}' provisioned at {{ server_ip }}" diff --git a/setup.sh b/setup.sh index c24d106..42026ac 100755 --- a/setup.sh +++ b/setup.sh @@ -91,7 +91,7 @@ echo info "configure your server" prompt admin_user "Admin username" "$USER" prompt server_name "Server hostname" "$stack_name" -prompt server_ip "Server IP (or TBD)" "0.0.0.0" +prompt server_ip "Server IP (leave TBD if provisioning via Hetzner)" "TBD" prompt domain "Domain" "example.com" prompt_secret hcloud_token "Hetzner API token (leave blank to skip)" @@ -113,17 +113,21 @@ info " rspamd: rspamd.$domain" # ── 7. generate secrets ───────────────────────────────────── info "generating secrets..." +export root_password admin_password export admin_mail_password notifications_mail_password git_mail_password -export grafana_admin_password rspamd_web_password goaccess_password rainloop_admin_password +export grafana_admin_password rspamd_web_password goaccess_password rainloop_admin_password radicale_password export tuwunel_registration_token restic_password export forgejo_secret_key forgejo_internal_token forgejo_jwt_secret +root_password=$(openssl rand -base64 32) +admin_password=$(openssl rand -base64 32) admin_mail_password=$(openssl rand -base64 32) notifications_mail_password=$(openssl rand -base64 32) git_mail_password=$(openssl rand -base64 32) grafana_admin_password=$(openssl rand -base64 32) rspamd_web_password=$(openssl rand -base64 32) goaccess_password=$(openssl rand -base64 32) +radicale_password=$(openssl rand -base64 32) rainloop_admin_password=$(openssl rand -base64 32) tuwunel_registration_token=$(openssl rand -base64 32) restic_password=$(openssl rand -base64 32) @@ -160,7 +164,7 @@ else info "writing stack.env..." cat > "$STACK_ENV" < "$DNS_CONFIG" ok "dns.yml created (uncomment DKIM records after first mail deployment)" fi