Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
Tworzenie narzędzi sieciowych w Pythonie z użyciem socketów
Wstęp
W poprzednim wpisie poświęconym tworzeniu narzędzi w Pythonie przedstawiłem kilka prostych skryptów korzystających z modułów sys
, os
oraz httplib
. W tym wpisie „zejdziemy” nieco niżej i zobaczymy, jak tworzyć skrypty, które komunikują się z siecią przez sockety.
Czym są sockety (gniazda)?
Jest to pewien mechanizm umożliwiający otwarcie kanału komunikacji pomiędzy hostami. Każde gniazdo posiada adres IP oraz numer portu i może przesyłać trzy rodzaje pakietów:
- datagramy (pakiety UDP) — są to tzw. „datagram sockets” — ten kanał komunikacji wykorzystuje protokół UDP, czyli połączenie jest bezstanowe. Gniazdko wysyła pakiet UDP do docelowego hosta/portu i nie sprawdza, czy komunikacja zakończyła się powodzeniem. Przykładem usługi działającej z użyciem UDP jest DNS (Domain Name Server, port 53);
- strumienie (pakiety TCP) — czyli „stream sockets” — te gniazdka wysyłają dane za pomocą protokołu TCP, a zatem otwierają kanał komunikacji na określonym porcie, dane wysyłane są w pakietach TCP, gniazdko (socket) nie zamyka połączenia i oczekuje na odpowiedź od hosta docelowego. Przykładem usługi korzystającej z TCP jest HTTP (HyperText Transfer Protocol, port 80 — gdy przeglądarka internetowa chce pobrać zasób z serwera WWW, robi to właśnie poprzez zestawienie kanału komunikacji TCP na porcie 80);
- raw sockets (IP sockets) — ostatni rodzaj to gniazdka, które są w stanie wysyłać pakiety IP z pominięciem obu przedstawionych powyżej rodzajów pakietów, które są niejako opakowaniem dla danych (UDP i TCP). Raw IP sockets pozwalają na zdefiniowanie pakietu IP z dowolnym nagłówkiem i przesłanie go dowolnie zdefiniowanym protokołem (np. ICMP).
Pierwszy skrypt
Przejdźmy zatem do praktyki. Na początek zobaczmy, co potrafi moduł socket
— napiszemy prosty odpowiednik polecenia host
, które w przypadku podania nazwy domenowej serwera zwraca nam jego adres IP.
#!/usr/bin/python # # uzycie: # ./host.py domena # import socket, sys # [1] def getHostIP(domain_name): ip_addr = socket.gethostbyname(domain_name) # [2] return ip_addr if __name__ == '__main__': domain = sys.argv[1] print "Adres IP dla domeny %s to %s" % (domain, getHostIP(domain))
Po zapisaniu i uruchomieniu skryptu, jako argument podajmy mu domenę serwisu Sekurak, czyli sekurak.pl. Powinniśmy otrzymać rezultat analogiczny do poniższego:
$ ./host.py sekurak.pl Adres IP dla domeny sekurak.pl to 178.32.219.59
Po zaimportowaniu modułu socket
[1], w funkcji getHostIP()
użyliśmy jednej z jego metod, [2], by uzyskać adres IP nazwy domenowej.
Oczywiście to tylko wycinek możliwości modułu socket. Warto przyjrzeć się dostępnej dokumentacji.
Architektura klient-serwer
Pora na coś bardziej złożonego. Napiszmy prostą implementację klienta, który będzie wysyłał dane przez protokół TCP, oraz serwera, który te dane odbierze. Zacznijmy od strony klienckiej:
#!/usr/bin/python # # KLIENT # import socket def sendPacket(): proto = socket.getprotobyname('tcp') # [1] s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto) # [2] try: s.connect(("127.0.0.1", 2222)) # [3] s.send("Hello world") # [4] resp = s.recv(1024) # [5] print resp except socket.error: pass finally: s.close() if __name__ == '__main__': sendPacket()
Pierwszą rzeczą, jaką musimy zrobić, zanim zdefiniujemy gniazdo, jest wybór protokołu, w jakim będzie pracować. W tym przykładzie będzie to TCP [1]. Następnie tworzymy gniazdko [2], podając niezbędne parametry: socket.AF_INET
określa, że korzystamy z adresacji IPv4, socket.SOCK_STREAM
to gniazdo przesyłające dane strumieniowo, ostatnim argumentem jest wybrany wcześniej protokół.
W [3] nawiązujemy połączenie z serwerem, który będzie nasłuchiwał połączeń na porcie 2222. Roboczo możemy zastosować np. netcata i nakazać mu nasłuchiwanie na docelowym porcie:
$ nc -l 2222
Po uruchomieniu przykładu powinniśmy uzyskać rezultat podobny do zamieszczonego poniżej:
Spróbujmy teraz zastąpić netcata skryptem napisanym w Pythonie. W tym celu musimy stworzyć prosty serwer, który będzie w stanie odebrać i wyświetlić dane wysłane klientem z poprzedniego przykładu.
#!/usr/bin/python # # SERWER # import socket def server(): proto = socket.getprotobyname('tcp') # [1] serv = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto) serv.bind(("localhost", 2222)) # [2] serv.listen(1) # [3] return serv serv = server() while 1: conn, addr = serv.accept() # [4] while 1: message = conn.recv(64) # [5] if message: conn.send('Hi, I am a server, I received: ' + message) else: break conn.close()
Podobnie jak w poprzednim przykładzie, tworzymy gniazdo TCP [1], ale tym razem, zamiast inicjować połączenie, otwieramy port 2222 [2], do którego „podpinamy” nasze gniazdo, od tego momentu nasłuchując danych, które będą zaadresowane na ten port [3]. Argument metody listen(1)
to ilość równoczesnych połączeń, jaką serwer ma obsłużyć — dla naszego przykładu wystarczy jedno.
Po uruchomieniu serwera i nawiązaniu połączenia (które tworzy identyfikator conn
— po tym identyfikatorze będziemy mogli odczytać dane, które nim nadeszły) odczytujemy [4] ze strumienia po 64 bajty [5] tak długo, jak pojawiają się nowe dane (w naszym przykładzie jest to tylko ciąg „Hello World!!!”, więc pętla zakończy się po pierwszej iteracji). Odebrane dane odsyłamy z powrotem, korzystając z identyfikatora połączenia conn
— ponieważ klient ma zaimplementowane odbieranie danych, rezultatem będzie wyświetlenie zwróconego przez serwer ciągu w konsoli klienta (listing KLIENT, linijka [5]).
Prosta implementacja polecenia traceroute
Pora na bardziej użyteczny przykład. Poniżej znajduje się kod źródłowy implementujący funkcjonalność polecenia traceroute
(traceroute(8) – Linux man page, jego odpowiednikiem w systemach z rodziny Windows jest tracert
). Oczywiście jest to bardzo uproszczony odpowiednik, ale doskonale nadaje się na przedstawienie zagadnienia definiowania gniazd sieciowych.
Przykład pochodzi ze strony Learning by doing: Writing your own traceroute in 8 easy steps.
Na początek kod źródłowy:
#!/usr/bin/python import socket,sys def main(dest_name): dest_addr = socket.gethostbyname(dest_name) # [1] port = 33434 max_hops = 30 icmp = socket.getprotobyname('icmp') udp = socket.getprotobyname('udp') ttl = 1 while True: recv_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp) # [2] send_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, udp) send_socket.setsockopt(socket.SOL_IP, socket.IP_TTL, ttl) # [3] recv_socket.bind(("", port)) # [4] send_socket.sendto("", (dest_name, port)) # [5] curr_addr = None curr_name = None try: _, curr_addr = recv_socket.recvfrom(512) curr_addr = curr_addr[0] try: curr_name = socket.gethostbyaddr(curr_addr)[0] except socket.error: curr_name = curr_addr except socket.error: pass finally: send_socket.close() recv_socket.close() if curr_addr is not None: curr_host = "%s (%s)" % (curr_name, curr_addr) else: curr_host = "*" print "%d\t%s" % (ttl, curr_host) ttl += 1 if curr_addr == dest_addr or ttl > max_hops: break if __name__ == "__main__": host = sys.argv[1] main(host)
Jak widzimy, wykorzystane zostały wszystkie poznane do tej pory mechanizmy socketów — odczytanie adersu IP z nazwy domenowej [1], utworzenie gniazd korzystających z protokołu ICMP (do wysyłania pakietów) oraz UDP [2], ustawianie opcji TTL (Time To Live) dla wysyłanych pakietów [3] (o tym akurat nie mówiliśmy, ale opis funkcji setsockopt()
można znaleźć w dokumentacji modułu socket
— socket.setsockopt(level, optname, value)).
Jak widać, dzięki bardzo dużej elastyczności modułu socket
, jesteśmy w stanie operować na różnych typach gniazd [4] czy kierunku komunikacji [5], nie jesteśmy także ograniczeni tylko przez jeden protokół.
Zakończenie
Przedstawione w artykule zagadnienia to tak naprawdę tylko czubek czubka góry lodowej, jaką jest programowanie (nie tylko w Pythonie i nie tylko sieciowe). Niewątpliwą zaletą opanowania mechanizmu socketów i sposobu pisania programów, które z nich korzystają, jest fakt, że implementacje w innych językach nie różnią się zbytnio (np. C.C++), oczywiście w ogólnych założeniach. To niewątpliwie bardzo ułatwia choćby przenoszenie kodu pomiędzy różnymi platformami.
Źródła
- Learning by doing: Writing your own traceroute in 8 easy steps | Oracle Blogs
- 17.2. socket — Low-level networking interface | The Python Standard Library
- Socket Programming HOWTO | Python HOWTOs
Rafał 'bl4de’ Janicki– bloorq[at]gmail.com
„(…) implementacje w innych językach nie różnią się zbytnio (np. C.C++), [b]oczwyiście[/b] w ogólnych założeniach.”
Tak, oczywiście chodzi o samą koncepcję (tworzenie gniazd, otwieranie połączeń, zapis do/odczyt z gniazd), nie o konkretną implementację w danym języku :)
Trochę niefortunnie brzmi część o „trzech rodzajach pakietów”. Chyba raczej chodzi o trzy rodzaje (lub idąc za dokumentacją typy) gniazd: dla pakietów TCP, UDP i bez wskazania konkretnego protokołu warstwy transportowej.
@MateuszM
Tak, dokładnie o to chodzi.
Nie lubię się powtarzać, ale…
Ruby!
;)
Zabiliście pythona – rozszerzcie trochę tą ostatnią ramkę z kodem źródłowym.
Obejście – zmniejszona czcionka :)
Po skopiowaniu i wklejeniu do edytora (Sublime Text) formatowanie jest zachowane :)
Świetny artykuł.
Pozdrawiam! L.
Świetna robota! Bardzo pomocny artykuł.