#!/usr/bin/env bash # ============================================================================= # gitea-backup.sh — Sauvegarde & Restauration complète de Gitea # Version : 1.0.0 # Usage : sudo ./gitea-backup.sh [backup|restore|list|cron|help] # ============================================================================= set -euo pipefail IFS=$'\n\t' # ── Couleurs ────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' # ── Répertoire du script ────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${SCRIPT_DIR}/.env" LOG_FILE="/var/log/gitea-backup.log" TIMESTAMP="$(date +%Y%m%d_%H%M%S)" # ============================================================================= # FONCTIONS UTILITAIRES # ============================================================================= log() { local level="$1"; shift local msg="$*" local ts; ts="$(date '+%Y-%m-%d %H:%M:%S')" echo -e "${ts} [${level}] ${msg}" >> "${LOG_FILE}" 2>/dev/null || true case "${level}" in INFO) echo -e "${GREEN}[INFO]${RESET} ${msg}" ;; WARN) echo -e "${YELLOW}[WARN]${RESET} ${msg}" ;; ERROR) echo -e "${RED}[ERROR]${RESET} ${msg}" ;; STEP) echo -e "${CYAN}[STEP]${RESET} ${BOLD}${msg}${RESET}" ;; SUCCESS) echo -e "${GREEN}[OK]${RESET} ${msg}" ;; esac } die() { log ERROR "$*" exit 1 } require_root() { [[ "${EUID}" -eq 0 ]] || die "Ce script doit etre execute en tant que root (sudo)." } require_command() { command -v "$1" &>/dev/null || die "Commande requise introuvable : $1" } confirm() { local prompt="${1:-Continuer ?} [o/N] " read -rp "$(echo -e "${YELLOW}${prompt}${RESET}")" answer [[ "${answer,,}" =~ ^(o|oui|y|yes)$ ]] } spinner() { local pid=$1; local msg="${2:-Traitement en cours...}" local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' local i=0 while kill -0 "${pid}" 2>/dev/null; do printf "\r${CYAN}%s${RESET} %s" "${spin:$((i % ${#spin})):1}" "${msg}" i=$(( i + 1 )); sleep 0.1 done printf "\r%-60s\r" " " } hr() { echo -e "${BLUE}$(printf '─%.0s' {1..70})${RESET}"; } # ============================================================================= # CHARGEMENT DE LA CONFIGURATION # ============================================================================= load_env() { [[ -f "${ENV_FILE}" ]] || die "Fichier .env.gitea introuvable : ${ENV_FILE}" # Valeurs par défaut GITEA_USER="git" GITEA_ROOT="/opt/gitea" GITEA_WORK_DIR="/var/lib/gitea" GITEA_CONF="/etc/gitea/app.ini" GITEA_BINARY="/usr/local/bin/gitea" BACKUP_DIR="/opt/Backups/gitea" KEEP_BACKUPS=7 INCLUDE_LOG="false" USE_NATIVE_DUMP="true" DB_TYPE="" DB_HOST="localhost" DB_PORT="" DB_NAME="" DB_USER="" DB_PASS="" # Chargement sécurisé while IFS='=' read -r key value; do [[ "${key}" =~ ^[[:space:]]*# ]] && continue [[ -z "${key// /}" ]] && continue key="${key// /}" value="${value%%#*}" value="${value%"${value##*[![:space:]]}"}" value="${value#\"}" ; value="${value%\"}" value="${value#\'}" ; value="${value%\'}" export "${key}=${value}" 2>/dev/null || true done < "${ENV_FILE}" # Vérifications [[ -f "${GITEA_CONF}" ]] || die "app.ini introuvable : ${GITEA_CONF}" mkdir -p "${BACKUP_DIR}" || die "Impossible de creer BACKUP_DIR : ${BACKUP_DIR}" touch "${LOG_FILE}" 2>/dev/null || LOG_FILE="/tmp/gitea-backup.log" } # ============================================================================= # DÉTECTION AUTOMATIQUE DEPUIS app.ini # ============================================================================= detect_gitea_config() { log STEP "Lecture de la configuration Gitea (${GITEA_CONF})" _ini_get() { local section="$1" key="$2" awk -F '=' \ -v sec="[${section}]" -v k="${key}" \ 'in_section && /^\[/ { in_section=0 } /^\[/ && $0==sec { in_section=1; next } in_section && /^[[:space:]]*'"${key}"'[[:space:]]*=/ { sub(/^[^=]+=/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit }' "${GITEA_CONF}" } # Chemins de données GITEA_REPO_ROOT="${GITEA_WORK_DIR}/repositories" local repo_path; repo_path=$(_ini_get "repository" "ROOT") [[ -n "${repo_path}" ]] && GITEA_REPO_ROOT="${repo_path}" GITEA_DATA_DIR="${GITEA_WORK_DIR}/data" local data_path; data_path=$(_ini_get "server" "APP_DATA_PATH") [[ -n "${data_path}" ]] && GITEA_DATA_DIR="${data_path}" GITEA_LOG_DIR="${GITEA_WORK_DIR}/log" local log_path; log_path=$(_ini_get "log" "ROOT_PATH") [[ -n "${log_path}" ]] && GITEA_LOG_DIR="${log_path}" GITEA_AVATAR_DIR="${GITEA_DATA_DIR}/avatars" GITEA_ATTACH_DIR="${GITEA_DATA_DIR}/attachments" GITEA_LFS_DIR="${GITEA_DATA_DIR}/lfs" # Base de données (si non forcée dans .env) if [[ -z "${DB_TYPE}" ]]; then DB_TYPE=$(_ini_get "database" "DB_TYPE") fi [[ -z "${DB_NAME}" ]] && DB_NAME=$(_ini_get "database" "NAME") [[ -z "${DB_HOST}" ]] && DB_HOST=$(_ini_get "database" "HOST") [[ -z "${DB_USER}" ]] && DB_USER=$(_ini_get "database" "USER") [[ -z "${DB_PASS}" ]] && DB_PASS=$(_ini_get "database" "PASSWD") # Séparer host:port si fourni ensemble if [[ "${DB_HOST}" == *:* ]]; then DB_PORT="${DB_HOST##*:}" DB_HOST="${DB_HOST%%:*}" fi [[ -n "${DB_TYPE}" ]] || die "Impossible de detecter DB_TYPE depuis app.ini" [[ -n "${DB_NAME}" ]] || die "Impossible de detecter le nom de la base de donnees" # Normalisation du type DB case "${DB_TYPE,,}" in postgres|postgresql) DB_TYPE="postgresql" ;; mysql|mysql2) DB_TYPE="mysql" ;; sqlite3|sqlite) DB_TYPE="sqlite3" ;; mssql) DB_TYPE="mssql" ;; esac log INFO "DB type : ${DB_TYPE}" log INFO "DB name : ${DB_NAME}" log INFO "Repos : ${GITEA_REPO_ROOT}" log INFO "Data : ${GITEA_DATA_DIR}" } # ============================================================================= # SAUVEGARDE DB # ============================================================================= backup_db_postgresql() { log STEP "Dump PostgreSQL → ${DB_NAME}" local dump_file="${TMP_DIR}/database.sql" local pg_opts=(-U "${DB_USER}" -h "${DB_HOST}") [[ -n "${DB_PORT}" ]] && pg_opts+=(-p "${DB_PORT}") if [[ -n "${DB_PASS}" ]]; then PGPASSWORD="${DB_PASS}" pg_dump "${pg_opts[@]}" \ --no-owner --no-acl --format=custom \ -f "${dump_file}" "${DB_NAME}" & else pg_dump "${pg_opts[@]}" --no-owner --no-acl --format=custom \ -f "${dump_file}" "${DB_NAME}" & fi spinner $! "Dump PostgreSQL en cours..." wait $! log SUCCESS "Dump PostgreSQL termine" } backup_db_mysql() { log STEP "Dump MySQL/MariaDB → ${DB_NAME}" local dump_file="${TMP_DIR}/database.sql" local my_opts=(--single-transaction --routines --triggers --events) [[ -n "${DB_HOST}" ]] && my_opts+=(-h "${DB_HOST}") [[ -n "${DB_PORT}" ]] && my_opts+=(-P "${DB_PORT}") [[ -n "${DB_USER}" ]] && my_opts+=(-u "${DB_USER}") if [[ -n "${DB_PASS}" ]]; then MYSQL_PWD="${DB_PASS}" mysqldump "${my_opts[@]}" "${DB_NAME}" > "${dump_file}" & else mysqldump "${my_opts[@]}" "${DB_NAME}" > "${dump_file}" & fi spinner $! "Dump MySQL en cours..." wait $! log SUCCESS "Dump MySQL termine" } backup_db_sqlite3() { log STEP "Backup SQLite3 → ${DB_NAME}" local dump_file="${TMP_DIR}/database.sql" local sqlite_path="${DB_NAME}" [[ "${sqlite_path}" != /* ]] && sqlite_path="${GITEA_WORK_DIR}/${sqlite_path}" [[ -f "${sqlite_path}" ]] || die "Fichier SQLite introuvable : ${sqlite_path}" # Copie à chaud + dump SQL pour double sécurité cp "${sqlite_path}" "${TMP_DIR}/database.db" sqlite3 "${sqlite_path}" .dump > "${dump_file}" & spinner $! "Dump SQLite3 en cours..." wait $! log SUCCESS "Dump SQLite3 termine" } # ============================================================================= # RESTAURATION DB # ============================================================================= restore_db_postgresql() { log STEP "Restauration PostgreSQL → ${DB_NAME}" local dump_file="${TMP_DIR}/database.sql" local pg_opts=(-U "${DB_USER}" -h "${DB_HOST}") [[ -n "${DB_PORT}" ]] && pg_opts+=(-p "${DB_PORT}") if [[ -n "${DB_PASS}" ]]; then PGPASSWORD="${DB_PASS}" psql "${pg_opts[@]}" -d postgres \ -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${DB_NAME}';" \ 2>/dev/null || true PGPASSWORD="${DB_PASS}" psql "${pg_opts[@]}" -d postgres \ -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" 2>/dev/null || true PGPASSWORD="${DB_PASS}" psql "${pg_opts[@]}" -d postgres \ -c "CREATE DATABASE \"${DB_NAME}\" OWNER \"${DB_USER}\";" PGPASSWORD="${DB_PASS}" pg_restore "${pg_opts[@]}" \ --no-owner --no-acl -d "${DB_NAME}" "${dump_file}" & else psql "${pg_opts[@]}" -d postgres \ -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${DB_NAME}';" \ 2>/dev/null || true psql "${pg_opts[@]}" -d postgres \ -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" 2>/dev/null || true psql "${pg_opts[@]}" -d postgres \ -c "CREATE DATABASE \"${DB_NAME}\" OWNER \"${DB_USER}\";" pg_restore "${pg_opts[@]}" --no-owner --no-acl \ -d "${DB_NAME}" "${dump_file}" & fi spinner $! "Restauration PostgreSQL..." wait $! || log WARN "pg_restore : avertissements ignorés (objets existants)" log SUCCESS "Restauration PostgreSQL terminee" } restore_db_mysql() { log STEP "Restauration MySQL → ${DB_NAME}" local dump_file="${TMP_DIR}/database.sql" local my_opts=() [[ -n "${DB_HOST}" ]] && my_opts+=(-h "${DB_HOST}") [[ -n "${DB_PORT}" ]] && my_opts+=(-P "${DB_PORT}") [[ -n "${DB_USER}" ]] && my_opts+=(-u "${DB_USER}") if [[ -n "${DB_PASS}" ]]; then MYSQL_PWD="${DB_PASS}" mysql "${my_opts[@]}" \ -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" MYSQL_PWD="${DB_PASS}" mysql "${my_opts[@]}" "${DB_NAME}" < "${dump_file}" & else mysql "${my_opts[@]}" \ -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" mysql "${my_opts[@]}" "${DB_NAME}" < "${dump_file}" & fi spinner $! "Restauration MySQL..." wait $! log SUCCESS "Restauration MySQL terminee" } restore_db_sqlite3() { log STEP "Restauration SQLite3 → ${DB_NAME}" local sqlite_path="${DB_NAME}" [[ "${sqlite_path}" != /* ]] && sqlite_path="${GITEA_WORK_DIR}/${sqlite_path}" if [[ -f "${TMP_DIR}/database.db" ]]; then # Restauration depuis la copie binaire (plus fiable) [[ -f "${sqlite_path}" ]] && cp "${sqlite_path}" "${sqlite_path}.pre_restore" cp "${TMP_DIR}/database.db" "${sqlite_path}" & spinner $! "Restauration SQLite3 (binaire)..." wait $! elif [[ -f "${TMP_DIR}/database.sql" ]]; then [[ -f "${sqlite_path}" ]] && cp "${sqlite_path}" "${sqlite_path}.pre_restore" sqlite3 "${sqlite_path}" < "${TMP_DIR}/database.sql" & spinner $! "Restauration SQLite3 (SQL dump)..." wait $! else die "Aucun fichier de base de donnees SQLite trouve dans l'archive" fi log SUCCESS "Restauration SQLite3 terminee" } # ============================================================================= # SAUVEGARDE PRINCIPALE # ============================================================================= do_backup() { hr echo -e "${BOLD}${CYAN}" echo " ██████╗ ██╗████████╗███████╗ █████╗ " echo " ██╔════╝ ██║╚══██╔══╝██╔════╝██╔══██╗" echo " ██║ ███╗██║ ██║ █████╗ ███████║" echo " ██║ ██║██║ ██║ ██╔══╝ ██╔══██║" echo " ╚██████╔╝██║ ██║ ███████╗██║ ██║" echo " ╚═════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝" echo -e "${RESET}${BOLD} Sauvegarde Gitea${RESET}" hr log INFO "Demarrage de la sauvegarde Gitea — ${TIMESTAMP}" log INFO "Config : ${GITEA_CONF}" log INFO "Destination : ${BACKUP_DIR}" detect_gitea_config TMP_DIR="$(mktemp -d /tmp/gitea_backup_XXXXXX)" trap 'rm -rf "${TMP_DIR}"' EXIT ARCHIVE_NAME="gitea_backup_${TIMESTAMP}.tar.gz" ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}" # ── Méthode 1 : gitea dump natif (si disponible et activé) ──────────────── if [[ "${USE_NATIVE_DUMP}" == "true" ]] && [[ -x "${GITEA_BINARY}" ]]; then log STEP "Utilisation du dump natif Gitea (${GITEA_BINARY})" local native_out="${TMP_DIR}/native_dump" mkdir -p "${native_out}" sudo -u "${GITEA_USER}" \ GITEA_WORK_DIR="${GITEA_WORK_DIR}" \ "${GITEA_BINARY}" dump \ --config "${GITEA_CONF}" \ --file "${native_out}/gitea_native_dump.zip" \ --type zip \ --skip-lfs-data=false \ 2>&1 | while read -r line; do log INFO " [gitea dump] ${line}"; done || { log WARN "gitea dump a echoue, basculement sur la methode manuelle" USE_NATIVE_DUMP="failed" } if [[ "${USE_NATIVE_DUMP}" != "failed" ]] && \ [[ -f "${native_out}/gitea_native_dump.zip" ]]; then log SUCCESS "Dump natif Gitea termine" # On continue quand même avec la sauvegarde manuelle pour le manifest fi fi # ── Dump de la base de données ───────────────────────────────────────────── log STEP "Sauvegarde de la base de donnees (${DB_TYPE})" mkdir -p "${TMP_DIR}/db" local TMP_DIR_DB="${TMP_DIR}/db" local TMP_DIR_SAVE="${TMP_DIR}" TMP_DIR="${TMP_DIR_DB}" case "${DB_TYPE}" in postgresql) backup_db_postgresql ;; mysql) backup_db_mysql ;; sqlite3) backup_db_sqlite3 ;; *) log WARN "Type DB '${DB_TYPE}' non supporte pour le dump manuel" ;; esac TMP_DIR="${TMP_DIR_SAVE}" # Déplacer les dumps dans TMP_DIR mv "${TMP_DIR_DB}"/* "${TMP_DIR}/" 2>/dev/null || true # ── Manifest ────────────────────────────────────────────────────────────── log STEP "Generation du manifest" { echo "=== GITEA BACKUP MANIFEST ===" echo "Timestamp : ${TIMESTAMP}" echo "Hostname : $(hostname -f)" echo "GITEA_CONF : ${GITEA_CONF}" echo "GITEA_WORK_DIR : ${GITEA_WORK_DIR}" echo "GITEA_REPO_ROOT: ${GITEA_REPO_ROOT}" echo "DB_TYPE : ${DB_TYPE}" echo "DB_NAME : ${DB_NAME}" echo "DB_HOST : ${DB_HOST}" echo "" echo "=== VERSION GITEA ===" if [[ -x "${GITEA_BINARY}" ]]; then "${GITEA_BINARY}" --version 2>/dev/null || echo "N/A" else echo "Binaire non trouve : ${GITEA_BINARY}" fi echo "" echo "=== STATISTIQUES DEPOTS ===" if [[ -d "${GITEA_REPO_ROOT}" ]]; then local repo_count; repo_count=$(find "${GITEA_REPO_ROOT}" -maxdepth 2 \ -name "*.git" -o -name "HEAD" 2>/dev/null | \ grep -c "HEAD" || echo "0") echo "Nombre de depots : ${repo_count}" echo "Taille totale : $(du -sh "${GITEA_REPO_ROOT}" 2>/dev/null | cut -f1)" fi echo "" echo "=== LFS ===" if [[ -d "${GITEA_LFS_DIR}" ]]; then echo "Taille LFS : $(du -sh "${GITEA_LFS_DIR}" 2>/dev/null | cut -f1)" else echo "LFS non configure ou vide" fi echo "" echo "=== APP.INI (sections principales) ===" grep -E '^\[|^APP_NAME|^RUN_USER|^DOMAIN|^ROOT_URL|^HTTP_PORT|^DB_TYPE|^HOST|^NAME' \ "${GITEA_CONF}" 2>/dev/null | head -40 || echo "N/A" } > "${TMP_DIR}/manifest.txt" log SUCCESS "Manifest genere" # ── Copie de la configuration ────────────────────────────────────────────── log STEP "Sauvegarde de la configuration (app.ini)" mkdir -p "${TMP_DIR}/config" cp "${GITEA_CONF}" "${TMP_DIR}/config/app.ini" [[ -f "${ENV_FILE}" ]] && cp "${ENV_FILE}" "${TMP_DIR}/config/.env.gitea.backup" # Clés SSH Gitea local gitea_home; gitea_home=$(eval echo "~${GITEA_USER}") if [[ -d "${gitea_home}/.ssh" ]]; then cp -r "${gitea_home}/.ssh" "${TMP_DIR}/config/ssh_keys" 2>/dev/null || true log INFO " Cles SSH incluses" fi log SUCCESS "Configuration sauvegardee" # ── Archive des dépôts ──────────────────────────────────────────────────── if [[ -d "${GITEA_REPO_ROOT}" ]]; then log STEP "Archivage des depots Git (${GITEA_REPO_ROOT})" ( tar -czf "${TMP_DIR}/repositories.tar.gz" \ -C "$(dirname "${GITEA_REPO_ROOT}")" \ "$(basename "${GITEA_REPO_ROOT}")" 2>/dev/null ) & spinner $! "Archivage des depots..." wait $! log SUCCESS "Depots archives" else log WARN "Dossier repositories introuvable : ${GITEA_REPO_ROOT}" fi # ── Archive des données (avatars, attachments, etc.) ───────────────────── if [[ -d "${GITEA_DATA_DIR}" ]]; then log STEP "Archivage des donnees (avatars, attachments...)" local tar_data_excludes=() [[ "${INCLUDE_LOG}" != "true" ]] && tar_data_excludes+=("--exclude=${GITEA_DATA_DIR}/log") ( tar "${tar_data_excludes[@]+"${tar_data_excludes[@]}"}" \ -czf "${TMP_DIR}/data.tar.gz" \ -C "$(dirname "${GITEA_DATA_DIR}")" \ "$(basename "${GITEA_DATA_DIR}")" 2>/dev/null ) & spinner $! "Archivage des donnees..." wait $! log SUCCESS "Donnees archivees" fi # ── Logs (optionnel) ────────────────────────────────────────────────────── if [[ "${INCLUDE_LOG}" == "true" ]] && [[ -d "${GITEA_LOG_DIR}" ]]; then log STEP "Archivage des logs" (tar -czf "${TMP_DIR}/logs.tar.gz" \ -C "$(dirname "${GITEA_LOG_DIR}")" \ "$(basename "${GITEA_LOG_DIR}")" 2>/dev/null) & spinner $! "Archivage des logs..." wait $! log SUCCESS "Logs archives" fi # ── Assemblage de l'archive finale ──────────────────────────────────────── log STEP "Assemblage de l'archive finale" local files_to_pack=() [[ -f "${TMP_DIR}/database.sql" ]] && files_to_pack+=("database.sql") [[ -f "${TMP_DIR}/database.db" ]] && files_to_pack+=("database.db") files_to_pack+=("manifest.txt" "config") [[ -f "${TMP_DIR}/repositories.tar.gz" ]] && files_to_pack+=("repositories.tar.gz") [[ -f "${TMP_DIR}/data.tar.gz" ]] && files_to_pack+=("data.tar.gz") [[ -f "${TMP_DIR}/logs.tar.gz" ]] && files_to_pack+=("logs.tar.gz") [[ -d "${TMP_DIR}/native_dump" ]] && files_to_pack+=("native_dump") (tar -czf "${ARCHIVE_PATH}" -C "${TMP_DIR}" "${files_to_pack[@]}" 2>/dev/null) & spinner $! "Assemblage final..." wait $! local size; size=$(du -sh "${ARCHIVE_PATH}" | cut -f1) log SUCCESS "Archive creee : ${ARCHIVE_PATH} (${size})" # ── Rotation ────────────────────────────────────────────────────────────── log STEP "Rotation des archives (conservation : ${KEEP_BACKUPS})" local count; count=$(find "${BACKUP_DIR}" -maxdepth 1 -name "gitea_backup_*.tar.gz" | wc -l) if [[ "${count}" -gt "${KEEP_BACKUPS}" ]]; then local to_delete=$(( count - KEEP_BACKUPS )) find "${BACKUP_DIR}" -maxdepth 1 -name "gitea_backup_*.tar.gz" \ -printf '%T+ %p\n' | sort | head -n "${to_delete}" | \ awk '{print $2}' | while read -r old; do rm -f "${old}" log INFO " Supprime : $(basename "${old}")" done else log INFO " Aucune rotation necessaire (${count}/${KEEP_BACKUPS})" fi hr echo -e "${GREEN}${BOLD} ✔ Sauvegarde Gitea terminee avec succes !${RESET}" echo -e "${GREEN} Archive : ${ARCHIVE_PATH}${RESET}" echo -e "${GREEN} Taille : ${size}${RESET}" hr } # ============================================================================= # RESTAURATION PRINCIPALE # ============================================================================= do_restore() { hr echo -e "${BOLD}${YELLOW}" echo " ██████╗ ███████╗███████╗████████╗ ██████╗ ██████╗ ███████╗" echo " ██╔══██╗██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██╔════╝" echo " ██████╔╝█████╗ ███████╗ ██║ ██║ ██║██████╔╝█████╗ " echo " ██╔══██╗██╔══╝ ╚════██║ ██║ ██║ ██║██╔══██╗██╔══╝ " echo " ██║ ██║███████╗███████║ ██║ ╚██████╔╝██║ ██║███████╗" echo " ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝" echo -e "${RESET}${BOLD} Restauration Gitea${RESET}" hr # ── Sélection de l'archive ──────────────────────────────────────────────── local backup_list mapfile -t backup_list < <(find "${BACKUP_DIR}" -maxdepth 1 \ -name "gitea_backup_*.tar.gz" | sort -r) if [[ ${#backup_list[@]} -eq 0 ]]; then die "Aucune archive de sauvegarde trouvee dans ${BACKUP_DIR}" fi echo -e "\n${BOLD} Sauvegardes disponibles :${RESET}\n" local i=1 for archive in "${backup_list[@]}"; do local fname; fname=$(basename "${archive}") local fsize; fsize=$(du -sh "${archive}" | cut -f1) local fdate; fdate=$(stat -c '%y' "${archive}" | cut -d'.' -f1) printf " ${CYAN}[%2d]${RESET} %-45s ${YELLOW}%6s${RESET} %s\n" \ "${i}" "${fname}" "${fsize}" "${fdate}" i=$(( i + 1 )) done echo -e " ${CYAN}[ 0]${RESET} Annuler\n" hr local choice while true; do read -rp "$(echo -e "${BOLD} Choisir une archive [0-$((i-1))] : ${RESET}")" choice [[ "${choice}" =~ ^[0-9]+$ ]] || continue [[ "${choice}" -eq 0 ]] && { log INFO "Restauration annulee."; return 0; } [[ "${choice}" -ge 1 && "${choice}" -le ${#backup_list[@]} ]] && break echo -e "${RED} Choix invalide.${RESET}" done local selected="${backup_list[$((choice-1))]}" log INFO "Archive selectionnee : $(basename "${selected}")" # ── Modes de restauration ───────────────────────────────────────────────── hr echo -e "\n${BOLD} Mode de restauration :${RESET}\n" echo -e " ${CYAN}[1]${RESET} Restauration complete (DB + depots + donnees + config)" echo -e " ${CYAN}[2]${RESET} Base de donnees uniquement" echo -e " ${CYAN}[3]${RESET} Depots Git uniquement" echo -e " ${CYAN}[4]${RESET} Donnees uniquement (avatars, attachments, LFS)" echo -e " ${CYAN}[5]${RESET} Configuration uniquement (app.ini + cles SSH)" echo -e " ${CYAN}[0]${RESET} Annuler\n" hr local mode while true; do read -rp "$(echo -e "${BOLD} Mode [0-5] : ${RESET}")" mode [[ "${mode}" =~ ^[0-5]$ ]] && break echo -e "${RED} Choix invalide.${RESET}" done [[ "${mode}" -eq 0 ]] && { log INFO "Restauration annulee."; return 0; } # ── Confirmation ────────────────────────────────────────────────────────── hr echo -e "\n${RED}${BOLD} ATTENTION : Cette operation va ecraser les donnees existantes !${RESET}" echo -e " Archive : $(basename "${selected}")" local mode_label case "${mode}" in 1) mode_label="Complete (DB + depots + donnees + config)" ;; 2) mode_label="Base de donnees uniquement" ;; 3) mode_label="Depots Git uniquement" ;; 4) mode_label="Donnees uniquement" ;; 5) mode_label="Configuration uniquement" ;; esac echo -e " Mode : ${BOLD}${mode_label}${RESET}" echo "" confirm " Confirmer la restauration ?" || { log INFO "Restauration annulee."; return 0; } detect_gitea_config # ── Extraction ──────────────────────────────────────────────────────────── TMP_DIR="$(mktemp -d /tmp/gitea_restore_XXXXXX)" trap 'rm -rf "${TMP_DIR}"' EXIT log STEP "Extraction de l'archive" (tar -xzf "${selected}" -C "${TMP_DIR}") & spinner $! "Extraction en cours..." wait $! log SUCCESS "Archive extraite" if [[ -f "${TMP_DIR}/manifest.txt" ]]; then hr echo -e "${BOLD} Informations de la sauvegarde :${RESET}" head -20 "${TMP_DIR}/manifest.txt" | sed 's/^/ /' hr fi # ── Arrêt de Gitea avant restauration ───────────────────────────────────── if [[ "${mode}" -ne 5 ]]; then log STEP "Arret du service Gitea" if systemctl is-active --quiet gitea 2>/dev/null; then systemctl stop gitea log SUCCESS "Service Gitea arrete" # On redémarrera à la fin GITEA_WAS_RUNNING=true else log WARN "Service Gitea non actif (ou non gere par systemd)" GITEA_WAS_RUNNING=false fi fi # ── Restauration DB ─────────────────────────────────────────────────────── if [[ "${mode}" -eq 1 || "${mode}" -eq 2 ]]; then [[ -f "${TMP_DIR}/database.sql" ]] || [[ -f "${TMP_DIR}/database.db" ]] || \ die "Aucun dump de base de donnees dans l'archive" case "${DB_TYPE}" in postgresql) restore_db_postgresql ;; mysql) restore_db_mysql ;; sqlite3) restore_db_sqlite3 ;; *) die "Type DB non supporte : ${DB_TYPE}" ;; esac fi # ── Restauration dépôts ─────────────────────────────────────────────────── if [[ "${mode}" -eq 1 || "${mode}" -eq 3 ]]; then if [[ -f "${TMP_DIR}/repositories.tar.gz" ]]; then log STEP "Restauration des depots Git" local repo_parent; repo_parent="$(dirname "${GITEA_REPO_ROOT}")" if [[ -d "${GITEA_REPO_ROOT}" ]]; then mv "${GITEA_REPO_ROOT}" "${GITEA_REPO_ROOT}.pre_restore_${TIMESTAMP}" log INFO " Anciens depots sauvegardes : $(basename "${GITEA_REPO_ROOT}").pre_restore_${TIMESTAMP}" fi (tar -xzf "${TMP_DIR}/repositories.tar.gz" -C "${repo_parent}") & spinner $! "Restauration des depots..." wait $! log SUCCESS "Depots restaures dans ${GITEA_REPO_ROOT}" else log WARN "Aucune archive de depots trouvee dans la sauvegarde" fi fi # ── Restauration données ────────────────────────────────────────────────── if [[ "${mode}" -eq 1 || "${mode}" -eq 4 ]]; then if [[ -f "${TMP_DIR}/data.tar.gz" ]]; then log STEP "Restauration des donnees (avatars, attachments, LFS...)" local data_parent; data_parent="$(dirname "${GITEA_DATA_DIR}")" if [[ -d "${GITEA_DATA_DIR}" ]]; then mv "${GITEA_DATA_DIR}" "${GITEA_DATA_DIR}.pre_restore_${TIMESTAMP}" log INFO " Anciennes donnees sauvegardees" fi (tar -xzf "${TMP_DIR}/data.tar.gz" -C "${data_parent}") & spinner $! "Restauration des donnees..." wait $! log SUCCESS "Donnees restaurees dans ${GITEA_DATA_DIR}" else log WARN "Aucune archive de donnees trouvee dans la sauvegarde" fi fi # ── Restauration configuration ──────────────────────────────────────────── if [[ "${mode}" -eq 1 || "${mode}" -eq 5 ]]; then if [[ -f "${TMP_DIR}/config/app.ini" ]]; then log STEP "Restauration de la configuration" cp "${GITEA_CONF}" "${GITEA_CONF}.pre_restore_${TIMESTAMP}" 2>/dev/null || true cp "${TMP_DIR}/config/app.ini" "${GITEA_CONF}" log SUCCESS "app.ini restaure" fi if [[ -d "${TMP_DIR}/config/ssh_keys" ]]; then local gitea_home; gitea_home=$(eval echo "~${GITEA_USER}") cp -r "${TMP_DIR}/config/ssh_keys/." "${gitea_home}/.ssh/" 2>/dev/null || true log SUCCESS "Cles SSH restaurees" fi fi # ── Ajustement des permissions ──────────────────────────────────────────── log STEP "Ajustement des permissions pour '${GITEA_USER}'" [[ -d "${GITEA_REPO_ROOT}" ]] && \ chown -R "${GITEA_USER}:${GITEA_USER}" "${GITEA_REPO_ROOT}" 2>/dev/null || true [[ -d "${GITEA_DATA_DIR}" ]] && \ chown -R "${GITEA_USER}:${GITEA_USER}" "${GITEA_DATA_DIR}" 2>/dev/null || true local gitea_home; gitea_home=$(eval echo "~${GITEA_USER}") [[ -d "${gitea_home}/.ssh" ]] && \ chmod 700 "${gitea_home}/.ssh" && \ chmod 600 "${gitea_home}/.ssh/"* 2>/dev/null || true log SUCCESS "Permissions ajustees" # ── Redémarrage de Gitea ────────────────────────────────────────────────── if [[ "${GITEA_WAS_RUNNING:-false}" == "true" ]]; then log STEP "Redemarrage du service Gitea" systemctl start gitea && log SUCCESS "Service Gitea redemarre" || \ log WARN "Echec du redemarrage — relancer manuellement : systemctl start gitea" fi hr echo -e "${GREEN}${BOLD} ✔ Restauration Gitea terminee avec succes !${RESET}" hr } # ============================================================================= # LISTING DES SAUVEGARDES # ============================================================================= do_list() { hr echo -e "${BOLD} Sauvegardes disponibles dans : ${BACKUP_DIR}${RESET}\n" local count=0 while IFS= read -r archive; do local fname; fname=$(basename "${archive}") local fsize; fsize=$(du -sh "${archive}" | cut -f1) local fdate; fdate=$(stat -c '%y' "${archive}" | cut -d'.' -f1) printf " ${CYAN}%-50s${RESET} ${YELLOW}%6s${RESET} %s\n" "${fname}" "${fsize}" "${fdate}" count=$(( count + 1 )) done < <(find "${BACKUP_DIR}" -maxdepth 1 -name "gitea_backup_*.tar.gz" | sort -r) [[ "${count}" -eq 0 ]] && echo -e " ${YELLOW}Aucune sauvegarde trouvee.${RESET}" echo "" echo -e " Total : ${BOLD}${count} archive(s)${RESET}" hr } # ============================================================================= # PLANIFICATION CRON # ============================================================================= do_cron() { local script_path; script_path="$(realpath "${BASH_SOURCE[0]}")" local cron_tag="# gitea-backup-auto" local cron_user="root" hr echo -e "${BOLD}${CYAN} Planification des sauvegardes automatiques (Cron)${RESET}" hr echo -e "\n${BOLD} Entrees cron actuelles pour gitea-backup :${RESET}\n" local existing existing=$(crontab -u "${cron_user}" -l 2>/dev/null | grep "${cron_tag}" || true) if [[ -n "${existing}" ]]; then echo "${existing}" | while read -r line; do echo -e " ${YELLOW}${line}${RESET}" done else echo -e " ${YELLOW}Aucune planification active.${RESET}" fi echo "" hr echo -e "\n${BOLD} Choisir une frequence de sauvegarde :${RESET}\n" echo -e " ${CYAN}[1]${RESET} Quotidienne — tous les jours a 03h00" echo -e " ${CYAN}[2]${RESET} Biquotidienne — 2x par jour a 03h00 et 15h00" echo -e " ${CYAN}[3]${RESET} Hebdomadaire — tous les lundis a 03h00" echo -e " ${CYAN}[4]${RESET} Mensuelle — le 1er du mois a 03h00" echo -e " ${CYAN}[5]${RESET} Personnalisee — saisir une expression cron manuellement" echo -e " ${CYAN}[6]${RESET} Supprimer — retirer toutes les planifications" echo -e " ${CYAN}[0]${RESET} Retour au menu principal" echo "" hr local cron_choice while true; do read -rp "$(echo -e "${BOLD} Votre choix [0-6] : ${RESET}")" cron_choice [[ "${cron_choice}" =~ ^[0-6]$ ]] && break echo -e "${RED} Choix invalide.${RESET}" done [[ "${cron_choice}" -eq 0 ]] && return 0 if [[ "${cron_choice}" -eq 6 ]]; then local current_cron current_cron=$(crontab -u "${cron_user}" -l 2>/dev/null \ | grep -v "${cron_tag}" || true) if [[ -n "${current_cron}" ]]; then echo "${current_cron}" | crontab -u "${cron_user}" - else crontab -u "${cron_user}" - <<< "" fi log SUCCESS "Planifications gitea-backup supprimees" return 0 fi local cron_expr="" case "${cron_choice}" in 1) cron_expr="0 3 * * *" ;; 2) cron_expr="0 3,15 * * *" ;; 3) cron_expr="0 3 * * 1" ;; 4) cron_expr="0 3 1 * *" ;; 5) echo -e "\n ${BOLD}Format :${RESET} minute heure jour_mois mois jour_semaine" echo -e " ${YELLOW}Exemples :${RESET}" echo -e " 0 3 * * * -> tous les jours a 03h00" echo -e " 30 2 * * 0 -> tous les dimanches a 02h30" echo -e " 0 4 */2 * * -> tous les 2 jours a 04h00" echo "" while true; do read -rp "$(echo -e "${BOLD} Expression cron : ${RESET}")" cron_expr local field_count; field_count=$(echo "${cron_expr}" | wc -w) [[ "${field_count}" -eq 5 ]] && break echo -e "${RED} Expression invalide (5 champs requis).${RESET}" done ;; esac local mail_opt='MAILTO=""' echo "" read -rp "$(echo -e "${YELLOW} Envoyer les logs cron par email ? (laisser vide pour ignorer) : ${RESET}")" mail_addr [[ -n "${mail_addr}" ]] && mail_opt="MAILTO=${mail_addr}" local cron_line="${cron_expr} ${script_path} backup ${cron_tag}" echo "" hr echo -e " ${BOLD}Entree cron qui sera ajoutee :${RESET}\n" [[ "${mail_opt}" != 'MAILTO=""' ]] && echo -e " ${YELLOW}${mail_opt}${RESET}" echo -e " ${YELLOW}${cron_line}${RESET}" echo "" confirm " Confirmer l'installation de cette tache cron ?" || { log INFO "Planification annulee." return 0 } local clean_cron clean_cron=$(crontab -u "${cron_user}" -l 2>/dev/null \ | grep -v "${cron_tag}" || true) { [[ -n "${clean_cron}" ]] && echo "${clean_cron}" echo "${mail_opt}" echo "${cron_line}" } | crontab -u "${cron_user}" - log SUCCESS "Tache cron installee pour root" if ! systemctl is-active --quiet cron 2>/dev/null && \ ! systemctl is-active --quiet crond 2>/dev/null; then log WARN "Le service cron ne semble pas actif : systemctl start cron" else log INFO "Service cron actif" fi hr echo -e "${GREEN}${BOLD} Planification cron configuree avec succes !${RESET}" hr } # ============================================================================= # AIDE # ============================================================================= do_help() { hr echo -e "${BOLD} gitea-backup.sh — Outil de sauvegarde/restauration Gitea${RESET}\n" echo -e " ${BOLD}Usage :${RESET}" echo -e " sudo $0 backup Creer une sauvegarde complete" echo -e " sudo $0 restore Menu interactif de restauration" echo -e " sudo $0 list Lister les sauvegardes disponibles" echo -e " sudo $0 cron Gerer la planification automatique" echo -e " sudo $0 help Afficher cette aide\n" echo -e " ${BOLD}Configuration (.env.gitea) :${RESET}" echo -e " GITEA_USER Utilisateur systeme qui fait tourner Gitea (defaut: git)" echo -e " GITEA_ROOT Repertoire d'installation du binaire" echo -e " GITEA_WORK_DIR Repertoire de travail Gitea (data, repos...)" echo -e " GITEA_CONF Chemin vers app.ini" echo -e " GITEA_BINARY Chemin vers le binaire gitea" echo -e " BACKUP_DIR Dossier de stockage des archives" echo -e " KEEP_BACKUPS Nombre d'archives a conserver (defaut: 7)" echo -e " INCLUDE_LOG Inclure les logs dans la sauvegarde (true/false)" echo -e " USE_NATIVE_DUMP Utiliser 'gitea dump' natif en complement (true/false)" echo -e " DB_TYPE Forcer le type DB (postgresql|mysql|sqlite3)" echo -e " DB_HOST/PORT Surcharger host/port de la DB" echo -e " DB_NAME/USER/PASS Surcharger les credentials DB\n" echo -e " ${BOLD}Contenu d'une archive :${RESET}" echo -e " database.sql Dump complet de la base de donnees" echo -e " manifest.txt Infos systeme, version, stats depots" echo -e " config/ app.ini + cles SSH" echo -e " repositories.tar.gz Tous les depots Git" echo -e " data.tar.gz Avatars, attachments, LFS" echo -e " native_dump/ Archive gitea dump natif (si active)\n" echo -e " ${BOLD}Journal :${RESET} ${LOG_FILE}" hr } # ============================================================================= # MENU INTERACTIF # ============================================================================= interactive_menu() { while true; do clear hr echo -e "${BOLD}${CYAN}" echo " ██████╗ ██╗████████╗███████╗ █████╗ ██████╗ █████╗ ██████╗██╗ ██╗██╗ ██╗██████╗" echo " ██╔════╝ ██║╚══██╔══╝██╔════╝██╔══██╗ ██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██║ ██║██╔══██╗" echo " ██║ ███╗██║ ██║ █████╗ ███████║ ██████╔╝███████║██║ █████╔╝ ██║ ██║██████╔╝" echo " ██║ ██║██║ ██║ ██╔══╝ ██╔══██║ ██╔══██╗██╔══██║██║ ██╔═██╗ ██║ ██║██╔═══╝" echo " ╚██████╔╝██║ ██║ ███████╗██║ ██║ ██████╔╝██║ ██║╚██████╗██║ ██╗╚██████╔╝██║" echo " ╚═════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝" echo -e "${RESET}${BOLD} Gestionnaire de Sauvegarde Gitea${RESET}" hr # Infos live local gitea_status="inconnu" systemctl is-active --quiet gitea 2>/dev/null && gitea_status="${GREEN}actif${RESET}" \ || gitea_status="${RED}inactif${RESET}" local backup_count; backup_count=$(find "${BACKUP_DIR}" -maxdepth 1 \ -name "gitea_backup_*.tar.gz" 2>/dev/null | wc -l) echo -e " ${BOLD}Config :${RESET} ${GITEA_CONF}" echo -e " ${BOLD}Service Gitea :${RESET} $(echo -e "${gitea_status}") ${BOLD}Archives :${RESET} ${backup_count} disponible(s)" hr echo "" echo -e " ${CYAN}[1]${RESET} ${BOLD}Creer une sauvegarde${RESET} - Sauvegarde complete (DB + depots + donnees + config)" echo -e " ${CYAN}[2]${RESET} ${BOLD}Restaurer${RESET} - Menu interactif de restauration" echo -e " ${CYAN}[3]${RESET} ${BOLD}Lister les sauvegardes${RESET} - Voir les archives disponibles" echo -e " ${CYAN}[4]${RESET} ${BOLD}Planification Cron${RESET} - Gerer les sauvegardes automatiques" echo -e " ${CYAN}[5]${RESET} ${BOLD}Afficher l'aide${RESET} - Documentation complete" echo -e " ${CYAN}[0]${RESET} ${BOLD}Quitter${RESET}" echo "" hr read -rp "$(echo -e "${BOLD} Votre choix : ${RESET}")" choice echo "" case "${choice}" in 1) do_backup ;; 2) do_restore ;; 3) do_list ;; 4) do_cron ;; 5) do_help ;; 0) echo -e "${GREEN} Au revoir !${RESET}\n"; exit 0 ;; *) echo -e "${RED} Choix invalide.${RESET}" ; sleep 1; continue ;; esac echo "" echo -e "${BLUE} Retour au menu dans 3 secondes...${RESET}" sleep 3 done } # ============================================================================= # POINT D'ENTRÉE # ============================================================================= main() { require_root require_command tar require_command gzip load_env local cmd="${1:-menu}" case "${cmd}" in backup) do_backup ;; restore) do_restore ;; list) do_list ;; cron) do_cron ;; help|-h|--help) do_help ;; menu|"") interactive_menu ;; *) echo -e "${RED}Commande inconnue : ${cmd}${RESET}"; do_help; exit 1 ;; esac } main "$@"