next up previous contents
Next: PF_UNIX Up: Gniazda sieciowe - podstawy Previous: TCP: SOCK_STREAM   Contents

SOCK_DGRAM (UDP)

W ten sposób poznaliśmy ogólne metody wykorzystywane do tworzenia serwerów oraz klientów pracujących w oparciu o komunikację połączeniową (SOCK_STREAM). Czas najwyższy zająć się drugim rodzajem komunikacji - bezpołączeniową (SOCK_DGRAM). Kwestię tą omówimy ponownie w odniesieniu do domeny Internetowej (PF_INET), w której datagramy obsługiwane są poprzez protokół UDP (ang. User Datagram Protocol). Postaramy się o stworzenie pary programów (klient i serwer) do przesyłania plików tekstowych. Zaczniemy tym razem od serwera:
  #define _XOPEN_SOURCE
  #include <unistd.h>             /* crypt()                                   */
  #include <sys/types.h>
  #include <sys/socket.h>         /* socket(), connect(), recv(),send(),bind() */
  #include <linux/in.h>           /* struct sockaddr_in                        */
  #include <stdio.h>              /* perror()                                  */
  #include <sys/stat.h>
  #include <fcntl.h>              /* open()                                    */
  
  #define SRVPORT 1111            /* numer portu serwera                       */
  #define CLIPORT 31337           /* numer portu gniazda klienta               */
  #define PASSWD  "sikret"        /* hasło wymagane do połączenia z serwerem   */
  #define CSALT   "Zz"            /* dwuznakowy tzw. salt dla funkcji crypt()  */
  #define PLIK    "/proc/meminfo" /* plik, który wyślemy klientowi             */
  
Tylko jeden komentarz: wszystkie makra oprócz ostatniego muszą być tak samo zdefiniowane zarówno w programie serwera, jak i klienta. W przeciwnym wypadku klient nie będzie w stanie komunikować się z serwerem. Dlaczego tak będzie zobaczymy w dalszej części programu.
  int main (void)
  {
    int                   sd, /* deskryptor gniazda         */
                          fd, /* deskryptor pliku           */
                         ret, /* pomocnicza                 */
    struct sockaddr_in saddr, /* adres lokalnego gniazda    */
                       caddr; /* adres zdalnego gniazda     */
    char           buf[1024], /* bufor                      */
                    hash[20]; /* bufor na zahashowane hasło */
  
    sd = socket (PF_INET, SOCK_DGRAM, 0);
    if (sd < 0)
      {
        perror ("socket()");
        exit (1);
      }
  
    saddr.sin_family = PF_INET;
    saddr.sin_port = htons (SRVPORT);
    saddr.sin_addr.s_addr = INADDR_ANY;
  
    if (bind (sd, (struct sockaddr *) &saddr, sizeof (saddr)) < 0)
      {
        perror ("bind()");
        exit (1);
      }
  
Do tej pory postępowaliśmy identycznie, jak w przypadku serwera działającego na bazie TCP. Otworzyliśmy więc lokalne gniazdo (PF_INET, SOCK_DGRAM) )i przypisaliśmy mu adres żeby zdalni klienci byli w stanie wysyłać do nas datagramy (gniazdo musi mieć przypisany adres aby możliwa była komunikacja z nim).
    sprintf (hash, "%s", crypt (PASSWD, CSALT));
  printf ("Czekam na datagramy ...\n");
  
Następnie obliczyliśmy tzw. one way hash na podstawie hasła (PASSWD) i tzw. salt. To ostatnie jest prawie dowolnym dwuliterowym ciągiem znaków ([a-zA-Z0-9./]) i jest stosowane przez algorytm liczenia funkcji hashującej. Ta sama funkcja (crypt()) jest używana w większości systemów unixowych do weryfikacji haseł użytkowników. Użyliśmy tej funkcji aby uniemożliwić przejęcie hasła w czystej postaci poprzez podsłuchiwanie sieci.

W następnej kolejności wchodzimy do pętli, w której będziemy obsługiwali nadchodzące żądania od klientów:

    while (1)
      {
        bzero (buf, sizeof (buf));
        len = sizeof (caddr);
        recvfrom (sd, buf, sizeof (buf), 0, (struct sockaddr *) &caddr, &len);
  
  // Widzimy wspomnianą jakiś czas temu funkcję recvfrom() ale że nie przyglądaliśmy się jej wtedy dokładnie zrobimy to teraz ({\sl man 2 recvfrom}):
  
  #include <sys/types.h>
  #include <sys/socket.h>
  
  int recvfrom(int s, void *buf, int len, unsigned int flags
               struct sockaddr *from, int *fromlen);
  
Funkcja ta jest bardziej uniwersalnym odpowiednikiem recv(). Bardziej uniwersalnym ponieważ recv() to po prostu recvfrom(s,buf,len,flags,NULL,NULL). W praktyce jest tak że, dla połączeń opartych na datagramach używa się recvfrom(), a dla połączeń strumieniowych recv(). Pierwsze cztery parametry oraz zwracana wartość mają takie samo znaczenie, jak dla recv(). Pozostają jeszcze dwa parametry:

from - Wskaźnik do struktury sockaddr. fromlen - Wskaźnik do wartości przechowującej wielkość parametru from.

Przypomina się funkcja accept() ... Nieprzypadkowo, ponieważ oba parametry mają to samo zadanie, co w tej funkcji. Wywołanie recvfrom() blokuje do czasu, aż nadejdą jakieś dane do odczytania. Kiedy tak się stanie struktura wskazywana przez *from zostanie wypełniona adresem gniazda, z którego te dane nadeszły. Teraz już pewnie domyślamy się, dlaczego do jednego rodzaju gniazd używa się recv(), a do innego recvfrom(). Połączenia strumieniowe można określić mianem połączeń punkt-punkt (ang. point-to-point, nie mylić z PPP ;) i z chwilą kiedy wywołanie accept() zwróci adres zdalnego gniazda mamy pewność, że dalsza wymiana danych będzie przebiegała tylko z jednym konkretnym gniazdem zdalnym. Dlatego też nie musimy za każdym razem przy odbiorze danych wypełniać od nowa struktury z adresem klienta. Inaczej wygląda komunikacja korzystająca z datagramów (celowo nie używamy słowa "połączenie") - paczki danych wędrują sobie, każdy swoją drogą od klienta do serwera. Nie następuje więc logiczne skojarzenie pary gniazd. Z tego powodu zawsze gdy odczytujemy otrzymane dane musimy wiedzieć, kto je do nas przysłał. Informację tą podaje nam recvfrom() w strukturze sockaddr klienta.

Oczywiście jest i odpowiednik send(). Nazywa się sendto() i wygląda tak (man 2 sendto):

  #include <sys/types.h>
  #include <sys/socket.h>
  
  int sendto(int s, const void *msg, int len, unsigned int flags, 
             const struct sockaddr *to, int tolen);
  
Wszystko, co powiedzieliśmy na temat podobieństwa recv() i recvfrom() odnosi się także do pary send()/sendto(). Struktura sockaddr w tym wywołaniu zawiera oczywiście adres gniazda, do którego zamierzamy wysłać datagram.

Jak widać recvfrom()/sendto() są funkcjami raczej mało wygodnymi w użyciu. Dużo mniej pisania wymaga para recv()/send(). Żeby było ciekawiej, istnieje możliwość używania recv()/send() na gniazdach bezpołączeniowych ... W jaki sposób tego dokonać zobaczymy w kodzie klienta.

Tymczasem popatrzmy, co dalej dzieje się w pętli:

        printf ("Datagram od %s:%i ... ", inet_ntoa (caddr.sin_addr.s_addr),
  ntohs (caddr.sin_port));
  
        if (!strncmp (buf, hash, strlen (hash))
            && ntohs (caddr.sin_port) == CLIPORT)
          {
            printf ("Przyjęty.\n");
            sendto (sd, "OK\n", 3, 0, (struct sockaddr *) &caddr,
                    sizeof (caddr));
  
Ten fragment dokonuje sprawdzenia, czy klient jest upoważniony do komunikacji z serwerem. Wybraliśmy procedurę autentyfikacji klienta opartą na dwóch elementach: haśle i porcie źródłowym (porcie, z którego nadaje klient). Serwer oczekuje od klienta datagramu zawierającego ciąg (hash) utworzony na maszynie klienta w ten sam sposób, co na serwerze (funkcja crypt()). Jeśli wyniki działania funkcji hashujących na serwerze i na kliencie pokrywają się oraz jeśli port źródłowy zgadza się z wcześniej ustalonym po obu stronach (CLIPOR) to serwer wysyła w stronę klienta datagram zawierający odpowiedź 'OK'.
            fd = open (PLIK, O_RDONLY);
            if (fd < 1)
              {
                perror ("open()");
                sendto (sd, "Błąd: open()\n", 13, 0, (struct sockaddr *) &caddr,
                        sizeof (caddr));
              }
  
            bzero (buf, sizeof (buf));
            while (read (fd, buf, sizeof (buf)) > 0)
              {
                sendto (sd, buf, strlen (buf), 0, (struct sockaddr *) &caddr,
                        sizeof (caddr));
                bzero (buf, sizeof (buf));
              }
            close (fd);
  
            sendto (sd, "END\n", 4, 0, (struct sockaddr *) &caddr,
                    sizeof (caddr));
          }
  
Następnie otwieramy wcześniej zdefiniowany plik i po kawałku przesyłamy go do klienta. Kiedy cały plik zostanie przetransmitowany wysyłamy klientowi jeszcze ciąg 'END' aby poinformować, że transmisja się powiodła.
        else
          {
            printf ("Odrzucony.\n");
            sendto (sd, "Błąd: złe hasło albo port źródłowy\n", 4, 0,
                    (struct sockaddr *) &caddr, sizeof (caddr));
          }
      }
  }
  
Blok else wykonuje się jeśli któryś z elementów autentyfikacji (hasło, port źródłowy) jest niepoprawny. Tutaj też kończy się pętla while oraz cały kod serwera.

Jakie różnice w stosunku do serwera TCP ? Nie używalismy funkcji listen() oraz accept(). Wystarczyło otworzyć gniazdo korzystające z UDP (PF_INET, SOCK_DGRAM) i odbierać po kolei wszystkie datagramy, które nadchodzą na to gniazdo. Nie zachodziła też konieczność tworzenia oddzielnego procesu dla każdego klienta. Dlaczego ? Ponieważ lokalne gniazdo nie było skojarzone z pojedyńczym gniazdem zdalnym. Wystarczyło jedno gniazdo aby obsłużyć wszystkich chętnych. Pamiętajmy jednak, że mieliśmy do czynienia z bardzo prostą wymianą informacji opartą na systemie pytanie-odpowiedź. W bardziej skomplikowanych przypadkach uzasadnione może stać się przydzielenie każdemu klientowi osobnego procesu. Nasz przykładowy serwer ma przynajmniej jedną sporą wadę - nie gwarantuje, że dane dotrą w niezmienionej postaci do klienta i, że w ogóle tam dotrą ! Używając funkcji send() na serwerze TCP mieliśmy pewność, że pakiety docierają do klienta. Troszczył się o to protokół TCP. Wysyłał on klientowi po jednym pakiecie i oczekiwał od niego potwierdzenia, że pakiet pomyślnie dotarł do celu. Jeśli w pewnym przedziale czasu nie otrzymał takiego potwierdzenia to próbował jeszcze kilka razy aż do skutku. Jeśli mimo to klient nie dawał znaku życia to warstwa TCP sygnalizowała błąd naszemu serwerowi. Mechanizmem takim nie dysponuje niestety protokół UDP. Jeśli chcielibyśmy mieć pewność, że dane przesyłane za jego pośrednictwem pomyślnie dotarły do celu musielibyśmy osobiście zaprogramować podobną procedurę weryfikacyjną, którą ma już wbudowaną protokół TCP.

Kod klienta niewiele różni się w zasadniczych kwestiach od kodu serwera:

  #define _XOPEN_SOURCE
  #include <unistd.h>             /* crypt()                                  */
  #include <sys/types.h>
  #include <sys/socket.h>         /* socket(), connect(), recv(), send()      */
  #include <linux/in.h>           /* struct sockaddr_in                       */
  #include <stdio.h>              /* perror()                                 */
  #include <netdb.h>              /* gethostbyname()                          */
  
  #define SRVPORT 1111            /* numer portu serwera                      */
  #define CLIPORT 31337           /* numer portu gniazda klienta              */
  #define SRVADDR "localhost"     /* adres serwera                            */
  #define PASSWD  "sikret"        /* haslo wymagane do polaczenia z serwerem  */
  #define CSALT   "Zz"            /* dwuznakowy tzw. salt dla funkcji crypt() */
  
  // Doszło jedno nowe makro zawierające nazwę maszyny, na której uruchomiony 
  // jest serwer.
  
  int main (void)
  {
    inti                  sd, /*  analogicznie                   */
                         len; /*      jak                        */
    struct sockaddr_in saddr, /*   w kodzie                      */
                       caddr; /*    klienta                      */
  
    struct hostent     *sent; /* struktura opisująca host-serwer */
    char buf[1024];           /* bufor ogólnego przeznaczenia ;) */
  
    sd = socket (PF_INET, SOCK_DGRAM, 0);
    if (sd < 0)
      {
        perror ("socket()");
        exit (1);
      }
  
    caddr.sin_family = PF_INET;
    caddr.sin_port = htons (CLIPORT);
    caddr.sin_addr.s_addr = INADDR_ANY;
  
    if (bind (sd, (struct sockaddr *) &caddr, sizeof (caddr)) < 0)
      {
        perror ("bind()");
        exit (1);
      }
  
Do pierwszej czynności już się przyzwyczailiśmy - utworzenie lokalnego gniazda. Drugim krokiem w tym przypadu jest ręczne przypisanie adresu do lokalnego gniazda. Z tym jeszcze nie mieliśmy do czynienia w przypadku klienta. Jak pamiętamy system zrobiłby to za nas ale wybrałby pierwszy z dostępnych portów. Nie możemy pozwolić sobie na taki luksus ponieważ nasz serwer nie zgodzi się na sesję jeśli nie będziemy nadawali ze ściśle określonego gniazda.
    printf ("Szukam adresu IP serwera %s ...\n", SRVADDR);
  sent = gethostbyname (SRVADDR);
    if (!sent)
      {
        herror ("gethostbyname()");
        exit (1);
      }
  
    saddr.sin_family = PF_INET;
    saddr.sin_port = htons (SRVPORT);
    bcopy (sent->h_addr, (char *) &saddr.sin_addr, sent->h_length);
  
Kolejnym krokiem jest wypełnienie struktury sockaddr gniazda zdalnego. Tutaj wszystko powinno być zrozumiałe.
    bzero (buf, sizeof (buf));
    sprintf (buf, "%s", crypt (PASSWD, CSALT));
  
Do bufora kopiujemy wynik działania funkcji hashującej. Już za chwilę wyślemy tą informację do serwera w celu udowodnienia, że jesteśmy uprawnieni do sesji.
  //connect (sd, (struct sockaddr *)\&saddr,sizeof(saddr));
  
Kolejna, pewnie zaskakująca, nowość. Otóż możliwe jest użycie funkcji connect() na gnieździe bezpołączeniowym. Jej działanie jest jednak inne niż dla gniazd strumieniowych. Funkcja connect() użyta w takim kontekście kojarzy po prostu adres zdalnego gniazda z gniazdem lokalnym i nic więcej. W żadnym razie nie próbuje nawiązywać połączenia z serwerem ! Jej działanie ma znaczenie jedynie dla naszej wygody ponieważ od tej pory moglibyśmy (jeśli linia nie była by zakomentowana oczywiście) używać funkcji recv()/send() w miejsce recvfrom()/sendto(). Po prostu system automatycznie wypełniałby za nas parametry *from i *fromlen tej drugej pary funkcji wartościami podanymi dla wywołania connect(). W tym przypadku, jak widać nie skorzystaliśmy z tej możliwości ale zawsze warto wiedzieć, że można to zrobić.
    printf ("Wysyłam hasło do %s:%i ...\n", inet_ntoa (saddr.sin_addr.s_addr),
  SRVPORT);
    sendto (sd, buf, strlen (buf), 0, (struct sockaddr *) &saddr,
            sizeof (saddr));
  
Wysyłamy do serwera zakodowane hasło i oczekujemy na przyzwolenie do dalszej komunikacji.
    printf ("Czekam na odpowiedź ...\n");
    do
      {
        bzero (buf, sizeof (buf));
        len = sizeof (caddr);
        recvfrom (sd, buf, sizeof (buf), 0, (struct sockaddr *) &caddr, &len);
      }
    while (caddr.sin_addr.s_addr != saddr.sin_addr.s_addr);
  
W pętli do-while odbieramy po kolei wszystkie datagramy, które przychodzą na nasz adres. Wychodzimy z pętli tylko wtedy, jeśli otrzymany datagram pochodzi od serwera. Pamiętajmy o tym, że istnieje możliwość przyjścia na nasz adres jakiegoś przypadkowego datagramu nie związanego zupełnie z sesją między nami a serwerem. Tutaj mała uwaga odnośnie użytych zmiennych: adresy źródłowe przybywającyh datagramów przechowujemy w strukturze wskazywanej przez caddr. Skorzystaliśmy z faktu, że struktura ta chwilę po wywołaniu bind() dla lokalnego gniazda może być bez problemów używana do innych celów. Jeszcze jedna uwaga: w żadnym wypadku nie należy korzystać z adresu źródłowego datagramu w celach weryfikacji uprawnień ! Adres ten można spreparować (ang. spoof) w kilka sekund i jest to o niebo prostsze niż w przypadku fałszowania adresu źródłowego pakietów obsługiwanych przez TCP.
    if (strncmp (buf, "OK", 2))
      {
        fprintf (stderr,
                 "Nieprawidlowe haslo albo zly port zrodlowy. Koncze ...\n");
        exit (1);
      }
    else
      printf ("Polaczenie przyjete.\n\n");
  
Jeśli doszliśmy do tego warunku if to znaczy, że dotarł do nas jakiś datagram od serwera. Najprawdopodobniej (pamiętajmy o wadach UDP) jest to odpowiedź na wysłany wcześniej przez nas datagram z hasłem. Sprawdzamy więc, czy serwer zgodził się na obsłużenie nas (ciąg 'OK' na początku datagramu).
    bzero (buf, sizeof (buf));
    len = sizeof (caddr);
    while (recvfrom (sd, buf, sizeof (buf), 0, (struct sockaddr *) &caddr, &len)
           > 0)
      {
        if (caddr.sin_addr.s_addr == saddr.sin_addr.s_addr)
          {
            if (!strncmp (buf, "END", 3))
              break;
            else if (!strncmp (buf, "Błąd:", 5))
              {
                printf ("%s", buf);
  break;
              }
            else
              printf ("%s", buf);
  }
        bzero (buf, sizeof (buf));
        len = sizeof (buf);
      }
  
    return 0;
  }
  
Ostatnim fragmentem programu jest pętla, która ponownie przegląda wszystkie nadchodzące datagramy, sprawdza, czy pochodzą one od serwera, a następnie podejumuje jedno z trzech działań: To tyle odnośnie korzystania z usług UDP. Niewątpliwie dało się zauważyć, że zaciera się granica pomiędzy serwerami i klientami opartymi na tym protokole. Jedne i drugie przeglądają wszystkie nadchodzące datagramy. Różnica pojawia się właściwie dopiero w warstwie aplikacji modelu sieciowego.


next up previous contents
Next: PF_UNIX Up: Gniazda sieciowe - podstawy Previous: TCP: SOCK_STREAM   Contents
Paweł Niewiadomski
2000-10-17