Bash avancé: It’s a trap!

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 :

trap "<commande>" <liste_signaux>

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.

#!/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

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 :

$ ./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 :

$ ./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 :

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:

$ ./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 :

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 :

# [...]
trap cleanup EXIT
trap process_int INT
# [...]

Et voilà, lors de l’exécution notre script et son interruption tout fonctionne comme prévu :

$ ./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

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 :

# [...]
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 :

# [...]
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 :

# [...]
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

Lancer le script

D’abord lançons le script et notons le numéros de PID :

$ ./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 :

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 :

[...]
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