252 lines
13 KiB
Markdown
252 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)
|
|
|
|
> 📝 **Note** — Cet article est une autocritique. Le script `fetch_scripts.sh` analysé ici est de ma propre fabrication, déployé sur mes propres machines. L'exercice consiste à le relire avec la distance d'un reviewer extérieur, pour identifier ce qui tient la route et ce qui mériterait d'être repris.
|
|
|
|
## 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. |