diff --git a/rpi-backup.sh b/rpi-backup.sh new file mode 100755 index 0000000..9138e13 --- /dev/null +++ b/rpi-backup.sh @@ -0,0 +1,467 @@ +#!/bin/bash + +#---------------------------------------------------------------------------------------------------- +#~ Nom : rpi-backup +#~ Description : Sauvegarde le contenu d'un Raspberry pi +#~ Auteur : Etienne Girault +#~ +#~ Usage : [-h] [-D] [-V] [-t] [-q] [-o] [-b] [-v] [-c] +#~ Usage : [-f [] +#~ Usage : [-l "DIR1 DIR2 DIR3"...] or [-l DIR1,DIR2,DIR3...] +#~ +#~ Options : -h, --help ; Affiche l'aide +#~ Options : -V, --version ; Affiche la version +#~ Options : -D, --debug ; Mode debug +#~ Options : -f, --file=[FILE] ; Détermine le chemin de la sauvegarde +#~ Options : -t, --test ; Mode test +#~ Options : -q, --quiet ; Mode silencieux +#~ Options : -l, --list ; Spécifie les répertoires à sauvegarder +#~ Options : -p, --print ; Affiche la taille et le chemin de la dernière sauvegarde +#~ Options : -b, --background ; Lancement du script en arrière-plan +#~ Options : -v, --view ; Affiche les derniers logs +#~ Options : -c, --configure ; Configure les variables du script + +#---------------------------------------------------------------------------------------------------- +# Déclaration des variables +#---------------------------------------------------------------------------------------------------- +script_name="$(grep '^#~ Nom' "$0" | sed 's/^.*: //')" +script_version="$(grep '^#~ v' "$0" | tail -1 | sed -e 's/^#~ //' -e 's/ *\-.*$//')" + +backup_user=backup +host_name=$(hostname -s) + +# Répertoire où la sauvegarde sera déposée +backup_dir=/backup +# Nom et chemin de la sauvegarde +backup_name=backup_${host_name}_$(date +"%Y%m%d") +backup_path="${backup_dir}/${backup_name}" + +# Répertoire où les listes des exports sera déposée +export_dir=${backup_dir}/export +# Localisation des fichiers config des repo git (dans un tableau pour prendre en compte '*') +git_configs=(/appli/source/*/.git/config) + +# Répertoires depuis la racine à sauvegarder +saved_dir="appli/data backup/export boot etc home root seedbox srv" + +# Répertoire d'archive +archive_dir=/data/backup +# Répertoire des anciennes archives +old_archive_dir=${archive_dir}/old + +# Fichier de log +log_file=/var/log/${script_name}.log + +# Variables configurables +configurable_vars="backup_user backup_dir export_dir archive_dir old_archive_dir saved_dir git_configs log_file" + +#---------------------------------------------------------------------------------------------------- +# Déclaraition des fonctions +#---------------------------------------------------------------------------------------------------- +print_script_name_version() { echo "$script_name $script_version" && sed -n '/^#~ Version/,$p' "$0" | grep '^#~' | sed 's/^#~ //'; } +print_script_description() { grep '^#~ Description' "$0" | sed 's/^.*: //'; } +print_script_usage() { grep '^#~ Usage' "$0" | sed -e 's/^.*: //' -e '1 s/^/Usage: '"$script_name"' /' -e '2,$ s/^/'"$(printf '%*s' "$(echo "Usage: $script_name" | wc -c)")"'/'; } +print_script_options() { echo "Options" && grep '^#~ Options' "$0" | sed -e 's/^.*://' -e 's/^ --/ --/' | column -ts \;; } +print_script_help() { echo "$script_name $script_version" && print_script_description && print_script_usage && print_script_options; } + +# Séquence de test avant le lancement du script +prelaunch_test() { + # Relance le script avec droits sudo si pas deja fait + [[ "$(whoami)" = root ]] || { sudo "$0" "$@"; exit; } + + # Détection du mode interactif ou silencieux + [[ ! "$mode_quiet" && -t 0 && -t 1 ]] && mode_interactive=0 + + # Décalage des arguments en fonction du nombre d'options choisies + shift $((OPTIND-1)) + # Si un argument reste en fin de chaîne, c'est qu'il y a un problème sur le nombre d'argument + [[ "$1" ]] && argument_error="$1" + # Si une erreur d'argument a été trouvée, l'affiche et sort du script + if [[ "$argument_error" ]]; then + msg_log 3 "Argument incompris: $argument_error" + exit 1 + fi + + # Test si l'utilisateur de backup existe, active le mode standalone sinon + if ! id "$backup_user" &>/dev/null; then + msg_log 3 "utilisateur $backup_user absent sur le système" + mode_standalone=0 + fi + + # Vérifie si un chemin de sauvegarde personnalisé a été demandé + custom_backup_path 1 + + # Test la présence du répertoire de sauvegarde (création si besoin) + test_directory "$backup_dir" 0 + # Test la présence du répertoire d'export (création si besoin) + test_directory "$export_dir" 0 + # Test la présence du répertoire d'archive, active le mode standalone sinon + test_directory "$archive_dir" || mode_standalone=0 + test_directory "$old_archive_dir" 0 + + # Test de la présence des répertoires à sauvegarder, les supprime de la liste si absent + for directory in $saved_dir; do + test_directory "/${directory}" || saved_dir="${saved_dir/$directory/}" + done + + # Si le dossier de log n'existe pas, active le flag no_logging + test_directory "$(dirname $log_file)" || no_logging=0 + + # Test des commandes d'export, affiche son absence si c'est le cas + test_command dpkg || msg_log 3 "l'outil dpkg est introuvable" + test_command pip2 || msg_log 3 "l'outil pip2 est introuvable" + test_command pip3 || msg_log 3 "l'outil pip3 est introuvable" + + # Mode test pour dev + [[ "$mode_test" ]] && fonction_test "$@" + return 0 +} + +# Test la présence d'une commande, retourne 1 si elle n'existe pas +# $1 : Commande testée +test_command() { type "$1" &>/dev/null && return 0 || return 1; } + +# Test la présence d'un réperoire, la possiblité de le créer, retourne 1 si pas présent/créé +# $1 : Répertoire testé +# [$2] : Si initialisée, le répertoire sera créé si possible +test_directory() { + # si le répertoire n'existe pas + [[ -d "$1" ]] && return + # si $2 est initialisée et si le répertoire parent existe + if [[ "$2" && -d "$(dirname $1)" ]]; then + # demande de creation du répertoire + if ask_question "Répertoire $1 absent, dois-je le créer ?"; then + msg_log 1 "Création du répertoire $1" + msg_log 2 "mkdir $1" + return 0 + # si l'utilisateur répond non, retourne 1 + else return 1; fi + # sinon, $2 n'est pas initialisée ou le répertoire parent n'existe pas, retourne 1 + else return 1; fi +} + +# Pose une question à l'utilisateur, si pas intéractif, la réponse est oui par défaut +# $1 : Question posée +ask_question() { + [[ ! "$mode_interactive" ]] && return 0 + echo -e "$1 \c" + read -r -n 1 question_answer < /dev/tty + echo "" + case $question_answer in + o|O|y|Y) return 0;; + *) return 1;; + esac +} + +# Affiche les étapes du script sur la sortie standard en mode interactif ou le fichier de log en mode silencieux +# $1 : Mode (0=message, 1=début de tâche, 2=fin de tache, 3=message sur le stderr, 4=debut/fin de script) +# $2 : Message à afficher ou code retour de la précédente commande +msg_log() { + # Si mode interactif, affichage des messages sur la console + + if [[ "$mode_interactive" ]]; then + case $1 in + 0) echo "$2";; + 1) echo -e "${2} \c";; + 2) if eval "$2" &> /dev/null; then + echo -e "\033[32;1mOK\033[0m" + else + echo -e "\033[31;1mErreur\033[0m" + fi;; + 3) echo "$2" >&2;; + esac + # Sinon, mode non interactif, affichage des messages sur le fichier de log + else + [[ "$no_logging" ]] && return 0 + format_date="$(date "+%h %d %T") ${host_name}:" + case $1 in + 0|3) echo "$format_date $2" >> "$log_file";; + 1) echo -e "$format_date ${2} \c" >> "$log_file";; + 2) if eval "$2" &> /dev/null; then + echo -e "\033[32;1mOK\033[0m" >> "$log_file" + else + echo -e "\033[31;1mErreur\033[0m" >> "$log_file" + fi;; + 4) echo "$format_date --- $2" >> "$log_file";; + esac + fi +} + +# Export des listes de paquets/librairies/repos installés dans le dossier d'export +export_lists() { + msg_log 1 "Export des listes de paquets/librairies/repos" + # Export de la liste des paquets apt + test_command dpkg && dpkg --get-selections > "${export_dir}/apt.list" + # Export de la liste des librairies python2 + test_command pip2 && pip2 freeze > "${export_dir}/pip2.list" + # Export de la liste des librairies python3 + test_command pip3 && pip3 freeze > "${export_dir}/pip3.list" + # Extraction des URLs des repos github + ls ${git_configs[*]} >/dev/null 2>&1 && sed -n '/url/p' ${git_configs[*]} | sed 's/^.*= //' > "${export_dir}/appli.list" + msg_log 2 + return 0 +} + +# Création de la sauvegarde +create_backup() { + # Suppression d'éventuel reliquat d'archive + [[ -f "$backup_path".tar ]] && rm -f "$backup_path".tar + # Création du fichier de sauvegarde et application du user de backup + touch "$backup_path".tar + [[ ! "$mode_standalone" ]] && chown "${backup_user}:${backup_user}" "$backup_path".tar + + # Sauvegarde de la liste des répertoires dans backup_name.tar dans le backup_dir + cd / || exit + for directory in $saved_dir; do + msg_log 1 "Sauvegarde du dossier /$directory" + msg_log 2 'tar --append --warning=no-file-ignored --file "$backup_path".tar "$directory"' + done + + msg_log 1 "Compression de la sauvegarde" + # Compression de l'archive tar en tar.gz + [[ -f "$backup_path".tar ]] && msg_log 2 'gzip -1 "$backup_path".tar' + [[ -f "$backup_path".tar.gz ]] && mv "$backup_path".t{ar.gz,gz} + + return 0 +} + +# Supprime les précédentes sauvegardes du dossier de sauvegarde +clear_backup() { + [[ "$mode_standalone" ]] && return + msg_log 1 "Suppression des précédentes sauvegardes du dossier $backup_dir" + msg_log 2 'find "$backup_dir" -maxdepth 1 ! -name "$backup_name".tgz -user "$backup_user" -print0 | xargs -r0 rm' + return 0 +} + + +# Rotations des sauvegardes réalisées par le user backup +rotate_backup() { + # Si le répertoire d'archive est injoignable, retour + [[ "$mode_standalone" ]] && return + msg_log 1 "Rotations des sauvegardes dans les archives $archive_dir" + for day in 01 10 20; do + # Déplace les sauvegardes de ce serveur datant du 01, 10 et 20 du mois, qui ont plus de 3 jours, du dossier archives vers les anciennes archives + su "$backup_user" -s /bin/bash -c "find $archive_dir -maxdepth 1 -name *${host_name}*${day}.tgz -type f -mtime +1 -print0 | xargs -r0 mv -t $old_archive_dir" + # Supprime les sauvegardes de ce serveur qui ont plus de 30 jours du dossier des anciennes archives + [[ ! "$day" = 01 ]] && su "$backup_user" -s /bin/bash -c "find $old_archive_dir -maxdepth 1 -name *${host_name}*${day}.tgz -mtime +30 -type f -print0 | xargs -r0 rm" + done + # Supprime les sauvegardes de ce serveur de plus de 5 jours du dossier d'archive + msg_log 2 'su "$backup_user" -s /bin/bash -c "find $archive_dir -maxdepth 1 -name *${host_name}* -type f -mtime +1 -print0 | xargs -r0 rm"' + + msg_log 1 "Copie la sauvegarde $backup_name dans le dossier d'archive $archive_dir" + # Copie la sauvegarde dans le dossier d'archive + msg_log 2 'su "$backup_user" -s /bin/bash -c "cp $backup_path.tgz $archive_dir"' + return 0 +} + +test_nextarg() { + [[ "${1::1}" = "-" ]] && argument_error="$1" + return 0 +} + +# Spécifie au script un chemin de sauvegarde personnalisé +# $1 : Mode (0=Initialise la variable backup_path avec $2, 1=Vérifie l'état) +# $2 : valeur du backup_path demandé +custom_backup_path() { + # Si la fonction est appelé avec un argument + case $1 in + 0) # $1 = 0, le backup_path est associé à $2 + backup_path="$2" + # Le drapeau custom_backup_path_set est levé + custom_backup_path_set=0;; + 1) # $1 = 1, on vérifie l'état des drapeaux, si custom_backup_path_flag pas levé, on retourne 0 + [[ ! "$custom_backup_path_flag" ]] && return 0 + # Si mode interactif + if [[ "$mode_interactive" ]]; then + # Si custom_backup_path_set n'est pas levé, on demande a l'utilisateur la valeur de backup_path + if [[ ! "$custom_backup_path_set" ]]; then + echo -e "Chemin de la sauvegarde ? \c" + read -r backup_path < /dev/tty; + fi + # Si mode interactif + # Tant que le répertoire de la valeur de backup_path n'existe pas, on redemande la valeur de backup_set à l'utilisateur + until test_directory "$(dirname $backup_path)"; do + echo -e "Chemin de sauvegarde invalide, réessaye :\c" + read -r backup_path < /dev/tty; + done + # Sinon, mode silencieux + else + # Si le drapeau custom_backup_path_set est levé + if [[ "$custom_backup_path_set" ]]; then + # Si le répertoire de la valeur de backup_path n'existe pas, affiche l'erreur sur les logs et quitte le script + test_directory "$(dirname $backup_path)" || { msg_log 3 "Le chemin de la sauvegarde $backup_path n'est pas valable"; exit 1;} + # Sinon, le drapeau custom_backup_path_set n'est pas levé, incompatible en mode silencieux, affiche l'erreur sur les logs et quitte le script + else + msg_log 3 "Le chemin de la sauvegarde $backup_path n'est pas spécifié" + exit 1; + fi + fi + # Si le backup_path est donné en relatif, transformation en chemin absolu + [[ ! "${backup_path::1}" = "/" ]] && backup_path="$(pwd)/$backup_path" + return 0;; + esac + # Le drapeau custom_backup_path_path est levé + custom_backup_path_flag=0 + mode_standalone=0 + return 0 +} + +# Sauvegarde les bases MySQL/MariaDB si le service est lancé +save_mysql() { + # Test si le service MySQL est démarré, retourne 0 sinon + systemctl is-active mysql --quiet || return 0 + msg_log 1 "Sauvegarde des bases MySQL" + # Dump des bases de données dans le répertoire d'export + msg_log 2 'mysqldump --all-databases > "${export_dir}/mysql-dump.sql"' + return 0 +} + +# Affiche la taille et le nom de la dernière sauvegarde +print_backup_name() { ls -lth "$backup_dir"/*.tgz | head -1 | awk '{printf "%-6s %s\n", $5, $9}'; } + +# Lance un éditeur de texte avec les variables configurables +configure_variable() { + mode_interactive=0 + # Crée un fichier de configuration temporaire + conf_file=$(mktemp) + # Affiche chaque variable dans le fichier de configuration + for each_variable in $configurable_vars; do + sed -n "/^${each_variable}=/p" "$0" >> "$conf_file" + done + # Edite le fichier de configuration + vi "$conf_file" + # Changement de l'IFS pour la lecture du fichier de configuration + IFS='=' + # Pour chaque ligne du fichier de configuration + while read -r the_variable new_value; do + # Vérifie si la variable écrite sur le fichier est modifiable + if echo "$configurable_vars" | grep -q "$the_variable"; then + # Si la valeur de la variable a été modifiée + if ! grep -q -F "$the_variable=$new_value" "$0"; then + # Demande confirmation à l'utilisateur pour modifier la variable + if ask_question "Modifier la variable '$the_variable=$new_value' ?"; then + # script_path=$(readlink -f "$0") + msg_log 1 "Modification de la variable '$the_variable'" + # Modifie la valeur de la variable directement dans le script + msg_log 2 'sed -i "s#^$the_variable=.*#$the_variable=$new_value#" "$(readlink -f $0)"' + fi + fi + else + msg_log 0 "Impossible de changer la variable '$the_variable'" + fi + done < "$conf_file" + # Supprime le fichier de configuration temporaire + rm "$conf_file" +} + +# Affiche les derniers logs +view_log() { + # Cherche le motif '--- Début', le stock quand il le trouve et ajoute au stock, si + sed -n '/--- Début/h;/--- Début/!H;$!b;x;p' "$log_file" +} + +# Fonction de test pour le developpement +fonction_test() { + + sed -n '/url/p' ${git_configs[*]} | sed 's/^.*= //' + msg_log 0 "test ok" + exit 0 +} + +#---------------------------------------------------------------------------------------------------- +# Début du script +#---------------------------------------------------------------------------------------------------- + +main() { + prelaunch_test "$@" + + msg_log 4 "Début de la sauvegarde" + + export_lists + save_mysql + create_backup + clear_backup + rotate_backup + + msg_log 4 "Fin de la sauvegarde" + + exit 0 +} + +#---------------------------------------------------------------------------------------------------- +# Gestion des options +#---------------------------------------------------------------------------------------------------- +while getopts ":-:hVDf:tql:pbvc" OPTION; do + case $OPTION in + h) print_script_help; exit;; + V) print_script_name_version; exit;; + D) set -x;; + f) test_nextarg "$OPTARG" && custom_backup_path 0 "$OPTARG";; + t) mode_test=0;; + q) mode_quiet=0;; + l) test_nextarg "$OPTARG" && saved_dir="${OPTARG//,/ }"; custom_backup_path;; + p) print_backup_name; exit;; + b) nohup "$script_name" "-q" "${@/-b/}" &> /dev/null & exit;; + v) view_log; exit;; + c) configure_variable; exit;; + :) [ "$OPTARG" = "f" ] && custom_backup_path;; + \?) echo "-$OPTARG: option invalide" && print_script_usage; exit;; + -) case $OPTARG in + help) print_script_help; exit;; + version) print_script_name_version; exit;; + debug) set -x;; + file) custom_backup_path 0 "$OPTARG";; # VERIF + test) mode_test=0;; + quiet) mode_quiet=0;; + list) saved_dir="${OPTARG//,/ }"; custom_backup_path;; + print) print_backup_name; exit;; + background) nohup "$script_name" "-q" "${@/-b/}" &> /dev/null & exit;; + view) view_log; exit;; + configure) configure_variable; exit;; + *) echo "--$OPTARG: option invalide" && print_script_usage; exit;; + esac ;; + esac +done + +# Reception de signaux d'interruptions +trap 'exit 1' INT TERM + +set -o errexit + +main "$@" + +#---------------------------------------------------------------------------------------------------- +# Fin du script +#---------------------------------------------------------------------------------------------------- + +#~ Version changelog +#~ v0.1 - Création du script +#~ v0.2 - Sauvegarde totale de la carte SD (~25min) +#~ v0.2.1 - Sauvegarde uniquement les répertoires utiles à la restauration (~2min) +#~ v0.2.2 - Nommage de la sauvegarde en fonction de la date +#~ v0.2.3 - Ajout des exports des listes de paquets/librairies/URLs github +#~ v0.2.4 - Ajout de l'option file pour définir un chemin à la sauvegarde +#~ v0.3 - Réécriture du script suivant les best practice bash +#~ v0.4 - Ajout des fonctions test_command et test_directory +#~ v0.4.1 - Ajout de la fonction ask_question +#~ v0.4.2 - Ajout du mode verbeux et interactif +#~ v0.4.3 - Ajout du mode standalone et la creation des répertoires nécessaires au script +#~ v0.5 - Utilisable multi serveur +#~ v0.5.1 - Mode verbeux par défaut en interactif, ajout du mode silencieux +#~ v0.5.2 - Refonte de la fonction msg_log +#~ v0.5.3 - Ajout du logging en mode non interactif +#~ v0.6 - Ajout de l'option list pour spécifier les répertoires à sauvegarder, à associer à l'option file +#~ v0.6.1 - Ajout de la fonction custom_backup_path pour gérer les erreurs d'un backup_path personnalisé +#~ v0.6.2 - Ajout de la fonction test_nextarg pour gérer les problèmes d'argument +#~ v1.0 - Publication pour mise en fonctionnement +#~ v1.1 - Ajout de la sauvegarde des bases MySQL/MariaDB +#~ v1.1.1 - Modification de la fonction msg_log pour la prise en compte du code retour +#~ v1.2 - Ajout de l'option print pour l'affichage de la taille et du nom de la dernière sauvegarde +#~ v1.3 - Ajout de l'option background pour le lancement en arriere plan +#~ v1.4 - Ajout de l'option view pour le l'affichage des derniers logs +#~ v1.5 - Ajout de l'option configure pour la reconfiguration des variables +#~ v1.5.1 - Améliorations de synthaxe +#~ v1.5.2 - Correction de l'utilisation de la variable git_config + + +# TODO - Restauration