Ajouter la sauvegarde automatique sur clé USB

Dès qu'on branche la clé déclarée (reconnue par son UUID), udev demande
à systemd de lancer une sauvegarde rsync des dossiers choisis. Ce qui a
disparu de l'ordinateur est retiré de la clé, mais conservé 30 jours dans
une corbeille datée sur la clé. Notifications de bureau, journal systemd,
garde-fous contre une destination erronée. Installateur, tests sans sudo
et mode d'emploi fournis.

Refs #1
This commit is contained in:
Cédrix 2026-11-05 17:44:54 +01:00
parent 9f65bc807b
commit a04a72054a
No known key found for this signature in database
8 changed files with 1164 additions and 0 deletions

View file

@ -10,6 +10,9 @@ Les évolutions du site des présentations, de la plus récente à la plus ancie
et conducteur dans `slides/sauvegardes/` (#1). et conducteur dans `slides/sauvegardes/` (#1).
- « Ranger et sauvegarder » : une diapo propose une arborescence type pour le dossier - « Ranger et sauvegarder » : une diapo propose une arborescence type pour le dossier
Documents, et une diapo facultative présente la copie automatique sur clé USB. Documents, et une diapo facultative présente la copie automatique sur clé USB.
- Nouveau script `slides/sauvegardes/script/` : sauvegarde rsync automatique dès
qu'on branche une clé USB déclarée, avec corbeille de 30 jours sur la clé
(installateur, tests, mode d'emploi dans `LISEZMOI.md`).
- Ajout de cette page des nouveautés. - Ajout de cette page des nouveautés.
## 10 octobre 2026 ## 10 octobre 2026

View file

@ -0,0 +1,5 @@
# /etc/udev/rules.d/99-sauvegarde-cle-usb.rules
# Écrit par installer.sh (@UUID@ est remplacé par l'UUID de la clé).
# Quand la partition portant cet UUID apparaît, udev demande à systemd de
# démarrer le service de sauvegarde. Les autres clés ne déclenchent rien.
ACTION=="add", SUBSYSTEM=="block", ENV{ID_FS_UUID}=="@UUID@", TAG+="systemd", ENV{SYSTEMD_WANTS}+="sauvegarde-cle-usb.service"

View file

@ -0,0 +1,228 @@
# Sauvegarde automatique sur clé USB
Petit outil pour **Linux Mint 22** (Cinnamon) : on branche **sa** clé de
sauvegarde, la copie de ses documents démarre toute seule, une notification
dit quand on peut la retirer.
Accompagne la séance Alpinux « Ranger et sauvegarder ses documents ».
## Sommaire
| Pour commencer | Au quotidien | En cas de besoin |
|---|---|---|
| [Pour qui ?](#pour-qui) | [Tester](#tester) | [Restaurer un fichier](#restaurer-un-fichier) |
| [Ce que ça fait](#ce-que-ça-fait) | [Lire le journal](#lire-le-journal) | [Désinstaller](#désinstaller) |
| [Installer (3 commandes)](#installer-en-3-commandes) | [Régler](#régler) | [Limites](#limites-à-connaître) |
| [Trouver l'UUID](#trouver-luuid-de-sa-clé) | | [Comment ça marche](#comment-ça-marche) |
## Pour qui ?
Pour qui veut une copie de ses documents **sans y penser** : on branche la
clé, on attend le message, on la débranche. Il faut savoir ouvrir un
terminal et taper une commande avec `sudo` (une fois, pour l'installation).
## Ce que ça fait
- Seule **la clé déclarée** déclenche la sauvegarde (reconnue par son
identifiant UUID). Une autre clé ne fait rien.
- Le dossier personnel (ou une liste de dossiers) est copié dans
`Sauvegarde/` sur la clé. Les fois suivantes, seul ce qui a changé est
recopié : c'est rapide.
- Ce qui a été **supprimé** de l'ordinateur est retiré de la clé, pour que la
copie reste fidèle.
> **Synchroniser n'est pas sauvegarder.** Une copie « miroir » recopie aussi
> les erreurs : un fichier effacé par mégarde disparaîtrait de la clé au
> branchement suivant. C'est pourquoi, par défaut, **rien n'est effacé d'un
> coup** : ce qui est retiré ou remplacé sur la clé est d'abord mis de côté
> dans `.corbeille/AAAA-MM-JJ_HHMMSS/`, et gardé **30 jours**.
- Notifications : « Sauvegarde en cours… », puis « Sauvegarde terminée,
vous pouvez retirer la clé », ou un message d'erreur (clé pleine…).
- Un fichier `derniere-sauvegarde.txt` à la racine de la clé indique la date
et le résultat de la dernière sauvegarde.
- Ne sont pas copiés : caches (`.cache`), corbeille de l'ordinateur,
miniatures, fichiers temporaires. `Téléchargements` peut être exclu.
## Installer en 3 commandes
Branchez d'abord la clé de sauvegarde, puis dans un terminal :
```bash
git clone --depth 1 https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.presentations.git
cd alpinux.presentations/slides/sauvegardes/script
sudo ./installer.sh
```
(Si `git` manque : `sudo apt install git`.)
L'installateur affiche les clés USB branchées, demande laquelle utiliser et
pour quel utilisateur, puis met tout en place. On peut le relancer sans
risque (changer de clé, mettre à jour) : vos réglages sont conservés.
Ensuite : **débranchez et rebranchez la clé**, la première sauvegarde part.
Elle peut être longue ; les suivantes sont rapides.
Sans questions : `sudo ./installer.sh --uuid E234-70AF --utilisateur marie`
## Trouver l'UUID de sa clé
L'installateur le fait pour vous. Pour le voir vous-même :
```bash
lsblk -o NAME,LABEL,UUID,FSTYPE,SIZE,TRAN
```
Repérez votre clé (colonne `TRAN` = `usb`, taille, nom `LABEL`) : l'UUID est
sur la ligne de la **partition** (`sdb1`…), par exemple `E234-70AF` pour une
clé FAT, ou `3d6f976c-1822-…` pour une clé ext4.
Attention : **reformater la clé change son UUID** ; relancez alors
`sudo ./installer.sh`.
## Tester
```bash
sudo sauvegarde-cle-usb --simulation # montre ce qui serait copié/supprimé, sans rien faire
sudo systemctl start sauvegarde-cle-usb # lance une vraie sauvegarde maintenant
```
Pour vérifier la logique sans clé ni `sudo` (développeurs) :
`./tester-sans-sudo.sh` — un dossier temporaire joue le rôle de la clé.
## Lire le journal
```bash
journalctl -u sauvegarde-cle-usb # tout l'historique
journalctl -u sauvegarde-cle-usb -b # depuis le démarrage
journalctl -u sauvegarde-cle-usb -f # en direct, pendant une sauvegarde
```
Ou plus simplement, ouvrez `derniere-sauvegarde.txt` sur la clé.
## Restaurer un fichier
**Un fichier qui existe encore dans la sauvegarde** : ouvrez la clé dans le
gestionnaire de fichiers, dossier `Sauvegarde/<votre nom>/…`, et copiez le
fichier où vous voulez (copier, pas déplacer).
**Un fichier supprimé ou une ancienne version** : il est dans la corbeille de
la clé, un dossier caché. Dans le gestionnaire de fichiers, appuyez sur
**Ctrl+H** pour voir les dossiers cachés, puis ouvrez `.corbeille/`. Chaque
sous-dossier porte la date de la sauvegarde où le fichier a été retiré
(ex. `2026-11-05_183012`) et reprend l'arborescence de vos dossiers.
Pour chercher un fichier par son nom :
```bash
find /media/$USER/*/.corbeille -iname '*lettre*'
```
**Tout restaurer** (nouvel ordinateur…) : copiez le contenu de
`Sauvegarde/<votre nom>/` dans votre dossier personnel.
## Régler
Tout est dans `/etc/sauvegarde-cle-usb.conf`, commenté :
`sudo xed /etc/sauvegarde-cle-usb.conf`
| Réglage | Rôle | Par défaut |
|---|---|---|
| `SOURCES` | dossiers à sauvegarder | le dossier personnel |
| `DOSSIER_DESTINATION` | dossier sur la clé | `Sauvegarde` |
| `CORBEILLE_JOURS` | durée de conservation de `.corbeille` ; `0` = miroir pur, sans filet (déconseillé) | `30` |
| `EXCLUSIONS` | ce qu'on ne copie pas (enlever le `#` devant `Téléchargements/` pour l'exclure) | caches, corbeille, temporaires |
| `VERIFIER_ESPACE` | vérifier la place libre avant de commencer | `oui` |
Pris en compte au prochain branchement.
## Désinstaller
```bash
sudo ./installer.sh --desinstaller
```
Retire le script, le service, la règle et la configuration. Les copies
présentes sur la clé ne sont pas touchées.
## Limites à connaître
- **Une clé n'est qu'une copie, et elle reste à la maison.** Un cambriolage
ou un dégât des eaux emporte l'ordinateur *et* la clé. Gardez aussi une
copie **ailleurs** (une 2ᵉ clé chez un proche, au travail, ou en ligne).
- **Attendez la notification** « Sauvegarde terminée » avant de retirer la
clé. Retirée en cours de route, la copie est incomplète (elle se
complétera au branchement suivant).
- **Clé FAT32 / exFAT / NTFS** (les clés vendues en magasin) : les documents
sont bien copiés, mais pas les droits Linux ni les liens symboliques
(ignorés, ce n'est pas une erreur). En **FAT32**, pas de fichier de plus de
**4 Go** (signalé par une notification) et certains caractères sont interdits
dans les noms (`: * ? " < > | \`) : ces fichiers-là ne sont pas copiés, la
sauvegarde est dite « avec avertissements » et, par prudence, rien n'est
supprimé de la clé ce jour-là. Pour une copie fidèle, formatez la clé en
**ext4** (Disques ou Formater dans le menu de la clé) — elle ne sera plus
lisible sous Windows.
- **Papiers sensibles** (impôts, banque, santé, pièces d'identité) : une clé
se perd facilement. **Chiffrez-la** (Disques → Formater → « Protégé par mot
de passe (LUKS) »). Après le déverrouillage, le démarrage automatique n'est
pas garanti : lancez alors `sudo systemctl start sauvegarde-cle-usb`, et
choisissez dans l'installateur la ligne `crypt` (clé déverrouillée).
- La corbeille de la clé prend de la place. Si la clé est pleine, supprimez
les plus vieux dossiers de `.corbeille/`.
- Les fichiers en cours d'écriture pendant la copie (base de données de
Thunderbird ou Firefox ouverts…) peuvent être copiés dans un état
incohérent : fermez ces logiciels pour une copie parfaite.
- Si la clé est branchée au démarrage de l'ordinateur, la sauvegarde part
aussitôt, avant même la connexion (sans notification).
## Comment ça marche
```
clé branchée ─► udev reconnaît l'UUID ─► systemd lance sauvegarde-cle-usb.service
─► le script monte la clé sur /run/sauvegarde-cle-usb
─► vérifications ─► rsync ─► purge des vieilles corbeilles
─► sync, démontage ─► notification « vous pouvez retirer la clé »
```
| Fichier (dans ce dossier) | Installé dans |
|---|---|
| `sauvegarde-cle-usb` (le script) | `/usr/local/sbin/` |
| `sauvegarde-cle-usb.conf` (modèle) | `/etc/sauvegarde-cle-usb.conf` |
| `sauvegarde-cle-usb.service` | `/etc/systemd/system/` |
| `99-sauvegarde-cle-usb.rules.modele` | `/etc/udev/rules.d/99-sauvegarde-cle-usb.rules` |
| `installer.sh`, `tester-sans-sudo.sh` | (non installés) |
**Pourquoi udev + systemd ?** udev voit la clé arriver, mais ne doit pas
lancer lui-même une tâche longue (il la tuerait au bout de quelques
secondes) : il demande donc à systemd de démarrer le service, qui tourne
aussi longtemps que nécessaire et écrit dans le journal.
**Pourquoi un montage dédié ?** Quand on branche la clé, Cinnamon (Nemo,
udisks) la monte souvent dans `/media/<utilisateur>/…`, mais pas toujours
(personne de connecté, automontage désactivé) et pas forcément avant le
démarrage de la sauvegarde. Le script monte donc lui-même la clé sur
`/run/sauvegarde-cle-usb`, que Nemo l'ait montée ou non : Linux accepte
plusieurs montages du même système de fichiers. Avantages : le chemin est
toujours le même, les options sont maîtrisées (clé FAT montée au nom de
l'utilisateur), pas de course avec l'automontage, et un « Éjecter » dans Nemo
pendant la copie ne retire que le montage de Nemo, pas celui de la
sauvegarde. Si ce montage échoue, le script réutilise le montage de Nemo
s'il est en écriture. À la fin : `sync` puis démontage de son seul montage
(celui de Nemo reste, la clé reste visible).
**Garde-fous** (une erreur de chemin avec suppression peut effacer des
données) :
- la sauvegarde refuse de partir si la destination n'est pas un sous-dossier
de la clé montée **et** portant l'UUID attendu (même à travers un lien
symbolique) ;
- refus si `SOURCES` est vide, si un dossier source est absent ou **vide**
(disque non monté, dossier chiffré verrouillé : sinon tout serait retiré de
la clé), ou si deux sources portent le même nom ;
- jamais deux sauvegardes en même temps (verrou) ;
- vérification de la place libre avant de commencer ;
- si rsync rencontre une erreur de lecture, il ne supprime rien.
**Options rsync** : clé ext4 → `-aH` (tout est conservé) ;
FAT/exFAT/NTFS → `-rt --modify-window=2` (dates à 2 s près, sans droits) ;
toujours `--delete --one-file-system`, plus
`--backup --backup-dir=.corbeille/<date>` si `CORBEILLE_JOURS` > 0.

View file

@ -0,0 +1,208 @@
#!/usr/bin/env bash
# =============================================================================
# installer.sh — installe (ou désinstalle) la sauvegarde automatique sur clé
# -----------------------------------------------------------------------------
# Utilisation, depuis ce dossier :
# sudo ./installer.sh installation (questions posées)
# sudo ./installer.sh --uuid E234-70AF --utilisateur marie
# installation sans questions
# sudo ./installer.sh --desinstaller tout retirer (la clé n'est pas touchée)
#
# On peut le relancer autant de fois que l'on veut (changer de clé, mettre
# à jour le script) : les réglages personnels de la configuration existante
# sont conservés, seuls l'UUID et l'utilisateur sont mis à jour.
# =============================================================================
set -euo pipefail
export LC_ALL=C.UTF-8
ICI="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_CIBLE="/usr/local/sbin/sauvegarde-cle-usb"
SERVICE_CIBLE="/etc/systemd/system/sauvegarde-cle-usb.service"
REGLE_CIBLE="/etc/udev/rules.d/99-sauvegarde-cle-usb.rules"
CONF_CIBLE="/etc/sauvegarde-cle-usb.conf"
UUID_CHOISI=""
UTILISATEUR_CHOISI=""
ACTION="installer"
erreur() { printf 'ERREUR : %s\n' "$*" >&2; exit 1; }
titre() { printf '\n== %s ==\n' "$*"; }
aide() {
sed -n '4,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--desinstaller|--désinstaller) ACTION="desinstaller" ;;
--uuid) UUID_CHOISI="${2:-}"; shift ;;
--utilisateur) UTILISATEUR_CHOISI="${2:-}"; shift ;;
--aide|--help|-h) aide; exit 0 ;;
*) aide >&2; erreur "option inconnue : $1" ;;
esac
shift
done
[[ "$(id -u)" -eq 0 ]] || erreur "lancez ce programme avec sudo : sudo $0"
recharger() {
systemctl daemon-reload
udevadm control --reload-rules
}
# -----------------------------------------------------------------------------
# Désinstallation
# -----------------------------------------------------------------------------
if [[ "$ACTION" == "desinstaller" ]]; then
titre "Désinstallation"
systemctl stop sauvegarde-cle-usb.service 2>/dev/null || true
for f in "$REGLE_CIBLE" "$SERVICE_CIBLE" "$SCRIPT_CIBLE" "$CONF_CIBLE"; do
if [[ -e "$f" ]]; then
rm -f -- "$f"
echo "Supprimé : $f"
fi
done
recharger
echo
echo "La sauvegarde automatique est désinstallée."
echo "Les copies déjà présentes sur la clé n'ont pas été touchées."
exit 0
fi
# -----------------------------------------------------------------------------
# Vérifications préalables
# -----------------------------------------------------------------------------
for f in sauvegarde-cle-usb sauvegarde-cle-usb.conf sauvegarde-cle-usb.service 99-sauvegarde-cle-usb.rules.modele; do
[[ -f "$ICI/$f" ]] || erreur "fichier manquant à côté de installer.sh : $f"
done
command -v rsync >/dev/null || erreur "rsync n'est pas installé : sudo apt install rsync"
command -v notify-send >/dev/null || echo "Remarque : notify-send absent (sudo apt install libnotify-bin) : pas de notifications."
# -----------------------------------------------------------------------------
# Choix de la clé
# -----------------------------------------------------------------------------
# Une partition est candidate si elle a un UUID et si elle est (directement ou
# non, ex. clé chiffrée déverrouillée) sur un périphérique USB.
lister_candidats() {
local nom type uuid fstype
while read -r nom type uuid fstype; do
[[ -n "$uuid" && -n "$fstype" ]] || continue
case "$type" in part|disk|crypt) ;; *) continue ;; esac
lsblk -nso TRAN "$nom" 2>/dev/null | grep -qx usb || continue
printf '%s %s %s\n' "$nom" "$uuid" "$fstype"
done < <(lsblk -rnpo NAME,TYPE,UUID,FSTYPE)
}
if [[ -z "$UUID_CHOISI" ]]; then
titre "Choix de la clé de sauvegarde"
echo "Branchez la clé à utiliser (et, si elle est chiffrée, déverrouillez-la"
echo "dans le gestionnaire de fichiers), puis appuyez sur Entrée."
read -r _
mapfile -t CANDIDATS < <(lister_candidats)
[[ "${#CANDIDATS[@]}" -gt 0 ]] || erreur "aucune clé USB trouvée. Est-elle bien branchée ?"
echo "Clés USB trouvées :"
echo
i=0
for c in "${CANDIDATS[@]}"; do
i=$((i + 1))
read -r nom _ _ <<<"$c"
printf ' [%d] ' "$i"
lsblk -dno NAME,LABEL,UUID,FSTYPE,SIZE "$nom" | head -n1
done
echo
echo "(Colonnes : nom, étiquette, UUID, système de fichiers, taille)"
echo "Pour comparer : lsblk -o NAME,LABEL,UUID,FSTYPE,SIZE,TRAN"
read -r -p "Numéro de la clé de sauvegarde : " num
[[ "$num" =~ ^[0-9]+$ && "$num" -ge 1 && "$num" -le "${#CANDIDATS[@]}" ]] || erreur "choix invalide."
read -r _ UUID_CHOISI fstype <<<"${CANDIDATS[$((num - 1))]}"
else
periph="$(blkid -U "$UUID_CHOISI" 2>/dev/null || true)"
if [[ -n "$periph" ]]; then
fstype="$(blkid -o value -s TYPE "$periph" 2>/dev/null || true)"
else
fstype=""
echo "Attention : aucune partition d'UUID $UUID_CHOISI n'est branchée actuellement."
fi
fi
[[ "$UUID_CHOISI" =~ ^[A-Za-z0-9-]+$ ]] || erreur "UUID invalide : $UUID_CHOISI"
periph="$(blkid -U "$UUID_CHOISI" 2>/dev/null || true)"
if [[ -n "$periph" && "$(lsblk -dno TYPE "$periph" 2>/dev/null)" == "crypt" ]]; then
echo "Clé chiffrée : le démarrage automatique n'est pas garanti après le"
echo "déverrouillage. Lancez alors la sauvegarde avec :"
echo " sudo systemctl start sauvegarde-cle-usb"
fi
case "${fstype:-}" in
crypto_LUKS)
erreur "c'est l'enveloppe chiffrée de la clé. Déverrouillez-la dans le gestionnaire de fichiers, relancez l'installateur et choisissez la ligne « crypt » qui apparaît." ;;
ext4|ext3|ext2|btrfs|xfs) echo "Clé au format Linux ($fstype) : droits et propriétaires seront conservés." ;;
vfat) echo "Clé au format FAT32 : ni droits ni liens symboliques, fichiers de 4 Go maximum." ;;
exfat|ntfs) echo "Clé au format $fstype : ni droits ni propriétaires (suffisant pour des documents)." ;;
"") ;;
*) echo "Format $fstype : non testé, copie prudente sans droits." ;;
esac
# -----------------------------------------------------------------------------
# Choix de l'utilisateur
# -----------------------------------------------------------------------------
if [[ -z "$UTILISATEUR_CHOISI" ]]; then
defaut="${SUDO_USER:-}"
[[ "$defaut" == "root" ]] && defaut=""
read -r -p "Utilisateur dont on sauvegarde le dossier personnel [${defaut}] : " UTILISATEUR_CHOISI
UTILISATEUR_CHOISI="${UTILISATEUR_CHOISI:-$defaut}"
fi
[[ -n "$UTILISATEUR_CHOISI" ]] || erreur "aucun utilisateur indiqué."
[[ "$UTILISATEUR_CHOISI" =~ ^[a-z_][a-z0-9_.-]*$ ]] || erreur "nom d'utilisateur invalide : $UTILISATEUR_CHOISI"
id -u "$UTILISATEUR_CHOISI" >/dev/null 2>&1 || erreur "utilisateur inconnu : $UTILISATEUR_CHOISI"
DOSSIER_PERSO="$(getent passwd "$UTILISATEUR_CHOISI" | cut -d: -f6)"
# -----------------------------------------------------------------------------
# Installation des fichiers
# -----------------------------------------------------------------------------
titre "Installation"
install -o root -g root -m 755 "$ICI/sauvegarde-cle-usb" "$SCRIPT_CIBLE"
echo "Script : $SCRIPT_CIBLE"
install -o root -g root -m 644 "$ICI/sauvegarde-cle-usb.service" "$SERVICE_CIBLE"
echo "Service : $SERVICE_CIBLE"
if [[ -f "$CONF_CIBLE" ]]; then
# Configuration existante : on garde les réglages, on met à jour la clé.
sed -i -e "s|^UUID=.*|UUID=\"$UUID_CHOISI\"|" \
-e "s|^UTILISATEUR=.*|UTILISATEUR=\"$UTILISATEUR_CHOISI\"|" "$CONF_CIBLE"
echo "Réglages : $CONF_CIBLE (conservés, clé et utilisateur mis à jour)"
echo " vérifiez la liste SOURCES si l'utilisateur a changé."
else
sed -e "s|^UUID=.*|UUID=\"$UUID_CHOISI\"|" \
-e "s|^UTILISATEUR=.*|UTILISATEUR=\"$UTILISATEUR_CHOISI\"|" \
-e "s|/home/marie|$DOSSIER_PERSO|g" \
"$ICI/sauvegarde-cle-usb.conf" > "$CONF_CIBLE"
echo "Réglages : $CONF_CIBLE"
fi
chown root:root "$CONF_CIBLE"
chmod 644 "$CONF_CIBLE"
sed "s|@UUID@|$UUID_CHOISI|" "$ICI/99-sauvegarde-cle-usb.rules.modele" > "$REGLE_CIBLE"
chmod 644 "$REGLE_CIBLE"
echo "Règle udev : $REGLE_CIBLE"
recharger
titre "C'est prêt"
cat <<FIN
Clé de sauvegarde : UUID $UUID_CHOISI
Utilisateur : $UTILISATEUR_CHOISI
1. Pour tester sans rien modifier : sudo sauvegarde-cle-usb --simulation
2. Débranchez puis rebranchez la clé : la sauvegarde démarre toute seule.
Attendez la notification « Sauvegarde terminée » avant de la retirer.
3. Journal : journalctl -u sauvegarde-cle-usb
Réglages (dossiers, exclusions, corbeille) : sudo xed $CONF_CIBLE
FIN

View file

@ -0,0 +1,475 @@
#!/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 <clé>/.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/<utilisateur>/. 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

View file

@ -0,0 +1,60 @@
# shellcheck shell=bash disable=SC2034
# =============================================================================
# /etc/sauvegarde-cle-usb.conf — réglages de la sauvegarde automatique
# -----------------------------------------------------------------------------
# Ce fichier est écrit par installer.sh. Vous pouvez le modifier ensuite :
# sudo xed /etc/sauvegarde-cle-usb.conf
# Les changements sont pris en compte au prochain branchement de la clé.
# Si vous changez l'UUID, relancez plutôt : sudo ./installer.sh
# Syntaxe : celle de bash (NOM="valeur", listes entre parenthèses).
# =============================================================================
# Identifiant (UUID) du système de fichiers de LA clé de sauvegarde.
# Une autre clé ne déclenche rien. Pour le trouver : lsblk -o NAME,LABEL,UUID
UUID="XXXX-XXXX"
# Utilisateur à prévenir par des notifications (et propriétaire des copies).
UTILISATEUR="marie"
# Dossiers à sauvegarder (un par ligne, entre guillemets).
# Par défaut : tout le dossier personnel.
SOURCES=(
"/home/marie"
)
# Exemple pour ne garder que l'essentiel :
# SOURCES=(
# "/home/marie/Documents"
# "/home/marie/Images"
# "/home/marie/Bureau"
# )
# Dossier, sur la clé, qui reçoit la copie (ex. la clé contiendra
# Sauvegarde/marie/Documents/...).
DOSSIER_DESTINATION="Sauvegarde"
# Ce qui n'existe plus sur l'ordinateur est retiré de la clé. Mais avant,
# ces fichiers (et les anciennes versions des fichiers modifiés) sont mis de
# côté dans .corbeille/AAAA-MM-JJ_HHMMSS sur la clé, pendant ce nombre de
# jours. 0 = miroir pur : ce qui est supprimé sur l'ordinateur est supprimé
# de la clé, sans filet. (Déconseillé.)
CORBEILLE_JOURS=30
# Ce qu'on ne sauvegarde pas (motifs rsync, relatifs aux dossiers ci-dessus).
# « dossier/ » = un dossier de ce nom, où qu'il soit
# « *.tmp » = tous les fichiers finissant par .tmp
EXCLUSIONS=(
".cache/" # caches des logiciels (se recréent tout seuls)
".local/share/Trash/" # la corbeille de l'ordinateur
".thumbnails/" # miniatures d'images
".gvfs/"
"*.tmp" # fichiers temporaires
"*~" # copies de secours des éditeurs
".~lock.*#" # verrous LibreOffice (document ouvert)
"*.part" # téléchargements inachevés
# "Téléchargements/" # enlevez le # pour ne pas sauvegarder ce dossier
# ".local/share/Steam/" # jeux : très gros, se retéléchargent
)
# Vérifier avant de commencer qu'il y a assez de place sur la clé ("oui"/"non").
# Cela demande un premier passage rapide (lecture seule) des dossiers.
VERIFIER_ESPACE="oui"

View file

@ -0,0 +1,18 @@
# /etc/systemd/system/sauvegarde-cle-usb.service
# Démarré par la règle udev 99-sauvegarde-cle-usb.rules quand LA clé de
# sauvegarde est branchée (udev ne doit pas lancer lui-même de tâche longue).
# Lancement manuel : sudo systemctl start sauvegarde-cle-usb
# Journal : journalctl -u sauvegarde-cle-usb
[Unit]
Description=Sauvegarde automatique sur clé USB
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/sauvegarde-cle-usb
# Une première sauvegarde peut être longue : pas de limite de temps.
TimeoutStartSec=infinity
# Rester discret pendant que l'ordinateur sert à autre chose.
Nice=10
IOSchedulingClass=idle

View file

@ -0,0 +1,167 @@
#!/usr/bin/env bash
# =============================================================================
# tester-sans-sudo.sh — vérifie la logique de sauvegarde, sans clé ni sudo
# -----------------------------------------------------------------------------
# Utilise le mode test de sauvegarde-cle-usb (SAUVEGARDE_TEST_DESTINATION) :
# un dossier temporaire joue le rôle de la clé. Rien n'est monté, rien n'est
# écrit dans /etc. Usage : ./tester-sans-sudo.sh [dossier-de-travail]
# =============================================================================
# shellcheck disable=SC2016 # les bash -c reçoivent leurs arguments via $1, voulu
set -euo pipefail
ICI="$(cd "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$ICI/sauvegarde-cle-usb"
BASE="${1:-$(mktemp -d)}"
TRAVAIL="$BASE/essai-$$"
SRC="$TRAVAIL/maison/marie"
CLE="$TRAVAIL/cle"
CONF="$TRAVAIL/test.conf"
export TMPDIR="$TRAVAIL"
REUSSIS=0
RATES=0
ok() { echo " OK $*"; REUSSIS=$((REUSSIS + 1)); }
rate() { echo " RATÉ $*"; RATES=$((RATES + 1)); }
verifier() { local desc="$1"; shift; if "$@"; then ok "$desc"; else rate "$desc"; fi; }
ecrire_conf() { # $1 = CORBEILLE_JOURS, $2 = DOSSIER_DESTINATION, $3.. = sources
local jours="$1" dest="$2"; shift 2
{
echo "UUID=\"TEST\""
echo "UTILISATEUR=\"$(id -un)\""
printf 'SOURCES=('; printf ' "%s"' "$@"; echo ' )'
echo "DOSSIER_DESTINATION=\"$dest\""
echo "CORBEILLE_JOURS=$jours"
echo 'EXCLUSIONS=( ".cache/" ".local/share/Trash/" "*.tmp" )'
echo 'VERIFIER_ESPACE="oui"'
} > "$CONF"
}
lancer() { # lance le script en mode test ; sortie dans $TRAVAIL/sortie.txt
sleep 1 # horodatage à la seconde : une corbeille par lancement
set +e
SAUVEGARDE_CONF="$CONF" SAUVEGARDE_TEST_DESTINATION="${DEST_TEST:-$CLE}" \
"$SCRIPT" "$@" > "$TRAVAIL/sortie.txt" 2>&1
CODE=$?
set -e
}
nb_corbeilles() { find "$CLE/.corbeille" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l; }
mkdir -p "$SRC/Documents" "$SRC/Images" "$SRC/.cache/firefox" "$SRC/.local/share/Trash/files" "$CLE"
echo "lettre v1" > "$SRC/Documents/lettre.txt"
echo "impôts 2026" > "$SRC/Documents/impots.txt"
echo "photo" > "$SRC/Images/vacances.jpg"
echo "cache" > "$SRC/.cache/firefox/gros-cache"
echo "jeté" > "$SRC/.local/share/Trash/files/vieux.txt"
echo "temporaire" > "$SRC/Documents/brouillon.tmp"
ecrire_conf 30 Sauvegarde "$SRC"
COPIE="$CLE/Sauvegarde/marie"
echo "Dossier de test : $TRAVAIL"
echo "1. Première sauvegarde"
lancer
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "lettre.txt copiée" test -f "$COPIE/Documents/lettre.txt"
verifier "vacances.jpg copiée" test -f "$COPIE/Images/vacances.jpg"
verifier ".cache exclu" test ! -e "$COPIE/.cache"
verifier "corbeille locale exclue" test ! -e "$COPIE/.local/share/Trash"
verifier "*.tmp exclu" test ! -e "$COPIE/Documents/brouillon.tmp"
verifier "derniere-sauvegarde.txt = OK" grep -q 'Résultat *: OK' "$CLE/derniere-sauvegarde.txt"
echo "2. Simulation (--simulation) après suppression de impots.txt"
rm "$SRC/Documents/impots.txt"
lancer --simulation
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "annonce la suppression" grep -q 'deleting.*impots.txt' "$TRAVAIL/sortie.txt"
verifier "impots.txt toujours sur la clé" test -f "$COPIE/Documents/impots.txt"
verifier "aucune corbeille créée" test "$(nb_corbeilles)" -eq 0
echo "3. Suppression côté ordinateur → retiré de la copie, gardé en corbeille"
lancer
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "impots.txt absent de la copie" test ! -e "$COPIE/Documents/impots.txt"
CORB1="$(find "$CLE/.corbeille" -mindepth 1 -maxdepth 1 -type d | head -n1)"
verifier "nom de corbeille AAAA-MM-JJ_HHMMSS" bash -c '[[ "$(basename "$1")" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}$ ]]' _ "$CORB1"
verifier "impots.txt dans .corbeille/<date>" test -f "$CORB1/marie/Documents/impots.txt"
echo "4. Modification d'un fichier → nouvelle version copiée, ancienne en corbeille"
echo "lettre v2, bien plus longue" > "$SRC/Documents/lettre.txt"
lancer
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "copie = v2" grep -q 'v2' "$COPIE/Documents/lettre.txt"
verifier "ancienne v1 en corbeille" bash -c 'grep -rqs "lettre v1" "$1"/.corbeille/*/marie/Documents/lettre.txt' _ "$CLE"
verifier "deux corbeilles" test "$(nb_corbeilles)" -eq 2
echo "5. Purge des corbeilles de plus de 30 jours"
mkdir -p "$CLE/.corbeille/2026-09-01_120000/marie" "$CLE/.corbeille/2026-10-30_120000"
echo vieux > "$CLE/.corbeille/2026-09-01_120000/marie/vieux.txt"
touch -d '40 days ago' "$CLE/.corbeille/2026-09-01_120000"
touch -d '6 days ago' "$CLE/.corbeille/2026-10-30_120000"
mkdir -p "$CLE/.corbeille/a-moi"; touch -d '90 days ago' "$CLE/.corbeille/a-moi"
lancer
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "corbeille de 40 jours purgée" test ! -e "$CLE/.corbeille/2026-09-01_120000"
verifier "corbeille de 6 jours gardée" test -d "$CLE/.corbeille/2026-10-30_120000"
verifier "dossier au nom inattendu épargné" test -d "$CLE/.corbeille/a-moi"
verifier "purge annoncée dans le journal" grep -q 'Purge.*2026-09-01_120000' "$TRAVAIL/sortie.txt"
echo "6. CORBEILLE_JOURS=0 : miroir pur, sans corbeille"
ecrire_conf 0 Sauvegarde "$SRC"
avant="$(nb_corbeilles)"
rm "$SRC/Images/vacances.jpg"
lancer
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "vacances.jpg absent de la copie" test ! -e "$COPIE/Images/vacances.jpg"
verifier "aucune nouvelle corbeille" test "$(nb_corbeilles)" -eq "$avant"
verifier "vacances.jpg nulle part sur la clé" test -z "$(find "$CLE" -name vacances.jpg)"
ecrire_conf 30 Sauvegarde "$SRC"
echo "7. Clé FAT simulée : lien symbolique ignoré sans erreur"
ln -s Documents/lettre.txt "$SRC/raccourci"
SAUVEGARDE_TEST_FSTYPE=vfat lancer
verifier "code de sortie 0" test "$CODE" -eq 0
verifier "lien non copié" test ! -e "$COPIE/raccourci" -a ! -L "$COPIE/raccourci"
rm "$SRC/raccourci"
echo "8. Garde-fous (tous doivent REFUSER, sans rien toucher)"
empreinte() { find "$CLE" "$SRC" -printf '%p %s\n' | sort | md5sum; }
E0="$(empreinte)"
DEST_TEST="$TRAVAIL/nexiste-pas" lancer
verifier "destination de test absente → refus" test "$CODE" -ne 0
DEST_TEST="/" lancer
verifier "destination « / » → refus" test "$CODE" -ne 0
for d in "../evasion" "a/../../evasion" "/tmp/x" "." ".corbeille" ""; do
ecrire_conf 30 "$d" "$SRC"; lancer
verifier "DOSSIER_DESTINATION=« $d » → refus" test "$CODE" -ne 0
done
verifier "rien n'a été créé hors de la clé" test ! -e "$TRAVAIL/evasion" -a ! -e "$TRAVAIL/x"
ln -s "$TRAVAIL/maison" "$CLE/piege"
ecrire_conf 30 piege "$SRC"; lancer
verifier "lien sur la clé vers l'extérieur → refus" test "$CODE" -ne 0
rm "$CLE/piege"
ecrire_conf 30 Sauvegarde; lancer
verifier "SOURCES vide → refus" test "$CODE" -ne 0
mkdir -p "$TRAVAIL/vide"
ecrire_conf 30 Sauvegarde "$TRAVAIL/vide"; lancer
verifier "source vide → refus" test "$CODE" -ne 0
ecrire_conf 30 Sauvegarde "$TRAVAIL/absent"; lancer
verifier "source inexistante → refus" test "$CODE" -ne 0
ecrire_conf 30 Sauvegarde "$CLE"; lancer
verifier "source contenant la clé → refus" test "$CODE" -ne 0
ecrire_conf 30 Sauvegarde "$SRC/Documents" "$TRAVAIL/maison/marie/../marie/Documents"; lancer
verifier "deux sources au même nom → refus" test "$CODE" -ne 0
verifier "clé et sources inchangées" test "$(empreinte)" = "$E0"
ecrire_conf 30 Sauvegarde "$SRC"
echo "9. Verrou : deux sauvegardes simultanées impossibles"
exec 8>"$TRAVAIL/sauvegarde-cle-usb-test.lock"; flock -n 8
lancer
verifier "2e exécution s'arrête sans rien faire" grep -q 'déjà en cours' "$TRAVAIL/sortie.txt"
exec 8>&-
echo
echo "Bilan : $REUSSIS réussis, $RATES ratés."
[[ "$RATES" -eq 0 ]] && rm -rf "$TRAVAIL"
[[ "$RATES" -eq 0 ]]