From e879a214a9f695929f9b71fd94e3212f15aa6e47 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Wed, 7 Oct 2026 11:08:22 +0200 Subject: [PATCH] =?UTF-8?q?ajoute=20les=20sauvegardes=20chiffr=C3=A9es=20v?= =?UTF-8?q?ers=20Dedibackup=20(r=C3=B4le=20Ansible,=20timer=20quotidien,?= =?UTF-8?q?=20rotation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- ansible/roles/sauvegardes/defaults/main.yml | 13 +++ ansible/roles/sauvegardes/handlers/main.yml | 4 + ansible/roles/sauvegardes/tasks/main.yml | 70 ++++++++++++ .../sauvegarde-dedibackup.service.j2 | 7 ++ .../templates/sauvegarde-dedibackup.sh.j2 | 75 +++++++++++++ .../templates/sauvegarde-dedibackup.timer.j2 | 11 ++ ansible/sauvegardes.yml | 8 ++ docs/journal.md | 39 +++++++ docs/runbooks/sauvegardes.md | 101 ++++++++++++++++++ 9 files changed, 328 insertions(+) create mode 100644 ansible/roles/sauvegardes/defaults/main.yml create mode 100644 ansible/roles/sauvegardes/handlers/main.yml create mode 100644 ansible/roles/sauvegardes/tasks/main.yml create mode 100644 ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.service.j2 create mode 100644 ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 create mode 100644 ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.timer.j2 create mode 100644 ansible/sauvegardes.yml create mode 100644 docs/runbooks/sauvegardes.md diff --git a/ansible/roles/sauvegardes/defaults/main.yml b/ansible/roles/sauvegardes/defaults/main.yml new file mode 100644 index 0000000..793d81c --- /dev/null +++ b/ansible/roles/sauvegardes/defaults/main.yml @@ -0,0 +1,13 @@ +--- +sauvegardes_retention_quotidiennes: 7 +sauvegardes_retention_hebdomadaires: 4 +# Jour de la semaine (1=lundi .. 7=dimanche, format `date +%u`) où la quotidienne +# du jour est aussi conservée comme hebdomadaire. +sauvegardes_jour_hebdomadaire: "7" +sauvegardes_heure_timer: "03:15" +sauvegardes_repertoires: + - /etc + - /home + - /root +sauvegardes_age_dir: /root/.age +sauvegardes_age_cle: /root/.age/cle-sauvegardes.txt diff --git a/ansible/roles/sauvegardes/handlers/main.yml b/ansible/roles/sauvegardes/handlers/main.yml new file mode 100644 index 0000000..321f92a --- /dev/null +++ b/ansible/roles/sauvegardes/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: recharger systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/sauvegardes/tasks/main.yml b/ansible/roles/sauvegardes/tasks/main.yml new file mode 100644 index 0000000..939d062 --- /dev/null +++ b/ansible/roles/sauvegardes/tasks/main.yml @@ -0,0 +1,70 @@ +--- +- name: installer age et lftp + ansible.builtin.apt: + name: + - age + - lftp + state: present + update_cache: true + cache_valid_time: 3600 + +- name: créer le répertoire de la clé age + ansible.builtin.file: + path: "{{ sauvegardes_age_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: générer la paire de clés age (si absente) + ansible.builtin.command: + cmd: "age-keygen -o {{ sauvegardes_age_cle }}" + creates: "{{ sauvegardes_age_cle }}" + +- name: restreindre les permissions de la clé privée age + ansible.builtin.file: + path: "{{ sauvegardes_age_cle }}" + owner: root + group: root + mode: "0600" + +- name: lire la clé publique age (pour affichage, jamais la clé privée) + ansible.builtin.command: + cmd: "grep -oP '(?<=public key: )age1\\S+' {{ sauvegardes_age_cle }}" + register: sauvegardes_age_pub + changed_when: false + +- name: déployer le script de sauvegarde + ansible.builtin.template: + src: sauvegarde-dedibackup.sh.j2 + dest: /usr/local/sbin/sauvegarde-dedibackup.sh + owner: root + group: root + mode: "0700" + +- name: déployer le service systemd + ansible.builtin.template: + src: sauvegarde-dedibackup.service.j2 + dest: /etc/systemd/system/sauvegarde-dedibackup.service + mode: "0644" + notify: recharger systemd + +- name: déployer le timer systemd + ansible.builtin.template: + src: sauvegarde-dedibackup.timer.j2 + dest: /etc/systemd/system/sauvegarde-dedibackup.timer + mode: "0644" + notify: recharger systemd + +- name: appliquer les changements systemd + ansible.builtin.meta: flush_handlers + +- name: activer et démarrer le timer de sauvegarde + ansible.builtin.systemd: + name: sauvegarde-dedibackup.timer + enabled: true + state: started + +- name: afficher la clé publique age (à connaître, jamais la privée) + ansible.builtin.debug: + msg: "Clé publique age du serveur : {{ sauvegardes_age_pub.stdout }}" diff --git a/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.service.j2 b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.service.j2 new file mode 100644 index 0000000..949e44e --- /dev/null +++ b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.service.j2 @@ -0,0 +1,7 @@ +[Unit] +Description=Sauvegarde quotidienne chiffrée vers Dedibackup +# Géré par Ansible (rôle sauvegardes) — ne pas modifier à la main. + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/sauvegarde-dedibackup.sh diff --git a/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 new file mode 100644 index 0000000..8b32b8a --- /dev/null +++ b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 @@ -0,0 +1,75 @@ +#!/bin/bash +# Géré par Ansible (rôle sauvegardes) — ne pas modifier à la main. +# +# Archive {{ sauvegardes_repertoires | join(', ') }} en tar.zst, chiffre avec age +# (clé publique du serveur — la clé privée ne quitte jamais le serveur, voir +# {{ sauvegardes_age_cle }}), envoie vers Dedibackup en FTP, puis applique la +# rotation ({{ sauvegardes_retention_quotidiennes }} quotidiennes, +# {{ sauvegardes_retention_hebdomadaires }} hebdomadaires). +set -euo pipefail + +# Les identifiants ne sont jamais affichés ni journalisés : on désactive +# l'option xtrace pour cette ligne précise, par précaution. +set +x +source /root/.dedibackup + +AGE_KEY="{{ sauvegardes_age_cle }}" +AGE_PUB=$(grep -oP '(?<=public key: )age1\S+' "$AGE_KEY") + +DATE=$(date +%Y%m%d) +DOW=$(date +%u) +SEMAINE=$(date +%G-W%V) + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +ARCHIVE_QUOT="quotidienne-${DATE}.tar.zst.age" + +tar --exclude="{{ sauvegardes_age_dir }}" --exclude='/root/.dedibackup' \ + -I zstd -cf - {{ sauvegardes_repertoires | join(' ') }} \ + | age -r "$AGE_PUB" -o "$WORKDIR/$ARCHIVE_QUOT" + +lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" -e " +set ftp:ssl-allow no +put $WORKDIR/$ARCHIVE_QUOT +quit +" +logger -t sauvegarde-dedibackup "Quotidienne envoyée : $ARCHIVE_QUOT" + +if [ "$DOW" = "{{ sauvegardes_jour_hebdomadaire }}" ]; then + ARCHIVE_HEBDO="hebdomadaire-${SEMAINE}.tar.zst.age" + cp "$WORKDIR/$ARCHIVE_QUOT" "$WORKDIR/$ARCHIVE_HEBDO" + lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" -e " + set ftp:ssl-allow no + put $WORKDIR/$ARCHIVE_HEBDO + quit + " + logger -t sauvegarde-dedibackup "Hebdomadaire envoyée : $ARCHIVE_HEBDO" +fi + +# --- Rotation : on ne garde que les N plus récentes de chaque catégorie. --- +rotation() { + local motif="$1" garder="$2" + local liste a_supprimer + liste=$(lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" -e " + set ftp:ssl-allow no + cls -1 ${motif} + quit + " 2>/dev/null | sort) + a_supprimer=$(echo "$liste" | head -n -"$garder") + if [ -n "$a_supprimer" ]; then + local commandes="" + while IFS= read -r fichier; do + [ -n "$fichier" ] && commandes+="rm $fichier;" + done <<< "$a_supprimer" + lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" -e " + set ftp:ssl-allow no + $commandes + quit + " + logger -t sauvegarde-dedibackup "Rotation $motif : supprimé $(echo "$a_supprimer" | wc -l) fichier(s)." + fi +} + +rotation "quotidienne-*.tar.zst.age" {{ sauvegardes_retention_quotidiennes }} +rotation "hebdomadaire-*.tar.zst.age" {{ sauvegardes_retention_hebdomadaires }} diff --git a/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.timer.j2 b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.timer.j2 new file mode 100644 index 0000000..47eb6ad --- /dev/null +++ b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.timer.j2 @@ -0,0 +1,11 @@ +[Unit] +Description=Déclenche la sauvegarde quotidienne vers Dedibackup +# Géré par Ansible (rôle sauvegardes) — ne pas modifier à la main. + +[Timer] +OnCalendar=*-*-* {{ sauvegardes_heure_timer }}:00 +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/ansible/sauvegardes.yml b/ansible/sauvegardes.yml new file mode 100644 index 0000000..b35cb36 --- /dev/null +++ b/ansible/sauvegardes.yml @@ -0,0 +1,8 @@ +--- +# Étape 3 du déploiement Syllabus : sauvegardes chiffrées vers Dedibackup. +- name: Sauvegardes du serveur Syllabus + hosts: syllabus + become: true + gather_facts: true + roles: + - sauvegardes diff --git a/docs/journal.md b/docs/journal.md index afaeef0..8b82e02 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -66,3 +66,42 @@ Durcir un serveur, c'est fondamentalement risqué pour l'accès qu'on a déjà : **Suite** Étape 3 : sauvegardes vers Dedibackup (archives `tar.zst` chiffrées `age`, rotation 7 quotidiennes + 4 hebdomadaires, timer systemd). + +## 2026-10-07 — Étape 3 : sauvegardes vers Dedibackup + +**Fait** + +- Rôle Ansible `sauvegardes` (`ansible/roles/sauvegardes/`) + playbook `ansible/sauvegardes.yml`. Pas de risque réseau/pare-feu/SSH ici, donc appliqué directement sans attendre d'accord explicite (règle 3). +- Paire de clés **age** générée sur le serveur (`/root/.age/cle-sauvegardes.txt`, `600`) : la clé privée ne quitte jamais le serveur. +- Script `/usr/local/sbin/sauvegarde-dedibackup.sh` : archive `/etc`, `/home`, `/root` en `tar.zst`, chiffre en flux avec `age`, envoie en FTP vers Dedibackup avec `lftp`, duplique en hebdomadaire le dimanche, applique la rotation (7 quotidiennes + 4 hebdomadaires, suppression du surplus sur Dedibackup). +- Timer systemd `sauvegarde-dedibackup.timer` : tous les jours à 03:15 (±5 min). +- `docs/runbooks/sauvegardes.md` écrit (déclenchement manuel, liste, restauration, rappel sur la clé age). + +**Commandes clés** + +```bash +cd ansible && script -q /dev/null ansible-playbook sauvegardes.yml -i inventory.ini +ssh paillasse "sudo systemctl start sauvegarde-dedibackup.service" +# restauration testée : voir docs/runbooks/sauvegardes.md +``` + +**Problèmes rencontrés et solution** + +- 1er envoi FTP : `530 Bad credentials`. Diagnostic sans jamais afficher les identifiants (longueur et forme des variables seulement). Cause probable : valeurs non protégées par des guillemets dans `/root/.dedibackup`, risque de troncature silencieuse par `bash` si le mot de passe contient `#`, `$` ou une espace. **Adam a corrigé le fichier lui-même** (jamais Claude, le fichier contient un secret). Après correction, l'envoi a réussi. +- Extraction de test avec un chemin `./etc/...` : échec (`Not found in archive`), car `tar` retire le `/` initial à la création (`etc/...`, sans `./`). Corrigé dans le runbook. +- `etc/os-release` n'a pas pu être lu après extraction : c'est un lien symbolique vers `../usr/lib/os-release`, hors du périmètre sauvegardé (`/etc`, `/home`, `/root` seulement) — comportement normal, pas un bug. + +**Critère vérifié** + +- Sauvegarde envoyée : `quotidienne-20261007.tar.zst.age`, confirmée présente sur Dedibackup (`lftp cls`). +- Fichier restauré avec succès dans `/tmp` : `etc/hostname` extrait et relu, contenu conforme (`sd-124443`). Dossier de test nettoyé après coup. + +**Comprendre** + +Chiffrer avant d'envoyer (plutôt que de compter sur un transport chiffré) a un avantage : Dedibackup n'a accès qu'à un FTP en clair, mais comme le contenu est déjà chiffré par `age` avant de partir, intercepter le transfert ne révèle rien d'utile. La contrepartie, c'est qu'il ne faut jamais perdre la clé privée `age` : sans elle, toutes les sauvegardes passées deviennent illisibles pour toujours, même si elles sont bien là sur Dedibackup. D'où le rappel à Adam de garder une copie de cette clé ailleurs que sur le serveur. + +**⚠️ Reste à faire par Adam** : copier `/root/.age/cle-sauvegardes.txt` en dehors du serveur (gestionnaire de mots de passe, clé USB chiffrée…) — voir `docs/runbooks/sauvegardes.md`. + +**Suite** + +Étape 4 : réseau pour OpenStack (`dummy0`, paire veth, NAT). Touche le réseau → accord explicite d'Adam obligatoire avant d'appliquer, filet anti-coupure, et redémarrage du serveur seulement avec son accord. diff --git a/docs/runbooks/sauvegardes.md b/docs/runbooks/sauvegardes.md new file mode 100644 index 0000000..7c13077 --- /dev/null +++ b/docs/runbooks/sauvegardes.md @@ -0,0 +1,101 @@ +# Runbook — Sauvegardes vers Dedibackup + +## Principe + +Chaque jour à 03:15 (±5 min aléatoires), le timer systemd `sauvegarde-dedibackup.timer` +déclenche `sauvegarde-dedibackup.service`, qui exécute +`/usr/local/sbin/sauvegarde-dedibackup.sh` (déployé par le rôle Ansible +`sauvegardes`, voir `ansible/roles/sauvegardes/`). + +Le script : + +1. Archive `/etc`, `/home`, `/root` en `tar.zst` (streaming, sans fichier intermédiaire en clair). +2. Chiffre le flux avec **age**, avec la clé publique du serveur (`/root/.age/cle-sauvegardes.txt`). +3. Envoie l'archive chiffrée en FTP vers Dedibackup (`dedibackup-dc3.online.net`, joignable uniquement depuis ce serveur). +4. Le dimanche (jour 7), duplique aussi l'archive du jour comme hebdomadaire. +5. Applique la rotation : garde les 7 quotidiennes et 4 hebdomadaires les plus récentes, supprime le reste sur Dedibackup (qui est limité à 1000 fichiers). + +Le FTP de Dedibackup n'est pas chiffré en transport, mais le contenu de +l'archive l'est déjà (age) avant l'envoi : une interception du transfert ne +révèle rien. + +## Où est la clé de déchiffrement + +- Clé privée age : `/root/.age/cle-sauvegardes.txt`, permissions `600`, **ne quitte jamais le serveur**. +- Clé publique (sans risque à diffuser) : voir la sortie de `age-keygen` lors du déploiement, ou : + ``` + sudo grep 'public key' /root/.age/cle-sauvegardes.txt + ``` + +**⚠️ Rappel pour Adam (pas fait automatiquement par Claude) : garder une copie de +`/root/.age/cle-sauvegardes.txt` en dehors du serveur** (gestionnaire de mots de +passe, clé USB chiffrée, etc.). Si le serveur est perdu, cette clé est la seule +façon de déchiffrer les sauvegardes existantes. Exemple pour la récupérer (à +lancer depuis le Mac, le fichier arrive en clair sur le Mac donc à stocker +immédiatement dans un endroit sûr puis à effacer du téléchargement) : +``` +scp paillasse:/root/.age/cle-sauvegardes.txt ~/Desktop/ +# puis la mettre immédiatement dans un gestionnaire de mots de passe, +# et supprimer la copie en clair sur le Desktop. +``` + +## Identifiants Dedibackup + +Dans `/root/.dedibackup` (permissions `600`, jamais dans Git) : +`DEDIBACKUP_HOST`, `DEDIBACKUP_USER`, `DEDIBACKUP_PASS` — **valeurs entre +guillemets simples** si elles contiennent des caractères spéciaux (`#`, `$`, +espace…), sinon `bash` les tronque silencieusement en les lisant. + +## Lancer une sauvegarde manuellement + +``` +ssh paillasse +sudo systemctl start sauvegarde-dedibackup.service +sudo journalctl -u sauvegarde-dedibackup.service --no-pager | tail -20 +``` + +## Lister les sauvegardes présentes sur Dedibackup + +``` +sudo bash -c ' +source /root/.dedibackup +lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" \ + -e "set ftp:ssl-allow no; cls -1 *.tar.zst.age; quit" +' +``` + +## Restaurer un fichier (procédure testée le 2026-10-07) + +``` +ssh paillasse +sudo bash -c ' +set -e +source /root/.dedibackup +RESTORE_DIR=/tmp/restauration-$(date +%Y%m%d-%H%M) +mkdir -p "$RESTORE_DIR" + +# 1. Télécharger l'archive voulue depuis Dedibackup +lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" \ + -e "set ftp:ssl-allow no; get quotidienne-AAAAMMJJ.tar.zst.age -o $RESTORE_DIR/archive.tar.zst.age; quit" + +# 2. Déchiffrer puis extraire (tout le contenu, ou un chemin précis en argument de tar) +age -d -i /root/.age/cle-sauvegardes.txt "$RESTORE_DIR/archive.tar.zst.age" \ + | tar -I zstd -xf - -C "$RESTORE_DIR" etc/un/fichier/precis + +ls -la "$RESTORE_DIR" +' +``` + +Pour tout restaurer (sans préciser de chemin après `-xf -`), **ne jamais le +faire directement vers `/etc`, `/home` ou `/root`** sans réfléchir : ça +écraserait l'état actuel. Toujours restaurer vers un répertoire temporaire +(`/tmp/...`) puis copier à la main ce qui est nécessaire. + +Les chemins dans l'archive n'ont **pas** de `/` ni de `./` au début (ex. +`etc/hostname`, pas `/etc/hostname` ni `./etc/hostname`) — `tar` les a retirés +à la création. + +## Vérifié le 2026-10-07 + +- Sauvegarde envoyée : `quotidienne-20261007.tar.zst.age`, confirmée présente sur Dedibackup via `cls`. +- Restauration testée : `etc/hostname` extrait dans `/tmp/test-restauration-20261007/`, contenu conforme (`sd-124443`).