diff --git a/deploy/backup/make-backup.sh b/deploy/backup/make-backup.sh new file mode 100755 index 0000000..83ed87e --- /dev/null +++ b/deploy/backup/make-backup.sh @@ -0,0 +1,119 @@ +#!/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://#")" +log "dumping database from ${PG_URL%%@*@}***@..." +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))" diff --git a/deploy/systemd/cayce-backup.service b/deploy/systemd/cayce-backup.service new file mode 100644 index 0000000..fbc2abd --- /dev/null +++ b/deploy/systemd/cayce-backup.service @@ -0,0 +1,14 @@ +[Unit] +Description=Cayce — encrypted daily backup (CayceOps DB + Gitea + config) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/cayce-make-backup +# hardening +NoNewPrivileges=true +ProtectHome=read-only +ReadWritePaths=/var/backups/cayce /tmp +ProtectSystem=strict +PrivateTmp=false diff --git a/deploy/systemd/cayce-backup.timer b/deploy/systemd/cayce-backup.timer new file mode 100644 index 0000000..22bf2a6 --- /dev/null +++ b/deploy/systemd/cayce-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Cayce — daily backup schedule (03:15 UTC) + +[Timer] +OnCalendar=*-*-* 03:15:00 UTC +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target