#!/usr/bin/env bash # ============================================================================= # sauvegarde-cle-usb — sauvegarde automatique vers UNE clé USB précise # ----------------------------------------------------------------------------- # Lancé par systemd (sauvegarde-cle-usb.service) quand udev voit apparaître # la clé déclarée dans /etc/sauvegarde-cle-usb.conf. Peut aussi être lancé # à la main pour tester : # # sudo systemctl start sauvegarde-cle-usb (recommandé : journal systemd) # sudo sauvegarde-cle-usb --simulation (montre ce qui serait fait) # sudo sauvegarde-cle-usb (sauvegarde réelle) # # Rappel : une sauvegarde « miroir » recopie aussi les suppressions. # C'est pourquoi, par défaut, tout ce qui disparaît ou change sur la clé # part d'abord dans /.corbeille/AAAA-MM-JJ_HHMMSS (gardée N jours). # # Mode test (sans sudo, sans clé, sans udev) : # SAUVEGARDE_TEST_DESTINATION=/tmp/fausse-cle \ # SAUVEGARDE_CONF=./ma-conf-de-test.conf ./sauvegarde-cle-usb # Le dossier indiqué joue le rôle de la racine de la clé : pas de montage, # pas de vérification d'UUID, notifications affichées dans le terminal. # SAUVEGARDE_TEST_FSTYPE=vfat permet de simuler une clé FAT. # ============================================================================= set -euo pipefail export LC_ALL=C.UTF-8 # messages et formats stables (dates, df) umask 022 NOM="sauvegarde-cle-usb" CONF="${SAUVEGARDE_CONF:-/etc/sauvegarde-cle-usb.conf}" POINT_MONTAGE_DEDIE="/run/sauvegarde-cle-usb" TEST_DESTINATION="${SAUVEGARDE_TEST_DESTINATION:-}" SIMULATION=0 # Variables d'état (utilisées par le nettoyage final) MONTE_PAR_NOUS=0 RACINE_CLE="" FICHIER_SORTIE="" RESULTAT_ECRIT=0 CLE_VERIFIEE=0 # passe à 1 quand on est sûr d'être sur la bonne clé UTILISATEUR_UID="" # ----------------------------------------------------------------------------- # Petites fonctions utilitaires # ----------------------------------------------------------------------------- journal() { printf '%s\n' "$*"; } # stdout -> journal systemd erreur() { printf 'ERREUR : %s\n' "$*" >&2; } aide() { cat <<'FIN' Utilisation : sauvegarde-cle-usb [--simulation] [--aide] --simulation montre ce qui serait copié/supprimé, sans rien modifier --aide affiche ce message Configuration : /etc/sauvegarde-cle-usb.conf Journal : journalctl -u sauvegarde-cle-usb FIN } # Notification de bureau dans la session graphique de l'utilisateur. # Ne fait jamais échouer le script (une notification ratée n'est pas grave). notifier() { local urgence="$1" titre="$2" message="$3" journal "[notification] $titre — $message" if [[ -n "$TEST_DESTINATION" ]]; then return 0 # mode test : terminal seulement fi [[ -n "$UTILISATEUR_UID" ]] || return 0 local bus="/run/user/$UTILISATEUR_UID/bus" [[ -S "$bus" ]] || return 0 # personne n'est connecté command -v notify-send >/dev/null 2>&1 || return 0 timeout 10 runuser -u "$UTILISATEUR" -- env \ DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ notify-send --app-name="Sauvegarde" --icon=drive-removable-media \ --urgency="$urgence" "$titre" "$message" >/dev/null 2>&1 || true } # Écrit le petit compte rendu lisible sur la clé. ecrire_resultat() { local resultat="$1" [[ "$CLE_VERIFIEE" -eq 1 && -d "$RACINE_CLE" ]] || return 0 [[ "$SIMULATION" -eq 0 ]] || return 0 local f="$RACINE_CLE/derniere-sauvegarde.txt" { echo "Dernière sauvegarde : $(date '+%d/%m/%Y à %H:%M:%S')" echo "Ordinateur : $(hostname)" echo "Résultat : $resultat" echo "Dossiers sauvegardés :" local s for s in "${SOURCES[@]}"; do echo " - $s"; done echo "Copie sur la clé : $DOSSIER_DESTINATION/" if [[ "${CORBEILLE_JOURS:-0}" -gt 0 ]]; then echo "Fichiers supprimés ou remplacés : .corbeille/ (gardés $CORBEILLE_JOURS jours)" fi if [[ -n "$FICHIER_SORTIE" && -s "$FICHIER_SORTIE" ]]; then echo echo "--- Résumé rsync ---" grep -E '^(Number of|Total file size|Total transferred|Literal data|sent |total size)' \ "$FICHIER_SORTIE" || true fi } > "$f" 2>/dev/null || true proprietaire_utilisateur "$f" RESULTAT_ECRIT=1 } # Sur une clé ext4, rend à l'utilisateur ce que root a créé (il pourra le # gérer depuis Nemo). Sur FAT/exFAT/NTFS, c'est l'option de montage uid= qui # s'en charge. proprietaire_utilisateur() { [[ -z "$TEST_DESTINATION" ]] || return 0 [[ "${TYPE_FS:-}" == "ext"* ]] || return 0 [[ "$(id -u)" -eq 0 ]] || return 0 chown "$UTILISATEUR:" "$@" 2>/dev/null || true } nettoyage() { local code=$? set +e if [[ "$code" -ne 0 && "$RESULTAT_ECRIT" -eq 0 ]]; then ecrire_resultat "ÉCHEC (code $code) — voir : journalctl -u $NOM" fi [[ -n "$FICHIER_SORTIE" ]] && rm -f "$FICHIER_SORTIE" if [[ -n "$RACINE_CLE" ]]; then sync -f "$RACINE_CLE/." 2>/dev/null || sync fi if [[ "$MONTE_PAR_NOUS" -eq 1 ]]; then if umount "$POINT_MONTAGE_DEDIE" 2>/dev/null; then journal "Clé démontée de $POINT_MONTAGE_DEDIE." else # Clé arrachée ou encore occupée : démontage « paresseux ». umount -l "$POINT_MONTAGE_DEDIE" 2>/dev/null journal "Démontage différé de $POINT_MONTAGE_DEDIE." fi rmdir "$POINT_MONTAGE_DEDIE" 2>/dev/null fi exit "$code" } # Arrête tout, avec notification et message clair. abandon() { erreur "$1" notifier critical "Sauvegarde impossible" "$1" exit "${2:-1}" } # ----------------------------------------------------------------------------- # Lecture des options et de la configuration # ----------------------------------------------------------------------------- for arg in "$@"; do case "$arg" in --simulation|-n) SIMULATION=1 ;; --aide|--help|-h) aide; exit 0 ;; *) erreur "option inconnue : $arg"; aide >&2; exit 2 ;; esac done trap nettoyage EXIT if [[ -z "$TEST_DESTINATION" && "$(id -u)" -ne 0 ]]; then erreur "ce script doit être lancé par root (sudo $NOM, ou sudo systemctl start $NOM)." exit 1 fi [[ -r "$CONF" ]] || { erreur "configuration introuvable : $CONF (lancez installer.sh)"; exit 1; } # La configuration est exécutée par root : elle doit appartenir à root et ne # pas être modifiable par d'autres (sinon n'importe qui pourrait y glisser # une commande). if [[ -z "$TEST_DESTINATION" ]]; then if [[ "$(stat -c '%u' "$CONF")" != 0 ]] || [[ -n "$(find "$CONF" -perm /022)" ]]; then erreur "$CONF doit appartenir à root et n'être modifiable que par lui (chmod 644)." exit 1 fi fi # Valeurs par défaut, puis lecture du fichier UUID="" UTILISATEUR="" SOURCES=() DOSSIER_DESTINATION="Sauvegarde" CORBEILLE_JOURS=30 EXCLUSIONS=() VERIFIER_ESPACE="oui" # shellcheck source=sauvegarde-cle-usb.conf . "$CONF" # --- Contrôles de la configuration ------------------------------------------- [[ -n "$UTILISATEUR" ]] || { erreur "UTILISATEUR est vide dans $CONF"; exit 1; } id -u "$UTILISATEUR" >/dev/null 2>&1 || { erreur "utilisateur inconnu : $UTILISATEUR"; exit 1; } UTILISATEUR_UID="$(id -u "$UTILISATEUR")" UTILISATEUR_GID="$(id -g "$UTILISATEUR")" [[ "$CORBEILLE_JOURS" =~ ^[0-9]+$ ]] || abandon "CORBEILLE_JOURS doit être un nombre (0 = pas de corbeille)." if [[ "${#SOURCES[@]}" -eq 0 ]]; then abandon "aucun dossier à sauvegarder (SOURCES est vide dans $CONF)." fi # Chaque source doit exister, être un dossier, et ne pas être vide : un # dossier personnel vide (disque non monté, dossier chiffré verrouillé...) # combiné à --delete viderait la clé ! declare -A NOMS_VUS=() SOURCES_PROPRES=() for s in "${SOURCES[@]}"; do [[ -n "$s" ]] || abandon "une entrée de SOURCES est vide." [[ -d "$s" ]] || abandon "le dossier à sauvegarder n'existe pas : $s" s="$(realpath -e -- "$s")" [[ "$s" != "/" ]] || abandon "sauvegarder / entier n'est pas prévu par cet outil." if [[ -z "$(find "$s" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then abandon "le dossier $s est vide : sauvegarde annulée par prudence." fi base="$(basename -- "$s")" if [[ -n "${NOMS_VUS[$base]:-}" ]]; then abandon "deux dossiers de SOURCES portent le même nom « $base » : ils s'écraseraient sur la clé." fi NOMS_VUS[$base]=1 SOURCES_PROPRES+=("$s") done SOURCES=("${SOURCES_PROPRES[@]}") # DOSSIER_DESTINATION : un nom de dossier simple, relatif à la racine de la clé. case "$DOSSIER_DESTINATION" in ""|"/"|"."|".."|/*|*/../*|../*|*/..|.corbeille|.corbeille/*) abandon "DOSSIER_DESTINATION invalide : « $DOSSIER_DESTINATION » (exemple : Sauvegarde)." ;; esac # ----------------------------------------------------------------------------- # Verrou : jamais deux sauvegardes en même temps # ----------------------------------------------------------------------------- if [[ -n "$TEST_DESTINATION" ]]; then VERROU="${TMPDIR:-/tmp}/$NOM-test.lock" else VERROU="/run/lock/$NOM.lock" fi exec 9>"$VERROU" if ! flock -n 9; then erreur "une sauvegarde est déjà en cours." exit 0 fi # ----------------------------------------------------------------------------- # Trouver (ou monter) la clé # ----------------------------------------------------------------------------- if [[ -n "$TEST_DESTINATION" ]]; then journal "*** MODE TEST : la clé est simulée par $TEST_DESTINATION ***" [[ -d "$TEST_DESTINATION" ]] || abandon "destination de test introuvable : $TEST_DESTINATION" RACINE_CLE="$(realpath -e -- "$TEST_DESTINATION")" [[ "$RACINE_CLE" != "/" ]] || abandon "destination de test « / » refusée." TYPE_FS="${SAUVEGARDE_TEST_FSTYPE:-$(findmnt -n -o FSTYPE --target "$RACINE_CLE" | head -n1)}" else [[ "$UUID" =~ ^[A-Za-z0-9-]+$ ]] || abandon "UUID absent ou invalide dans $CONF." PERIPH_LIEN="/dev/disk/by-uuid/$UUID" # udev peut nous lancer une fraction de seconde avant que le lien existe. for _ in $(seq 1 20); do [[ -b "$PERIPH_LIEN" ]] && break sleep 0.5 done [[ -b "$PERIPH_LIEN" ]] || abandon "la clé de sauvegarde (UUID $UUID) n'est pas branchée." PERIPH="$(readlink -f "$PERIPH_LIEN")" TYPE_FS="$(blkid -o value -s TYPE "$PERIPH" 2>/dev/null || true)" [[ -n "$TYPE_FS" ]] || abandon "système de fichiers illisible sur $PERIPH." notifier normal "Sauvegarde en cours…" "Clé détectée. Ne la retirez pas avant le message « Sauvegarde terminée »." # Choix : on monte la clé NOUS-MÊMES sur un point dédié, même si # Nemo/udisks l'a déjà montée dans /media//. Linux accepte # plusieurs montages du même système de fichiers ; ainsi le chemin est # connu, les options maîtrisées, et un « Éjecter » dans Nemo ne coupe # pas la sauvegarde en plein milieu (udisks voit la clé encore utilisée). mkdir -p "$POINT_MONTAGE_DEDIE" chmod 755 "$POINT_MONTAGE_DEDIE" if mountpoint -q "$POINT_MONTAGE_DEDIE"; then # Reste d'une exécution interrompue : on vérifie que c'est la bonne clé. if [[ "$(findmnt -n -o UUID --mountpoint "$POINT_MONTAGE_DEDIE")" != "$UUID" ]]; then abandon "$POINT_MONTAGE_DEDIE est occupé par un autre disque." fi MONTE_PAR_NOUS=1 else case "$TYPE_FS" in vfat|exfat|ntfs|ntfs3) OPTS="rw,nosuid,nodev,noatime,uid=$UTILISATEUR_UID,gid=$UTILISATEUR_GID,umask=022" ;; *) OPTS="rw,nosuid,nodev,noatime" ;; esac if mount -o "$OPTS" "$PERIPH" "$POINT_MONTAGE_DEDIE"; then MONTE_PAR_NOUS=1 RACINE_CLE="$POINT_MONTAGE_DEDIE" else # Plan B : réutiliser un montage existant (udisks) en écriture. rmdir "$POINT_MONTAGE_DEDIE" 2>/dev/null || true EXISTANT="$(findmnt -n -o TARGET,OPTIONS --source "$PERIPH" | awk '$2 ~ /(^|,)rw(,|$)/ {print $1; exit}')" [[ -n "$EXISTANT" ]] || abandon "impossible de monter la clé ($PERIPH, $TYPE_FS)." journal "Montage dédié impossible, réutilisation du montage existant : $EXISTANT" RACINE_CLE="$EXISTANT" fi fi RACINE_CLE="${RACINE_CLE:-$POINT_MONTAGE_DEDIE}" fi # ----------------------------------------------------------------------------- # GARDE-FOU : la destination doit être SUR la clé, et nulle part ailleurs # ----------------------------------------------------------------------------- # Un rsync --delete vers un mauvais dossier effacerait des données : on # vérifie tout, et on refuse au moindre doute. DESTINATION="$RACINE_CLE/$DOSSIER_DESTINATION" if [[ -z "$TEST_DESTINATION" ]]; then mountpoint -q "$RACINE_CLE" || abandon "la clé n'est pas montée sur $RACINE_CLE." UUID_MONTE="$(findmnt -n -o UUID --mountpoint "$RACINE_CLE" | head -n1)" [[ "$UUID_MONTE" == "$UUID" ]] || abandon "$RACINE_CLE n'est pas la clé attendue (UUID $UUID_MONTE au lieu de $UUID)." fi if [[ "$SIMULATION" -eq 0 ]]; then mkdir -p -- "$DESTINATION" || abandon "impossible de créer $DESTINATION (clé protégée en écriture ?)" proprietaire_utilisateur "$DESTINATION" fi if [[ -e "$DESTINATION" ]]; then DEST_REELLE="$(realpath -e -- "$DESTINATION")" else DEST_REELLE="$(realpath -m -- "$DESTINATION")" fi case "$DEST_REELLE/" in "$RACINE_CLE"/?*/) ;; # bien un sous-dossier de la racine de la clé *) abandon "destination hors de la clé : $DEST_REELLE (refusé)." ;; esac if [[ -z "$TEST_DESTINATION" && -e "$DEST_REELLE" ]]; then # Même un lien symbolique piégé ne doit pas nous faire sortir de la clé. [[ "$(findmnt -n -o UUID --target "$DEST_REELLE" | head -n1)" == "$UUID" ]] \ || abandon "la destination $DEST_REELLE n'est pas sur la clé (refusé)." fi for s in "${SOURCES[@]}"; do case "$DEST_REELLE/" in "$s"/*) abandon "la destination est à l'intérieur de $s (refusé)." ;; esac case "$s/" in "$DEST_REELLE"/*) abandon "$s est à l'intérieur de la destination (refusé)." ;; esac done DESTINATION="$DEST_REELLE" CLE_VERIFIEE=1 journal "Clé : $RACINE_CLE ($TYPE_FS) — copie dans : $DESTINATION" # ----------------------------------------------------------------------------- # Options rsync # ----------------------------------------------------------------------------- HORODATAGE="$(date '+%Y-%m-%d_%H%M%S')" CORBEILLE_RACINE="$RACINE_CLE/.corbeille" CORBEILLE="$CORBEILLE_RACINE/$HORODATAGE" OPTS_RSYNC=(--delete --one-file-system --human-readable --stats --info=del1 --info=name0) case "$TYPE_FS" in ext2|ext3|ext4|btrfs|xfs|f2fs) # Système de fichiers Linux : on garde tout (droits, propriétaires, # dates, liens symboliques) ; -H : liens physiques. OPTS_RSYNC+=(-a -H) ;; vfat|exfat|ntfs|ntfs3|fuseblk) # FAT/exFAT/NTFS : ni droits ni propriétaires ; dates à 2 s près. # Les liens symboliques sont ignorés (rsync affiche « skipping # non-regular file ») : ce n'est pas une erreur. OPTS_RSYNC+=(-rt --modify-window=2 --no-perms --no-owner --no-group) if [[ "$TYPE_FS" == "vfat" ]]; then # FAT32 : 4 Go maximum par fichier. OPTS_RSYNC+=(--max-size=4095M) fi ;; *) journal "Système de fichiers « $TYPE_FS » non prévu : copie prudente (-rt)." OPTS_RSYNC+=(-rt --modify-window=2) ;; esac if [[ "$CORBEILLE_JOURS" -gt 0 ]]; then # Rien n'est perdu d'un coup : fichiers supprimés ou remplacés -> .corbeille/ OPTS_RSYNC+=(--backup "--backup-dir=$CORBEILLE") fi # Exclusions : ce qui ne sert à rien de sauvegarder for e in "${EXCLUSIONS[@]}"; do [[ -n "$e" ]] && OPTS_RSYNC+=("--exclude=$e") done if [[ "$SIMULATION" -eq 1 ]]; then OPTS_RSYNC+=(--dry-run --itemize-changes --info=name1) journal "*** SIMULATION : rien ne sera modifié sur la clé ***" fi # ----------------------------------------------------------------------------- # Vérification de l'espace libre (estimation par un passage à blanc) # ----------------------------------------------------------------------------- # Approximation : avec la corbeille, les fichiers remplacés ne libèrent pas # de place ; on compare donc la quantité à copier à l'espace libre. if [[ "$VERIFIER_ESPACE" == "oui" && "$SIMULATION" -eq 0 ]]; then A_COPIER="$(rsync "${OPTS_RSYNC[@]}" --dry-run --info=del0 --no-human-readable "${SOURCES[@]}" "$DESTINATION/" 2>/dev/null \ | sed -n 's/^Total transferred file size: \([0-9,]*\).*/\1/p' | tr -d ',' || true)" LIBRE="$(df -B1 --output=avail "$RACINE_CLE" | tail -n1 | tr -d ' ')" if [[ "$A_COPIER" =~ ^[0-9]+$ && "$LIBRE" =~ ^[0-9]+$ ]]; then journal "À copier : $(numfmt --to=iec "$A_COPIER") — libre sur la clé : $(numfmt --to=iec "$LIBRE")" if (( A_COPIER > LIBRE )); then abandon "clé trop petite ou pleine : il faut $(numfmt --to=iec "$A_COPIER"), il reste $(numfmt --to=iec "$LIBRE")." 3 fi fi fi # Avertissement FAT32 : fichiers de plus de 4 Go non copiés if [[ "$TYPE_FS" == "vfat" ]]; then GROS="$(find "${SOURCES[@]}" -xdev -type f -size +4095M 2>/dev/null | head -n 5 || true)" if [[ -n "$GROS" ]]; then journal "Fichiers de plus de 4 Go NON copiés (limite FAT32) :" journal "$GROS" notifier normal "Sauvegarde : fichiers trop gros" "Certains fichiers dépassent 4 Go et ne tiennent pas sur cette clé (FAT32). Voir le journal." fi fi # ----------------------------------------------------------------------------- # La sauvegarde # ----------------------------------------------------------------------------- DEBUT="$(date +%s)" FICHIER_SORTIE="$(mktemp)" journal "Sauvegarde de : ${SOURCES[*]}" set +e rsync "${OPTS_RSYNC[@]}" "${SOURCES[@]}" "$DESTINATION/" 2>&1 | tee "$FICHIER_SORTIE" CODE_RSYNC="${PIPESTATUS[0]}" set -e DUREE=$(( $(date +%s) - DEBUT )) if [[ "$SIMULATION" -eq 1 ]]; then journal "Simulation terminée (code rsync $CODE_RSYNC). Rien n'a été modifié." exit 0 fi # Rend à l'utilisateur la corbeille créée par root (clé ext4). if [[ -d "$CORBEILLE" ]]; then proprietaire_utilisateur "$CORBEILLE_RACINE" "$CORBEILLE" fi # ----------------------------------------------------------------------------- # Purge des vieilles corbeilles # ----------------------------------------------------------------------------- if [[ "$CORBEILLE_JOURS" -gt 0 && -d "$CORBEILLE_RACINE" ]]; then # Uniquement les dossiers au format de date attendu, directement dans .corbeille while IFS= read -r -d '' vieux; do journal "Purge de l'ancienne corbeille : $(basename "$vieux")" rm -rf -- "$vieux" done < <(find "$CORBEILLE_RACINE" -mindepth 1 -maxdepth 1 -type d \ -name '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9]' \ -mtime +"$CORBEILLE_JOURS" -print0) fi sync -f "$DESTINATION/." 2>/dev/null || sync # ----------------------------------------------------------------------------- # Bilan # ----------------------------------------------------------------------------- case "$CODE_RSYNC" in 0|24) # 24 = fichiers disparus pendant la copie (normal si on travaille) ecrire_resultat "OK ($DUREE s)" journal "Sauvegarde terminée en $DUREE s." notifier normal "Sauvegarde terminée" "Vous pouvez retirer la clé." ;; 23) # Copie partielle : souvent des noms interdits sur FAT (: ? * " < > | \) # ou des fichiers illisibles. Les suppressions n'ont PAS été faites. ecrire_resultat "PARTIELLE ($DUREE s) — certains fichiers n'ont pas pu être copiés" journal "Sauvegarde PARTIELLE : certains fichiers n'ont pas été copiés (voir ci-dessus)." notifier normal "Sauvegarde terminée avec des avertissements" \ "Quelques fichiers n'ont pas pu être copiés (noms interdits sur la clé ?). Détails : journalctl -u $NOM. Vous pouvez retirer la clé." exit 23 ;; *) if grep -q 'No space left on device' "$FICHIER_SORTIE"; then ecrire_resultat "ÉCHEC : clé pleine" abandon "la clé est pleine. Libérez de la place (ou videz .corbeille) puis rebranchez-la." 3 fi ecrire_resultat "ÉCHEC (code rsync $CODE_RSYNC)" abandon "la sauvegarde a échoué (code rsync $CODE_RSYNC). Détails : journalctl -u $NOM" "$CODE_RSYNC" ;; esac