From 203bd5bf6e1cf4c68bc21ff1a05565345cf22022 Mon Sep 17 00:00:00 2001 From: Matthias Johnson Date: Sat, 28 Feb 2026 21:38:13 -0700 Subject: [PATCH 1/2] adding credit for coming soon page --- roles/caddy/templates/index.html.j2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/roles/caddy/templates/index.html.j2 b/roles/caddy/templates/index.html.j2 index 5dba69b..5a6ee0e 100644 --- a/roles/caddy/templates/index.html.j2 +++ b/roles/caddy/templates/index.html.j2 @@ -81,6 +81,8 @@ From db70b4ba06a92d0ced420b55939f40adc8daa9ed Mon Sep 17 00:00:00 2001 From: Matthias Johnson Date: Sun, 1 Mar 2026 17:43:14 -0700 Subject: [PATCH 2/2] Add storage_box playbook and fix HCLOUD_TOKEN extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add storage_box role: generates SSH key pair, creates Hetzner Storage Box with known password, installs public key via install-ssh-key, writes storagebox.yml to stack config. Idempotent: skips key install if SSH key auth already works. - Add deploy.yml: one-shot playbook chaining provision → dns → storage_box → bootstrap → site for fresh deployments - Fix .envrc HCLOUD_TOKEN extraction stripping surrounding quotes from vault YAML values - Add restic_storagebox_password to vault template and setup.sh prompt - Add sshpass to README prerequisites Co-Authored-By: Claude Sonnet 4.6 --- .envrc | 13 +++- CLAUDE.md | 12 +++- README.md | 13 +++- inventory/group_vars/all/config.yml.setup | 8 ++- inventory/group_vars/all/vault.yml.setup | 1 + playbooks/deploy.yml | 27 +++++++ playbooks/storage_box.yml | 25 +++++++ requirements.yml | 2 +- roles/restic/defaults/main.yml | 5 ++ roles/restic/tasks/backend_sftp.yml | 4 +- roles/storage_box/defaults/main.yml | 20 ++++++ roles/storage_box/tasks/main.yml | 86 +++++++++++++++++++++++ setup.sh | 20 +++--- 13 files changed, 218 insertions(+), 18 deletions(-) create mode 100644 playbooks/deploy.yml create mode 100644 playbooks/storage_box.yml create mode 100644 roles/storage_box/defaults/main.yml create mode 100644 roles/storage_box/tasks/main.yml diff --git a/.envrc b/.envrc index 8bf73c2..f0cc64c 100644 --- a/.envrc +++ b/.envrc @@ -1,19 +1,30 @@ # 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 " set it in your environment, or run: echo > .stack" >&2 + echo " new here? run: ./setup.sh" >&2 + echo " existing stack? 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 b1be529..b94f76c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,17 +37,23 @@ 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. `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 +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 **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..258c741 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/) for its backup target. enable it and configure `restic_repository` in `config.yml` once you have one. +> **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`. ## what you need @@ -33,6 +33,7 @@ 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). @@ -45,7 +46,7 @@ run the interactive setup wizard: ./setup.sh ``` -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. +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. activate the stack and review the generated config: @@ -67,9 +68,17 @@ 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 df90af3..717826e 100644 --- a/inventory/group_vars/all/config.yml.setup +++ b/inventory/group_vars/all/config.yml.setup @@ -137,11 +137,17 @@ 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/island_restic_backup" +# restic_ssh_key: "/root/.ssh/restic_backup" # ============================================================ # GoAccess (web analytics) diff --git a/inventory/group_vars/all/vault.yml.setup b/inventory/group_vars/all/vault.yml.setup index e9bc1cc..34ee18d 100644 --- a/inventory/group_vars/all/vault.yml.setup +++ b/inventory/group_vars/all/vault.yml.setup @@ -51,6 +51,7 @@ 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 new file mode 100644 index 0000000..003c438 --- /dev/null +++ b/playbooks/deploy.yml @@ -0,0 +1,27 @@ +--- +# 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 new file mode 100644 index 0000000..feeca0e --- /dev/null +++ b/playbooks/storage_box.yml @@ -0,0 +1,25 @@ +--- +# 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 1ae0048..ace86fe 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,5 +1,5 @@ --- collections: - name: hetzner.hcloud - version: ">=6.0.0" + version: ">=6.7.0" - name: ansible.posix diff --git a/roles/restic/defaults/main.yml b/roles/restic/defaults/main.yml index 0f92b5e..f681ebf 100644 --- a/roles/restic/defaults/main.yml +++ b/roles/restic/defaults/main.yml @@ -2,6 +2,11 @@ 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 9cfac45..2795f1d 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_backup # local path in your playbook repo - dest: "{{ restic_ssh_key }}" # e.g. /root/.ssh/restic_backup + src: "{{ restic_local_key_path }}" + dest: "{{ restic_ssh_key }}" owner: root group: root mode: '0600' diff --git a/roles/storage_box/defaults/main.yml b/roles/storage_box/defaults/main.yml new file mode 100644 index 0000000..e172ed3 --- /dev/null +++ b/roles/storage_box/defaults/main.yml @@ -0,0 +1,20 @@ +--- +# 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 new file mode 100644 index 0000000..ed7e943 --- /dev/null +++ b/roles/storage_box/tasks/main.yml @@ -0,0 +1,86 @@ +--- +- 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 98246b8..11cb54e 100755 --- a/setup.sh +++ b/setup.sh @@ -101,7 +101,12 @@ if [[ -z "$hcloud_token" ]]; then warn "no Hetzner token provided — add it to vault.yml manually if needed" fi -export admin_user server_name server_ip domain hcloud_token +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 ssh_key_pub="${ssh_key_path}.pub" echo @@ -118,7 +123,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 +export tuwunel_registration_token restic_password restic_storagebox_password export forgejo_secret_key forgejo_internal_token forgejo_jwt_secret root_password=$(openssl rand -base64 32) @@ -176,7 +181,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 $stack_name $restic_storagebox_name' \ < "$TEMPLATES/config.yml.setup" > "$CONFIG" ok "config.yml created" fi @@ -242,8 +247,7 @@ echo "Next steps:" echo " 1. Review $CONFIG" 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 " 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"