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>
This commit is contained in:
parent
203bd5bf6e
commit
db70b4ba06
13 changed files with 218 additions and 18 deletions
13
.envrc
13
.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-name> > .stack" >&2
|
||||
echo " new here? run: ./setup.sh" >&2
|
||||
echo " existing stack? run: echo <stack-name> > .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
|
||||
|
|
|
|||
12
CLAUDE.md
12
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/<service>/` 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)
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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) |
|
||||
| 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_<service>: 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/<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:
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
27
playbooks/deploy.yml
Normal file
27
playbooks/deploy.yml
Normal 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
25
playbooks/storage_box.yml
Normal 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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
collections:
|
||||
- name: hetzner.hcloud
|
||||
version: ">=6.0.0"
|
||||
version: ">=6.7.0"
|
||||
- name: ansible.posix
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
20
roles/storage_box/defaults/main.yml
Normal file
20
roles/storage_box/defaults/main.yml
Normal 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 }}"
|
||||
86
roles/storage_box/tasks/main.yml
Normal file
86
roles/storage_box/tasks/main.yml
Normal 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
|
||||
20
setup.sh
20
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue