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 "<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.

```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
