backup: encrypted daily backups — VPS generates, Mac pulls (Tanda B, point 1)
ci / lint-test-leaks (pull_request) Canceled after 0s
ci / lint-test-leaks (pull_request) Canceled after 0s
deploy/backup/make-backup.sh (installed as /usr/local/bin/cayce-make-backup): - pg_dump custom-format of the cayce-ops DB (URL from env or .env, SQLAlchemy driver qualifier stripped, credentials never logged) - gitea dump (repos + sqlite + attachments), live; consistency notes in RESTORE.md - config tar: Caddyfile(+d), gitea app.ini + internal_token, cayce-* units, .env - EACH artifact encrypted with age BEFORE leaving the tmpdir; recipient read from /etc/cayce-backup/age-recipient.pub (public key only). The private age key never touches this server. - plaintext exists ONLY in a mktemp dir removed by an EXIT trap; a partial day directory is removed on failure; any step failure => exit != 0 => journald entry - manifest.sha256 over the encrypted artifacts (pull side verifies integrity WITHOUT the age private key) - destination /var/backups/cayce/YYYY-MM-DD/ (750 root:backup-pull, 640) - retention: 7 daily dirs, pruned by mtime systemd: cayce-backup.service (oneshot, hardened: ProtectSystem=strict, NoNewPrivileges, ProtectHome=read-only) + cayce-backup.timer (daily 03:15 UTC, Persistent). Timer ENABLED on the VPS per operator instruction; runs fail loudly (exit 1, clear journald message) until the age recipient is provided — that is the designed not-configured state.
This commit is contained in:
Executable
+119
@@ -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 <plaintext-path> <name.age>
|
||||
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))"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user