Title: Bash avancé: It's a trap! Category: sysadmin Tags: bash, script, signaux, pl-fr Date: 2022-11-16 0:10 Modified: 2022-11-18 9:55 Cover: assets/backgrounds/ackbar-trap.jpg Pour l'instant, on ne peut pas dire que 2022 soit une année productive côté article de blog. Le seul et unique article de cette année parlait de *Bash* qui a eu un peu de succès. Comme toute les séries B un peu populaire, il lui fallait une suite. Et bien la voici! Dans cette suite, il va être question de signaux, de **"trap"** et bien entendu de *Bash*. ## Dis, c'est quoi un signal Un signal est une sorte de notification envoyée à un processus lorsqu'un événement particulier a eu lieu. Plus concrètement, lorsqu'un programme ne répond pas dans votre terminal et que vous faites Ctrl+C, le système envoi le signal SIGINT au processus en cours lui signifiant d'interrompre séance tenante toute action. Chaque signal est associé à un numéro Voici un liste de quelques signaux (norme POSIX) : Signal | Valeur | Action | Commentaire --------|--------|--------|---------------------------------------------------- SIGHUP | 1 | Term | Déconnexion du terminal ou fin du processus de contrôle SIGINT | 2 | Term | Interruption depuis le clavier `CTRL + C` SIGQUIT | 3 | Core | Demande ”Quitter” depuis le clavier `CTRL + \` SIGILL | 4 | Core | Instruction illégale SIGABRT | 6 | Core | Signal d’arrêt depuis abort(3) SIGFPE | 8 | Core | Erreur mathématique virgule flottante SIGKILL | 9 | Term | Signal ”KILL” ... | ... | ... | ... ## Les utiliser dans Bash : trap Paf, l'intrigue arrive comme ça d'un coup : nous allons utiliser la commande `trap` pour gérer les signaux. C'est une commande interne à *Bash* simple à utiliser : ```bash trap "" ``` Attention cependant, tous les signaux ne peuvent pas être piégés : `SIGKILL` par exemple ne peut pas donner lieu à exécution d'une commande. ## Dans la vraie vie? Prenons un cas concret : un script nécessite l'ouverture d'une connexion SSH persistante à l'aide des options `ControlMaster` et `ContolPath` histoire de pouvoir lancer plusieurs connexions successives plus rapidement (et sans se ré-identifier). Si le script ne se passe pas comme prévu alors il est plutôt conseillé de faire le ménage et terminer la connexion maître. ```bash #!/usr/bin/env bash # Changer ces valeurs par celles adapté à votre configuration server="192.168.0.254" user="user" # Réutilisons ce que nous avons créé lors du précédent article source ./message.sh ssh_sock="/tmp/$(mktemp -u XXXXXXXXXX)" connect() { msg "Create SSH main connection wih socket ${ssh_sock}" ssh -N -o ControlMaster=yes -o ControlPath="$ssh_sock" -f ${user}@${server} || { error "Can't connect to $server"; exit 20; } } launch_command() { if [ -z "$1" ] then error "Launch command require 1 parameter" exit 31 fi local command command="$1" ssh -q -t -S "$ssh_sock" ${user}@${server} "$command" || { error "Error executing $command"; exit 30; } } cleanup() { msg "Close SSH main connection" ssh -q -o ControlPath="$ssh_sock" -O exit $server || { error "Can't close SSH master connection, $ssh_sock remain"; } # Sans ce exit, INT ne termine pas le script exit } connect trap cleanup EXIT if [ "$1" = "error" ] then launch_command fi for (( i=1; i<=5; i++ )) do launch_command "echo 'Message N°$i from $server'" sleep 1 done ``` Ce script est disponible en suivant [ce lien]({attach}files/premier_script.sh) La connexion principale est initiée par la fonction `connect`. Juste en dessous de l'appel de cette dernière nous trouvons notre instruction `trap`. Elle appelle la commande `cleanup` lorsque le signal `EXIT` est envoyé. `EXIT` n'est pas un signal standard du système mais interne à *Bash* comme `ERR`, `DEBUG` ou `RETURN`. Ensuite, si l'argument `error` est passé à notre script, `launch_command` est exécutée mais sans paramètre, ce qui va générer une erreur. Enfin notre script exécute 5 fois en boucle la fonction `launch_command` qui se charge d'afficher un petit message sur la machine distante en utilisant la connexion SSH créée dans notre fonction `connect`. Nous avons donc de quoi tester trois scénarios ### Laisser le script finir normalement Appeler le script sans argument et le laisser se terminer sans intervenir est notre premier scénario. Voici le résultat : ```none $ ./script.sh Create SSH main connection wih socket /tmp/TvCuLSzkMN Enter passphrase for key '/home/user/.ssh/key.ed25519': Message N°1 from 192.168.0.254 Message N°2 from 192.168.0.254 Message N°3 from 192.168.0.254 Message N°4 from 192.168.0.254 Message N°5 from 192.168.0.254 Close SSH main connection ``` Nous pouvons voir que notre fonction `cleanup` a bien été appelée à la fin de notre script exécutée lors de la réception du signal `EXIT` ### Générer une erreur Maintenant passons `error` en paramètre et observons le résultat : ```none $ ./script.sh error Enter passphrase for key '/home/user/.ssh/key.ed25519': Create SSH main connection wih socket /tmp/Al77btSXKf ERROR: Launch command require 1 parameter Close SSH main connection ``` `launch_command` appelée sans paramètre conduit à la sortie de notre script avec un code supérieur à 0. Cette sortie est capturée par `trap` qui lance aussi la fonction de nettoyage. Vérifions le code de retour de notre script : ```none echo $? 31 ``` Le code de retour est bien celui défini dans notre fonction `launch_command` lorsqu'on l'exécute sans paramètre. ### Interrompre l'exécution Enfin observons ce qui se passe lorsque l'on interrompt l'exécution du script avec Ctrl+C: ```none $ ./script.sh Create SSH main connection wih socket /tmp/0gFlBD6siZ Enter passphrase for key '/home/user/.ssh/key.ed25519': Message N°1 from 192.168.0.254 Message N°2 from 192.168.0.254 ^CClose SSH main connection $ echo $? 0 ``` Ici encore tout se passe comme prévu, sauf que le code de retour est 0, il faudrait pouvoir changer ce comportement afin de signifier que le script ne s'est pas terminé comme prévu et retourner un code supérieur à 0. ## Différencier les pièges en fonction du signal Tout est prévu dans *Bash* pour contourner ce problème : il est possible de lancer plusieurs commandes `trap`. Modifions un petit peu notre script. ### Gérer le signal `SIGINT` Nous allons ajouter une fonction spécifique pour le signal juste après `cleanup` : ```bash process_int(){ error "Script interrupted by user (SIGINT)" exit 255 } ``` ### ajouter un piège Maintenant nous n'avons plus qu'a ajouter un piège : ```bash # [...] trap cleanup EXIT trap process_int INT # [...] ``` Et voilà, lors de l'exécution notre script et son interruption tout fonctionne comme prévu : ```none $ ./script.sh Create SSH main connection wih socket /tmp/0gFlBD6siZ Enter passphrase for key '/home/user/.ssh/key.ed25519': Message N°1 from 192.168.0.254 Message N°2 from 192.168.0.254 ^CERROR: Script interrupted by user (SIGINT) Close SSH main connection $ echo $? 255 ``` Le message de notre fonction `process_int` s'affiche et la fonction `cleanup` se lance automatiquement. Le script modifié est disponible en suivant [ce lien]({attach}files/second_script.sh) ## Vérifier la connexion au master avec un signal Il est possible d'utiliser tout un tas de signaux et de les intercepter avec `trap`. Intéressons nous maintenant à `SIGUSR1`. C'est -- avec `SIGUSR2` -- un signal servant pour ce que l'on veut. Utilisons le pour afficher l'état de la connexion SSH master. Rajoutons la fonction suivante après `launch_command` : ```bash # [...] check_conn() { msg "Check connection on ${server}" ssh -S "$ssh_sock" -O check $server sleep 10 } #[...] ``` Puis de modifions le début de notre script comme ceci : ```bash # [...] msg "Current PID: $$" connect trap cleanup EXIT trap process_int INT trap check_conn USR1 # [...] ``` Au début nous affichons le PID de notre script, nous allons en avoir besoin pour lui envoyer le signal `USR1` avec `kill` depuis un autre terminal. Et enfin piégeons notre signal avec `trap` pour exécuter `check_conn`. Afin d'avoir le temps de lancer la commande `kill`, augmentons de 20 le nombre de tour de boucle effectué comme ci-dessous : ```bash # [...] for (( i=1; i<=20; i++ )) do launch_command "echo 'Message N°$i from $server'" sleep 1 done ``` Le script modifié est disponible en suivant [ce lien]({attach}files/troisieme_script.sh) ### Lancer le script D'abord lançons le script et notons le numéros de PID : ```none $ ./script.sh Current PID: 9499 Create SSH main connection wih socket /tmp/A4IdltbuxY Enter passphrase for key '/home/user/.ssh/key.ed25519': Message N°1 from 192.168.0.254 ``` Puis dans un second terminal il nous suffit d'envoyer le signal : ```none kill -USR1 9499 ``` Retour sur notre premier teminal, nous pouvons voir que le signal est alors reçu par notre script qui va exécuter la fonction attachée : ```none [...] Message N°6 from 192.168.0.254 Message N°7 from 192.168.0.254 Check connection on 192.168.0.254 Master running (pid=9502) ``` Et continuer son exécution ensuite. ## En conclusion Tout au long de cet article, nous avons vu ce qu'était un signal et comment l'intercepter dans un script écrit en Bash. Bien entendu il est possible d'utiliser les signaux dans d'autres langages, la façon de procéder est similaire. **Le nettoyage des traces laissées par un script** est la principale utilisation documentée ci-et-là dans différents tutoriaux. Mais les possibilités offertes par ce système de communication inter-processus dans vos script **vont bien au delà de ce simple usage**. ## Crédits L'image en entête est tirée du film *Star Wars, le retour du Jedi* © Lucasfilms Ltd, Disney