Utilisation du protocole SSH avec Erlang/OTP

Magazine
Marque
GNU/Linux Magazine
Numéro
267
Mois de parution
janvier 2024
Spécialité(s)


Résumé

Secure Shell, plus communément appelé SSH, est une application très largement utilisée pour l’administration des systèmes Unix/Linux. Utilisant le même acronyme, le protocole SecSH ou SSH, standardisé par l’IETF, fait partie de ces protocoles qui ont inclus nativement des principes de chiffrement modernes en leur sein, à l’instar de leurs anciens concurrents de l’époque tels que RSH ou TELNET. Installé par défaut sur une très grande majorité de systèmes libres comme propriétaires, SSH est littéralement incontournable. Résultat d’un effort collectif de plusieurs années de travail, il a eu l’incroyable privilège de façonner le vaste univers du numérique que tout un chacun connaît aujourd’hui. Sa flexibilité légendaire, son respect des bonnes pratiques, ses nombreuses spécifications ainsi que ses innombrables implémentations font de ce protocole un outil parfait pour l’apprentissage de la programmation et de la sécurité.


Body

Cette série d’articles commencée mi-2020 a pour objectif d’introduire le développement avec Erlang/OTP par la pratique et au travers d’un projet réel, en utilisant le minimum de dépendances externes. Le code qui sera présenté dans les prochaines lignes utilise la version R25 d’Erlang/OTP, le tout est testé sur une distribution proche de Debian GNU/Linux et un système d’exploitation OpenBSD-current. Le retour des fonctions n’est généralement pas fourni – sauf cas exceptionnel, pour des raisons de manque de place.

1. Introduction

L’administration à distance ne date pas d’hier. Il y a encore quelques décennies, les programmeurs, ingénieurs et autres chercheurs utilisaient un système de terminal pour se connecter à distance à des mainframes, bien souvent un ou plusieurs supercalculateurs partagés entre différents utilisateurs connectés. Une simple ligne téléphonique faisait généralement office de lien physique entre les clients et les serveurs. La démocratisation des ordinateurs personnels dans les années 80 et 90 fit évoluer drastiquement les contraintes. Même si peu d’élus avaient un accès à ces technologies, la demande d’échanges nécessitait une sécurité accrue. La connexion à un serveur distant, que ce soit au moyen de TELNET, RLOGIN ou RSH, se faisait en clair, avec tous les risques que cela pouvait comporter, une attaque par « l’homme du milieu » pouvant facilement extraire tous les mots de passe ou informations sensibles des connexions et ainsi donner l’accès à tout type d’actifs.

Comme dit le proverbe, « le malheur des uns fait le bonheur des autres », et l’époque de l’insouciance arriva progressivement à son terme. L’utilisation de la cryptographie pour les échanges a eu raison de cette déconcertante simplicité qu’avaient les pirates de l’époque à simplement lire les données d’authentification en clair. Le monde de l’informatique venait de rentrer dans un environnement bien plus large, celui de la cryptographie. La standardisation des algorithmes comme DES, AES, RSA ou encore MD5 et SHA permirent de sécuriser à moindre coût les échanges entre les utilisateurs et leurs équipements distants. Par ailleurs, dans cette jungle qu’est Internet, quelques-uns de ces protocoles sortirent du lot, tels que SSL/TLS/DTLS, largement utilisés pour le Web, et SSH, généralement utilisé pour l’administration des serveurs distants. Ce dernier a eu une longue et mouvementée histoire durant les 20 ou 30 dernières années. La première version fut conçue en 1995 par un Finlandais nommé Tatu Ylonen, ayant pour ambition de remplacer la grande majorité des protocoles non sécurisés, en particulier FTP et RSH.

De nombreuses implémentations sont désormais disponibles, la plus célèbre étant celle offerte et développée par les équipes d’OpenBSD, communément appelée OpenSSH [1], d’ores et déjà déployée sur la quasi-totalité des systèmes d’exploitation libres et open source. D’autres acteurs existent sur le marché comme Dropbear, WolfSSH, Putty ou encore lsh. Qu’en est-il pour les bibliothèques de développement ? Eh bien, il s’avère qu’un large choix s’offre aux développeurs, le protocole SSH peut être facilement utilisé en C via LibSSH, en Rust via ssh-rs, en Java via sshj, en Python via Paramiko, en JavaScript via paramikojs et en Erlang via l’application SSH. Bien évidemment, cette dernière implémentation est celle qui va être présentée dans les prochaines lignes de cet article.

2. Présentation et fonctionnement du protocole SSH

Créé dans les années 90, il aura fallu attendre un peu plus d’une dizaine d’années pour qu’une série de standards soient acceptés, spécifiant les différentes briques logiques et fondamentales de l’architecture du protocole Secure Shell (secsh) ou SSH. La première de ces vagues ayant été validée par l’IETF au courant de l’année 2006, celle-ci se compose de la RFC 4250 [2] qui contient l’allocation des différentes valeurs numériques et termes utilisés par le protocole ; de la RFC 4251 [3] qui explique le fonctionnement global de l’architecture, décrivant les méthodes de transport, les méthodes d’authentification ainsi que le protocole utilisé pour la connexion ; de la RFC 4252 [4] qui décrit les étapes du protocole d’authentification offert par SSH ; de la RFC 4253 [5] qui explique et spécifie la méthode de transport ainsi que les échanges binaires ; et pour finir de la RFC 4254 [6] qui liste les mécanismes de connexion et les nombreuses fonctionnalités liées à SSH. D’autres standards relatifs à ce protocole ont bien évidemment été produits plus tard.

Lors de la conception du protocole et des différents brouillons, l’IANA [2] attribua officiellement le port 22 comme étant le port utilisé par défaut avec le protocole SSH. Un serveur écoutera donc généralement sur le port TCP/22 par défaut. La création du lien entre un client et le serveur se fait en plusieurs étapes, la première étant naturellement la validation de la poignée de main TCP. Durant ces nombreuses phases, la connexion peut être interrompue à n’importe quel moment, aussi bien par le client que par le serveur. En voici un résumé :

  • Étape 1 (RFC 4253 [5]) : après une connexion TCP réussie, le client et le serveur ayant chacun récupéré un socket actif envoient alors simultanément une chaîne d’identification contenant la version du protocole SSH, la version du logiciel utilisé ainsi qu’un commentaire optionnel. Le second échange, directement envoyé à la suite par ces deux entités, contient un cookie sous la forme d’un nombre aléatoire, une liste d’algorithmes supportés pour l’échange des clés, les algorithmes supportés pour l’échange des données, le type de méthodes supportées pour l’authentification des messages, le type de compression, et, si nécessaire, la langue désirée. Tous ces éléments sont rangés par ordre de préférence, le premier terme de chacune des listes étant le favori. Si les deux parties possèdent les bons algorithmes, l’échange de clés peut alors débuter.
  • Étape 2 (RFC 4253 [5]) : en fonction de l’algorithme sélectionné pour l’échange de clés, un plus ou moins grand nombre de messages seront échangés entre le client et le serveur. Cette étape est toutefois nécessaire pour permettre l’initialisation d’un lien chiffré et sécurisé entre les deux acteurs. La RFC 4253 décrit les différentes étapes à suivre pour faire cet échange au moyen de l’algorithme Diffie-Hellman.
  • Étape 3 (RFC 4253 [5]) : si l’échange de clés a été réalisé avec succès, la connexion est chiffrée de bout en bout. Le client peut alors demander d’avoir accès à un service. Selon la RFC en question, seuls deux types de services sont actuellement supportés : l’authentification ou la connexion directe. Généralement, le client demande d’abord l’accès à la connexion au moyen d’une authentification, et si ce n’est pas le cas, le serveur pourra l’y contraindre.
  • Étape 4 (RFC 4252 [4]) : il y a de toute façon de fortes chances pour que le serveur offre une session seulement après une authentification valide. Comme défini dans la RFC, c’est le serveur SSH qui prend l’initiative en envoyant la liste des méthodes supportées, laissant alors la liberté au client de choisir laquelle est préférée et/ou supportée. En cas de succès, le service demandé est alors activé. La connexion entre le serveur et le client est alors fonctionnelle.
  • Étape 5 (RFC 4254 [6]) : SSH est un protocole utilisant des canaux (ou channels en anglais) permettant de multiplexer une connexion. Pour faire simple, au lieu de se connecter plusieurs fois à un serveur, il est possible de réutiliser une connexion déjà existante en créant un nouveau canal. Lors de la création d’un de ces canaux, le serveur met à disposition différents types de sessions, pouvant donner accès à un terminal, offrir une solution pour le transfert de ports, donnant accès à un sous-système comme SFTP, d’exécuter des commandes à distance ou tout type d’applications qui auraient été conçues sur mesure.

3. Présentation de la suite logicielle OpenSSH

Avant de parler d’Erlang/OTP et de l’implémentation du protocole SSH réalisé et livré avec, il est peut-être préférable de montrer une implémentation d’ores et déjà fonctionnelle comme celle d’OpenSSH. Cela permettra aussi d’éviter les éventuels problèmes entre les nombreuses versions présentes sur le marché. Même si elles sont généralement compatibles entre elles, la présence de certaines fonctionnalités peut manquer en fonction du paquet précompilé et installé sur différents types de systèmes d’exploitation.

3.1 Compilation et Installation d’OpenSSH

La dernière version en date de cet article sera localement compilée sur un système proche de Debian. La même procédure pourra être utilisée sur d’autres systèmes d’exploitation, en installant les prérequis et autres dépendances. Dans le cas d’une distribution Debian, les paquets autoconf, make, libssl-dev, gcc et zlib1g-dev sont nécessaires pour que la compilation se déroule sans encombre.

shell$ apt-get update && apt-get install -y autoconf2.69 make libssl-dev zlib1g-dev gcc

La dernière version en date au moment de l’écriture de cet article est la 9.3p1. Les sources sont récupérables directement au moyen de git via un des miroirs officiels. Ici, le miroir GitHub [7] est utilisé.

shell$ git clone --depth 1 --branch V_9_3_P1 https://github.com/openssh/openssh-portable
shell$ cd openssh-portable

La version de l’application autoconf a besoin d’être disponible dans l’environnement courant au moyen de la variable ${AUTOCONF_VERSION}. Le préfixe /tmp/openssh-portable de l’installation sera aussi stocké dans la variable ${TARGET} pour des raisons de simplicité.

shell$ export AUTOCONF_VERSION=2.69
shell$ export TARGET=/tmp/openssh-portable
shell$ mkdir -p ${TARGET}

Le logiciel autoreconf permet de générer automatiquement un fichier de configuration pour faciliter la procédure de compilation. Le fichier généré se nomme habituellement « configure », et contient un script shell compatible avec le système en cours d’utilisation. Après sa génération, il est exécuté pour paramétrer une destination d’installation différente de celle par défaut. Toutes les options supportées par OpenSSH peuvent aussi être affichées en rajoutant le drapeau --help. Attention tout de même, l’ajout de certaines fonctionnalités nécessitera sans doute l’installation de nouvelles dépendances sur le système hôte. En cas de succès – si tous les prérequis sont présents – les commandes make et make install sont exécutées à leur tour pour respectivement compiler et installer le logiciel en question. Une procédure des plus classiques.

shell$ autoreconf
shell$ ./configure --prefix ${TARGET} && make && make install

La suite d’outils fournie par OpenSSH doit maintenant être installée dans le répertoire « /tmp/openssh-portable » suivant les standards d’installation des systèmes Unix et en respectant l’arborescence standard.

3.2 Présentation rapide d’OpenSSH

OpenSSH livre plusieurs outils pour gérer les différents documents indispensables au bon fonctionnement du serveur et du client. La première commande à connaître est ssh-keygen, celle-ci permet de gérer les clés privées, publiques et autres certificats utilisés pour sécuriser les connexions, tout en offrant la possibilité de signer et de vérifier d’éventuels certificats. Le drapeau -A est d’ordinaire utilisé automatiquement après l’installation pour générer les clés privées et publiques du serveur. Quant au paramètre -f, dans ce contexte précis, il permet de définir le chemin où seront stockés les documents générés. Chaque serveur doit posséder ses propres clés uniques permettant de l’identifier par rapport aux clients désirant se connecter.

shell$ mkdir -p ${TARGET}/etc/ssh
shell$ ${TARGET}/bin/ssh-keygen -A -f ${TARGET}
shell$ ${TARGET}/bin/ssh-keygen -t rsa -f ${TARGET}/utilisateur/id_rsa

sshd est le daemon SSH et permet de démarrer le serveur qui acceptera les connexions clientes. Pour démarrer un serveur sans qu’il ne soit automatiquement lancé en tâche de fond, le drapeau -D doit être utilisé. L’ajout du drapeau -d permet de le configurer en mode debug. Le paramètre -f permet de définir le chemin du fichier de configuration à utiliser et le paramètre -p le port d’écoute du socket TCP. Cette commande est pratique pour tester d’éventuelles implémentations de clients, en acceptant uniquement une seule connexion.

shell$ ${TARGET}/sbin/sshd -d -D -f ${TARGET}/etc/sshd_config -p 22222

Maintenant qu’un serveur est démarré, la commande ssh peut être utilisée. Cet exécutable est le client SSH fourni avec OpenSSH, permettant de créer les connexions avec le serveur. Le drapeau -v permet d’incrémenter la verbosité de l’application en affichant les messages de debug. Le paramètre -p permet de définir le port de destination du serveur. Le dernier argument correspond au serveur cible, dans le cadre de cet article : localhost.

shell$ ${TARGET}/bin/ssh -v -p 22222 localhost

Évidemment, bien d’autres commandes existent. Les précédentes ont été affichées ici, car elles seront réutilisées dans la suite de l’article et permettront de tester les futures productions. Il est toujours utile d’avoir des implémentations fonctionnelles quand il est question de développement. Par ailleurs, d’autres commandes existent comme sftp, scp, ssh-agent, ssh-keysign, ssh-pkcs11-helper, ssh-add et sftp-server. Le lecteur pourra aller regarder la documentation officielle [8] extrêmement bien fournie et détaillée.

4. Utilisation de l’application SSH avec Erlang/OTP

Quel est l’intérêt de livrer par défaut une application implémentant le protocole SSH dans Erlang ? D’un point de vue d’un langage classique, cela n’a effectivement que peu d’intérêt. Un tel langage utilisera une bibliothèque externe pour créer un client ou serveur customisé pour un projet bien particulier. Dans le cas d’Erlang/OTP, la logique est sensiblement différente. Ce langage et son environnement se comportent plus comme un système d’exploitation intégré dans une machine virtuelle. L’ajout d’un module et d’une application comme SSH dans cet écosystème permet d’étendre drastiquement les fonctionnalités de base. En démarrant l’application ssh [9] par défaut avec les bons paramètres passés en arguments, le shell de la machine virtuelle Erlang devient alors accessible via SSH au moyen d’un client classique. Loin d’être un gadget, l’ajout du support de SSH dans la BEAM permettra de contrôler à distance l’environnement au moyen d’un protocole sécurisé, donnant ainsi la capacité aux développeurs ou administrateurs de gérer l’environnement en cours de fonctionnement et en temps réel.

Le code source de ce module est disponible directement dans le projet OTP dans le répertoire lib/ssh. La documentation associée est complète et permet d’avoir une bonne idée de comment cette application fonctionne au travers de nombreux exemples pratiques. Avant de pouvoir commencer à l’utiliser, il est nécessaire de savoir si cette application est correctement installée sur le système d’exploitation en cours d’utilisation. Si l’installation a été faite depuis les sources, il n’y aura normalement aucun problème. Si Erlang/OTP a été installé sur un système GNU/Linux de type Debian/Ubuntu, il sera alors nécessaire de s’assurer de la présence du package erlang-ssh et de l’installer si ce n’est pas déjà fait. Après cette vérification, il suffit d’exécuter le shell Erlang via la commande erl.

shell$ apt-get update && apt-get install -y erlang-ssh
shell$ erl -name serveur@localhost

L’application ssh peut être démarrée de plusieurs façons, via la fonction application:ensure_all_started/1 ou via la fonction ssh:start/0. Dans les deux cas, les modules et autres applications dépendantes seront automatiquement démarrés si elles ne l’étaient pas déjà.

(serveur@localhost)1> {ok, _} = application:ensure_all_started(ssh).

Cette application est globalement découpée en deux parties, la partie serveur et la partie cliente. Pour démarrer le serveur, les fonctions ssh:daemon/1, ssh:daemon/2 ou ssh:daemon/3 peuvent être utilisées. Pour ssh:daemon/2, le premier argument correspond au port d’écoute et le second aux options de configuration du socket TCP ou du daemon applicatif ssh. Le code suivant permet de configurer une authentification basique avec un utilisateur utilisateur et son mot de passe password. Le répertoire système peut être configuré au moyen du paramètre system_dir. Ce répertoire contient les clés de l’hôte précédemment générées avec OpenSSH et ssh-keygen. La fonction retourne alors l’identifiant du processus démarré, écoutant sur le port TCP/22222.

(serveur@localhost)2> Authentification = [{"utilisateur", "password"}].
(serveur@localhost)3> OptionServeur = [{system_dir, "/tmp/openssh-portable/etc/ssh"}
                                      ,{user_passwords, Authentification}].
(serveur@localhost)4> {ok, ServeurPid} = ssh:daemon(22222, OptionServeur).

Il est maintenant possible de venir se connecter avec n’importe quel client SSH au moyen des informations précédemment configurées dans un autre terminal ouvert pour l’occasion. La commande ssh suivie du port et du couple utilisateur/hôte donne alors accès au shell Erlang après que le serveur ait accepté les bons identifiants de l’utilisateur.

shell$ ssh -p 22222 utilisateur@localhost
SSH server
Enter password for "utilisateur"
password:
Eshell V13.1.2 (abort with ^G)
(serveur@localhost)1> exit().

Il est aussi possible d’utiliser l’application ssh fournie avec Erlang comme client. Pour ce faire, les fonctions ssh:connect/2, ssh:connect/3 et ssh:connect/4 seront ici les principales méthodes à utiliser pour se connecter. Il est tout de même nécessaire de créer un canal dédié via la fonction ssh_connection:session_channel/1 puis d’envoyer les commandes avec la fonction ssh_connection:send/3. Les réponses du serveur sont directement reçues dans la boite mail du processus en cours d’utilisation et accessibles en utilisant la fonction flush/0 ou l’opérateur receive.

(serveur@localhost)5> OptionClient = [{user, "utilisateur"},{password, "password"}].
(serveur@localhost)6> {ok, ClientPid} = ssh:connect(localhost, 22222, OptionClient).
(serveur@localhost)7> {ok, ChannelId} = ssh_connection:session_channel(ClientPid).
(serveur@localhost)8> ok = ssh_connection:send(ClientPid, ChannelId, <<"ok.">>).
(serveur@localhost)9> flush().

Toujours du côté client, pour terminer la connexion, la fonction ssh:close/1 doit être appelée.

(serveur@localhost)10> ssh:close(ClientPid)

Maintenant, pour arrêter le service en écoute du côté serveur, les fonctions ssh:stop_daemon/1, ssh:stop_daemon/2 et ssh:stop_daemon/3 seront à utiliser.

(serveur@localhost)12> ssh:stop_daemon(ServeurPid).

Ces quelques fonctions seront probablement les seules à connaître pour les développeurs qui voudraient utiliser le shell Erlang à distance au moyen de SSH. Pour arrêter l’application, si le besoin se fait sentir, les fonctions application:stop/1 ou ssh:stop/0 peuvent alors être utilisées pour totalement arrêter le service.

(serveur@localhost)13> ok = application:stop(ssh).

Dans quelle situation utiliser cette fonctionnalité ? Dans un environnement de développement, de test et même en production. Si compilée avec les bonnes options, la version déployée peut facilement embarquer SSH et permettre ainsi à toute personne ayant l’accès de modifier l’état de la machine virtuelle, une solution pratique, mais potentiellement dangereuse. Tout dépendra des besoins du projet.

5. Gestion des paires de clés privées/publiques

La gestion des clés avec l’application ssh au sein d’Erlang se fait généralement au moyen du module ssh_file. Celui-ci s’appuie sur les spécifications d’OpenSSH pour récupérer les clés stockées sur le système de fichiers avec les chemins par défaut tel que /etc/ssh ou ${HOME}/.ssh. Ce module exporte aussi toutes les fonctions en charge d’encoder et de décoder les différents types de formats proposés par les standards et autres implémentations. Tout n’est pas rose malheureusement, car ce module n’est pas totalement compatible avec toutes les fonctionnalités qu’un développeur serait en droit d’attendre. La génération d’une paire de clés ne peut se faire qu’au moyen du module public_key, mais l’encodage de la clé privée en elle-même est encore au stade expérimental et il faudra alors se rabattre sur la commande ssh-keygen (livré avec OpenSSH). Par exemple, le code suivant permet de générer plusieurs types de paires de clés. L’argument -t permet de passer le type d’algorithme (rsa, ed25519 et ecdsa), l’argument -f permet de définir le nom de fichier qui contiendra la clé privée, l’argument -C permet de rajouter un commentaire et l’argument -N permet de configurer un mot de passe pour protéger le contenu de la clé privée.

shell$ ssh-keygen -t rsa -f ${TARGET}/etc/id_rsa -C rsa -N ''
shell$ ssh-keygen -t ed25519 -f ${TARGET}/etc/id_ed25519 -C ed25519 -N '' 
shell$ ssh-keygen -t ecdsa -b 256 -f ${TARGET}/etc/id_ecdsa_256 -C ecdsa-256 -N '' 

Comme vu précédemment, les paires de clés s’appuyant sur le module public_key, il est alors préférable d’importer son en-tête pour avoir accès aux records qui y sont définis, permettant de simplifier la lecture du code produit. Pour ce faire, la fonction rr/1 propre au shell Erlang est utilisée. L’utilisation des fonctionnalités d’encodage et de décodage nécessite aussi la connaissance des algorithmes de chiffrement supportés par l’application ssh, une telle liste est récupérable au moyen de la fonction ssh:default_algorithms/0. L’indice représenté sous la forme d’un atom définit le contexte d’utilisation, comme public_key, qui sera celui utilisé dans cette partie de l’article. Quelques exemples d’algorithmes disponibles :'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp256', 'ssh-ed25519', 'rsa-sha2-256' ou encore 'rsa-sha2-512'.

1> rr(public_key).
2> Algorithms = ssh:default_algorithms().
3> PublicKeyAlgorithms = proplists:get_value(public_key, Algorithms).

D’un point de vue utilisateur, les clés sont récupérées par défaut dans le répertoire .ssh de l’utilisateur courant, le changement de ce répertoire se fait au moyen de l’option user_dir.

4> UserOptions = [{user_dir, "/tmp/utilisateur/etc"}].

Les clés privées de l’utilisateur sont alors retournées au moyen de la fonction ssh_file:user_key/2, où le premier argument correspond à l’algorithme et le second aux options. Si la clé privée en question est présente dans le répertoire et qu’elle respecte la convention de nommage (id_rsa pour l’algorithme RSA id_ed25519 pour l’algorithme ed25519 et ainsi de suite), la clé est alors retournée sous la forme du record associé à l’algorithme choisi. En cas d’échec, la raison est aussi retournée.

5> {ok, #'ECPrivateKey'{} = EcdsaUser} = ssh_file:user_key('ecdsa-sha2-nistp256', UserOptions).
6> {ok, #'ECPrivateKey'{} = Ed25519User} = ssh_file:user_key('ssh-ed25519', UserOptions).
7> {ok, #'RSAPrivateKey'{} = RsaUser} = ssh_file:user_key('rsa-sha2-256', UserOptions).
8> {error, enoent} = ssh_file:user_key('ssh-ed448', UserOptions).

Ce n’est pas tout. Le serveur SSH a aussi besoin de ses clés pour identifier l’hôte, une fonction a été prévue pour ce cas d’usage. Celle-ci s’appelle ssh_file:host_key/2 et va rechercher les clés privées en utilisant une convention de nommage différente (id_host_rsa_key pour l’algorithme RSA, par exemple). Les arguments sont les mêmes que ceux passés pour la fonction ssh_file:user_key/2.

9> HostOptions = [{system_dir, "/tmp/utilisateur/etc/ssh"}].
10> {ok, EcdsaHost} = ssh_file:host_key('ecdsa-sha2-nistp256', HostOptions).
11> {ok, #'ECPrivateKey'{} = Ed25519Host} = ssh_file:host_key('ssh-ed25519', HostOptions).
12> {ok, #'RSAPrivateKey'{} = RsaHost} = ssh_file:host_key('rsa-sha2-256', HostOptions).
13> {error, enoent} = ssh_file:host_key('ssh-ed448', HostOptions).

Les clés publiques, quant à elles, sont généralement disponibles localement dans le format standard d’OpenSSH. L’importation de ces informations peut se faire après avoir ouvert le fichier et lu son contenu, action réalisable avec la fonction file:read_file/1, puis de le décoder au moyen de la fonction ssh_file:decode/2. Le format d’encodage généralement utilisé se nomme openssh_key et est utilisé ici comme second argument de cette fonction, le premier étant le contenu encodé du fichier. La structure de données retournée est une liste contenant une ou plusieurs clés publiques avec leurs commentaires associés ou tout autre paramètre défini dans le format de données.

13> {ok, PublicKeyContent} = file:read_file("/tmp/openssh-portable/utilisateur/id_rsa.pub").
14> DecodedPublicKey = ssh_file:decode(PublicKeyContent, openssh_key).
15> [{#'RSAPublicKey'{} = RsaPublicKey, Data}|_] = DecodedPublicKey.
16> Comment = proplists:get_value(comment, Data).

Pour réencoder cette clé publique, la fonction ssh_file:encode/2 doit être utilisée. Le premier argument correspond à la liste de clés sous la forme d’un record, accolé aux éventuels paramètres, valeur stockée dans la variable DecodedPublicKey. Le second argument configure le type d’encodage sous la forme d’un atom, le même que celui utilisé pour la fonction ssh_file:decode/2.

17> EncodedPublicKey = ssh_file:encode(DecodedPublicKey, openssh_key).
18> file:write_file("/tmp/openssh-portable/id_rsa.pub2", EncodedPublicKey).

Aucune fonctionnalité n’est actuellement disponible au sein de l’application ssh pour générer « facilement » une paire de clés privée/publique, mais les clés en question utilisant des standards de la cryptographie déjà supportés par Erlang, il est toujours possible de les générer localement au moyen des fonctions public_key:generate_key/1 ou crypto:generate_key/2. Le code suivant permet de créer une clé RSA et une clé ed25519. Malheureusement, pour ce qui est de l’encodage des clés privées, il faudra se rabattre sur les formats DEM et/ou PEM couramment utilisés avec TLS.

19> RsaPrivateKey = public_key:generate_key({rsa, 2048, 65537}).   
20> #'RSAPublicKey'{ modulus = RsaPrivateKey#'RSAPrivateKey'.modulus
                   , publicExponent = RsaPrivateKey#'RSAPrivateKey'.publicExponent }.
21> RsaPublicKey = #'RSAPublicKey'{ modulus = RsaPrivateKey#'RSAPrivateKey'.modulus
                                  , publicExponent = RsaPrivateKey#'RSAPrivateKey'.publicExponent }.
22> RsaEncoded = ssh_file:encode([{RsaPublicKey, []}], openssh_key).

23> EdPrivateKey = public_key:generate_key({namedCurve, ed25519}).
24> EdEncoded = ssh_file:encode([{EdPrivateKey, []}], openssh_key). 

Un dernier aspect tout aussi important que l’accès aux clés privées ou publiques est la récupération des identifiants de clés pour les hôtes distants. Ces données sont normalement disponibles dans le fichier nommé known_host, et la fonction ssh_file:is_host_key/5 offre la possibilité de vérifier ces données. Le premier argument correspond à une clé publique, le second à l’hôte sous la forme d’un nom de domaine ou d’une adresse IP, le troisième au port TCP utilisé, le quatrième au type d’algorithme et le dernier aux options. Cette fonction retourne un booléen.

25> false = ssh_file:is_host_key(RsaPublicKey, "localhost", 22, rsa, []).

Quelques fonctions sont encore à présenter, succinctement : ssh_file:add_host_key/4 permet de rajouter un nouvel hôte dans le fichier known_host et ssh_file:is_auth_key/3 donne la possibilité au serveur de savoir si une clé publique stockée dans le fichier authorized_keys a le droit de se connecter au serveur.

6. Intégration du protocole SSH dans le projet cache

Cette longue série d’articles a vu naître un projet de service de gestion de cache – proche du fonctionnement de Redis ou de Memcache – permettant de mettre en pratique les nombreuses capacités offertes par Erlang/OTP. Étape maintenant devenue une habitude, ce projet va être mis à jour pour supporter l’inclusion de ce nouveau standard qu’est SSH, que ce soit d’un point de vue client ou d’un point de vue serveur. Comme le support de cette fonctionnalité n’est pas un petit ajout, il est tout d’abord préférable d’incrémenter le numéro de version de l’application présente dans le fichier src/cache.app.src, passant de la version 0.6.0 à la version 0.7.0 🅐. L’application ssh est aussi rajoutée à la liste des dépendances 🅑 ainsi que les options de fonctionnement liées au daemon 🅒.

{application, cache,
 [{description, "A simple cache application"},
  {vsn, "0.7.0"}, % 🅐
  {registered, [cache_sup, cache]},
  {mod, {cache_app, [ssl, ssh]}}, % 🅑
  {applications,[kernel, stdlib, ssl, ssh]},
  {env,[{ssl, [{protocol, tls},{certfile, "cert.pem"},{keyfile, "key.pem"},{verify, verify_none}]}
       ,{ssh, [{port, 22222}]} % 🅒
       ]},
  {modules, []},
  {licenses, ["Apache 2.0"]},
  {links, []}
 ]}.

6.1 Modification de l’arbre de supervision de l’application

L’application cache essaie d’utiliser au maximum les fondements d’Erlang/OTP, ceux-ci incluent la création d’un arbre de supervision pour contrôler les nombreux processus fonctionnant sur la machine virtuelle. Le superviseur applicatif – celui en charge du bon fonctionnement de l’application cache – se nomme cache_sup et est défini dans le fichier src/cache_sup.erl. Il est en charge de tous les sous-processus liés de près ou de loin à cette application. Le code a été amputé de tous les autres standards implémentés dans les précédents articles, et la majorité des modifications sont effectuées au niveau de la fonction init/1.

-module(cache_sup).
-behavior(supervisor).
-export([init/1, start_link/0]).
start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []).

Les arguments à passer au superviseur en charge de l’application ssh dénommé cache_ssh_sup sont stockés dans la variable SshArgs 🅐. Le triplet « Module Fonction Argument » ou MFA est stocké dans la variable SshStart 🅑. Finalement, les spécifications du processus sont stockées dans la variable SshSpec 🅒.

init(_Args) ->
    SupervisorConf = #{ strategy => one_for_one, intensity => 1, period => 5 },
    % ...
    SshArgs = [{local, cache_ssh_sup}, cache_ssh_sup, []], % 🅐
    SshStart = {supervisor, start_link, SshArgs}, % 🅑
    SshSpec = #{ id => cache_ssh_sup, start => SshStart, type => supervisor }, % 🅒
    {ok, {SupervisorConf, [SshSpec]}}.

Les sources du module cache_ssh_sup sont quant à elles stockées dans le fichier src/cache_ssh_sup.erl. Ce module est le superviseur dédié pour le support du protocole SSH au sein de l’application et donc, des processus qui devront gérer le service et ses dépendances directes comme le stockage des paires de clés.

-module(cache_ssh_sup).
-behavior(supervisor).
-export([init/1]).

La fonction init/1 permet d’initialiser l’état du superviseur. Deux processus seront supervisés 🅓, le processus cache_ssh_server_key_store dont les spécifications sont retournées par la fonction store/0 en charge de la gestion des clés et le superviseur du daemon qui sera responsable du service, qui est pour sa part retourné par la fonction daemon/0.

init(Arguments) ->
    SupervisorSpec = #{ strategy => one_for_one },
    {ok, {SupervisorSpec, [store(), daemon()]}}. % 🅓

Comme vu dans le paragraphe précédent, un gestionnaire de clés nommé cache_ssh_server_key_store 🅔 fait aussi partie de l’arbre de supervision. Il permet de créer et de stocker les clés, mais son contenu sera détaillé dans la partie 6.2. C’est un processus travailleur qui n’a aucun autre processus à superviser.

store() ->
    #{ id => cache_ssh_server_key_store
     , start => {cache_ssh_server_key_store, start_link, []} % 🅔
     , restart => permanent
     , type => worker }.

La fonction daemon/0 permet de configurer le daemon SSH. Les options du daemon sont récupérées au niveau des variables d’environnement de l’application cache 🅕 sous la forme d’une proplist. La valeur du port à utiliser est récupérée au moyen de la fonction proplists:get_value/3. Si cette valeur est non définie, le port TCP/22222 sera utilisé par défaut 🅖. Les options par défaut sont alors configurées, les clés sont récupérées via le module cache_ssh_server_key_handler, la gestion de la ligne de commande et du sous-système cache utilisent le module cache_ssh_server_handler, l’exécution de commande et le shell sont désactivés 🅗. Pour démarrer le daemon, la fonction ssh:daemon/3 est appelée au moyen d’un tuple représentant le Module, la Fonction et les Arguments (MFA).

daemon() ->
    SshOpts = application:get_env(cache, ssh, []), % 🅕
    DaemonPort = proplists:get_value(port, SshOpts, 22222), % 🅖
    DaemonArgs = [DaemonPort, [{key_cb, cache_ssh_server_key_handler}
                              ,{ssh_cli, {cache_ssh_server_handler, []}}
                              ,{subsystems, [{"cache", {cache_ssh_server_handler, []}}]}
                              ,{exec, disabled}]
                              ,{shell, disabled}], % 🅗
    #{ id => cache_ssh_daemon
     , start => {ssh, daemon, DaemonArgs} % 🅘
     , restart => permanent
     , type => supervisor }.

6.2 Création du gestionnaire de clés SSH pour le serveur

Le module cache ssh_server_key_handler utilise le behavior ssh_server_key_api et, comme son nom l’indique, il va être responsable du partage des clés publiques pour la partie serveur, permettant ainsi de gérer l’accès du service aux différents clients qui voudraient y avoir accès. Le code a été réduit à l’extrême, et seulement deux lignes sont réellement utiles, celles définissant les fonctions host_key/2 et is_auth_key/3 qui sont exportées, comme l’exige le behavior.

-module(cache_ssh_server_key_handler).
-export([host_key/2, is_auth_key/3]).
-behavior(ssh_server_key_api). % 🅐

Lors d’une connexion cliente, le serveur partage ses clés publiques avec le client. Il est donc nécessaire d’avoir accès à ces informations. Pour éviter d’utiliser les clés présentes sur le système de fichiers, habituellement configuré dans le répertoire /etc/ssh et dont l’accès est normalement restreint, ces dernières seront récupérées à l’aide d’un autre processus au moyen de la fonction cache_ssh_server_key_store:get/1.

host_key(Type, DaemonOptions) -> cache_ssh_server_key_store:get(Type).

Ce qui est recherché, dans un premier temps, c’est d’avoir une connexion chiffrée de bout en bout sans authentification. Pour ce faire, la fonction de rappel is_auth_key/2 doit alors toujours retourner true. Si une authentification par clé était nécessaire, il serait alors possible de filtrer les accès en fonction de la clé publique de l’utilisateur et/ou son nom. En cas de refus, la valeur false pourra être retournée. Le premier argument de cette fonction correspond donc à la clé publique de l’utilisateur, le second à son nom et le dernier contient les options passées au daemon lors de son démarrage.

is_auth_key(PublicUserKey, User, DaemonOptions) -> true.

Il est temps de passer à la partie en charge du stockage et de la génération des clés. Le module cache_ssh_server_key_store a été conçu pour être minimaliste, ce qui permettra d’expliquer de nouveaux concepts. Tout d’abord, il utilise le behavior gen_server. À l’exception des fonctions start_link/0 et get/2, les autres fonctions sont standard et correspondent au prérequis de ce behavior.

-module(cache_ssh_server_key_store).
-behavior(gen_server). % 🅑
-export([start_link/0, get/1]).
-export([init/1, handle_call/3, handle_info/2, handle_cast/2]).

La fonction start_link/0 permet de simplifier le démarrage du processus lors de son ajout dans l’arbre de supervision. Il utilise la fonction gen_server:start_link/4 pour démarrer un nouveau processus local enregistré avec le nom du module.

start_link() -> gen_server:start_link({local,?MODULE},?MODULE, [], []).

La fonction d’initialisation init/1 ne prend en charge aucun argument et le paramètre pour définir l’état du processus est une structure de données map vide.

init(_) -> {ok, #{}}.

La fonction de rappel handle_call/3 est ici la seule qui sera réellement utilisée. Lors de la réception d’un message contenant le tuple {get, Type}Type est un algorithme normalement supporté par SSH, le processus appelle la fonction update_or_fetch/2 qui a la responsabilité de retourner une clé.

handle_call({get, 'rsa-sha2-256'}, _, Store) -> update_or_fetch('ssh-rsa', Store);
handle_call({get, 'rsa-sha2-512'}, _, Store) -> update_or_fetch('ssh-rsa', Store);
handle_call({get, Type}, _, Store) -> update_or_fetch(Type, Store);
handle_call(_, _, Store) -> {noreply, Store}.

Les fonctions de rappel handle_cast/2 et handle_info/2 sont définies, mais ne sont pas utilisées, cela évite d’avoir des messages d’alerte lors de la compilation et permet aussi d’éviter que de nombreux messages restent bloqués dans la boite aux lettres du processus en étant automatiquement supprimés.

handle_cast(_, Store) -> {noreply, Store}.
handle_info(_, Store) -> {noreply, Store}.

Erlang/OTP est livré avec un préprocesseur permettant de créer des macros, généralement utilisées pour mettre des valeurs statiques, il permet aussi d’éviter la répétition de code. La macro ici développée se nomme UPDATE_OR_FETCH et utilise deux arguments. Le premier correspond au type d’algorithme utilisé, et le second au tuple passé à la fonction public_key:generate_key/1 pour générer une clé. Deux définitions de fonctions sont alors générées, une qui va récupérer la valeur de la clé et la retourner 🅒, l’autre qui sera en charge de générer une nouvelle clé si elle n’est pas présente, puis de la stocker 🅓.

-define(UPDATE_OR_FETCH(TYPE, GENERATE),
        update_or_fetch(TYPE, #{ TYPE := Key} = Store) -> {reply, {ok, Key}, Store}; % 🅒
        update_or_fetch(TYPE, Store) ->
               Key = public_key:generate_key(GENERATE), % 🅓
               {reply, {ok, Key}, Store#{ TYPE => Key }}).

Lorsque cette macro est utilisée pour le support de RSA avec le code UPDATE_OR_FETCH('ssh-rsa', {rsa, 3096, 65537}); le résultat suivant est produit – mis volontairement en commentaire pour l’exemple.

% update_or_fetch('ssh-rsa', #{ 'ssh-rsa' := Key} = Store) ->
%   {reply, {ok, Key}, Store};
% update_or_fetch('ssh-rsa', Store) ->
%   Key = public_key:generate_key({rsa, 4096, 65537}),
% {reply, {ok, Key}, Store#{ TYPE => Key }}

L’application de la macro précédemment définie est utilisée pour tous les algorithmes supportés par défaut avec OpenSSH pour des raisons évidentes de compatibilité. L’exécution d’une macro se fait en préfixant son identifiant avec un point d’interrogation, éventuellement suivi des arguments. La dernière fonction permet de retourner le tuple {error, notfound} pour n’importe quel autre type d’algorithme demandé 🅔.

?UPDATE_OR_FETCH('ssh-rsa', {rsa, 4096, 65537});
?UPDATE_OR_FETCH('ecdsa-sha2-nistp256', {namedCurve, 'secp256r1'});
?UPDATE_OR_FETCH('ecdsa-sha2-nistp384', {namedCurve, 'secp384r1'});
?UPDATE_OR_FETCH('ecdsa-sha2-nistp521', {namedCurve, 'secp521r1'});
?UPDATE_OR_FETCH('ssh-ed25519', {namedCurve, ed25519});
update_or_fetch(_,Store) -> {reply, {error, notfound}, Store}. % 🅔

La fonction get/1 est l’interface de communication privilégiée pour communiquer avec le processus de gestion des clés. Cette fonction permet d’envoyer le tuple {get, Type}, où type correspond bien évidemment au type d’algorithme demandé sous la forme d’un atom.

get(Type) -> gen_server:call(?MODULE, {get, Type}, 10000).

6.3 Création du code pour le serveur SSH

De nombreuses méthodes peuvent être utilisées pour concevoir un serveur SSH, celle présentée ici utilise la création d’un module conçu autour du behavior ssh_server_channel 🅐. Comme son nom l’indique, ce behavior permet de gérer directement les évènements reçus sur un canal et évite de passer par la gestion complète des différentes étapes de la connexion. Le behavior en question utilise principalement quatre fonctions de rappel : init/1, handle_msg/2 et handle_ssh_msg/2. Le module s’appelle quant à lui cache_ssh_server_handler et est enregistré dans le fichier src/cache_ssh_server_handler.erl.

-module(cache_ssh_server_handler).
-behavior(ssh_server_channel). % 🅐
-export([init/1, handle_msg/2, handle_ssh_msg/2, terminate/2]).

La fonction de rappel init/1 permet d’initialiser l’état du processus avec un état spécifique. Les informations sur la connexion et le canal ayant pour vocation d’être stockées dans le processus, un simple map fera amplement l’affaire au vu du peu de données à gérer.

init(Args) -> {ok, #{}}.

La fonction handle_msg/2 permet de regrouper les évènements de différents types généralement supportés par le behavior gen_server comme call, cast et info. Le premier message reçu lors du démarrage du processus est celui indiquant l’état du canal, s’il est prêt à être utilisé ou non. Dans tous les cas, cette fonction supprime silencieusement tous les messages reçus par défaut.

handle_msg({ssh_channel_up, ChannelId, ConnectionRef} = _Message, State) -> 
  {ok, #{connection => ConnectionRef, channel => ChannelId}}.
handle_msg(Msg, State) -> {ok, State}.

La fonction handle_ssh_msg/2 permet d’agir sur les évènements liés au protocole SSH lui-même, en particulier ceux issus du canal en cours d’utilisation. Le serveur reçoit une commande sous la forme d’un message et transfère son contenu à la fonction parse/4 qui est en charge du traitement de l’information. Tout comme la fonction handle_msg/2, tous les autres messages reçus sont supprimés.

handle_ssh_msg({ssh_cm, ConnectionRef,{data, _, ChannelId, Command}}, State) ->
    parse(ConnectionRef, ChannelId, Command, State);
handle_ssh_msg(Msg, State) -> {ok, State}.

La fonction parse/4 récupère les informations essentielles pour communiquer avec le client au travers du canal, la référence à la connexion, l’identifiant du canal, la commande brute et l’état du processus en cours d’utilisation. La commande est alors parsée avec la fonction cache_lib:parse/1 et son résultat est routé vers la fonction execute/4 en cas de succès, sinon, vers la fonction execution_end/3.

parse(ConnectionRef, ChannelId, Command, State) ->
    case cache_lib:parse(Command) of
        {M, F, A} = Call -> execute(ConnectionRef, ChannelId, Call, State); % 🅑
        {error, Raison} -> execution_end(ConnectionRef, ChannelId, State); % 🅒
        Otherwise -> execution_end(ConnectionRef, ChannelId, State)
    end.

La fonction execute/3 exécute la commande parsée. Lors d’un appel asynchrone, la chaîne de caractères ok est retournée au client 🅓. Dans le cas d’un appel synchrone, les données sont toutes retournées directement au client, que ce soit une simple réponse sous la forme d’un bitstring 🅔 ou d’une liste de réponses 🅕. Pour le cas particulier d’une réponse multiple, la fonction lists:map/2 est utilisée pour envoyer linéairement les valeurs au client.

execute(ConnectionRef, ChannelId, {M, F, A} = Call, State) ->
    case erlang:apply(M, F, A) of
        ok -> ssh_connection:send(ConnectionRef, ChannelId, <<"ok\n">>); % 🅓
        Reponse when is_bitstring(Reponse) ->
            ssh_connection:send(ConnectionRef, ChannelId, <<Reponse/bitstring, "\n">>); % 🅔
        Reponse when is_list(Reponse) ->
            lists:map(fun(X) ->
              ssh_connection:send(ConnectionRef, ChannelId, <<X/bitstring, "\n">>)
            end, Reponse) % 🅕
    end,
    execution_end(ConnectionRef, ChannelId, State).

La fonction execution_end/3 est responsable de l’arrêt de la connexion avec le client. Le « protocole » simpliste qui a été créé dans les premiers articles indique que la connexion entre le client et le serveur doit être explicitement fermée quand les données de la réponse ont été correctement envoyées par le serveur. C’est exactement ce qui reproduit cette fonction, le canal est d’abord fermé au moyen de la fonction ssh_connection:send_eof/2 🅖. Le lien est automatiquement clôturé au moyen de la fonction ssh_connection:close/2 lors de l’arrêt du processus. Il aurait été aussi possible de créer une fonction de rappel terminate/2 pour gérer cette partie.

execution_end(ConnectionRef, ChannelId, State) ->
    ssh_connection:send_eof(ConnectionRef, ChannelId), % 🅖
    {stop, ChannelId, State}.

Pour rappel, le projet peut être démarré avec l’aide de la commande rebar3, le service devrait alors être disponible sur le port TCP/22222 sur l’interface locale. L’autre possibilité est de recompiler l’application au moyen de la fonction r3:do(compile), puis de redémarrer l’application via les fonctions application:stop(ssh) et application:start(ssh).

shell$ rebar3 shell

La meilleure façon de tester si le serveur fonctionne correctement est d’utiliser OpenSSH et d’exécuter quelques commandes pour voir si il est possible de rajouter au moins une clé avec une valeur associée au moyen de la commande add key value. Cette dernière est envoyée dans l’entrée standard de la commande ssh, où le drapeau -T permet de désactiver l’allocation d’un pseudoterminal, le paramètre -p configure le port du serveur, -S permet de choisir le type de sous-système à utiliser, et localhost fait référence au serveur en écoute.

shell$ echo "add key value" | ssh -T -p 22222 -S cache localhost
ok

Maintenant qu’une valeur a été rajoutée, il devrait alors être possible de la récupérer avec la commande get key. La commande ssh reste exactement la même.

shell$ echo "get key" | ssh -T -p 22222 -S cache localhost
value

Le client livré avec OpenSSH fonctionne parfaitement et permet d’ajouter une valeur associée à une clé, mais aussi de récupérer directement la valeur stockée.

6.4 Création du code pour le client SSH

Utiliser un outil comme OpenSSH pour rajouter des données brutes dans la « base de données » peut être assez pratique, mais il est quand même recommandé de créer une implémentation – même minimaliste – d’un client. Il serait tout à fait possible d’envoyer la commande simplement après la création de la connexion au moyen de la fonction ssh:connect/3, mais la méthode qui va suivre utilise un processus dédié pour communiquer avec le serveur, agissant comme un proxy. Cette fonctionnalité est rendue possible grâce au behavior ssh_client_channel 🅐. Ce dernier permet de gérer un canal ouvert et de router les données émises par le serveur. Les fonctions de rappel sont similaires à celles présentes dans le behavior gen_server.

-module(cache_ssh_client).-behavior(ssh_client_channel). % 🅐
-export([connect/3, add/4, delete/3, get/3, get_keys/2, get_values/2]).
-export([init/1, terminate/2]).
-export([handle_call/3, handle_info/2, handle_cast/2, handle_msg/2, handle_ssh_msg/2]).

La première étape du client est de se connecter au moyen de la fonction ssh:connect/3 🅑 puis de créer un nouveau canal au niveau de la fonction ssh_connection:session_channel/2 🅒. La connexion est donc maintenant active et un canal est créé, ces deux informations sont stockées dans la variable Args 🅓 qui sera alors utilisée comme argument pour initialiser l’état du processus un peu plus tard. Le processus client peut être alors démarré au moyen de la fonction ssh_client_channel:start/4 🅔. Cette dernière attend quatre arguments, le premier correspond à la connexion, le second au canal précédemment créé, le troisième au module de rappel à utiliser, c’est-à-dire cache_ssh_client, et le dernier argument sera directement transféré à la fonction init/1 en charge d’initialiser le processus.

connect(Host, Port, Options) ->
    {ok, Connection} = ssh:connect(Host, Port, Options), % 🅑
    {ok, Channel} = ssh_connection:session_channel(Connection, 10000), % 🅒
    Args = #{ connection => Connection, channel => Channel }, % 🅓
    ssh_client_channel:start(Connection, Channel, ?MODULE, Args). % 🅔

La fonction init/1 est la première fonction appelée par le processus en charge de la communication avec le serveur. La connexion et le canal reçu sont utilisés pour créer une nouvelle session. Le serveur de cache s’attend à recevoir différents types de sessions, dans le cas de ce client, le sous-système cache est utilisé. Cette étape se fait au moyen de la fonction ssh_connection:subsystem/4 🅕, qui s’attend à recevoir la connexion active, un canal, le nom du sous-système à utiliser suivi d’un délai d’attente. Après l’exécution de cette fonction, une session est ouverte, le client et le serveur sont prêts à émettre et à recevoir des données.

init(#{ connection := Connection, channel := Channel } = State) ->
    success = ssh_connection:subsystem(Connection, Channel, "cache", 10000), % 🅕
    {ok, State}.

La fonction de rappel handle_msg/2 permet de gérer les messages reçus par le processus en dehors des messages reçus sur le canal SSH ouvert, les messages provenant des fonctions call ou cast, par exemple. Le premier message qu’elle reçoit indique si la connexion est prête à être utilisée et correspond à un tuple dont la première valeur est l’atom ssh_channel_up, suivi de l’identifiant du canal et de la référence à la connexion. Les autres messages reçus peuvent être ignorés pour le moment, à l’exception peut-être de celui contenant l’atom ‘EXIT’ propre à l’arrêt d’un processus, mais qui ne sera pas détaillé ici.

handle_msg({ssh_channel_up, _Channel, _Connection} = _Message, State) -> {ok, State}.
handle_msg(_Message, State) -> {ok, State}.

La fonction de rappel handle_call/3 est similaire à celle offerte par le behavior gen_server et correspond à un appel synchrone. Lors de la réception d’un message, l’émetteur s’attend à recevoir une réponse. Dans le cas de cette définition, aucune réponse n’est fournie à ce dernier, mais l’état du processus est altéré avec le rajout d’une nouvelle clé utilisant l’atom from et contenant l’identifiant de l’émetteur pour une réponse décalée. Effectivement, lors de l’émission d’une commande synchrone au serveur, une ou plusieurs réponses seront envoyées, et une mémoire tampon devra alors être utilisée.

handle_call({command, Command} = _Message, From, State) ->
    send_command(Command, State),
    {noreply, State#{ from => From }}.

Si la fonction handle_call/3 permet de gérer les appels synchrones, la fonction handle_cast/2 permet de gérer les appels synchrones. L’émetteur n’attend aucune réponse particulière, et assume que la commande a été correctement passée au serveur distant.

handle_cast({command, Command} = _Message, State) ->
    send_command(Command, State),
    {noreply, State}.

La fonction de rappel handle_info/2 a été créée ici, mais ne sera pas utilisée. Les messages reçus sont alors silencieusement supprimés de la boite aux lettres du processus.

handle_info(_Message, State) -> {noreply, State}.

La fonction de rappel handle_ssh_msg/2 est un peu plus complexe que les autres et gère les messages reçus par le serveur. Les deux premières définitions permettent de gérer les messages dans un mode synchrone, où le client s’attend à une réponse. La première définition 🅖 permet de concaténer un nouveau message reçu s’il y en a déjà un d’existant. La seconde paramètre l’état lors de la réception si l’état du processus reçoit un premier message du serveur 🅗. Dans les deux cas, la clé reply de l’état fait office de mémoire tampon. La troisième définition 🅘 correspond à la clôture du canal par le serveur, le processus client s’arrête et donne la main à la fonction terminate/2. La quatrième et dernière définition supprime silencieusement tous les autres messages de la boite aux lettres du processus.

handle_ssh_msg({ssh_cm,_Connection,{data,_,_Channel,Data}} = _Message
              ,#{ from := _From, reply := Reply } = State) ->
    {ok, State#{ reply => <<Reply/binary, Data/binary>>}}; % 🅖
handle_ssh_msg({ssh_cm,_Connection,{data,_,_Channel,Data}} = _Message
              ,#{ from := _From } = State) ->
    {ok, State#{ reply => Data}}; % 🅗
handle_ssh_msg({ssh_cm,_Connection,{eof,_Channel}}, #{ channel := Channel } = State) ->
    {stop, Channel, State}; % 🅘
handle_ssh_msg(_Message, State) -> {ok, State}.

La fonction terminate/2 permet d’exécuter les étapes d’arrêt du processus. La première définition permet de gérer un appel synchrone et récupère les informations de l’émetteur pour lui envoyer 🅚 le message contenu dans la mémoire tampon en le décodant préalablement au moyen de la fonction string:split/3 🅙. Cette technique est assez courante, mais nécessite une certaine rigueur. Effectivement, si le client ne reçoit pas de réponse lors d’un appel synchrone, il restera bloqué. La configuration d’un délai du côté client est alors vivement recommandée.

terminate(_Reason, #{ from := From, reply := Reply } = _State) ->
    DecodedReply = string:split(Reply, <<"\n">>, all), % 🅙
    ssh_client_channel:reply(From, DecodedReply); % 🅚
terminate(_Reason, _State) -> ok.

La fonction send_command/2, utilisée au niveau des fonctions de rappel handle_cast/2 et handle_call/3, permet d’envoyer plus facilement une commande au serveur. Les commandes sont alors concaténées pour créer une charge utile au moyen de la fonction cache_lib:join/1 🅛, puis sont envoyées au serveur au moyen de la fonction ssh_connection:send/3 🅜.

send_command(Command, #{ channel := Channel, connection := Connection } = _State) ->
    Data = cache_lib:join(Command), % 🅛
    ssh_connection:send(Connection, Channel, <<Data/binary, "\n">>). % 🅜

Les dernières fonctions à définir sont les interfaces qui seront utilisées par les développeurs. Elles suivent toutes le même modèle et permettent d’ajouter, de supprimer ou de récupérer des données sur le serveur. La première étape est la création de la connexion au moyen de la fonction connect/3 précédemment définie, puis de l’utilisation de la fonction ssh_client_channel:cast/2 pour les appels asynchrones, ou de la fonction ssh_client_channel:call/3 pour les appels synchrones.

add(Host, Port, Key, Value) ->
    {ok, Connection} = connect(Host, Port, []),
    ssh_client_channel:cast(Connection, {command, [<<"add">>, Key, Value]}).
delete(Host, Port, Key) ->
    {ok, Connection} = connect(Host, Port, []),
    ssh_client_channel:cast(Connection, {command, [<<"delete">>, Key]}, 10000).
get(Host, Port, Key) ->
    {ok, Connection} = connect(Host, Port, []),
    ssh_client_channel:call(Connection, {command, [<<"get">>, Key]}, 10000).
get_keys(Host, Port) ->
    {ok, Connection} = connect(Host, Port, []),
    ssh_client_channel:call(Connection, {command, [<<"get_keys">>]}, 10000).
get_values(Host, Port) ->
    {ok, Connection} = connect(Host, Port, []),
    ssh_client_channel:call(Connection, {command, [<<"get_values">>]}, 10000).

Il est maintenant temps de tester si cette implémentation est fonctionnelle. Les données précédemment ajoutées au moyen d’OpenSSH devraient encore être présentes sur le serveur s’il n’a pas été redémarré. Pour le vérifier, la fonction cache_ssh_client:get/3 est appelée pour recevoir la valeur associée à la clé key. L’ajout de données est effectué avec la fonction cache_ssh_client:add/4.

Shell> erl
1> cache_ssh_client:get(localhost, 22222, <<"key">>).
[<<"value">>,<<>>]
2> cache_ssh_client:add(localhost, 22222, <<"macle">>, <<"mavaleur">>).
ok
3> cache_ssh_client:get(localhost, 22222, <<"macle">>).
[<<"mavaleur">>,<<>>]

L’application cache peut maintenant être accessible au moyen de SSH avec son propre client, mais aussi via les autres implémentations comme OpenSSH.

Conclusion

Écrire un article sur SSH est un grand honneur, honneur encore plus grand quand le début de sa carrière professionnelle débuta avec l’amélioration d’une solution applicative utilisant SSH. Quel administrateur système n’a pas connu l’excitation et l’émerveillement – mais aussi parfois l’effroi – d’utiliser cette commande ? Bercé par des films de science-fiction comme Matrix ou Hackers, l’imaginaire collectif a fait de ce genre d’instruments de véritables outils de pouvoir et de domination, permettant de contrôler un parc de machines à distance, d’une manière sécurisée et sans effort. Au même titre que Nmap, qui fut l’un des piliers des mouvements alternatifs à la fin du dernier millénaire, SSH fait aujourd’hui partie des fondations de l’administration système, et ce, depuis presque 25 ans. Utilisé avant tout pour avoir accès au terminal d’un serveur, son usage s’est aujourd’hui étendu à l’automatisation et à l’orchestration de plateformes avec des logiciels comme Chef, Ansible ou Salt. Son incroyable souplesse lui permettant aussi bien de faire office de VPN d’appoint, d’outil de contournement pour les pare-feux ou encore de passerelle entre différents réseaux, il reste à ce jour un couteau suisse indispensable à tout administrateur qui se respecte.

Même si le protocole en lui-même reste globalement plus simple à utiliser que son homologue TLS, les principes de sécurité et de chiffrement augmentent significativement sa complexité. Ses nombreuses fonctionnalités embarquées sont appréciables dans de nombreuses situations, mais nécessitent leur compréhension pour pouvoir les utiliser correctement. Toutefois, l’implémentation réalisée par les développeurs d’Erlang/OTP est stable, documentée et utilisable par tout le monde. Certes, des interfaces permettant de contrôler certains comportements sont difficiles d’accès, voire absentes, et le manque de support pour quelques formats de fichiers se fait ressentir. N’en déplaise à tous ses éventuels détracteurs, cette version du protocole SSH permettra tout de même à quiconque de réaliser un serveur ou un client en un minimum de lignes de codes, le tout au moyen d’une abstraction ergonomique made in Erlang. Que demander de plus pour une bibliothèque livrée par défaut avec ce langage ?

Références

[1] Site officiel du projet OpenSSH : https://www.openssh.com

[2] Numéros alloués au protocole SSH (RFC4250) : https://datatracker.ietf.org/doc/html/rfc4250

[3] Architecture du protocole SSH (RFC4251) : https://datatracker.ietf.org/doc/html/rfc4251

[4] Protocole d’authentification SSH (RFC4252) : https://datatracker.ietf.org/doc/html/rfc4252

[5] Protocole de couche transport SSH (RFC4253) : https://datatracker.ietf.org/doc/html/rfc4253

[6] Protocole de connexion SSH (RFC4254) : https://datatracker.ietf.org/doc/html/rfc4254

[7] Miroir officiel GitHub du projet OpenSSH : https://github.com/openssh/openssh-portable

[8] Manuel de la commande SSH : https://man.openbsd.org/ssh

[9] Documentation Erlang/OTP de l’application ssh : https://www.erlang.org/doc/apps/ssh

Pour aller plus loin

Toutes les capacités livrées avec l’application ssh d’Erlang/OTP n’ont malheureusement pas été présentées. Il est possible d’utiliser le module ssh_sftp comme client SFTP, le module ssh_sftpd pour lancer automatiquement un serveur sous-système SFTP donnant accès au système de fichiers ou encore le module ssh_agent, ce dernier permettant d’accéder à des clés au moyen d’un agent.

La communauté Erlang fournit aussi quelques exemples d’applications, comme ssh-proxy (https://github.com/flussonic/ssh-proxy) permettant de créer un proxy SSH ou encore erlyssh (https://github.com/NetEase/erlyssh), donnant la possibilité à l’utilisateur d’exécuter des commandes en parallèle. Pour finir, le module sshrpc (https://github.com/jj1bdx/sshrpc) permettra d’avoir accès aux fonctionnalités RPC d’Erlang via une connexion SSH.



Article rédigé par

Par le(s) même(s) auteur(s)

Création d’un serveur web avec Erlang/OTP et inets httpd

Magazine
Marque
GNU/Linux Magazine
Numéro
268
Mois de parution
mars 2024
Spécialité(s)
Résumé

Inventé en 1989 par Tim Berners-Lee dans les bureaux du CERN et grandement inspiré par les idées du projet Xanadu conçu par Ted Nelson en 1965, le protocole HTTP, ou HyperText Transfer Protocol est probablement l’une des solutions de communication les plus utilisées sur Internet. Même si certains de ses concurrents reviennent au goût du jour, comme Gopher avec le minimaliste Gemini, HTTP reste de facto le protocole utilisé par tous les navigateurs sur le marché. En plus d’être simple, extensible et sans états, il est évolutif, ayant à son actif pas moins de 5 versions spécifiées. Bref, il était impensable de pouvoir l’ignorer plus longtemps, et encore moins acceptable de faire fi de son utilisation au sein de l’écosystème Erlang...

Création de connexions TLS/DTLS avec Erlang/OTP

Magazine
Marque
GNU/Linux Magazine
Numéro
266
Mois de parution
novembre 2023
Spécialité(s)
Résumé

Navigation web, échanges de courriers électroniques, diffusions audio ou vidéo, conférences en ligne, communications entre équipements embarqués, automatisation… Autant de services nécessitant la mise en place d’un niveau de confidentialité important, dont l’un des impératifs est de garantir la protection des données pour ne pas les voir fuiter dans la nature. C’est avec ces contraintes que la société Netscape a conçu au début du XXIe siècle ce qui est aujourd’hui devenu le standard pour offrir ce niveau de sécurité. D’abord dénommé SSL puis rebaptisé TLS, ce protocole, ainsi que son collègue DTLS furent élaborés pour répondre à toutes ces exigences.

Génération et gestion de certificats avec Erlang/OTP

Magazine
Marque
GNU/Linux Magazine
Numéro
265
Mois de parution
septembre 2023
Spécialité(s)
Résumé

Avec plus de 230 millions de certificats détectés sur le Web en 2023, SSL/TLS reste aujourd’hui le standard de référence pour sécuriser les connexions sur Internet. Sa démocratisation fut grandement accélérée avec l’apparition d’acteurs comme Let’s Encrypt, offrant des outils gratuits et accessibles à tous pour aider à la création de certificats signés par une entité de confiance. À contrario, cette facilité d’accès à ces services a fait oublier les étapes de création de ce type de document, pourtant d’une importance fondamentale pour la protection des données des utilisateurs...

Les derniers articles Premiums

Les derniers articles Premium

Bun.js : l’alternative à Node.js pour un développement plus rapide

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Dans l’univers du développement backend, Node.js domine depuis plus de dix ans. Mais un nouveau concurrent fait de plus en plus parler de lui, il s’agit de Bun.js. Ce runtime se distingue par ses performances améliorées, sa grande simplicité et une expérience développeur repensée. Peut-il rivaliser avec Node.js et changer les standards du développement JavaScript ?

PostgreSQL au centre de votre SI avec PostgREST

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Dans un système d’information, il devient de plus en plus important d’avoir la possibilité d’échanger des données entre applications. Ce passage au stade de l’interopérabilité est généralement confié à des services web autorisant la mise en œuvre d’un couplage faible entre composants. C’est justement ce que permet de faire PostgREST pour les bases de données PostgreSQL.

La place de l’Intelligence Artificielle dans les entreprises

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’intelligence artificielle est en train de redéfinir le paysage professionnel. De l’automatisation des tâches répétitives à la cybersécurité, en passant par l’analyse des données, l’IA s’immisce dans tous les aspects de l’entreprise moderne. Toutefois, cette révolution technologique soulève des questions éthiques et sociétales, notamment sur l’avenir des emplois. Cet article se penche sur l’évolution de l’IA, ses applications variées, et les enjeux qu’elle engendre dans le monde du travail.

Petit guide d’outils open source pour le télétravail

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Ah le Covid ! Si en cette période de nombreux cas resurgissent, ce n’est rien comparé aux vagues que nous avons connues en 2020 et 2021. Ce fléau a contraint une large partie de la population à faire ce que tout le monde connaît sous le nom de télétravail. Nous avons dû changer nos habitudes et avons dû apprendre à utiliser de nombreux outils collaboratifs, de visioconférence, etc., dont tout le monde n’était pas habitué. Dans cet article, nous passons en revue quelques outils open source utiles pour le travail à la maison. En effet, pour les adeptes du costume en haut et du pyjama en bas, la communauté open source s’est démenée pour proposer des alternatives aux outils propriétaires et payants.

Les listes de lecture

9 article(s) - ajoutée le 01/07/2020
Vous désirez apprendre le langage Python, mais ne savez pas trop par où commencer ? Cette liste de lecture vous permettra de faire vos premiers pas en découvrant l'écosystème de Python et en écrivant de petits scripts.
11 article(s) - ajoutée le 01/07/2020
La base de tout programme effectuant une tâche un tant soit peu complexe est un algorithme, une méthode permettant de manipuler des données pour obtenir un résultat attendu. Dans cette liste, vous pourrez découvrir quelques spécimens d'algorithmes.
10 article(s) - ajoutée le 01/07/2020
À quoi bon se targuer de posséder des pétaoctets de données si l'on est incapable d'analyser ces dernières ? Cette liste vous aidera à "faire parler" vos données.
Voir les 68 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous