#!/bin/bash # ============================================================================== # Wordly.art - PostgreSQL Backup vers NAS Synology via SSH/rsync # ============================================================================== # Sauvegarde la base PostgreSQL et l'archive DR sur le NAS via SSH/rsync. # Pas de montage CIFS — rsync SSH direct sur /volume1/backups/wordly. # # CRON (installé par install-crontab.sh) : # 0 */6 * * * bash /opt/wordly/scripts/backup-to-nas.sh >> /var/log/wordly-backup.log 2>&1 # # Usage : # ./backup-to-nas.sh # Backup complet → NAS # ./backup-to-nas.sh --full # Identique (alias explicite) # ./backup-to-nas.sh --list # Lister les archives disponibles sur le NAS # ============================================================================== set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" # ============================================================================== # CHARGER LE .env # ============================================================================== ENV_FILE="${PROJECT_ROOT}/.env" if [ -f "${ENV_FILE}" ]; then set -a set +u source "${ENV_FILE}" set -u set +a else echo "ERROR: .env introuvable : ${ENV_FILE}" >&2 exit 1 fi # ============================================================================== # CONFIGURATION (depuis .env) # ============================================================================== # NAS SSH NAS_HOST="${NAS_HOST:-192.168.1.146}" NAS_USER="${NAS_USER:-wordly-backup}" NAS_PATH="${NAS_PATH:-/volume1/backups/wordly}" NAS_SSH_PORT="${NAS_SSH_PORT:-22}" NAS_SSH_KEY="${NAS_SSH_KEY:-/root/.ssh/wordly_nas_key}" # PostgreSQL POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-wordly-postgres}" POSTGRES_USER="${POSTGRES_USER:-translate}" POSTGRES_DB="${POSTGRES_DB:-translate_db}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD doit être défini dans .env}" # Rétention sur le NAS (nombre d'archives à garder) DAILY_RETENTION=${DAILY_RETENTION:-7} WEEKLY_RETENTION=${WEEKLY_RETENTION:-4} MONTHLY_RETENTION=${MONTHLY_RETENTION:-6} # Telegram TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}" # ============================================================================== # INTERNALS # ============================================================================== TIMESTAMP=$(date +"%Y%m%d_%H%M%S") DAY_OF_WEEK=$(date +"%u") # 1=Lun, 7=Dim DAY_OF_MONTH=$(date +"%d") SNAPSHOT_NAME="wordly_dr_${TIMESTAMP}.tar.gz" LOCAL_TMP="/tmp/wordly_backup_${TIMESTAMP}" SSH_CMD="ssh -i ${NAS_SSH_KEY} -p ${NAS_SSH_PORT} -o BatchMode=yes -o ConnectTimeout=10" RSYNC_CMD="rsync -az -e 'ssh -i ${NAS_SSH_KEY} -p ${NAS_SSH_PORT} -o BatchMode=yes -o ConnectTimeout=10'" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' LOG_PREFIX="[Backup ${TIMESTAMP}]" log() { echo "${LOG_PREFIX} $1" >&2; } log_success() { echo -e "${LOG_PREFIX} ${GREEN}✅ $1${NC}" >&2; } log_error() { echo -e "${LOG_PREFIX} ${RED}❌ ERROR: $1${NC}" >&2; } log_warning() { echo -e "${LOG_PREFIX} ${YELLOW}⚠️ $1${NC}" >&2; } # ============================================================================== # TELEGRAM # ============================================================================== send_telegram() { local message="$1" if [ -n "${TELEGRAM_BOT_TOKEN}" ] && [ -n "${TELEGRAM_CHAT_ID}" ]; then curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=${TELEGRAM_CHAT_ID}" \ -d "text=${message}" \ -d "parse_mode=Markdown" \ >/dev/null 2>&1 || true fi } # ============================================================================== # PRÉREQUIS # ============================================================================== check_prerequisites() { log "Vérification des prérequis..." # Clé SSH if [ ! -f "${NAS_SSH_KEY}" ]; then log_error "Clé SSH introuvable : ${NAS_SSH_KEY}" log_error "Lancez d'abord : sudo bash scripts/setup-nas.sh" exit 1 fi # Connectivité SSH vers le NAS if ! ${SSH_CMD} "${NAS_USER}@${NAS_HOST}" "echo OK" >/dev/null 2>&1; then log_error "Impossible de se connecter au NAS ${NAS_HOST} via SSH." log_error "Vérifiez : ssh -i ${NAS_SSH_KEY} ${NAS_USER}@${NAS_HOST}" send_telegram "🚨 *Wordly Backup ÉCHOUÉ* NAS inaccessible : ${NAS_HOST} Date : $(date '+%Y-%m-%d %H:%M:%S')" exit 1 fi log_success "NAS SSH : OK" # Docker + container PostgreSQL if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${POSTGRES_CONTAINER}$"; then log_error "Container PostgreSQL '${POSTGRES_CONTAINER}' n'est pas en cours d'exécution !" send_telegram "🚨 *Wordly Backup ÉCHOUÉ* PostgreSQL container non trouvé Date : $(date '+%Y-%m-%d %H:%M:%S')" exit 1 fi log_success "PostgreSQL container : OK" } # ============================================================================== # BACKUP POSTGRESQL # ============================================================================== backup_postgres() { log "Dump PostgreSQL de '${POSTGRES_DB}'..." mkdir -p "${LOCAL_TMP}" local dump_file="${LOCAL_TMP}/db_${TIMESTAMP}.dump.gz" if ! docker exec \ -e PGPASSWORD="${POSTGRES_PASSWORD}" \ "${POSTGRES_CONTAINER}" \ pg_dump \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ --format=custom \ --no-owner \ --no-acl \ 2>/dev/null | gzip > "${dump_file}"; then log_error "pg_dump a échoué !" send_telegram "🚨 *Wordly Backup ÉCHOUÉ* pg_dump error sur ${POSTGRES_DB} Date : $(date '+%Y-%m-%d %H:%M:%S')" rm -rf "${LOCAL_TMP}" exit 1 fi # Vérification taille local size_bytes size_bytes=$(stat -c %s "${dump_file}" 2>/dev/null || stat -f %z "${dump_file}") local min_bytes=1024 # 1KB minimum (safe for new/small databases) if [ "${size_bytes}" -lt "${min_bytes}" ]; then log_error "Dump trop petit ($(numfmt --to=iec ${size_bytes})) — base de données vide ?" send_telegram "🚨 *Wordly Backup ÉCHOUÉ* Dump PostgreSQL trop petit : $(numfmt --to=iec ${size_bytes}) Date : $(date '+%Y-%m-%d %H:%M:%S')" rm -rf "${LOCAL_TMP}" exit 1 fi log_success "Dump PostgreSQL : $(numfmt --to=iec ${size_bytes})" echo "${dump_file}" } # ============================================================================== # CRÉER L'ARCHIVE DR (dump + .env + docker-compose + configs) # ============================================================================== create_dr_archive() { local dump_file="$1" log "Construction de l'archive DR..." # Copier les fichiers de config [ -f "${PROJECT_ROOT}/.env" ] && cp "${PROJECT_ROOT}/.env" "${LOCAL_TMP}/.env.production" [ -f "${PROJECT_ROOT}/docker-compose.yml" ] && cp "${PROJECT_ROOT}/docker-compose.yml" "${LOCAL_TMP}/" [ -d "${PROJECT_ROOT}/docker" ] && cp -r "${PROJECT_ROOT}/docker" "${LOCAL_TMP}/" # Compresser local archive_path="/tmp/${SNAPSHOT_NAME}" tar -czf "${archive_path}" -C "${LOCAL_TMP}" . rm -rf "${LOCAL_TMP}" # Vérification intégrité if ! gzip -t "${archive_path}" 2>/dev/null; then log_error "Archive DR corrompue !" rm -f "${archive_path}" exit 1 fi local size size=$(du -h "${archive_path}" | cut -f1) log_success "Archive DR créée : ${SNAPSHOT_NAME} (${size})" echo "${archive_path}|${size}" } # ============================================================================== # ENVOYER SUR LE NAS VIA SCP/rsync SSH # ============================================================================== push_to_nas() { local archive_path="$1" local size="$2" log "Transfert vers le NAS via rsync SSH..." log " Source : ${archive_path}" log " Dest : ${NAS_USER}@${NAS_HOST}:${NAS_PATH}/snapshots/${SNAPSHOT_NAME}" # Dossier quotidien/hebdo/mensuel sur le NAS local nas_dest="${NAS_PATH}/snapshots" # Transfer principal if ! rsync -az \ -e "ssh -i ${NAS_SSH_KEY} -p ${NAS_SSH_PORT} -o BatchMode=yes -o ConnectTimeout=30" \ "${archive_path}" \ "${NAS_USER}@${NAS_HOST}:${nas_dest}/${SNAPSHOT_NAME}"; then log_error "rsync vers le NAS a échoué !" send_telegram "🚨 *Wordly Backup ÉCHOUÉ* rsync SSH vers ${NAS_HOST} a échoué Fichier local conservé : ${archive_path} Date : $(date '+%Y-%m-%d %H:%M:%S')" # Garder le fichier local comme fallback mkdir -p "${PROJECT_ROOT}/backups/emergency" mv "${archive_path}" "${PROJECT_ROOT}/backups/emergency/${SNAPSHOT_NAME}" log_warning "Archive conservée localement : ${PROJECT_ROOT}/backups/emergency/${SNAPSHOT_NAME}" exit 1 fi log_success "Archive transférée sur le NAS : ${nas_dest}/${SNAPSHOT_NAME}" # Copie hebdomadaire (dimanche) if [ "${DAY_OF_WEEK}" = "7" ]; then ${SSH_CMD} "${NAS_USER}@${NAS_HOST}" \ "cp ${nas_dest}/${SNAPSHOT_NAME} ${NAS_PATH}/snapshots/weekly_${SNAPSHOT_NAME}" 2>/dev/null || true log "Archive hebdomadaire copiée." fi # Copie mensuelle (1er du mois) if [ "${DAY_OF_MONTH}" = "01" ]; then ${SSH_CMD} "${NAS_USER}@${NAS_HOST}" \ "cp ${nas_dest}/${SNAPSHOT_NAME} ${NAS_PATH}/snapshots/monthly_${SNAPSHOT_NAME}" 2>/dev/null || true log "Archive mensuelle copiée." fi # Nettoyage local rm -f "${archive_path}" } # ============================================================================== # ROTATION DES ARCHIVES SUR LE NAS # ============================================================================== cleanup_nas() { log "Rotation des archives sur le NAS (conservation : ${DAILY_RETENTION} jours)..." # Supprimer les archives wordly_dr_* plus vieilles que DAILY_RETENTION ${SSH_CMD} "${NAS_USER}@${NAS_HOST}" \ "find ${NAS_PATH}/snapshots -name 'wordly_dr_*.tar.gz' -mtime +${DAILY_RETENTION} -delete 2>/dev/null; \ find ${NAS_PATH}/snapshots -name 'weekly_*.tar.gz' | sort -r | tail -n +$((WEEKLY_RETENTION + 1)) | xargs rm -f 2>/dev/null; \ find ${NAS_PATH}/snapshots -name 'monthly_*.tar.gz' | sort -r | tail -n +$((MONTHLY_RETENTION + 1)) | xargs rm -f 2>/dev/null; \ echo OK" | grep -q "OK" log_success "Rotation des archives OK" } # ============================================================================== # SYNCHRONISER LES SCRIPTS SUR LE NAS (pour restauration depuis .98) # ============================================================================== sync_scripts() { rsync -az \ -e "ssh -i ${NAS_SSH_KEY} -p ${NAS_SSH_PORT} -o BatchMode=yes" \ --exclude="__pycache__" \ --exclude="*.pyc" \ "${SCRIPT_DIR}/" \ "${NAS_USER}@${NAS_HOST}:${NAS_PATH}/scripts/" 2>/dev/null || true } # ============================================================================== # LISTER LES ARCHIVES DISPONIBLES # ============================================================================== list_archives() { log "Archives disponibles sur le NAS :" ${SSH_CMD} "${NAS_USER}@${NAS_HOST}" \ "ls -lht ${NAS_PATH}/snapshots/wordly_dr_*.tar.gz 2>/dev/null || echo '(aucune archive)'" } # ============================================================================== # MAIN # ============================================================================== main() { case "${1:-}" in --list) ENV_FILE="${PROJECT_ROOT}/.env" [ -f "${ENV_FILE}" ] && { set -a; source "${ENV_FILE}"; set +a; } list_archives exit 0 ;; --full|*) ;; esac echo "" echo "=================================================================" echo " Wordly.art — Backup → NAS Synology 192.168.1.146" echo " DB : ${POSTGRES_DB}" echo " NAS : ${NAS_USER}@${NAS_HOST}:${NAS_PATH}" echo " $(date '+%Y-%m-%d %H:%M:%S')" echo "=================================================================" echo "" check_prerequisites # 1. Dump PostgreSQL local dump_file dump_file=$(backup_postgres) # 2. Créer l'archive DR local archive_info archive_info=$(create_dr_archive "${dump_file}") local archive_path="${archive_info%%|*}" local archive_size="${archive_info##*|}" # 3. Envoyer sur le NAS via rsync SSH push_to_nas "${archive_path}" "${archive_size}" # 4. Rotation cleanup_nas # 5. Sync scripts sync_scripts # 6. Notification Telegram send_telegram "✅ *Wordly.art Backup OK* Archive : \`${SNAPSHOT_NAME}\` Taille : ${archive_size} NAS : \`${NAS_PATH}/snapshots/\` Date : $(date '+%Y-%m-%d %H:%M:%S')" echo "" log_success "=================================================================" log_success "Backup complet terminé !" log_success " Archive : ${NAS_PATH}/snapshots/${SNAPSHOT_NAME}" log_success " Lister : bash scripts/backup-to-nas.sh --list" log_success "=================================================================" echo "" } main "$@"