#!/usr/bin/env bash # # ZBrain Backup # ============== # Делает дамп всех Postgres БД (brainhub + все gbrain_*) и конфигов. # Шифрует архивы через age (нужен age и публичный ключ). # # Запуск из cron, например: # 0 3 * * * root /opt/zbrain/scripts/backup.sh set -euo pipefail # ============================================================ # Конфигурация # ============================================================ BACKUP_DIR="${BACKUP_DIR:-/var/backups/zbrain}" RETENTION_DAYS="${RETENTION_DAYS:-14}" AGE_RECIPIENT_FILE="${AGE_RECIPIENT_FILE:-/etc/zbrain/backup.age.pub}" LOG_FILE="${LOG_FILE:-/var/log/zbrain/backup.log}" ZBRAIN_CONFIG_DIR="/etc/zbrain" ZBRAIN_DATA_DIR="/var/lib/zbrain" DATE=$(date +%Y%m%d-%H%M%S) BACKUP_PATH="${BACKUP_DIR}/${DATE}" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" } err() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" | tee -a "$LOG_FILE" >&2 } # ============================================================ # Проверки # ============================================================ [[ $EUID -eq 0 ]] || { err "Требуется root"; exit 1; } if ! command -v age &>/dev/null; then err "age не установлен. apt install age" exit 1 fi if [[ ! -f "$AGE_RECIPIENT_FILE" ]]; then err "Файл получателя age не найден: $AGE_RECIPIENT_FILE" err "Создай ключ: age-keygen -o /etc/zbrain/backup.age" err "И публичную часть в $AGE_RECIPIENT_FILE" exit 1 fi mkdir -p "$BACKUP_PATH" "$(dirname "$LOG_FILE")" chmod 700 "$BACKUP_DIR" log "=== ZBrain backup: $DATE ===" # ============================================================ # Postgres dump (каждая БД отдельно) # ============================================================ log "Дамп Postgres БД..." # Список всех ZBrain БД (brainhub + gbrain_*) DATABASES=$(sudo -u postgres psql -tAc " SELECT datname FROM pg_database WHERE datname = 'brainhub' OR datname LIKE 'gbrain_%' ORDER BY datname ") for db in $DATABASES; do log " ${db}" dump_file="${BACKUP_PATH}/${db}.sql" sudo -u postgres pg_dump --format=custom --compress=9 --file="${dump_file}.dump" "$db" # Шифруем age -R "$AGE_RECIPIENT_FILE" -o "${dump_file}.dump.age" "${dump_file}.dump" rm "${dump_file}.dump" log " ✓ ${db}.sql.dump.age ($(du -h "${dump_file}.dump.age" | cut -f1))" done # pg_dumpall для глобальных объектов (roles, tablespaces) log "Дамп глобальных Postgres объектов..." sudo -u postgres pg_dumpall --globals-only > "${BACKUP_PATH}/_globals.sql" age -R "$AGE_RECIPIENT_FILE" -o "${BACKUP_PATH}/_globals.sql.age" "${BACKUP_PATH}/_globals.sql" rm "${BACKUP_PATH}/_globals.sql" # ============================================================ # Конфиги # ============================================================ log "Архив конфигов..." tar czf "${BACKUP_PATH}/config.tar.gz" \ -C / \ etc/zbrain \ etc/postgresql/16/main/postgresql.conf \ etc/postgresql/16/main/conf.d \ etc/postgresql/16/main/pg_hba.conf \ etc/systemd/system/zbrain-*.service \ etc/nginx 2>/dev/null || true age -R "$AGE_RECIPIENT_FILE" -o "${BACKUP_PATH}/config.tar.gz.age" "${BACKUP_PATH}/config.tar.gz" rm "${BACKUP_PATH}/config.tar.gz" log "✓ config.tar.gz.age" # ============================================================ # gbrain data dir (config.json per-brain содержит пароли) # ============================================================ log "Архив brain configs..." if [[ -d "$ZBRAIN_DATA_DIR/brains" ]]; then tar czf "${BACKUP_PATH}/brains-meta.tar.gz" \ -C "$ZBRAIN_DATA_DIR" \ --exclude='brains/*/data' \ brains/ age -R "$AGE_RECIPIENT_FILE" -o "${BACKUP_PATH}/brains-meta.tar.gz.age" "${BACKUP_PATH}/brains-meta.tar.gz" rm "${BACKUP_PATH}/brains-meta.tar.gz" log "✓ brains-meta.tar.gz.age" fi # ============================================================ # Манифест # ============================================================ cat > "${BACKUP_PATH}/MANIFEST.txt" <