Compare commits

...

2 commits

Author SHA1 Message Date
db70b4ba06 Add storage_box playbook and fix HCLOUD_TOKEN extraction
- 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 <noreply@anthropic.com>
2026-03-01 17:43:14 -07:00
203bd5bf6e adding credit for coming soon page 2026-02-28 21:38:13 -07:00
14 changed files with 220 additions and 18 deletions

13
.envrc
View file

@ -1,19 +1,30 @@
# Stack selection — set LINDERHOF_STACK before sourcing, or create a .stack file # Stack selection — set LINDERHOF_STACK before sourcing, or create a .stack file
watch_file .stack
if [[ -z "${LINDERHOF_STACK:-}" ]]; then if [[ -z "${LINDERHOF_STACK:-}" ]]; then
if [[ -f "$PWD/.stack" ]]; then if [[ -f "$PWD/.stack" ]]; then
LINDERHOF_STACK="$(cat "$PWD/.stack")" LINDERHOF_STACK="$(cat "$PWD/.stack")"
echo "linderhof: LINDERHOF_STACK is set to '$LINDERHOF_STACK'"
fi fi
fi fi
if [[ -z "${LINDERHOF_STACK:-}" ]]; then if [[ -z "${LINDERHOF_STACK:-}" ]]; then
echo "linderhof: LINDERHOF_STACK is not set" >&2 echo "linderhof: LINDERHOF_STACK is not set" >&2
echo " set it in your environment, or run: echo <stack-name> > .stack" >&2 echo " new here? run: ./setup.sh" >&2
echo " existing stack? run: echo <stack-name> > .stack" >&2
else else
export LINDERHOF_STACK export LINDERHOF_STACK
export LINDERHOF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof/$LINDERHOF_STACK" export LINDERHOF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/linderhof/$LINDERHOF_STACK"
export ANSIBLE_INVENTORY="$LINDERHOF_DIR/hosts.yml" export ANSIBLE_INVENTORY="$LINDERHOF_DIR/hosts.yml"
export ANSIBLE_VAULT_PASSWORD_FILE="$LINDERHOF_DIR/vault-pass" 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 # Per-stack overrides: DOCKER_HOST, etc. — written by setup.sh
if [[ -f "$LINDERHOF_DIR/stack.env" ]]; then if [[ -f "$LINDERHOF_DIR/stack.env" ]]; then
# shellcheck source=/dev/null # shellcheck source=/dev/null

View file

@ -37,17 +37,23 @@ Note: Inventory and vault password are set via `ANSIBLE_INVENTORY` and `ANSIBLE_
**Deployment Pattern:** Each service is deployed to `/srv/<service>/` on the target host with a `compose.yml` and environment files. **Deployment Pattern:** Each service is deployed to `/srv/<service>/` on the target host with a `compose.yml` and environment files.
**Standalone Playbooks** (not in `site.yml`): **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) - `provision.yml` - Provision a cloud VM (Hetzner)
- `dns.yml` - Manage DNS zones/records via Hetzner DNS API - `dns.yml` - Manage DNS zones/records via Hetzner DNS API
- `bootstrap.yml` - First-time server setup (run once as root before site.yml) - `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) - `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): **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 1. `provision.yml` - create server, auto-writes IP to hosts.yml and config.yml
2. `dns.yml` - create DNS records 2. `dns.yml` - create DNS records
3. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root) 3. `storage_box.yml` - generate SSH key, configure storage box, writes storagebox.yml to stack config
4. `site.yml` - deploy all services 4. `bootstrap.yml` - users, SSH hardening, packages, Docker (connects as root)
5. `dkim_sync.yml` - generate DKIM keys, write to stack config, publish to DNS 5. `site.yml` - deploy all services
**Playbook Execution Order** (via `site.yml`): **Playbook Execution Order** (via `site.yml`):
1. networks.yml - Pre-create all Docker networks (must run before any service) 1. networks.yml - Pre-create all Docker networks (must run before any service)

View file

@ -22,7 +22,7 @@ set `enable_<service>: false` in `config.yml` to disable any service — DNS rec
| image update alerts | `enable_diun` | on | [diun](https://github.com/crazy-max/diun) | | image update alerts | `enable_diun` | on | [diun](https://github.com/crazy-max/diun) |
| intrusion prevention | `enable_fail2ban` | on | [fail2ban](https://github.com/fail2ban/fail2ban) | | 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 ## what you need
@ -33,6 +33,7 @@ set `enable_<service>: false` in `config.yml` to disable any service — DNS rec
- `ansible` and `ansible-galaxy` - `ansible` and `ansible-galaxy`
- `direnv` (optional but recommended — loads `.envrc` automatically) - `direnv` (optional but recommended — loads `.envrc` automatically)
- `ssh-keygen`, `openssl`, `envsubst` (standard on most systems) - `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). 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 ./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/<stack>/` 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/<stack>/` and won't overwrite existing files.
activate the stack and review the generated config: 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: 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 ```bash
ansible-playbook playbooks/provision.yml # create server, writes IP to stack config 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/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 --tags bootstrap # users, SSH hardening, packages, Docker
ansible-playbook playbooks/site.yml # deploy all services ansible-playbook playbooks/site.yml # deploy all services
ansible-playbook playbooks/dkim_sync.yml # generate DKIM keys and publish to DNS ansible-playbook playbooks/dkim_sync.yml # generate DKIM keys and publish to DNS

View file

@ -137,11 +137,17 @@ grafana_root_url: "https://{{ grafana_domain }}"
# Restic (encrypted backups) # Restic (encrypted backups)
# ============================================================ # ============================================================
restic_backend_type: "sftp" 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_host: "uXXXXXX.your-storagebox.de"
# restic_user: uXXXXXX # restic_user: uXXXXXX
# restic_ssh_port: 23 # restic_ssh_port: 23
# restic_remote_path: "backups/$server_name" # restic_remote_path: "backups/$server_name"
# restic_ssh_key: "/root/.ssh/island_restic_backup" # restic_ssh_key: "/root/.ssh/restic_backup"
# ============================================================ # ============================================================
# GoAccess (web analytics) # GoAccess (web analytics)

View file

@ -51,6 +51,7 @@ diun_email_password: "$notifications_mail_password"
# restic # restic
# password generated with: openssl rand -base64 32 # password generated with: openssl rand -base64 32
restic_password: "$restic_password" restic_password: "$restic_password"
restic_storagebox_password: "$restic_storagebox_password"
# fail2ban (optional — IPs/CIDRs to whitelist) # fail2ban (optional — IPs/CIDRs to whitelist)
# fail2ban_ignoreip: "your-home-ip/32" # fail2ban_ignoreip: "your-home-ip/32"

27
playbooks/deploy.yml Normal file
View file

@ -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

25
playbooks/storage_box.yml Normal file
View file

@ -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

View file

@ -1,5 +1,5 @@
--- ---
collections: collections:
- name: hetzner.hcloud - name: hetzner.hcloud
version: ">=6.0.0" version: ">=6.7.0"
- name: ansible.posix - name: ansible.posix

View file

@ -81,6 +81,8 @@
</main> </main>
<footer> <footer>
powered by <a href="https://codeberg.org/opennomad/linderhof" target="_blank" rel="noopener">linderhof</a> powered by <a href="https://codeberg.org/opennomad/linderhof" target="_blank" rel="noopener">linderhof</a>
&nbsp;·&nbsp;
design by <a href="https://github.com/YC/coming-soon" target="_blank" rel="noopener">steven tang</a>
</footer> </footer>
</body> </body>
</html> </html>

View file

@ -2,6 +2,11 @@ restic_backend_type: "sftp"
restic_password: "" restic_password: ""
# restic_repo: set explicitly when restic_backend_type is not 'sftp' # 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: >- restic_backup_paths: >-
{{ {{
['/etc/letsencrypt', '/srv/caddy'] ['/etc/letsencrypt', '/srv/caddy']

View file

@ -1,8 +1,8 @@
--- ---
- name: Deploy Restic SSH key - name: Deploy Restic SSH key
ansible.builtin.copy: ansible.builtin.copy:
src: restic_backup # local path in your playbook repo src: "{{ restic_local_key_path }}"
dest: "{{ restic_ssh_key }}" # e.g. /root/.ssh/restic_backup dest: "{{ restic_ssh_key }}"
owner: root owner: root
group: root group: root
mode: '0600' mode: '0600'

View file

@ -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 }}"

View file

@ -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

View file

@ -101,7 +101,12 @@ if [[ -z "$hcloud_token" ]]; then
warn "no Hetzner token provided — add it to vault.yml manually if needed" warn "no Hetzner token provided — add it to vault.yml manually if needed"
fi 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" export ssh_key_pub="${ssh_key_path}.pub"
echo echo
@ -118,7 +123,7 @@ info "generating secrets..."
export root_password admin_password export root_password admin_password
export admin_mail_password notifications_mail_password git_mail_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 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 export forgejo_secret_key forgejo_internal_token forgejo_jwt_secret
root_password=$(openssl rand -base64 32) root_password=$(openssl rand -base64 32)
@ -176,7 +181,7 @@ if [[ -f "$CONFIG" ]]; then
warn "config.yml already exists — skipping (not overwriting)" warn "config.yml already exists — skipping (not overwriting)"
else else
info "writing config.yml..." 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" < "$TEMPLATES/config.yml.setup" > "$CONFIG"
ok "config.yml created" ok "config.yml created"
fi fi
@ -242,8 +247,7 @@ echo "Next steps:"
echo " 1. Review $CONFIG" echo " 1. Review $CONFIG"
echo " 2. Review $VAULT (ansible-vault edit)" echo " 2. Review $VAULT (ansible-vault edit)"
echo " 3. Review $DNS_CONFIG" echo " 3. Review $DNS_CONFIG"
echo " 4. Provision a server: ansible-playbook playbooks/provision.yml" echo " 4. Deploy: ansible-playbook playbooks/deploy.yml"
echo " 5. Update DNS: ansible-playbook playbooks/dns.yml" echo ""
echo " 6. Bootstrap server: ansible-playbook playbooks/site.yml --tags bootstrap" echo " If mail is enabled, sync DKIM keys once the server is up:"
echo " 7. Deploy: ansible-playbook playbooks/site.yml" echo " ansible-playbook playbooks/dkim_sync.yml"
echo " 8. Sync DKIM keys to DNS: ansible-playbook playbooks/dkim_sync.yml"