Articles

SSH & SCP in Python mit Paramiko

Posted on
19 min read
January 03

SSH SCP in Python mit Paramiko

Cloud-Anbieter machen seit Jahren ein Vermögen mit sauber verpackten Managed Services. Ob Datenbanken oder Message-Broker, Entwickler wie wir scheinen kein Problem damit zu haben, ein bisschen mehr zu bezahlen, damit man sich um die Dinge kümmert. Aber Moment, sind wir nicht normalerweise die letzten, die sich für weniger Optimierung und weniger Kontrolle entscheiden? Warum ist das der Zeitpunkt, an dem wir uns anders entscheiden? Wenn ich eine Vermutung anstellen müsste, würde ich darauf wetten, dass es zum Teil daran liegt, dass serverseitige DevOps irgendwie scheiße sind.

Als Entwickler ist das Konfigurieren oder Debuggen eines VPS in der Regel Arbeit, die nicht einkalkuliert ist, und es ist nicht besonders lohnend. Im besten Fall wird Ihre Anwendung am Ende wahrscheinlich genauso laufen wie Ihre lokale Umgebung. Wie könnten wir diesen unvermeidlichen Teil unserer Arbeit besser machen? Nun, wir könnten es automatisieren.

Paramiko und SCP sind zwei Python-Bibliotheken, die wir zusammen verwenden können, um Aufgaben zu automatisieren, die wir auf einem entfernten Host ausführen möchten, wie das Neustarten von Diensten, das Durchführen von Aktualisierungen oder das Abrufen von Protokolldateien. Wir werden uns ansehen, wie das Skripting mit diesen Bibliotheken aussieht. Ich warne Sie vor: Es gibt eine beträchtliche Menge an Code in diesem Tutorial, was dazu führt, dass ich so aufgeregt bin, dass ich andere zu meinen manischen Code-Tutorial-Episoden zwinge. Wenn Sie anfangen, sich verloren zu fühlen, finden Sie das vollständige Repo hier:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP in Python mit Paramiko. Tragen Sie zur Entwicklung von hackersandslackers/paramiko-tutorial bei, indem Sie ein Konto auf GitHub anlegen.
GitHubhackersandslackers

Paramiko lehnt sich stark an die „in-the-weeds“ Seite der Python-Bibliotheken an. Wenn Sie etwas Einfaches suchen, das einfach nur den Job erledigen kann, ist pyinfra angeblich eine großartige (und einfache) Alternative.

Einrichten von SSH-Schlüsseln

Um eine SSH-Verbindung zu authentifizieren, müssen wir einen privaten RSA SSH-Schlüssel einrichten (nicht zu verwechseln mit OpenSSH). Mit folgendem Befehl können wir einen Schlüssel erzeugen:

$ ssh-keygen -t rsa
Erzeugen Sie einen RSA-Schlüssel

Dieser Befehl fordert uns auf, einen Namen für unseren Schlüssel anzugeben. Nennen Sie ihn, wie Sie wollen:

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

Als Nächstes werden Sie aufgefordert, ein Kennwort einzugeben (Sie können dieses leer lassen).

Nun, da wir unseren Schlüssel haben, müssen wir diesen auf unseren Remote-Host kopieren. Am einfachsten geht das mit ssh-copy-id:

$ ssh-copy-id -i ~/.ssh/mykey username@my_remote_host.org
Schlüssel auf entfernten Host kopieren

Überprüfen unseres SSH-Schlüssels

Wenn Sie überprüfen möchten, welche Schlüssel Sie bereits haben, finden Sie diese im .ssh Verzeichnis Ihres Systems:

$ cd ~/.ssh
Überprüfen Sie /.ssh-Verzeichnis

Wir suchen nach Schlüsseln, die mit dem folgenden Header beginnen:

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

Fühlen Sie sich frei, das gleiche auf Ihrer FPS zu tun.

Start unseres Scripts

Lassen Sie uns unsere Bibliotheken installieren. Starten Sie die virtuelle Umgebung, die Sie bevorzugen, und lassen Sie es krachen:

$ pip3 install paramiko scp
Installieren Sie paramiko & scp

Nur noch eine Sache, bevor wir sinnvollen Python-Code schreiben! Erstellen Sie eine Konfigurationsdatei, die die Variablen enthält, die wir für die Verbindung mit unserem Host benötigen. Hier sind die Grundzüge dessen, was wir für unseren Server benötigen:

  • Host: Die IP-Adresse oder URL des Remote-Hosts, auf den wir zugreifen möchten.
  • Benutzername: Dies ist der Benutzername, den Sie für die SSH-Verbindung zu Ihrem Server verwenden.
  • Passphrase (optional): Wenn Sie bei der Erstellung Ihres SSH-Schlüssels eine Passphrase angegeben haben, geben Sie diese hier an. Denken Sie daran, dass die Passphrase Ihres SSH-Schlüssels nicht mit dem Passwort Ihres Benutzers identisch ist.
  • SSH-Schlüssel: Der Dateipfad des Schlüssels, den wir zuvor erstellt haben. Unter OSX befindet sich dieser im Ordner ~/.ssh Ihres Systems. Der SSH-Schlüssel, den wir anvisieren, muss einen zugehörigen Schlüssel mit der Dateierweiterung .pub haben. Dies ist unser öffentlicher Schlüssel; wenn Sie früher mitgemacht haben, sollte dieser bereits für Sie generiert worden sein.

Wenn Sie versuchen, Dateien von Ihrem Remote-Host hoch- oder herunterzuladen, müssen Sie zwei weitere Variablen angeben:

  • Remote Path: Der Pfad zum Remote-Verzeichnis, das wir für die Dateiübertragung anvisieren. Wir können entweder Dinge in diesen Ordner hochladen oder den Inhalt herunterladen.
  • Lokaler Pfad: Die gleiche Idee wie oben, aber in umgekehrter Reihenfolge. Der Einfachheit halber ist der lokale Pfad, den wir verwenden werden, einfach /data und enthält Bilder von niedlichen Fuchs-Gifs.

Nun haben wir alles, was wir brauchen, um eine respektable config.py-Datei zu erstellen:

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

Erstellen eines SSH-Clients

Wir werden eine Klasse namens RemoteClient erstellen, um die Interaktionen mit unserem entfernten Host zu behandeln. Bevor wir uns zu sehr ins Zeug legen, beginnen wir einfach damit, die Klasse RemoteClient mit den Variablen zu instanziieren, die wir in config.py erstellt haben:

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

Bislang nichts Beeindruckendes: Wir haben nur ein paar Variablen gesetzt und sie an eine nutzlose Klasse übergeben. Lassen Sie uns die Dinge ein wenig vorantreiben, ohne unseren Konstruktor zu verlassen:

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

Wir haben drei neue Dinge hinzugefügt, die mit unserer Klasse instanziiert werden:

  • self.client: self.client wird letztlich als Verbindungseinwand in unserer Klasse dienen, ähnlich wie man in Datenbankbibliotheken mit Begriffen wie conn umgegangen ist. Unsere Verbindung wird None sein, bis wir uns explizit mit unserem Remote-Host verbinden.
  • self.scp: Ähnlich wie self.client, behandelt aber ausschließlich Verbindungen zum Übertragen von Dateien.
  • self.__upload_ssh_key() ist keine Variable, sondern eine Funktion, die automatisch ausgeführt wird, wenn unser Client instanziert wird. Der Aufruf von __upload_ssh_key() teilt unserem RemoteClient-Objekt mit, dass es sofort bei der Erstellung nach lokalen ssh-Schlüsseln suchen soll, damit wir versuchen können, diese an unseren entfernten Host zu übergeben. Andernfalls wären wir gar nicht in der Lage, eine Verbindung aufzubauen.

SSH-Schlüssel auf einen entfernten Host hochladen

Wir sind an dem Abschnitt dieser Übung angelangt, an dem wir einen verheerend unrühmlichen Boilerplate-Code herausschlagen müssen. Das ist typischerweise der Punkt, an dem emotional unterlegene Individuen der schieren, dumpfen Obskurität des Verständnisses von SSH-Schlüsseln und der Aufrechterhaltung von Verbindungen erliegen. Machen Sie keinen Fehler: Authentifizierung und Verwaltung von Verbindungen zu irgendetwas programmatisch ist überwältigend langweilig… es sei denn, Ihr Reiseführer ist zufällig ein bezaubernder Wortschöpfer, der als Ihr liebevoller Beschützer durch die gefährliche Unklarheit dient. Manche Leute nennen diesen Beitrag ein Tutorial. Ich habe vor, es Kunst zu nennen.

RemoteClient wird mit zwei privaten Methoden beginnen: __get_ssh_key() und __upload_ssh_key(). Erstere holt einen lokal gespeicherten öffentlichen Schlüssel ab, und letztere übergibt im Erfolgsfall diesen öffentlichen Schlüssel an unseren entfernten Host als Zugangsberechtigung. Sobald ein lokal erzeugter öffentlicher Schlüssel auf einem entfernten Rechner existiert, wird dieser Rechner uns dann für immer unsere Anfragen zur Verbindung mit ihm anvertrauen: keine Passwörter erforderlich. Wir werden auf dem Weg dorthin eine ordentliche Protokollierung einbauen, nur für den Fall, dass wir auf Probleme stoßen:

"""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() ist ganz einfach: Es prüft, ob ein SSH-Schlüssel in dem Pfad existiert, den wir in unserer Config angegeben haben und der für die Verbindung zu unserem Host verwendet werden soll. Wenn die Datei tatsächlich existiert, setzen wir fröhlich unsere self.ssh_key-Variable, so dass dieser Schlüssel von nun an hochgeladen und von unserem Client verwendet werden kann. Paramiko stellt uns ein Submodul namens RSAKey zur Verfügung, um alles, was mit RSA-Schlüsseln zu tun hat, einfach zu handhaben, wie z.B. das Parsen einer privaten Schlüsseldatei in eine brauchbare Verbindungsauthentifizierung. Das bekommen wir hier:

 RSAKey.from_private_key_file(self.ssh_key_filepath)
RSA-Schlüssel aus lokaler Datei lesen.

Wenn unser RSA-Schlüssel unverständlicher Unsinn statt eines echten Schlüssels wäre, hätte Paramikos SSHException dies erkannt und frühzeitig eine Exception ausgelöst, die genau das erklärt. Die richtige Nutzung der Fehlerbehandlung einer Bibliothek nimmt eine Menge Rätselraten aus der Frage heraus, was schief gelaufen ist, besonders in Fällen, in denen es das Potenzial für zahlreiche Unbekannte in einem Nischenbereich gibt, mit dem keiner von uns oft zu tun hat.

_upload_ssh_key() ist der Punkt, an dem wir unseren SSH-Schlüssel in die Kehle unseres entfernten Servers stopfen können, während wir schreien: „LOOK! IHR KÖNNT MIR JETZT FÜR IMMER VERTRAUEN!“ Um dies zu erreichen, gehe ich ein bisschen „old school“ vor, indem ich Bash-Befehle über das os.system von Python übergebe. Solange mich niemand in den Kommentaren auf einen saubereren Ansatz aufmerksam macht, gehe ich davon aus, dass dies die knallharte Art ist, Schlüssel an einen entfernten Server zu übergeben.

Die standardmäßige Nicht-Python-Methode zur Übergabe von Schlüsseln an einen Host sieht wie folgt aus:

ssh-copy-id -i ~/.ssh/mykey user@host
SSSH-Schlüssel an entfernten Host übergeben

Das ist genau das, was wir in unserer Funktion in Python erreichen, die wie folgt aussieht:

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

Ich nehme an, Sie lassen sich diesen /dev/null 2>&1 Teil nicht entgehen? Na gut. Wenn Sie es wissen müssen, hier ist ein Typ auf StackOverflow, der es besser erklärt, als ich es kann:

> ist für Redirect /dev/null ist ein schwarzes Loch, wo alle Daten, die gesendet werden, verworfen werden. 2 ist der Dateideskriptor für Standardfehler. > ist für den Redirect. & ist das Symbol für den Dateideskriptor (ohne es würde das folgende 1 als Dateiname gelten). 1 ist der Dateideskriptor für Standard O.

Wir sagen also im Grunde unserem entfernten Server, dass wir ihm etwas geben, und er fragt: „Wo soll ich das Ding hinstellen?“, worauf wir antworten: „Nirgendwo im physischen Raum, denn dies ist kein Objekt, sondern ein ewiges Symbol unserer Freundschaft. Unser entfernter Gastgeber wird daraufhin von Dankbarkeit und Emotionen überflutet, denn ja, Computer haben Emotionen, aber das kann uns im Moment nicht stören.

Verbinden mit unserem Client

Wir fügen unserem Client eine Methode namens connect() hinzu, um die Verbindung mit unserem Host zu handhaben:

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

Schauen wir uns das mal genauer an:

  • client = SSHClient() legt die Grundlage für die Erstellung eines Objekts, das unseren SSH-Client repräsentiert. Die folgenden Zeilen konfigurieren dieses Objekt, um es nützlicher zu machen.
  • load_system_host_keys() weist unseren Client an, nach allen Hosts zu suchen, mit denen wir uns in der Vergangenheit verbunden haben, indem er die Datei known_hosts unseres Systems durchsucht und die SSH-Schlüssel findet, die unser Host erwartet. Wir haben uns in der Vergangenheit nie mit unserem Host verbunden, also müssen wir unseren SSH-Schlüssel explizit angeben.
  • set_missing_host_key_policy() sagt Paramiko, was im Falle eines unbekannten Schlüsselpaares zu tun ist. Dies erwartet eine in Paramiko eingebaute „Richtlinie“, der wir ein bestimmtes AutoAddPolicy() zuweisen werden. Wenn wir unsere Richtlinie auf „auto-add“ setzen, bedeutet das, dass Paramiko automatisch den fehlenden Schlüssel lokal hinzufügt, wenn wir versuchen, uns mit einem nicht erkannten Host zu verbinden.
  • connect() ist die wichtigste Methode des SSHClient (wie Sie sich vielleicht vorstellen können). Wir sind endlich in der Lage, unseren Host, Benutzer und SSH-Schlüssel zu übergeben, um das zu erreichen, worauf wir alle gewartet haben: eine glorreiche SSH-Verbindung zu unserem Server! Die connect()-Methode erlaubt eine Menge Flexibilität durch eine große Anzahl von optionalen Schlüsselwortargumenten. Ich übergebe hier zufällig ein paar: Das Setzen von look_for_keys auf True gibt Paramiko die Erlaubnis, in unserem ~/.ssh-Ordner nach SSH-Schlüsseln zu suchen, und das Setzen von timeout wird automatisch Verbindungen schließen, die wir wahrscheinlich vergessen werden zu schließen. Wir könnten sogar Variablen für Dinge wie Port und Passwort übergeben, wenn wir uns auf diese Weise mit unserem Host verbinden würden.

Verbindung trennen

Wir sollten Verbindungen zu unserem entfernten Host schließen, wenn wir sie nicht mehr benutzen. Dies nicht zu tun, muss nicht unbedingt katastrophal sein, aber ich hatte schon ein paar Fälle, in denen genügend hängende Verbindungen schließlich den eingehenden Verkehr auf Port 22 ausreizten. Unabhängig davon, ob Ihr Anwendungsfall einen Neustart als Katastrophe oder milde Unannehmlichkeit ansieht, sollten wir unsere verdammten Verbindungen wie Erwachsene schließen, als ob wir uns nach dem Kacken den Hintern abwischen würden. Unabhängig von Ihrer Verbindungshygiene plädiere ich dafür, eine Timeout-Variable zu setzen (wie wir bereits gesehen haben). Wie auch immer: voila:

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

Spaßfakt: Das Setzen von self.client.close() setzt self.client tatsächlich gleich None, was in Fällen nützlich ist, in denen Sie prüfen wollen, ob eine Verbindung bereits offen ist.

Unix-Befehle ausführen

Wir haben jetzt eine wunderbare Python-Klasse, die RSA-Schlüssel finden, sich verbinden und die Verbindung trennen kann. Allerdings fehlt ihr die Fähigkeit, etwas Nützliches zu tun.

Wir können das beheben und endlich anfangen, „Dinge“ zu tun, und zwar mit einer brandneuen Methode zum Ausführen von Befehlen, die ich treffend execute_commands() nenne (das ist korrekt, „Befehle“ im Sinne von potenziell-mehr-als-einer, darauf gehen wir gleich noch ein). Die ganze Arbeit wird von der eingebauten exec_command()-Methode des Paramiko-Clients erledigt, die einen einzelnen String als Befehl akzeptiert und ausführt:

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

Die gerade erstellte Funktion execute_commands() erwartet eine Liste von Strings, die als Befehle ausgeführt werden sollen. Das ist zum Teil der Bequemlichkeit halber, aber auch, weil Paramiko zwischen den Befehlen keine „Zustands“-Änderungen (wie z.B. das Ändern von Verzeichnissen) durchführt, so dass jeder Befehl, den wir an Paramiko übergeben, davon ausgehen sollte, dass wir vom Stammverzeichnis unseres Servers aus arbeiten. Ich habe mir die Freiheit genommen, drei solcher Befehle wie folgt zu übergeben:

remote.execute_commands()
__init__.py

Ich kann den Inhalt eines Verzeichnisses durch Verkettung von cd path/to/dir && ls anzeigen, aber die Ausführung von cd path/to/dir gefolgt von ls würde ins Leere laufen, weil ls beim zweiten Mal die Liste der Dateien im Stammverzeichnis unseres Servers zurückgibt.

Sie werden feststellen, dass client.exec_command(cmd) drei Werte anstelle von einem zurückgibt: Das kann nützlich sein, um zu sehen, welche Eingabe welche Ausgabe erzeugt hat. Hier sind zum Beispiel die vollständigen Protokolle für das von mir angegebene Beispiel, bei dem ich drei Befehle an remote.execute_commands() übergeben habe:

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

Ein paar schöne Sachen hier. Jetzt können Sie sehen, welche Sites auf meinem Server sind, welche Bots mich spammen und wie viele Node-Prozesse ich laufen habe.

Ich möchte nicht noch mehr Zeit auf die Kunst der Befehlsausführung verschwenden, aber es ist erwähnenswert, warum wir nach jedem Befehl stdout.channel.recv_exit_status() aufrufen. Das Warten auf recv_exit_status() nach dem Aufruf von client.exec_command() zwingt uns, unsere Befehle synchron auszuführen, da sonst die Wahrscheinlichkeit besteht, dass unser entfernter Rechner die Befehle nicht so schnell entschlüsseln kann, wie wir sie weitergeben.

Hochladen (und Herunterladen) von Dateien über SCP

SCP bezeichnet sowohl das Protokoll zum Kopieren von Dateien auf entfernte Rechner (Secure Copy Protocol) als auch die Python-Bibliothek, die dieses nutzt. Wir haben die SCP-Bibliothek bereits installiert, also importieren wir sie.

Die SCP- und Paramiko-Bibliotheken ergänzen sich gegenseitig und machen das Hochladen via SCP super einfach. SCPClient() erzeugt ein Objekt, das „transport“ von Paramiko erwartet, was wir mit self.conn.get_transport() bereitstellen. Das Erstellen einer SCP-Verbindung ist von der Syntax her an unseren SSH-Client angelehnt, aber diese Verbindungen sind getrennt. Es ist möglich, eine SSH-Verbindung zu schließen und eine SCP-Verbindung offen zu lassen, also tun Sie das nicht. Öffnen Sie eine SCP-Verbindung wie folgt:

self.scp = SCPClient(self.client.get_transport())
Öffnen Sie eine SCP-Verbindung.

Das Hochladen einer einzelnen Datei ist langweilig, also lassen Sie uns stattdessen ein ganzes Verzeichnis mit Dateien hochladen. bulk_upload() nimmt eine Liste von Dateipfaden entgegen und ruft dann __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

Unsere Methode erwartet zwei Zeichenketten: die erste ist der lokale Pfad zu unserer Datei, und die zweite ist der Pfad des entfernten Verzeichnisses, in das wir hochladen möchten.

SCP’s put() Methode wird eine lokale Datei auf unseren entfernten Host hochladen. Dadurch werden vorhandene Dateien mit demselben Namen ersetzt, falls sie zufällig am angegebenen Zielort existieren. Das war’s schon!

Dateien herunterladen

Das Gegenstück zu SCP’s put() ist die get() Methode:

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

Unser großes, schönes Skript

Wir haben jetzt eine kranke Python-Klasse, um SSH und SCP mit einem entfernten Host zu handhaben… setzen wir sie in die Tat um! Das folgende Snippet ist ein schneller Weg, um zu testen, was wir bisher gebaut haben. Kurz gesagt, dieses Skript sucht nach einem lokalen Ordner, der mit Dateien gefüllt ist (in meinem Fall habe ich den Ordner mit Fox-Gifs gefüllt 🦊).

Schauen Sie sich an, wie einfach es ist, eine main.py zu erstellen, die dank unserer RemoteClient-Klasse komplexe Aufgaben auf entfernten Rechnern erledigt:

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

Hier ist die Ausgabe unserer Upload-Funktion:

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 erfolgreich hochgeladen

Es hat funktioniert! Sie glauben mir nicht? Warum überprüfen wir es nicht selbst, indem wir remote.execute_commands() ausführen?

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
Ausgabe von cd /var/www/ && ls

Da haben Sie es. Direkt aus dem Mund des Fuchses.

Take It And Run With It

An dieser Stelle möchte ich mich kurz bei Ihnen allen bedanken und mich dafür entschuldigen, dass Sie noch hier sind. Ich habe mir geschworen, keine Tutorials mehr zu schreiben, die mehr als zweitausend Wörter lang sind, und dieses hier ist dabei, fünftausend Wörter Unsinn zu produzieren. Daran werde ich arbeiten. Neues Jahr, neues Ich.

Für Ihre Bequemlichkeit habe ich den Quellcode für dieses Tutorial auf Github hochgeladen. Fühlen Sie sich frei, diesen zu nehmen und damit zu arbeiten! Zum Abschluss möchte ich Ihnen noch den Kern der Client-Klasse zeigen, die wir zusammengestellt haben:

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

Den vollständigen Quellcode für dieses Tutorial finden Sie hier:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP in Python mit Paramiko. Tragen Sie zur Entwicklung von hackersandslackers/paramiko-tutorial bei, indem Sie ein Konto auf GitHub anlegen.
GitHubhackersandslackers
Todd Birchards Avatar's avatar
Todd Birchard 123 Beiträge
New York City

SiteGithubTwitter

Engineer mit einer andauernden Identitätskrise. Macht alles kaputt, bevor er Best Practices lernt. Völlig normal und emotional stabil.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.