În cadrul acestui capitol folosim orchestratia de containere definită aici. Pentru a rula această orchestrație, este suficient să executăm:
cd computer-networks/capitolul2
docker network prune
docker compose up -d
Folosim DNS pentru a afla IP-urile corespunzătoare numelor. În general numele sunt (Fully Qualified Domain Names) salvate cu un punct în plus la sfârșit.
În linux și macOS există aplicația dig
cu care putem interoga entries de DNS. Pe windows există aplicația nslookup.
Puteți rula exemplele de mai jos dintr-un container docker docker compose exec rt1 bash
.
#1. cele 13 root servers de DNS:
dig
;; ANSWER SECTION:
. 18942 IN NS m.root-servers.net.
. 18942 IN NS a.root-servers.net.
. 18942 IN NS b.root-servers.net.
. 18942 IN NS c.root-servers.net.
. 18942 IN NS d.root-servers.net.
. 18942 IN NS e.root-servers.net.
. 18942 IN NS f.root-servers.net.
. 18942 IN NS g.root-servers.net.
. 18942 IN NS h.root-servers.net.
. 18942 IN NS i.root-servers.net.
. 18942 IN NS j.root-servers.net.
. 18942 IN NS k.root-servers.net.
. 18942 IN NS l.root-servers.net.
. 18942 IN NS m.root-servers.net.
#2. facem request-uri iterative pentru a afla adresa IP corespunzatoare lui fmi.unibuc.ro
dig @a.root-servers.net fmi.unibuc.ro
;; AUTHORITY SECTION:
ro. 172800 IN NS sec-dns-b.rotld.ro.
ro. 172800 IN NS dns-c.rotld.ro.
ro. 172800 IN NS dns-at.rotld.ro.
ro. 172800 IN NS dns-ro.denic.de.
ro. 172800 IN NS primary.rotld.ro.
ro. 172800 IN NS sec-dns-a.rotld.ro.
#3. interogam un nameserver responsabil de top-level domain .ro
dig @sec-dns-b.rotld.ro fmi.unibuc.ro
;; QUESTION SECTION:
fmi.unibuc.ro. IN A
;; AUTHORITY SECTION:
unibuc.ro. 86400 IN NS ns.unibuc.ro.
;; ADDITIONAL SECTION:
ns.unibuc.ro. 86400 IN A 80.96.21.3
#am aflat de la @sec-dns-b.rotld.ro ca ns.unibuc.ro se gaseste la adresa: 80.96.21.3
#4. trimitem un ultim mesaj:
mesaj = "hey, ns.unibuc.ro, care este adresa IP pentru numele fmi.unibuc.ro?"
IP dst: 80.96.21.3 (ns.unibuc.ro)
PORT dst: aplicația de pe portul: 53 - constanta magică (vezi IANA și ICANN)
#cand fac cererea, deschid un port temporar (47632)
#pentru a primi inapoi raspunsul DNS destinat aplicatiei care a făcut cererea
Interogări către serverul DNS 8.8.8.8 de la google.
# interogam serverul 8.8.8.8 pentur a afla la ce IP este fmi.unibuc.ro
dig @8.8.8.8 fmi.unibuc.ro
; <<>> DiG 9.10.3-P4-Ubuntu <<>> @8.8.8.8 fmi.unibuc.ro
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 16808
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;fmi.unibuc.ro. IN A
;; ANSWER SECTION:
fmi.unibuc.ro. 12925 IN A 193.226.51.15
;; Query time: 39 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Wed May 13 13:29:13 EEST 2020
;; MSG SIZE rcvd: 58
DNS stochează nu doar informații despre IP-ul corespunzător unui hostname, ci există mai multe tipuri de intrări (record types) în baza de date:
dig @8.8.8.8 fmi.unibuc.ro MX
dig @8.8.8.8 fmi.unibuc.ro NS
dig @8.8.8.8 fmi.unibuc.ro TXT
Protocolul pentru DNS lucrează la nivelul aplicației și este standardizat pentru UDP, port 53. Acesta se bazează pe request-response iar în cazul în care nu se primesc răspunsuri după un număr de reîncercări (de multe ori 2), programul anunță că nu poate găsi IP-ul pentru hostname-ul cerut (“can’t resolve”). Headerul protocolului este definit aici.
Intrați în browser și deschideți Developer Tools (de obicei, apăsând tasta F12). Accesați pagina https://fmi.unibuc.ro și urmăriți în tabul Network cererile HTTP.
import requests
from bs4 import BeautifulSoup
# dictionar cu headerul HTTP sub forma de chei-valori
headers = {
"Accept": "text/html",
"Accept-Language": "en-US,en",
"Cookie": "__utmc=177244722",
"Host": "fmi.unibuc.ro",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.79 Safari/537.36"
}
response = requests.get('https://fmi.unibuc.ro', headers=headers)
print (response.text[:200])
# proceseaza continutul html
supa = BeautifulSoup(response.text)
# cauta lista cu clasa ultimeleor postari
div = supa.find('ul', {'class': 'wp-block-latest-posts__list'})
print(div.text)
Executați următoarele comenzi pe un server AWS sau pe calculatorul vostru personal.
Flask este un framework pentru web simplist și minimal pentru python.
# instalam flask
pip install flask
# ne mutam in directorul unde se gaseste simple_flask.py
cd computer-networks/capitolul2/src
# executam scriptul
python3 simple_flask.py
fastapi este un alt framework gândit pentru apeluri HTTP asincrone. Executarea aplicației este preferat a se face prin-un ASGI Asynchronous Server Gateway Interface, în cazul nostru vom folosu uvicorn.
# instalam depedințele
pip install "fastapi[all]"
# daca variabila de environment PATH nu contine /home/$USER/bin
# trebuie adaugat manual
export PATH=$PATH:~/.local/bin/
# ne mutam in directorul unde se gaseste simple_flask.py
cd computer-networks/capitolul2/src
# executăm aplicația cu uvicorn
uvicorn simple_fastapi:app --reload --host 0.0.0.0 --port 8080
go.ro
care să trimită de fiecare dată către router-ul vostru de acasă.192.168.0.15
:02:42:ac:1e:00:02
)8080
)80
către 192.168.0.15:8080
A
care să redirecționeze domeniul către adresa IPv4 a instanței voastre.sudo apt install certbot
pe instanța voastră.sudo certbot certonly -d NUME_DOMENIU --preferred-challenges dns --manual
. Opțiunile din comanda anterioară --preferred-challenges dns --manual
indică faptul că veți valida faptul că domeniul vă aparține printr-o intrare adăugată manual de tipul TXT
în configurarea domeniului. În general, dacă doriți ca certificatele să se reînnoiască automat, nu folosiți cele două opțiuni, ci deschideți din security groups portul 80 și 443 care va valida automat conexiunea cu serverul./etc/letsencrypt/live/NUME_DOMENIU/
vor fi generate perechi de chei public-private pentru domeniile voastre.sudo uvicorn simple_fastapi:app --reload --host 0.0.0.0 --port 8080 --ssl-keyfile /etc/letsencrypt/live/NUME_DOMENIU/privkey.pem --ssl-certfile /etc/letsencrypt/live/NUME_DOMENIU/fullchain.pem
Pentru securitare și privacy, sunt dezvoltate metode noi care encriptează cererile către DNS. DNS over HTTPS sau DoH este explicat in detaliu aici. Privacy-ul oferit de DoH poate fi exploatat și de malware iar mai multe detalii despre securitatea acestuia pot fi citite aici.
rt1
scriptul ‘simple_flask.py’ care deserveste API HTTP pentru GET si POST. Daca accesati in browser http://localhost:8001 ce observati?docker compose exec rt2 bash
. Testati conexiunea catre API-ul care ruleaza pe rt1 folosind curl: curl -X POST http://rt1:8001/post -d '{"value": 10}' -H 'Content-Type: application/json'
. Scrieti o metoda POST care ridică la pătrat un numărul definit în value
. Apelați-o din cod folosind python requests.
SSH este o aplicație client-server care permite instanțierea unui shell pe un calculator care se află într-o altă locație pe rețea. Reprezintă o alternativă la telent și rlogin (aplicații nesecurizate). Folosește protocolul TCP pentru transport. De ce? De obicei portul 22 este rezervat pentru SSH Permite și crearea unui tunel prin care să se transmită date în mod securizat Cea mai sigură metodă este conectarea prin pereche cheie publică-cheie privată, dar funcționează și prin conexiune pe bază de parolă.
Dacă avem un server deschis pe localhost, putem folosi SSH pentru a face un tunel prin TCP prin care să trimitem orice fel de mesaje. Asta înseamnă că putem trimite mesaje TCP prin TCP-ul deschis cu SSH.
Aplicație server deschisă pe localhost pe server, accesibilă local prin tunel SSH
Puteți încerca să deschideți un server simplu (folosind simple_flask.py
deschis cu localhost:8002
pe un server remote). Folisiți ssh -N -L 8083:localhost:8002 USER@IP_SERVER
ca să redirecționați mesajele care vin pe portul local 8083 către adresa localhost:8002 de pe server. Puteți accesa aplicația din browserul local folosind portul local pe care tocmai l-am alocat (8083)
Dynamic Port Forwarding (nerecomandat)
Putem transforma server-ul într-un proxy securizat prin care să trimitem toate mesajele ssh -N -D 8081 USER@IP_SERVER
. Dynamic port forwarding deschide un canal de comunicare de pe adresa localhost:8081
către server, encapsulând orice mesaj de la nivelele inferioare.
La nivelul browserului putem seta SOCKS proxy ca fiind localhost:8081
.
Dacă verificăm în browser care este adresa IP, vom vedea că este chiar adresa serverului pe care am instanțiat conexiunea SSH cu dynamic port forwarding.
Încercați mai multe exemple de tuneluri SSH
Este un API disponibil în mai toate limbajele de programare cu care putem implementa comunicarea pe rețea la un nivel mai înalt. Semnificația flag-urilor este cel mai bine explicată în tutoriale de unix sockets care acoperă partea de C. În limbajul python avem la dispoziție exact aceleași funcții și flag-uri ca în C iar interpretarea lor nu ține de un limbaj de programare particular.
Este un protocol simplu la nivelul transport. Header-ul acestuia include portul sursă, portul destinație, lungime și un checksum opțional:
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| Source | Destination |
| Port | Port |
+--------+--------+--------+--------+
| | |
| Length | Checksum |
+--------+--------+--------+--------+
|
| data octets / payload
+---------------- ...
Toate câmpurile din header sunt reprezentate pe câte 16 biți sau 2 octeți:
Câteva caracteristi ale protocolului sunt descrise aici iar partea de curs este acoperită în mare parte aici. UDP este implementat la nivelul sistemului de operare, iar Socket API ne permite să interacționăm cu acest protocol folosind apeluri de sistem.
În primă fază trebuie să importăm librăria socket:
import socket
Se instanțiază un obiect sock
cu AF_INET pentru adrese de tip IPv4, SOCK_DGRAM
(datagrams - connectionless, unreliable messages of a fixed maximum length) pentru datagrams și IPPROTO_UDP
pentru a specifica protocolul UDP:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP)
Apelăm funcția bind pentru a asocia un port unei adrese și pentru ca aplicația sa își aloce acel port prin care poate primi sau transmite mesaje. In cazul de fata, adresa folosita este localhost, pe interfata loopback, ceea ce inseamnă că aplicația noastră nu va putea comunica cu alte dispozitive pe rețea, ci doar cu alte aplicații care se găsesc pe același calculator/container:
port = 10000
adresa = 'localhost'
# tuplu adresa, port
server_address = (adresa, port)
#functia bind primeste ca parametru un obiect de tip tuplu
sock.bind(server_address)
Pentru a aloca un port astfel încât serverul să poată comunica pe rețea, trebuie fie să folosim adresa IP a interfeței pe care o folosim pentru comunicare (eth0 în cadrul containerelor de docker), fie să folosim o meta-adresă IP rezervată: 0.0.0.0
face ca toate interfețele să fie deschise către comunicare. Mai multe detalii despre această adresă puteți citi aici.
În momentul în care un client trimite serverului mesaje, acestea sunt stocate într-un buffer. Dimensiunea bufferului depinde de configurarea sistemului de operare, detaliile pentru linux sunt aici sau o postare cu mai multe explicații aici.
Pentru a primi un mesaj, serverul poate să apeleze funcția recvfrom
care are ca parametru numărul de bytes pe care să-l citească din buffer și o serie de flags optionale.
# citeste 16 bytes din buffer
data, address = sock.recvfrom(16)
Funcția produce un apel blocant, deci programul stă în așteptare ca bufferul să se umple de octeți pentru a fi citiți. În cazul în care serverul nu primește mesaje, metoda stă în așteptare. Putem regla timpul de așteptare prin settimeout.
Valoarea returnată este un tuplu cu octeții citiți și adresa de la care au fost trimiși:
print("Date primite: ", data)
print("De la adresa: ", address)
Folosim funcția sendto
pentru a transmite octeți către o adresă de tip tuplu. Putem trimite înapoi un string prin care confirmăm primirea mesajului:
payload = bytes('Am primit: ', 'utf-8') + data
sent = sock.sendto(payload, address)
print ("Au fost trimisi ", sent, ' bytes')
În cele din urmă putem închide socket-ul folosind metoda close()
sock.close()
Pentru a putea trimite mesaje, clientul trebuie să folosească adresa IP și port corespunzătoare serverului:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, proto=socket.IPPROTO_UDP)
port = 10000
adresa = 'localhost'
server_address = (adresa, port)
În python3, un string nu poate fi trimis prin socket decât dacă este convertit în șir de octeți. Conversia se poate face fie prin alegerea unei codificări:
mesaj = "salut de la client, 你好"
print(type(mesaj))
# encoding utf-16
octeti = mesaj.encode('utf-16')
print (octeti)
print(type(octeti))
În linux codificarea default este UTF-8, un format în care unitățile de reprezentare a caracterelor sunt formate din 8 biți. Pentru a printa literele în terminal ar fi bine să folosim UTF-8. Caracterele ASCII putem să le convertim în bytes punând litera b
în față:
octeti = b"salut de la client cu" # nu merg caractere non-ascii 'țășîâ'
# apelam string encode
octeti = octeti + "你好".encode('utf-8')
# sau apelam constructorul de bytes cu un encoding
octeti = octeti + bytes("你好", 'utf-8')
print (octeti)
Observăm aici că nu am apelat metoda bind ca în server, dar cu toate astea metoda sendto
pe care o folosim pentru a trimite octeții alocă implicit un port efemer pe care îl utilizează pentru a trimite și primi mesaje.
sent = sock.sendto(octeti, server_address)
Prin același socket putem apela si metoda de citire, în cazul de față 18 bytes din buffer. În cazul în care serverul nu trimite înapoi niciun mesaj, apelul va bloca programul indefinit până când va primi mesaje în buffer.
data, adresa_de_la_care_primim = sock.recvfrom(18)
print(data, adresa_de_la_care_primim)
În cele din urmă pentru a închide conexiunea și portul, apelăm funcția close:
sock.close()
O diagramă a procesului anterior este reprezentată aici:
În directorul capitolul2/src aveți două scripturi udp_server.py și udp_client.py. Spre deosebire de exemplul prezentat mai sus, serverul stă în continuă aștepatre de mesaje iar clientul trimite mesajul primit ca prim argument al programului.
python3 udp_server.py
și python3 udp_client.py "mesaj de trimis"
.docker compose exec rt1 bash
. Pe rt1 folositi calea relativă montată în directorul elocal pentru a porni serverul: python3 /elocal/src/udp_server.py
.docker compose exec rt2 bash -c "python3 /elocal/src/udp_client.py salut"
docker compose exec rt1 bash
. Utilizați tcpdump -nvvX -i any udp port 10000
pentru a scana mesajele UDP care circulă pe portul 10000. Apoi apelați clientul pentru a genera trafic.Este un protocol mai avansat de la nivelul transport. Header-ul acestuia este mai complex și va fi explicat în detaliu în capitolul3:
0 1 2 3 Offs.
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Source Port | Destination Port | 1
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Sequence Number | 2
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Acknowledgment Number | 3
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Data |0 0 0| |C|E|U|A|P|R|S|F| |
|Offset | Res.|N|W|C|R|C|S|S|Y|I| Window | 4
| | |S|R|E|G|K|H|T|N|N| |
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Checksum | Urgent Pointer | 5
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Options (if data offset > 5) |
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
| Application data |
-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-
Câteva caracteristici ale protocolului sunt descrise aici. Înainte de a face pașii de mai jos, urmăriți partea de curs acoperită în mare parte aici. Între un client și un server se execută un proces de stabilire a conexiunii prin three-way handshake.
Server-ul se instanțiază cu AF_INET, SOCK_STREAM
(fiindcă TCP operează la nivel de byte streams) și IPPROTO_TCP
pentru specificarea protocolului TCP.
# TCP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=socket.IPPROTO_TCP)
port = 10000
adresa = 'localhost'
server_address = (adresa, port)
sock.bind(server_address)
Protocolul TCP stabilește o conexiune între client și server prin acel 3-way handshake explicat aici. Numărul de conexiuni în aștepare se poate stabili prin metoda listen
, apel prin care se marchează în același timp socketul ca fiind gata să accepte conexiuni.
sock.listen(5)
Metoda accept
este una blocantă și stă în așteptarea unei conexiuni. În cazul în care nu se conectează niciun clinet la server, metoda va bloca programul indefinit. Altfel, când un client inițializează 3-way handshake, metoda accept construieste un obiect de tip socket nou prin care se menține conexiunea cu acel client în mod specific.
while True:
conexiune, addr = sock.accept()
time.sleep(30)
# citim 16 bytes in bufferul asociat conexiunii
payload = conexiune.recv(16)
# trimitem înapoi un mesaj
conexiune.send("Hello from TCP!".encode('utf-8'))
# închidem conexiunea, dar nu și socket-ul serverului care
# așteaptă alte noi conxiuni TCP
conexiune.close()
sock.close()
Clientul trebuie să folosească adresa IP și portul cu serverului:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=socket.IPPROTO_TCP)
port = 10000
adresa = 'localhost'
server_address = (adresa, port)
Prin apelul funcției connect, se inițializează 3-way handshake și conexiunea cu serverul.
# 3-way handshake creat
sock.connect(server_address)
# trimite un mesaj
sock.send("Mesaj TCP client".encode('utf-8'))
# primeste un mesaj
data = sock.recv(1024)
print (data)
# inchide conexiunea
sock.close()
O diagramă a procesului anterior este reprezentată aici:
În directorul capitolul2/src aveți două scripturi tcp_server.py și tcp_client.py.
python3 tcp_server.py
și python3 tcp_client.py "mesaj de trimis"
.docker compose exec rt1 bash -c "python3 /elocal/src/tcp_server.py"
.docker compose exec rt1 bash -c "python3 /elocal/src/tcp_client.py TCP_MESAJ"
docker compose exec rt1 bash -c "tcpdump -Snnt tcp"
pentru a porni tcpdump pe rt1.Exemplu 3-way handshake captat cu tcpdump:
tcpdump -Snn tcp
Clientul apelează funcția connect((‘198.13.0.14’, 10000)) iar mesajul din spate arată așa:
IP 172.111.0.14.59004 > 198.13.0.14.10000: Flags [S], seq 2416620956, win 29200, options [mss 1460,sackOK,TS val 897614012 ecr 0,nop,wscale 7], length 0
În acest punct lucrurile se întâmplă undeva în interiorul funcției accept din server la care nu avem acces. Serverul răspunde prin SYN-ACK:
IP 198.13.0.14.10000 > 172.111.0.14.59004: Flags [S.], seq 409643424, ack 2416620957, win 28960, options [mss 1460,sackOK,TS val 2714984427 ecr 897614012,nop,wscale 7], length 0
După primirea cererii de sincronizare a serverului, clientul confirmă primirea, lucru care se execută în spatele funcției connect:
IP 172.111.0.14.59004 > 198.13.0.14.10000: Flags [.], ack 409643425, win 229, length 0
La trimiterea unui mesaj, se folosește flag-ul push (PSH) și intervalul de secventă de dimensiune 1:
IP 172.111.0.14.59004 > 198.13.0.14.10000: Flags [P.], seq 2416620957:2416620958, ack 409643425, win 229, length 1
Serverul dacă primește mesaju, trimite automat un mesaj cu flag-ul ACK și Ack Nr numărul de octeți primiți.
IP 198.13.0.14.10000 > 172.111.0.14.59004: Flags [.], ack 2416620958, win 227, length 0
În scapy avem mai multe funcții de trimitere a pachetelor:
send()
- trimite un pachet pe rețea la nivelul network (layer 3), iar secțiunea de ethernet este completată de către sistemanswered, unanswered = sr()
- send_receive - trimite pachete pe rețea în loop și înregistrează și răspunsurile primite într-un tuplu (answered, unanswered), unde answered și unanswered reprezintă o listă de tupluri [(pachet_trimis1, răspuns_primit1), …,(pachet_trimis100, răspuns_primit100)]answer = sr1()
- send_receive_1 - trimite pe rețea un pachet și înregistrează primul răspunsulPentru a trimite pachete la nivelul legatură de date (layer 2), completând manual câmpuri din secțiunea Ethernet, avem echivalentul funcțiilor de mai sus:
sendp()
- send_ethernet trimite un pachet la nivelul data-link, cu layer Ether customanswered, unanswered = srp()
- send_receive_ethernet trimite pachete la layer 2 și înregistrează răspunsurileanswer = srp1()
- send_receive_1_ethernet la fel ca srp, dar înregistreazî doar primul răspuns