Laboratorul 06 - Protocolul UDP

Lectura Laborator

Inainte de laborator

Pentru a simula o retea virtuala vom folosi Mininet. Vom avea nevoie de pachetele mininet si openvswitch-testcontroller.

 sudo apt update
 sudo apt install mininet openvswitch-testcontroller xterm
 sudo pip3 install mininet

Pentru a avea un font mai mare in terminalele deschise de mininet:

 echo "xterm*font: *-fixed-*-*-*-18-*" >> ~/.Xresources
 xrdb -merge ~/.Xresources

Nivelul Transport

Porturi

Porturile sunt conceptul ce ne ajuta sa facem multiplexare la nivel de aplicatie.

In mod obisnuit, cand ne referim la un port, ne gandim la un slot fizic ce asigura legatura dintre un calculator si dispozitive periferice (tastatura, imprimanta) sau dintre calculator si Internet (cablu Ethernet).

In contextul retelelor de comunicatie, un port este un numar asociat unei aplicatii (nu unui host). Daca o aplicatie doreste sa comunice cu alte aplicatii (aflate pe masini diferite sau aplicatii ce ruleaza local), aceasta expune un port, o locatie logica prin care accepta conexiuni si prin care se realizeaza schimbul de date.

Aceste numere permit aplicatiilor sa partajeze concurent resursele de retea.(Serverul de mail nu asteapta terminarea altor procese ce implica reteaua (web surfing) pentru a putea trimite un mail la destinatie).

Numerele sunt reprezentate pe 2 octeti, de la 0 la 65535, insa cele pana la 1024 sunt rezervate pentru aplicatii standard, precum:

  • FTP (File Transfer Protocol, transfer date): 20,21
  • SSH (Secure Shell): 22
  • Telnet (Login remote, mesaje text necriptate): 23
  • SMTP (Simple Mail Transfer Protocol): 25
  • HTTP (Hypertext Transfer Protocol, folosit in WWW): 80
  • POP3 (Post Office Protocol): 110
  • IMAP (Internet Message Access Protocol): 143

O lista completa a porturilor rezervate gasiti pe site-ul IANA

Pentru a identifica o aplicatie cu care vrem sa comunicam este nevoie de adresa IP a masinii pe care ruleaza si portul deschis de aplicatie. De exemplu, prin localhost:80 solicitam accesul la aplicatia de pe masina locala ce ofera servicii web.

UDP

Serviciu neorientat conexiune: nu se stabileste o conexiune intre client si server. Asadar, serverul nu va asteapta apeluri de conexiune, ci asteapta direct datagrame de la clienti. Acest tip de comunicare este intalnit in sistemele client-server in care se transmit putine mesaje si in general prea rar pentru a mentine o conexiune activa intre cele doua entitati. (Un exemplu in acest caz este DNS-ul).

Nu se garanteaza ordinea primirii mesajelor si nici prevenirea pierderilor pachetelor. UDP-ul se utilizeaza mai ales in retelele in care exista o pierdere foarte mica de pachete si in cadrul aplicatiilor pentru care pierderea unui pachet nu este foarte grava (Un exemplu: aplicatiile streaming video).

Are un overhead foarte mic, in comparatie cu celelalte protocoale de transport (Are un header de 8 bytes, in comparatie cu TCP-ul care are minim 20 bytes)

Header UDP

Un segment UDP se numeste datagrama si este format dintr-un header de 8 bytes, urmat de date (In general ne vom referi la datele transmise de aplicatie drept payload) dupa cum putem observa in figura:

Portul sursa este ales random de catre masina sursa a pachetului dintre porturile libere existente pe acea masina. Este un numar pe 16 biti, intre 0 si 65535. Identifica procesul UDP care a transmis segmentul.

Portul destinatie este portul pe care masina destinatie poate receptiona pachete. Identifica procesul UDP care va procesa datele primite.

Length este lungimea in octeti (bytes) a datagramei (header size + data size).

Checksum este valoarea sumei de verificare pentru datagrama. In sectiunea urmatoare este prezentat modul de calcul a checksum-ului pentru un pachet UDP.

In cadrul laboratorului nu vom interactiona direct cu header-ul UDP ci doar cu datele carate prin acest protocol. Vom utiliza un API care ne va returna continutul unui datagrame UDP primita de catre sistemul de operare Linux.

Transport reliable peste UDP

In unele cazuri, nu avem nevoie de transmisie reliable peste UDP (e.g. streaming web unde daca pierdem cateva cadre, putem interpola la receiver). Totusi, deseori avem nevoie sa stim cu certitudine ca un mesaj a ajuns in partea cealalta. Pentru a rezolva aceasta problema vom studia implementari de tip sliding window, mai exact cazul simplu in care fereastra de transmisie este egala cu 1.

Un exemplu de protocol reliable peste UDP este Quick UDP Internet Connections (QUIC) dezvoltat de Google in 2012.

Stop and Wait

Unul dintre cele mai simple metode de a transmite datele peste un mediu in care datagramele se pot pierde este protocolul stop and wait. In cadrul protocolului, sender-ul va trimite un frame si la primirea unei datagrame de ACK, va trimite urmatorul frame. Sender-ul foloseste un timeout pentru retransmisie in cazul in care o datagrama este pierduta.

Stop and wait este un caz particular de protocol ce foloseste sliding windows, in cazul acesta windows_size = 1. Pentru a intelege mai bine conceptul de fereastra glsianta va recomandam urmatorul simulator ce implementeaza doi algoritmi diferiti: go-back-n si selective repeat.

De notat faptul ca aceasta metoda este generala si nu se aplica doar la nivelul transport. TCP foloseste o fereastra de transmitere, dar intalnim acest concept si la nivele mai joase din stiva OSI.

Sockets

In cadrul laboratorului nu vom implementa protocolul UDP ci vom folosi implementarea existenta din kernel-ul de Linux. Acest lucru se realizeaza prin intermediul API-ului de sockets. Un socket este un canal generalizat de comunicare intre procese, reprezentat in Unix print-un descriptor de fisiere*. El ofera posibilitatea de comunicare intre procese aflate pe masini diferite intr-o retea.

* Un file descriptor este un handle prin care un proces comunica cu resursele sistemului.

In continuare, se vor prezenta principalele functii pentru manipularea socketilor,

Comunicare client-server UNIX

Intr-o arhitectura client-server, clientul trimite request-uri (cere resurse) catre server, iar acesta din urma trimite inapoi un raspuns (cu resursa).

Pasi urmati pentru a schimba mesaje folosind UDP la nivelul Transport folosind API-ul de sockets sunt urmatorii:

  1. Deschide un socket unix in scopul de a permite comunicarea intre procese/statii diferite folosind descriptori de fisiere (file descriptors) cu apelul socket().
  2. Asociaza o adresa pentru socketul deschis cu apelul bind(). In general, folosim bind() atunci cand dorim sa asteptam conexiuni pe un anumit port. Bind este chemat pe server pentru a specifica la ce port sa lege socket-ul.
  3. Trimite/Receptioneaza date cu apelul recvfrom()/sendto().
  4. Inchide socket prin close().

socket()

Pentru a afla mai multe informatii, putem accesa urmatorul capitol5.2 socket()—Get the File Descriptor!

#include <sys/types.h>
#include <sys/socket.h>
 
/* creare socket in C */
/* int socket(int domain, int type, int protocol); */
 
/* pentru UDP, folosim un socket de tip SOCK_DGRAM */
int sockid = socket(PF_INET, SOCK_DGRAM, 0);
 
if (sockid == -1) {
  /* trateaza eroare */
}

Explicatii:

  • sockid - file descriptor pentru socket. In caz de eroare se intoarce -1 si se seteaza variabila errno
  • domain - reprezinta familia protocoalelor pe care urmeaza sa le utilizam in transferul informatiei. Vom folosi valorile *PF_INET* pentru IPv4 sau *PF_INET6* pentru IPv6.
  • type - reprezinta tipul socketului. Valori uzuale:
  • SOCK_STREAM - Indicata stabilirea unei comunicatii bazata pe construirea unei conexiuni intre sursa si destinatie. Comunicatia este FIFO, fiabila si sigura, o vom folosi la laboratorul urmator cu TCP.
  • SOCK_DGRAM - Ofera un flux de date bidirectional, care nu promite sa fie sigur, in secventa sau neduplicat. Un proces care receptioneaza mesaje pe un socket datagrama, poate gasi mesaje duplicate si posibil intr-o ordine diferita fata de cea in care au fost trimise.
  • protocol - specifica protocolul de transport utilizat. Valorea campului este bine sa fie 0 pentru a se alege protocolul corect in functie de type.
bind()

Utilizata in server pentru lega un socket de un port si eventual de un subnet. Pentru a afla mai multe informatii, putem accesa 5.3 bind()—What port am I on?.

#include <sys/types.h>
#include <sys/socket.h>
 
/*int bind(int sockfd, struct sockaddr *my_addr, int addrlen)*/
 
struct sockaddr myaddr;
memset(&myaddr, 0, sizeof(servaddr));
myaddr.sin_family = AF_INET; // IPv4
/* INADDR_ANY = 0.0.0.0 as uint32 */
myaddr.sin_addr.s_addr = INADDR_ANY;
myaddr.sin_port = htons(atoi(8888));
 
int rs = bind(sockfd, myaddr, sizeof(servaddr);
/* in urma apelului, sockfd va avea adresa my_addr */
if (rs == -1) {
  /* trateaza eroare* /
}

Explicatii:

  • sockfd - Descriptorul de fisier returnat de socket()
  • myaddr - Structura sockaddr ce contine informatii despre adresa IP si port
  • addrlen - lungimea lui my_addr

Observam ca apelul bind utilizeaza o variabila de tip struct sockaddr_in.

struct sockaddr_in
#include <netinet/net.h>
 
/* tip de date pentru a retine adresa unui socket in cazul comunicatiei prin Internet
 * are nevoie de un numar de port si adresa IP a calculatorului
 */
struct sockaddr_in {
  unsigned short sin_family;
  unsigned short int sin_port;
  struct in_addr sin_addr;
}

Explicatii:

  • sin_family - indica formatul particular de adresa: AF_INET (protocolul IPv4), AF_INET6 (protocol IPv6)
  • sin_port - portul utilizat in NETWORK BYTE ORDER
  • sin_addr - adresa IP in NETWORK BYTE ORDER, in format in_addr mentionat mai jos
recvfrom()/ sendto()

Functile sunt folosite pentru a primi/trimite o datagrama peste un socket. Mai multe detalii gasiti aici.

#include <sys/types.h>
#include <sys/socket.h>
 
struct sockaddr to;
// Filling server information
memset(&to, 0, sizeof(servaddr));
to.sin_family = AF_INET;
to.sin_port = htons(8888);
int rc = inet_aton("127.0.0.1", &to.sin_addr);
 
 
int byteswrite = sendto(int sockfd, char *buff, int nbytes, int flags, struct sockaddr *to, int addrlen);
if (byteswrite == -1) {
  /* trateaza eroare */
}
 
/* from va fi populata de apelul recvfrom si va contine informatii despre cine a trimis datagrama catre noi */
struct sockaddr from;
int bytesread = recvfrom(int sockfd, char *buff, int nbytes, int flags, struct sockaddr *from, int *addrlen);
if (bytesread == -1) {
  /* trateaza eroare */
}

Explicatii:

  • sockfd - Descriptorul de fisier returnat de socket()
  • buff - Bufferul unde se gasesc datele ce urmeaza a fi trimise/ bufferul unde se vor receptiona datele
  • flags - Specifica conditii de efectuare a transmisiei
  • to/from - Structura ce indica adresa unde se trimite/ de unde se primesc date. In cazul lui recvfrom() se populeaza de functie.
  • addrlen - Lungimea structurii to/from in octeti
close()/ shutdown()

Pentru a inchide un socket se foloseste functia de inchidere a unui descriptor de fisier din Unix:

#include <unistd.h>
 
int close(ind fd);

Acest lucru va impiedica alte citiri si scrieri din socket. Pentru mai mult control asupra socketului, se foloseste functia shutdown(), care permite intreruperea comunicatiei selectiv, schimband modul de utilizare a legaturii full-duplex.

Pentru a afla mai multe informatii, putem accesa 5.9 close() and shutdown()—Get outta my face!.

#include <sys/socket.h>
 
int shutdown(int sockfd, int how);

Explicatii:

  • sockfd - Descriptorul de fisier returnat de socket()
  • how - Specifica modul de inchidere: 0 - Nu se mai citesc date. 1 - Nu se mai pot face transmiteri de date. 2 - Se intrerupe comunicatia in ambele directii.

Shutdown() nu inchide un descriptor de fisier, ci doar ii schimba modul de utilizare. Resursele trebuie eliberate folosind close() la final.

Beyond C/C++

API-ul de sockets este o o abstractizare foarte raspandita. Practic majoritatea interactiunilor cu internetul o sa fie prin intermediul API-ul de sockets din sistemul de operare. Spre exemplu, orice implica networking in python functioneaza peste o abstractizare de sockets din limbaj care cheama apelurile de sistem de linux de sockets. In codul de mai jos putem vedea un astfel de exemplu. De notat ca la fel se intampla si in Java, Haskell, etc.

Click to display ⇲

Click to hide ⇱

import socket
 
localIP     = "127.0.0.1"
localPort   = 20001
bufferSize  = 1024
 
msgFromServer       = "Hello UDP Client"
bytesToSend         = str.encode(msgFromServer)
 
# Create a datagram socket
UDPServerSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
# Bind to address and ip
 
UDPServerSocket.bind((localIP, localPort))
print("UDP server up and listening")
 
# Listen for incoming datagrams
while(True):
    bytesAddressPair = UDPServerSocket.recvfrom(bufferSize)
    message = bytesAddressPair[0]
    address = bytesAddressPair[1]
 
    clientMsg = "Message from Client:{}".format(message)
    clientIP  = "Client IP Address:{}".format(address)
 
    print(clientMsg)
    print(clientIP)
 
    # Sending a reply to client
    UDPServerSocket.sendto(bytesToSend, address)

Exercitii

Porniti de la urmatorul schelet de cod.

[Task-ul 1]: Plecând de la scheletul de cod oferit, scrieți o pereche de programe (client și server) care să permită transferul unui fișier binar de la client la server.

Acest exercitiu va fi rulat pe masina locala, nu este necesar sa folositi mininet.

Pentru a genera fișierul de test folosiți comanda:

dd if=/dev/urandom of=file.bin bs=512 count=1000

Pentru a vedea dacă fișierul s-a trimis corect, folosiți md5sum. Sumele de control ale celor 2 fișiere trebuie să fie identice.

Specificații:

  1. Clientul trimite un fisier binar prin UDP, iar serverul il va receptiona datele si le va scrie in fisierul lui.
  2. Fișierul trimis de client se va numi fisier.bin, iar fișierul salvat de server se va numi received_file.bin.
  3. Clientul va transmite fișierul în calupuri de câte 1024 octeți.

La final, vom studia folosind Wireshark datagramele trimise de catre client.

Mai mult, daca sunteti in aceasi retea (e.g. pe acelasi WiFi) va puteti grupa cate doi pentru a trimite un fisier de la unu la celalalt. Mai exact, unul dintre voi va porni un server cu bind pe INADDR_ANY (0.0.0.0), iar clientul va putea trimite catre ip-ul celuilalt un fisierul. Felicitari, ati facut o aplicatie simpla de transfer de fisiere.

[Task-ul 2]:

Dacă ați rulat task-ul 1 cu ambele componente pe aceeași mașină, probabil totul a mers bine, însă o soluție naivă (care trimite datagrame doar de la sender la receiver), ascunde 2 probleme:

  1. Dacă transmițătorul este mai rapid decât receptorul, acesta din urma poate fi inundat și să nu reușească să proceseze la timp datagramele;
  2. Două situații foarte posibile în cazul comunicării către mașini diferite (pierderea unei datagrame sau inversarea ordinii datagramelor) sunt netratate.

Pentru a rezolva aceste probleme, vom implementați un protocol de tipul stop-and-wait.

Acest exercitiu va fi rulat doar pe mininet. Lansați-l doar folosind comanda:

sudo python3 topology.py

2.1 Folosind simulatorul de la aceasta adresa vom simula protocolul stop and wait. Vom selecta urmatorii parametrii in protocol: protocol selective repeat si windows size 1. Pentru a pierde un pachet in timpul sumularii vom da click pe acesta.

2.2 Plecând de la scheletul de cod pentru task-ul 2 implementati protocolul stop-and-wait. Vom folosi mininet pentru a simula un link defectuos intre un sender (host 1) si u nreceiver (host 2). Topologia este un simpla:

    L1          L2
h1 <--> router <--> h2

Link 1 - 10 Mbps, 5ms delay, 5% packet loss

Link 2 - 10 Mbps, 5ms delay, 6% packet loss

Pentru a obține condiții de rețea relevante (cu limitări de bandă și pierderi de pachete) rulați acest task cu mininet folosind comanda

sudo python3 topology.py

În urma rulării comenzii, vi se vor deschide două terminale, pe două host-uri având atribuite următoarele adrese:

h1 - 192.168.1.100
h2 - 172.16.0.100

Pentru implementarea mecanismelor de timeout necesare, va trebui ca apelul de recvfrom să nu se mai blocheze la infinit în lipsa unor noi mesaje (așa cum face implicit). Pentru asta, puteți folosi setsockopt ca mai jos:

struct timeval tv;
tv.tv_sec = 1;
tv.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof(tv));

Ulterior, caz de timeout recvfrom va întoarce -1 și va seta errno la EAGAIN sau EWOULDBLOCK.

Task 3. Bonus. Implementati un protocol ce foloseste sliding window, precum go-back-n sau selective repeat. Va recomandam simulatorul acesta pentru a intelege mai bine functionalitatea.

Task 4. Bonus. Pentru topologia de mai sus si valorile date pentru L1 si L2, care este dimensiunea optima a ferestrei sender-ului?

Putem folosi urmatoare forumla pentru a determina dimensiunea in Mb: Optimal size = (size of the link in Mb/s) x (round trip delay in seconds) Afland dimensiunea maxima a unei datagrame UDP putem calcula dimensiunea optima a ferestrei.

Extra/Hints

Pentru a intelege mai bine conceptul de byte order, va recomandam urmatorul articol byte order

Pentru a face trecerea campului sin_addr din struct sockaddr_in in string si invers avem functiile: inet_ntoa si inet_aton.

Observatie: Cateodata, cand se incearca sa se reporneasca un _server_, bind() nu reuseste sa asigneze, iar eroarea este “Address already in use”. Asta inseamna ca un socket care a fost conectat pe acel port inca mai este agatat si utilizeaza portul. In aceasta situatie, fie se poate astepta deconectarea portului respectiv, fie se specifica programatic reutilizarea portului cu setsockopt()

int enable = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)) < 0)
    perror("setsockopt(SO_REUSEADDR) failed");

O posibila solutie a laboratorului se gaseste aici

pc/laboratoare/06.txt · Last modified: 2022/04/13 15:06 by vlad_andrei.badoiu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0