diff --git a/CLAUDE.md b/CLAUDE.md index bef5a44..b1be529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,12 +40,14 @@ Note: Inventory and vault password are set via `ANSIBLE_INVENTORY` and `ANSIBLE_ - `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) +- `dkim_sync.yml` - Fetch DKIM keys from mailserver and publish to DNS (run once after first mail deploy) **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 +5. `dkim_sync.yml` - generate DKIM keys, write to stack config, publish to DNS **Playbook Execution Order** (via `site.yml`): 1. networks.yml - Pre-create all Docker networks (must run before any service) diff --git a/README.md b/README.md index 71f0851..20b5044 100644 --- a/README.md +++ b/README.md @@ -2,38 +2,27 @@ > *Linderhof* — the smallest and most intimate of Ludwig II's Bavarian palaces, the only one he lived to see completed; built entirely to his own vision as a private retreat. ([Wikipedia](https://en.wikipedia.org/wiki/Linderhof_Palace)) -a self-hosting stack based on ansible and docker compose that comes with +**[codeberg.org/opennomad/linderhof](https://codeberg.org/opennomad/linderhof)** -- email - - [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver) - - [rainloop](https://www.rainloop.net/) -- web server - - [caddy](https://caddyserver.com/) -- git server - - [forgejo](https://forgejo.org/) -- matrix homeserver - - [tuwunel](https://github.com/matrix-construct/tuwunel) -- monitoring - - [alloy](https://github.com/grafana/alloy) - - [grafana](https://grafana.com/) - - [prometheus](https://prometheus.io/) - - [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 - - [nebula](https://github.com/slackhq/nebula) -- docker image update notifications - - [diun](https://github.com/crazy-max/diun) -- intrusion prevention - - [fail2ban](https://github.com/fail2ban/fail2ban) +a self-hosting stack based on ansible and docker compose that comes with email, web server, git hosting, matrix, monitoring, web analytics, calendar & contacts, backups, overlay networking, and intrusion prevention — no databases, no external services. -other features include: -- runs on opensource -- no databases / no external services +set `enable_: false` in `config.yml` to disable any service — DNS records, Docker networks, and deployment tasks are all skipped automatically. + +| service | toggle | default | powered by | +|---|---|---|---| +| web server | `enable_caddy` | on | [caddy](https://caddyserver.com/) | +| email | `enable_mail` | on | [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver), [rainloop](https://www.rainloop.net/) | +| git hosting | `enable_forgejo` | on | [forgejo](https://forgejo.org/) | +| matrix homeserver | `enable_tuwunel` | on | [tuwunel](https://github.com/matrix-construct/tuwunel) | +| monitoring | `enable_monitoring` | on | [prometheus](https://prometheus.io/), [grafana](https://grafana.com/), [loki](https://github.com/grafana/loki), [alloy](https://github.com/grafana/alloy) | +| web analytics | `enable_goaccess` | on | [goaccess](https://goaccess.io/) | +| calendar & contacts | `enable_radicale` | on | [radicale](https://radicale.org/) | +| backups | `enable_restic` | **off** | [restic](https://github.com/restic/restic) | +| overlay network | `enable_nebula` | on | [nebula](https://github.com/slackhq/nebula) | +| image update alerts | `enable_diun` | on | [diun](https://github.com/crazy-max/diun) | +| intrusion prevention | `enable_fail2ban` | on | [fail2ban](https://github.com/fail2ban/fail2ban) | + +> **restic** is off by default — it requires a [Hetzner Storage Box](https://www.hetzner.com/storage/storage-box/) for its backup target. enable it and configure `restic_repository` in `config.yml` once you have one. ## what you need @@ -76,41 +65,27 @@ ansible-galaxy collection install -r requirements.yml ## deploy -### provision a server (Hetzner) +full deployment order for a fresh server: ```bash -ansible-playbook playbooks/provision.yml +ansible-playbook playbooks/provision.yml # create server, writes IP to stack config +ansible-playbook playbooks/dns.yml # create DNS zones and records +ansible-playbook playbooks/site.yml --tags bootstrap # users, SSH hardening, packages, Docker +ansible-playbook playbooks/site.yml # deploy all services +ansible-playbook playbooks/dkim_sync.yml # generate DKIM keys and publish to DNS ``` -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`. +**provision** creates the server on Hetzner, 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 +**dns** creates all zones and records conditional on your `enable_*` settings — disabled services get no DNS entries. -```bash -ansible-playbook playbooks/dns.yml -``` +**bootstrap** connects as `root` (the only user on a fresh server), creates your admin user with passwordless sudo, hardens SSH, and installs base packages including Docker. -creates all DNS zones and records for your domain. records are conditional on your `enable_*` settings — disabled services won't get DNS entries. +**site.yml** deploys all enabled services. subsequent runs are idempotent — safe to re-run to apply config changes. -### bootstrap the server +> **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. -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 -``` - -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. +**dkim_sync** generates DKIM keys for all mail domains, writes them to your stack config, and publishes the `mail._domainkey` DNS records. safe to re-run. ## bring your own server @@ -118,7 +93,7 @@ deploys all enabled services. subsequent runs are idempotent — safe to re-run 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 +2. ensure your SSH key is authorized for the admin user and they have passwordless sudo — or run `ansible-playbook playbooks/site.yml --tags bootstrap` 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` @@ -155,24 +130,6 @@ stack config lives at `$XDG_CONFIG_HOME/linderhof//`: ``` -## 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 @@ -213,24 +170,13 @@ 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: +run `dkim_sync.yml` once after the first mail deployment — it generates DKIM keys for all mail domains, writes them to your stack config, and publishes the `mail._domainkey` DNS records automatically: ```bash -docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim//mail.pub -ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml +ansible-playbook playbooks/dkim_sync.yml ``` -add under `dkim_keys`: -```yaml -dkim_keys: - example.com: "v=DKIM1; k=rsa; p=..." -``` - -then re-run DNS — the `mail._domainkey` record is created automatically: - -```bash -ansible-playbook playbooks/dns.yml -``` +keys are stored in `$LINDERHOF_DIR/group_vars/all/dkim.yml` (plain file — DKIM public keys are not secret). safe to re-run; only generates keys for domains that don't have one yet. ## common operations diff --git a/inventory/group_vars/all/config.yml.setup b/inventory/group_vars/all/config.yml.setup index ef052ee..df90af3 100644 --- a/inventory/group_vars/all/config.yml.setup +++ b/inventory/group_vars/all/config.yml.setup @@ -17,6 +17,7 @@ # ============================================================ # Services — set to false to disable # ============================================================ +enable_caddy: true enable_mail: true enable_forgejo: true enable_monitoring: true @@ -34,6 +35,12 @@ enable_radicale: true # ============================================================ domain: $domain server_name: $server_name + +# Labels applied to all Hetzner cloud resources (server, SSH key). +# DNS resources do not support labels. +hcloud_labels: + managed-by: linderhof + stack: $stack_name server_ip: $server_ip admin_user: $admin_user admin_shell: /bin/zsh diff --git a/inventory/group_vars/all/vault.yml.setup b/inventory/group_vars/all/vault.yml.setup index d08713f..e9bc1cc 100644 --- a/inventory/group_vars/all/vault.yml.setup +++ b/inventory/group_vars/all/vault.yml.setup @@ -54,9 +54,3 @@ restic_password: "$restic_password" # fail2ban (optional — IPs/CIDRs to whitelist) # fail2ban_ignoreip: "your-home-ip/32" - -# 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: "v=DKIM1; k=rsa; p=..." diff --git a/playbooks/caddy.yml b/playbooks/caddy.yml index 92d4c38..6e9b632 100644 --- a/playbooks/caddy.yml +++ b/playbooks/caddy.yml @@ -4,4 +4,5 @@ become: true roles: - - caddy + - role: caddy + when: enable_caddy | default(true) diff --git a/playbooks/dkim_sync.yml b/playbooks/dkim_sync.yml new file mode 100644 index 0000000..c63d6ff --- /dev/null +++ b/playbooks/dkim_sync.yml @@ -0,0 +1,72 @@ +--- +# Fetch DKIM public keys from the running mailserver and publish them to DNS. +# +# Safe to re-run — only generates keys for domains that don't have one yet. +# Run after the first mail deployment: +# ansible-playbook playbooks/dkim_sync.yml +# +# What it does: +# 1. Generates DKIM keys for any domain missing one (skips existing keys) +# 2. Discovers each domain's key file at runtime via find — the algorithm prefix +# (rsa-2048-..., ed25519-...) may vary across docker-mailserver versions, +# but the *.public.dns.txt suffix is stable +# 3. Writes $LINDERHOF_DIR/group_vars/all/dkim.yml (plain file — DKIM keys are public) +# 4. Runs dns.yml to create/update mail._domainkey TXT records for all domains + +- name: Fetch DKIM keys from mailserver + hosts: all + gather_facts: false + + tasks: + - name: Check for existing DKIM key files + # /srv/mail/config is the host-side mount of /tmp/docker-mailserver in the container + command: find /srv/mail/config/rspamd/dkim -name "*{{ item }}.public.dns.txt" -type f + loop: "{{ mail_domains }}" + register: dkim_existing + changed_when: false + failed_when: false + + - name: Generate DKIM keys for domains without existing keys + command: docker exec mailserver setup config dkim + when: dkim_existing.results | selectattr('stdout', 'equalto', '') | list | length > 0 + changed_when: true + + - name: Find DKIM DNS TXT key file for each domain + command: find /srv/mail/config/rspamd/dkim -name "*{{ item }}.public.dns.txt" -type f + loop: "{{ mail_domains }}" + register: dkim_file_paths + changed_when: false + + - name: Read DKIM public key for each domain + slurp: + src: "{{ item.stdout | trim }}" + loop: "{{ dkim_file_paths.results }}" + loop_control: + label: "{{ item.item }}" + register: dkim_keys_raw + + - name: Build dkim_keys dict + set_fact: + dkim_keys_collected: >- + {{ dkim_keys_collected | default({}) | combine({item.item.item: item.content | b64decode | trim}) }} + loop: "{{ dkim_keys_raw.results }}" + loop_control: + label: "{{ item.item.item }}" + + - name: Write dkim.yml to stack config directory + delegate_to: localhost + become: false + # Written to a separate dkim.yml rather than config.yml so this playbook can safely + # overwrite it without touching the hand-edited config. Ansible loads all files under + # group_vars/all/ automatically, so dkim_keys is available to all roles either way. + copy: + content: | + --- + # DKIM public keys — written automatically by dkim_sync.yml, do not edit manually + dkim_keys: + {% for domain_name, key in dkim_keys_collected.items() %} + {{ domain_name }}: "{{ key }}" + {% endfor %} + dest: "{{ lookup('env', 'ANSIBLE_INVENTORY') | dirname }}/group_vars/all/dkim.yml" + +- import_playbook: dns.yml diff --git a/playbooks/dns.yml b/playbooks/dns.yml index e464379..46be005 100644 --- a/playbooks/dns.yml +++ b/playbooks/dns.yml @@ -4,13 +4,7 @@ # Zone definitions live in $LINDERHOF_DIR/group_vars/all/dns.yml # (generated from inventory/group_vars/all/dns.yml.setup by setup.sh). # -# To add DKIM keys after first mail deployment: -# docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim//mail.pub -# Then add to vault.yml: -# ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml -# dkim_keys: -# example.com: "v=DKIM1; k=rsa; p=..." -# And uncomment the mail._domainkey record in dns.yml. +# DKIM records are managed automatically by dkim_sync.yml — do not add manually. # # Usage: ansible-playbook playbooks/dns.yml - name: Manage DNS zones on Hetzner Cloud diff --git a/roles/caddy/tasks/main.yml b/roles/caddy/tasks/main.yml index db38c66..74e55a4 100644 --- a/roles/caddy/tasks/main.yml +++ b/roles/caddy/tasks/main.yml @@ -38,6 +38,16 @@ mode: "0775" # also allow members of the docker group to write loop: "{{ caddy_sites }}" +- name: Deploy default landing page for empty sites + template: + src: index.html.j2 + dest: "/srv/caddy/sites/{{ item }}/index.html" + owner: root + group: docker + mode: "0644" + force: false # never overwrite real content + loop: "{{ caddy_sites }}" + - name: Install Caddyfile template: src: Caddyfile.j2 diff --git a/roles/caddy/templates/index.html.j2 b/roles/caddy/templates/index.html.j2 new file mode 100644 index 0000000..5dba69b --- /dev/null +++ b/roles/caddy/templates/index.html.j2 @@ -0,0 +1,86 @@ +{# + Adapted from "Coming Soon" by Steven Tang (github.com/YC/coming-soon) + MIT License — https://github.com/YC/coming-soon/blob/master/LICENSE +#} + + + + + + {{ item }} + + + +
+

{{ item }}.

+

Something's on its way.

+
+ + + diff --git a/roles/dns/defaults/main.yml b/roles/dns/defaults/main.yml index 0db6919..2329672 100644 --- a/roles/dns/defaults/main.yml +++ b/roles/dns/defaults/main.yml @@ -1,2 +1,3 @@ --- dns_zones: [] +hcloud_labels: {} # override in config.yml — see config.yml.setup for recommended labels diff --git a/roles/dns/tasks/extra_mail_domain.yml b/roles/dns/tasks/extra_mail_domain.yml index c2bbc41..0996821 100644 --- a/roles/dns/tasks/extra_mail_domain.yml +++ b/roles/dns/tasks/extra_mail_domain.yml @@ -7,6 +7,7 @@ ttl: 300 records: - value: "{{ server_ip }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -18,6 +19,7 @@ ttl: 300 records: - value: "10 {{ mail_hostname }}." + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -29,6 +31,7 @@ ttl: 300 records: - value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -40,6 +43,7 @@ ttl: 300 records: - value: "{{ ('v=DMARC1; p=none; rua=mailto:dmarc@' + extra_domain) | hetzner.hcloud.txt_record }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -51,6 +55,7 @@ ttl: 300 records: - value: "{{ dkim_keys[extra_domain] | hetzner.hcloud.txt_record }}" + labels: "{{ hcloud_labels }}" 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 003f616..33da81c 100644 --- a/roles/dns/tasks/main.yml +++ b/roles/dns/tasks/main.yml @@ -3,6 +3,7 @@ hetzner.hcloud.zone: name: "{{ item.zone }}" mode: primary + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present loop: "{{ dns_zones }}" @@ -17,6 +18,7 @@ type: "{{ item.1.type }}" ttl: "{{ item.1.ttl | default(300) }}" records: "{{ item.1.records }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present loop: "{{ dns_zones | subelements('records') }}" @@ -29,6 +31,7 @@ hetzner.hcloud.zone: name: "{{ item }}" mode: primary + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present loop: "{{ mail_domains | difference([domain]) }}" @@ -51,6 +54,7 @@ ttl: 300 records: - value: "{{ item.value | hetzner.hcloud.txt_record }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present loop: "{{ dkim_keys | default({}) | dict2items }}" diff --git a/roles/provision/defaults/main.yml b/roles/provision/defaults/main.yml index f542660..17e035e 100644 --- a/roles/provision/defaults/main.yml +++ b/roles/provision/defaults/main.yml @@ -3,3 +3,4 @@ cloud_provider: hetzner hcloud_server_type: cx23 hcloud_image: ubuntu-24.04 hcloud_location: fsn1 +hcloud_labels: {} # override in config.yml — see config.yml.setup for recommended labels diff --git a/roles/provision/tasks/hetzner.yml b/roles/provision/tasks/hetzner.yml index a54ae54..2b0af11 100644 --- a/roles/provision/tasks/hetzner.yml +++ b/roles/provision/tasks/hetzner.yml @@ -3,6 +3,7 @@ hetzner.hcloud.ssh_key: name: "{{ admin_user }}" public_key: "{{ admin_ssh_key }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -14,6 +15,7 @@ location: "{{ hcloud_location }}" ssh_keys: - "{{ admin_user }}" + labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present register: server_result diff --git a/setup.sh b/setup.sh index 42026ac..98246b8 100755 --- a/setup.sh +++ b/setup.sh @@ -45,6 +45,8 @@ ok "collections installed" echo info "stack setup" prompt stack_name "Stack name" "home" +export stack_name + LINDERHOF_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof" STACK_DIR="$LINDERHOF_CONFIG_DIR/$stack_name" @@ -174,7 +176,7 @@ if [[ -f "$CONFIG" ]]; then warn "config.yml already exists — skipping (not overwriting)" else info "writing config.yml..." - envsubst '$admin_user $server_name $server_ip $domain $ssh_key_pub' \ + envsubst '$admin_user $server_name $server_ip $domain $ssh_key_pub $stack_name' \ < "$TEMPLATES/config.yml.setup" > "$CONFIG" ok "config.yml created" fi @@ -242,6 +244,6 @@ echo " 2. Review $VAULT (ansible-vault edit)" echo " 3. Review $DNS_CONFIG" echo " 4. Provision a server: ansible-playbook playbooks/provision.yml" echo " 5. Update DNS: ansible-playbook playbooks/dns.yml" -echo " 6. Deploy: ansible-playbook playbooks/site.yml" -echo " 7. After mail deploys, retrieve DKIM keys and add to vault.yml:" -echo " docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim/$domain/mail.pub" +echo " 6. Bootstrap server: ansible-playbook playbooks/site.yml --tags bootstrap" +echo " 7. Deploy: ansible-playbook playbooks/site.yml" +echo " 8. Sync DKIM keys to DNS: ansible-playbook playbooks/dkim_sync.yml"