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

13 KiB

> 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

📝 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 :

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

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

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

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

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

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

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

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.

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

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.