ci / lint-test-leaks (pull_request) Canceled after 0s
The previous redaction (${PG_URL%%@*@}) never matched when the URL has a
single @, so the full DSN (user:password@host) went to the log/journal.
Now: scheme + ***:***@ + hostport/db only.
Found during the point-3 E2E test; the leaked value in that transcript
was the already-public test DB credential (itest probe DB).
122 lines
5.6 KiB
Bash
Executable File
122 lines
5.6 KiB
Bash
Executable File
#!/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://#")"
|
|
_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))"
|