Guillaume Fenollar DevOps et SysAdmin Freelance

Guillaume FENOLLAR

Ingénieur Linux/DevOps Indépendant

− Montpellier −

Executer plusieurs commandes non-interactives via SSH

SSH est de facto le protocole de communication entre serveurs préféré des administrateurs de systèmes Unix/Linux/Mac et j'en passe, nul besoin de le présenter. Par contre je peux voir nombre de gens (et très honnêtement ça m'arrive encore plus de fois que je ne le voudrais) mis en échec par l'ensemble de couches impliquées quand ils essaient d'exécuter plusieurs commandes à distance en mode non-interactif. Voilà un petit article pour trouver rapidement les bonnes syntaxe pour ne plus avoir de mauvaise surprises !

Un accès SSH pour les contrôler tous

Essayons d’aller du cas le plus simple au plus complexe. Première chose à garder en tête: tout caractère spécial des commandes que vous exécutez dans votre shell local sera interprêté, c’est très important de garder ça en tête à chaque moment. Donc bien sûr si vous écrivez :

ssh user@host echo $USER
Cela vous donnera l’utilisateur local et non distant car votre shell local substituera la variable avant même de l’avoir envoyée sur votre hôte distant. Seule solution, échapper ! On échappe habituellement par des single quotes (’) mais on peut également utiliser le backslash. Je recommande tout de même la solution du single quote car elle résoud pas mal d’autres problèmes d’un coup

# Meh...
ssh user@host echo \$USER
# Mieux !
ssh user@host 'echo $USER'

Ok, facile. Maintenant, comment faire pour exécuter plusieurs commandes sur un hôte distant de façon propre et dans un ordre précis ? Si vous avez tout de suite pensé Ansible/SaltStack/Chef/Puppet (rayez la mention inutile), alors vous avez parfaitement raison mais ce n’est pas le sujet d’aujourd’hui :D

On peut en effet séparer les commandes par des point-virgules (;) ou des double esperluettes (&&) mais ça peut vite devenir fouilli, à garder seulement si la taille de la ligne résultante reste raisonnable. On peut également écrire un script localement au besoin, l’envoyer puis l’exécuter à distance. Ce n’est pas vraiment problématique en soi, c’est facile et rapide à comprendre, mais ça reste tout de même laborieux et un peu moche, avouons-le (et ça exécute bien plus de commandes que nécessaires).

La méthode préférée sera alors le Here-Document, fonctionnalité merveilleuse de bash s’il en est.

ssh user@host -T << "EOF"
  var=text
  commande2 $var
  commande3 || commande4
EOF

Le -T est juste là pour éviter le warning qui va popper irrémédiablement vu qu’aucun terminal n’est alloué au stdin de ssh de cette façon. Si vous ne connaissez pas le Here-Doc, le mot de fin, ici EOF est spécifié en argument après les doubles chevrons pour indiquer que tout le document entre la deuxième ligne et le même mot (EOF) sera envoyé à l’entrée standard de la commande appelante, ici ssh.

Attention, j’ai utilisé ici “EOF” et non pas EOF, ce qui fait une grande différence. Entouré de double quotes, “EOF” indique à bash de ne pas interprêter les variables et certains caractères spéciaux du document utilisé. L’inverse peut-être voulu et est très utile par exemple pour créer un fichier selon les valeurs de variables de l’environnement courant :

TEMP=$(mktemp) # Je crée un fichier temporaire
cat > $TEMP << EOF
# fichier généré le $(date +%F)
[env]
user=$USER
shell=$SHELL
EOF

C’est un exemple très simpliste qui fonctionnera avec tout le monde mais avec de vraies données ça peut être très robuste. Donc on ignore les double quotes si on veut interpréter les variables, et on les ajoute si on veut que les “$” restent tel quels.

Amusons-nous dans la joie et l’allégresse

Ça c’est un peu la solution simple dans un monde simple. Par contre, si vous devez faire exécuter les commandes avec un autre utilisateur que celui avec lequel vous vous connectez, ça peut rapidement devenir un cauchemar. Par exemple, disons que vous travaillez localement avec un utilisateur userlocal, vous vous connectez avec un utilisateur habilité à se connecter user1, mais que sur cette machine, vous devez exécuter une commande avec les droits de l’utilisateur user2, lui ne pouvant être utilisé pour la connexion SSH. Bienvenue dans l’enfer des échappements, là où vont les mauvais sysadmins finissent leur vie. Voici quelques petits exemples facile à reproduire chez vous si vous avez au moins deux hôtes à dispo :

ssh user1@host sudo -u user2 echo $USER
> userlocal # Faute de débutant, le $USER est résolu par votre propre shell localement

ssh user1@host sudo -u user2 'echo $USER'
> user1 # Zut, le $USER est résolu par mon deuxième shell distant avant sudo

ssh user1@host sudo -u user2 'echo \$USER'
> $USER # La variable n'est même plus interprêtée. Elle est bien exécutée par user2 mais pas substituée. Il nous faut un shell !

ssh user1@host sudo -u user2 sh -c 'echo $USER'
>      # Résultat vide ! Si avec ça vous n'avez l'impression de reculer plutôt qu'avancer,
       # vous avez les nerfs solides ! C'est dû au fait que 'sh -c' prend une chaine de caractère
       # en argument, et non pas plusieurs. Rien à voir avec SSH, essayez sh -c 'echo test' en local, vous verrez.
       # Vous allez me dire que c'en est une, mais vos simple quotes ici disparaissent une fois arrivés 
       # sur votre hôte distant. Il faut les échapper aussi localement !

ssh user1@host sudo -u user2 sh -c 'echo $USER'
> userlocal # Mouahah. C'est à ce moment là que vous vous demandez pourquoi vous faites pas de 
            # l'élèvage de brebis dans le Cantal plutôt. Oui, c'est à devenir chèvre... (pardon)

ssh user1@host sudo -u user2 sh -c \'echo \$USER\'
> user2 # Victoire ! Mais à quel prix... Sinon vous pouvez échapper plus globalement mais ça ne réduit pas la 
        # complexité, même si vous allez sûrement préféré cette solution :
ssh user1@host "sudo -u user2 sh -c 'echo \$USER'"
> user2

Ok c’est un peu l’enfer, mais on se débrouille pour un simple test comme cela. Par contre, ça peut devenir carrément violent pour les neurones si on doit appliquer ça à un script plus touffu avec moult variables et caractères spéciaux. Si vous avez une solution bien plus simple qui respecte ce besoin, je suis intéressé par vos retours d’expérience !

Dans ce cas là je préconiserais plutôt une méthode abordée plus haut qui est de construire un script localement, l’envoyer (via scp) et l’exécuter. C’est bien plus simple à debugger et ça fait le taff. Par contre ça crée plusieurs connexions SSH à l’occasion, ce qui peut être un peu lourd, surtout sur les réseaux kerberisés.

Multiplexage SSH

Pour rendre ça plus rapide, il y a toujours l’option de multiplexage de connexions SSH si vous ne connaissez pas. L’idée est d’ouvrir un agent de multiplexage gardant une connexion ouverte le temps que vous passez toutes les commandes que vous voulez à travers la même connexion TCP, ce qui réduit le temps de chaque commande à exécuter.

Pour l’activer, ça se passe par le fichier de configuration avec les paramètres suivants :

ControlPath ~/.ssh/%r@%h:%p # Spécifie l'emplacement du fichier socket, dans votre homedir c'est bien
ControlMaster auto          # Comportement du multiplexage. auto utilise la connexion si déjà ouverte
ControlPersist 5m           # Combien de temps la connexion sera maintenue ouverte

À noter que cette option utilise un process ssh supplémentaire pendant la durée du ControlPersist. Comme toute configuration cliente SSH, vous pouvez soit inclure ceci globalement via votre fichier /etc/ssh/ssh_config, soit juste pour votre utilisateur et potentiellement par hôte cible via votre fichier ~/ssh/config

Host mon-serveur
  ControlPath ~/.ssh/%r@%h:%p 
  ControlMaster auto          
  ControlPersist 5m           

Enfin, vous pouvez également l’exécuter seulement pour une connexion indépendante grâce aux options inline:

opts="-o ControlPath=~/.ssh/%r@%h:%p -o ControlMaster=auto -o ControlPersist=10m"
ssh $opts user@host env
ssh $opts user@host "echo merci d\'avoir lu jusqu\'ici !"