Articles

SSH & SCP in Python met Paramiko

Posted on
19 min gelezen
03 januari

SSH SCP in Python met Paramiko

Cloudproviders slaan al jaren hun slag met keurig verpakte managed services. Of het nu gaat om databases of message brokers, ontwikkelaars zoals wij lijken er geen probleem mee te hebben om een beetje extra te betalen om dingen geregeld te krijgen. Maar wacht eens, zijn wij niet typisch de laatste mensen die kiezen voor minder optimalisatie en minder controle? Waarom is dit het moment dat we anders beslissen? Als ik moet gokken, zou ik zeggen dat het deels komt omdat server-side DevOps een beetje zuigt.

Als ontwikkelaar is het configureren of debuggen van een VPS meestal werk waar geen rekening mee wordt gehouden, en het is niet bijzonder lonend. In het beste geval zal je applicatie waarschijnlijk uiteindelijk hetzelfde draaien als je lokale omgeving. Hoe kunnen we dit onvermijdelijke deel van ons werk beter maken?

Paramiko en SCP zijn twee Python bibliotheken die we samen kunnen gebruiken om taken te automatiseren die we op een remote host zouden willen uitvoeren, zoals het herstarten van services, het uitvoeren van updates, of het ophalen van log bestanden. We gaan eens kijken hoe scripting met deze bibliotheken eruit ziet. Eerlijke waarschuwing: er is een aanzienlijke hoeveelheid code in deze tutorial, wat me vaak enthousiast genoeg maakt om anderen mee te slepen in mijn manische code-tutorial episodes. Als je je verloren begint te voelen, kan de volledige repo hier gevonden worden:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP in Python met Paramiko. Draag bij aan de ontwikkeling van hackersandslackers/paramiko-tutorial door een account aan te maken op GitHub.
GitHubhackersandslackers
Paramiko leunt zwaar op de “in-the-weeds” kant van Python bibliotheken. Als je op zoek bent naar iets eenvoudigs dat gewoon de klus kan klaren, is pyinfra waarschijnlijk een geweldig (en eenvoudig) alternatief.

SSH sleutels instellen

Om een SSH verbinding te authenticeren, moeten we een private RSA SSH sleutel instellen (niet te verwarren met OpenSSH). We kunnen een sleutel genereren met het volgende commando:

$ ssh-keygen -t rsa
Generate an RSA key

Dit zal ons vragen om een naam voor onze sleutel op te geven. Geef hem een naam zoals u wilt:

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 prompt

Na de volgende stap wordt u gevraagd een wachtwoord op te geven (u kunt dit gerust leeg laten).

Nu we onze sleutel hebben, moeten we deze naar onze host op afstand kopiëren. De eenvoudigste manier om dit te doen is door gebruik te maken van ssh-copy-id:

$ ssh-copy-id -i ~/.ssh/mykey username@my_remote_host.org
Kopieer sleutel naar host op afstand

Verifiëren van onze SSH Sleutel

Als u wilt controleren welke sleutels u al heeft, kunt u deze vinden in de .ssh directory van uw systeem:

$ cd ~/.ssh
Check /.ssh directory

We zijn op zoek naar sleutels die beginnen met de volgende header:

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

Voel je vrij om hetzelfde te doen op je FPS.

Start ons script

Laten we onze bibliotheken installeren. Start de virtuele omgeving van je voorkeur en laat ze scheuren:

$ pip3 install paramiko scp
Installeer paramiko & scp

Nog één ding voordat we wat zinvolle Python-code schrijven! Maak een configuratiebestand met de variabelen die we nodig hebben om verbinding te maken met onze host. Dit zijn de basisgegevens die we nodig hebben om in onze server te komen:

  • Host: Het IP adres of de URL van de remote host die we proberen te benaderen.
  • Gebruikersnaam: Dit is de gebruikersnaam die u gebruikt om te SSH-en op uw server.
  • Passphrase (optioneel): Als u een passphrase hebt opgegeven toen u uw ssh-sleutel maakte, specificeert u die hier. Denk eraan dat de passphrase van uw SSH-sleutel niet hetzelfde is als het wachtwoord van uw gebruiker.
  • SSH-sleutel: Het bestandspad van de sleutel die we eerder hebben gemaakt. Op OSX staat deze in de ~/.ssh map van je systeem. De SSH-sleutel waar we ons op richten moet een begeleidende sleutel hebben met een .pub bestandsextensie. Dit is onze publieke sleutel; als je het eerder hebt gevolgd, zou deze al voor je gegenereerd moeten zijn.

Als je bestanden probeert te uploaden of downloaden van je host op afstand, moet je nog twee variabelen toevoegen:

  • Remote Path: Het pad naar de externe map die we willen gebruiken voor bestandsoverdracht. We kunnen dingen uploaden naar deze map of de inhoud ervan downloaden.
  • Lokaal pad: Hetzelfde idee als hierboven, maar dan omgekeerd. Voor ons gemak, het lokale pad dat we zullen gebruiken is simpelweg /data, en bevat plaatjes van schattige vossen gifs.

Nu hebben we alles wat we nodig hebben om een respectabel config.py bestand te maken:

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

Een SSH-client maken

We gaan een klasse maken met de naam RemoteClient om de interacties af te handelen die we met onze host op afstand zullen hebben. Voordat we te ingewikkeld gaan doen, beginnen we met het instantiëren van de RemoteClient-klasse met de variabelen die we in config.py hebben aangemaakt:

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

Niets indrukwekkends tot nu toe: we hebben alleen wat variabelen ingesteld en deze doorgegeven aan een nutteloze klasse. Laten we de zaken wat opvoeren zonder onze constructor te verlaten:

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

We hebben drie nieuwe dingen toegevoegd die met onze klasse moeten worden geïnstantieerd:

  • self.client: self.client zal uiteindelijk dienen als het verbindingsbezwaar in onze klasse, vergelijkbaar met hoe je in databasebibliotheken bent omgegaan met terminologie als conn. Onze verbinding zal None zijn totdat we expliciet verbinding maken met onze host op afstand.
  • self.scp: Vergelijkbaar met self.client, maar behandelt uitsluitend verbindingen voor het overbrengen van bestanden.
  • self.__upload_ssh_key() is geen variabele, maar eerder een functie die automatisch wordt uitgevoerd wanneer onze client wordt geïnstantieerd. Het aanroepen van __upload_ssh_key() vertelt ons RemoteClient object om te controleren op lokale ssh sleutels onmiddellijk na de creatie, zodat we kunnen proberen om ze door te geven aan onze remote host. Anders zouden we helemaal geen verbinding tot stand kunnen brengen.

Ssh-sleutels uploaden naar een host op afstand

We zijn bij het deel van deze oefening aangekomen waar we wat verwoestend roemloze boilerplate code eruit moeten slaan. Dit is typisch het punt waar emotioneel inferieure individuen bezwijken voor de duffe obscuriteit van het begrijpen van SSH sleutels en het onderhouden van verbindingen. Vergis je niet: authenticatie en het programmatisch beheren van verbindingen met wat dan ook is overweldigend saai… tenzij je gids toevallig een betoverende woordensmid is, die dient als je liefdevolle beschermer door de gevaarlijke obscuriteit. Sommige mensen noemen deze post een handleiding. Ik ben van plan het kunst te noemen.

RemoteClient begint met twee privé-methoden: __get_ssh_key() en __upload_ssh_key(). De eerste zal een lokaal opgeslagen publieke sleutel ophalen, en indien succesvol, zal de tweede deze publieke sleutel afleveren aan onze host op afstand als een olijftak van toegang. Zodra een lokaal aangemaakte publieke sleutel bestaat op een machine op afstand, zal die machine ons dan voor altijd onze verzoeken om er verbinding mee te maken toevertrouwen: geen wachtwoorden nodig. We zullen onderweg een logboek bijhouden, voor het geval we in de problemen komen:

"""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() is vrij eenvoudig: het controleert of er een SSH-sleutel bestaat op het pad dat we in onze config hebben opgegeven om te gebruiken voor de verbinding met onze host. Als het bestand inderdaad bestaat, stellen we gelukkig onze self.ssh_key variabele in, zodat deze sleutel kan worden geupload en gebruikt door onze client vanaf hier. Paramiko voorziet ons van een submodule genaamd RSAKey om gemakkelijk alle RSA sleutel gerelateerde zaken af te handelen, zoals het parsen van een private sleutel bestand in een bruikbare verbindings authenticatie. Dat is wat we hier krijgen:

 RSAKey.from_private_key_file(self.ssh_key_filepath)
Read RSA key from local file.

Als onze RSA-sleutel onbegrijpelijke onzin was in plaats van een echte sleutel, zou Paramiko’s SSHException dit hebben opgemerkt en al in een vroeg stadium een uitzondering hebben gemaakt waarin precies dat wordt uitgelegd. Goed gebruik maken van de foutafhandeling van een bibliotheek neemt veel giswerk weg van “wat ging er mis,” vooral in gevallen waar er potentieel is voor vele onbekenden in een niche waar we ons niet vaak mee bezighouden.

_upload_ssh_key() is waar we onze SSH sleutel in de keel van onze server op afstand kunnen duwen terwijl we roepen, “KIJK! JE KUNT ME NU VOOR ALTIJD VERTROUWEN!” Om dit te bereiken, ga ik een beetje “old school” door bash commando’s door te geven via Python’s os.system. Tenzij iemand me in de commentaren wijst op een schonere aanpak, neem ik aan dat dit de beste manier is om sleutels aan een server op afstand door te geven.

De standaard niet-Python manier om sleutels aan een host door te geven ziet er als volgt uit:

ssh-copy-id -i ~/.ssh/mykey user@host
SH-sleutel doorgeven aan host op afstand

Dit is precies wat we bereiken in onze functie in Python, die er als volgt uitziet:

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

Ik neem aan dat je me dat /dev/null 2>&1 stukje niet wil laten ontglippen? Prima. Als je het echt wilt weten, hier is iemand op StackOverflow die het beter uitlegt dan ik:

> is voor redirect /dev/null is een zwart gat waar alle verzonden data, zal worden weggegooid. 2 is de bestandsdescriptor voor Standaard Fout. > is voor redirect. & is het symbool voor bestandsdescriptor (zonder dat zou de volgende 1 worden beschouwd als een bestandsnaam). 1 is de bestandsdescriptor voor Standaard O.

Dus eigenlijk vertellen we onze remote server dat we hem iets geven, en hij heeft zoiets van “waar laat ik dit ding,” waarop wij antwoorden “nergens in de fysieke ruimte, want dit is geen object, maar eerder een eeuwig symbool van onze vriendschap. Onze gastheer op afstand wordt dan overspoeld met dankbaarheid en emotie, want ja, computers hebben emoties, maar daar kunnen we ons nu niet druk om maken.

Verbinden met onze client

We voegen een methode aan onze client toe genaamd connect() om het verbinden met onze host af te handelen:

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

Laten we dit eens uitsplitsen:

  • client = SSHClient() zet de eerste stap voor het aanmaken van een object dat onze SSH-client vertegenwoordigt. De volgende regels zullen dit object configureren om het bruikbaarder te maken.
  • load_system_host_keys() instrueert onze client om te zoeken naar alle hosts waarmee we in het verleden verbinding hebben gemaakt door te kijken naar het known_hosts bestand van ons systeem en de SSH keys te vinden die onze host verwacht. We hebben in het verleden nog nooit verbinding gemaakt met onze host, dus we moeten onze SSH sleutel expliciet opgeven.
  • set_missing_host_key_policy() vertelt Paramiko wat te doen in het geval van een onbekend sleutelpaar. Dit is in afwachting van een “policy” ingebouwd in Paramiko, waar we specifieke AutoAddPolicy() aan gaan toevoegen. Als we onze policy op “auto-add” zetten, betekent dit dat als we verbinding proberen te maken met een niet-herkende host, Paramiko automatisch de ontbrekende sleutel lokaal zal toevoegen.
  • connect() is de belangrijkste methode van SSHClient (zoals je je misschien kunt voorstellen). We zijn eindelijk in staat om onze host, gebruiker en SSH sleutel door te geven om te bereiken waar we allemaal op hebben gewacht: een glorieuze SSH verbinding met onze server! De connect() methode laat ook een ton aan flexibiliteit toe via een enorme reeks optionele sleutelwoord argumenten. Ik geef er hier toevallig een paar door: look_for_keys instellen op True geeft Paramiko toestemming om in onze ~/.ssh folder rond te kijken om zelf SSH keys te ontdekken, en timeout instellen zal automatisch verbindingen sluiten die we waarschijnlijk vergeten te sluiten. We kunnen zelfs variabelen doorgeven voor dingen als poort en wachtwoord, als we er voor gekozen hebben om op deze manier verbinding te maken met onze host.

Verbinding verbreken

We zouden verbindingen met onze host op afstand moeten sluiten als we er klaar mee zijn. Als je dat niet doet, hoeft dat niet per se rampzalig te zijn, maar ik heb een paar gevallen gehad waarbij genoeg hangende verbindingen uiteindelijk het inkomende verkeer op poort 22 zouden overbelasten. Ongeacht of uw gebruikscasus een reboot beschouwt als een ramp of als een licht ongemak, laten we gewoon onze verdomde verbindingen sluiten als volwassenen, alsof we onze kont afvegen na het poepen. Ongeacht je verbindingshygiëne, ik ben voorstander van het instellen van een timeout variabele (zoals we eerder zagen). Hoe dan ook. voila:

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

Leuk weetje: door self.client.close() in te stellen, wordt self.client gelijkgesteld aan None, wat handig is in gevallen waarin je zou willen controleren of een verbinding al open is.

Uitvoeren van Unix Commando’s

We hebben nu een prachtige Python klasse die RSA sleutels kan vinden, verbinden en ontkoppelen. Het mist echter de mogelijkheid om, nou ja, iets nuttigs te doen.

We kunnen dit verhelpen en eindelijk “dingen” gaan doen met een gloednieuwe methode om commando’s uit te voeren, die ik toepasselijk execute_commands() zal noemen (dat is correct, “commando’s” als in potentieel-meer-dan-een, we zullen het daar zo meteen over hebben). Het beenwerk van dit alles wordt gedaan door de ingebouwde exec_command() methode van de Paramiko client, die een enkele string accepteert als een commando en het uitvoert:

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

De functie die we zojuist hebben gemaakt execute_commands() verwacht een lijst met tekenreeksen om als commando’s uit te voeren. Dat is gedeeltelijk voor het gemak, maar ook omdat Paramiko geen “state” veranderingen (zoals het veranderen van mappen) tussen de commando’s zal uitvoeren, dus elk commando dat we aan Paramiko doorgeven moet aannemen dat we vanuit de root van onze server werken. Ik ben zo vrij geweest om drie van deze commando’s door te geven:

remote.execute_commands()
__init__.py

Ik kan de inhoud van een directory bekijken door cd path/to/dir && ls te ketenen, maar het uitvoeren van cd path/to/dir gevolgd door ls zou in niets resulteren omdat ls de tweede keer de lijst met bestanden in de root van onze server retourneert.

Je zult zien dat client.exec_command(cmd) drie waarden teruggeeft in plaats van één: dit kan handig zijn om te zien welke invoer welke uitvoer produceerde. Hier zijn bijvoorbeeld de volledige logs voor het voorbeeld dat ik gaf, waarbij ik drie commando’s doorgaf aan 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
Uitvoer

Dit is prachtig spul. Nu kun je zien welke sites er op mijn server staan, welke bots me spammen, en hoeveel node-processen ik draai.

Ik wil niet veel meer tijd verspillen aan de kunst van het uitvoeren van commando’s, maar het is de moeite waard om te vermelden waarom we stdout.channel.recv_exit_status() na elk commando roepen. Wachten op recv_exit_status() om terug te komen na het uitvoeren van client.exec_command() dwingt onze commando’s synchroon uit te voeren, anders is de kans groot dat onze machine op afstand de commando’s niet zo snel kan ontcijferen als wij ze doorgeven.

Bestanden uploaden (en downloaden) via SCP

SCP verwijst zowel naar het protocol voor het kopiëren van bestanden naar machines op afstand (secure copy protocol) als naar de Python bibliotheek, die hiervan gebruik maakt. We hebben de SCP bibliotheek al geïnstalleerd, dus importeer die shit.

De SCP en Paramiko bibliotheken vullen elkaar aan om het uploaden via SCP super eenvoudig te maken. SCPClient() maakt een object aan dat “transport” verwacht van Paramiko, die we voorzien van self.conn.get_transport(). Het creeren van een SCP connectie maakt gebruik van onze SSH client in termen van syntax, maar deze connecties zijn gescheiden. Het is mogelijk om een SSH verbinding te sluiten en een SCP verbinding open te laten, dus doe dat niet. Open een SCP-verbinding als volgt:

self.scp = SCPClient(self.client.get_transport())
Open een SCP-verbinding.

Een enkel bestand uploaden is saai, dus laten we in plaats daarvan een hele map met bestanden uploaden. bulk_upload() accepteert een lijst met bestandspaden, en roept dan __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

Onze methode verwacht twee strings te ontvangen: de eerste is het lokale pad naar ons bestand, en de tweede is het pad van de externe map waarnaar we willen uploaden.

SCP’s put() methode zal een lokaal bestand uploaden naar onze host op afstand. Dit zal bestaande bestanden met dezelfde naam vervangen als ze toevallig bestaan op de bestemming die we opgeven. Dat is alles wat nodig is!

Downloaden van bestanden

De tegenhanger van SCP’s put() is de 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

Onze grote mooie script

We hebben nu een zieke Python-klasse om SSH en SCP met een host op afstand af te handelen… laten we die aan het werk zetten! De volgende snippet is een snelle manier om te testen wat we tot nu toe hebben gebouwd. In het kort, dit script zoekt naar een lokale map gevuld met bestanden (in mijn geval, heb ik de map gevuld met fox gifs 🦊).

Kijk eens hoe eenvoudig het is om een main.py te maken die complexe taken op machines op afstand afhandelt, dankzij onze RemoteClient-klasse:

"""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 is de uitvoer van onze uploadfunctie:

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 met succes geüpload

Het is gelukt! Geloof je me niet? Waarom controleren we het zelf niet door remote.execute_commands() uit te voeren?

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
Uitvoer van cd /var/www/ && ls

Daar heb je het. Rechtstreeks uit de mond van de vos.

Take It And Run With It

Hier wil ik een moment nemen om jullie allemaal te bedanken, en me verontschuldigen dat jullie hier nog zijn. Ik heb mezelf gezworen geen tutorials van meer dan tweeduizend woorden meer te posten, en deze lijkt wel vijfduizend woorden onzin te worden. Ik zal daar aan werken. Nieuw jaar, nieuwe ik.

Voor het gemak heb ik de broncode van deze tutorial geupload naar Github. Voel je vrij om er mee aan de slag te gaan! Tot slot laat ik jullie achter met de belangrijkste onderdelen van de Client-klasse die we hebben samengesteld:

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

De volledige broncode voor deze tutorial kan hier worden gevonden:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP in Python met Paramiko. Draag bij aan de ontwikkeling van hackersandslackers/paramiko-tutorial door een account aan te maken op GitHub.
GitHubhackersandslackers

Todd Birchard's avatar's avatar
Todd Birchard 123 Posts
New York City

SiteGithubTwitter

Engineer met een voortdurende identiteitscrisis. Breekt alles af voordat hij best practices heeft geleerd. Volkomen normaal en emotioneel stabiel.

Geef een reactie

Het e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *