diff --git a/CLAUDE.md b/CLAUDE.md index b1be529..bef5a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,14 +40,12 @@ 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 20b5044..71f0851 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,38 @@ > *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)) -**[codeberg.org/opennomad/linderhof](https://codeberg.org/opennomad/linderhof)** +a self-hosting stack based on ansible and docker compose that comes with -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. +- 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) -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. +other features include: +- runs on opensource +- no databases / no external services ## what you need @@ -65,27 +76,41 @@ ansible-galaxy collection install -r requirements.yml ## deploy -full deployment order for a fresh server: +### provision a server (Hetzner) ```bash -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 +ansible-playbook playbooks/provision.yml ``` -**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`. +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`. -**dns** creates all zones and records conditional on your `enable_*` settings — disabled services get no DNS entries. +### update DNS -**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. +```bash +ansible-playbook playbooks/dns.yml +``` -**site.yml** deploys all enabled services. subsequent runs are idempotent — safe to re-run to apply config changes. +creates all DNS zones and records for your domain. records are conditional on your `enable_*` settings — disabled services won't get DNS entries. -> **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. +### bootstrap the server -**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. +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. ## bring your own server @@ -93,7 +118,7 @@ ansible-playbook playbooks/dkim_sync.yml # generate DKIM keys and publi 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 `ansible-playbook playbooks/site.yml --tags bootstrap` first if starting from root access +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` @@ -130,6 +155,24 @@ 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 @@ -170,13 +213,24 @@ ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.yml ## after first mail deployment — DKIM -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: +retrieve the generated DKIM public key and add it to the vault: ```bash -ansible-playbook playbooks/dkim_sync.yml +docker exec mailserver cat /tmp/docker-mailserver/rspamd/dkim//mail.pub +ansible-vault edit $LINDERHOF_DIR/group_vars/all/vault.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. +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 +``` ## common operations diff --git a/inventory/group_vars/all/config.yml.setup b/inventory/group_vars/all/config.yml.setup index df90af3..ef052ee 100644 --- a/inventory/group_vars/all/config.yml.setup +++ b/inventory/group_vars/all/config.yml.setup @@ -17,7 +17,6 @@ # ============================================================ # Services — set to false to disable # ============================================================ -enable_caddy: true enable_mail: true enable_forgejo: true enable_monitoring: true @@ -35,12 +34,6 @@ 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 e9bc1cc..d08713f 100644 --- a/inventory/group_vars/all/vault.yml.setup +++ b/inventory/group_vars/all/vault.yml.setup @@ -54,3 +54,9 @@ 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 6e9b632..92d4c38 100644 --- a/playbooks/caddy.yml +++ b/playbooks/caddy.yml @@ -4,5 +4,4 @@ become: true roles: - - role: caddy - when: enable_caddy | default(true) + - caddy diff --git a/playbooks/dkim_sync.yml b/playbooks/dkim_sync.yml deleted file mode 100644 index c63d6ff..0000000 --- a/playbooks/dkim_sync.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -# 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 46be005..e464379 100644 --- a/playbooks/dns.yml +++ b/playbooks/dns.yml @@ -4,7 +4,13 @@ # Zone definitions live in $LINDERHOF_DIR/group_vars/all/dns.yml # (generated from inventory/group_vars/all/dns.yml.setup by setup.sh). # -# DKIM records are managed automatically by dkim_sync.yml — do not add manually. +# 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. # # 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 74e55a4..db38c66 100644 --- a/roles/caddy/tasks/main.yml +++ b/roles/caddy/tasks/main.yml @@ -38,16 +38,6 @@ 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 deleted file mode 100644 index 5dba69b..0000000 --- a/roles/caddy/templates/index.html.j2 +++ /dev/null @@ -1,86 +0,0 @@ -{# - 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 2329672..0db6919 100644 --- a/roles/dns/defaults/main.yml +++ b/roles/dns/defaults/main.yml @@ -1,3 +1,2 @@ --- 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 0996821..c2bbc41 100644 --- a/roles/dns/tasks/extra_mail_domain.yml +++ b/roles/dns/tasks/extra_mail_domain.yml @@ -7,7 +7,6 @@ ttl: 300 records: - value: "{{ server_ip }}" - labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -19,7 +18,6 @@ ttl: 300 records: - value: "10 {{ mail_hostname }}." - labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -31,7 +29,6 @@ ttl: 300 records: - value: "{{ 'v=spf1 mx -all' | hetzner.hcloud.txt_record }}" - labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -43,7 +40,6 @@ 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 @@ -55,7 +51,6 @@ 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 33da81c..003f616 100644 --- a/roles/dns/tasks/main.yml +++ b/roles/dns/tasks/main.yml @@ -3,7 +3,6 @@ hetzner.hcloud.zone: name: "{{ item.zone }}" mode: primary - labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present loop: "{{ dns_zones }}" @@ -18,7 +17,6 @@ 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') }}" @@ -31,7 +29,6 @@ hetzner.hcloud.zone: name: "{{ item }}" mode: primary - labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present loop: "{{ mail_domains | difference([domain]) }}" @@ -54,7 +51,6 @@ 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 17e035e..f542660 100644 --- a/roles/provision/defaults/main.yml +++ b/roles/provision/defaults/main.yml @@ -3,4 +3,3 @@ 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 2b0af11..a54ae54 100644 --- a/roles/provision/tasks/hetzner.yml +++ b/roles/provision/tasks/hetzner.yml @@ -3,7 +3,6 @@ hetzner.hcloud.ssh_key: name: "{{ admin_user }}" public_key: "{{ admin_ssh_key }}" - labels: "{{ hcloud_labels }}" api_token: "{{ hcloud_token }}" state: present @@ -15,7 +14,6 @@ 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 98246b8..42026ac 100755 --- a/setup.sh +++ b/setup.sh @@ -45,8 +45,6 @@ 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" @@ -176,7 +174,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 $stack_name' \ + envsubst '$admin_user $server_name $server_ip $domain $ssh_key_pub' \ < "$TEMPLATES/config.yml.setup" > "$CONFIG" ok "config.yml created" fi @@ -244,6 +242,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. 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" +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"