TCP szerver készítése Mérési segédlet Informatika 2
A labor feladat célja, hogy a hallgatót megismertesse a TCP/IP protokollt használó programok készítésével. Ezen belül bemutatásra kerül a Berkley Socket API, amelynek segítségével lehetővé válik a hálózati kommunikáció implementálása Linux/Unix és MS Windows rendszereken. A labor elvégzéséhez szükséges a C programozói tudás!
A Berkeley socket API ismertetése A következő fejezetekben a Berkeley socket API használatába tekintünk be. Ennek használatával lehetséges a Linux/Unix és MS Windows rendszereken a hálózati kommunikáció implementálása.
A socket A socket az applikációk közötti kommunikációt lehetővé tévő kétirányú kapcsolat elnevezéseként értelmezhető. Tulajdonképpen egy SOCKET típusú leíró1, amelyet létre kell hoznunk, majd az egyes rendszerhívásoknál ezzel hivatkozhatunk az adott kommunikációs csatornára. Új socketeket a socket() rendszerhívással hozhatunk létre. Létrehozáskor a sockethez egy protokollt rendelünk, amelyet az majd használni fog. Azonban ebben az állapotban a socket még nem kapcsolódik sehova, ezért kommunikációra még nem használható. SOCKET socket(int domain, int type, int protocol);
A függvény 0-nál kisebb értékkel tér vissza hiba esetén. Ha sikeres, akkor egy socket leíróval, amely 0 vagy nagyobb érték. A három paraméter a használandó protokollt definiálja. Az első (domain) megadja a protokoll családot. A következő táblázat néhány lehetséges értékét tartalmazza. Protokoll PF_UNIX, PF_LOCAL PF_INET PF_INET6
Jelentés Unix domain (gépen belüli kommunikáció) IPv4 protokoll IPv6 protokoll
A következő paraméter (type) kiválasztja a protokoll családon belül a kommunikáció típusát. Lehetséges értékei az alábbiak:
1
Linux alatt: int típusú
1
Típus
Jelentés Sorrendtartó, megbízható, kétirányú, kapcsolatalapú SOCK_STREAM bájtfolyam-kommunikációt valósít meg. Datagramalapú (kapcsolatmentes, nem megbízható) SOCK_DGRAM kommunikáció. Sorrendtartó, megbízható, kétirányú, kapcsolatalapú SOCK_SEQPACKET kommunikációs vonal, fix méretű datagramok számára. SOCK_RAW Nyers hálózati protokoll hozzáférést tesz lehetővé. Megbízható datagramalapú kommunikációs réteg. SOCK_RDM (Nem sorrendtartó.) A harmadik paraméter (protocol) a protokoll család kiválasztott típusán belül választ ki egy protokollt. Mivel egy protokoll családon belül egy kommunikáció típust többnyire csak egy protokoll implementál, ezért a tipikus értéke: 0
Példa Az alábbi példa egy TCP socketet hoz létre: SOCKET sock; if((sock = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; }
A kapcsolat felépítése Ha összeköttetés-alapú kommunikációt szeretnénk folytatni egy kliens és egy szerver program között, akkor ehhez stream jelegű socketet kell létrehoznunk. Azonban ez önmagában nem elegendő a kommunikációhoz. Össze kell kapcsolnunk a két fél socketét, hogy adatokat vihessünk át rajta. Ennek a kapcsolat felépítésnek a részleteit a következő ábrán láthatjuk.
Kliens
Szerver
socket()
socket()
bind()
listen() connect() accept()
Kapcsolat létrejött
1. ábra A socket kapcsolat felépítése
2
Az ábrán a kliens és a szerver oldalon egymás után meghívandó függvények láthatóak. Elsőre látható, hogy a kapcsolat felépítési mechanizmus aszimmetrikus. A szerver oldalon a feladat, hogy egy bizonyos címen várja a program a kapcsolódási igényeket, majd amikor ezek befutnak, akkor kiépítse a kapcsolatot. A kliens oldalon ezzel szemben a feladat azl, hogy a szerver címére kapcsolódási kérést küldjünk, amelyre az reagálhat. Ezen feladatok végrehajtását tekintsük át lépésenként. Szerver: 1. A szerver oldalon létre kell hoznunk egy socketet, amelyet a későbbiek során szerver socketként fogunk használni. A szerver socket kommunikációra nem alkalmas, csak kapcsolatok fogadására. 2. Össze kell állítanunk egy cím struktúrát, amelyben leírjuk, hogy a szerver hol várja a kapcsolódásokat. 3. Az előbbi lépésben összeállított címhez hozzá kell kötnünk a socketet. 4. Be kell kapcsolnunk a szerver-socket módot, vagyis hogy a socket várja a kapcsolódásokat. 5. Fogadnunk kell az egyes kapcsolódásokat. Ennek során minden kapcsolathoz létrejön egy kliens socket, amely az adott féllel való kommunikációra használható. Kliens: 1. A kliens oldalon is létre kell hoznunk socketet. Ezt az előző esettől eltérően kliens-socketként, a kommunikációra fogjuk használni. 2. Össze kell állítanunk egy cím struktúrát, amelyben leírjuk, hogy a szerver milyen címen érhető el. 3. Kapcsolódnunk kell a szerverhez. Amikor ez a kapcsolat létrejött, akkor a továbbiakban a socket képes továbbítani az adatokat. A következőkben áttekintjük, hogy az egyes lépések milyen függvények segítségével valósítható meg. Azonban mivel a feladat szerver készítése, ezért a szerver lépéseire koncentrálunk első sorban.
Cím összeállítása Mielőtt a cím összeállításra rátérnénk meg kell vizsgálnunk egy problémát. A címek összeállítása során több bájtos adattípusokat használunk (pl.: IPv4 cím 4 bájtos long, a port szám 2 bájtos short). Azonban az egyes processzorok különböző módon tárolják az egyes adattípusokat. Két tárolási mód terjedt el: a big endian, amely a magasabb helyi értékű bájttal kezdi a tárolást, illetve ennek ellentéte a little endian. Big endian architektúra például a Sun Sparc, little endian pedig az Intel x86. Azonban a hálózaton ezeknek a különböző architektúráknak is meg kell érteniük egymást, ezért közös adatábrázolásra van szükség. Ezt nevezzük hálózati bájt-sorrendnek, amely a big endian ábrázolást követi. Az adott architektúrán használt ábrázolást pedig hoszt bájt-sorrendnek. A két ábrázolás közötti váltásokat a következő függvények végzik: Függvény Jelentés ntohs Egy 16-bites számot a hálózati bájt-sorrendből a hoszt bájt-sorrend sorrendjébe vált át. ntohl Egy 32-bites számot a hálózati bájt-sorrendből a 3
htons htonl
hoszt bájt-sorrendjébe vált át. Egy 16-bites számot a hoszt bájt-sorrendjéből hálózati bájt-sorrendbe vált át. Egy 32-bites számot a gép bájt-sorrendjéből hálózati hoszt bájt-sorrendbe vált át.
Azokon az architektúrákon, ahol szükséges a konverzió ezek a függvények megfordítják a bájt-sorrendet, ahol pedig nem, ott csak visszaadják a paraméter értékét. A címábrázoláshoz a socket API a következő általános struktúrát alkalmazza: struct sockaddr { unsigned short sa_family; char sa_data[14]; };
/* címcsalád, AF_xxx */ /* 14 bájtos protokoll cím */
A címet használó függvények paraméterként ilyen típust várnak. Azonban egyes protokollokhoz létezik ennek specializált változata is, amely az adott protokoll esetén könnyebben használható. IPv4 esetén ez a következő képen néz ki: struct sockaddr_in { unsigned short int unsigned short int struct in_addr unsigned char };
sin_family; sin_port; sin_addr; sin_zero[8];
/* /* /* /*
Címcsalád = AF_INET */ A port száma */ IP cím */ struct sockaddr vége */
Ezen belül az in_addr struktúra az alábbi: struct in_addr { unsigned long int s_addr; }
Ebben helyezhetjük el a cím egyes bájtjait. Ne feledjük, hogy mind a címnek, mind a portnak hálózati bájtsorrendűnek kell lennie, ezért a korábban látott htonl() illetve htons() függvényeket kell használnunk a konverziójukhoz. A szerver esetén a cím beállításnál valójában csak a portot kell megadnunk, míg a címre használhatjuk az INADDR_ANY makrót. Ez valójában a 0.0.0.0 címet jelenti, amely azt mondja meg, hogy minden hálózati interfész adott portján várjuk a kapcsolódást.
Példa Az alábbiakban két példát láthatunk a cím összeállítására. Az első példában egy szerver számára állítjuk össze a címhez kötéshez a cím struktúrát. A példában a szerver a számítógép össze hálózati interfészén az 1234-es porton lesz majd elérhető: sockaddr_in addr;
4
addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(1234);
A másik példa azt mutatja meg, hogy hogyan állíthatjuk össze a kliensben a címstruktúrát, amiben a szerver címe szerepel. A szerver IP címe esetünkben 192.168.0.1 és portja 1234 lesz: sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_addr.s_addr = inet_addr("192.168.0.1"); addr.sin_port = htons(1234);
A socket címhez kötése Az előző lépésben összeállított címet hozzá kell rendelnünk a sockethez. Ezt a műveletet kötésnek nevezzük, és a következő rendszerhívással lehet végre hajtani: int bind(SOCKET sock, struct sockaddr *my_addr, socklen_t addrlen);
Az első paraméter a socket leírója, a második a címet leíró struktúra, az utolsó a címet leíró struktúra hossza. A visszatérési érték sikeres végrehajtás esetén 0, egyébként pedig mínusz érték.
Példa Az alábbi példában a korábban összeállított címmel hajtjuk végre a címhez kötés műveletét: if(bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
A szerver mód bekapcsolása A címhez kötés után a socketet szerver módba kapcsolhatjuk. Ez után már kapcsolódhatunk hozzá klienssel. A szerver módba kapcsolást a következő rendszerhívással végezhetjük el: int listen(SOCKET sock, int backlog);
Az első paraméter a socket leírója. A második paraméter, a backlog megadja, hogy hány kapcsolódni kívánó socket kérelme után utasítsa vissza az újakat. Ebbe csak azok a kapcsolódó socketek számítanak bele, amelyeket még nem fogadott a szerver. A visszatérési érték sikeres végrehajtás esetén 0, egyébként pedig mínusz érték.
5
Példa A korábban már címhez kötött socketet az alábbi módon kapcsolhatjuk szerversocket módba: if(listen(sock, 5) < 0) { perror("listen"); return 1; }
Kliensek kapcsolódásának fogadása A kliensek kapcsolódását a következő rendszerhívás fogadja: SOCKET accept(SOCKET *addrlen);
sock,
struct
sockaddr
*addr,
socklen_t
Az első paraméter a szerver socket leírója. Az addr és addrlen paraméterekben a másik oldal címét kapjuk meg. Az addrlen egy integer szám, amely megadja az addr változó méretét. Amennyiben nem vagyunk kíváncsiak a csatlakozó kliens címére, akkor az addr és addrlen paramétereknek megadhatunk NULL értéket. A függvény visszatérési értéke az új kapcsolat leírója, egy kliens socket. A későbbiekben ezt használhatjuk a kommunikációhoz. Ha a visszatérési érték 0-nál kisebb, akkor hibát jelez.
Kapcsolódás a szerverhez A kliens oldalon a cím összeállítása után kapcsolódnunk kell a szerverhez. Ezt a következő rendszerhívással tehetjük meg: int connect(SOCKET addrlen);
sock,
struct
sockaddr
*addr,
socklen_t
Az első paraméter a kliens socket leírója. Az addr és addrlen paraméterekben a szerver címét adjuk meg. Az addr paraméter tartalmazza a címet, míg az addrlen az addr paraméterben átadott cím méretét kell, hogy megadja. A függvény 0 értékkel jelzi a sikeres végrehajtást, míg mínusz értékkel a hibát.
Példa A korábbi példában összeállított szerver címre a kapcsolódást az alábbi példa mutatja be: if(connect(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("connect"); return 1; }
6
Adatok küldése és fogadása A kliens és a szerver program összekapcsolt kliens socketét egy kétirányú csővezetéknek tekinthetjük, amelyen bájtokat küldhetünk és fogadhatunk. Az adatok küldésére a következő rendszerhívást használhatjuk: int send(SOCKET s, const void *msg, size_t len, int flags);
Az első argumentum a socket leírója, a második az elküldendő adat pufferére mutató pointer, a harmadik az elküldendő adat mérete. A flags paraméter a kommunikáció jellegét módosító beállításokat tartalmazhat. A gyakorlat keretein belül használjuk a 0 értéket. A függvény visszatérési értéke tartalmazza az elküldött bájtok számát. A mínusz érték a hibát jelzi. Adatokat a recv() rendszerhívással fogadhatunk. Ez a függvény addig nem tér vissza, amíg valamilyen információ nem érkezik. Az alakja a következő: int recv(SOCKET s, void *buf, size_t len, int flags);
Az első paraméter itt is a kliens socket leírója, a második a fogadó puffer mutatója, a harmadik pedig a puffer mérete. A flags paraméter itt is a kommunikáció jellegét módosító beállításokat tartalmazhat, azonban jelenleg használjuk a 0 értéket. A függvény visszatérési értéke a beolvasott bájtok számát tartalmazza. Ez kisebb vagy egyenlő azzal, amit mi puffer méretnek megadtunk. Figyeljünk arra, hogy a puffer tartalmából csak annyi bájt a hasznos információ, amennyit ez a visszatérési érték megad. Ha a visszatérési érték 0, akkor az azt jelzi, hogy a kapcsolat lezárult. Ebben az esetben befejeződött a kommunikáció és le kell zárnunk a socketet. Ha a visszatérési érték mínusz szám, akkor az hibát jelez.
A kapcsolat lezárása A kapcsolatot a következő rendszerhívással zárhatjuk le2: int closesocket(SOCKET s);
A paraméter a socket leírója. A visszatérési érték sikeres végrehajtás esetén 0, egyébként pedig mínusz érték.
Ellenőrző kérdések 1. Mi a különbség a szerver és a kliens socket között? 2. A cím összeállításánál miért szükséges a számokat konvertálni? 3. Miért szükséges a szerver socketet címhez kötni és miért nem kell a kliens socketet? 4. Az accept() függvény meghívásakor mi történik, ha éppen nincs bejövő kapcsolat? 5. A kommunikációs kapcsolatot hogyan zárhatja le a kliens, illetve a szerver oldal?
2
Linux alatt: int close(int s);
7
6. Írjon C nyelvű kódrészletet, amely az s leíróval reprezentált kliens socketből képes 16 byte adat fogadására! 7. Írjon C nyelvű kódrészletet, amely az s leíróval reprezentált kliens socketen keresztül elküldi a „hello” stringet! 8. Írjon C nyelvű kódrészletet, amely megvizsgálja, hogy az str1 és str2 nevű karakter tömbök tartalma megegyezik-e! 9. Írjon C nyelvű kódrészletet, amely megvizsgálja, hogy az str1 nevű karakter tömb tartalmazza-e az str2 nevű karakter tömb értékét! 10. Írjon C nyelvű kódrészletet, amely megvizsgálja, hogy az str1 nevű karakter tömb tartalmazza-e a ch nevű karaktert!
A feladat A gyakorlat során a hallgató feladata, hogy elkészítsen egy TCP szerver applikációt C nyelven, amely a következőket teljesíti: • Egy paraméterként megadott TCP porton fogadja a kliensek kapcsolódásait! Vagyis megvalósítja azokat a funkciókat, amelyeket a szerver esetén a segédlet leír. • Egy kapcsolat fogadása után elegendő, ha csak az adott klienssel foglalkozik. Azonban amikor lezárul a kapcsolat, akkor fogadja új kliens kapcsolódását! • A klienssel való kommunikáció során elvégzendő műveleteket a gyakorlatvezető ismerteti!
8
Függelék A – MS Windows segítség Az MS Windows alatti fejlesztést MS Visual Studio program használatával végezzük. A project létrehozásakor célszerű az üres C++ alkalmazás választani kiindulási pontnak.
A program lefordítása és futtatása Az elkészült program lefordításához szükséges a ws2_32.lib könyvtár állomány használata, mert különben a hálózatkezelő függvényeink implementációját a linker nem találja meg. Vagyis a project beállításainál a linkelés parancssorához vegyük hozzá a ws2_32.lib állományt. A programot paraméterezve kell futtatnunk. Ennek legegyszerűbb módja, ha nyitunk egy parancsterminált (cmd.exe). Megkeressük a lefordított exe állományt és lefuttatjuk. Paraméterként meg kell adnunk a port számát.
A szerver kipróbálása Az elkészült TCP szerver programot a telnet program segítségével próbálhatjuk ki, amely egy TCP kliens. A telnet program használatához egy command prompt-ot kell nyitnunk. Ezt kétféle módon tehetjük. A Start / Programs / Accessories / Command Prompt menüpont segítségével, vagy a Start / Run menüpont kiválasztásával és a cmd parancs lefuttatásával. A command prompt ablakban kell kiadnunk a telnet parancsot. Első paraméterként meg kell adnunk a szerver gép nevét, amely lokális futtatás esetén localhost, második paraméterként pedig a szerver portját. Például: telnet localhost 1234
A program jelzi, ha nem sikerül kapcsolódnia a szerverhez. Ha a kapcsolat felépült, akkor a begépelt sorokat elküldi a szerver programnak. A telnet program MS Windows implementációja karakterenként küldi el azt, amit begépeltünk, így a szervernek egyből reagálnia kell minden karakter után.
A program váza Az alábbi C (C++) nyelvű programváz segítséget nyújt a fejlesztés elkezdéséhez MS Windows alatt: #include <stdio.h> #include <winsock2.h> int main(int argc, char* argv[]) { WSADATA wsd; if(WSAStartup(0x0202, &wsd) != 0) { perror("WSAStartup"); return 1; } if(argc < 2)
{ printf("Hasznalat: %s port\n", argv[0]); return 1; } //TODO WSACleanup(); return 0; }
Függelék B – Linux segítség A feladat megvalósításához szükség van egy terminál, egy szövegszerkesztő, és a gcc program használatára. Szövegszerkesztőnek a kwrite program használata javasolt, mivel kezelőfelülete egyszerű és rendelkezik szintaxis kiemelő funkcióval. Az alábbiakban ismertetett parancsokat a terminál ablakba kell beírni.
A programozói segítség Az egyes rendszerhívásokról és C függvényekről a man parancs segítségével angol nyelvű segítséget érhetünk el. Ennek formája: Rendszerhívások esetén: man 2 rendszerhívás
C függvények esetén: man 3 C-függvény
A program lefordítása és futtatása Az elkészült forráskódot a gcc parancs segítségével fordíthatjuk le. Ennek paraméterezése a következő: gcc -Wall -o kimenet forrás.c
A fordító sikeres fordítás esetén nem ír ki semmit, csak előállítja a futtatható program kódot. Probléma esetén a problémákat három csoportra oszthatjuk: Típus Figyelmeztetés (warning) Fordítási hiba (compiler error) Linker hiba (linker error)
Leírás A jelzett sor szintaktikailag nem hibás, de érdemes ellenőrizni, mert elvi hibás lehet. Viszont a program lefordul és használható. A jelzett sor szintaktikailag hibás, vagy valamely korábbi sorban elkövetett hiba hatása. A linker nem találja a hivatkozott kódot. Többnyire függvény név elírások okozzák.
A lefordított programot a következő módon lehet futtatni: ./program param1
Ez az aktuális könyvtárból lefuttatja a program nevű programot a param1 paraméterrel. A programot leállítani a Ctrl+C billentyű kombinációval lehet.
A szerver kipróbálása Az elkészült TCP szerver programot a telnet program segítségével próbálhatjuk ki, amely egy TCP kliens. A telnet programnak első paraméterként meg kell adnunk a
szerver gép nevét, amely lokális futtatás esetén localhost, második paraméterként pedig a szerver portját. Például: telnet localhost 1234
A program jelzi, ha nem sikerül kapcsolódnia a szerverhez. Ha a kapcsolat felépült, akkor a begépelt sorokat elküldi a szerver programnak. Figyeljünk arra, hogy mivel a terminál kanonikus, ezért sorokat fog elküldeni az Enter lenyomása után!
A program váza Az alábbi C nyelvű programváz segítséget nyújt a fejlesztés elkezdéséhez Linux alatt: #include #include #include #include #include #include
<stdio.h> <stdlib.h>
<string.h> <sys/socket.h>
int main(int argc, char* argv[]) { int ssock; int reuse = 1; if((ssock = socket(PF_INET, SOCK_STREAM, 0)) < 0) { perror("socket"); return 1; } setsockopt(ssock, sizeof(reuse)); //TODO return 0; }
SOL_SOCKET,
SO_REUSEADDR,
&reuse,