Articles

SSH & SCP en Python con Paramiko

Posted on
19 min read
03 de enero

SSH SCP en Python con Paramiko

Los proveedores de la nube han hecho una fortuna con los servicios gestionados bien empaquetados durante años. Ya sea bases de datos o corredores de mensajes, los desarrolladores como nosotros no parecen tener un problema en pagar un poco más para tener las cosas cuidadas. Pero espera, ¿no somos normalmente los últimos en optar por menos optimización y menos control? ¿Por qué es este el momento en que decidimos lo contrario? Si tuviera que hacer una conjetura, apostaría a que es en parte porque el DevOps del lado del servidor apesta.

Como desarrollador, configurar o depurar un VPS suele ser un trabajo que no se tiene en cuenta, y no es particularmente gratificante. En el mejor de los casos, es probable que tu aplicación acabe funcionando igual que tu entorno local. ¿Cómo podríamos mejorar esta parte inevitable de nuestro trabajo? Bueno, podríamos automatizarla.

Paramiko y SCP son dos librerías de Python que podemos usar juntas para automatizar tareas que querríamos ejecutar en un host remoto como reiniciar servicios, hacer actualizaciones o coger archivos de registro. Vamos a echar un vistazo a lo que es el scripting con estas bibliotecas. Advertencia: hay una cantidad considerable de código en este tutorial, lo que tiende a excitarme lo suficiente como para coaccionar a otros en mis maníacos episodios de tutoriales de código. Si empiezas a sentirte perdido, el repo completo se puede encontrar aquí:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP en Python con Paramiko. Contribuye al desarrollo de hackersandslackers/paramiko-tutorial creando una cuenta en GitHub.
GitHubhackersandslackers

Paramiko se inclina mucho hacia el lado «en la maleza» de las librerías Python. Si estás buscando algo fácil que sólo pueda hacer el trabajo, pyinfra es supuestamente una gran (y fácil) alternativa.

Configuración de claves SSH

Para autenticar una conexión SSH, necesitamos configurar una clave privada RSA SSH (no confundir con OpenSSH). Podemos generar una clave utilizando el siguiente comando:

$ ssh-keygen -t rsa
Generar una clave RSA

Esto nos pedirá que proporcionemos un nombre para nuestra clave. Ponle el nombre que quieras:

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):
Pregunta RSA

A continuación, se nos pedirá una contraseña (no dudes en dejarla en blanco).

Ahora que tenemos nuestra clave, necesitamos copiarla a nuestro host remoto. La forma más sencilla de hacerlo es utilizando ssh-copy-id:

$ ssh-copy-id -i ~/.ssh/mykey username@my_remote_host.org
Copiar la clave al host remoto

Verificar nuestra clave SSH

Si quieres comprobar qué claves tienes ya, éstas se pueden encontrar en el directorio .ssh de tu sistema:

$ cd ~/.ssh
Comprueba el directorio /.ssh directory

Buscamos claves que empiecen por la siguiente cabecera:

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

Siéntete libre de hacer lo mismo en tu FPS.

Comenzando nuestro Script

Instalemos nuestras librerías. Arranca el entorno virtual que prefieras y déjalo correr:

$ pip3 install paramiko scp
Instalar paramiko & scp

¡Sólo una cosa más antes de escribir código Python con sentido! Crear un archivo de configuración para mantener las variables que necesitaremos para conectarnos a nuestro host. Aquí están los fundamentos de lo que necesitamos para entrar en nuestro servidor:

  • Host: La dirección IP o la URL del host remoto al que estamos intentando acceder.
  • Nombre de usuario: Este es el nombre de usuario que utilizas para acceder por SSH a tu servidor.
  • Frase de contraseña (opcional): Si especificó una frase de contraseña cuando creó su clave ssh, especifíquela aquí. Recuerde que la frase de contraseña de su clave SSH no es la misma que la contraseña de su usuario.
  • Clave SSH: La ruta del archivo de la clave que creamos anteriormente. En OSX, éstas viven en la carpeta ~/.ssh de tu sistema. La clave SSH a la que nos dirigimos debe tener una clave adjunta con una extensión de archivo .pub. Esta es nuestra clave pública; si usted estaba siguiendo a lo largo de antes, esto ya debería haber sido generado para usted.
  • Si usted está tratando de subir o descargar archivos de su host remoto, tendrá que incluir dos variables más:

    • Ruta remota: La ruta del directorio remoto al que queremos dirigirnos para las transferencias de archivos. Podemos subir cosas a esta carpeta o descargar su contenido.
    • Ruta local: La misma idea que la anterior, pero a la inversa. Para nuestra comodidad, la ruta local que usaremos es simplemente /data, y contiene imágenes de lindos gifs de zorros.
    • Ahora tenemos todo lo que necesitamos para hacer un archivo config.py respetable:

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

      Creando un cliente SSH

      Vamos a crear una clase llamada RemoteClient para manejar las interacciones que tendremos con nuestro host remoto. Antes de que nos pongamos demasiado elegantes, vamos a empezar las cosas instanciando la clase RemoteClient con las variables que creamos en 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

      Hasta ahora nada impresionante: sólo hemos establecido algunas variables y las hemos pasado a una clase inútil. Vamos a darle un empujón a las cosas sin salir de nuestro constructor:

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

      Hemos añadido tres cosas nuevas que se instancian con nuestra clase:

      • self.client: self.client servirá en última instancia como la objeción de conexión en nuestra clase, de forma similar a como se ha tratado la terminología como conn en las bibliotecas de bases de datos. Nuestra conexión será None hasta que nos conectemos explícitamente a nuestro host remoto.
      • self.scp: Similar a self.client, pero se encarga exclusivamente de las conexiones para la transferencia de archivos.
      • self.__upload_ssh_key() no es una variable, sino una función que se ejecutará automáticamente cada vez que se instancie nuestro cliente. Llamar a __upload_ssh_key() es decirle a nuestro objeto RemoteClient que busque las claves ssh locales inmediatamente después de la creación para que podamos intentar pasarlas a nuestro host remoto. De lo contrario, no podríamos establecer una conexión en absoluto.

      Carga de claves SSH a un host remoto

      Hemos llegado a la sección de este ejercicio en la que tenemos que derribar algo de código boilerplate devastadoramente inglorioso. Esto es típicamente donde los individuos emocionalmente inferiores sucumben a la pura oscuridad aburrida de entender las claves SSH y mantener las conexiones. No te equivoques: autenticar y gestionar conexiones a cualquier cosa mediante programación es abrumadoramente aburrido… a menos que tu guía turístico sea un encantador escritor, que te sirva de amoroso protector a través de la peligrosa oscuridad. Algunos llaman a este post un tutorial. Yo pretendo llamarlo arte.

      El RemoteClient comenzará con dos métodos privados: __get_ssh_key() y __upload_ssh_key(). El primero buscará una clave pública almacenada localmente, y si tiene éxito, el segundo entregará esta clave pública a nuestro host remoto como rama de olivo de acceso. Una vez que existe una clave pública creada localmente en una máquina remota, esa máquina confiará para siempre en nuestras solicitudes para conectarse a ella: no se necesitan contraseñas. Incluiremos un registro adecuado en el camino, por si acaso nos encontramos con algún problema:

      """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() es bastante simple: verifica que existe una clave SSH en la ruta que especificamos en nuestro config para ser usada para conectarse a nuestro host. Si el archivo existe, se activa nuestra variable self.ssh_key, para que esta clave pueda ser cargada y utilizada por nuestro cliente de aquí en adelante. Paramiko nos proporciona un submódulo llamado RSAKey para manejar fácilmente todas las cosas relacionadas con la clave RSA, como el análisis de un archivo de clave privada en una autenticación de conexión utilizable. Esto es lo que obtenemos aquí:

       RSAKey.from_private_key_file(self.ssh_key_filepath)
      Leer la clave RSA desde el archivo local.

      Si nuestra clave RSA fuera un sinsentido incomprensible en lugar de una clave real, la SSHException de Paramiko lo habría detectado y habría lanzado una excepción al principio explicando precisamente eso. La utilización adecuada del manejo de errores de una biblioteca elimina muchas de las conjeturas de «qué salió mal», especialmente en casos como cuando hay potencial para numerosas incógnitas en un espacio de nicho con el que ninguno de nosotros se mete a menudo.

      _upload_ssh_key() es donde llegamos a atascar nuestra clave SSH en la garganta de nuestro servidor remoto mientras gritamos, «¡Mira! AHORA PUEDES CONFIAR EN MÍ PARA SIEMPRE». Para lograr esto, voy un poco a la «vieja escuela» pasando comandos bash a través de os.system de Python. A menos que alguien me haga conocer un enfoque más limpio en los comentarios, asumiré que esta es la forma más badass de manejar el paso de claves a un servidor remoto.

      La forma estándar no Python de pasar claves a un host tiene este aspecto:

      ssh-copy-id -i ~/.ssh/mykey user@host
      Pasar la clave SSH al host remoto

      Esto es precisamente lo que conseguimos en nuestra función en Python, que tiene este aspecto:

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

Supongo que no me dejarás colar esa /dev/null 2>&1 parte. Bien. Si quieres saberlo, aquí tienes a un tipo en StackOverflow que lo explica mejor que yo:

> es para redirigir /dev/null es un agujero negro donde cualquier dato enviado, será descartado. 2 es el descriptor de archivo para el error estándar. > es para la redirección. & es el símbolo del descriptor de archivo (sin él, el siguiente 1 se consideraría un nombre de archivo). 1 es el descriptor de archivo para el estándar O.

Así que básicamente le estamos diciendo a nuestro servidor remoto que le estamos dando algo, y todo es como «¿dónde pongo esta cosa?», a lo que respondemos «en ningún lugar físico del espacio, ya que esto no es un objeto, sino un símbolo eterno de nuestra amistad». A continuación, nuestro anfitrión remoto se inunda de gratitud y emoción, porque sí, los ordenadores tienen emociones, pero no podemos molestarnos por eso ahora mismo.

Conectando con nuestro cliente

Añadiremos un método a nuestro cliente llamado connect() para gestionar la conexión con nuestro host:

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

Desglosemos esto:

  • client = SSHClient() establece el escenario para crear un objeto que represente a nuestro cliente SSH. Las siguientes líneas configurarán este objeto para hacerlo más útil.
  • load_system_host_keys() ordena a nuestro cliente que busque todos los hosts a los que nos hemos conectado en el pasado buscando en el archivo known_hosts de nuestro sistema y encontrando las claves SSH que espera nuestro host. Nunca nos hemos conectado a nuestro host en el pasado, por lo que necesitamos especificar nuestra clave SSH explícitamente.
  • set_missing_host_key_policy() le dice a Paramiko qué hacer en caso de un par de claves desconocido. Esto espera una «política» incorporada a Paramiko, a la que vamos a especificar AutoAddPolicy(). Configurar nuestra política como «auto-add» significa que si intentamos conectarnos a un host no reconocido, Paramiko añadirá automáticamente la clave que falta localmente.
  • connect() es el método más importante de SSHClient (como puedes imaginar). Por fin podemos pasar nuestro host, usuario y clave SSH para conseguir lo que todos estábamos esperando: ¡una gloriosa conexión SSH a nuestro servidor! El método connect() permite una gran flexibilidad a través de una amplia gama de argumentos opcionales. Sucede que paso algunos aquí: establecer look_for_keys a True le da a Paramiko permiso para buscar en nuestra carpeta ~/.ssh para descubrir claves SSH por su cuenta, y establecer timeout cerrará automáticamente las conexiones que probablemente olvidemos cerrar. Incluso podríamos pasar variables para cosas como el puerto y la contraseña, si hubiéramos elegido conectarnos a nuestro host de esta manera.

Desconectando

Deberíamos cerrar las conexiones a nuestro host remoto siempre que hayamos terminado de usarlas. No hacerlo puede no ser necesariamente desastroso, pero he tenido unos cuantos casos en los que suficientes conexiones colgantes acabaron por colapsar el tráfico entrante en el puerto 22. Independientemente de si su caso de uso puede considerar un reinicio como un desastre o un inconveniente leve, cerremos nuestras malditas conexiones como adultos, como si estuviéramos limpiando nuestros traseros después de hacer caca. No importa la higiene de tu conexión, abogo por establecer una variable de tiempo de espera (como vimos antes). En fin, voilá:

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

Dato divertido: poner self.client.close() en realidad pone self.client igual a None, lo cual es útil en casos en los que se quiera comprobar si una conexión ya está abierta.

Ejecución de comandos Unix

Ahora tenemos una maravillosa clase de Python que puede encontrar claves RSA, conectar y desconectar. Le falta la capacidad de hacer, bueno, cualquier cosa útil.

Podemos arreglar esto y finalmente empezar a hacer «cosas» con un nuevo método para ejecutar comandos, que llamaré apropiadamente execute_commands() (es correcto, «comandos» como en potencialmente-más-uno, vamos a tocar en un momento). El trabajo de todo esto lo hace el método incorporado del cliente de Paramiko exec_command(), que acepta una sola cadena como comando y la ejecuta:

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 función que acabamos de crear execute_commands() espera una lista de cadenas para ejecutar como comandos. Esto es en parte por conveniencia, pero también es porque Paramiko no ejecutará ningún cambio de «estado» (como el cambio de directorios) entre los comandos, por lo que cada comando que pasamos a Paramiko debe asumir que estamos trabajando fuera de la raíz de nuestro servidor. Me tomé la libertad de pasar tres de estos comandos así:

remote.execute_commands()
__init__.py

Puedo ver el contenido de un directorio encadenando cd path/to/dir && ls, pero ejecutar cd path/to/dir seguido de ls daría como resultado la nada porque ls la segunda vez devuelve la lista de archivos en la raíz de nuestro servidor.

Verás que client.exec_command(cmd) devuelve tres valores en lugar de uno: esto puede ser útil para ver qué entrada produjo qué salida. Por ejemplo, aquí están los registros completos para el ejemplo que proporcioné donde pasé tres comandos a 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
Salida

Algunas cosas hermosas aquí. Ahora puedes ver qué sitios están en mi servidor, qué bots me están haciendo spam y cuántos procesos de nodos estoy ejecutando.

No quiero perder mucho más tiempo en el arte de ejecutar comandos, pero vale la pena mencionar la presencia de por qué llamamos stdout.channel.recv_exit_status() después de cada comando. Esperar a que recv_exit_status() regrese después de ejecutar client.exec_command() obliga a que nuestros comandos se ejecuten de forma sincronizada, de lo contrario es probable que nuestra máquina remota no esté a punto de descifrar los comandos tan rápido como los pasamos.

Cargando (y descargando) archivos vía SCP

SCP se refiere tanto al protocolo para copiar archivos a máquinas remotas (secure copy protocol) como a la librería Python, que lo utiliza. Ya hemos instalado la librería SCP, así que importa esa mierda.

Las librerías SCP y Paramiko se complementan para hacer que subir archivos vía SCP sea súper fácil. SCPClient() crea un objeto que espera el «transporte» de Paramiko, que proporcionamos con self.conn.get_transport(). La creación de una conexión SCP se apoya en nuestro cliente SSH en términos de sintaxis, pero estas conexiones son independientes. Es posible cerrar una conexión SSH y dejar una conexión SCP abierta, así que no lo hagas. Abre una conexión SCP así:

self.scp = SCPClient(self.client.get_transport())
Abre una conexión SCP.

Subir un solo archivo es aburrido, así que subamos un directorio entero de archivos en su lugar. bulk_upload() acepta una lista de rutas de archivos, y luego llama a __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

Nuestro método espera recibir dos cadenas: la primera es la ruta local de nuestro archivo, y la segunda es la ruta del directorio remoto al que queremos subir.

El método put() de SCP subirá un archivo local a nuestro host remoto. Esto reemplazará los archivos existentes con el mismo nombre si es que existen en el destino que especificamos. ¡Eso es todo lo que se necesita!

Descargar archivos

La contraparte del método put() de SCP es el método 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

Nuestro gran y hermoso script

Ahora tenemos una clase Python enferma para manejar SSH y SCP con un host remoto… ¡pongámosla a trabajar! El siguiente fragmento es una forma rápida de probar lo que hemos construido hasta ahora. En resumen, este script busca una carpeta local llena de archivos (en mi caso, llené la carpeta con gifs de zorros 🦊).

Comprueba lo fácil que es crear un main.py que maneje tareas complejas en máquinas remotas gracias a nuestra clase 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

Aquí está la salida de la función de subida de nuestro producto:

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
Los zorros subieron con éxito

¡Funcionó! ¿No me creen? Por qué no lo comprobamos nosotros mismos ejecutando 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
Salida de cd /var/www/ && ls

Ahí lo tienes. Directamente de la boca del zorro.

Tómalo y corre con él

Aquí es donde me gustaría tomarme un momento para daros las gracias a todos, y pediros disculpas por seguir aquí. Me juré a mí mismo que dejaría de publicar tutoriales de más de dos mil palabras, y este está buscando empujar cinco mil palabras de tonterías. Trabajaré en ello. Año nuevo, yo nuevo.

Para tu comodidad, he subido la fuente de este tutorial a Github. ¡Siéntase libre de tomar esto y correr con él! Para terminar, os dejo con la carne y las patatas de la clase Cliente que hemos montado:

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

El código fuente completo de este tutorial se puede encontrar aquí:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP en Python con Paramiko. Contribuye al desarrollo de hackersandslackers/paramiko-tutorial creando una cuenta en GitHub.
GitHubhackersandslackers

Todd Birchard's avatar's avatar
Todd Birchard 123 Posts
Ciudad de Nueva York
SiteGithubTwitter

Ingeniero con una crisis de identidad continua. Rompe todo antes de aprender las mejores prácticas. Completamente normal y emocionalmente estable.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *