# Sauvegarde — protéger un parc domestique _Guide de reconstruction. Il décrit le système de sauvegarde d'un petit parc de machines : ce qu'il protège, comment il fonctionne, et surtout ce qu'il ne couvre pas._ ⚠️ **Trois lignes de ce document dépassent 84 caractères et s'enrouleront dans le PDF sans signe visible : les trois lignes de cron — la sauvegarde du serveur de musique, l'image de la clé d'amorçage et le relevé du tableau de bord.** Cron n'accepte aucune continuation — **les recopier depuis le fichier `.md`, jamais depuis le PDF.** Toutes les autres commandes longues ont été coupées par continuation `\`. **Le fichier `.md` de cette page :** [ouvrir le `.md`](https://blog.infolaf.ca/wp-content/uploads/fichiers/guide-sauvegarde-public.md) **Ce que cette page couvre :** l'inventaire des **données** à protéger et les machines qui les portent, le mécanisme commun à toutes les sauvegardes du parc — un démon rsync sur chaque machine, un serveur qui tire —, les quatre niveaux de protection et leurs angles morts, **les douze scripts de tirage écrits en entier**, les moyens de savoir que tout cela fonctionne encore, et **le tableau de bord qui les relève chaque nuit, ses deux scripts compris**. Remettre les données en place fait l'objet de la page **[Restauration](https://blog.infolaf.ca/wiki/recuperation-restauration-reconstruction/)**. **Contexte :** un serveur qui cumule le rôle de NAS et d'hôte de conteneurs, un conteneur web sous ISPConfig, un poste de travail. Les adresses, noms d'hôtes et chemins sont des exemples à transposer. **Quatre choses valent le détour même sans monter le même système.** Que le filtrage des modules rsync s'applique **côté serveur** et non côté client — ce qui décide de ce qu'un mot de passe volé donnerait vraiment : section *Le filtrage*. Que ce filtre est pourtant écrit **deux fois**, dans le module et dans le script qui le tire, et qu'élargir l'un sans l'autre ne transporte rien de plus — sans qu'aucune erreur ne le dise : même section. Que les angles morts des niveaux hors site doivent être **complémentaires plutôt que superposés**, ce qui est le seul raisonnement qui fasse tenir l'ensemble : section *Les quatre niveaux*. Et qu'une tâche qui **réussit à ne rien faire** est plus difficile à repérer qu'une tâche qui échoue : section *Savoir que ça fonctionne*. **Convention : chaque bloc de commandes indique la machine où il s'exécute.** --- ## Ce qui est protégé L'inventaire des **données**, avant tout mécanisme. C'est cette liste qui permet de repérer un oubli — pas la liste des scripts, qui ne peut par construction que décrire ce qui existe déjà. | Donnée | Où elle vit | Volume | |---|---|---| | Sites web — contenu réel : **blog, photos, webdav** | `web-srv` : `/var/www`, disque `sdb2` monté depuis l'hôte | ~59 Go | | Bases MySQL (6) | `web-srv` : `/var/lib/mysql` | ~68 Mo en vidages | | Agendas et carnets d'adresses (Radicale) | `web-srv` : `/var/lib/radicale/collections` | faible | | Configurations de service | `/etc/` des trois machines : bind, samba, nfs, icecast2, ices2, rsyncd | faible | | Crontabs | `/var/spool/cron/crontabs/` de nas-host et du conteneur web | faible | | Scripts | `/home/hostadmin/scripts`, `/home/webadmin/scripts`, `/home/user/Scripts` | faible | | Bibliothèque musicale — métadonnées | nas-host : MusicIP, MusicMagic, bliss | faible | | Configurations d'applications du poste | poste-bureau : filezilla, puddletag, quodlibet | faible | | Machines virtuelles VirtualBox | poste-bureau : `/home/user/VirtualBox_VMs` | important | | Systèmes des conteneurs eux-mêmes | pool ZFS `local` de nas-host | ~7,4 Go en archives | | Bibliothèque audio | nas1 | important | | Photographies originales | nas1 **et** dépôt chiffré hors site | important | **La surface réelle est plus petite que l'inventaire des sites.** Neuf sites sont déclarés dans le panneau d'hébergement, six sont actifs, mais **trois seulement hébergent des fichiers** : | Site | Actif | Nature | |---|---|---| | `blog.example.com` | ✅ | contenu | | `photos.example.com` | ✅ | contenu (Piwigo) | | `dav.example.com` | ✅ | contenu (WebDAV) | | `lt.example.com` | ✅ | proxy inverse seul → LanguageTool | | `musique.example.com` | ✅ | proxy inverse seul → Navidrome | | `routeur.example.com` | ✅ | proxy inverse seul → admin du routeur | | `cumulus.example.com` | ❌ | désactivé | | `ha.example.com` | ❌ | désactivé | 🎯 **Un proxy inverse ne porte aucun fichier à sauvegarder**, seulement une configuration Apache — que les modules de configuration emportent déjà. Confondre les deux catégories fait croire à six sites à protéger là où il y en a trois, et le blog est le seul dont la perte serait irremplaçable. **Les arbitrages d'importance, pour ne pas les redécouvrir :** - **`photos.example.com`** est une vitrine reconstructible. Les photographies originales sont sauvegardées hors site ; le site n'en est qu'une présentation. - **`webdav`** est une expérimentation. Son contenu existe déjà sur le NAS et, en bonne partie, hors site. Ce n'est pas une donnée unique. - **La bibliothèque audio** n'est pas encore déposée hors site — le ménage des fichiers n'est pas terminé. Elle est couverte par nas1 et par le disque externe chiffré. - **Délibérément non couverts** : les vidéos, pour leur volume ; les systèmes d'exploitation des hôtes, qui se reconstruisent depuis ce wiki. ⚠️ **Un service retiré doit sortir de cet inventaire en même temps que de la crontab.** Un serveur de statistiques autonome a vécu ici en doublon d'une extension du blog, avec sa base, son module et ses copies, longtemps après avoir cessé d'être utile. Tant qu'une ligne reste dans le tableau, elle donne à la sauvegarde une étendue qu'elle n'a plus. --- ## Les machines et le stockage de destination | Machine | IP | Rôle | |---|---|---| | **nas-host** | `192.168.0.11` (eno2) | Hôte Incus **et** NAS. Porte nas1/nas2, Samba, NFS, Icecast2. **Orchestre toutes les sauvegardes.** | | `web-srv` | `192.168.0.6` | Conteneur : Apache/ISPConfig, MariaDB, Radicale, BIND, proxy inverse | | `pi-hole` | `192.168.0.5` | Conteneur : DNS | | `squid-proxy` | `192.168.0.7` | Conteneur : mandataire | | `navidrome` | `192.168.0.8` | Conteneur : diffusion musicale | | `languagetool` | `192.168.0.10` | Conteneur : correction linguistique | | **poste-bureau** | `192.168.0.106` | Poste de travail Linux Mint | ⚠️ **La seconde carte réseau de l'hôte n'est pas disponible pour autre chose.** `eno1` (`192.168.0.2`) porte le réseau macvlan des conteneurs, et c'est elle qui permet à l'hôte de joindre ses propres conteneurs — donc de les sauvegarder. La réaffecter couperait la chaîne entière sans toucher à un seul script. ### Un seul disque, deux noms Le volume de destination est **un disque unique** (`/dev/sda1`, ext4, 12,7 To) monté **une seule fois**, sur `/media/nas1-14TB`. Il contient deux répertoires, `nas1` et `nas2`, que deux **montages liés** exposent comme s'il s'agissait de deux disques : | Point de montage | Réalité | |---|---| | `/media/nas1-14TB` | `/dev/sda1`, ext4, 12,7 To — le montage réel | | `/media/nas1` | montage lié de `/media/nas1-14TB/nas1` | | `/media/nas2` | montage lié de `/media/nas1-14TB/nas2` | Les lignes correspondantes de `/etc/fstab` : ``` UUID=4788d777-… /media/nas1-14TB ext4 defaults,acl 0 2 /media/nas1-14TB/nas1 /media/nas1 none bind /media/nas1-14TB/nas2 /media/nas2 none bind ``` Les anciennes lignes qui montaient `nas1` et `nas2` par UUID sont **commentées** : vestiges de l'époque où c'étaient deux disques. 🔴 **Ces deux noms viennent d'une époque révolue, et ils trompent encore.** Écrire « sur nas2 » ne met rien à l'abri de ce qui menace nas1 — voir *Fragilités connues*, où la conséquence est développée. Le nommage a survécu au matériel qui le justifiait ; c'est la forme la plus discrète de fausse sécurité, parce qu'elle ne repose sur aucune erreur technique. **Note d'entretien — la réserve ext4.** `mkfs.ext4` réserve 5 % du volume à root, soit **652 Gio** sur 13 To. Cette marge est justifiée sur une partition système, où elle permet à root de réparer un disque plein ; elle est sans objet sur un volume de pures données. Ramenée à 1 % : ```bash sudo tune2fs -m 1 /dev/sda1 ``` Cela rend **522 Gio** tout en laissant 130 Gio à l'allocateur, ce qui évite la fragmentation. ⚠️ **Ne jamais appliquer cela à `/`.** --- ## Le principe : tirer, jamais pousser Chaque machine à sauvegarder **expose des modules** par un démon rsync, sur le port 873, déclarés dans son `/etc/rsyncd.conf`. Le serveur de sauvegarde **va les chercher**. ``` serveur ──rsync://──► conteneur web ──► /media/nas1/Backups/ serveur ──rsync://──► lui-même ──► /media/nas1/Backups/ serveur ──rsync://──► poste de travail ──► /media/nas1/Backups/ ``` La syntaxe `::` désigne le démon, pas SSH : ``` rsync -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::photos/ \ /media/nas1/Backups/web-srv_www/photos ``` **Trois propriétés découlent de ce choix, et ce sont elles qui justifient tout le reste.** **1. Tout passe par le réseau.** Aucun accès au stockage des conteneurs depuis l'hôte n'est nécessaire. ℹ️ C'est ce qui a permis de migrer l'hôte d'un gestionnaire de conteneurs à un autre **sans réécrire un seul chemin de sauvegarde** : les modules ne savent pas où vivent les données, ils savent seulement les servir. **2. Les modules sont en lecture seule.** Le démon ne peut rien écrire. ⚠️ **Conséquence à connaître avant d'en avoir besoin : le chemin de restauration n'est pas l'inverse du chemin de sauvegarde.** On ne restaure pas en renversant une commande — voir la page **[Restauration](https://blog.infolaf.ca/wiki/recuperation-restauration-reconstruction/)**. **3. Le trafic n'est pas chiffré.** Le mot de passe est protégé par défi-réponse, mais les données circulent en clair. Compromis assumé sur un réseau domestique fermé ; à ne pas reproduire au-delà. **Le contrôle d'accès tient en trois couches**, et aucune ne suffit seule : | Couche | Ce qu'elle fait | |---|---| | `hosts allow = 192.168.0.11` | Seul le serveur de sauvegarde peut ouvrir une connexion | | `auth users = test` | Un compte propre au démon, sans existence sur le système | | `secrets file` + `--password-file` | Le mot de passe, côté serveur et côté client | **Le compte `test` n'est pas un compte Unix.** Le démon tient sa propre liste ; ce nom n'ouvre aucune session, aucun shell, et n'apparaît pas dans `/etc/passwd`. ⚠️ **Le fichier de mots de passe doit être en `600`**, sans quoi rsync refuse de s'en servir. C'est une des rares protections que l'outil impose plutôt que de suggérer. --- ## Les quatre niveaux, et ce que chacun ne couvre pas C'est la section la plus utile de cette page. **Une sauvegarde qu'on croit plus large qu'elle ne l'est vaut moins qu'une sauvegarde dont on connaît les limites.** | Niveau | Quoi | Quand | Où | |---|---|---|---| | **Quotidien** | Données des services, copie cohérente | Chaque nuit | `/media/nas1/Backups/` | | **Mensuel** | Export complet des conteneurs | Le 1er, deux mois conservés | `/media/nas1/Backups/incus-exports/` | | **Hors site** | Copie du mensuel, plus l'image de la clé de démarrage | Continu, versions conservées 30 jours | Proton Drive | | **Figé** | État d'avant une migration | Ponctuel | À ne jamais purger | **Aucun ne suffit seul.** Le quotidien restaure des données, le mensuel reconstruit un serveur, le hors site survit à la perte de la machine, le figé permet le retour en arrière. **Et voici ce que chacun laisse passer :** | Niveau | Ne couvre pas | |---|---| | Quotidien | La perte du serveur lui-même — les copies y sont | | Mensuel | Le contenu des disques montés depuis l'hôte : `incus export` **ne les capture pas** | | Hors site | L'erreur et la malveillance **au-delà de trente jours** : les versions antérieures expirent, et une compromission du compte les atteindrait aussi | | Hors site | Tout ce qui n'est pas dans son périmètre : les fichiers des sites, la bibliothèque audio | | Disques externes | L'écart depuis la dernière copie manuelle — jusqu'à deux mois | | Disques externes | Un sinistre au domicile pendant qu'ils y sont branchés | | Tous | Les systèmes d'exploitation : **reconstruits depuis le wiki, pas restaurés** | ### Le recoupement qui fait tenir l'ensemble C'est le raisonnement à retenir, et il vaut au-delà de ce parc : **les angles morts des niveaux hors machine doivent être complémentaires, pas superposés.** - Ce que le nuage ne porte pas — les fichiers des sites, l'audio —, les disques externes le portent. - Ce que le nuage ne protège que trente jours — l'erreur, la corruption, le rançongiciel —, la **coupure physique** des disques le protège sans limite de durée. - Ce que les disques n'ont pas — la fraîcheur —, le nuage l'a. ⚠️ **Une fenêtre de conservation ne vaut que ce que vaut la fréquence d'inspection.** Trente jours de versions antérieures ne protègent que les erreurs remarquées dans le mois. Une archive à laquelle on ne touche jamais est justement celle dont on découvrira le problème trop tard — d'où le niveau débranché, qui n'a pas d'échéance. Il ne reste qu'un seul point où les deux échouent ensemble : un sinistre au domicile, disques présents, sur des données modifiées depuis la dernière copie manuelle. **Risque réel mais étroit, et résultat d'un arbitrage assumé plutôt que d'un oubli.** ⚠️ **Deux copies au même endroit ne font pas deux niveaux.** Une réplication continue vers le nuage et une sauvegarde locale protègent du même sinistre — la panne matérielle — et d'aucun autre. C'est le débranchement qui crée le second niveau, pas la duplication. --- ## Anatomie d'un module Tous les modules suivent la même forme. La connaître dispense de relire chacun. ``` [nom_du_module] uid = compte gid = groupe path = /chemin/servi exclude = lost+found/ ** include = fichier-a-servir hosts allow = 192.168.0.11 comment = Ce qui s'affiche dans la liste des modules read only = true auth users = test secrets file = /etc/rsyncd.scrt ``` **`uid` et `gid` décident de ce que le démon peut lire.** Un module qui sert un site déclare le compte de ce site ; un module qui sert `/etc` déclare `root`. **`comment` n'est pas décoratif.** Il s'affiche quand on liste les modules d'une machine, et c'est ce qui rend l'inventaire lisible : ``` rsync --list-only rsync://192.168.0.6/ ``` **Écrire des libellés descriptifs plutôt que « `Backup ` »**, qui ne fait que répéter le nom. La liste ci-dessus est le seul endroit où l'on voit tout le parc d'un coup d'œil ; autant qu'elle dise quelque chose. ⚠️ **Aucun redémarrage n'est nécessaire après une modification.** Le démon relit sa configuration **à chaque connexion entrante**. Un `systemctl restart` après édition ne fait que couper les transferts en cours. --- ## Le filtrage vit côté serveur, pas côté client C'est le point le plus important de cette page, et le plus facile à manquer. **Le défaut, tel qu'il existait ici.** Plusieurs modules déclaraient `path = /etc/` sans aucun filtre, et le tri était fait **côté client**, dans les scripts, par des `--include` sur la ligne de commande. Ça fonctionne — les scripts ne récupèrent que ce qu'ils demandent. Mais : ``` rsync --list-only --password-file=… test@192.168.0.11::rsync_nas-host/ ``` …retournait **tout `/etc/`**. Deux cents entrées, dont `shadow`, `gshadow`, `sudoers`, le fichier de mots de passe du démon lui-même, et les répertoires `ssh/` et `letsencrypt/`. 🔴 **Le filtre client dit ce qu'on demande, pas ce qu'on pourrait demander.** Le mot de passe du démon protégeait l'accès, donc il n'y avait pas d'escalade — mais **une fuite de ce seul mot de passe aurait livré tout `/etc/` au lieu d'un fichier de configuration.** La différence entre les deux situations ne se voit dans aucun journal et n'apparaît dans aucun test fonctionnel : les sauvegardes marchaient parfaitement dans les deux cas. **Le correctif**, deux lignes dans le module : ``` exclude = lost+found/ ** include = rsyncd.conf ``` Après quoi le même `--list-only` ne rend plus que deux lignes. ⚠️ **L'ordre de ces deux lignes dans le fichier n'a aucune importance.** Le démon assemble les règles **par type de paramètre**, dans un ordre fixe où `include` précède toujours `exclude`. La première règle qui correspond l'emporte : `rsyncd.conf` est retenu avant que le `**` ne rejette le reste. C'est contre-intuitif pour qui a l'habitude des filtres en ligne de commande, où l'ordre d'écriture fait loi. **Un effet de bord instructif.** En élargissant un motif de `ices2_*_config.xml` à `ices2*config.xml`, un fichier a été récupéré que l'ancien motif ne pouvait pas atteindre — il exigeait quelque chose entre `ices2_` et `_config.xml`. Ce fichier n'avait **jamais** été sauvegardé, et rien ne le signalait : un filtre trop étroit ne produit pas d'erreur, il produit un silence. **La règle qui en découle**, et qui vaut pour tout module : *réduire ce qu'un composant **peut** faire, pas seulement ce qu'il fait.* ### 🔴 Mais le filtre est écrit DEUX fois Le filtre du module dit ce qu'on **peut** demander. Le script de tirage, lui, garde le sien, qui dit ce qu'on **demande**. Les deux existent, et **rien ne signale leur désaccord.** Le cas se pose dès qu'on ajoute un fichier à ce qui est sauvegardé — par exemple `/etc/apache2/.htpasswd-routeur`, qui protège un mandataire. Élargir le module : ``` exclude = lost+found/ ** include = rsyncd.conf apache2/ .htpasswd-routeur ``` ⚠️ **Le répertoire doit être nommé en plus du fichier** : le `**` exclut tout, donc rsync ne descend pas dans `apache2/` si on ne l'ouvre pas. Le démon servait alors bien le fichier. **Et la sauvegarde ne le recevait toujours pas** : le script portait encore `--include='rsyncd.conf' --exclude='**'`, et jetait ce que le démon lui offrait. Le tirage se terminait à `échecs : 0`. **Les deux lignes doivent être élargies ensemble** — celle du module, et celle du script : ``` --exclude='lost+found/' --include='rsyncd.conf' --include='apache2/' \ --include='apache2/.htpasswd-routeur' --exclude='**' ``` **Dans un script, l'ordre d'écriture fait loi** — contrairement au fichier du démon, qui assemble ses règles par type. Les `--include` doivent précéder le `--exclude='**'`, sinon ils ne sont jamais atteints. ### ✅ Et la démonstration que le serveur commande Le même jour, une correction trop large a fait réclamer `apache2/` par le script qui tire la configuration de **nas-host** — machine dont le module, lui, est resté filtré à `rsyncd.conf`. **Rien n'est arrivé.** Le répertoire de destination ne contient toujours que `rsyncd.conf`. Le client a demandé, le démon n'a pas servi. **C'est la preuve, faite par accident, que c'est bien le filtre du module qui borne — et non celui du script.** --- ## L'exception : le seul module inscriptible Un module du parc porte `read only = false`. Il sert à **téléverser** des images vers la galerie d'un site, pas à sauvegarder. ``` [gallerie] uid = web2 gid = client1 path = /var/www/clients/client1/web2/web/galleries hosts allow = 192.168.0.11 comment = Upload photos.example.com read only = false auth users = test secrets file = /etc/rsyncd.scrt ``` ⚠️ **Son chemin est à l'intérieur de la racine web d'un site en ligne.** Un module inscriptible pointant là où le serveur web sert des fichiers mérite d'être connu pour ce qu'il est : ce qui borne le risque n'est pas le module mais le `hosts allow`, qui n'autorise qu'une seule machine à ouvrir la connexion. **Ne pas s'en servir comme chemin de restauration.** Il ne vise qu'un répertoire, et l'employer ainsi ferait de la seule brèche voulue un outil général. **Ce module a son propre article.** Ce qui l'emploie — la tâche nocturne qui pousse les photos depuis le NAS, son garde-fou, et la galerie qui les reçoit — est décrit dans [Piwigo](https://blog.infolaf.ca/wiki/piwigo/). Les neuf lignes ci-dessus sont reproduites ici parce qu'elles portent tout le propos de cette section ; la déclaration qui fait foi reste celle du fichier complet. ⚠️ **Une tâche qui pousse avec `--delete` doit vérifier sa source avant d'agir.** Un répertoire source démonté se présente comme un répertoire vide, et la synchronisation propage cette vacuité à la destination — dans le cas présent, une galerie entière. C'est le seul endroit du parc où ce risque existe, précisément parce que c'est le seul module en écriture. --- ## Les modules du conteneur web Le conteneur expose treize modules. Leur déclaration complète — `/etc/rsyncd.conf` en entier, prêt à recopier — figure dans [Serveur web – Apache multi-PHP et ISPConfig](https://blog.infolaf.ca/wiki/serveurweb-apachemulti-php-ispconfig/), section *Le démon rsync*. **C'est là qu'on en a besoin : pendant qu'on rebâtit cette machine.** Un fichier de configuration n'est écrit en entier que dans un seul article ; les autres y renvoient, faute de quoi deux copies finissent par diverger et c'est toujours celle qu'on ne relit pas qui est fausse. Ce qui compte ici est ce que chaque module expose, pour savoir ce qui est sauvegardé et ce qui ne l'est pas : | Module | Ce qu'il expose | |---|---| | `blog` | les fichiers du site WordPress | | `webdav` | le partage WebDAV de `dav.example.com`, **répertoire parent compris** — donc son fichier de mots de passe `.htdigest` | | `photos` | les fichiers du site Piwigo, galeries comprises | | `gallerie` | **en écriture** — le téléversement des photos, pas une sauvegarde | | `cumulus` | les fichiers d'un site désactivé, conservés | | `mysql` | les vidages de bases produits par le conteneur | | `scripts_NS2` | les scripts du conteneur | | `radicale_users` | configuration et comptes du serveur de calendriers | | `radicale_data` | les calendriers et carnets d'adresses eux-mêmes | | `rsync_NS2` | `/etc/`, filtré à `rsyncd.conf` **et** `apache2/.htpasswd-routeur` | | `crontab_NS2` | la crontab du compte applicatif, filtrée | | `www-full` | `/var/www/` en entier, sans filtre | | `log_NS2` | le journal des tâches nocturnes du conteneur | **Quatre remarques sur ces modules.** ⚠️ **`crontab_NS2` doit être filtré**, sinon il sert **toutes** les crontabs du système, celle de root comprise. Le `include` nomme le seul compte dont la crontab a une valeur propre ; celle de root n'en a pas ici, ses deux lignes étant posées par l'installateur du panneau et donc reproductibles. ⚠️ **`www-full` recouvre plusieurs autres modules.** Il sert `/var/www/` en entier — donc les fichiers de `blog`, `photos` et `webdav` une seconde fois, vers une autre destination. Ce n'est pas un doublon inutile : c'est ce qui fait que la suppression d'un site se propage sans qu'on touche à rien, et c'est aussi ce qui explique qu'un `--delete` puisse retirer des dizaines de milliers de fichiers d'un coup après un ménage. **`log_NS2` sert le journal des tâches nocturnes du conteneur.** Sans lui, savoir si un vidage de base a échoué obligerait à entrer dans le conteneur pour lire un fichier. Exposer son propre journal est ce qui rend la surveillance possible depuis un seul endroit. **Les modules que nas-host expose pour lui-même** — sa propre configuration, ses scripts — sont décrits dans la [page de l'hôte](https://blog.infolaf.ca/wiki/incus-hote-et-conteneur-serveur-nas/), qui en a besoin pour se reconstruire. **Les scripts qui tirent tout cela, en revanche, sont sur cette page** : section *Les scripts de tirage*. --- ## Les modules du poste de travail Le poste sert sept modules, tous en lecture seule, et c'est le seul démon du parc dont la déclaration ne figure dans aucune page de machine. | Module | Chemin servi | Tiré par | |---|---|---| | `scripts_Beelink` | `/home/user/Scripts` | `backup_scripts_poste-bureau.sh` | | `rsync_Beelink` | `/etc/`, **filtré côté serveur** sur `rsyncd.conf` | `backup_config_poste-bureau.sh` | | `filezilla_Beelink` | `/home/user/.config/filezilla` | `backup_config_poste-bureau.sh` | | `puddletag.local_Beelink` | `/home/user/.local/share`, filtré | `backup_config_poste-bureau.sh` | | `puddletag.config_Beelink` | `/home/user/.config`, filtré | `backup_config_poste-bureau.sh` | | `quodlibet.config_Beelink` | `/home/user/.config`, filtré | `backup_config_poste-bureau.sh` | | `virtualbox_Beelink` | `/home/user/VirtualBox_VMs` | `backup_virtualbox_poste-bureau.sh` | ⚠️ **Quatre de ces modules servent le même répertoire parent, `/home/user/.config`, chacun filtré sur ce qui le concerne.** C'est la forme la plus économique — un seul chemin, plusieurs vues — et c'est aussi celle où élargir un filtre de trop ouvre le répertoire entier. Le filtre est ce qui tient le module, pas le chemin. 🔴 **Le chemin de `filezilla_Beelink` a été faux pendant des années**, pointant vers `~/.filezilla` alors que le logiciel avait déménagé sa configuration vers `~/.config/filezilla` plusieurs versions plus tôt. Le module échouait à chaque nuit, la destination gardait une copie figée d'avant le déménagement, **et rien ne le disait**. C'est l'examen annuel des modules qui l'a trouvé — voir *Savoir que ça fonctionne*. ## Le module du conteneur Pi-hole Le filtre DNS n'expose qu'un seul module, et il ne sert pas des fichiers de configuration vivants : il sert une **archive** que le conteneur fabrique lui-même chaque nuit, dix minutes avant d'être tiré. | Module | Ce qu'il sert | |---|---| | `config_pihole` | les archives de configuration déposées dans `/var/backups/pihole` | 🎯 **Pourquoi une archive plutôt que les fichiers eux-mêmes.** La configuration du filtre tient dans un fichier de réglages et une base SQLite en cours d'usage. Copier une base pendant qu'elle est ouverte donne une copie dont rien ne garantit la cohérence. L'outil d'export livré avec le filtre produit une archive cohérente avec elle-même, et ne retient que la part personnelle — ni l'historique des requêtes, ni les dizaines de milliers de domaines téléchargés, qui se régénèrent d'une commande. C'est le raisonnement qui fait préférer un vidage de base à une copie de `/var/lib/mysql`. **La fabrication est déclenchée depuis l'hôte, pas par une tâche interne au conteneur.** La raison est le mode de défaillance : si le conteneur ne démarre pas, une tâche interne n'écrit aucun rapport — le journal ne contient pas une erreur, il ne contient rien, et l'absence est ce qui se repère le plus difficilement. Lancée de l'extérieur, la tâche échoue bruyamment dans le journal de l'hôte à l'heure prévue. ⚠️ **Ce que l'archive ne contient pas.** Elle ne connaît que ce que le filtre connaît. La configuration du relais qui chiffre la sortie DNS lui échappe entièrement ; le script la copie donc à côté, dans un sous-dossier que le même module emporte. **Toute pièce installée sur cette machine hors du filtre appelle le même geste**, sans quoi la sauvegarde couvre moins que ce qu'elle paraît couvrir. Le script de fabrication, le démon du conteneur et sa surcharge sont écrits au complet dans [Pi-hole](https://blog.infolaf.ca/wiki/pi-hole-filtrage-resolution-noms-reseau-local/) : c'est là qu'on rebâtit cette machine. ## Le script qui fabrique les vidages de bases Il tourne **dans le conteneur**, pas sur le serveur de sauvegarde : c'est là que sont les bases. Le serveur ne fait que tirer le résultat par le module `mysql`. Le script complet — `/home/webadmin/scripts/backup_mysql.sh` et sa tâche planifiée — figure dans [Serveur web – Apache multi-PHP et ISPConfig](https://blog.infolaf.ca/wiki/serveurweb-apachemulti-php-ispconfig/), section *Le vidage des bases*, pour la même raison que ci-dessus. Ce qui suit est ce qu'il faut comprendre de son fonctionnement pour juger si la sauvegarde tient réellement. **Cinq décisions valent d'être expliquées, parce qu'aucune n'est évidente.** **1. Il découvre les bases plutôt que de les nommer.** `SHOW DATABASES` moins les schémas système. Une base ajoutée est sauvegardée dès le lendemain sans qu'on y pense, et une base supprimée cesse d'elle-même d'être vidée. ⚠️ Un script qui liste les bases en dur échoue en silence le jour où l'on en ajoute une — et c'est la sorte d'oubli qu'on découvre en cherchant une sauvegarde qui n'existe pas. **2. Il écrit d'abord dans un fichier temporaire, puis renomme.** Si `mysqldump` échoue à mi-parcours, **la version de la veille reste intacte**. Écrire directement sur la destination remplacerait une bonne sauvegarde par une mauvaise, ce qui est pire que de ne pas sauvegarder du tout. **3. Il refuse de continuer si la liste des bases est vide.** Une liste vide ne veut pas dire « rien à sauvegarder », elle veut dire « MySQL ne répond pas ». Sans ce garde-fou, le script se terminerait avec succès en n'ayant rien fait. **4. Il nettoie les vidages orphelins — mais seulement si tout a réussi.** Un fichier dont la base n'existe plus resterait indéfiniment, et le `--delete` du tirage le recopierait fidèlement puisqu'il existe encore à la source. ⚠️ **Le garde-fou est le cœur de cette partie** : si MySQL rendait une liste partielle, le nettoyage effacerait des sauvegardes parfaitement valides. **Un script de sauvegarde qui détruit sur une erreur est pire que celui qui ne nettoie pas.** **5. Les identifiants sont dans `~/.my.cnf`, jamais sur la ligne de commande.** Un mot de passe passé en argument est visible par tout compte de la machine dans la liste des processus, le temps de l'exécution. ⚠️ **Ce fichier d'identifiants existe et doit être en `600`.** Cette page dit qu'il existe, où il est et quelles permissions il porte — jamais ce qu'il contient. La même règle vaut pour le fichier de mots de passe du démon rsync. --- ⚠️ **Ne jamais lancer ce script en root.** Ses identifiants sont dans le `~/.my.cnf` du compte du conteneur. Lancé par root, il écrirait des vidages **appartenant à root** dans le répertoire de ce compte — que l'exécution suivante, redevenue normale, ne pourrait plus remplacer. La sauvegarde se figerait alors sur la version produite ce jour-là, avec des succès sincères à chaque nuit suivante. 🎯 **C'est le même motif que partout ailleurs sur cette page : le dégât n'est pas l'échec, c'est le succès qui ne fait plus rien.** Un `chown` corrige l'accident ; encore faut-il savoir qu'il a eu lieu. Deux protections de forme complètent les décisions ci-dessus : un **compteur d'échecs par base** avec `exit $ERREURS`, pour qu'un échec sur une base parmi six ne se perde pas dans le succès des autres ; et les **marqueurs datés de début et de fin**, qui donnent l'heure et la durée — deux secondes en régime normal, ce qui rend toute dérive immédiatement lisible. ## Les scripts de tirage Ils vivent tous dans `/home/hostadmin/scripts/rsync/auto/` sur le serveur de sauvegarde, et sont lancés par la crontab du compte `hostadmin` — reproduite dans la [page de l'hôte](https://blog.infolaf.ca/wiki/incus-hote-et-conteneur-serveur-nas/). Une seule exception : l'image de la clé d'amorçage, qui exige root et part d'un fichier de `/etc/cron.d/`. **Les lignes longues sont coupées ici par des continuations `\`, et la fonction `r` est écrite sur trois lignes plutôt qu'une.** Ce sont les deux seules libertés prises avec le texte réel : sur la machine, plusieurs lignes font deux cents caractères. **Rien d'autre ne diffère** — mêmes options, mêmes chemins écrits en entier, dans le même ordre. Les formes sont rigoureusement équivalentes ; celles-ci se recopient sans se casser dans un PDF ou une fenêtre étroite. ### Le squelette commun Tous suivent la même forme, et il vaut mieux la comprendre une fois que dix : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "nom_du_module" r echo "" echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` **Ce que chaque ligne apporte :** - **La ligne 2 charge le garde-fou de destination** — le veto décrit juste après. - **La fonction `r`** enveloppe chaque `rsync` : elle signale l'échec avec les deux derniers arguments, c'est-à-dire la source et la destination, et **incrémente un compteur**. Sans elle, le code de sortie du script serait celui de sa dernière commande, et un échec au milieu passerait inaperçu. - **L'étiquette `echo` avant chaque tirage** nomme le module dans le journal commun, où quatre scripts écrivent la même nuit. - **Les deux marqueurs `===`** encadrent l'exécution. ⚠️ **C'est sur eux qu'on cherche, jamais sur le mot « erreur »** : un journal de rsync est plein de noms de fichiers comme `error.log.3.gz` ou `awstats…errors404.html`, qu'une recherche par mot confond avec des messages. ```bash grep -E "^===" /var/log/backup/$(date +%F).log ``` - **`exit $ERREURS`** : le code de sortie **est** le nombre d'échecs. ### Le garde-fou de destination — un fichier, pas dix copies 🔴 **Une destination ne « manque » jamais devant rsync.** `/media/nas1` et `/media/nas2` sont des points de montage : **démontés, les chemins existent toujours** comme simples répertoires du disque système, et rsync y écrirait soixante gigaoctets sans se plaindre — remplissant `/` et fabriquant une sauvegarde qui n'est pas sur le disque de sauvegarde. Un chemin mal nommé, lui, est simplement créé. **Dans les deux cas l'écriture réussit.** C'est pourquoi le contrôle doit avoir lieu **avant** d'écrire, et pourquoi il vaut veto. `/home/hostadmin/scripts/rsync/auto/verifier_montages.sh`, au complet : ```bash #!/bin/bash # Garde-fou commun aux tâches de sauvegarde de nas-host. # Se charge en tête de chaque script par la ligne : # . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh # # Motif : /media/nas1 et /media/nas2 sont des points de montage. Démontés, # les chemins existent toujours comme simples répertoires du disque système, # et rsync y écrirait sans se plaindre — remplissant / et fabriquant une # sauvegarde qui n'est pas sur le disque de sauvegarde. # # Un « exit » dans un fichier chargé par « . » arrête le script appelant : # c'est ce qui permet à ce contrôle de valoir veto, et non simple avis. for MONTAGE in /media/nas1 /media/nas2; do if ! mountpoint -q "$MONTAGE"; then echo "ARRÊT : $MONTAGE n'est pas monté — aucune sauvegarde." exit 1 fi done ``` **La ligne à poser en tête de chaque script**, immédiatement après le shebang : ``` . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ``` **Le fichier est en `644`, volontairement non exécutable** : il n'a pas vocation à être lancé seul, et un `chmod +x` inviterait à le faire. 🎯 **Pourquoi un fichier commun plutôt que dix copies** : le jour où un troisième disque s'ajoute, il y a **une** place à corriger, et aucun script ne peut « oublier » la correction. ⚠️ **Il exige que les DEUX disques soient montés**, même pour les neuf scripts qui n'écrivent que sur `nas1`. C'est plus strict que nécessaire, et c'est délibéré : **une sauvegarde qui ne tourne pas se voit dans le journal du lendemain ; une sauvegarde écrite au mauvais endroit ne se voit jamais.** **Le veto se vérifie, il ne se suppose pas.** Le comportement d'`exit` dans un fichier chargé par `.` a été éprouvé dans les deux sens : ```bash V=/home/hostadmin/scripts/rsync/auto/verifier_montages.sh bash -c ". $V ; echo 'PASSE — la sauvegarde aurait lieu'" mkdir -p /tmp/pas-un-montage sed 's|/media/nas1|/tmp/pas-un-montage|' $V > /tmp/gf-test.sh bash -c ". /tmp/gf-test.sh ; echo 'PASSE — ne doit PAS apparaitre'" echo "code de sortie : $?" ``` Disques montés : `PASSE`. Disque absent simulé : le message `ARRÊT`, **aucun `PASSE`**, et code de sortie `1`. ### Le garde-fou de source — seulement là où il a un sens Le garde-fou ci-dessus protège la **destination**. Un seul script protège aussi sa **source**, et c'est celui qui tire les sites : leur source est distante, vue à travers le démon du conteneur, et `mountpoint` ne peut rien dire d'un montage qui est sur l'autre machine. **Le seuil a donc été mesuré avant d'être écrit** — `rsync --list-only` rend toujours une ligne pour le répertoire lui-même : ``` 2 lignes ou plus = le module a du contenu 1 ligne = module vide (/var/www non monté dans le conteneur) 0 ligne = module injoignable (conteneur arrêté, mot de passe refusé) ``` 🎯 **Le même test attrape deux pannes de natures différentes**, et toutes deux mènent au même désastre avec `--delete`. **Un module refusé compte comme un échec**, pas comme un succès silencieux : le script sort en `1` et sa ligne de bilan le dit. Un tirage sauté qui ne se voit pas serait exactement la panne muette que tout ce dispositif traque. ### L'inventaire Ce tableau sert à s'y retrouver ; **les scripts eux-mêmes sont écrits en entier ci-dessous**, et c'est de là qu'on recopie. | Script | Ce qu'il tire | Quand | |---|---|---| | `backup_mysql-ns2.sh` | les vidages de bases du conteneur web | 00 h 15 | | `backup_web-ns2.sh` | les fichiers des sites, et `/var/www` en entier | 00 h 20 | | `backup_pihole.sh` | l'archive de configuration du filtre DNS — **s'exécute dans le conteneur** | 00 h 35 | | `backup_scripts.sh` | les scripts du conteneur web et ceux de nas-host | 00 h 40 | | `backup_config.sh` | les configurations des trois machines, les calendriers, le journal | 00 h 45 | | `backup_scripts_poste-bureau.sh` | les scripts du poste de travail | 00 h 50 | | `backup_config_poste-bureau.sh` | les configurations d'applications du poste | 00 h 55 | | `backup_logiciels.sh` | les configurations d'applications de nas-host | 01 h 00 | | `backup_lyrion.sh` | la configuration du serveur de musique — **copie locale, par root** | 01 h 05 | | `upload_photos_…sh` | **pousse** les photos vers la galerie — voir [Piwigo](https://blog.infolaf.ca/wiki/piwigo/) | 01 h 30 | | `backup_virtualbox_poste-bureau.sh` | les machines virtuelles du poste | le 1er, 02 h 00 | | `backup_incus_export.sh` | l'export mensuel des conteneurs | le 1er, 03 h 00 | | `backup_cle_amorcage.sh` | l'image de la clé d'amorçage — section suivante | 04 h 00, par root | ⚠️ **`upload_photos_photos-webadmin-ca.sh` est le seul qui POUSSE**, et le seul qui ne charge pas le garde-fou commun — il n'écrit sur aucun des deux disques de sauvegarde, il lit `nas1` et écrit dans la galerie. Il porte donc son propre contrôle, en ligne : `mountpoint -q /media/nas1` **et** un test de source non vide. Son texte complet est dans [Piwigo](https://blog.infolaf.ca/wiki/piwigo/), qui décrit ce qu'il alimente. ### Les scripts, au complet `backup_mysql-ns2.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" r -avz --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::mysql/ \ /media/nas1/Backups/web-srv_MySQL_db echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` ⚠️ **Il ne fabrique rien : il tire.** Le vidage est produit dans le conteneur à minuit, et ce script vient le chercher un quart d'heure plus tard. Le lancer sans que l'autre ait tourné ne teste que la moitié de la chaîne. `backup_web-ns2.sh` — celui qui porte les deux garde-fous : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh # Tire les sites de web-srv (192.168.0.6) vers les disques de sauvegarde. # Chaque tirage est un miroir : --delete propage les suppressions. P=/home/hostadmin/scripts/rsync/auto/rsync_pass SRV=test@192.168.0.6 ERREURS=0 echo "=== $(basename "$0") — début $(date -Is)" echo "" # Garde-fou propre à ce script — un module vide ou injoignable n'est # jamais tiré. rsync --list-only rend toujours une ligne pour le # répertoire lui-même : # 2 lignes ou plus = le module a du contenu # 1 ligne = module vide (/var/www non monté dans le conteneur) # 0 ligne = module injoignable (conteneur arrêté, mot de passe) # Dans les deux derniers cas, --delete viderait la sauvegarde. tirer() { MODULE="$1"; shift N=$(rsync --list-only --password-file "$P" \ "$SRV::$MODULE/" 2>/dev/null | wc -l) if [ "$N" -lt 2 ]; then echo "ARRÊT : module $MODULE vide ou injoignable — non tiré." ERREURS=$((ERREURS+1)) return fi rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}" ERREURS=$((ERREURS+1)) } } tirer photos \ -avz --stats --delete --exclude='quota.*' --password-file "$P" \ "$SRV::photos/" /media/nas1/Backups/web-srv_www/photos tirer blog \ -avz --stats --delete --exclude='quota.*' --password-file "$P" \ "$SRV::blog/" /media/nas1/Backups/web-srv_www/blog tirer webdav \ -avz --stats --delete --exclude='quota.*' --password-file "$P" \ "$SRV::webdav/" /media/nas1/Backups/web-srv_www/webdav tirer www-full \ -avz --stats --delete --exclude='quota.*' --exclude='lost+found/' \ --password-file "$P" \ "$SRV::www-full/" /media/nas2/Backups/web-srv_www-full_path # Modules désactivés, conservés pour mémoire : # cumulus — ancien site # matomo — retiré le 7 septembre 2026, site supprimé # web — ancienne adresse 192.168.0.100 # webmail — jamais réactivé echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` **Le commentaire final n'est pas décoratif.** Quatre modules ont existé et n'existent plus ; sans cette liste, chacun redeviendrait un jour une question — « pourquoi celui-là n'est-il pas sauvegardé ? ». `backup_scripts.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "scripts_NS2" r -avz --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::scripts_NS2/ \ /media/nas1/Backups/Scripts/web-srv echo "" echo "scripts_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::scripts_nas-host/ \ /media/nas1/Backups/Scripts/nas-host echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` 🎯 **C'est ce script qui met les autres à l'abri.** Les douze scripts décrits sur cette page vivent dans `/home/hostadmin/scripts/`, et c'est le module `scripts_nas-host` qui les sauvegarde. Une reconstruction les récupère donc du disque de sauvegarde plutôt que de cette page — laquelle sert quand le disque, lui aussi, a disparu. `backup_config.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "Crontab_NS2" r -avz --stats --delete --exclude='lost+found/' \ --include='webadmin' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::crontab_NS2/ \ /media/nas1/Backups/Config/web-srv/crontab echo "" echo "Crontab_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='hostadmin' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::crontab_nas-host/ \ /media/nas1/Backups/Config/nas-host/crontab echo "" echo "rsync_NS2" r -avz --stats --delete --exclude='lost+found/' \ --include='rsyncd.conf' --include='apache2/' \ --include='apache2/.htpasswd-routeur' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::rsync_NS2/ \ /media/nas1/Backups/Config/web-srv/etc echo "" echo "rsync_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='rsyncd.conf' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::rsync_nas-host/ \ /media/nas1/Backups/Config/nas-host/etc echo "" echo "samba_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='smb.conf' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::samba_nas-host/ \ /media/nas1/Backups/Config/nas-host/ echo "" echo "nfs_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='exports' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::nfs_nas-host/ \ /media/nas1/Backups/Config/nas-host/ echo "" echo "Radical_NS2" r -avz --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::radicale_users/ \ /media/nas1/Backups/Config/web-srv/Radicale echo "" echo "Radical_data_NS2" r -avz --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::radicale_data/ \ "/media/nas1/Backups/Carnets d'adresses/Radical_server_data" echo "" echo "icecast2_config_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='icecast.xml' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::icecast2_config_nas-host/ \ /media/nas1/Backups/Config/nas-host/icecast2 echo "" echo "ices2_config_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='ices2_*_config.xml' --exclude='**' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::ices2_config_nas-host/ \ /media/nas1/Backups/Config/nas-host/ices2 echo "" echo "ices2_metadata_nas-host" r -avz --stats --delete --exclude='lost+found/' \ --include='metadata_*' --exclude='*.pid' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::ices2_metadata_nas-host/ \ /media/nas1/Backups/Config/nas-host/ices2_metadata echo "" echo "config_pihole" r -avz --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.5::config_pihole/ \ /media/nas1/Backups/Config/pi-hole echo "" echo "log_NS2" r -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.6::log_NS2/ \ /media/nas1/Backups/Config/web-srv/log echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` ⚠️ **Deux de ces tirages écrivent dans le même répertoire** — `samba_nas-host` et `nfs_nas-host` visent `Config/nas-host/`, chacun filtré à son seul fichier. Voir *Fragilités connues* : ce qui rend ce partage possible est aussi ce qui y laisse un fichier mort quand on retire un tirage. **Le chemin des carnets d'adresses porte une espace** et doit donc rester entre guillemets. Une recherche par expression régulière qui s'arrête au premier blanc le tronque — et fait croire à une destination manquante. `backup_scripts_poste-bureau.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "scripts_Beelink" r -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::scripts_Beelink/ \ /media/nas1/Backups/Scripts/Beelink/Scripts echo "" echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` `backup_config_poste-bureau.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "rsync_Beelink" r -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::rsync_Beelink/ \ /media/nas1/Backups/Config/Beelink/etc echo "" echo "filezilla_Beelink" r -avz --recursive --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::filezilla_Beelink/ \ /media/nas1/Backups/Configuration_logiciels/[.]filezilla/ echo "" echo "puddletag.local_Beelink" r -avz --recursive --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::puddletag.local_Beelink/ \ /media/nas1/Backups/Configuration_logiciels/puddletag/[.]local/share/ echo "" echo "puddletag.config_Beelink" r -avz --recursive --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::puddletag.config_Beelink/ \ /media/nas1/Backups/Configuration_logiciels/puddletag/[.]config/ echo "" echo "quodlibet.config_Beelink" r -avz --recursive --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::quodlibet.config_Beelink/ \ /media/nas1/Backups/Configuration_logiciels/quodlibet/[.]config/ echo "" echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` `backup_logiciels.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "musicip_nas-host" r -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::musicip_nas-host/ \ /media/nas1/Backups/Configuration_logiciels/musicip/MusicIP/ echo "" echo "MusicMagic_nas-host" r -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::MusicMagic_nas-host/ \ /media/nas1/Backups/Configuration_logiciels/musicip/[.]MusicMagic/ echo "" echo "bliss_nas-host" r -avz --stats --delete \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.11::bliss_nas-host/ \ /media/nas1/Backups/Configuration_logiciels/[.]bliss/ echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` `backup_lyrion.sh` — **le seul qui ne tire rien** : le serveur de musique vit sur l'hôte, la copie est donc locale et aucun module n'intervient. ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh # Sauvegarde de la configuration de Lyrion Music Server. # # Lyrion tourne directement sur nas-host : la copie est locale, aucun module # rsync n'est nécessaire. # # DOIT TOURNER EN ROOT, depuis /etc/cron.d/. Les répertoires internes de # certaines extensions sont en 0701 : le propriétaire a tout, les autres # peuvent traverser mais PAS lister. rsync doit énumérer avant de copier, # donc le compte hostadmin n'en verrait rien — et la sauvegarde contiendrait une # extension amputée de son binaire et de ses bibliothèques. Restaurée, une # telle extension se charge et échoue, ce qui est pire qu'une extension # franchement absente. # # Ces mêmes modes arrêtent tout lecteur venu d'ailleurs, y compris la # réplication hors site, qui lit par le partage NFS. L'export écrase # l'identité des clients vers uid 1000 / gid 150 : les copies sont donc # déposées avec la lecture ouverte au groupe 150, et rattachées à ce groupe. # « --chmod » et « --chown » ne touchent QUE la copie ; la source garde ses # modes, et le serveur de musique n'en sait rien. # # Deux périmètres, et un seul critère : ce qui ne se reconstruit pas. # prefs/ réglages, lecteurs, favoris, historique d'écoute # cache/InstalledPlugins/ le code des extensions — un dépôt amont peut # disparaître, et Lyrion efface sa propre liste # d'extensions au démarrage s'il trouve le # répertoire vide. # Le reste de cache/ est exclu : une réanalyse le refabrique vraiment. # # persist.db est une base SQLite que le serveur tient ouverte en permanence. # Ses transactions récentes vivent dans le fichier -wal et ne sont pas encore # dans le .db : une copie du seul .db peut être incohérente. On demande donc # la copie à SQLite lui-même — même principe que le vidage MySQL. # # Elle pèse 71 Mo et se réécrit EN ENTIER chaque nuit : rsync n'y peut rien, # c'est un fichier neuf à chaque passage. Les autres bases de prefs/ ne sont # pas traitées ainsi — elles appartiennent à des extensions qui ne les # tiennent pas ouvertes en permanence. set -u PREFS=/var/lib/squeezeboxserver/prefs PLUGINS=/var/lib/squeezeboxserver/cache/InstalledPlugins DEST=/media/nas1/Backups/Configuration_logiciels/lyrion GROUPE=150 ERREURS=0 # Sans ce refus, un lancement à la main sous hostadmin copierait tout SAUF les # répertoires en 0701, en signalant des erreurs qu'on prendrait volontiers # pour un incident passager. if [ "$(id -u)" -ne 0 ]; then echo "REFUS : doit tourner en root — voir /etc/cron.d/backup-lyrion" exit 1 fi r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" if [ ! -d "$PREFS" ]; then echo "ÉCHEC : $PREFS introuvable" echo "=== $(basename "$0") — fin $(date -Is) — échecs : 1" exit 1 fi mkdir -p "$DEST" # 1. Copie cohérente de la base des écoutes. # Écriture sous un nom temporaire, renommée seulement en cas de succès : # une copie tronquée ne doit jamais porter le nom d'une copie valide. # SQLite écrit sous root : le groupe et le mode se posent après coup, # pour que ce fichier suive la même règle que ceux de rsync. if command -v sqlite3 >/dev/null; then if sqlite3 "$PREFS/persist.db" ".backup '$DEST/persist.db.tmp'"; then mv "$DEST/persist.db.tmp" "$DEST/persist.db" chgrp "$GROUPE" "$DEST/persist.db" chmod g+r "$DEST/persist.db" echo "OK persist.db ($(du -h "$DEST/persist.db" | cut -f1))" else rm -f "$DEST/persist.db.tmp" echo "ÉCHEC : copie SQLite — la copie précédente est conservée" ERREURS=$((ERREURS+1)) fi else echo "ÉCHEC : sqlite3 absent — installer le paquet sqlite3" ERREURS=$((ERREURS+1)) fi # 2. Le reste de prefs/, sans les fichiers vivants de la base. r -av --delete \ --chmod=Dg+rX,Fg+r --chown=":$GROUPE" \ --exclude='persist.db' \ --exclude='persist.db-wal' \ --exclude='persist.db-shm' \ "$PREFS/" "$DEST/prefs/" # 3. Le code des extensions. # Un répertoire disparu est le symptôme même de l'incident que cette # sauvegarde existe pour couvrir : il compte comme un échec. if [ -d "$PLUGINS" ]; then r -av --delete \ --chmod=Dg+rX,Fg+r --chown=":$GROUPE" \ "$PLUGINS/" "$DEST/InstalledPlugins/" else echo "ÉCHEC : $PLUGINS introuvable" ERREURS=$((ERREURS+1)) fi echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` **Sa tâche planifiée ne vit pas dans la crontab** mais dans `/etc/cron.d/backup-lyrion`, parce qu'elle tourne sous root : ``` # Sauvegarde de la configuration de Lyrion Music Server. # Tourne en root : les répertoires internes de certaines extensions sont en # 0701, et le compte hostadmin ne peut pas les énumérer. SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO="" 05 01 * * * root /home/hostadmin/scripts/rsync/auto/backup_lyrion.sh >> /var/log/backup/$(date +\%F).log 2>&1 ``` ```bash sudo chown root:root /etc/cron.d/backup-lyrion sudo chmod 644 /etc/cron.d/backup-lyrion ``` **01 h 05 tient dans le creux** entre la dernière tâche de 01 h 00 et le téléversement des photos de 01 h 30 — et bien après 00 h 15, ce qui respecte la contrainte de la section *Ordonnancement* : le journal du jour doit être créé par une tâche ordinaire, pas par une tâche root. 🔴 **Le mot « cache » dans le second chemin est un piège, et c'est la décision qui porte tout ce script.** L'intuition veut qu'une extension perdue se retélécharge — ce qui suppose trois choses simultanément vraies : que le dépôt d'origine existe encore, que son adresse soit connue, et que la liste des extensions installées ait survécu. Les trois échouent indépendamment, et deux d'entre elles ont déjà échoué ensemble sur cette machine : un dépôt tiers a disparu, et le serveur réécrit sa propre liste au démarrage — trouvant le répertoire vide, il y inscrit une liste vide et **efface ainsi la mémoire de ce qui était installé**. 🎯 **La distinction utile n'est donc pas « réglages contre cache », mais « ce qui se reconstruit contre ce qui ne se reconstruit pas ».** Le reste de `cache/` — pochettes, bibliothèque analysée, plusieurs gigaoctets — est vraiment refabriqué par une réanalyse, et reste dehors. ⚠️ **Les droits `0701` ne sont pas une curiosité : ils décident de l'utilisateur du script, et de la lisibilité de la copie.** Ce que root peut énumérer, un lecteur venu du partage ne le peut pas : sans le `--chmod` et le `--chown` de ce script, la réplication hors site s'arrête sur ces répertoires, et rien ne le signale hors de son propre message d'erreur — la sauvegarde locale, elle, se déclare réussie. Mesuré sous `hostadmin`, le répertoire des extensions annonçait 121 Mo ; copié par root, il en fait 173. Les 52 Mo d'écart sont exactement ce que `hostadmin` ne pouvait pas énumérer. Le tirage aurait signalé ses erreurs — le compteur fait son travail —, mais la sauvegarde aurait contenu un tiers de code en moins. ⚠️ **À la restauration, les fichiers `-wal` et `-shm` d'une ancienne session doivent être retirés** avant de redémarrer le serveur : laissés à côté d'une base restaurée, SQLite tenterait de leur appliquer des transactions qui ne correspondent plus à elle. La procédure complète est dans [Restauration](https://blog.infolaf.ca/wiki/recuperation-restauration-reconstruction/). `backup_virtualbox_poste-bureau.sh` : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh ERREURS=0 r() { rsync "$@" || { echo "ÉCHEC : ${*: -2:1} -> ${*: -1}"; ERREURS=$((ERREURS+1)); } } echo "=== $(basename "$0") — début $(date -Is)" echo "" echo "virtualbox_Beelink" r -av --stats --delete --exclude='lost+found/' \ --password-file /home/hostadmin/scripts/rsync/auto/rsync_pass \ test@192.168.0.106::virtualbox_Beelink/ \ /media/nas2/Backups/virtualbox_dirty/Beelink echo "" echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit $ERREURS ``` **`-av` sans `z` ici**, contrairement aux autres : les images de disque des machines virtuelles sont déjà compressées, et la compression au vol coûterait du temps de calcul pour rien. `backup_incus_export.sh` — le seul qui ne tire rien : il fabrique, sur place : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh # Export mensuel de tous les conteneurs Incus. # Ne purge l'ancien que si TOUS les exports ont réussi. DEST="/media/nas1/Backups/incus-exports" GARDE=2 REP="$DEST/$(date +%Y-%m)" echo "===== Export Incus — $(date -Is)" # Garde-fou : une liste vide ne veut pas dire « aucun conteneur », # elle veut dire « Incus n'a pas répondu ». Sans ce contrôle, la boucle # ne tourne pas, le compteur d'erreurs reste à zéro, et le script # annonce un succès complet avec zéro conteneur exporté. CONTENEURS=$(incus list --format csv -c n) if [ -z "$CONTENEURS" ]; then echo "ÉCHEC : aucun conteneur listé — Incus répond-il ?" echo "===== Terminé — $(date -Is)" exit 1 fi mkdir -p "$REP" || { echo "Impossible de créer $REP"; exit 1; } ERREURS=0 for c in $CONTENEURS; do echo "--- $c" if incus export "$c" "$REP/$c.tar.gz"; then echo "OK $(du -h "$REP/$c.tar.gz" | cut -f1)" else echo "ÉCHEC pour $c" ERREURS=$((ERREURS + 1)) fi done if [ "$ERREURS" -ne 0 ]; then echo "$ERREURS échec(s) — purge annulée par précaution." exit 1 fi # Ne garder que les $GARDE répertoires les plus récents cd "$DEST" || exit 1 ls -1d */ 2>/dev/null | sort -r | tail -n +$((GARDE + 1)) | while read -r vieux; do echo "Purge de $vieux" rm -rf "./$vieux" done echo "Conservés :" ls -1d "$DEST"/*/ df -h "$DEST" echo "===== Terminé — $(date -Is)" ``` ⚠️ **Il découvre les conteneurs plutôt que de les nommer** — `incus list` —, donc un conteneur ajouté est exporté le mois suivant sans qu'on y pense. Même raisonnement que la découverte des bases dans le vidage MySQL. 🔴 **Et pour la même raison, il refuse de continuer sur une liste vide.** Une liste vide ne veut pas dire « aucun conteneur », elle veut dire « Incus n'a pas répondu ». Sans ce contrôle, la boucle ne tourne pas, le compteur d'erreurs reste à zéro, et **le script annonce un succès complet avec zéro conteneur exporté**. ⚠️ **Un compteur d'échecs ne voit jamais l'absence de tentative** — c'est le même défaut que le vidage MySQL évite de la même façon. **Le garde-fou se place avant la création du répertoire mensuel** : quand Incus ne répond pas, rien ne doit être créé. Et il s'éprouve sans exporter cinq conteneurs, en remplaçant la liste par du vide dans une copie : ```bash sed 's|incus list --format csv -c n|printf ""|' \ /home/hostadmin/scripts/rsync/auto/backup_incus_export.sh > /tmp/exp-test.sh bash /tmp/exp-test.sh ; echo "code de sortie : $?" ``` Attendu : le message d'échec, aucune ligne d'export, et **code de sortie `1`**. ⚠️ **`incus export` ne capture pas les disques attachés depuis l'hôte.** L'export du conteneur web ne contient donc **pas** les 64 Go des sites, qui vivent sur un périphérique bloc monté sur `/var/www`. C'est le tirage du module `www-full` qui les porte, et personne d'autre. ⚠️ **Ses marqueurs diffèrent des autres** : `===== Export Incus` et `===== Terminé`, sans ligne `échecs :`. La recherche `grep -E "^==="` les attrape quand même, mais le bilan chiffré manque. C'est un écart connu, pas un défaut à corriger dans l'urgence. --- ## Ce qui n'est écrit par personne 🔴 **Tout ce qui est dans `Backups/` n'est pas sauvegardé pour autant.** Plusieurs répertoires y ont été déposés à la main, ou l'ont été par un tirage aujourd'hui retiré. **Ils ne bougent plus, et rien ne le dit.** | Répertoire | Pourquoi il est là | |---|---| | `web-srv_22.04_Exportation contenu Wordpress` | archive figée — la version *est* l'information | | `lxd-vm_22.04_images`, `lxd-images_pre-migration` | exports d'avant la migration du gestionnaire de conteneurs, **plus l'image d'une machine virtuelle de 2023** — figés en une fois, à ne jamais purger | | `virtualbox_dirty/N73` | conservé volontairement : la clé Windows associée peut resservir | | `Config/nas-host/bind` | reste d'un service retiré du conteneur web | | `Configuration_logiciels/Sauvegardes manuelles/` | neuf dépôts faits à la main, certains depuis 2013 | 🎯 **La règle qui en découle** : un répertoire de sauvegarde qui n'apparaît dans aucun script est une **archive**, pas une sauvegarde. Les deux se ressemblent trait pour trait dans un explorateur de fichiers. **D'où la séparation physique** : ``` /media/nas1/Backups/Configuration_logiciels/Sauvegardes manuelles/ ``` Tout ce qui a été déposé à la main y est descendu. **Ce qui reste à la racine de `Configuration_logiciels/` est exactement ce que les scripts écrivent chaque nuit** — `[.]bliss`, `[.]filezilla`, `musicip`, `puddletag`, `quodlibet` — et la distinction se lit sans ouvrir un seul script. **Un dépôt manuel peut avoir l'air maintenu.** `[.]jxplorer` porte les crochets de la convention `[.]nom`, exactement comme deux répertoires réellement tirés — alors que rien ne l'a touché depuis plus de dix ans. ⚠️ **La condition à préserver, et elle n'est écrite nulle part ailleurs : aucun tirage ne doit jamais viser `Configuration_logiciels/` lui-même.** Tous visent un sous-répertoire nommé. Un `--delete` sur le parent effacerait les dépôts manuels dès la nuit suivante — et ils ne sont, par définition, sauvegardés nulle part. ### Et les services que personne ne tire Le tableau ci-dessus recense des répertoires qui ont l'air vivants et ne le sont plus. Le trou symétrique est plus difficile à voir : **un service dont la configuration n'a de répertoire nulle part.** Il n'apparaît dans aucune liste, il ne vieillit sur aucun disque, et rien ne le distingue d'un service correctement couvert tant qu'on ne cherche pas. **Le partage DLNA vers les téléviseurs — exclusion délibérée.** Le service qui l'assure aujourd'hui n'est pas celui d'hier. Le précédent portait une configuration longuement ajustée, et un module la tirait ; le service actuel se configure en quelques lignes, et **le remonter de zéro coûte moins que d'entretenir une sauvegarde de plus**. Le module de l'ancien a disparu avec lui, rien n'a pris sa place, et c'est voulu. 🎯 **C'est écrit ici pour une seule raison** : dans deux ans, l'absence de ce module ressemblera trait pour trait à un oubli. Une exclusion décidée et une exclusion subie ont exactement la même apparence sur un disque — seule la trace écrite les sépare. **Le serveur de musique de l'hôte — le trou qu'on vient de boucher.** Sa configuration n'était couverte par aucun module ni aucun script, et le trou n'avait pas été choisi : il a été **découvert**, à l'occasion d'un incident, après des années. Il fait aujourd'hui l'objet de `backup_lyrion.sh`, plus haut sur cette page. ⚠️ **Une sauvegarde absente ne se signale jamais.** Aucune erreur, aucun journal, aucun symptôme : rien n'indiquait que ce service n'était pas couvert. C'est le mode de défaillance que l'inventaire des données, en tête de cette page, existe pour attraper — et il ne l'attrape que si on le relit. **Un service qui s'ajoute au parc n'ajoute pas sa propre ligne à cet inventaire ; quelqu'un doit le faire.** ### Le cas inverse : un module qui sauvegardait le vide Un module exposait `/var/vmail` — la racine des boîtes de courriel du conteneur web — et **aucun script ne le tirait**. La crainte, chaque fois qu'on le rencontrait, était qu'il contienne un historique de messages sans copie ailleurs. Personne n'osait y toucher. **Il ne contenait rien.** Quatre fichiers, 9,5 Ko : le squelette créé par le panneau d'hébergement à l'installation. Aucune boîte, aucun message. Le module exposait un répertoire vide depuis des années, et sa seule production était l'hésitation qu'il provoquait. 🎯 **Vérifier a coûté une commande ; ne pas vérifier avait coûté des années d'incertitude.** C'est le rapport habituel entre les deux, et il vaut d'être noté : devant un répertoire dont on ne sait pas ce qu'il contient, la mesure est presque toujours moins chère que la prudence. **Quatre vérifications avant de démonter la pile entière**, et chacune répondait à une question différente : | Question | Ce qu'on a regardé | |---|---| | Du courrier arrive-t-il encore ici ? | l'enregistrement MX public du domaine, qui pointe ailleurs | | Du courrier est-il parti d'ici ? | l'absence de tout `status=sent` au journal du serveur | | Les sites s'en servent-ils ? | ils passent par le SMTP du fournisseur, pas par le `sendmail` local | | Le panneau le régénérerait-il ? | l'option correspondante était déjà décochée | Vingt et un paquets ont été purgés — serveur SMTP, serveur IMAP, analyseur antivirus, filtre antipourriel et leurs dépendances —, puis les répertoires de configuration et de données retirés, et le module supprimé de `rsyncd.conf`. ℹ️ **La file d'attente contenait 1437 messages de `root` jamais livrés**, empilés depuis des années : des sorties de tâches cron adressées à un système de courriel qui ne fonctionnait pas. C'est le symptôme d'une règle énoncée ailleurs sur cette page — une sortie envoyée quelque part où personne ne la lit équivaut à une sortie jetée. ⚠️ **Le bénéfice n'est pas la place** — 56,7 Mo. C'est la surface d'attaque et l'entretien : un analyseur de formats exposé à des données hostiles, un serveur IMAP et POP3 ouvert sans aucune boîte derrière, et une mise à jour quotidienne de signatures antivirus pour protéger un courrier inexistant. --- ## Deux conventions de nommage ### 🎯 Jamais de numéro de version de système **Un nom de répertoire de sauvegarde ne porte jamais la version du système de la machine sauvegardée.** Sinon chaque montée de version oblige à réécrire des scripts qui n'ont rien à voir avec elle — et à renommer des répertoires pendant que des tâches nocturnes tournent. Le parc en portait huit ; ils ont été renommés. ⚠️ **Le renommage et la réécriture des scripts tiennent dans la même fenêtre** : si une tâche partait entre les deux, rsync recréerait le nom manquant et retirerait tout le contenu une seconde fois. **Les seules exceptions sont les archives figées** du tableau ci-dessus : aucun script actif ne les écrit, et la version *y est* l'information. ### ℹ️ Les crochets de `[.]nom` Plusieurs destinations portent des crochets : `[.]bliss`, `[.]filezilla`, `[.]MusicMagic`, `[.]config`. **Ce n'est pas une faute de motif shell.** C'est une convention délibérée : **les crochets rendent visible dans la sauvegarde ce qui est caché à la source.** Un répertoire `.filezilla` disparaîtrait de l'affichage d'un explorateur ou d'un `ls` ordinaire, et on oublierait qu'il est sauvegardé. ⚠️ **Avant de « corriger » une bizarrerie qui dure depuis des années, demander pourquoi elle est là.** Sa durée est une information. --- ## La clé de démarrage — imager un périphérique plutôt qu'un dossier Ce serveur ne sait pas démarrer sur son disque système : sa carte mère est en BIOS hérité et ne reconnaît pas le NVMe comme périphérique d'amorçage. `/boot` vit donc sur une **clé USB**, qui doit rester branchée en permanence. ⚠️ **C'est le point de fragilité de la machine, et il a une propriété désagréable : une copie déposée sur son propre disque serait inutilisable**, puisqu'il faudrait démarrer pour la lire. Cette sauvegarde-là n'a de sens que **hors site**. ### Le disque entier, pas la partition ``` sudo lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,MODEL ``` La clé porte deux partitions : une minuscule d'un mégaoctet, et celle montée sur `/boot`. **L'image doit couvrir le périphérique entier**, pas la seconde partition. **Pourquoi cette petite partition d'un mégaoctet compte plus que sa taille ne le laisse croire.** Elle porte le cœur de GRUB — le code que le BIOS charge après le secteur d'amorçage. Copier seulement `/boot` donnerait une partition pleine de fichiers justes sur une clé qui ne démarrerait pas, et le diagnostic serait déroutant : tout serait là, rien ne fonctionnerait. La table de partition et le secteur d'amorçage sont dans le même cas — ils n'existent nulle part ailleurs. ### Lecture seule, puis remise à zéro des blocs libres Deux gestes qui se renforcent, à faire avant la copie. ``` sudo mount -o remount,ro /boot ``` Imager un système de fichiers monté en écriture peut capturer un état incohérent. `/boot` ne sert à rien en fonctionnement normal — il n'est lu qu'au démarrage et écrit qu'à l'installation d'un noyau — donc la bascule est indolore. ``` sudo zerofree -v /dev/sdc2 ``` ⚠️ **Sans cette étape, la compression ne sert presque à rien.** Un `dd` copie tous les secteurs, y compris ceux qui ne contiennent plus que d'anciennes données effacées — et ces octets-là ne se compressent pas. `zerofree` remet à zéro les blocs marqués libres, **sans toucher à aucun fichier existant**, et il exige justement un montage en lecture seule. **Ses trois nombres méritent d'être lus** : blocs remis à zéro, blocs libres, blocs au total. Sur cette machine, `796363/3620518/3776512` — soit 14,8 Go libres sur 15,5, dont **3,3 Go contenaient encore d'anciennes données**. C'est exactement ce qui aurait résisté à la compression. ### L'image ``` sudo dd if=/dev/sdc bs=4M status=progress \ | gzip -c > /chemin/vers/boot-usb-AAAA-MM-JJ.img.gz ``` ``` sudo mount -o remount,rw /boot ``` ⚠️ **Ne pas oublier le remontage en écriture.** Une partition `/boot` laissée en lecture seule ne se remarque pas — jusqu'à la prochaine mise à jour de noyau, qui échoue alors sans rapport apparent avec ce qu'on a fait des semaines plus tôt. **Le gain est considérable, et mesuré** : 274 Mo de contenu réel dans une clé de 15 Go donnent une image compressée de **261 Mio — 272 833 688 octets** — contre 15,5 Go pour un `dd` nu. Cinquante-neuf fois plus petit, pour exactement les mêmes secteurs. Ça compte doublement quand ce fichier part hors site. ### Vérifier souvent, agir rarement Une image prise une fois vieillit : `/boot` change à chaque nouveau noyau, donc à chaque mise à jour automatique. Une image de six mois ne contient plus le noyau sur lequel la machine démarre. **La réponse n'est pas de la refaire périodiquement** — ce serait réécrire 15 Go pour rien la plupart du temps — mais de **vérifier chaque nuit et de n'agir qu'au changement**. L'empreinte porte sur les **noms, tailles et dates** des fichiers de `/boot`, pas sur leur contenu : lire 274 Mo chaque nuit pour ne rien trouver serait du gaspillage, et ces trois attributs suffisent. Un noyau installé, un initrd régénéré ou un `grub.cfg` réécrit les modifient tous. ```bash find /boot -type f -printf '%p %s %T@\n' | sort | sha256sum | cut -d' ' -f1 ``` **Le script complet**, à déposer dans le répertoire des scripts de sauvegarde : ```bash #!/bin/bash . /home/hostadmin/scripts/rsync/auto/verifier_montages.sh # Image de la clé USB d'amorçage de nas-host, rafraîchie automatiquement. # # nas-host ne sait pas démarrer sur son NVMe en BIOS hérité : /boot vit sur une # clé USB. Si elle lâche, la machine ne démarre plus — et une copie déposée # sur son propre disque serait inaccessible, puisqu'il faudrait démarrer pour # la lire. D'où une image hors site. # # PRINCIPE : vérifier chaque nuit, n'agir que si /boot a changé. Un nouveau # noyau change /boot ; une nuit ordinaire, non. L'image suit donc les noyaux # sans intervention et sans réécrire 15 Go pour rien. # # DOIT TOURNER EN ROOT : remontage, zerofree et dd l'exigent. # # Version 1.1.0 — 8 septembre 2026 # # 1.1.0 — deux ajouts après un essai de lancement manuel. # a) Refus explicite hors root. Sans lui, un lancement sans sudo échouait # au remontage, et le filet de sécurité annonçait alors que /boot était # resté en lecture seule — alors qu'il n'y avait jamais été mis. Un # filet qui produit un faux signal est pire qu'aucun filet. # b) Progression de dd à l'écran quand la sortie est un terminal, silence # sous cron. Le même script sert donc aux deux usages sans réglage. set -u # --- Doit tourner en root : mount, zerofree et dd l'exigent ----------------- if [ "$(id -u)" -ne 0 ]; then echo "ÉCHEC : ce script doit tourner en root." echo " sudo $0" exit 1 fi CLE=/dev/sdc # le disque ENTIER : table de partition + amorçage + /boot PART=/dev/sdc2 # la partition ext4 montée sur /boot DEST="/media/nas1/Backups/Sauvegarde-OS/Clé usb d'amorçage de nas-host" EMPREINTE=/var/lib/boot-image.empreinte GARDER=3 # nombre d'images conservées NOM="boot-usb-$(date +%F).img.gz" ERREURS=0 # Progression à l'écran en lancement manuel, silence sous cron. if [ -t 1 ]; then DD_ETAT=progress ; else DD_ETAT=none ; fi echo "=== $(basename "$0") — début $(date -Is)" # --------------------------------------------------------------------------- # 1. Faut-il refaire l'image ? # --------------------------------------------------------------------------- # L'empreinte porte sur les noms, tailles et dates des fichiers de /boot — # pas sur leur contenu. Lire 274 Mo chaque nuit pour ne rien trouver serait # du gaspillage, et ces trois attributs suffisent : un noyau installé, un # initrd régénéré ou un grub.cfg réécrit les modifient tous. ACTUELLE=$(find /boot -type f -printf '%p %s %T@\n' 2>/dev/null \ | sort | sha256sum | cut -d' ' -f1) if [ -z "$ACTUELLE" ]; then echo "ÉCHEC : impossible de lire /boot — est-il monté ?" echo "=== $(basename "$0") — fin $(date -Is) — échecs : 1" exit 1 fi if [ -f "$EMPREINTE" ] && [ "$(cat "$EMPREINTE")" = "$ACTUELLE" ]; then echo "/boot inchangé — aucune image à refaire." echo "=== $(basename "$0") — fin $(date -Is) — échecs : 0" exit 0 fi echo "/boot a changé depuis la dernière image — nouvelle copie." [ -d "$DEST" ] || { echo "ÉCHEC : $DEST introuvable"; exit 1; } # --------------------------------------------------------------------------- # 2. Filet de sécurité : /boot doit TOUJOURS redevenir inscriptible # --------------------------------------------------------------------------- # Sans ce piège, une erreur au milieu du traitement laisserait /boot en # lecture seule. Rien ne le remarquerait avant la prochaine mise à jour de # noyau, qui échouerait alors sans rapport apparent avec ce script. remettre_rw() { mount -o remount,rw /boot 2>/dev/null if mount | grep -q ' /boot .*[(,]ro[,)]'; then echo "ALERTE : /boot est resté en LECTURE SEULE — le remettre à la main :" echo " sudo mount -o remount,rw /boot" fi } trap remettre_rw EXIT # --------------------------------------------------------------------------- # 3. Lecture seule, puis remise à zéro des blocs libres # --------------------------------------------------------------------------- # Deux raisons de passer en lecture seule, et elles se renforcent : # - imager un système de fichiers monté en écriture peut capturer un état # incohérent ; # - zerofree l'exige, et refuse de travailler autrement. if ! mount -o remount,ro /boot; then echo "ÉCHEC : impossible de passer /boot en lecture seule" exit 1 fi # zerofree ne touche à AUCUN fichier : il remet à zéro les blocs marqués # libres, pour que gzip les réduise à presque rien. Sans lui, l'image # compressée porterait encore les anciennes données effacées. if ! zerofree "$PART"; then echo "ÉCHEC : zerofree sur $PART" ERREURS=$((ERREURS + 1)) exit 1 fi # --------------------------------------------------------------------------- # 4. L'image, du disque ENTIER # --------------------------------------------------------------------------- # Du disque entier, pas de la seule partition : c'est ce qui rend la clé # amorçable. La table de partition et la petite partition d'amorçage BIOS — # celle qui porte le noyau de GRUB — n'existent nulle part ailleurs. # # Écriture sous un nom temporaire, renommé seulement en cas de succès : une # image tronquée ne doit jamais porter le nom d'une image valide. TMP="$DEST/.$NOM.tmp" if dd if="$CLE" bs=4M status="$DD_ETAT" | gzip -c > "$TMP"; then mv "$TMP" "$DEST/$NOM" echo "$ACTUELLE" > "$EMPREINTE" echo "OK $NOM ($(ls -lh "$DEST/$NOM" | awk '{print $5}'))" else rm -f "$TMP" echo "ÉCHEC dd/gzip — l'image précédente est conservée" ERREURS=$((ERREURS + 1)) fi # --------------------------------------------------------------------------- # 5. Ne garder que les dernières # --------------------------------------------------------------------------- # Uniquement si tout a réussi : sur un échec, on ne touche pas à ce qui # existe. Un script de sauvegarde qui détruit sur une erreur est pire que # celui qui ne fait pas le ménage. if [ "$ERREURS" -eq 0 ]; then ls -1t "$DEST"/boot-usb-*.img.gz 2>/dev/null \ | tail -n +$((GARDER + 1)) | while read -r vieille; do rm -f "$vieille" \ && echo "RETIRÉ $(basename "$vieille") — au-delà des $GARDER conservées" done fi echo "=== $(basename "$0") — fin $(date -Is) — échecs : $ERREURS" exit "$ERREURS" ``` **Quatre décisions valent l'explication.** ⚠️ **Le piège `trap` remet toujours `/boot` en écriture**, y compris si le script s'arrête en cours de route. Sans lui, une erreur laisserait la partition en lecture seule et rien ne le remarquerait avant la prochaine mise à jour de noyau. Il vérifie même que le remontage a réussi, et le dit sinon. ⚠️ **L'image s'écrit sous un nom temporaire, renommée seulement en cas de succès.** Une image tronquée ne doit jamais porter le nom d'une image valide. ⚠️ **L'empreinte n'est enregistrée qu'après une image réussie.** Si la copie échoue, l'empreinte reste l'ancienne et le script réessaiera la nuit suivante — au lieu de croire le travail fait. **Le ménage des anciennes n'a lieu que si tout a réussi.** Sur un échec, on ne touche à rien. ### Ce script a besoin de root `mount`, `zerofree` et `dd` l'exigent. Il ne peut donc pas vivre dans la crontab du compte de sauvegarde comme les autres. `/etc/cron.d/` convient bien : le fichier est explicite, il nomme l'utilisateur, et il est sauvegardé avec le reste de `/etc`. `/etc/cron.d/image-cle-amorcage` : ``` # Image de la clé USB d'amorçage de nas-host. # Ne fait quelque chose qu'au changement de /boot — donc au changement de noyau. SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO="" 00 04 * * * root /home/hostadmin/scripts/rsync/auto/backup_cle_amorcage.sh >> /var/log/backup/$(date +\%F).log 2>&1 ``` **04 h 00 n'est pas un choix arbitraire.** Les autres tâches nocturnes s'enchaînent de 00 h 15 à 01 h 30, et le 1er du mois jusqu'à 03 h 30. Une nuit ordinaire, celle-ci ne fait que lire `/boot` et repartir en une seconde ; mais la nuit d'un nouveau noyau elle occupe onze minutes de lecture disque, et il vaut mieux qu'elle ne les prenne pas pendant qu'une autre copie travaille. **Le `%` doit être échappé dans une ligne de cron** — sans la barre oblique inverse, cron le lit comme une fin de commande et la redirection tombe à côté. C'est l'erreur classique des lignes de cron qui datent leur journal. ### Mise en place Le script doit être exécutable, et le fichier de cron doit appartenir à root. ``` chmod +x /home/hostadmin/scripts/rsync/auto/backup_cle_amorcage.sh sudo chown root:root /etc/cron.d/image-cle-amorcage sudo chmod 644 /etc/cron.d/image-cle-amorcage ``` ⚠️ **Cron ignore un fichier de `/etc/cron.d/` qui n'appartient pas à root ou qui est inscriptible par le groupe — et il l'ignore en silence.** Aucune erreur, aucun journal : la tâche ne s'exécute simplement jamais. C'est une panne particulièrement pénible à diagnostiquer, parce que tout *semble* installé. **Amorcer l'empreinte, juste après avoir fabriqué la première image à la main :** ``` sudo sh -c 'find /boot -type f -printf "%p %s %T@\n" \ | sort | sha256sum | cut -d" " -f1 \ > /var/lib/boot-image.empreinte' ``` ⚠️ **Sans ce geste, la nuit suivante refait quinze gigaoctets pour rien.** Le fichier d'empreinte n'existe pas encore, le script en conclut que `/boot` a changé, et il recopie une image qu'on vient tout juste de faire. **Vérifier, en lançant le script à la main :** ``` sudo /home/hostadmin/scripts/rsync/auto/backup_cle_amorcage.sh ``` Il doit refuser de travailler : ``` === backup_cle_amorcage.sh — début 2026-09-08T12:01:00-04:00 /boot inchangé — aucune image à refaire. === backup_cle_amorcage.sh — fin 2026-09-08T12:01:00-04:00 — échecs : 0 ``` **C'est le bon essai à faire, et il est gratuit.** Il prouve d'un coup que le script est exécutable, qu'il lit `/boot`, qu'il trouve son fichier d'empreinte et qu'il sait s'arrêter — sans rien écrire ni immobiliser la machine onze minutes. --- ## Ordonnancement Les tirages sont espacés plutôt que simultanés : ils lisent le même disque de destination, et les faire se chevaucher ne ferait que les ralentir mutuellement. ⚠️ **L'ordre relatif compte plus que les heures elles-mêmes.** Le vidage des bases est produit **dans le conteneur à 00:00** ; le serveur le tire **à 00:15**. Quinze minutes de marge. **Si les bases grossissent au point que le vidage dépasse ce délai, le serveur tirerait un fichier en cours d'écriture** — et l'obtiendrait sans erreur, tronqué. C'est à surveiller quand la durée du vidage s'allonge, pas le jour où la restauration échoue. ### Deux tâches vivent ailleurs que dans la crontab 🔴 **La crontab du compte de sauvegarde n'est pas l'inventaire des tâches de la machine, et c'est un piège.** Deux tâches tournent depuis `/etc/cron.d/`, **sous root**, parce qu'elles exigent des privilèges que ce compte n'a pas. Elles n'apparaissent nulle part dans la crontab, et ne se révèlent que dans le journal. ``` /etc/cron.d/backup-lyrion 01:05 configuration du serveur de musique /etc/cron.d/image-cle-amorcage 04:00 image de la clé d'amorçage ``` **Pour inventorier ce qui s'exécute réellement, il faut donc regarder les deux endroits :** ```bash sudo ls -l /var/spool/cron/crontabs/ ls -l /etc/cron.d/ ``` ⚠️ **Et root apporte une contrainte que rien ne rappelle : aucune tâche sous root ne doit être la première de la nuit.** Le journal quotidien est créé par la première tâche qui écrit dedans. Créé par root, les tâches suivantes — qui tournent sous un compte ordinaire — ne pourraient plus y ajouter leurs lignes, et perdraient leur trace **sans erreur visible**, la redirection échouant là où personne ne la lit. La première tâche de la nuit est à 00:15 sous le compte de sauvegarde : l'ordre tient, et c'est à vérifier avant d'avancer l'heure d'une tâche root. ⚠️ **Cron ignore en silence un fichier de `/etc/cron.d/` qui n'appartient pas à root, qui est inscriptible par le groupe, dont le nom contient un point, ou qui ne se termine pas par un saut de ligne.** Aucune erreur, aucun journal : la tâche ne s'exécute simplement jamais. Le contrôle qui tranche est de redémarrer cron et de lire ce qu'il dit — une ligne mal formée s'y signale par un `ERROR` nommant le fichier. ```bash sudo systemctl restart cron sleep 3 ; sudo journalctl -u cron --since "1 min ago" --no-pager | tail ``` ### La réplication hors site n'a pas d'heure, et c'est voulu Le niveau hors site ne figure pas dans la crontab : `incus-exports/` est répliqué par un outil qui **observe le système de fichiers**, avec verrou et anti-rebond, et qui passe **en plus une fois par nuit sur l'arborescence entière**. 🎯 **La passe complète est la garantie ; l'observateur n'est qu'une optimisation.** C'est le bon sens de cette répartition : une couche événementielle peut manquer un changement — un événement perdu, un redémarrage au mauvais moment, un dépassement de la file du noyau — et rien ne le signalerait. La passe complète, elle, ne dépend d'aucun événement : elle compare. **Le pire cas est donc vingt-quatre heures de retard, jamais une absence.** Il n'y a par conséquent **aucun ordonnancement à coordonner** avec les tâches de la nuit. Un export mensuel produit à 03:00 part quand il part ; il sera dehors au plus tard le lendemain. ⚠️ **La conservation est de trente jours, en mode révision.** Une archive corrompue ou effacée par erreur reste récupérable pendant un mois. Au-delà, la version saine a disparu en ligne — c'est le niveau, et non le mécanisme, qui porte cette limite. **Les journaux vont dans un fichier daté par jour**, purgé au-delà de six mois : ``` 30 03 1 * * find /var/log/backup/ -name '*.log' -mtime +180 -delete ``` ⚠️ **Ne pas mêler les journaux de sauvegarde à ceux d'autres tâches.** Deux services de diffusion audio écrivaient autrefois dans ce même répertoire — jusqu'à 5,6 Mo pour une seule journée, noyant les rapports qu'on venait d'y mettre. Un journal qu'on n'ouvre plus parce qu'il est illisible ne sert à rien. --- ## Le démon est un point d'appui unique, et silencieux 🔴 **Un `killall rsync` lancé en root sur l'hôte arrête les deux démons du parc à la même seconde** — celui de l'hôte et celui du conteneur. **Deux enseignements, et le second est le plus gênant.** ⚠️ **Une commande visant un nom de processus, lancée en root sur l'hôte, traverse les conteneurs.** Leurs processus ont leur propre espace de noms de PID, mais l'hôte les voit tous. Cela vaut pour `killall`, `pkill`, et tout ce qui cible par nom plutôt que par identifiant. ⚠️ **Et rien ne les relance.** `killall` envoie un SIGTERM, que le démon traite proprement : code de sortie 0, « Deactivated successfully ». **Pour systemd, ce n'est pas une panne mais un arrêt normal.** La chaîne de sauvegarde entière reste hors service sur les deux machines sans qu'aucun signal ne l'indique. **La parade**, à poser sur chaque machine qui sert des modules — `/etc/systemd/system/rsync.service.d/override.conf` : ```ini [Service] Restart=always RestartSec=5 ``` ``` sudo systemctl daemon-reload ``` ⚠️ **`Restart=on-failure` n'aurait rien changé** : un SIGTERM produit une sortie propre, pas un échec. Seul `always` relance quel qu'en soit le motif. Un `systemctl stop` explicite reste respecté — systemd distingue l'arrêt voulu de la disparition. ⚠️ **Ne pas créer ce fichier avec `systemctl edit`.** La commande ouvre un fichier ne contenant que des commentaires ; si l'on enregistre sans que systemd y détecte de contenu utile, il **supprime le fichier** avec un message discret. Le créer à la main est déterministe. **Vérifier que la surcharge est prise en compte :** ``` systemctl cat rsync ``` Deux blocs doivent apparaître : l'unité, puis le chemin de la surcharge. Son absence signifie qu'elle n'existe pas. **Le test qui valide vraiment :** ``` sudo killall rsync ``` ``` sleep 8 ; systemctl is-active rsync ``` **Le `sleep` n'est pas de la prudence, il est nécessaire** : sans lui on interroge pendant les cinq secondes de `RestartSec` et l'on croit à un échec. ### Deux surcharges dans le même répertoire, et il ne faut pas les confondre La surcharge de relance ci-dessus n'est pas la seule que porte ce service. Une seconde, posée à l'installation, le **durcit** : `ProtectSystem=full`, `PrivateDevices=yes`, `NoNewPrivileges=yes`. Elle rend `/usr`, `/boot` et `/etc` en lecture seule **pour ce service seulement**, ce qui est sans effet sur son travail — tous les modules étant déclarés `read only = true`, le démon n'écrit nulle part — mais borne ce qu'une faille dans rsync permettrait d'atteindre. Les commandes et le tableau des trois options sont dans [la page de l'hôte](https://blog.infolaf.ca/wiki/incus-hote-et-conteneur-serveur-nas/), qui est l'endroit où cette machine se rebâtit. 🎯 **Le raisonnement est celui des filtres de `/etc` : réduire ce qu'un composant *peut* faire, et pas seulement ce qu'il fait.** Un service qui n'écrit jamais n'a aucune raison d'en garder le droit. ⚠️ **Ce sont deux fichiers distincts dans `/etc/systemd/system/rsync.service.d/`, et c'est délibéré.** Rien n'empêcherait de les fondre en un seul, mais les réunir ferait qu'une erreur en modifiant l'un emporterait l'autre — et l'un des deux est ce qui relève le démon après un arrêt accidentel. **Vérifier ce qui est réellement actif se fait sur le service, pas sur les fichiers :** ```bash systemctl show rsync \ -p ProtectSystem -p PrivateDevices -p NoNewPrivileges -p Restart ``` --- ## Fragilités connues Les écrire vaut mieux que les découvrir. Aucune n'est un défaut à corriger d'urgence ; toutes sont des arbitrages dont il faut connaître le prix. ### 🟠 Deux points de montage, un seul système de fichiers Ce qui fut historiquement deux disques est aujourd'hui deux points de montage sur **une seule partition**. Écrire « sur le second » ne met donc rien à l'abri : ni d'une panne matérielle, ni d'une corruption, ni même d'un disque plein — l'espace est commun. La conséquence est bornée par les disques externes, qui portent les deux séparément, **sur deux supports distincts**. La répartition qui n'existe plus en ligne existe hors ligne. ### 🟠 Plusieurs tirages écrivent dans le même répertoire avec `--delete` Deux tirages de configuration visent aujourd'hui `Backups/Config/nas-host/` — `smb.conf` et `exports` —, chacun avec `--delete`. Ça fonctionne parce que rsync **protège de la suppression les fichiers exclus par filtre**, et que chaque ligne exclut tout sauf son propre fichier. ⚠️ **La sécurité repose entièrement sur ce comportement subtil.** Le jour où quelqu'un ajoute `--delete-excluded` en croyant faire le ménage, les configurations s'effacent mutuellement. 🔴 **Et le même comportement a un revers.** Qu'un tirage visant ce répertoire soit commenté, et **son fichier reste** — protégé de la suppression par les filtres des tirages voisins, qui l'excluent. Il vieillit sur place indéfiniment, sans que rien n'indique qu'il n'est plus rafraîchi. Un cas de ce parc y a séjourné trois ans avant d'être retiré à la main. 🎯 **Ce qui fait tenir le partage d'un répertoire est exactement ce qui y laisse un fichier mort indéfiniment.** Retirer un tirage n'est donc pas fini tant que son fichier n'a pas été retiré à la main. ### 🟠 La détection d'échec ne dépend plus d'une lecture humaine — mais elle dépend encore d'un registre **Ce qui était vrai et ne l'est plus.** Les journaux existaient, et rien n'y signalait un échec. Le cas concret : un vidage de base qui échoue, le script qui conserve correctement la version de la veille, le serveur qui la tire sans broncher. Tout paraissait normal, et la sauvegarde vieillissait en silence jusqu'à ce que quelqu'un ouvre le journal. Un relevé nocturne lit désormais ces journaux à la place de l'œil humain, contrôle l'âge des répertoires de destination, et énonce un verdict sur une page — voir *Savoir que ça fonctionne*. Le cas du vidage figé est précisément celui qu'il attrape : le fichier ne change plus, son répertoire vieillit, et l'âge est mesuré indépendamment de ce que le journal raconte. 🔴 **Mais le relevé ne vaut que ce que vaut son registre, et c'est là que la fragilité s'est déplacée.** Il ne cherche que les tâches et les répertoires qu'on lui a nommés. Une tâche ajoutée à la crontab sans être ajoutée au registre du relevé **n'est surveillée par rien**, exactement comme avant — à ceci près que la page affiche maintenant un bilan rassurant qui ne la concerne pas. ⚠️ **Ajouter une tâche de sauvegarde est donc un geste en deux temps, et le second est facile à oublier** : la ligne de cron, puis l'entrée dans le registre. Le premier seul produit une sauvegarde qui fonctionne et que personne ne surveille ; le second seul produit une alerte quotidienne pour une tâche qui n'existe pas. Des deux oublis, c'est le premier qui est dangereux, parce qu'il est silencieux. **Et la lecture humaine n'a pas disparu, elle s'est déplacée** : quelqu'un doit ouvrir la page. Une page qu'on cesse de consulter ramène exactement à la situation d'avant, avec en plus l'illusion d'être couvert. --- ## Savoir que ça fonctionne ### Le relevé nocturne, et la page qu'il fabrique Tout ce qui suit dans cette section se faisait à la main. **Une tâche de 4 h 30 le fait désormais chaque nuit et en dépose le résultat sur une page.** Les contrôles manuels restent décrits ici — ils servent à vérifier le relevé lui-même, et à travailler le jour où il ne tourne plus. Le dispositif tient en deux scripts qui ne partagent aucun travail : | Script | Ce qu'il fait | |---|---| | `releve-tableau-de-bord.sh` | **mesure et juge** — lit les journaux de la nuit, l'âge des répertoires de destination, les services, les montages, l'espace disque, les prisons `fail2ban` des deux machines et le trafic d'Internet vers le seul service public sans authentification ; écrit un fichier de valeurs | | `build_tableau-de-bord.py` | **met en forme seulement** — fabrique la page HTML et tient l'historique des trente derniers jours | 🎯 **Toute la connaissance du parc vit dans le premier, aucune dans le second.** Ce qui compte comme une alerte, quel âge est toléré, quelle tâche doit avoir tourné : le script de mesure décide, le formateur ne fait que rendre. Changer un seuil ne demande donc jamais de toucher au HTML, et refaire la présentation ne peut pas altérer un verdict. **Ce que le relevé cherche dans les journaux est une absence, pas une erreur.** Chaque script de tirage écrit un marqueur de début et un marqueur de fin dans le journal du jour. Une tâche qui échoue laisse un marqueur de fin avec un compte d'échecs ; une tâche **qui n'a pas démarré** — parce que le garde-fou de montage l'a interrompue avant tout, par exemple — ne laisse rien du tout. C'est cette absence-là que le relevé sait nommer, et c'est elle qu'une lecture humaine rate le plus facilement : on repère une ligne d'erreur, on ne repère pas une ligne manquante. **Deux familles de contrôles, et elles sont complémentaires** : ce que les journaux racontent, et ce que le disque montre. Un répertoire de destination trop vieux est une alerte même si le journal de la nuit est parfait — c'est exactement le cas du vidage de base figé. **La ligne de crontab**, sur nas-host, sous le compte qui porte les tâches : ``` 30 04 * * * cd /home/hostadmin/scripts/tableau-de-bord && bash releve-tableau-de-bord.sh >/dev/null && python3 build_tableau-de-bord.py >/dev/null ``` ⚠️ **4 h 30 vient après la dernière tâche de la nuit, et ce n'est pas un détail** : avancé avant, le relevé dénoncerait chaque matin comme manquantes des tâches qui n'ont simplement pas encore tourné. Un tableau de bord qui crie tous les jours cesse d'être lu en une semaine. **Et 4 h 30 plutôt que 4 h 15** parce que l'image de la clé d'amorçage part à 4 h et occupe onze minutes les nuits où un nouveau noyau a changé `/boot` — la marge est mince, elle est mesurée. ℹ️ **Le `&&` entre les deux commandes est délibéré.** Si la mesure échoue, la mise en forme ne s'exécute pas : la page conserve le contenu de la veille, **visiblement daté d'hier**, plutôt que d'afficher une journée à demi mesurée qui aurait l'air d'un relevé valide. La date affichée est donc elle-même un contrôle. ⚠️ **Cette ligne renvoie sa sortie vers le néant, alors que cette page l'interdit ailleurs — et l'exception est justifiée, pas tolérée.** La règle vise les tâches dont la sortie est **la seule trace** de ce qu'elles ont fait. Ici, la sortie n'est pas la trace : **le résultat est un fichier**, et ce fichier porte sa propre date. Une tâche qui n'a pas tourné laisse une page datée de la veille, visible du premier coup d'œil, là où un journal aurait demandé qu'on aille l'ouvrir. Le rapport de terminal, lui, n'a rien à faire dans le journal des sauvegardes, qu'il noierait. 🎯 **Et la distinction va plus loin que la redirection : cette tâche ne produit pas d'événement, elle livre une lecture.** Les journaux qu'elle dépouille existent déjà et gardent, eux, la trace de ce qui s'est passé pendant la nuit. Lui ouvrir un journal à elle reviendrait à consigner l'acte de lire. **Son seul mode de défaillance visible est donc la page elle-même**, restée à la date de la veille. Et l'objection habituelle — « si personne ne la regarde, personne ne le verra » — ne tient pas ici : **une page de veille que personne n'ouvre n'a aucune raison d'exister.** Le jour où c'est vrai, le problème n'est plus la redirection. Le dispositif complet — les deux scripts au long, le lanceur du poste de travail et son raccourci de bureau — est écrit en fin de page, section *Le tableau de bord*. ### Les journaux, chaque semaine ``` ls -lh /var/log/backup/ ``` ⚠️ **Un journal absent est plus inquiétant qu'un journal contenant une erreur.** Le premier veut dire que rien ne s'est lancé ; le second, que quelque chose a essayé. ⚠️ **Ne jamais juger une sauvegarde à la date de ses fichiers.** `rsync -a` préserve la date de la source : un fichier de 2022 dans une copie faite cette nuit est parfaitement normal. La question se pose au journal, pas au système de fichiers. ### L'examen des modules, une fois l'an Un module dont le chemin a disparu échoue chaque nuit sans rien dire d'autre qu'une ligne dans un journal que personne ne lit. Cette boucle interroge chaque module d'un démon et dit lequel répond : ```bash P=/home/hostadmin/scripts/rsync/auto/rsync_pass for m in $(rsync --list-only --password-file=$P test@192.168.0.6:: \ | awk '{print $1}'); do printf '%-26s ' "$m" rsync --list-only --password-file=$P "test@192.168.0.6::$m/" \ >/dev/null 2>&1 && echo OK || echo ÉCHEC done ``` À répéter en changeant l'adresse pour chaque machine. Dix secondes chacune. **Le gain :** un module mort ne se signale d'aucune autre façon. Passé sur l'ensemble des modules du parc, ce contrôle en a trouvé **deux** — un répertoire disparu après le déménagement d'un service, un chemin de configuration changé par une mise à jour — tous deux en échec silencieux depuis des années. #### Le message à savoir lire : `@ERROR: chroot failed` C'est la réponse qu'un module rend quand **son chemin n'existe plus**. Le démon s'enferme par `chroot` dans le chemin du module **avant** de servir quoi que ce soit ; si le répertoire a disparu, l'enfermement échoue et le service s'arrête là. 🎯 **Le message ne parle pas du chemin, et c'est ce qui le rend opaque la première fois.** Il décrit l'opération qui a échoué, pas sa cause. Devant lui, la question à poser est toujours la même : *ce chemin existe-t-il encore sur la machine qui sert le module ?* Le cas rencontré : un module servant `/etc/bind` sur l'hôte, longtemps après que le DNS eut déménagé dans un conteneur. Aucune donnée perdue — la destination n'avait même jamais été créée — mais le module a échoué chaque nuit dans l'intervalle, sans que rien d'autre qu'une ligne de journal ne le dise. ### Trois contrôles qui n'en sont pas Trois gestes couramment pris pour des vérifications, et qui ne prouvent rien. 🔴 **Un bilan à `échecs : 0` mesure l'exécution, pas la destination.** Un script relancé après un renommage rend zéro qu'il écrive au bon endroit ou non, puisque rsync crée ce qui manque. **Le contrôle qui tranche cherche les noms qui ne devraient plus exister** : ```bash find /media/nas1/Backups /media/nas2/Backups -maxdepth 2 -name '*22.04*' ``` 🔴 **`rsync --list-only` ne descend pas dans les sous-répertoires sans `-r`.** Un contrôle bâti là-dessus rend exactement la même sortie que le filtre serve le fichier attendu ou non — et cette sortie a l'air complète. 🔴 **`ls` ne montre pas les fichiers cachés.** Un répertoire de sauvegarde qui paraît vide peut contenir un `.htpasswd` ou un `.htdigest` — précisément les fichiers qu'on tient à sauvegarder. **`ls -la`, ou mieux, `find` :** ```bash find /media/nas1/Backups/Config -type f -printf '%p %s o %TY-%Tm-%Td\n' ``` 🎯 **Ce que les trois ont en commun** : chacune répond fidèlement à une question qui n'est pas celle qu'on croit poser, et **aucune ne se plaint**. Devant un contrôle rassurant, la question à se poser est : *cette commande pourrait-elle distinguer le cas où tout va bien du cas où rien ne va ?* ### L'essai de restauration, deux fois l'an Voir la page **[Restauration](https://blog.infolaf.ca/wiki/recuperation-restauration-reconstruction/)**. Une sauvegarde jamais restaurée est une hypothèse, pas une sauvegarde. ### Et le cas le plus difficile à voir : ce qui réussit à ne rien faire 🔴 **Une tâche qui échoue finit par se voir. Une tâche qui réussit à ne rien faire est invisible pour toujours.** Le cas rencontré ici : une tâche planifiée lançait un script **toutes les cinq minutes**, avec toute sa sortie redirigée vers `/dev/null`. Le programme qu'elle appelait avait été désinstallé, mais le script — déposé dans `/usr/local/bin`, donc n'appartenant à aucun paquet — avait survécu à la purge. Et il **n'échouait pas** : il cherchait des fichiers de configuration, n'en trouvait aucun, et un garde-fou l'empêchait d'aller plus loin. Il posait un verrou, ne faisait rien, retirait le verrou, et sortait proprement. Son répertoire de configuration était vide **depuis près de quatre ans** — de l'ordre de 400 000 exécutions parfaitement réussies et parfaitement inutiles. **Ce que ça apprend, et qui ne figure dans aucun manuel :** - Une purge de paquet **n'emporte pas** ce qui vit dans `/usr/local/`, ni les tâches planifiées d'un compte de service, ni le compte lui-même. - `2> /dev/null` sur une tâche planifiée rend toute panne future indétectable. **Rediriger la sortie vers un journal, jamais vers le néant.** - L'inventaire à faire n'est donc pas seulement « qu'est-ce qui échoue », mais **« qu'est-ce qui s'exécute encore, et pourquoi »** : ``` sudo ls -l /var/spool/cron/crontabs/ ``` ``` ls -l /etc/cron.d/ /etc/cron.daily/ ``` Pour chaque entrée, une seule question : *si je supprimais ça, qu'est-ce qui cesserait de fonctionner ?* Une réponse hésitante vaut enquête. --- ## Le tableau de bord — le dispositif au complet Ce qui suit bâtit de zéro la page décrite plus haut. Deux scripts sur le serveur, un lanceur sur le poste de travail, et rien d'autre : ni service, ni base de données, ni dépendance réseau. 🎯 **Le partage du travail entre les deux scripts est la décision structurante, et tout le reste en découle.** Le relevé mesure et **juge** — c'est lui qui sait ce qu'est une alerte. Le constructeur met en forme et ne juge rien. Un seuil se change donc sans jamais toucher au HTML, et la présentation se refait sans risque d'altérer un verdict. Et regarder le fichier de données avant la page localise une panne du premier coup. ### Avant de commencer Trois conditions, toutes vérifiables en une commande. **Sur `[nas-host]`** : ``` { echo "--- compte dans incus-admin ?" id -nG | tr ' ' '\n' | grep -x incus-admin || echo NON echo "--- python3" python3 --version 2>&1 echo "--- dossier cible" ls -d "/media/nas1/Backups/Tableau de bord - nas-host" 2>&1 } ``` Le compte `hostadmin` doit appartenir au groupe `incus-admin` : le relevé interroge les conteneurs et `fail2ban` à travers `incus exec`, sans quoi il faudrait le faire tourner en root. Le dossier cible doit exister et lui appartenir. S'il manque, le créer : ``` mkdir -p "/media/nas1/Backups/Tableau de bord - nas-host" ``` ### Le relevé **Sur `[nas-host]`** — ouvrir l'éditeur : ``` mkdir -p /home/hostadmin/scripts/tableau-de-bord && \ nano /home/hostadmin/scripts/tableau-de-bord/releve-tableau-de-bord.sh ``` Contenu : ```bash #!/bin/bash # Relevé de l'état des sauvegardes et des services de nas-host. # # Mesure, affiche à l'écran, et écrit UN fichier de données — donnees.tsv, # dans le dossier du tableau de bord. Rien d'autre, nulle part ailleurs. # # La page est fabriquée par un second script qui ne fait que mettre en forme # ce fichier. Le jugement — ce qui compte comme une alerte — vit ici, à un # seul endroit. set -u export LC_ALL=C.UTF-8 DOSSIER="/media/nas1/Backups/Tableau de bord - nas-host" TSV="$DOSSIER/donnees.tsv" JOURNAL_DIR=/var/log/backup JOURNAL="$JOURNAL_DIR/$(date +%F).log" [ -f "$JOURNAL" ] || JOURNAL=$(ls -1t "$JOURNAL_DIR"/*.log 2>/dev/null | head -1) JOUR=$(date +%d) AUJOURDHUI=$(date -d "$(date +%F)" +%s) # nom|rythme|heure prévue TACHES=" backup_mysql-ns2.sh|quotidien|00:15 backup_web-ns2.sh|quotidien|00:20 backup_pihole.sh|quotidien|00:35 backup_scripts.sh|quotidien|00:40 backup_config.sh|quotidien|00:45 backup_scripts_poste-bureau.sh|quotidien|00:50 backup_config_poste-bureau.sh|quotidien|00:55 backup_logiciels.sh|quotidien|01:00 backup_lyrion.sh|quotidien|01:05 upload_photos_photos-webadmin-ca.sh|quotidien|01:30 backup_virtualbox_poste-bureau.sh|mensuel|02:00 backup_incus_export.sh|mensuel|03:00 backup_cle_amorcage.sh|quotidien|04:00 " # jours tolérés|chemin TEMOINS=" 1|/media/nas1/Backups/web-srv_MySQL_db 2|/media/nas1/Backups/Config/web-srv/log 2|/media/nas1/Backups/Config/pi-hole 2|/media/nas1/Backups/Configuration_logiciels/lyrion 35|/media/nas1/Backups/incus-exports 90|/media/nas1/Backups/Sauvegarde-OS/Clé usb d'amorçage de nas-host " SERVICES="icecast2 ices2-chromecast lyrionmusicserver minidlna smbd nmbd nfs-server incus zfs-zed" if [ ! -d "$DOSSIER" ]; then echo "ÉCHEC : $DOSSIER introuvable" exit 1 fi : > "$TSV" || { echo "ÉCHEC : impossible d'écrire $TSV"; exit 1; } # Les champs sont séparés par une tabulation. « $* » les joint avec le # premier caractère d'IFS, d'où le réglage local. emit() { local IFS=$'\t' printf '%s\n' "$*" >> "$TSV" } # Les boucles « while read » alimentées par un tube tournent dans un # sous-processus : une variable de comptage incrémentée dedans serait perdue # à la sortie. Les alertes vont donc dans le fichier, et le total se compte # à la fin en le relisant. alerte() { emit ALERTE "$1"; } # printf complète en OCTETS, pas en caractères : un « é » en occupe deux et # décale la colonne. En locale UTF-8, ${#texte} compte bien les caractères. col() { local texte="$1" large="$2" n n=${#texte} printf '%s' "$texte" while [ "$n" -lt "$large" ]; do printf ' '; n=$((n + 1)); done } ligne4() { col "$1" 36; col "$2" 7; col "$3" 12; printf '%s\n' "$4" } emit META genere "$(date -Is)" emit META journal "$(basename "$JOURNAL")" echo "RELEVÉ DU $(date '+%F %H:%M') journal : $(basename "$JOURNAL")" echo # -------------------------------------------------------------------------- # 1. Les tâches de la nuit # -------------------------------------------------------------------------- # On part du registre des tâches ATTENDUES, pas de ce que le journal # contient. C'est le seul moyen de voir une tâche arrêtée avant sa première # ligne — le cas d'un disque non monté, où le script sort sur « ARRÊT : » # sans laisser ni début ni fin. echo "== TÂCHES" ligne4 NOM HEURE ÉTAT DÉTAIL echo "$TACHES" | while IFS='|' read -r nom rythme heure; do [ -n "$nom" ] || continue if [ "$rythme" = mensuel ] && [ "$JOUR" != "01" ]; then ligne4 "$nom" "$heure" "mensuelle" "prévue le 1er" emit TACHE "$nom" "$heure" mensuelle "prévue le 1er" continue fi if [ "$nom" = backup_incus_export.sh ]; then deb=$(grep -c '^===== Export Incus' "$JOURNAL" 2>/dev/null) fin=$(grep -c '^===== Terminé' "$JOURNAL" 2>/dev/null) n=$(grep -c '^ÉCHEC pour' "$JOURNAL" 2>/dev/null) else deb=$(grep -c -- "^=== $nom — début" "$JOURNAL" 2>/dev/null) fin=$(grep -c -- "^=== $nom — fin" "$JOURNAL" 2>/dev/null) n=$(grep -- "^=== $nom — fin" "$JOURNAL" 2>/dev/null \ | sed -E 's/.*(échecs|code de sortie) : //' | tail -1) fi n=${n:-0} if [ "$deb" -eq 0 ] && [ "$fin" -eq 0 ]; then ligne4 "$nom" "$heure" "ABSENTE" "aucune trace dans le journal" emit TACHE "$nom" "$heure" absente "aucune trace dans le journal" alerte "$nom n'a laissé aucune trace dans le journal" elif [ "$fin" -eq 0 ]; then ligne4 "$nom" "$heure" "INACHEVÉE" "début sans fin" emit TACHE "$nom" "$heure" inachevee "début sans fin" alerte "$nom a commencé sans se terminer" elif [ "$n" != "0" ]; then ligne4 "$nom" "$heure" "ÉCHEC" "$n échec(s)" emit TACHE "$nom" "$heure" echec "$n échec(s)" alerte "$nom : $n échec(s)" else ligne4 "$nom" "$heure" "ok" "" emit TACHE "$nom" "$heure" ok "" fi done # -------------------------------------------------------------------------- # 2. Fraîcheur des destinations # -------------------------------------------------------------------------- # Le journal dit si la tâche a tourné ; il ne dit pas si elle a rapporté # quelque chose. Une source figée produit un succès sincère sur une # sauvegarde qui vieillit. On regarde donc la date du fichier le plus # récent — celle du répertoire ne veut rien dire, rsync ne la touche que si # le contenu change. # # L'âge se compte en jours de CALENDRIER : un écart en secondes afficherait # « 0 j » pour un fichier d'hier soir et « 1 j » pour un fichier d'hier midi. echo echo "== FRAÎCHEUR DES DESTINATIONS" col DESTINATION 44; col DERNIER 12; col ÂGE 8; echo ÉTAT echo "$TEMOINS" | while IFS='|' read -r tolere chemin; do [ -n "$chemin" ] || continue court=${chemin#/media/nas1/Backups/} if [ ! -d "$chemin" ]; then col "$court" 44; col "-" 12; col "-" 8; echo "ABSENT" emit TEMOIN "$court" - - "$tolere" absent alerte "destination absente : $court" continue fi horo=$(find "$chemin" -type f -printf '%T@\n' 2>/dev/null \ | sort -rn | head -1) if [ -z "$horo" ]; then col "$court" 44; col "-" 12; col "-" 8; echo "VIDE" emit TEMOIN "$court" - - "$tolere" vide alerte "destination vide : $court" continue fi date_lis=$(date -d "@${horo%.*}" +%F) jours=$(( (AUJOURDHUI - $(date -d "$date_lis" +%s)) / 86400 )) if [ "$jours" -gt "$tolere" ]; then col "$court" 44; col "$date_lis" 12; col "${jours} j" 8 echo "TROP VIEUX (toléré : $tolere j)" emit TEMOIN "$court" "$date_lis" "$jours" "$tolere" vieux alerte "$court : $jours jours, toléré $tolere" else col "$court" 44; col "$date_lis" 12; col "${jours} j" 8; echo "ok" emit TEMOIN "$court" "$date_lis" "$jours" "$tolere" ok fi done # -------------------------------------------------------------------------- # 3. Services # -------------------------------------------------------------------------- echo echo "== SERVICES" for s in $SERVICES; do etat=$(systemctl is-active "$s" 2>&1) col "$s" 24; echo "$etat" emit SERVICE "$s" "$etat" [ "$etat" = active ] || alerte "service $s : $etat" done echo echo "-- conteneurs" while IFS=, read -r nom etat; do [ -n "$nom" ] || continue col "$nom" 24; echo "$etat" emit CONTENEUR "$nom" "$etat" [ "$etat" = RUNNING ] || alerte "conteneur $nom : $etat" done < <(incus list --format csv -c ns 2>/dev/null) # « Actif » ne veut pas dire « en ondes ». ices2 peut tourner sans être # connecté à Icecast : c'est la panne déjà rencontrée. On lit donc les # points de montage réellement publiés, et on ne nomme aucune station — # un seul dongle, les quatre unités rtl-fm s'excluent. echo echo "-- diffusion" FLUX=$(curl -s --max-time 5 http://localhost:8000/status-json.xsl 2>/dev/null) for m in /Radio_FM.ogg /Chromecast.ogg; do if echo "$FLUX" | grep -q "$m"; then col "$m" 24; echo "en ondes" emit FLUX "$m" "en ondes" else col "$m" 24; echo "ABSENT DU SERVEUR" emit FLUX "$m" absent alerte "flux $m non connecté à Icecast" fi done N_FM=$(systemctl list-units 'rtl-fm-*.service' --state=active \ --no-legend --no-pager 2>/dev/null | wc -l) col "unités rtl-fm actives" 24; echo "$N_FM (attendu : 1)" emit RTLFM "$N_FM" [ "$N_FM" = 1 ] || alerte "unités rtl-fm actives : $N_FM au lieu de 1" # systemctl --failed sort en code 0 même sans rien à dire : on teste donc # le contenu, pas le code de sortie. Une section muette serait ambiguë. echo echo "-- unités en échec" ECHECS_UNITS=$(systemctl --failed --no-legend --no-pager) if [ -n "$ECHECS_UNITS" ]; then echo "$ECHECS_UNITS" | sed 's/^/ /' while read -r u reste; do [ -n "$u" ] || continue emit UNITEECHEC "$u" alerte "unité en échec : $u" done <<< "$ECHECS_UNITS" else echo " aucune" fi # -------------------------------------------------------------------------- # 4. fail2ban — sur l'hôte ET dans le conteneur web # -------------------------------------------------------------------------- # Deux machines portent des prisons, pour des raisons différentes : le # conteneur sert des sites publics, l'hôte reçoit le SSH exposé depuis # Internet. Les interroger ensemble est le seul moyen de n'en pas oublier # une — ce relevé n'a longtemps connu que le conteneur, et rien ne le disait. # # L'écart entre les deux compteurs est le signal : des échecs qui montent # avec zéro blocage n'est pas une panne du filtre, c'est une fenêtre de # comptage mal réglée. Les deux se corrigent à des endroits différents. # # Sur l'hôte, le socket de fail2ban est en 0700 root. Ce script tourne sous # hostadmin et passe donc par une règle sudo étroite, déposée dans # /etc/sudoers.d/tableau-de-bord. Le « -n » interdit toute demande de mot de # passe : sous cron, une invite resterait suspendue jusqu'au délai. # # Registre : machine|prison|note. « nas-host » désigne la machine locale. PRISONS=" nas-host|sshd|SSH exposé web-srv|wordpress-login|échecs de connexion web-srv|wordpress-honeypot|ancienne adresse, 1 essai web-srv|apache-401| web-srv|sshd|réseau local seulement " echo echo "== FAIL2BAN" col MACHINE 12; col PRISON 20; col ÉCHECS 10 col BLOCAGES 10; col ACTUELS 10; echo NOTE echo "$PRISONS" | while IFS='|' read -r machine j note; do [ -n "$j" ] || continue if [ "$machine" = nas-host ]; then # Les « /dev/null /dev/null /dev/null" \ /dev/null \ | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) if [ -n "$FIN" ]; then DATE_FIN=$(date -d "$FIN" +%F) RESTE=$(( ( $(date -d "$FIN" +%s) - $(date +%s) ) / 86400 )) echo "expire le $DATE_FIN — $RESTE jours restants" emit CERT "$DATE_FIN" "$RESTE" [ "$RESTE" -le 30 ] && alerte "certificat : $RESTE jours restants" else echo "LECTURE IMPOSSIBLE" emit CERT - - alerte "certificat : lecture impossible" fi # -------------------------------------------------------------------------- # 7. Le verdict # -------------------------------------------------------------------------- NB=$(grep -c '^ALERTE' "$TSV") echo if [ "$NB" -eq 0 ]; then echo "== RIEN À REGARDER" else echo "== $NB POINT(S) À REGARDER" grep '^ALERTE' "$TSV" | cut -f2- | sed 's/^/ • /' fi echo echo "données : $TSV" ``` Éprouver : **Sur `[nas-host]`** : ``` bash /home/hostadmin/scripts/tableau-de-bord/releve-tableau-de-bord.sh ``` Les douze tâches doivent apparaître, les deux mensuelles marquées « prévue le 1er » les jours où elles ne sont pas dues. ### Le constructeur **Sur `[nas-host]`** — ouvrir l'éditeur : ``` nano /home/hostadmin/scripts/tableau-de-bord/build_tableau-de-bord.py ``` Contenu : ```python #!/usr/bin/env python3 """Fabrique la page du tableau de bord à partir de donnees.tsv. Ce script ne mesure rien et ne juge rien : il met en forme. Le jugement — ce qui compte comme une alerte — est décidé par le relevé, et arrive ici sous forme de lignes ALERTE. Il tient aussi l'historique : chaque passage ajoute l'état du jour à historique.tsv, qui alimente la bande de trente jours. C'est de la tenue de registre, pas de la mesure — et ça évite de relire trente journaux à chaque fois. La page est autonome : aucune requête sortante, aucune police distante, aucune bibliothèque. """ import html import sys from datetime import date, timedelta from pathlib import Path DOSSIER = Path("/media/nas1/Backups/Tableau de bord - nas-host") SOURCE = DOSSIER / "donnees.tsv" HISTORIQUE = DOSSIER / "historique.tsv" SORTIE = DOSSIER / "tableau-de-bord.html" JOURS_BANDE = 30 JOURS_GARDE = 60 def lire(chemin): """Range les lignes du TSV par type : {'TACHE': [[...], ...], ...}""" donnees = {} for ligne in chemin.read_text(encoding="utf-8").splitlines(): if not ligne.strip(): continue champs = ligne.split("\t") donnees.setdefault(champs[0], []).append(champs[1:]) return donnees def historiser(d): """Enregistre l'état du jour et rend {tâche: {jour: état}}. Relancer le script le même jour REMPLACE les lignes du jour plutôt que de les empiler : le lanceur peut être cliqué dix fois sans fausser la bande. Au-delà de JOURS_GARDE, les lignes sont oubliées. """ meta = dict(tuple(m) for m in d.get("META", [])) jour = meta.get("genere", "")[:10] or date.today().isoformat() limite = (date.today() - timedelta(days=JOURS_GARDE)).isoformat() lignes = [] if HISTORIQUE.exists(): for l in HISTORIQUE.read_text(encoding="utf-8").splitlines(): if l.strip(): c = l.split("\t") if len(c) == 3 and c[0] >= limite and c[0] != jour: lignes.append(c) for nom, _heure, etat, _detail in d.get("TACHE", []): lignes.append([jour, nom, etat]) lignes.sort() HISTORIQUE.write_text( "\n".join("\t".join(l) for l in lignes) + "\n", encoding="utf-8") hist = {} for j, nom, etat in lignes: hist.setdefault(nom, {})[j] = etat return hist def e(texte): return html.escape(str(texte), quote=True) def pastille(texte, classe): return f'{e(texte)}' ETATS_TACHE = { "ok": ("ok", "ok"), "mensuelle": ("mensuelle", "neutre"), "absente": ("absente", "crit"), "inachevee": ("inachevée", "crit"), "echec": ("échec", "crit"), } ETATS_TEMOIN = { "ok": ("à jour", "ok"), "vieux": ("trop vieux", "crit"), "absent": ("absent", "crit"), "vide": ("vide", "crit"), } # Une tâche mensuelle non due ce jour-là n'est pas un trou : elle se rend # comme une case vide, au même titre qu'un jour sans relevé. CASES = { "ok": "c-ok", "echec": "c-crit", "absente": "c-crit", "inachevee": "c-crit", } def bande(hist, nom): """Trente cases, du plus ancien à gauche au jour même à droite.""" largeur = JOURS_BANDE * 12 - 2 out = [f""] for k in range(JOURS_BANDE): j = (date.today() - timedelta(days=JOURS_BANDE - 1 - k)).isoformat() etat = hist.get(nom, {}).get(j) out.append(f"" f"{j}") out.append("") return "".join(out) # ATTENTION : une valeur CSS ne doit JAMAIS être coupée en deux lignes. Une # chaîne entre guillemets contenant un saut de ligne est invalide, la # déclaration est rejetée en silence, et le texte retombe sur la police par # défaut du navigateur. CSS = """ :root{ --ground:#dfe6e8; --surface:#fff; --surface-2:#eaf1f2; --line:#c2ced1; --ink:#14191b; --ink-2:#4d585c; --ink-3:#79868b; --accent:#16626d; --ok:#2c6e4a; --warn:#8a6100; --crit:#9d2f2f; --ok-bg:#dcecE3; --warn-bg:#f6edd8; --crit-bg:#f7e0de; --f-titre:"Ubuntu Condensed","DejaVu Sans Condensed",system-ui,sans-serif; --f-texte:"Ubuntu","Noto Sans","DejaVu Sans",system-ui,sans-serif; --f-mono:ui-monospace,"DejaVu Sans Mono","Liberation Mono",monospace; } @media (prefers-color-scheme:dark){:root:not([data-theme=light]){ --ground:#0d1113; --surface:#191f21; --surface-2:#222a2c; --line:#333d40; --ink:#e7edee; --ink-2:#a4b1b5; --ink-3:#78868b; --accent:#5fb9c6; --ok:#63b98a; --warn:#d3a441; --crit:#e0736f; --ok-bg:#17301f; --warn-bg:#2c2416; --crit-bg:#341d1d; }} *{box-sizing:border-box} body{margin:0;background:var(--ground);color:var(--ink); font-family:var(--f-texte);line-height:1.5} .wrap{max-width:1000px;margin:0 auto;padding:26px 20px 56px; display:flex;flex-direction:column;gap:20px} h1{font-family:var(--f-titre);font-size:34px;margin:0;letter-spacing:-.01em} .entete{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 16px; border-bottom:2px solid var(--ink);padding-bottom:12px} .entete .quand{margin-left:auto;font-family:var(--f-mono);font-size:13px; color:var(--ink-2)} .entete .quand.vieux{color:var(--warn);font-weight:600} .verdict{border-radius:4px;padding:16px 20px;border:1px solid} .verdict.calme{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)} .verdict.alerte{background:var(--crit-bg);border-color:var(--crit)} .verdict h2{font-family:var(--f-titre);font-size:22px;margin:0} .verdict ul{margin:10px 0 0;padding-left:20px;color:var(--ink)} .verdict li{margin:3px 0} section.bloc{background:var(--surface);border:1px solid var(--line); border-radius:4px;overflow:hidden} section.bloc>h2{font-family:var(--f-titre);font-size:19px;margin:0; padding:12px 18px;background:var(--surface-2); border-bottom:1px solid var(--line)} .corps{padding:16px 18px;display:flex;flex-direction:column;gap:22px} h3{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em; text-transform:uppercase;color:var(--ink-3);margin:0 0 10px} table{width:100%;border-collapse:collapse;font-size:14px} th{text-align:left;font-family:var(--f-mono);font-size:10.5px; letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3); padding:0 10px 6px 0;border-bottom:1px solid var(--line)} th.num{text-align:right} td{padding:7px 10px 7px 0;border-bottom:1px solid var(--line)} tr:last-child td{border-bottom:none} td.mono{font-family:var(--f-mono);font-size:13px;white-space:nowrap} td.num{font-family:var(--f-mono);font-variant-numeric:tabular-nums; text-align:right;white-space:nowrap} td.plein{width:100%} .chip{display:inline-block;font-family:var(--f-mono);font-size:10.5px; letter-spacing:.06em;text-transform:uppercase;padding:2px 7px; border-radius:3px;border:1px solid} .chip.ok{color:var(--ok);background:var(--ok-bg);border-color:var(--ok)} .chip.crit{color:var(--crit);background:var(--crit-bg); border-color:var(--crit)} .chip.warn{color:var(--warn);background:var(--warn-bg); border-color:var(--warn)} .chip.neutre{color:var(--ink-3);background:transparent; border-color:var(--line)} .duo{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:22px} .grand{font-family:var(--f-titre);font-size:44px;line-height:1; font-variant-numeric:tabular-nums} .note{font-size:13px;color:var(--ink-3)} footer{font-size:12.5px;color:var(--ink-3);border-top:1px solid var(--line); padding-top:12px} /* Jauge et bande sont dessinées en SVG et non avec un fond CSS : un objet de contenu s'imprime toujours, un arrière-plan non — les navigateurs les suppriment par défaut, et la case « imprimer les arrière-plans » de leur boîte de dialogue prime sur print-color-adjust. */ .jauge{display:block;width:100%;height:14px} .j-fond{fill:none;stroke:var(--accent);stroke-width:1} .j-plein{fill:var(--accent)} .bande{display:block;width:100%;height:13px} .c-ok{fill:var(--ok)} .c-crit{fill:var(--crit)} .c-vide{fill:var(--line)} @media print{ :root{--ground:#fff; --surface:#fff; --surface-2:#f0f0f0;} :root{--line:#b8c4c7; --ink:#000; --ink-2:#333; --ink-3:#555;} *{print-color-adjust:exact; -webkit-print-color-adjust:exact} body{background:#fff} .wrap{max-width:none;padding:0} section.bloc,table,.verdict,.duo>div{break-inside:avoid} } """ def construire(d, hist): meta = dict(tuple(m) for m in d.get("META", [])) alertes = [a[0] for a in d.get("ALERTE", [])] p = [] A = p.append A("") A('') A('') A("Tableau de bord — nas-host") A(f"
") A("

Tableau de bord — nas-host

") A(f"
" f"relevé du {e(meta.get('genere','?')[:16].replace('T',' '))}
") A("
") if alertes: A("
") n = len(alertes) A(f"

{n} point{'s' if n > 1 else ''} à regarder

    ") for a in alertes: A(f"
  • {e(a)}
  • ") A("
") else: A("

Rien à regarder

") # --- Sauvegardes --- A("

Sauvegardes

") A("

La nuit écoulée

" "") for nom, heure, etat, detail in d.get("TACHE", []): libelle, classe = ETATS_TACHE.get(etat, (etat, "neutre")) A(f"" f"") A("
TâchePrévueÉtatDétail
{e(nom)}{e(heure)}{pastille(libelle, classe)}{e(detail)}
") A("

Trente jours

" "

Du plus ancien à gauche au jour même à droite. Une " "case pâle signifie qu'aucun relevé n'existe pour ce jour — " "l'historique se remplit à partir d'aujourd'hui. Les tâches " "mensuelles n'y marquent que le 1er.

") for nom, _h, _e, _d in d.get("TACHE", []): A(f"" f"") A("
{e(nom)}{bande(hist, nom)}
") A("

Fraîcheur des destinations

" "

La date du fichier le plus récent, et non celle du " "répertoire : rsync ne touche cette dernière que si le contenu change. " "Chaque destination porte son propre âge toléré.

" "" "" "") for court, dat, jours, tolere, etat in d.get("TEMOIN", []): libelle, classe = ETATS_TEMOIN.get(etat, (etat, "neutre")) A(f"" f"" f"") A("
DestinationDernierÂgeToléréÉtat
{e(court)}{e(dat)}{e(jours)} j{e(tolere)} j{pastille(libelle, classe)}
") # --- Services --- A("

Services

") A("
") A("

Unités

") for nom, etat in d.get("SERVICE", []): c = "ok" if etat == "active" else "crit" A(f"" f"") A("
{e(nom)}{pastille(etat, c)}
") A("

Conteneurs

") for nom, etat in d.get("CONTENEUR", []): c = "ok" if etat == "RUNNING" else "crit" A(f"" f"") A("
{e(nom)}{pastille(etat, c)}
") A("
") A("

Diffusion

" "

Une unité active ne prouve pas qu'un flux est en " "ondes : on lit les points de montage réellement publiés par Icecast. " "Un seul dongle, donc exactement une unité rtl-fm attendue — quelle " "que soit la station.

") for mont, etat in d.get("FLUX", []): c = "ok" if etat == "en ondes" else "crit" A(f"" f"") for (n,) in d.get("RTLFM", []): c = "ok" if n == "1" else "crit" A(f"" f"") A("
{e(mont)}{pastille(etat, c)}
unités rtl-fm actives{pastille(n + ' (attendu : 1)', c)}
") ue = d.get("UNITEECHEC", []) A("

Unités en échec

") if ue: A("") for (u,) in ue: A(f"" f"") A("
{e(u)}{pastille('en échec', 'crit')}
") else: A(f"

{pastille('aucune', 'ok')}

") A("
") # --- Système --- A("

Système

") A("
") for taille, _utilise, libre, pct in d.get("ESPACE", []): try: large = min(99.0, float(pct) * 0.99) except ValueError: large = 0.0 A("

Espace

" f"
{e(pct)} %
" "" "" f"" f"

{e(libre)} libres sur {e(taille)}. " "Une seule jauge : nas1 et nas2 sont des montages liés du même " "disque.

") for date_fin, reste in d.get("CERT", []): A("

Certificat générique

" f"
{e(reste)}
" f"

jours avant le {e(date_fin)}. Renouvellement " "manuel : rien sur la machine ne s'en charge.

") for (n_lt,) in d.get("LT", []): A("

LanguageTool depuis Internet

" f"
{e(n_lt)}
" "

requêtes venues d'ailleurs que du réseau local et " "du tunnel, hier. Seul des six sites publics à n'exiger aucune " "authentification : toute valeur autre que zéro mérite qu'on le " "restreigne.

") A("
") A("

fail2ban

" "

Trois nombres de portées différentes : échecs et " "blocages comptent depuis le démarrage du service, " "« actuels » dit ce qui est bloqué à l'instant. Des échecs qui montent " "sans aucun blocage signalent un seuil hors d'atteinte.

" "" "" "") for machine, nom, ech, ban, act, note in d.get("JAIL", []): A(f"" f"" f"" f"") A("
MachinePrisonÉchecsBlocagesActuelsNote
{e(machine)}{e(nom)}{e(ech)}{e(ban)}{e(act)}{e(note)}
") A(f"
Page fabriquée à partir de {e(SOURCE.name)} et de " f"{e(HISTORIQUE.name)}, produits par le relevé sur nas-host. " "Aucune requête sortante.
") A("
") A("""""") A("") return "\n".join(p) def main(): if not SOURCE.exists(): sys.exit(f"ÉCHEC : {SOURCE} introuvable — lancer le relevé d'abord.") d = lire(SOURCE) hist = historiser(d) SORTIE.write_text(construire(d, hist), encoding="utf-8") print(f"page écrite : {SORTIE}") print(f"historique : {HISTORIQUE} ({len(hist)} tâches suivies)") if __name__ == "__main__": main() ``` Fabriquer la page : **Sur `[nas-host]`** : ``` python3 /home/hostadmin/scripts/tableau-de-bord/build_tableau-de-bord.py ``` ### Le lanceur sur le poste **Sur `[poste-bureau]`** — ouvrir l'éditeur : ``` mkdir -p ~/.local/bin ~/.local/share/applications && \ nano ~/.local/bin/tableau-de-bord ``` Contenu : ```bash #!/bin/bash # Ouvre le tableau de bord de nas-host, après avoir tenté de le rafraîchir. # # Le rafraîchissement est une commodité, pas une condition : s'il échoue, # la page s'ouvre avec les données du dernier passage. Son en-tête indique # l'âge du relevé et passe à l'ambre au-delà de 36 h. PAGE="/media/nas1/Backups/Tableau de bord - nas-host/tableau-de-bord.html" CIBLE=hostadmin@192.168.0.11 DISTANT=/home/hostadmin/scripts/tableau-de-bord if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$CIBLE" \ "bash $DISTANT/releve-tableau-de-bord.sh >/dev/null && python3 $DISTANT/build_tableau-de-bord.py >/dev/null" 2>/dev/null then notify-send -i dialog-warning "Tableau de bord" \ "Rafraîchissement impossible — affichage du dernier passage." \ 2>/dev/null fi if [ -f "$PAGE" ]; then exec xdg-open "$PAGE" else notify-send -i dialog-error "Tableau de bord" \ "Page introuvable — /media/nas1 est-il monté ?" 2>/dev/null exit 1 fi ``` Le rafraîchissement suppose une clé SSH vers `hostadmin@192.168.0.11`. Sans elle, la notification s'affiche et la page s'ouvre quand même. Pour l'établir, une seule fois : ``` ssh-copy-id hostadmin@192.168.0.11 ``` **Sur `[poste-bureau]`** — le raccourci : ``` nano ~/.local/share/applications/tableau-de-bord.desktop ``` Contenu : ``` [Desktop Entry] Type=Application Name=Tableau de bord — nas-host Comment=État des sauvegardes et des services Exec=/home/user/.local/bin/tableau-de-bord Icon=utilities-system-monitor Terminal=false Categories=System;Monitor; ``` Le chemin de `Exec` doit être absolu : le tilde n'est pas développé dans un fichier `.desktop`. **Sur `[poste-bureau]`** — rendre exécutable, poser sur le bureau, autoriser : ``` chmod +x ~/.local/bin/tableau-de-bord cp ~/.local/share/applications/tableau-de-bord.desktop ~/Bureau/ chmod +x ~/Bureau/tableau-de-bord.desktop gio set ~/Bureau/tableau-de-bord.desktop metadata::trusted true update-desktop-database ~/.local/share/applications 2>/dev/null ``` ⚠️ Cinnamon exige les deux dernières opérations. Sans le bit exécutable et sans la marque de confiance, l'icône affiche le nom du fichier et refuse de se lancer au double-clic. ### Vérifier L'essai qui compte n'est pas le lancement manuel mais celui en environnement dépouillé : cron n'a ni le `PATH` de la session, ni ses variables, ni sa locale. **Sur `[nas-host]`** : ``` env -i HOME=/home/hostadmin PATH=/usr/bin:/bin /bin/bash -c \ 'cd /home/hostadmin/scripts/tableau-de-bord && bash releve-tableau-de-bord.sh >/dev/null && python3 build_tableau-de-bord.py' ``` Un code de sortie nul et deux lignes de confirmation. Un `command not found` sur `incus`, `curl` ou `openssl` trahirait un `PATH` insuffisant. Relancer ce test ne fausse rien : l'historique remplace les lignes du jour au lieu de les empiler. **Le tableau surveille sa propre fraîcheur.** L'en-tête recalcule l'âge du relevé à l'ouverture et passe à l'ambre au-delà de trente-six heures. Si l'ordonnancement cesse, la page le dit d'elle-même — c'est la seule surveillance dont le dispositif ait besoin pour lui-même. ### Régler les seuils Les âges tolérés sont des valeurs de départ. Une destination qui se plaint sans raison n'a pas un défaut de sauvegarde : elle a un seuil trop serré pour ce qu'elle contient. `incus-exports` est mensuel, et l'archive de la clé d'amorçage ne change qu'au gré du système. Ils se modifient dans le bloc `TEMOINS` du relevé, un nombre de jours par ligne. 🎯 **Une mesure peut exister pour ne rien dire pendant des années, et c'est son intérêt.** La dernière section du relevé compte les requêtes venues d'Internet vers le seul site public que rien ne protège par mot de passe. Elle affiche zéro, et affichera zéro tant que personne ne l'aura trouvé. **Le jour où ce n'est plus zéro, la décision de fermer ce service se prend sur un fait plutôt que sur une crainte** — et elle se prend le lendemain, pas au centième visiteur. ⚠️ **Ce zéro doit rester visible sur la page, et pas seulement déclencher une alerte.** Une mesure qui ne parle que lorsqu'elle se déclenche est indiscernable d'une mesure morte : « aucune alerte » et « le relevé ne mesure plus rien » ont exactement la même apparence. Le chiffre affiché est ce qui prouve que le contrôle tourne encore. 🔴 **Un seuil mal posé transforme un tableau de veille en bruit de fond.** Le relevé signalait d'abord toute prison affichant des échecs sans aucun blocage — ce qui paraît sensé et ne l'est pas : une prison qui compte deux échecs avec un seuil à cinq **fonctionne exactement comme prévu**. La règle aurait produit un point à regarder chaque matin, pour rien. Le signal utile est un compteur qui monte franchement sans que rien ne soit jamais banni ; le seuil est donc à dix, et la nuance est écrite dans le script. Les trois nombres du panneau n'ont d'ailleurs pas la même portée : échecs et blocages comptent depuis le démarrage du service — bannissements rétablis depuis la base compris —, tandis qu'« actuels » dit ce qui est bloqué à l'instant. Les comparer terme à terme fait conclure à une panne là où la prison travaille. 🔴 **Mais avant de régler un seuil, il faut savoir si le témoin peut mesurer quelque chose.** `rsync -a` préserve les dates de la source : la fraîcheur d'un répertoire de configurations miroir ne dit pas si le tirage fonctionne, elle dit **quand tu as utilisé le logiciel pour la dernière fois**. Un témoin posé sur `Configuration_logiciels/` affichait « ok » parce que deux applications avaient bougé la veille, et serait passé au rouge un mois tranquille où tout allait bien. Il a été retiré : aucun seuil ne pouvait le rendre juste. 🎯 **Un témoin de fraîcheur ne vaut que là où le fichier est refabriqué, pas recopié.** Les vidages de bases, l'archive du filtre DNS, la copie SQLite du serveur de musique, les exports mensuels : ceux-là portent la date de la tâche. Un miroir rsync de fichiers qui ne changent pas porte la date de leur dernière modification, et ce n'est pas la même question. Les miroirs de configurations et de scripts n'ont donc aucun témoin : leur exécution se lit dans le registre des tâches, et nulle part ailleurs. ⚠️ **Retirer un témoin ne retire aucune surveillance de la tâche elle-même** : son exécution reste lue dans le registre des tâches, par les marqueurs du journal. Ce qu'on perd est un indicateur qui racontait autre chose que ce qu'on y lisait — et c'est un gain, selon la doctrine des *trois contrôles qui n'en sont pas*. ⚠️ **Deux tâches ne doivent jamais écrire dans un même répertoire surveillé.** Le témoin prend le fichier le plus récent de toute l'arborescence : la plus assidue des deux maintient le témoin au vert pour les deux, et la défaillance de l'autre devient invisible. De même, le seuil de remplissage du disque est à 85 % et celui du certificat à trente jours, tous deux dans la dernière section du relevé. ### Ce qu'il faut savoir avant de modifier **Le registre des tâches est la source de vérité.** Ajouter une tâche de sauvegarde à la crontab sans l'ajouter au bloc `TACHES` la rend invisible au tableau — et une tâche absente du registre ne sera jamais signalée absente. **Trois formats de marqueurs cohabitent dans le journal.** La forme courante est `===