Utilisation de asyncssh

Dans cet article je vais essayer d'illustrer simplement comment utiliser le module asyncssh de Python pour :

  • Scripter des actions distantes
  • Etablir un tunnel ssh permettant de faire du port forwarding

Alors si vous vous êtes déjà confronté à ces problématiques vous avez peut-être déjà croisé la route du module Python paramiko. Alors personellement je ne recommande pas l'usage de paramiko (en gros j'ai perdu un temps monstrueux avec paramiko, c'est peut-être ma faute attention, notamment lorsque je devais faire des portages d'application entre windows 7 / windows 10 et Linux) et du coup depuis ce jour je suis passé à asyncssh et ça fonctionne parfaitement.

La seule difficulté avec asyncssh vient du async .... En effet c'est un module destiné à être utilisé dans un mode de programmation asynchrone avec asyncio il faut donc être un peu à l'aise avec les async/await dans Python 🤨 ! Si ce n'est pas le cas je vous invite à aller jeter un oeil sur le mooc Python 3 en semaine 8 vous avez une magnifique introduction à la programmation asynchrone.

Connection à un serveur

La première étape est bien évidemment la connection au serveur ssh. Pour cela on passe par la fonction connect de asyncssh qui prend entre autre arguments :

  • Un nom d'utilisateur username
  • Une authentification avec deux solutions possibles :
    • mot de passe classique dans ce cas il suffit de spécifier l'argument password
    • Clé ssh et dans ce cas il faut spécifier l'argument client_keys qui est une liste contenant la où les clés ssh ainsi que l'argument passphrase qui est donc la passphrase de la clé ssh.
client = await asyncssh.connect( server, username=username, password=password)

Une fois connecté, exécuter des commandes !

Une fois la connection avec notre serveur établie nous pouvons commencer à faire des choses intéressantes (enfin ça dépend de vous ça). Mais pour commencer la méthode essentielle à connaître est la méthode run qui comme son nom l'indique va nous permettre d'exécuter des commandes à distance. Dans sa version basique la méthode run prend une chaîne de caractère par exemple si je veux lancer la commande ls ~/Documents et bien c'est ce qu'on fait !

out = await client.run("ls ~/Documents")

Alors le out que l'on récupère est une instance de asyncssh.SSHCompletedProcess on s'en fout un peu tout ce qu'il faut savoir c'est qu'il y a dans ce truc 3 attributs qui sont utiles :

  • out.returncode si différent de 0 y a un truc qui a planté quelque part !
  • out.stdout la sortie standard
  • out.stderr la sortie d'erreur

Alors juste vous le savez peut-être mais suivant la commande que vous exécutez ne regardez pas que dans le stderr si vous avez des erreurs car il existe tout un tas de logiciels qui écrivent les erreurs dans le stdout et certain ont même le génie d'ecrire des infos dans le stderr ...

Ou bien lire et écrire des fichiers

De la même manière il est possible une fois la connection établie de lire et/ou écrire des fichiers via sftp si vous voulez tout savoir. Pour cela il suffit de créer un client sftp à partir du client ssh une fois la connexion établie.

async with client.start_sftp_client() as sftp:
    ## Read, write file, create directory, ... 
    ## all on remote file system 
    pass 

Une fois le client sftp créé nous pouvons utiliser tout une ribambelle (oui j'aime bien ce mot) de méthodes par exemple :

  • sftp.exists( remote_path )
  • sftp.mkdir( remote_path )
  • sftp.open( fname, mode)
  • sftp.chmod( remote_path, mode )
  • ...

Pour la liste complète des méthodes RTFM

Et avec un petit rebond c'est tout aussi simple !

Alors le truc génial 🤩 de la fonction asyncssh.connect est qu'elle accepte un argument optionnel tunnel. Cet argument permet de spécifier un client ssh déjà connecté. En gros si pour accéder à la machine machine-C depuis machine-A on doit forcément passer par la machine machine-B et bien avec cet argument tunnel ce sera super simple il suffira de faire un truc du genre :

proxy = await asyncssh.connect( "machine-B", username=username, password=password)
client = await asyncssh.connect( "machine-C", username=username, password=password, tunnel=proxy) 

Il existe également une version avec context manager qui prends la forme suivante :

async with asyncssh.connect( "machine-B", username=username, password=password) as proxy: 
    async with asyncssh.connect( "machine-C", username=username, password=password, tunnel=proxy) as client:
        # do something crazy with my ssh client 

Port forwarding et tunnel ssh

Pour finir je vais vous montrer l'autre truc super sympa de asyncssh à savoir que l'on peut depuis un boût de Python construire un tunnel ssh qui va nous permettre de faire du port forwarding. Deux trucs sympa :

  • Le même code va fonctionner sous Linux, Mac et Windows donc pas de prise de tête
  • On peut comme ça automatiser et surtout cacher plein d'opérations aux end-users. Car je sais pas vous mais moi si je demande aux gens de faire un tunnel ssh à la main avec OpenSSH ma boite mail va exploser avec les "ça marche pas" !!
async with asyncssh.connect(hostname, username=username) as client:
    listener = await client.forward_local_port("", local_port, remote_ip, remote_port)
    await listener.wait_closed()

Le Python précédent est équivalent à la commande OpenSSH suivante pour les connaisseurs :

ssh -L local_port:remote_ip:remote_port username@hostname

Bon alors par contre le code Python précédent est bloquant, c'est-à-dire qu'une fois le tunnel créé on ne peut rien faire d'autre ce qui peut s'avérer génant. Une solution assez simple pour ne pas se retrouver bloqué est simplement de déléguer la création du tunnel à un process séparé de notre process principal. Pour cela un petit coup de multiprocessing et ça roule !

def standalone_tunnel(hostname, username, password, local_post, remote_port, remote_ip ):
    async def do_job(hostname, username, password, local_post, remote_port, remote_ip ):
        async with asyncssh.connect(hostname, username=username, password=password) as client:
            listener = await client.forward_local_port("", local_port, remote_ip, remote_port)
            await listener.wait_closed()
    try:
        asyncio.get_event_loop().run_until_complete(do_job(hostname, username, password, local_post, remote_port, remote_ip))
    except Exception as e:
        print("Port Forwarding Error : " + str(e))


import multiprocessing 
p = multiprocessing.Process(target=standalone_tunnel, args=(hostname, username, password, local_post, remote_port, remote_ip))
p.start()

Conclusion

Je viens de vous montrer en mode express comment le module asyncssh de Python peut très facilement nous permettre de scripter des actions nécessitant des connections à des serveurs distants via ssh. Il y a évidemment plein d'applications possibles à cela mais je vais juste vous en présenter deux que j'ai eu à réaliser.

La première application a été la mise en place d'un outil pour faire de la visualisation distante. En effet à partir de Mars 2020 et une certaine pandémie mondiale il a fallu dans le labo où je suis que les utilisateurs puissent travailler à distance, notamment avec des softs disposant d'interface graphique un peu lourde. Pour cela j'ai développé une solution assez simple où chaque utilisateur lance sur un serveur du labo un serveur VNC et ensuite via un tunnel ssh (qui forward le port VNC de l'utilisateur vers un port local de son portable en faisant un rebond via la passerelle ssh) peut accéder à sa session graphique via le client VNC installé localement. Et bien évidemment si je demande aux utilisateurs de faire tout ça à la main 🤯 donc j'ai packagé tout ça dans une petite application Python à base de asyncssh et PyQt pour le graphique et en deux clics les utilisateurs peuvent se connecter !

La seconde application que j'ai pu avoir de asyncssh est le développement d'un utilitaire Python pour lancer un jupyter notebook sur un noeuds de calcul du cluster et faire le port forwarding qui va bien pour que l'utilisateur puisse ensuite ouvrir son notebook et travailler dans son navigateur en local. Pour cela le process est assez simple :

  1. Connexion à la frontal du cluster (besoin de deux rebonds dans mon cas mais facile avec asyncssh 😜)
  2. Ecriture sur le filesystem du cluster d'un script de soumission pour le gestionnaire de job (slurm)
  3. Soumission du job slurm
  4. Une fois le job slurm commencé, ouverture des fichiers de log du job pour récupérer le nom sur lequel le job est parti et le port sur lequel jupyter a démarré
  5. Connexion ssh au noeud du cluster où mon job tourne
  6. Création du tunnel ssh pour le port forwarding
  7. C'est bon il n'y a plus qu'à ouvrir son navigateur 📓