Articles

SSH & SCP en Python avec Paramiko

Posted on
19 min read
Janvier 03

SSH SCP en Python avec Paramiko

Les fournisseurs de cloud ont fait un massacre avec des services gérés soigneusement emballés pendant des années. Que ce soit des bases de données ou des courtiers de messages, les développeurs comme nous ne semblent pas avoir de problème à payer un peu plus pour que les choses soient prises en charge. Mais attendez, ne sommes-nous pas généralement les dernières personnes à opter pour moins d’optimisation et moins de contrôle ? Pourquoi est-ce le moment de décider autrement ? Si je devais faire une supposition, je parierais que c’est en partie parce que le DevOps côté serveur est plutôt nul.

En tant que développeur, la configuration ou le débogage d’un VPS est généralement un travail non comptabilisé, et il n’est pas particulièrement gratifiant. Au mieux, votre application finira probablement par fonctionner de la même manière que votre environnement local. Comment pouvons-nous améliorer cette partie inévitable de notre travail ? Eh bien, nous pourrions l’automatiser.

Paramiko et SCP sont deux bibliothèques Python que nous pouvons utiliser ensemble pour automatiser les tâches que nous voudrions exécuter sur un hôte distant, comme le redémarrage des services, les mises à jour ou la saisie des fichiers journaux. Nous allons voir à quoi ressemble l’écriture de scripts avec ces bibliothèques. Je vous préviens : ce tutoriel contient une quantité non négligeable de code, ce qui a tendance à m’exciter suffisamment pour contraindre les autres à participer à mes épisodes maniaques de tutoriel de code. Si vous commencez à vous sentir perdu, le repo complet se trouve ici :

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP en Python avec Paramiko. Contribuez au développement de hackersandslackers/paramiko-tutorial en créant un compte sur GitHub.
GitHubhackersandslackers

Paramiko s’appuie fortement sur le côté « in-the-weeds » des bibliothèques Python. Si vous cherchez quelque chose de facile qui peut juste faire le travail, pyinfra est censé être une excellente (et facile) alternative.

Configuration des clés SSH

Pour authentifier une connexion SSH, nous devons configurer une clé SSH RSA privée (à ne pas confondre avec OpenSSH). Nous pouvons générer une clé à l’aide de la commande suivante :

$ ssh-keygen -t rsa
Générer une clé RSA

Cette commande nous invitera à fournir un nom pour notre clé. Donnez-lui le nom que vous voulez :

Generating a public/private rsa key pair.Enter the file in which you wish to save they key (i.e., /home/username/.ssh/id_rsa):
Invite RSA

Puis, on vous demandera de fournir un mot de passe (n’hésitez pas à le laisser vide).

Maintenant que nous avons notre clé, nous devons la copier sur notre hôte distant. La façon la plus simple de le faire est d’utiliser ssh-copy-id :

$ ssh-copy-id -i ~/.ssh/mykey username@my_remote_host.org
Copie de la clé vers l’hôte distant

Vérification de notre clé SSH

Si vous souhaitez vérifier quelles clés vous possédez déjà, celles-ci peuvent être trouvées dans le répertoire .ssh de votre système :

$ cd ~/.ssh
Vérifier /.ssh directory

Nous recherchons des clés qui commencent par l’en-tête suivant :

-----BEGIN RSA PRIVATE KEY-----...-----END RSA PRIVATE KEY-----
id_rsa

N’hésitez pas à faire de même sur votre FPS.

Démarrer notre script

Installons nos bibliothèques. Lancez l’environnement virtuel que vous préférez et laissez-les s’exprimer :

$ pip3 install paramiko scp
Installer paramiko & scp

Juste une dernière chose avant d’écrire du code Python significatif ! Créez un fichier de configuration pour contenir les variables dont nous aurons besoin pour nous connecter à notre hôte. Voici les bases de ce dont nous avons besoin pour entrer dans notre serveur:

  • Hôte : L’adresse IP ou l’URL de l’hôte distant auquel nous essayons d’accéder.
  • Nom d’utilisateur : Il s’agit du nom d’utilisateur que vous utilisez pour vous connecter en SSH à votre serveur.
  • Passphrase (facultatif) : Si vous avez spécifié une phrase de passe lorsque vous avez créé votre clé ssh, indiquez-la ici. N’oubliez pas que la phrase de passe de votre clé SSH n’est pas la même que le mot de passe de votre utilisateur.
  • Clé SSH : Le chemin de fichier de la clé que nous avons créée plus tôt. Sur OSX, celles-ci vivent dans le dossier ~/.ssh de votre système. La clé SSH que nous ciblons doit avoir une clé d’accompagnement avec une extension de fichier .pub. Il s’agit de notre clé publique ; si vous suiviez plus tôt, elle devrait déjà avoir été générée pour vous.

Si vous essayez de télécharger des fichiers depuis votre hôte distant, vous devrez inclure deux autres variables :

  • Pathique distant : Le chemin vers le répertoire distant que nous cherchons à cibler pour les transferts de fichiers. Nous pouvons soit télécharger des choses vers ce dossier, soit en télécharger le contenu.
  • Path local : Même idée que ci-dessus, mais à l’inverse. Pour notre commodité, le chemin local que nous utiliserons est simplement /data, et contient des images de gifs de renards mignons.

Maintenant nous avons tout ce dont nous avons besoin pour faire un fichier config.py respectable:

"""Remote host configuration."""from os import environ, pathfrom dotenv import load_dotenv# Load environment variables from .envbasedir = path.abspath(path.dirname(__file__))load_dotenv(path.join(basedir, '.env'))# Read environment variableshost = environ.get('REMOTE_HOST')user = environ.get('REMOTE_USERNAME')ssh_key_filepath = environ.get('SSH_KEY')remote_path = environ.get('REMOTE_PATH')local_file_directory = 'data'
config.py

Création d’un client SSH

Nous allons créer une classe appelée RemoteClient pour gérer les interactions que nous aurons avec notre hôte distant. Avant d’être trop fantaisistes, commençons par instancier la classe RemoteClient avec les variables que nous avons créées dans config.py:

"""Client to handle connections and actions executed against a remote host."""class RemoteClient: """Client to interact with a remote host via SSH & SCP.""" def __init__(self, host, user, ssh_key_filepath, remote_path): self.host = host self.user = user self.ssh_key_filepath = ssh_key_filepath self.remote_path = remote_path
client.py

Rien d’impressionnant jusqu’ici : nous avons juste défini quelques variables et les avons passées dans une classe inutile. Passons à la vitesse supérieure sans quitter notre constructeur :

"""Client to handle connections and actions executed against a remote host."""from paramiko import SSHClient, AutoAddPolicy, RSAKeyfrom paramiko.auth_handler import AuthenticationException, SSHExceptionclass RemoteClient: """Client to interact with a remote host via SSH & SCP.""" def __init__(self, host, user, ssh_key_filepath, remote_path): self.host = host self.user = user self.ssh_key_filepath = ssh_key_filepath self.remote_path = remote_path self.client = None self.scp = None self.conn = None self._upload_ssh_key()
client.py

Nous avons ajouté trois nouvelles choses à instancier avec notre classe :

  • self.client : self.client servira finalement d’objection de connexion dans notre classe, de manière similaire à la façon dont vous avez traité une terminologie comme conn dans les bibliothèques de bases de données. Notre connexion sera None jusqu’à ce que nous nous connections explicitement à notre hôte distant.
  • self.scp : similaire à self.client, mais gère exclusivement les connexions pour le transfert de fichiers.
  • self.__upload_ssh_key() n’est pas une variable, mais plutôt une fonction à exécuter automatiquement chaque fois que notre client est instancié. L’appel de __upload_ssh_key() indique à notre objet RemoteClient de vérifier les clés ssh locales dès leur création afin que nous puissions essayer de les transmettre à notre hôte distant. Sinon, nous ne serions pas en mesure d’établir une connexion du tout.

Transférer les clés SSH vers un hôte distant

Nous avons atteint la section de cet exercice où nous devons frapper un code passe-partout dévastateur et inglorieux. C’est typiquement là que les individus émotionnellement inférieurs succombent à l’obscurité pure et simple de la compréhension des clés SSH et du maintien des connexions. Ne vous y trompez pas : l’authentification et la gestion des connexions à tout ce qui est programmé sont excessivement ennuyeuses… à moins que votre guide touristique ne soit un orfèvre des mots, qui vous protège avec amour dans une obscurité périlleuse. Certaines personnes appellent ce post un tutoriel. J’ai l’intention de l’appeler de l’art.

RemoteClient va commencer avec deux méthodes privées : __get_ssh_key() et __upload_ssh_key(). La première va chercher une clé publique stockée localement, et en cas de succès, la seconde va livrer cette clé publique à notre hôte distant comme un rameau d’olivier d’accès. Une fois qu’une clé publique créée localement existe sur une machine distante, cette machine nous confiera à jamais nos demandes de connexion : aucun mot de passe n’est requis. Nous inclurons une journalisation appropriée en cours de route, au cas où nous rencontrerions des problèmes :

"""Client to handle connections and actions executed against a remote host."""from os import systemfrom paramiko import SSHClient, AutoAddPolicy, RSAKeyfrom paramiko.auth_handler import AuthenticationException, SSHExceptionfrom scp import SCPClient, SCPExceptionfrom .log import loggerclass RemoteClient: """Client to interact with a remote host via SSH & SCP.""" ... def _get_ssh_key(self): """ Fetch locally stored SSH key. """ try: self.ssh_key = RSAKey.from_private_key_file(self.ssh_key_filepath) logger.info(f'Found SSH key at self {self.ssh_key_filepath}') except SSHException as error: logger.error(error) return self.ssh_key def _upload_ssh_key(self): try: system(f'ssh-copy-id -i {self.ssh_key_filepath} {self.user}@{self.host}>/dev/null 2>&1') system(f'ssh-copy-id -i {self.ssh_key_filepath}.pub {self.user}@{self.host}>/dev/null 2>&1') logger.info(f'{self.ssh_key_filepath} uploaded to {self.host}') except FileNotFoundError as error: logger.error(error)
client.py

_get_ssh_key()est assez simple : il vérifie qu’une clé SSH existe au chemin que nous avons spécifié dans notre config à utiliser pour se connecter à notre hôte. Si le fichier existe effectivement, nous définissons joyeusement notre variableself.ssh_key, de sorte que cette clé puisse être téléchargée et utilisée par notre client à partir de maintenant. Paramiko nous fournit un sous-module appelé RSAKey pour gérer facilement tout ce qui concerne les clés RSA, comme l’analyse d’un fichier de clé privée en une authentification de connexion utilisable. C’est ce que nous obtenons ici :

 RSAKey.from_private_key_file(self.ssh_key_filepath)
Lire la clé RSA à partir du fichier local.

Si notre clé RSA était un non-sens incompréhensible au lieu d’une vraie clé, la SSHException de Paramiko l’aurait attrapé et aurait soulevé une exception très tôt expliquant justement cela. L’utilisation appropriée de la gestion des erreurs d’une bibliothèque élimine une grande partie de la devinette de « ce qui s’est mal passé », en particulier dans des cas comme celui où il y a un potentiel pour de nombreuses inconnues dans un espace de niche avec lequel aucun d’entre nous ne déconne souvent.

_upload_ssh_key() est le moment où nous obtenons de coincer notre clé SSH dans la gorge de notre serveur distant tout en criant, « LOOK ! TU PEUX ME FAIRE CONFIANCE POUR TOUJOURS MAINTENANT ! » Pour ce faire, je fais un peu « vieille école » en passant les commandes bash via la os.system de Python. À moins que quelqu’un me signale une approche plus propre dans les commentaires, je vais supposer que c’est la façon la plus badass de gérer le passage des clés à un serveur distant.

La façon standard non-Python de passer des clés à un hôte ressemble à ceci :

ssh-copy-id -i ~/.ssh/mykey user@host
Passer une clé SSH à un hôte distant

C’est précisément ce que nous accomplissons dans notre fonction en Python, qui ressemble à ceci :

system(f'ssh-copy-id -i {self.ssh_key_filepath} \ {self.user}@{self.host}>/dev/null 2>&1')

Je suppose que vous ne me laisserez pas glisser ce /dev/null 2>&1 bit par vous ? Bien. Si vous devez le savoir, voici un gars sur StackOverflow qui l’explique mieux que moi :

> est pour la redirection /dev/null est un trou noir où toute donnée envoyée, sera rejetée. 2 est le descripteur de fichier pour l’erreur standard. > est pour la redirection. & est le symbole du descripteur de fichier (sans lui, le 1 suivant serait considéré comme un nom de fichier). 1 est le descripteur de fichier pour Standard O.

Donc, en gros, nous disons à notre serveur distant que nous lui donnons quelque chose, et il est du genre  » où est-ce que je mets ce truc ? « , ce à quoi nous répondons  » nulle part dans l’espace physique, car ce n’est pas un objet, mais plutôt un symbole éternel de notre amitié « . Notre hôte distant est alors inondé de gratitude et d’émotion, car oui, les ordinateurs ont des émotions, mais nous ne pouvons pas être dérangés par cela pour le moment.

Connexion à notre client

Nous allons ajouter une méthode à notre client appelée connect() pour gérer la connexion à notre hôte :

...class RemoteClient: """Client to interact with a remote host via SSH & SCP.""" ... def _connect(self): """Open connection to remote host.""" if self.conn is None: try: self.client = SSHClient() self.client.load_system_host_keys() self.client.set_missing_host_key_policy(AutoAddPolicy()) self.client.connect( self.host, username=self.user, key_filename=self.ssh_key_filepath, look_for_keys=True, timeout=5000 ) self.scp = SCPClient(self.client.get_transport()) except AuthenticationException as error: logger.error(f'Authentication failed: \ did you remember to create an SSH key? {error}') raise error return self.client
client.py

Décomposons cela:

  • client = SSHClient() prépare le terrain pour créer un objet représentant notre client SSH. Les lignes suivantes vont configurer cet objet pour le rendre plus utile.
  • load_system_host_keys() demande à notre client de rechercher tous les hôtes auxquels nous nous sommes connectés dans le passé en regardant le fichier known_hosts de notre système et en trouvant les clés SSH que notre hôte attend. Nous ne nous sommes jamais connectés à notre hôte dans le passé, nous devons donc spécifier notre clé SSH explicitement.
  • set_missing_host_key_policy() indique à Paramiko ce qu’il faut faire en cas de paire de clés inconnue. Ceci attend une « politique » intégrée à Paramiko, à laquelle nous allons spécifier AutoAddPolicy(). Définir notre politique à « auto-add » signifie que si nous tentons de nous connecter à un hôte non reconnu, Paramiko ajoutera automatiquement la clé manquante localement.
  • connect() est la méthode la plus importante de SSHClient (comme vous pouvez l’imaginer). Nous sommes enfin en mesure de passer notre hôte, notre utilisateur et notre clé SSH pour obtenir ce que nous attendions tous : une glorieuse connexion SSH à notre serveur ! La méthode connect() permet une tonne de flexibilité via une vaste gamme d’arguments de mots-clés optionnels également. Il se trouve que j’en passe quelques-uns ici : définir look_for_keys à True donne à Paramiko la permission de regarder dans notre dossier ~/.ssh pour découvrir les clés SSH par lui-même, et définir timeout fermera automatiquement les connexions que nous oublierons probablement de fermer. Nous pourrions même passer des variables pour des choses comme le port et le mot de passe, si nous avions choisi de nous connecter à notre hôte de cette façon.

Déconnexion

Nous devrions fermer les connexions à notre hôte distant dès que nous avons fini de les utiliser. Ne pas le faire ne serait pas nécessairement désastreux, mais j’ai eu quelques cas où suffisamment de connexions suspendues finissaient par maximiser le trafic entrant sur le port 22. Indépendamment du fait que votre cas d’utilisation puisse considérer un redémarrage comme un désastre ou un léger désagrément, fermons nos fichues connexions comme des adultes, comme si nous nous essuyions les fesses après avoir fait caca. Quelle que soit votre hygiène de connexion, je préconise de définir une variable de timeout (comme nous l’avons vu précédemment). Bref, voilà :

class RemoteClient: ... def disconnect(self): """Close ssh connection.""" if self.client: self.client.close() if self.scp: self.scp.close()
client.py

Fun fact : le réglage de self.client.close() met en fait self.client à égalité avec None, ce qui est utile dans les cas où vous pourriez vouloir vérifier si une connexion est déjà ouverte.

Exécution de commandes Unix

Nous avons maintenant une merveilleuse classe Python qui peut trouver des clés RSA, se connecter et se déconnecter. Il lui manque cependant la capacité de faire, eh bien, quoi que ce soit d’utile.

Nous pouvons corriger cela et enfin commencer à faire des  » trucs  » avec une toute nouvelle méthode pour exécuter des commandes, que je vais judicieusement baptiser execute_commands() (c’est exact,  » commandes  » comme dans potentiellement plus qu’une, nous en parlerons dans un moment). Le travail de fond de tout cela est effectué par la méthode intégrée du client Paramiko exec_command(), qui accepte une seule chaîne de caractères comme commande et l’exécute :

class RemoteClient: ... def execute_commands(self, commands): """ Execute multiple commands in succession. :param commands: List of unix commands as strings. :type commands: List """ self.conn = self._connect() for cmd in commands: stdin, stdout, stderr = self.client.exec_command(cmd) stdout.channel.recv_exit_status() response = stdout.readlines() for line in response: logger.info(f'INPUT: {cmd} | OUTPUT: {line}')
client.py

La fonction que nous venons de créer execute_commands() attend une liste de chaînes à exécuter comme commandes. C’est en partie pour des raisons de commodité, mais c’est aussi parce que Paramiko n’exécutera aucun changement d' »état » (comme le changement de répertoire) entre les commandes, donc chaque commande que nous passons à Paramiko doit supposer que nous travaillons à partir de la racine de notre serveur. J’ai pris la liberté de passer trois de ces commandes comme ceci:

remote.execute_commands()
__init__.py

Je peux afficher le contenu d’un répertoire en enchaînant cd path/to/dir && ls, mais l’exécution de cd path/to/dir suivie de ls aboutirait au néant car ls la deuxième fois renvoie la liste des fichiers de la racine de notre serveur.

Vous remarquerez que client.exec_command(cmd) renvoie trois valeurs plutôt qu’une : cela peut être utile pour voir quelle entrée a produit quelle sortie. Par exemple, voici les journaux complets pour l’exemple que j’ai fourni où j’ai passé trois commandes à remote.execute_commands():

2020-01-02 23:20:16.103 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: django2020-01-02 23:20:16.103 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: ghost2020-01-02 23:20:16.103 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: hackers-hbs2020-01-02 23:20:16.103 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: html2020-01-02 23:20:16.104 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: hustlers2020-01-02 23:20:16.104 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: pizza2020-01-02 23:20:16.104 | paramiko_tutorial.client:execute_cmd:95 - INPUT: cd /var/www/ && ls | OUTPUT: toddbirchard2020-01-02 23:20:16.196 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 134.209.37.49 - - "GET / HTTP/2.0" 404 139 "-" "Mozilla/5.0 (compatible; NetcraftSurveyAgent/1.0; [email protected])"2020-01-02 23:20:16.196 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 54.36.148.187 - - "GET /robots.txt HTTP/1.1" 404 149 "-" "Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)"2020-01-02 23:20:16.196 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 54.36.150.104 - - "GET / HTTP/1.1" 404 139 "-" "Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)"2020-01-02 23:20:16.196 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 46.229.168.146 - - "GET /robots.txt HTTP/1.1" 200 92 "-" "Mozilla/5.0 (compatible; SemrushBot/6~bl; +http://www.semrush.com/bot.html)"2020-01-02 23:20:16.197 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 46.229.168.153 - - "GET /the-art-of-technical-documentation/ HTTP/1.1" 200 9472 "-" "Mozilla/5.0 (compatible; SemrushBot/6~bl; +http://www.semrush.com/bot.html)"2020-01-02 23:20:16.197 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 157.55.39.171 - - "GET /robots.txt HTTP/1.1" 200 94 "-" "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"2020-01-02 23:20:16.197 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 40.77.167.220 - - "GET / HTTP/1.1" 200 3791 "-" "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"2020-01-02 23:20:16.197 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 54.36.150.64 - - "GET /the-ruin-of-feeling-powerless/ HTTP/2.0" 200 9605 "-" "Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)"2020-01-02 23:20:16.197 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 54.36.150.187 - - "GET /bigquery-and-sql-databases/ HTTP/2.0" 404 146 "-" "Mozilla/5.0 (compatible; AhrefsBot/6.1; +http://ahrefs.com/robot/)"2020-01-02 23:20:16.197 | paramiko_tutorial.client:execute_cmd:95 - INPUT: tail /var/log/nginx/access.log | OUTPUT: 46.229.168.149 - - "GET /robots.txt HTTP/1.1" 502 182 "-" "Mozilla/5.0 (compatible; SemrushBot/6~bl; +http://www.semrush.com/bot.html)"2020-01-02 23:20:16.322 | paramiko_tutorial.client:execute_cmd:95 - INPUT: ps aux | grep node | OUTPUT: ghost 1354 39.2 3.4 1223844 140172 ? Sl 04:20 0:05 /usr/bin/node current/index.js2020-01-02 23:20:16.323 | paramiko_tutorial.client:execute_cmd:95 - INPUT: ps aux | grep node | OUTPUT: ghost 1375 36.8 3.3 1217696 135548 ? Sl 04:20 0:04 /usr/bin/node current/index.js2020-01-02 23:20:16.323 | paramiko_tutorial.client:execute_cmd:95 - INPUT: ps aux | grep node | OUTPUT: ghost 1395 46.8 3.6 1229824 147384 ? Sl 04:20 0:06 /usr/bin/node current/index.js2020-01-02 23:20:16.323 | paramiko_tutorial.client:execute_cmd:95 - INPUT: ps aux | grep node | OUTPUT: ghost 1410 37.7 3.2 1216320 132912 ? Sl 04:20 0:04 /usr/bin/node current/index.js2020-01-02 23:20:16.323 | paramiko_tutorial.client:execute_cmd:95 - INPUT: ps aux | grep node | OUTPUT: root 1848 0.0 0.0 13312 3164 ? Ss 04:20 0:00 bash -c ps aux | grep node2020-01-02 23:20:16.323 | paramiko_tutorial.client:execute_cmd:95 - INPUT: ps aux | grep node | OUTPUT: root 1850 0.0 0.0 14856 1104 ? S 04:20 0:00 grep node
Output

De belles choses ici. Maintenant, vous pouvez voir quels sites sont sur mon serveur, quels bots me spamment et combien de processus de nœuds je fais tourner.

Je ne veux pas perdre beaucoup plus de temps sur l’art d’exécuter des commandes, mais il faut mentionner la présence de la raison pour laquelle nous appelons stdout.channel.recv_exit_status() après chaque commande. Le fait d’attendre que recv_exit_status() revienne après l’exécution de client.exec_command() force nos commandes à être exécutées de manière synchrone, sinon il y a de fortes chances que notre machine distante ne soit pas en mesure de déchiffrer les commandes aussi rapidement que nous les transmettons.

Chargement (et téléchargement) de fichiers via SCP

SCP désigne à la fois le protocole de copie de fichiers vers des machines distantes (secure copy protocol) ainsi que la bibliothèque Python, qui l’utilise. Nous avons déjà installé la bibliothèque SCP, alors importez cette merde.

Les bibliothèques SCP et Paramiko se complètent pour rendre le téléchargement via SCP super facile. SCPClient() crée un objet qui attend « transport » de Paramiko, que nous fournissons avec self.conn.get_transport(). La création d’une connexion SCP s’inspire de notre client SSH en termes de syntaxe, mais ces connexions sont distinctes. Il est possible de fermer une connexion SSH et de laisser une connexion SCP ouverte, alors ne le faites pas. Ouvrez une connexion SCP comme ceci :

self.scp = SCPClient(self.client.get_transport())
Ouvrir une connexion SCP.

Télécharger un seul fichier est ennuyeux, alors téléchargeons plutôt un répertoire entier de fichiers. bulk_upload() accepte une liste de chemins de fichiers, puis appelle __upload_single_file()

class RemoteClient: ... def bulk_upload(self, files): """ Upload multiple files to a remote directory. :param files: List of paths to local files. :type files: List """ self.conn = self._connect() uploads = logger.info(f'Finished uploading {len(uploads)} files to {self.remote_path} on {self.host}')
client.py

Notre méthode s’attend à recevoir deux chaînes de caractères : la première étant le chemin local de notre fichier, et la seconde étant le chemin du répertoire distant vers lequel nous souhaitons télécharger.

La méthode put() deSCP va télécharger un fichier local vers notre hôte distant. Cela remplacera les fichiers existants portant le même nom s’il se trouve qu’ils existent à la destination que nous avons spécifiée. C’est tout ce qu’il faut !

Téléchargement de fichiers

L’homologue de la méthode put() de SCP est la méthode get() :

class RemoteClient: ... def download_file(self, file): """Download file from remote host.""" if self.conn is None: self.conn = self.connect() self.scp.get(file)
client.py

Notre grand et beau script

Nous avons maintenant une classe Python malade pour gérer SSH et SCP avec un hôte distant… mettons-la au travail ! Le snippet suivant est un moyen rapide de tester ce que nous avons construit jusqu’à présent. En bref, ce script recherche un dossier local rempli de fichiers (dans mon cas, j’ai rempli le dossier avec des fox gifs 🦊).

Voyez comme il est facile de créer un main.py qui gère des tâches complexes sur des machines distantes grâce à notre classe RemoteClient :

"""Perform tasks against a remote host."""from config import ( host, user, ssh_key_filepath, local_file_directory, remote_path)from .files import fetch_local_filesfrom .client import RemoteClientdef main(): """Initialize remote host client and execute actions.""" remote = RemoteClient(host, user, ssh_key_filepath, remote_path) upload_files_to_remote(remote) execute_command_on_remote(remote) remote.disconnect()def upload_files_to_remote(remote): """Upload files to remote via SCP.""" local_files = fetch_local_files(local_file_directory) remote.bulk_upload(local_files)def execute_command_on_remote(remote): """Execute UNIX command on the remote host.""" remote.execute_cmd('cd /var/www/ghost ls')
__init__.py

Voici la sortie de notre fonction de téléchargement :

2020-01-03 00:00:27.215 | paramiko_tutorial.client:__upload_single_file:85 - Uploaded data/fox1.gif to /uploads/2020-01-03 00:00:27.985 | paramiko_tutorial.client:__upload_single_file:85 - Uploaded data/fox2.gif to /uploads/2020-01-03 00:00:30.015 | paramiko_tutorial.client:__upload_single_file:85 - Uploaded data/fox3.gif to /uploads/2020-01-03 00:00:30.015 | paramiko_tutorial.client:bulk_upload:73 - Finished uploading 3 files to /uploads/ on 149.433.117.1425
Foxes téléchargés avec succès

Cela a marché ! Vous ne me croyez pas ? Pourquoi ne pas vérifier par nous-mêmes en exécutant remote.execute_commands() ?

2020-01-03 00:08:55.955 | paramiko_tutorial.client:execute_commands:96 - INPUT: cd /uploads/ && ls | OUTPUT: fox1.gif2020-01-03 00:08:55.955 | paramiko_tutorial.client:execute_commands:96 - INPUT: cd /uploads/ && ls | OUTPUT: fox2.gif2020-01-03 00:08:55.956 | paramiko_tutorial.client:execute_commands:96 - INPUT: cd /uploads/ && ls | OUTPUT: fox3.gif
Sortie de cd /var/www/ && ls

Voilà. Tout droit sorti de la bouche du renard.

Take It And Run With It

C’est ici que je voudrais prendre un moment pour vous remercier tous, et m’excuser que vous soyez encore là. J’ai fait le serment d’arrêter de poster des tutoriels de plus de deux mille mots, et celui-ci cherche à pousser cinq mille mots d’absurdité. Je vais travailler là-dessus. Nouvelle année, nouveau moi.

Pour vous faciliter la tâche, j’ai téléchargé la source de ce tutoriel sur Github. N’hésitez pas à prendre cela et à courir avec ! Pour terminer, je vous laisse avec la viande et les pommes de terre de la classe Client que nous avons mise en place :

"""Client to handle connections and actions executed against a remote host."""from os import systemfrom paramiko import SSHClient, AutoAddPolicy, RSAKeyfrom paramiko.auth_handler import AuthenticationException, SSHExceptionfrom scp import SCPClient, SCPExceptionfrom .log import loggerclass RemoteClient: """Client to interact with a remote host via SSH & SCP.""" def __init__(self, host, user, ssh_key_filepath, remote_path): self.host = host self.user = user self.ssh_key_filepath = ssh_key_filepath self.remote_path = remote_path self.client = None self.scp = None self.conn = None self._upload_ssh_key() @logger.catch def _get_ssh_key(self): """ Fetch locally stored SSH key.""" try: self.ssh_key = RSAKey.from_private_key_file(self.ssh_key_filepath) logger.info(f'Found SSH key at self {self.ssh_key_filepath}') except SSHException as error: logger.error(error) return self.ssh_key @logger.catch def _upload_ssh_key(self): try: system(f'ssh-copy-id -i {self.ssh_key_filepath} {self.user}@{self.host}>/dev/null 2>&1') system(f'ssh-copy-id -i {self.ssh_key_filepath}.pub {self.user}@{self.host}>/dev/null 2>&1') logger.info(f'{self.ssh_key_filepath} uploaded to {self.host}') except FileNotFoundError as error: logger.error(error) @logger.catch def _connect(self): """Open connection to remote host. """ if self.conn is None: try: self.client = SSHClient() self.client.load_system_host_keys() self.client.set_missing_host_key_policy(AutoAddPolicy()) self.client.connect( self.host, username=self.user, key_filename=self.ssh_key_filepath, look_for_keys=True, timeout=5000 ) self.scp = SCPClient(self.client.get_transport()) except AuthenticationException as error: logger.error(f'Authentication failed: did you remember to create an SSH key? {error}') raise error return self.client def disconnect(self): """Close ssh connection.""" if self.client: self.client.close() if self.scp: self.scp.close() @logger.catch def bulk_upload(self, files): """ Upload multiple files to a remote directory. :param files: List of paths to local files. :type files: List """ self.conn = self._connect() uploads = logger.info(f'Finished uploading {len(uploads)} files to {self.remote_path} on {self.host}') def _upload_single_file(self, file): """Upload a single file to a remote directory.""" upload = None try: self.scp.put( file, recursive=True, remote_path=self.remote_path ) upload = file except SCPException as error: logger.error(error) raise error finally: logger.info(f'Uploaded {file} to {self.remote_path}') return upload def download_file(self, file): """Download file from remote host.""" self.conn = self._connect() self.scp.get(file) @logger.catch def execute_commands(self, commands): """ Execute multiple commands in succession. :param commands: List of unix commands as strings. :type commands: List """ self.conn = self._connect() for cmd in commands: stdin, stdout, stderr = self.client.exec_command(cmd) stdout.channel.recv_exit_status() response = stdout.readlines() for line in response: logger.info(f'INPUT: {cmd} | OUTPUT: {line}')
client.py

Le code source complet de ce tutoriel se trouve ici :

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP en Python avec Paramiko. Contribuez au développement de hackersandslackers/paramiko-tutorial en créant un compte sur GitHub.
GitHubhackersandslackers

Todd. Avatar de Birchard's avatar
Dodd Birchard 123 Posts
New York City

SiteGithubTwitter

Ingénieur avec une crise d’identité permanente. Casse tout avant d’apprendre les meilleures pratiques. Tout à fait normal et émotionnellement stable.

.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *