diff --git a/.envrc b/.envrc index f0cc64c..8bf73c2 100644 --- a/.envrc +++ b/.envrc @@ -1,30 +1,19 @@ # Stack selection — set LINDERHOF_STACK before sourcing, or create a .stack file -watch_file .stack if [[ -z "${LINDERHOF_STACK:-}" ]]; then if [[ -f "$PWD/.stack" ]]; then LINDERHOF_STACK="$(cat "$PWD/.stack")" - echo "linderhof: LINDERHOF_STACK is set to '$LINDERHOF_STACK'" fi fi if [[ -z "${LINDERHOF_STACK:-}" ]]; then echo "linderhof: LINDERHOF_STACK is not set" >&2 - echo " new here? run: ./setup.sh" >&2 - echo " existing stack? run: echo > .stack" >&2 + echo " set it in your environment, or run: echo > .stack" >&2 else export LINDERHOF_STACK export LINDERHOF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof/$LINDERHOF_STACK" export ANSIBLE_INVENTORY="$LINDERHOF_DIR/hosts.yml" export ANSIBLE_VAULT_PASSWORD_FILE="$LINDERHOF_DIR/vault-pass" - # Extract HCLOUD_TOKEN from vault for hcloud CLI and Ansible modules - if [[ -f "$LINDERHOF_DIR/vault-pass" && -f "$LINDERHOF_DIR/group_vars/all/vault.yml" ]]; then - HCLOUD_TOKEN="$(ansible-vault view "$LINDERHOF_DIR/group_vars/all/vault.yml" \ - --vault-password-file "$LINDERHOF_DIR/vault-pass" 2>/dev/null \ - | grep '^hcloud_token:' | sed 's/^hcloud_token: *"\?\(.*\)$/\1/; s/"$//')" - export HCLOUD_TOKEN - fi - # Per-stack overrides: DOCKER_HOST, etc. — written by setup.sh if [[ -f "$LINDERHOF_DIR/stack.env" ]]; then # shellcheck source=/dev/null diff --git a/CLAUDE.md b/CLAUDE.md index b94f76c..b1be529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,23 +37,17 @@ 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`): -- `deploy.yml` - Full first-time deployment (chains provision → dns → storage_box → bootstrap → site) - `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) -- `storage_box.yml` - Create/configure a Hetzner Storage Box for restic backups (run once before enabling restic) **Full deployment order** (fresh server): -1. `deploy.yml` - runs all steps below in one shot (first-time only — bootstrap connects as root) -2. `dkim_sync.yml` - generate DKIM keys, write to stack config, publish to DNS (run once after mail is up) - -**What `deploy.yml` runs internally:** 1. `provision.yml` - create server, auto-writes IP to hosts.yml and config.yml 2. `dns.yml` - create DNS records -3. `storage_box.yml` - generate SSH key, configure storage box, writes storagebox.yml to stack config -4. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root) -5. `site.yml` - deploy all services +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 258c741..20b5044 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ set `enable_: false` in `config.yml` to disable any service — DNS rec | 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/). run `ansible-playbook playbooks/storage_box.yml` once to create the box, generate an SSH key pair, and install it — then set `enable_restic: true` and re-run `site.yml`. +> **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 @@ -33,7 +33,6 @@ set `enable_: false` in `config.yml` to disable any service — DNS rec - `ansible` and `ansible-galaxy` - `direnv` (optional but recommended — loads `.envrc` automatically) - `ssh-keygen`, `openssl`, `envsubst` (standard on most systems) - - `sshpass` (only needed for `storage_box.yml`) if you already have a server with SSH access and passwordless sudo, you can skip provisioning and jump straight to [deploy](#deploy). @@ -46,7 +45,7 @@ run the interactive setup wizard: ./setup.sh ``` -it walks you through: stack name, SSH key, admin username, server hostname, domain, Hetzner API token, storage box name and password, and generates all secrets. config is written 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. activate the stack and review the generated config: @@ -68,17 +67,9 @@ ansible-galaxy collection install -r requirements.yml full deployment order for a fresh server: -```bash -ansible-playbook playbooks/deploy.yml # provision → dns → storage_box → bootstrap → site (all-in-one) -ansible-playbook playbooks/dkim_sync.yml # generate DKIM keys and publish to DNS (run once after mail is up) -``` - -or step by step: - ```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/storage_box.yml # create storage box and install SSH key (if using restic) 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 diff --git a/inventory/group_vars/all/config.yml.setup b/inventory/group_vars/all/config.yml.setup index 717826e..df90af3 100644 --- a/inventory/group_vars/all/config.yml.setup +++ b/inventory/group_vars/all/config.yml.setup @@ -137,17 +137,11 @@ grafana_root_url: "https://{{ grafana_domain }}" # Restic (encrypted backups) # ============================================================ restic_backend_type: "sftp" -# Storage box name in Hetzner Cloud (https://console.hetzner.cloud) -restic_storagebox_name: "$restic_storagebox_name" -# To create a new storage box via storage_box.yml (rather than adopting an existing one): -# restic_storagebox_type: bx11 -# restic_storagebox_location: $hcloud_location -# The following are written automatically by storage_box.yml — do not edit manually # restic_host: "uXXXXXX.your-storagebox.de" # restic_user: uXXXXXX # restic_ssh_port: 23 # restic_remote_path: "backups/$server_name" -# restic_ssh_key: "/root/.ssh/restic_backup" +# restic_ssh_key: "/root/.ssh/island_restic_backup" # ============================================================ # GoAccess (web analytics) diff --git a/inventory/group_vars/all/vault.yml.setup b/inventory/group_vars/all/vault.yml.setup index 34ee18d..e9bc1cc 100644 --- a/inventory/group_vars/all/vault.yml.setup +++ b/inventory/group_vars/all/vault.yml.setup @@ -51,7 +51,6 @@ diun_email_password: "$notifications_mail_password" # restic # password generated with: openssl rand -base64 32 restic_password: "$restic_password" -restic_storagebox_password: "$restic_storagebox_password" # fail2ban (optional — IPs/CIDRs to whitelist) # fail2ban_ignoreip: "your-home-ip/32" diff --git a/playbooks/deploy.yml b/playbooks/deploy.yml deleted file mode 100644 index 003c438..0000000 --- a/playbooks/deploy.yml +++ /dev/null @@ -1,27 +0,0 @@ ---- -# Full first-time deployment — provisions and deploys everything in one shot. -# Usage: ansible-playbook playbooks/deploy.yml -# -# Prerequisites: run setup.sh first, then review config.yml, vault.yml, dns.yml -# -# This playbook is intended for initial deployments only. After the first run, -# bootstrap will fail (root SSH is disabled) — use site.yml for subsequent deploys. -# -# dkim_sync.yml is intentionally excluded: it requires the mail server to be -# fully running and keys generated. Run it manually after confirming mail is up: -# ansible-playbook playbooks/dkim_sync.yml - -- import_playbook: provision.yml -- import_playbook: dns.yml -- import_playbook: storage_box.yml - -# Refresh inventory so the newly provisioned server IP is visible to subsequent plays -- name: Refresh inventory - hosts: localhost - connection: local - gather_facts: false - tasks: - - meta: refresh_inventory - -- import_playbook: bootstrap.yml -- import_playbook: site.yml diff --git a/playbooks/storage_box.yml b/playbooks/storage_box.yml deleted file mode 100644 index feeca0e..0000000 --- a/playbooks/storage_box.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -# Configure a Hetzner Storage Box for restic backups. -# Run once before the first restic deployment (enable_restic: true). -# -# Prerequisites: -# - restic_storagebox_id set in config.yml -# - hetzner_robot_user / hetzner_robot_password set in vault.yml -# -# What it does: -# 1. Generates an SSH key pair in LINDERHOF_DIR (skips if already present) -# 2. Fetches storage box details from Robot API (derives restic_user / restic_host) -# 3. Enables SSH access on the storage box -# 4. Uploads the public key (replaces any existing key with the same label) -# 5. Writes LINDERHOF_DIR/group_vars/all/storagebox.yml — loaded automatically -# by Ansible on subsequent runs, no manual config edits required - -- name: Configure Hetzner Storage Box for restic backups - hosts: localhost - connection: local - gather_facts: false - become: false - - roles: - - role: storage_box - tags: storage_box diff --git a/requirements.yml b/requirements.yml index ace86fe..1ae0048 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,5 +1,5 @@ --- collections: - name: hetzner.hcloud - version: ">=6.7.0" + version: ">=6.0.0" - name: ansible.posix diff --git a/roles/caddy/templates/index.html.j2 b/roles/caddy/templates/index.html.j2 index 5a6ee0e..5dba69b 100644 --- a/roles/caddy/templates/index.html.j2 +++ b/roles/caddy/templates/index.html.j2 @@ -81,8 +81,6 @@ diff --git a/roles/restic/defaults/main.yml b/roles/restic/defaults/main.yml index f681ebf..0f92b5e 100644 --- a/roles/restic/defaults/main.yml +++ b/roles/restic/defaults/main.yml @@ -2,11 +2,6 @@ restic_backend_type: "sftp" restic_password: "" # restic_repo: set explicitly when restic_backend_type is not 'sftp' -# SFTP backend: path to the SSH private key on the controller and on the target server -# Both are written by storage_box.yml — no need to set these manually -restic_local_key_path: "{{ lookup('env', 'LINDERHOF_DIR') }}/restic_backup" -restic_ssh_key: /root/.ssh/restic_backup - restic_backup_paths: >- {{ ['/etc/letsencrypt', '/srv/caddy'] diff --git a/roles/restic/tasks/backend_sftp.yml b/roles/restic/tasks/backend_sftp.yml index 2795f1d..9cfac45 100644 --- a/roles/restic/tasks/backend_sftp.yml +++ b/roles/restic/tasks/backend_sftp.yml @@ -1,8 +1,8 @@ --- - name: Deploy Restic SSH key ansible.builtin.copy: - src: "{{ restic_local_key_path }}" - dest: "{{ restic_ssh_key }}" + src: restic_backup # local path in your playbook repo + dest: "{{ restic_ssh_key }}" # e.g. /root/.ssh/restic_backup owner: root group: root mode: '0600' diff --git a/roles/storage_box/defaults/main.yml b/roles/storage_box/defaults/main.yml deleted file mode 100644 index e172ed3..0000000 --- a/roles/storage_box/defaults/main.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -# Storage box name used to identify (or create) the box -restic_storagebox_name: "{{ server_name }}-backup" - -# Set these only when creating a new storage box from scratch. -# Leave unset if the box already exists (identified by restic_storagebox_name above). -# restic_storagebox_type: bx11 -# restic_storagebox_location: fsn1 - -# SSH port for Hetzner Storage Boxes -restic_ssh_port: 23 - -# Path where the private key is stored on the Ansible controller (per-stack) -restic_local_key_path: "{{ lookup('env', 'LINDERHOF_DIR') }}/restic_backup" - -# Path on the target server where the private key will be deployed -restic_ssh_key: /root/.ssh/restic_backup - -# Remote path on the storage box for this server's backups -restic_remote_path: "backups/{{ server_name }}" diff --git a/roles/storage_box/tasks/main.yml b/roles/storage_box/tasks/main.yml deleted file mode 100644 index ed7e943..0000000 --- a/roles/storage_box/tasks/main.yml +++ /dev/null @@ -1,86 +0,0 @@ ---- -- name: Generate restic SSH key pair - ansible.builtin.command: - cmd: >- - ssh-keygen -t ed25519 - -f {{ restic_local_key_path }} - -N "" - -C "restic-{{ server_name }}" - creates: "{{ restic_local_key_path }}" - check_mode: false - -- name: Check if SSH public key exists - ansible.builtin.stat: - path: "{{ restic_local_key_path }}.pub" - register: ssh_pub_key_stat - -- name: Read SSH public key - ansible.builtin.slurp: - src: "{{ restic_local_key_path }}.pub" - register: ssh_pub_key_raw - when: ssh_pub_key_stat.stat.exists - -- name: Set public key fact - ansible.builtin.set_fact: - restic_ssh_pub_key: "{{ ssh_pub_key_raw.content | b64decode | trim }}" - when: ssh_pub_key_stat.stat.exists - -- name: Configure Hetzner Storage Box - hetzner.hcloud.storage_box: - name: "{{ restic_storagebox_name }}" - storage_box_type: "{{ restic_storagebox_type | default(omit) }}" - location: "{{ restic_storagebox_location | default(omit) }}" - password: "{{ restic_storagebox_password }}" - api_token: "{{ hcloud_token }}" - access_settings: - ssh_enabled: true - state: present - register: storagebox_result - when: ssh_pub_key_stat.stat.exists - -- name: Check SSH key auth on Storage Box - ansible.builtin.shell: | - echo "bye" | sftp -i {{ restic_local_key_path }} \ - -o BatchMode=yes -o StrictHostKeyChecking=no \ - -P 23 \ - {{ storagebox_result.hcloud_storage_box.username }}@{{ storagebox_result.hcloud_storage_box.server }} - register: ssh_key_check - failed_when: false - changed_when: false - when: ssh_pub_key_stat.stat.exists - -- name: Install SSH public key on Storage Box - ansible.builtin.shell: | - cat {{ restic_local_key_path }}.pub | \ - sshpass -p "{{ restic_storagebox_password }}" \ - ssh -o StrictHostKeyChecking=no -p 23 \ - {{ storagebox_result.hcloud_storage_box.username }}@{{ storagebox_result.hcloud_storage_box.server }} \ - install-ssh-key - no_log: true - when: ssh_pub_key_stat.stat.exists and ssh_key_check.rc != 0 - -- name: Write storagebox.yml to stack config directory - ansible.builtin.copy: - content: | - --- - # Storage box config — written automatically by storage_box.yml, do not edit manually - restic_user: {{ storagebox_result.hcloud_storage_box.username }} - restic_host: {{ storagebox_result.hcloud_storage_box.server }} - restic_ssh_port: {{ restic_ssh_port }} - restic_remote_path: {{ restic_remote_path }} - restic_ssh_key: {{ restic_ssh_key }} - restic_local_key_path: {{ restic_local_key_path }} - dest: "{{ lookup('env', 'ANSIBLE_INVENTORY') | dirname }}/group_vars/all/storagebox.yml" - mode: "0600" - when: ssh_pub_key_stat.stat.exists - -- name: Print connection info - ansible.builtin.debug: - msg: - - "Storage box configured successfully" - - "User: {{ storagebox_result.hcloud_storage_box.username }}" - - "Host: {{ storagebox_result.hcloud_storage_box.server }}" - - "Remote path: {{ restic_remote_path }}" - - "Local key: {{ restic_local_key_path }}" - - "Next: set enable_restic: true and run site.yml or restic.yml" - when: ssh_pub_key_stat.stat.exists diff --git a/setup.sh b/setup.sh index 11cb54e..98246b8 100755 --- a/setup.sh +++ b/setup.sh @@ -101,12 +101,7 @@ if [[ -z "$hcloud_token" ]]; then warn "no Hetzner token provided — add it to vault.yml manually if needed" fi -echo -info "configure restic backups (optional — leave blank to skip)" -prompt restic_storagebox_name "Storage box name" "${server_name}-backup" -prompt_secret restic_storagebox_password "Storage box password (leave blank to skip)" - -export admin_user server_name server_ip domain hcloud_token restic_storagebox_name restic_storagebox_password +export admin_user server_name server_ip domain hcloud_token export ssh_key_pub="${ssh_key_path}.pub" echo @@ -123,7 +118,7 @@ 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 radicale_password -export tuwunel_registration_token restic_password restic_storagebox_password +export tuwunel_registration_token restic_password export forgejo_secret_key forgejo_internal_token forgejo_jwt_secret root_password=$(openssl rand -base64 32) @@ -181,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 $stack_name $restic_storagebox_name' \ + envsubst '$admin_user $server_name $server_ip $domain $ssh_key_pub $stack_name' \ < "$TEMPLATES/config.yml.setup" > "$CONFIG" ok "config.yml created" fi @@ -247,7 +242,8 @@ echo "Next steps:" echo " 1. Review $CONFIG" echo " 2. Review $VAULT (ansible-vault edit)" echo " 3. Review $DNS_CONFIG" -echo " 4. Deploy: ansible-playbook playbooks/deploy.yml" -echo "" -echo " If mail is enabled, sync DKIM keys once the server is up:" -echo " ansible-playbook playbooks/dkim_sync.yml" +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"