Files
varlog/data/bea327e2-9d1c-4ff6-a5a5-26748c80018b/revisions/0001.md
T

250 lines
13 KiB
Markdown

# > Comment un simple script Bash peut télécharger, mettre à jour et synchroniser une bibliothèque de scripts distants — et pourquoi il faut le lire avec un œil critique.
[fetch_scripts.sh](https://git.abonnel.fr/cedricAbonnel/notes-techniques/raw/commit/18c6dd9e45e57d272659da6e2c53b79048985400/scripts/fetch_scripts.sh)
## Le contexte
L'idée derrière ce script est élégante : centraliser une collection de scripts utilitaires dans un dépôt Git public (ici, une instance Forgejo auto-hébergée), puis fournir un **unique point d'entrée** que l'on télécharge sur n'importe quelle machine. Ce point d'entrée se met à jour tout seul, propose à l'opérateur de choisir quels sous-ensembles de scripts récupérer, et maintient une synchronisation locale du dépôt distant.
C'est typiquement le genre d'outil qui se déploie en une ligne :
```bash
wget https://git.example.fr/.../fetch_scripts.sh && bash fetch_scripts.sh
```
Décortiquons ce qu'il fait, étape par étape, puis voyons où il faudrait taper.
---
## Étape 1 — L'auto-mise à jour
```bash
SCRIPT_URL="https://git.abonnel.fr/.../fetch_scripts.sh"
SCRIPT_NAME=$(basename "$0")
TMP_SCRIPT="/tmp/$SCRIPT_NAME"
wget -q -O "$TMP_SCRIPT" "$SCRIPT_URL"
if ! cmp -s "$TMP_SCRIPT" "$0"; then
echo "🔄 Mise à jour du script..."
mv "$TMP_SCRIPT" "$0"
chmod +x "$0"
exec "$0" "$@"
fi
```
**Ce qui se passe :** le script télécharge sa propre version distante dans `/tmp`, la compare octet-à-octet avec lui-même (`cmp -s`), et si elle diffère, il s'écrase, se rend exécutable, et **se relance** via `exec` (qui remplace le processus courant — pas d'empilement de shells).
**Pourquoi c'est malin :** ça garantit qu'à chaque exécution, l'opérateur travaille avec la version canonique du dépôt. Pas besoin de mécanisme de versioning, pas de vérification de hash, pas de paquet à publier.
**Pourquoi c'est risqué :** on y reviendra dans la critique, mais en résumé — l'auto-mise à jour silencieuse depuis une URL en HTTPS sans signature est une porte d'entrée pour la chaîne d'approvisionnement.
---
## Étape 2 — Récupération du catalogue de dossiers
```bash
REPO_URL="https://git.abonnel.fr/.../scripts"
DIR_LIST_FILE=".directories.txt"
MANDATORY_DIR="common"
TMP_DIR=$(mktemp -d)
wget -q -O "$TMP_DIR/$DIR_LIST_FILE" "$REPO_URL/$DIR_LIST_FILE"
mapfile -t AVAILABLE_DIRS < "$TMP_DIR/$DIR_LIST_FILE"
```
Le dépôt distant contient un fichier `.directories.txt` qui liste les catégories de scripts disponibles (par exemple : `common`, `proxmox`, `php`, `monitoring`…). Ce fichier est la **source de vérité** : ajouter une catégorie côté serveur la rend immédiatement disponible côté client.
`mapfile` (alias `readarray`) lit le fichier ligne à ligne dans un tableau Bash. Plus propre qu'une boucle `while read`.
Un dossier `common` est marqué comme obligatoire — il sera toujours téléchargé, sans demander à l'utilisateur.
---
## Étape 3 — Mémoire de la sélection précédente
```bash
SELECTED_DIRS_FILE=".selected_dirs.txt"
if [ -f "$SELECTED_DIRS_FILE" ]; then
mapfile -t PREVIOUS_SELECTION < "$SELECTED_DIRS_FILE"
else
PREVIOUS_SELECTION=()
fi
```
À chaque exécution, le script relit la sélection de la fois précédente. C'est ce qui permet à l'interface graphique (étape suivante) de **pré-cocher** les bons dossiers : on n'a pas à refaire son choix à chaque mise à jour.
---
## Étape 4 — L'interface `whiptail`
```bash
CHOICES=()
for dir in "${AVAILABLE_DIRS[@]}"; do
if [ "$dir" == "$MANDATORY_DIR" ]; then
continue
fi
if [[ " ${PREVIOUS_SELECTION[*]} " =~ " $dir " ]]; then
CHOICES+=("$dir" "" ON)
else
CHOICES+=("$dir" "" OFF)
fi
done
SELECTED_DIRS=$(whiptail --title "Sélection des dossiers" --checklist \
"Sélectionnez les dossiers à télécharger :" 20 60 10 \
"${CHOICES[@]}" 3>&1 1>&2 2>&3)
```
`whiptail` est l'outil de dialogue ncurses standard sur Debian/Ubuntu — il affiche cette boîte bleue familière avec des cases à cocher, navigable au clavier. Idéal en SSH.
La gymnastique `3>&1 1>&2 2>&3` est un classique : `whiptail` écrit son interface sur stdout et sa réponse sur stderr. Il faut donc **échanger les deux** pour capturer la sélection dans `$SELECTED_DIRS` tout en laissant l'interface s'afficher.
L'expression `[[ " ${ARRAY[*]} " =~ " $dir " ]]` est une astuce courante pour tester l'appartenance à un tableau Bash — on entoure d'espaces pour éviter les correspondances partielles (`web` qui matcherait `web-server`).
---
## Étape 5 — Synchronisation : ajouts et suppressions
```bash
SELECTED_DIRS_ARRAY=("$MANDATORY_DIR" $(echo "$SELECTED_DIRS" | tr -d '"'))
echo "${SELECTED_DIRS_ARRAY[@]}" > "$SELECTED_DIRS_FILE"
for dir in "${PREVIOUS_SELECTION[@]}"; do
if [[ ! " ${SELECTED_DIRS_ARRAY[*]} " =~ " $dir " ]]; then
echo "🗑 Suppression du dossier $dir..."
rm -rf "$dir"
fi
done
```
Logique de diff : tout ce qui était sélectionné avant et ne l'est plus est **supprimé du disque**. Ça maintient le répertoire local propre — pas de scripts orphelins qui traînent.
`whiptail` renvoie la sélection sous forme de chaîne entre guillemets (`"dir1" "dir2"`), d'où le `tr -d '"'` pour les retirer avant de constituer le tableau.
---
## Étape 6 — Téléchargement des fichiers de chaque dossier
```bash
for TARGET_DIR in "${SELECTED_DIRS_ARRAY[@]}"; do
wget -q -O "$LIST_PATH" "$REPO_URL/$TARGET_DIR/.list_files.txt"
mkdir -p "$TARGET_DIR"
while read -r file; do
wget -q -O "$TARGET_DIR/$(basename "$file")" "$REPO_URL/$TARGET_DIR/$file"
done < "$LIST_PATH"
for existing_file in "$TARGET_DIR"/*; do
if [ -f "$existing_file" ] && ! grep -qx "$(basename "$existing_file")" "$LIST_PATH"; then
rm "$existing_file"
fi
done
chmod +x "$TARGET_DIR"/*.sh
done
```
Même logique récursive d'un niveau plus bas : chaque dossier contient son propre `.list_files.txt` listant ses fichiers. On télécharge ceux qui y figurent, on supprime ceux qui n'y figurent plus, et on rend tout exécutable.
C'est une forme de `rsync` artisanal, basé sur des manifestes plats. Ça fonctionne sans avoir à installer `git` sur la machine cible — seuls `wget` et `whiptail` sont requis.
---
## Critique : ce qui marche, ce qui inquiète
### Les bons côtés
**La logique d'idempotence** est solide. Le script peut tourner cent fois de suite, il convergera toujours vers le même état : les dossiers sélectionnés contiendront exactement les fichiers du manifeste, ni plus, ni moins. C'est le bon réflexe DevOps.
**L'auto-bootstrap** est ergonomique. Une seule URL à retenir, tout le reste se télécharge tout seul. Pour une bibliothèque personnelle de scripts d'admin, c'est imbattable en simplicité.
**Pas de dépendances exotiques.** `wget`, `whiptail`, `mapfile` : tout est disponible nativement sur Debian. Le script tourne aussi bien sur un conteneur LXC fraîchement provisionné que sur une machine établie.
**Le manifeste séparé** (`.directories.txt` et `.list_files.txt`) découple la liste des fichiers de leur contenu. C'est plus simple qu'un parsing HTML de l'index Git, et ça reste sous contrôle éditorial.
### Les angles morts
#### 1. Aucune vérification d'intégrité
C'est **le** point critique. Le script télécharge du code exécutable en HTTPS, sans vérifier :
- ni signature GPG,
- ni hash SHA256,
- ni même que le serveur a bien répondu correctement.
`wget -q` en mode silencieux **n'échoue pas visiblement** : si la requête renvoie une page d'erreur 404 ou une page de connexion captive Wi-Fi en HTML, elle sera écrite dans le fichier de destination. La vérification suivante (`cmp -s`) considérera ce HTML comme « différent », fera le `mv`, et au prochain `exec` le shell essaiera d'exécuter du HTML. Au mieux ça crashe, au pire ça exécute des balises interprétables.
**Pire encore pour l'auto-update :** si quelqu'un compromet l'instance Forgejo (ou interpose un proxy malveillant capable de servir un certificat valide pour `git.abonnel.fr`), le prochain `fetch_scripts.sh` télécharge et exécute du code arbitraire avec les privilèges de l'utilisateur courant — souvent root pour ce genre d'outils d'admin.
**Correctif minimal :** publier un fichier `.sha256sums` signé GPG dans le dépôt, le télécharger, vérifier sa signature avec une clé connue localement, puis valider chaque fichier téléchargé contre ce manifeste.
#### 2. `wget` sans gestion d'erreur
```bash
wget -q -O "$TMP_SCRIPT" "$SCRIPT_URL"
if ! cmp -s "$TMP_SCRIPT" "$0"; then
```
Si `wget` échoue (réseau coupé, DNS HS, certificat expiré), `$TMP_SCRIPT` sera soit vide soit absent. `cmp -s` retournera « différent », et le script **écrasera la version locale par un fichier vide**. À la prochaine exécution, plus rien ne fonctionne.
**Correctif :** vérifier le code de retour de `wget`, vérifier que le fichier téléchargé n'est pas vide, et vérifier qu'il commence bien par `#!/bin/bash` avant d'écraser quoi que ce soit.
```bash
if ! wget -q -O "$TMP_SCRIPT" "$SCRIPT_URL"; then
echo "❌ Téléchargement échoué, on garde la version actuelle"
elif [ ! -s "$TMP_SCRIPT" ] || ! head -n1 "$TMP_SCRIPT" | grep -q "^#!"; then
echo "❌ Fichier téléchargé invalide"
rm -f "$TMP_SCRIPT"
elif ! cmp -s "$TMP_SCRIPT" "$0"; then
# ...
fi
```
#### 3. Le `exec "$0" "$@"` perd les modifications de l'environnement
Si le script a été lancé par `bash fetch_scripts.sh` (donc sans le bit exécutable, sans shebang utilisé), `$0` vaut `fetch_scripts.sh`. Après `mv`, on `exec` un fichier qui pourrait ne pas être dans le `$PATH`. En pratique ça marche parce qu'on est dans le bon répertoire, mais c'est fragile — un `cd` quelque part dans le script suffirait à le casser.
#### 4. Injection via les noms de fichiers du manifeste
```bash
while read -r file; do
wget -q -O "$TARGET_DIR/$(basename "$file")" "$REPO_URL/$TARGET_DIR/$file"
done < "$LIST_PATH"
```
Le contenu de `.list_files.txt` est utilisé directement dans une URL et dans un chemin de fichier local. Si quelqu'un peut écrire dans ce fichier manifeste (ce qui revient à pouvoir pousser sur le dépôt Forgejo), il peut y mettre des chemins comme `../../../etc/cron.d/backdoor` et écrire en dehors du répertoire prévu.
`basename` neutralise partiellement la chose côté nom local, mais l'URL côté distant accepte n'importe quoi. C'est moins critique que la première faille, mais ça mérite un filtre regex (`[a-zA-Z0-9._-]+` uniquement).
#### 5. `whiptail` et la sélection vide
Si l'utilisateur ne coche rien et valide, `$SELECTED_DIRS` est vide. Le script continue avec seulement `common`, ce qui est probablement le comportement attendu. Mais si `whiptail` n'est pas installé (rare mais possible, par exemple sur Alpine ou un Debian minimal sans `whiptail`), le script échoue avec une erreur peu explicite. Un test préalable `command -v whiptail` éviterait la déconvenue.
#### 6. Pas de log, pas de mode dry-run
Pour un outil qui supprime des fichiers (`rm -rf "$dir"`), l'absence d'option `--dry-run` qui afficherait ce qui *serait* fait sans rien toucher est gênante. Une frappe distraite sur la checklist, et un dossier entier disparaît sans warning.
#### 7. Le verrou manquant
Rien n'empêche deux instances de `fetch_scripts.sh` de tourner en parallèle (par exemple via `cron` et un opérateur en interactif). Un `flock` sur un fichier de lock éviterait des courses sur les opérations de download/delete.
---
## Verdict
C'est un script **utile, lisible, et bien construit pour un usage personnel** sur des machines de confiance. La logique de synchronisation est saine, l'ergonomie `whiptail` est appréciable, l'auto-bootstrap est élégant.
Mais dès qu'on franchit la frontière du « j'utilise ça sur mes propres machines avec mon propre dépôt », les manques se font sentir : **pas de vérification d'intégrité, pas de gestion d'erreur réseau, pas d'option de récupération**. Dans un contexte d'équipe ou de production, ces points sont bloquants.
### Pistes d'évolution prioritaires
1. **Signature ou checksum** : publier un `MANIFEST.sha256` signé GPG, le vérifier avant tout `mv` ou exécution.
2. **`set -euo pipefail`** en tête de script pour faire échouer proprement à la première erreur.
3. **Vérifier `wget`** : code de retour, fichier non vide, shebang présent.
4. **Backup avant écrasement** : conserver la version précédente (`fetch_scripts.sh.bak`) pour pouvoir revenir en arrière.
5. **Option `--dry-run`** pour visualiser sans appliquer.
6. **Filtre regex** sur les noms de fichiers du manifeste pour éviter les traversées de chemin.
7. **Lock file** via `flock` pour éviter les exécutions concurrentes.
Avec ces ajouts, on passe d'un script « pratique » à un outil de déploiement digne de ce nom — sans rien perdre de sa simplicité initiale.