age backups everywhere

This commit is contained in:
counterweight 2026-09-12 16:20:42 +02:00
parent 27e036eccd
commit 394f2519ff
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
11 changed files with 200 additions and 15 deletions

View file

@ -7,3 +7,10 @@ root_domain: contrapeso.xyz
# playbooks are kept deliberately — the check logic is meant to be rewired to # playbooks are kept deliberately — the check logic is meant to be rewired to
# whatever replaces it. This flag keeps them inert until then. See archive/uptime_kuma/. # whatever replaces it. This flag keeps them inert until then. See archive/uptime_kuma/.
uptime_kuma_enabled: false uptime_kuma_enabled: false
# age recipient for all backup artefacts
age_backup_recipient: "age192wwdaseqej2ggwyp884gtm05c396anp7chr0vr8m47g50fahpyqr9fsza"
# Public key small-backups-box pulls with
# Authorised on each source host for an unprivileged, dedicated user only
backup_pull_public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOfIixKMhA9z+Nvyx6ToZIniC8aEgyiInRiboaTTemgX offsite-backup-pull"

View file

@ -2,16 +2,23 @@
# Reach the VMs over Tailscale, and fall back to the LAN if the tailnet is down. # Reach the VMs over Tailscale, and fall back to the LAN if the tailnet is down.
# #
# ansible_host is a MagicDNS name. If tailscaled is not running on the control # ansible_host is a MagicDNS name. If tailscaled is not running on the control
# node that name does not resolve, so `nc %h %p` fails fast and the second nc # node that name does not resolve, the probe fails, and the LAN address recorded
# takes over on the LAN address recorded as lan_ip in inventory.ini. # as lan_ip in inventory.ini takes over.
# #
# This is safe against the LAN addresses drifting again (which is how # Why the probe-then-connect shape rather than a plain `nc -w5 %h %p`:
# fulcrum/mempool came to be transposed): known_hosts is keyed to the MagicDNS # netcat-openbsd's -w is an IDLE timeout as well as a connect timeout, so a
# NAME, so if lan_ip ever points at a different machine the host key will not # single `nc -w5` silently tears down the SSH session after five quiet seconds.
# match and ssh aborts. Verified 2026-09-12 by pointing fulcrum-box at # That produced intermittent "Data could not be sent to remote host" failures on
# mempool-box's address: "Host key verification failed." # exactly the long, quiet operations (apt) where a dropped connection costs most.
# `nc -z` probes, then `exec nc` carries the session with no timeout at all.
#
# Safe against the LAN addresses drifting again (which is how fulcrum/mempool
# came to be transposed): known_hosts is keyed to the MagicDNS NAME, so if
# lan_ip ever points at a different machine the host key will not match and ssh
# aborts. Verified by pointing fulcrum-box at mempool-box's address:
# "Host key verification failed."
# #
# lan_ip is a convenience, not an identity. If it goes stale the fallback stops # lan_ip is a convenience, not an identity. If it goes stale the fallback stops
# working; it will never connect you to the wrong box. # working; it will never connect you to the wrong box.
ansible_ssh_common_args: >- ansible_ssh_common_args: >-
-o ProxyCommand="sh -c 'nc -w2 %h %p 2>/dev/null || nc -w4 {{ lan_ip }} %p'" -o ProxyCommand="sh -c 'nc -z -w5 %h %p 2>/dev/null && exec nc %h %p || exec nc {{ lan_ip }} %p'"

View file

@ -14,3 +14,15 @@
- name: headscale - name: headscale
source: "backup-pull@headscale.contrapeso.xyz:/opt/backups/headscale/" source: "backup-pull@headscale.contrapeso.xyz:/opt/backups/headscale/"
retention_days: 90 retention_days: 90
- name: memos
source: "backup-pull@memos-box:/opt/backups/memos/"
retention_days: 90
- name: vaultwarden
source: "backup-pull@prd-vipy:/opt/backups/vaultwarden/"
retention_days: 90
- name: lnbits
source: "backup-pull@prd-vipy:/opt/backups/lnbits/"
retention_days: 90
- name: forgejo
source: "backup-pull@prd-vipy:/opt/backups/forgejo/"
retention_days: 14

View file

@ -39,6 +39,56 @@ The role pipes it into `age`, so plaintext never touches the disk. Use `-C /`
with relative paths in `tar` rather than absolute ones: it avoids tar's "removing with relative paths in `tar` rather than absolute ones: it avoids tar's "removing
leading /" and makes the restore target explicit. leading /" and makes the restore target explicit.
## Services that are not systemd
`backup_source_stop_service` runs `systemctl stop/start`. For anything else,
give the pair explicitly — vaultwarden is a docker compose stack, so
`systemctl stop vaultwarden` silently does nothing:
```yaml
backup_source_stop_command: "docker compose -f /opt/vaultwarden/docker-compose.yml stop"
backup_source_start_command: "docker compose -f /opt/vaultwarden/docker-compose.yml start"
```
The same EXIT trap wraps both forms. The assert refuses a stop command without a
matching start command, because that combination fails in the one way you would
not notice: the service stops and never comes back.
## More than one thing to back up
`tar` takes several paths, so multiple files or directories are normally **one**
artefact — headscale captures `/var/lib/headscale` and `/etc/headscale` together,
lnbits captures its data directory and its `.env`.
Prefer one artefact. A backup should be a consistent snapshot, and two artefacts
written by two runs can drift — you can end up restoring an `.env` that does not
match the database it configures. Pulling a single file back out needs no
unpacking:
```bash
age -d -i <identity> <artefact> | tar -xzO opt/lnbits/lnbits/.env
```
If you genuinely need separate artefacts, call the role twice with different
`backup_source_name`s rather than extending it — but only one call may set
`backup_source_stop_service`, or the service is stopped twice per night.
The case this shape cannot express is a **database dump plus a file tree**
(`pg_dump` and a media directory, say): you cannot merge those into one stream
without staging plaintext on disk, which is exactly what this design avoids.
None of the current services need it — all are file trees, all stopped for the
dump. A future one that does should use two role calls.
## Everything here is sqlite, so everything stops
All five services are sqlite-backed, several in WAL mode (`-wal`/`-shm` files
present). A live copy of a WAL-mode database can be torn or stale, so each is
stopped for the duration. Measured downtime: under a second for headscale and
memos, ~6 s vaultwarden, ~11 s lnbits, and **2m36s for forgejo** — 2.7 G of repos
and database. That last one is the real cost of a consistent snapshot; if it
becomes unacceptable the answer is `sqlite3 .backup` plus an online repo copy,
not skipping the stop.
## The trap is the reason this role exists ## The trap is the reason this role exists
When `backup_source_stop_service` is set, the script stops the unit and installs When `backup_source_stop_service` is set, the script stops the unit and installs

View file

@ -17,8 +17,12 @@ backup_source_recipient: "{{ age_backup_recipient }}"
backup_source_pull_user: backup-pull backup_source_pull_user: backup-pull
backup_source_pull_key: "{{ backup_pull_public_key }}" backup_source_pull_key: "{{ backup_pull_public_key }}"
# Safety # Safety. Give either a systemd unit, or an explicit pair of commands for
backup_source_stop_service: "" # local unit stopped for the dump, restored by a trap # services that are not systemd-managed (vaultwarden is a docker compose stack).
# Whichever is used, a trap guarantees the restart.
backup_source_stop_service: "" # systemd unit stopped for the dump
backup_source_stop_command: "" # overrides stop_service when set
backup_source_start_command: "" # required alongside stop_command
# Retention here is LOCAL and short; small-backups-box keeps the long tail. # Retention here is LOCAL and short; small-backups-box keeps the long tail.
backup_source_retention_days: 7 backup_source_retention_days: 7

View file

@ -7,9 +7,11 @@
- backup_source_dump_command | length > 0 - backup_source_dump_command | length > 0
- backup_source_recipient | length > 0 - backup_source_recipient | length > 0
- backup_source_recipient is match('^age1[0-9a-z]{58}$') - backup_source_recipient is match('^age1[0-9a-z]{58}$')
- not (backup_source_stop_command | length > 0 and backup_source_start_command | length == 0)
fail_msg: >- fail_msg: >-
backup_source: '{{ backup_source_name | default("<unnamed>") }}' needs a name, backup_source: '{{ backup_source_name | default("<unnamed>") }}' needs a name,
description, dump command and a valid age recipient (age1... 62 chars). description, dump command and a valid age recipient (age1... 62 chars).
backup_source_stop_command must be paired with backup_source_start_command.
quiet: true quiet: true
# Declared here rather than assumed. Stage 1 installed it by hand; this is what # Declared here rather than assumed. Stage 1 installed it by hand; this is what

View file

@ -11,8 +11,10 @@ RETENTION_DAYS={{ backup_source_retention_days }}
RECIPIENT="{{ backup_source_recipient }}" RECIPIENT="{{ backup_source_recipient }}"
SUFFIX="{{ backup_source_artifact_suffix }}" SUFFIX="{{ backup_source_artifact_suffix }}"
NAME="{{ backup_source_name }}" NAME="{{ backup_source_name }}"
{% if backup_source_stop_service %} {% if backup_source_stop_service or backup_source_stop_command %}
SERVICE="{{ backup_source_stop_service }}" STOP_CMD={{ (backup_source_stop_command or ('systemctl stop ' ~ backup_source_stop_service)) | quote }}
START_CMD={{ (backup_source_start_command or ('systemctl start ' ~ backup_source_stop_service)) | quote }}
SERVICE="{{ backup_source_stop_service or backup_source_description }}" # label for the log only
{% endif %} {% endif %}
TIMESTAMP=$(date +%Y%m%d_%H%M%S) TIMESTAMP=$(date +%Y%m%d_%H%M%S)
@ -39,13 +41,13 @@ chmod 700 "$BACKUP_DIR"
# here or they accumulate forever. # here or they accumulate forever.
rm -f "${BACKUP_DIR}/${NAME}_"*.partial rm -f "${BACKUP_DIR}/${NAME}_"*.partial
{% if backup_source_stop_service %} {% if backup_source_stop_service or backup_source_stop_command %}
# --- Stop the service, and guarantee it comes back --- # --- Stop the service, and guarantee it comes back ---
# The trap is the point: without it a failed dump leaves the service down until # The trap is the point: without it a failed dump leaves the service down until
# the next timer fires. Every hand-written script this replaced had that bug. # the next timer fires. Every hand-written script this replaced had that bug.
log "Stopping ${SERVICE}..." log "Stopping ${SERVICE}..."
systemctl stop "$SERVICE" eval "$STOP_CMD"
trap 'log "Restarting ${SERVICE}..."; systemctl start "${SERVICE}" || true' EXIT trap 'log "Restarting ${SERVICE}..."; eval "$START_CMD" || true' EXIT
{% endif %} {% endif %}
# --- Dump straight into age; plaintext never touches the disk --- # --- Dump straight into age; plaintext never touches the disk ---

View file

@ -0,0 +1,25 @@
---
# Forgejo backup: dumps locally on vipy, encrypted with age.
#
# The biggest artefact in the estate (~2.7 G) and the reason retention here is
# short: 7 days locally would be 19 G of vipy's 36 G free. The box keeps 14.
# Forgejo is sqlite3 (DB_TYPE in app.ini), so it is stopped for the dump — the
# old job did the same.
- name: Configure the Forgejo backup on the edge host
hosts: edge
become: yes
vars_files:
- ../../group_vars/all/main.yml
- ./forgejo_vars.yml
tasks:
- name: Ensure Forgejo dumps itself, encrypted, on a timer
ansible.builtin.include_role:
name: backup_source
vars:
backup_source_name: forgejo
backup_source_description: "Forgejo"
backup_source_dump_command: "tar -czf - -C / var/lib/forgejo etc/forgejo"
backup_source_stop_service: forgejo
backup_source_retention_days: 2
backup_source_on_calendar: "*-*-* 02:30:00"

View file

@ -0,0 +1,25 @@
---
# LNBits backup: dumps locally on vipy, encrypted with age.
# The old job produced TWO gpg artefacts (data, then .env separately). They are
# folded into one tar here so the wallet database and the .env that configures
# it are always the same point in time; two artefacts written by two runs can
# drift. Pulling one file back out needs no unpacking:
# age -d -i <identity> <artefact> | tar -xzO opt/lnbits/lnbits/.env
- name: Configure the LNBits backup on the edge host
hosts: edge
become: yes
vars_files:
- ../../group_vars/all/main.yml
- ./lnbits_vars.yml
tasks:
- name: Ensure LNBits dumps itself, encrypted, on a timer
ansible.builtin.include_role:
name: backup_source
vars:
backup_source_name: lnbits
backup_source_description: "LNBits"
backup_source_dump_command: "tar -czf - -C / opt/lnbits/data opt/lnbits/lnbits/.env"
backup_source_stop_service: lnbits
backup_source_retention_days: 7
backup_source_on_calendar: "*-*-* 02:20:00"

View file

@ -0,0 +1,26 @@
---
# Memos backup: dumps locally on memos-box, encrypted with age.
# Replaces the lapy pull, which had been writing EMPTY directories since
# 2025-12-27 — its script hardcoded 192.168.1.130, which DHCP later reassigned
# to a different machine that has no rsync.
- name: Configure the Memos backup on its own host
hosts: memos
become: yes
vars_files:
- ../../group_vars/all/main.yml
- ./memos_vars.yml
tasks:
- name: Ensure Memos dumps itself, encrypted, on a timer
ansible.builtin.include_role:
name: backup_source
vars:
backup_source_name: memos
backup_source_description: "Memos"
backup_source_dump_command: "tar -czf - -C / var/lib/memos"
# sqlite in WAL mode: stopping checkpoints the WAL, so the artefact is a
# consistent database rather than a torn mid-write copy. The old rsync
# job did not stop it.
backup_source_stop_service: memos
backup_source_retention_days: 7
backup_source_on_calendar: "*-*-* 02:00:00"

View file

@ -0,0 +1,25 @@
---
# Vaultwarden backup: dumps locally on vipy, encrypted with age.
# Previously rsynced to lapy in the CLEAR; the artefact now never exists
# unencrypted, on disk or on the wire.
- name: Configure the Vaultwarden backup on the edge host
hosts: edge
become: yes
vars_files:
- ../../group_vars/all/main.yml
- ./vaultwarden_vars.yml
tasks:
- name: Ensure Vaultwarden dumps itself, encrypted, on a timer
ansible.builtin.include_role:
name: backup_source
vars:
backup_source_name: vaultwarden
backup_source_description: "Vaultwarden"
backup_source_dump_command: "tar -czf - -C / opt/vaultwarden/data"
# Not systemd — a docker compose stack — so stop/start explicitly.
# sqlite in WAL mode, hence stopping at all.
backup_source_stop_command: "docker compose -f /opt/vaultwarden/docker-compose.yml stop"
backup_source_start_command: "docker compose -f /opt/vaultwarden/docker-compose.yml start"
backup_source_retention_days: 7
backup_source_on_calendar: "*-*-* 02:10:00"