Articles

SSH & SCP w Pythonie z Paramiko

Posted on
19 min. czytania
03 stycznia

SSH SCP w Pythonie z Paramiko

Dostawcy usług chmurowych od lat zarabiają na starannie zapakowanych usługach zarządzanych. Czy to bazy danych czy brokerzy wiadomości, deweloperzy tacy jak my nie wydają się mieć problemu z płaceniem trochę więcej, aby mieć rzeczy pod opieką. Ale zaraz, czy nie jesteśmy zazwyczaj ostatnimi ludźmi, którzy decydują się na mniejszą optymalizację i mniejszą kontrolę? Dlaczego właśnie teraz postanowiliśmy inaczej? Gdybym miał zgadywać, powiedziałbym, że to częściowo dlatego, że DevOps po stronie serwera jest do bani.

Jako deweloper, konfigurowanie lub usuwanie błędów w VPS-ie to zwykle praca, której nie da się policzyć i która nie jest szczególnie satysfakcjonująca. W najlepszym wypadku, Twoja aplikacja prawdopodobnie będzie działała tak samo jak Twoje środowisko lokalne. Jak moglibyśmy usprawnić tę nieuniknioną część naszej pracy? Cóż, możemy ją zautomatyzować.

Paramiko i SCP to dwie biblioteki Pythona, których możemy użyć razem do zautomatyzowania zadań, które chcielibyśmy wykonać na zdalnym hoście, takich jak restartowanie usług, aktualizacje czy pobieranie plików dziennika. Przyjrzymy się, jak wygląda pisanie skryptów przy użyciu tych bibliotek. Ostrzegam: w tym tutorialu jest spora ilość kodu, co sprawia, że jestem na tyle podekscytowany, że zmuszam innych do wzięcia udziału w moich maniakalnych epizodach kodowo-tutorialnych. Jeśli zaczynasz się gubić, pełne repo można znaleźć tutaj:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP w Pythonie z Paramiko. Przyczyniaj się do rozwoju hackersandslackers/paramiko-tutorial tworząc konto na GitHub.
GitHubhackersandslackers
Paramiko mocno opiera się na stronie „in-the-weeds” bibliotek Pythona. Jeśli szukasz czegoś łatwego, co może po prostu wykonać pracę, pyinfra jest podobno świetną (i łatwą) alternatywą.

Ustawianie kluczy SSH

Aby uwierzytelnić połączenie SSH, musimy skonfigurować prywatny klucz RSA SSH (nie mylić z OpenSSH). Możemy wygenerować klucz za pomocą następującego polecenia:

$ ssh-keygen -t rsa
Generate an RSA key

W tym momencie zostaniemy poproszeni o podanie nazwy dla naszego klucza. Nazwij go, jak chcesz:

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

Następnie zostaniesz poproszony o podanie hasła (nie krępuj się zostawić tego pustego).

Teraz, gdy mamy już nasz klucz, musimy go skopiować na nasz zdalny host. Najłatwiej jest to zrobić za pomocą ssh-copy-id:

$ ssh-copy-id -i ~/.ssh/mykey username@my_remote_host.org
Kopiuj klucz do zdalnego hosta

Weryfikacja naszego klucza SSH

Jeśli chciałbyś sprawdzić jakie klucze już posiadasz, można je znaleźć w katalogu .ssh w systemie:

$ cd ~/.ssh
Sprawdź /.ssh

Szukamy kluczy, które zaczynają się od następującego nagłówka:

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

Nie krępuj się zrobić tego samego na swoim FPS.

Rozpoczęcie naszego skryptu

Zainstalujmy nasze biblioteki. Odpal dowolne środowisko wirtualne i daj się ponieść emocjom:

$ pip3 install paramiko scp
Instalujemy paramiko & scp

Jeszcze jedna rzecz, zanim napiszemy jakiś sensowny kod Pythona! Utwórz plik konfiguracyjny, który będzie zawierał zmienne potrzebne do połączenia się z naszym hostem. Oto podstawowe informacje o tym, czego potrzebujemy, aby dostać się do naszego serwera:

  • Host: Adres IP lub URL zdalnego hosta, do którego próbujemy się dostać.
  • Nazwa użytkownika: To jest nazwa użytkownika, której używasz do SSH na swoim serwerze.
  • Fraza hasła (opcjonalnie): Jeśli podczas tworzenia klucza ssh podałeś frazę hasła, podaj ją tutaj. Pamiętaj, że fraza hasła klucza SSH nie jest taka sama jak hasło użytkownika.
  • Klucz SSH: Ścieżka do pliku z kluczem, który utworzyliśmy wcześniej. W systemie OSX znajduje się on w folderze ~/.ssh. Klucz SSH, do którego się odwołujemy, musi mieć dołączony klucz z rozszerzeniem .pub. Jest to nasz klucz publiczny; jeśli podążałeś za nim wcześniej, powinien on już zostać wygenerowany dla Ciebie.

Jeśli próbujesz wysyłać lub pobierać pliki ze zdalnego hosta, będziesz musiał dołączyć jeszcze dwie zmienne:

  • Remote Path: Ścieżka do zdalnego katalogu, który chcemy wykorzystać do przesyłania plików. Możemy albo przesyłać rzeczy do tego katalogu albo pobierać jego zawartość.
  • Local Path: Ta sama idea co powyżej, ale w odwrotnej kolejności. Dla naszej wygody, lokalna ścieżka, której będziemy używać to po prostu /data, i zawiera obrazki słodkich lisich gifów.

Teraz mamy wszystko, czego potrzebujemy, aby stworzyć porządny plik config.py:

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

Tworzenie klienta SSH

Powstanie klasa RemoteClient, która będzie obsługiwać interakcje z naszym zdalnym hostem. Zanim zaczniemy zbytnio kombinować, zacznijmy od zainicjowania klasy RemoteClient za pomocą zmiennych, które utworzyliśmy w 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

Jak na razie nic imponującego: po prostu ustawiliśmy kilka zmiennych i przekazaliśmy je do bezużytecznej klasy. Podnieśmy więc poprzeczkę, nie wychodząc z naszego konstruktora:

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

Dodaliśmy trzy nowe rzeczy, które mają być instancjonowane wraz z naszą klasą:

  • self.client: self.client będzie ostatecznie służył jako obiekt połączenia w naszej klasie, podobnie do tego, jak miałeś do czynienia z terminologią taką jak conn w bibliotekach bazodanowych. Nasze połączenie będzie None dopóki nie połączymy się jawnie z naszym zdalnym hostem.
  • self.scp: Podobny do self.client, ale obsługuje wyłącznie połączenia do przesyłania plików.
  • self.__upload_ssh_key() nie jest zmienną, ale raczej funkcją, która ma być uruchamiana automatycznie za każdym razem, gdy nasz klient zostanie zainicjowany. Wywołanie __upload_ssh_key() mówi naszemu obiektowi RemoteClient, aby sprawdził lokalne klucze ssh natychmiast po utworzeniu, tak abyśmy mogli spróbować przekazać je do naszego zdalnego hosta. W przeciwnym razie, nie bylibyśmy w stanie nawiązać połączenia.

Uploading SSH Keys to a Remote Host

Dotarliśmy do części tego ćwiczenia, w której musimy wywalić trochę niszczycielsko brzydkiego kodu. Jest to typowy moment, w którym emocjonalnie gorsze jednostki poddają się czystej, nudnej mętności zrozumienia kluczy SSH i utrzymywania połączeń. Nie popełnij błędu: uwierzytelnianie i zarządzanie połączeniami z czymkolwiek programistycznie jest przytłaczająco nudne… chyba, że twoim przewodnikiem jest czarujący mistrz słowa, służący jako twój kochający obrońca przez niebezpieczną ciemność. Niektórzy ludzie nazywają ten post samouczkiem. Ja zamierzam nazywać go sztuką.

RemoteClient zacznie od dwóch prywatnych metod: __get_ssh_key() oraz __upload_ssh_key(). Pierwsza z nich pobierze lokalnie przechowywany klucz publiczny, a jeśli się powiedzie, druga dostarczy ten klucz publiczny do naszego zdalnego hosta jako gałązkę oliwną dostępu. Gdy lokalnie utworzony klucz publiczny istnieje na zdalnej maszynie, maszyna ta na zawsze zaufa naszym prośbom o połączenie się z nią: nie potrzeba żadnych haseł. Po drodze włączymy odpowiednie logowanie, na wszelki wypadek, gdybyśmy napotkali jakieś problemy:

"""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() jest dość prosty: weryfikuje czy istnieje klucz SSH w ścieżce, którą podaliśmy w naszym configu do połączenia z naszym hostem. Jeśli plik faktycznie istnieje, ustawiamy naszą zmienną self.ssh_key, więc ten klucz może być załadowany i używany przez naszego klienta od tego momentu. Paramiko dostarcza nam podmoduł o nazwie RSAKey, aby łatwo obsługiwać wszystkie rzeczy związane z kluczami RSA, jak np. parsowanie pliku klucza prywatnego do użytecznego uwierzytelnienia połączenia. Oto co tutaj otrzymujemy:

 RSAKey.from_private_key_file(self.ssh_key_filepath)
Odczytaj klucz RSA z lokalnego pliku.

Gdyby nasz klucz RSA był niezrozumiałą bzdurą zamiast prawdziwym kluczem, wyjątek SSHException Paramiko wychwyciłby to i wcześnie podniósł wyjątek wyjaśniający właśnie to. Prawidłowe wykorzystanie obsługi błędów w bibliotece zabiera wiele z domysłów na temat „co poszło nie tak”, szczególnie w przypadkach, gdy istnieje potencjał dla wielu niewiadomych w niszowej przestrzeni, z którą żaden z nas nie zadziera często.

_upload_ssh_key() to miejsce, w którym dostajemy się do zakleszczenia naszego klucza SSH w gardle naszego zdalnego serwera podczas krzyczenia, „LOOK! TERAZ MOŻESZ MI ZAUFAĆ NA ZAWSZE!”. Aby to osiągnąć, idę trochę „starą szkołą”, przekazując komendy basha przez Pythona os.system. O ile ktoś nie uświadomi mi czystszego podejścia w komentarzach, założę, że jest to najbardziej paskudny sposób na obsługę przekazywania kluczy do zdalnego serwera.

Standardowy, nie-Pythonowy sposób przekazywania kluczy do hosta wygląda tak:

ssh-copy-id -i ~/.ssh/mykey user@host
Przekaż klucz SSH do zdalnego hosta

To jest dokładnie to, co osiągamy w naszej funkcji w Pythonie, która wygląda tak:

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

Podejrzewam, że nie pozwolisz mi przeoczyć tego /dev/null 2>&1 bitu? Dobrze. Jeśli musisz wiedzieć, oto jakiś facet na StackOverflow wyjaśniający to lepiej niż ja mogę:

> jest dla przekierowania /dev/null jest czarną dziurą, w której wszelkie wysłane dane, zostaną odrzucone. 2 to deskryptor pliku dla Standard Error. > jest dla przekierowania. & to symbol deskryptora pliku (bez niego następujący 1 byłby uważany za nazwę pliku). 1 to deskryptor pliku dla Standard O.

Więc w zasadzie mówimy naszemu zdalnemu serwerowi, że coś mu dajemy, a on odpowiada „gdzie mam to położyć”, na co my odpowiadamy „nigdzie w fizycznej przestrzeni, ponieważ to nie jest przedmiot, ale raczej wieczny symbol naszej przyjaźni”. Nasz zdalny gospodarz jest wtedy zalany wdzięcznością i wzruszeniem, bo tak, komputery mają emocje, ale nie możemy sobie tym teraz zawracać głowy.

Połączenie z naszym klientem

Dodamy do naszego klienta metodę o nazwie connect() aby obsłużyć połączenie z naszym hostem:

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

Porozkładajmy to na czynniki pierwsze:

  • client = SSHClient() Ustawia scenę do stworzenia obiektu reprezentującego naszego klienta SSH. Poniższe linie skonfigurują ten obiekt tak, aby był bardziej użyteczny.
  • load_system_host_keys() instruuje naszego klienta, aby szukał wszystkich hostów, z którymi łączył się w przeszłości, patrząc na nasz systemowy plik known_hosts i znajdując klucze SSH, których oczekuje nasz host. Nigdy nie łączyliśmy się z naszym hostem w przeszłości, więc musimy jawnie określić nasz klucz SSH.
  • set_missing_host_key_policy() mówi Paramiko co zrobić w przypadku nieznanej pary kluczy. Jest to oczekiwanie na „politykę” wbudowaną w Paramiko, do której będziemy się odnosić AutoAddPolicy(). Ustawienie naszej polityki na „auto-add” oznacza, że jeśli spróbujemy połączyć się z nierozpoznanym hostem, Paramiko automatycznie doda brakujący klucz lokalnie.
  • connect() jest najważniejszą metodą SSHClienta (jak można sobie wyobrazić). W końcu jesteśmy w stanie przekazać nasz host, użytkownika i klucz SSH, aby osiągnąć to, na co wszyscy czekaliśmy: wspaniałe połączenie SSH z naszym serwerem! Metoda connect() pozwala na dużą elastyczność dzięki szerokiemu wachlarzowi opcjonalnych argumentów w postaci słów kluczowych. Tak się składa, że podałem tutaj kilka: ustawienie look_for_keys na True daje Paramiko pozwolenie na rozejrzenie się w naszym folderze ~/.ssh w celu samodzielnego odkrycia kluczy SSH, a ustawienie timeout automatycznie zamknie połączenia, które prawdopodobnie zapomnimy zamknąć. Moglibyśmy nawet przekazać zmienne dla takich rzeczy jak port i hasło, jeśli zdecydowalibyśmy się połączyć z naszym hostem w ten sposób.

Rozłączanie

Powinniśmy zamykać połączenia do naszego zdalnego hosta, kiedy tylko skończymy z nich korzystać. Niedopełnienie tego obowiązku nie musi być katastrofalne, ale miałem kilka przypadków, w których wystarczająca ilość wiszących połączeń spowodowała maksymalne ograniczenie ruchu przychodzącego na porcie 22. Niezależnie od tego, czy Twój przypadek użycia może uznać restart za katastrofę czy łagodną niedogodność, po prostu zamknijmy nasze cholerne połączenia jak dorośli, tak jakbyśmy wycierali nasze tyłki po zrobieniu kupy. Bez względu na higienę połączenia, jestem zwolennikiem ustawiania zmiennej timeout (jak widzieliśmy wcześniej). W każdym razie. voila:

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

Fun fact: ustawienie self.client.close() faktycznie ustawia self.client na równe None, co jest przydatne w przypadkach, w których możesz chcieć sprawdzić, czy połączenie jest już otwarte.

Wykonywanie komend uniksowych

Mamy teraz wspaniałą klasę Pythona, która potrafi znaleźć klucze RSA, połączyć się i rozłączyć. Brakuje jej jednak możliwości zrobienia, cóż, czegokolwiek użytecznego.

Możemy to naprawić i w końcu zacząć robić „rzeczy” dzięki zupełnie nowej metodzie wykonywania poleceń, którą trafnie nazwę execute_commands() (to prawda, „polecenia” w znaczeniu potencjalnie więcej niż jedno, zajmiemy się tym za chwilę). Cała ta praca wykonywana jest przez wbudowaną metodę klienta Paramiko exec_command(), która przyjmuje pojedynczy ciąg znaków jako polecenie i wykonuje je:

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

Funkcja, którą właśnie stworzyliśmy execute_commands() oczekuje listy ciągów znaków do wykonania jako komendy. Jest to częściowo dla wygody, ale również dlatego, że Paramiko nie będzie uruchamiać żadnych zmian „stanu” (jak zmiana katalogów) pomiędzy komendami, więc każda komenda przekazywana do Paramiko powinna zakładać, że pracujemy z roota naszego serwera. Pozwoliłem sobie przekazać trzy takie komendy w następujący sposób:

remote.execute_commands()
__init__.py

Mogę wyświetlić zawartość katalogu poprzez łańcuchowanie cd path/to/dir && ls, ale uruchomienie cd path/to/dir a następnie ls spowodowałoby nicość, ponieważ ls za drugim razem zwraca listę plików w katalogu głównym naszego serwera.

Zauważysz, że client.exec_command(cmd) zwraca trzy wartości zamiast jednej: może to być przydatne, aby zobaczyć, które dane wejściowe dały jakie wyniki. Na przykład, oto pełne logi dla przykładu, który podałem, gdzie przekazałem trzy komendy do 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
Wyjście

Niezwykle piękne rzeczy. Teraz możesz zobaczyć, jakie strony są na moim serwerze, jakie boty mnie spamują i ile procesów węzłowych mam uruchomionych.

Nie chcę tracić więcej czasu na sztukę wykonywania poleceń, ale warto wspomnieć o tym, dlaczego po każdej komendzie wywołujemy stdout.channel.recv_exit_status(). Oczekiwanie na powrót recv_exit_status() po uruchomieniu client.exec_command() wymusza synchroniczne wykonywanie naszych poleceń, w przeciwnym razie istnieje prawdopodobieństwo, że nasza zdalna maszyna nie będzie w stanie rozszyfrować poleceń tak szybko, jak my je przekazujemy.

Wysyłanie (i pobieranie) plików przez SCP

SCP odnosi się zarówno do protokołu kopiowania plików na zdalne maszyny (secure copy protocol), jak i do biblioteki Pythona, która go wykorzystuje. Mamy już zainstalowaną bibliotekę SCP, więc zaimportuj to gówno.

Biblioteki SCP i Paramiko uzupełniają się wzajemnie, czyniąc przesyłanie plików przez SCP super łatwym. SCPClient() tworzy obiekt, który oczekuje „transportu” od Paramiko, który zapewniamy za pomocą self.conn.get_transport(). Tworzenie połączenia SCP jest pod względem składniowym podczepione pod naszego klienta SSH, ale te połączenia są oddzielne. Możliwe jest zamknięcie połączenia SSH i pozostawienie otwartego połączenia SCP, więc nie rób tego. Otwórz połączenie SCP w ten sposób:

self.scp = SCPClient(self.client.get_transport())
Otwórz połączenie SCP.

Wysyłanie pojedynczego pliku jest nudne, więc zamiast tego wyślijmy cały katalog plików. bulk_upload() przyjmuje listę ścieżek do plików, a następnie wywołuje __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

Nasza metoda oczekuje otrzymania dwóch ciągów znaków: pierwszy to lokalna ścieżka do naszego pliku, a drugi to ścieżka do zdalnego katalogu, do którego chcemy przesłać plik.

Metoda SCP put() prześle lokalny plik do naszego zdalnego hosta. Zastąpi to istniejące pliki o tej samej nazwie, jeśli tak się składa, że istnieją w miejscu docelowym, które podaliśmy. To wszystko, co trzeba zrobić!

Pobieranie plików

Odpowiednikiem metody SCP put() jest metoda 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

Nasz wielki, piękny skrypt

Mamy teraz chorą klasę Pythona do obsługi SSH i SCP ze zdalnym hostem… zabierzmy się do pracy! Poniższy snippet jest szybkim sposobem na przetestowanie tego, co zbudowaliśmy do tej pory. W skrócie, skrypt ten szuka lokalnego folderu wypełnionego plikami (w moim przypadku, wypełniłem folder gifami z lisami 🦊).

Sprawdź, jak łatwo jest stworzyć main.py, który obsługuje złożone zadania na zdalnych maszynach dzięki naszej klasie 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

Tutaj jest wyjście naszej funkcji wysyłania:

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 uploaded successfully

To zadziałało! Nie wierzysz mi? Dlaczego nie sprawdzimy tego sami, uruchamiając 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
Wyjście z cd /var/www/ && ls

Tutaj to masz. Prosto z ust lisa.

Take It And Run With It

W tym miejscu chciałbym poświęcić chwilę, aby podziękować wam wszystkim i przeprosić, że wciąż tu jesteście. Złożyłem sobie przysięgę, że przestanę publikować tutoriale o długości ponad dwóch tysięcy słów, a ten wygląda na pięć tysięcy słów bzdur. Popracuję nad tym. Nowy rok, nowy ja.

Dla waszej wygody, umieściłem źródła tego tutoriala na Githubie. Czujcie się swobodnie i korzystajcie z niego! Aby zakończyć, zostawię cię z mięsem i ziemniakami klasy Client, którą razem stworzyliśmy:

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

Pełny kod źródłowy dla tego tutoriala można znaleźć tutaj:

hackersandslackers/paramiko-tutorial
📡🐍SSH & SCP w Pythonie z Paramiko. Przyczyniaj się do rozwoju hackersandslackers/paramiko-tutorial tworząc konto na GitHub.
GitHubhackersandslackers
Todd Birchard's avatar's avatar
Todd Birchard 123 Posty
Nowy Jork
SiteGithubTwitter

Engineer z ciągłym kryzysem tożsamości. Łamie wszystko, zanim nauczy się najlepszych praktyk. Całkowicie normalny i stabilny emocjonalnie.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *