#!/usr/bin/env bash # cayce-backup — encrypted daily backup of CayceOps + Gitea + config. # # Design contract (Tanda B): # - EVERY artifact is encrypted with age BEFORE it leaves the tmpdir. # No plaintext is ever written outside the tmpdir, which is removed # by a trap even on failure. # - The age recipient lives in /etc/cayce-backup/age-recipient.pub # (public key only). The PRIVATE key never touches this server: # decryption happens on the operator's machine after a pull. # - Any step failure => non-zero exit => journald entry (run via systemd). # - Retention: 7 daily directories under /var/backups/cayce/. # # The restore procedure lives in deploy/backup/RESTORE.md. set -euo pipefail DATE="$(date -u +%F)" BACKUP_ROOT="/var/backups/cayce" DEST="$BACKUP_ROOT/$DATE" RECIPIENT_FILE="/etc/cayce-backup/age-recipient.pub" CAYCE_DIR="/home/ubuntu/cayce-ops" ENV_FILE="$CAYCE_DIR/.env" RETENTION_DAYS=7 log() { echo "[cayce-backup] $(date -u +%FT%TZ) $*"; } die() { echo "[cayce-backup] FATAL: $*" >&2; exit 1; } # --- preconditions ------------------------------------------------------------- [[ $EUID -eq 0 ]] || die "must run as root (systemd)" command -v age >/dev/null || die "age not installed" [[ -r "$RECIPIENT_FILE" ]] || die "age recipient not configured: create $RECIPIENT_FILE (public key only)" RECIPIENT="$(tr -d "[:space:]" < "$RECIPIENT_FILE")" [[ "$RECIPIENT" == age1* ]] || die "$RECIPIENT_FILE does not look like an age1... public key" # --- workspace: everything plaintext lives here and dies here ------------------ TMP="$(mktemp -d /tmp/cayce-backup.XXXXXX)" # 711: root-only listing, but the git user must be able to traverse into # its own 700 subdir for gitea dump; plaintext root files stay unreadable chmod 711 "$TMP" cleanup() { rm -rf "$TMP" if [[ "${FAILED:-0}" == "1" ]]; then rm -rf "$DEST" # never leave a partial day directory fi } trap cleanup EXIT STAGE="$TMP/stage" # encrypted artifacts land here before install install -d -m 700 "$STAGE" encrypt() { # encrypt age -r "$RECIPIENT" -o "$STAGE/$2" "$1" } # --- 1. Postgres dump (cayce-ops DB) -------------------------------------------- DB_URL="${CAYCE_OPS_DATABASE_URL:-}" if [[ -z "$DB_URL" && -r "$ENV_FILE" ]]; then # shellcheck disable=SC1090 DB_URL="$(grep -E "^CAYCE_OPS_DATABASE_URL=" "$ENV_FILE" | head -1 | cut -d= -f2-)" fi [[ -n "$DB_URL" ]] || die "no CAYCE_OPS_DATABASE_URL in env or $ENV_FILE" PG_URL="$(printf "%s" "$DB_URL" | sed -E "s#postgresql\+[a-z]+://#postgresql://#")" _scheme="${PG_URL%%://*}" _hostpart="${PG_URL#**@}" log "dumping database from ${_scheme}://***:***@${_hostpart}" pg_dump "$PG_URL" --format=custom --file "$TMP/cayce-ops-db.dump" encrypt "$TMP/cayce-ops-db.dump" "cayce-ops-db.dump.age" log "database dump: $(du -h "$TMP/cayce-ops-db.dump" | cut -f1) plaintext, encrypted ok" # --- 2. Gitea dump (repos + sqlite + attachments) ------------------------------- GITEA_BIN="$(command -v gitea || echo /usr/local/bin/gitea)" [[ -x "$GITEA_BIN" ]] || die "gitea binary not found" install -d -o git -g git -m 700 "$TMP/gitea-work" log "running gitea dump (live; see RESTORE.md for consistency notes)..." sudo -u git "$GITEA_BIN" dump -c /etc/gitea/app.ini \ --file "$TMP/gitea-work/gitea-dump.zip" --tempdir "$TMP/gitea-work" --quiet encrypt "$TMP/gitea-work/gitea-dump.zip" "gitea-dump.zip.age" log "gitea dump: $(du -h "$TMP/gitea-work/gitea-dump.zip" | cut -f1) plaintext, encrypted ok" # --- 3. Config tar (Caddy, Gitea app.ini + internal_token, units, .env) ---------- CONF="$TMP/config" install -d -m 700 "$CONF" mkdir -p "$CONF/caddy" "$CONF/gitea" "$CONF/systemd" "$CONF/cayce-ops" cp -a /etc/caddy/Caddyfile "$CONF/caddy/" 2>/dev/null || true cp -a /etc/caddy/Caddyfile.d "$CONF/caddy/" 2>/dev/null || true cp -a /etc/gitea/app.ini "$CONF/gitea/" 2>/dev/null || true cp -a /etc/gitea/internal_token "$CONF/gitea/" 2>/dev/null || true find /etc/systemd/system -maxdepth 1 -name "cayce-*" \ -exec cp -a {} "$CONF/systemd/" \; 2>/dev/null || true [[ -r "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$CONF/cayce-ops/dot-env" \ || log "WARNING: $ENV_FILE not present — config backup continues without it" # at least Caddy or Gitea config must exist, else the tar is useless [[ -e "$CONF/caddy/Caddyfile" || -e "$CONF/gitea/app.ini" ]] \ || die "no config sources found (Caddyfile/app.ini missing)" tar -C "$CONF" -czf "$TMP/config.tar.gz" . encrypt "$TMP/config.tar.gz" "config.tar.gz.age" log "config tar: $(du -h "$TMP/config.tar.gz" | cut -f1) plaintext, encrypted ok" # --- 4. install + manifest ------------------------------------------------------- install -d -m 750 -o root -g backup-pull "$BACKUP_ROOT" 2>/dev/null \ || install -d -m 700 "$BACKUP_ROOT" # backup-pull group may not exist yet install -d -m 750 -o root -g backup-pull "$DEST" 2>/dev/null \ || install -d -m 700 "$DEST" install -m 640 -o root -g backup-pull "$STAGE"/*.age "$DEST/" 2>/dev/null \ || install -m 600 "$STAGE"/*.age "$DEST/" ( cd "$DEST" && sha256sum ./*.age > "$TMP/manifest.sha256" ) install -m 640 -o root -g backup-pull "$TMP/manifest.sha256" "$DEST/manifest.sha256" 2>/dev/null \ || install -m 600 "$TMP/manifest.sha256" "$DEST/manifest.sha256" log "manifest written: $(wc -l < "$DEST/manifest.sha256") artifacts" # --- 5. retention ----------------------------------------------------------------- PRUNED=0 while IFS= read -r old; do rm -rf "$old" PRUNED=$((PRUNED + 1)) log "retention: removed $old" done < <(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "2*-*-*" -mtime +"$RETENTION_DAYS") log "retention: kept last $RETENTION_DAYS days (pruned $PRUNED)" log "BACKUP OK → $DEST ($(du -sh "$DEST" | cut -f1))"