Socketprogrammierung mit C++ und dem Windows API

Wie wir im ersten Teil des Artikels gesehen haben, kann unsere Anwendung mit einem einfachen Aufruf blockiert werden und steht dann keinem Client mehr zur Verfügung. Unser Socketserver funktioniert zwar, ist aber für eine – vielleicht sogar unternehmenskritische Anwendung – nicht wirklich geeignet. Wir müssen sie also ein wenig umbauen.
Verfügbarkeit des C++-Sockets
Der Modus unseres Sockets ist - wie gerade erwähnt - blocked. Was liegt entsprechend näher, als den Socket vom blocked- in den unblocked-Modus zu setzen? Das Socket-API stellt uns hierzu die Funktion ioctlsocket() bereit. Sie nimmt drei Parameter entgegen:
Zunächst natürlich das Socket. Hier übergeben wir unser listenSocket.
Im zweiten Parameter wird das so genannte Kommando übergeben, ein Integerwert welcher angibt, welche Aktion wir auf dem übergebenen Socket aus dem ersten Parameter ausführen wollen. Hier stehen uns die folgenden drei Optionen zur Verfügung:
- FIONBIO ermöglicht das An- bzw. Abschalten des blocked-Modus für den Socket.
- FIONREAD fragt bei einer bestehenden Verbindung des Sockets, wieviel Bytes bereits übertragen und noch nicht ausgelesen wurden.
- SIOCATMARK dient zur Prüfung bei einer bestehenden Verbindung des Sockets, ob alle Bytes einer OOB-(Out-of-bound)-Verbindung oder UDP-Verbindung bereits gelesen wurden.
Der dritte Parameter schließlich ist ein Zeiger auf einen u_long und nimmt die zum zweiten Parameter gehörenden Daten auf. Wird FIONBIO als Kommando übergeben, gibt ein Wert ungleich eins an, dass der Socket in den nonblocking-Modus zu schalten ist, während der Wert Null den blocking-Modus aktiviert. Bei der Angabe von FIONREAD wird über diesen Datenwert die Anzahl der anstehenden Bytes bzw. eins oder Null zurück gegeben. Schalten wir unser Socket also über in den nonblocking-Modus.
Listing 8: C++ - Socket in den nonblocking-Modus schalten
… u_long data = 1; ioctlsocket( listenSocket, FIONBIO, &data ); …
Führen wir unseren Socketserver ein zweites Mal aus, stellen wir jetzt allerdings fest, dass er gar nicht mehr richtig funktioniert. Wer jetzt genau aufgepasst hat und sich ein bisschen im Text zurückerinnert, weiß bereits warum. Dadurch, dass unser Socket sich jetzt im nonblocking-Modus befindet, wartet WSAAccept() nicht solange, bis eine Verbindung aufgebaut werden soll, sondern kehrt sofort zum Aufrufer zurück; in den meisten Fällen mit dem Wert INVALID_SOCKET. Wir benötigen also eine Schleife um den Aufruf von WSAAccept(), um die Funktion wiederholt aufzurufen. Damit diese Schleife nun nicht unseren Server blockiert und damit den gleichen Effekt hätte wie ein Socket im blocked-Modus, sollten Sie dafür sorgen, dass, wenn es sich um einen Server mit grafischer Oberfläche handelt, die Nachrichtenschleife abgearbeitet wird. Dass Verbindungen aufgebaut werden können, auch wenn gerade nicht WSAAccept() aufgerufen wird, übernimmt wiederum listen().
Hat ein Client eine Verbindung aufgebaut, liefert WSAAccept() wie gewohnt ein gültiges Socket zurück und wir kommen zum zweiten Einsatz von ioctlsocket(). Da WSAAccept() nicht mehr blockiert, blockiert auch WSARecv() nicht mehr, da der requestSocket den gleichen Modus wie der listenSocket besitzt. Wir benötigen entsprechend, ebenfalls in einer Schleife, einen wiederholten Aufruf von...
Listing 9: C++ - Anzahl Bytes in einer Verbindung ermitteln
… u_long sizeOfData = 0; ioctlsocket( requestSocket, FIONREAD, &sizeOfData ); …
Nur, wenn in sizeOfData ein Wert größer Null enthalten ist, muss ein Aufruf von WSARecv() erfolgen. Da wir gerade an dieser Stelle angekommen sind: über den nonblocking-Socket können Sie Ihrem Server mit ein paar Zeilen Code noch weitere Sicherheit geben. Ein einzelner Client, der zwar eine Verbindung aufgebaut, aber keine Daten überträgt, kann unserem Server nicht mehr schaden, viele Clients allerdings weiterhin. Jede aufgebaute Verbindung legt einen Socket an und die Anzahl der möglichen Sockets ist letztlich durch die verfügbaren Ressourcen begrenzt. Wenn viele Clients kommen oder ein Client, der viele Verbindungen zu Ihrem Server aufgebaut, läuft dieser in seinen Ressourcen über. Es kann kein Socket mehr angelegt und damit auch keine ernsthafte Verbindung aufgebaut werden. Damit es nicht soweit kommt, geben Sie einem Client, der eine Verbindung aufgebaut hat, eine begrenzte Zeit, um Daten zu übertragen. Kommen die erwarteten Daten nicht in der entsprechenden Zeit an, kappen Sie die Verbindung via closesocket().
Listing 10: C++ - Socketschleife für Verbindungen
… CONST UINT waitCount = 10; do { SOCKET requestSocket = WSAAccept( … ); if ( INVALID_SOCKET != requestSocket ) { u_long countOfData = 0; ioctlsocket( requestSocket, FIONREAD, &countOfData ); if ( countOfData ) { std:unique_ptr<CHAR, VOID(*)( CHAR* )> buffer( new CHAR[countOfData + 1], deleteArray<CHAR> ); WSABUF wsabuf; wsabuf.buf = buffer.get(); wsabuf.len = countOfData; WSARec( … ) … } else { if ( !( --waitCount ) ) { closesocket( requestSocket ); requestSocket = INVALID_SOCKET; } else { Sleep( 1000 ); } } } // ggf. Nachrichtenschleife konrollieren } while( countOfAcceptCalls ); …
Hochverfügbarkeit von C++-Sockets
Soweit so gut, doch eine wirkliche Hochverfügbarkeit erreichen wir für unseren Socketserver so nicht. Hierzu müssen wir auf Threads zurückgreifen. Starten Sie einen so genannten Listenerthread, welcher für die Annahme von Verbindungen zuständig ist. Auf diese Weise ist der Mainthread unseres Servers immer verfügbar und kann andere Aufgaben übernehmen, während der Listenerthread ständig auf neue Anfragen wartet. Kommt eine Verbindung rein, wird ein so genannter Verbindungsthread gestartet. Dieser bekommt das requestSocket übergeben und kümmert sich nach der Methode aus Listing 10 um die Kommunikation mit dem Client. Zwar müssen je nach Funktion ihrer Serveranwendung die Threads synchronisiert werden, dies stellt allerdings keine große Herausforderung dar, zumal die Anwendung an sich so gut wie nicht mehr blockiert werden kann. Ein ganz wichtiger Tipp allerdings noch zur Verwendung von Threads: die gerade erwähnten Verbindungsthreads, welche die Kommunikation mit dem Client übernehmen, laufen autark, d. h., wann und mit welchem Rückgabewert sie sich beenden, sollte für die Anwendung nicht interessant sein. Damit diese Threads nun nicht unnötige Ressourcen verschlingen, müssen Sie diese unabhängig ihres Threadhandles ausführen. Listing 11 zeigt, wie Sie einen Thread vom internen Threadhandle detachen. Dies führt dazu, dass, sobald der Thread beendet wird, die zugehörigen Systemressourcen sofort freigegeben werden. Im anderen Fall wären diese solange verfügbar, bis der Exitwert des Thread abgefragt wird und Ihr Server läuft Gefahr, unter hoher Last out of Threads zu laufen.
Listing 11: C++ - Thread detachen
… void myThreadFunction(…) { std::thread thisThread( std::this_thread::get_id() ); thisThread.detach(); } …
Kommunikation mit C++-Sockets
Der Server ist nun in der Lage zu kommunizieren und weist auch eine entsprechende Verfügbarkeit auf. Ein wichtiger Bestandteil fehlt allerdings noch. Als einen Vorteil hatte ich zu Beginn des Artikels erwähnt, dass die Daten zwischen Server und Client nativ ausgetauscht werden können und nicht wie zum Beispiel bei SOAP- oder REST-Services im Textformat. Dies ist soweit auch ganz richtig, allerdings ist ein Punkt hier dennoch zu beachten: das Format, in welchem ein Client seine Daten schickt, kann sich vom Format anderer Clients unterscheiden. Bezogen auf Textdaten kann zum Beispiel ein Client Ansitext und in anderer Unicode verwenden. Je nachdem, welcher Art die Clients sind, müssen Sie das Format festlegen. Allgemein empfiehlt sich Unicode. Aber auch das Zahlenformat kann abweichen. Prozessoren der x86-Baureihe verwenden das sogenannte Little-Endian-Format, bei welchem das kleinstwertige Byte an der niederen Stelle, der kleinsten Adresse, gespeichert wird. Riscprozessoren hingegen nutzen das Big-Endian-Format, bei welchem dies genau umgekehrt ist. Verbindet sich ein Client, welcher auf einer anderen Rechnerarchitektur wie derjenigen läuft, auf welcher Ihre Serveranwendung ausgeführt wird, kommt eine Zahl wie zum Beispiel 4711 nicht als 4711 an.
Blicken wir zurück an die Angabe des Ports, an welchem unsere Serveranwendung lauscht. Damit der richtige Wert genutzt wird, haben wir über die Funktion htons() den Wert vom so genannten Hostformat ins Netzwerkformat geändert. Und dies müssen wir für alle Zahlenwerte durchführen. Die zugehörigen Funktion sind in Winsock2.h deklariert, die Funktion zum Zurückwandeln wie zum Beispiel ntohs() ebenso.
Sicherheit des C++-Socketservers
Bevor wir uns der Implementierung einer Clientanwendung widmen, kehren wir noch einmal zur Prüffunktion von WSAAccept() zurück. Ich hatte bereits erwähnt, dass über diese Funktion die Möglichkeit besteht, den Zugriff auf den Socketserver zu kontrollieren und zwar zu einem sehr frühen Zeitpunkt. Als Beispiel möchte ich an dieser Stelle eine sehr einfache Möglichkeit vorstellen: die Filterung anhand der IP-Adresse.
Wichtig für diese Filterung ist der erste Parameter der Prüfroutine. Hier wird die Adresse einer WSABUF-Struktur übergeben, welche wir bereits im Rahmen von WSARecv() kennengelernt haben. An dieser Stelle enthält das Attribut WSABUF.buf die Adresse einer sockaddr-Struktur und repräsentiert damit die IP-Adresse des Clients. Je nachdem, ob wir es mit einem IPv4 oder IPv6 zu tun haben, können wir diese IP-Adresse nun gegen eine sogenannte Whitelist oder Blacklist vergleichen und ein CF_ACCEPT für ok oder CF_REJECT für abgelehnt zurückliefern. Zu empfehlen sind, je nach Aufbau der allgemeinen Zugriffssicherheiten, eher Whitelists, in den die erlaubten IP-Adressen enthalten sind. Blacklists haben immer den Nachteil, dass eine verbotene Adresse oder ein verbotener Adressbereich – denn auch diese können wir selbstverständlich vergleichen – fehlt und somit eine Lücke besteht. Listing 12 zeigt ein Beispiel einer Prüffroutine, in welcher sich der Client im selben Netzwerksegment wie der Server befinden muss. Eine Möglichkeit, um verschiedene Netzwerke zu trennen, um zum Beispiel den Zugriff aus einem Entwicklungsnetz auf den Socketserver im Produktivnetz zu verhindern.
Listing 12: C++ - Eingehende Verbindungen prüfen
… int CALLBACK conditionFunction( LPWSABUF callerId, LPWSABUF callerData, LPQOS qos, LPQOS sqos, LPWSABUF calleeId, LPWSABUF calleeData, GROUPS* s, DWORD callbackData ) { // wir beschränken uns der Einfachheit halber mal nur auf IPv4 sockaddr_in* client = (sockaddr_in*) callerId->buf; sockaddr_in* server = (sockaddr_in*) calleeId->buf; int returnValue = CF_REFECT; if ( client ) { if ( client->sin_addr.S_un.S_und_B.b1 == server->sin_addr.S_un.S_un_B.b1 && client->sin_addr.S_un.S_und_B.b2 == server->sin_addr.S_un.S_un_B.b2 && client->sin_addr.S_un.S_und_B.b3 == server->sin_addr.S_un.S_un_B.b3 ) { returnValue = CF_ACCEPT; } } return( returnValue ); } … // conditionFunction verwenden WSAAccept( …, &conditionFunction, 0 ); …
Das Beispiel zeigt auch, dass zum Beispiel die eigene Adressinformation ebenfalls in den Parametern enthalten ist. Die weiteren Parameter sind mit Ausnahme des letzten für die hier vorgestellten Punkte eher unwichtig, nähere Informationen sind in der Dokumentation zu finden.
Kurz noch ein Wort zum letzten Parameter callbackData. Wenn Sie WSAAccept() aufrufen, haben Sie die Möglichkeit, zusätzlich zur Angabe der Prüffroutine einen eigenen Wert mit zu übergeben. Wenn dieser genutzt werden soll, empfiehlt sich die Angabe von this. So können Sie callbackData in der Routine auf Ihre Klasse casten und können hierüber Informationen aus der Instanz abfragen.
Bleibt zum Abschluss der Socketserver noch die Frage zu beantworten, wie früh das Verwenden der Prüfroutine dem Client das Scheitern der Verbindung mitteilt. Die Verbindung zwischen einer Server- und einer Clientanwendung wird über den sogenannten "Dreiwegehandschlag" vollzogen: der Client schickt ein SYNC, der Server ein SYNC/ACK und der Client ein abschließendes SYNC. Bereits beim Eintreffen des SYNC wird die Prüffunktion aufgerufen und somit die Verbindung sehr früh unterbunden. Der Vorteil zeigt sich bei Portscannern, die prüfen, welche Ports auf einem Rechner bedient werden. Diese laufen in diesem Fall sofort auf einen Fehler und gehen davon aus, dass hinter dem jeweiligen Port keine Anwendung steht. Wird die Erlaubnis er später geprüft, ist bereits eine Verbindung zustande gekommen was einem Portscanner wiederum mitteilt, dass dort eine Anwendung lauscht und nur die Zugriffsbeschränkung zu umgehen ist. Des Weiteren werden sogenannte SYNC-Attacken ebenfalls sofort gekappt. Dies erfordert – gerade bei einem SYNC-Flood-Angriff – dass Ihre Routine schnell arbeitet. Wichtig ist hier noch der Hinweis, dass die Prüfroutine allein den Server nicht für wichtige Verbindungen am Leben hält, in Kombination mit der angesprochenen Threadlösung erreichen Sie hier allerdings sehr viel.
Client - Anwendung eines C++-Socketservers
Damit haben wir einen Socketserver geschrieben, welcher es Ihnen ermöglicht, Daten auf native Weise entsprechenden Clientanwendungen zur Verfügung zu stellen bzw. diese entgegen zu nehmen. Schauen wir uns nun die Clientseite an.
Die Grundvoraussetzungen, um einen Socketclient zu schreiben, haben wir bereits im Server kennengelernt. Wir müssen uns wieder ein Socket anlegen, so wie wir es im Server bereits getan haben. Dieses Socket wird im Client nun nicht an den lokalen Client und einen dortigen Port gebunden, sondern an den Rechner, auf welchem unser Socketserver läuft; die Adresse benötigen wir natürlich ebenfalls und damit sind wir auch schon wieder in unserer Serverimplementierung. Um die Netzwerkadresse des Rechners zu erhalten, greifen wir auch im Client auf die Funktion getaddrinfo() zurück und übergeben ihr diesmal den Namen des Rechners, auf welchem unser Socketserver läuft. Der Rest ist identisch, allerdings ist darauf zu achten, dass wir die gleiche Adresse ermitteln, an welcher unser Socketserver läuft. Haben wir diesen an eine IPv4 Adresse gebunden, benötigen wir diese auch im Client. Es ist daher im Client sinnvoll, sowohl die IPv4- als auch die IPv6-Adresse zu ermitteln, wenn nicht bekannt ist, an welche der anzusprechende Server gebunden wurde. Die Angabe des Ports erfolgt in der erhaltenen Socketadresse dann wie bereits im Server kennengelernt.
Nachdem wir nun die Adresse inklusive Port haben, können wir eine Verbindung zum Server aufbauen. Hierzu stellt uns das Socket-API die Funktion WSAConnect() zur Verfügung. Kehrt diese mit dem Wert 0 zurück, wurde die Verbindung erfolgreich aufgebaut und wir können Daten übertragen und empfangen.
Dies gilt selbstverständlich für alle Serveranwendungen. Sie können also nicht nur unsern Socketserver ansprechen, sondern auch alle anderen und zum Beispiel eine Verbindung zu einer Webadresse herstellen. Bei der Datenübertragung müssen Sie sich dann – wie bei unserem eigenen Socketserver ebenfalls – an das jeweilige Datenprotokoll halten. Bauen Sie eine Verbindung zu einer Webadresse auf entsprechend an http.
Das war's bereits für den Client? Noch nicht ganz, denn auch beim Client können wir noch ein bisschen was für die Verfügbarkeit tun. Nehmen wir folgendes Szenario: um die Ausfallsicherheit zu erhöhen, starten Sie nicht nur eine Instanz Ihres Socketservers, sondern zum Beispiel vier. Wenn die Clientanwendung den ersten nicht erreicht, versucht Sie es beim zweiten usw. Das Windows Socket API macht es uns hier einfach, dies umzusetzen und stellt uns diese Funktion WSAConnectByList() zur Verfügung. Sie müssen sich durch die Verwendung von WSAConnectByList() nicht mehr selbst darum kümmern, eine Verbindung zu den möglichen Serveranwendungen aufzubauen, Sie müssen lediglich in der Konfiguration Ihrer Clientanwendung die Adressen der jeweiligen Rechner hinterlegen und übergeben diese als Liste an WSAConnectByList().
Schauen wir uns dies genauer an:
Als erster Parameter ist wie bei WSAConnect() das angelegte Socket zu übergeben.
Im zweiten Parameter folgt jetzt die Adressliste und wird in Form eines Zeigers auf eine SOCKET_ADDRESS_LIST-Instanz übergeben. Diese Instanz legen Sie dynamisch an, was bereits beim Einlesen der Konfiguration Ihres Clients erfolgen kann. Eine SOCKET_ADDRESS_LIST besteht aus zwei Attributen. Im ersten – iAddressCount – wird die Anzahl der Adressinformationen übergeben, der zweite ist ein Array von SOCKET_ADDRESS-Instanzen; daher die dynamische Anlage. Für unser Beispiel mit vier Serverinstanzen benötigen Sie daher sizeof( INT ) + ( sizeof( SOCKET_ADDRESS ) * 4 ) Bytes an Speicher. Befüllen können Sie Ihre Adressliste anschließend wie folgt:
Listing 13: C++ - SOCKET_ADDRESS_LIST füllen
… std::list<sockaddr_in> listOfServerAddresses; // Konfiguration der Adressen in std::list einlesen und dabei bereits als sockaddr_in Instanz ablegen … int index = 0; socketAddressList->iAddressCount = listOfServerAddresses.size(); std::for_each( listOfServerAddresses.begin(), listOfServerAddresses.end(), [&] ( sockaddr_in& sockAddress ) { socketAddressList->Address[index].iSockaddrLenght = sizeof( sockAddress ); socketAddressList->Address[index++].lpSockaddr = &sockAddress; } ); …
Als nächstes sind zwei Parameterpaare zu übergeben, welche zunächst für die lokale Adressinformation und anschließend für die Remoteadressinformation stehen: ein Zeiger auf die Größe der Adressstruktur (sockaddr_in) und ein Zeiger auf eine Adressstrukturinstanz. Konnte WSAConnectByList() die Verbindung zu einer der angegebenen Adressen aufbauen, ist in der ersten Adressinstanz die lokale Adressinformation und in der zweiten die Adressinformation des Clients enthalten.
Der vorletzte Parameter ermöglicht die Angabe eines Timeout-Wertes als Zeiger auf einen timeval, wie lange versucht werden soll, eine Verbindung aufzubauen. Konnte innerhalb dieser Zeit keine Verbindung aufgebaut werden, kehrt WSAConnectByList() erfolglos zurück. Geben Sie NULL an, werden alle Verbindungen bis zu einer erfolgreichen Verbindung durchprobiert. So können Sie sichergehen, dass WSAConnectByList() nicht abbricht, ohne vorher alle Adressen in Ihrer Liste versucht zu haben. Der letzte Parameter schließlich ist reserviert.
Fazit
Einen Socketserver und entsprechende Clientanwendungen zu schreiben, ist, wie wir gesehen haben, gar nicht so schwer. Es gibt ein paar Fallstricke, auf welche Sie achten müssen, damit Ihre Serveranwendung nicht nur die Anfragen von Clientanwendungen beantworten kann, sondern eine stabile und zuverlässige Anwendung darstellt. Auch oder gerade in Punkto Sicherheit und Verfügbarkeit bei gezielten Angriffen. Hierzu bietet das Socket API Funktionen und Möglichkeiten an, die grundsätzliche Architektur muss allerdings auch stimmen und hier bietet uns wiederum C++ gerade mit C++ 11/14 Klassen an, die uns unterstützen.
So haben wir umfangreiche Möglichkeiten zur Verfügung, einen Socketserver genau nach unseren Bedürfnissen zu erstellen, um die Daten in der benötigten optimalen Form auszutauschen. Und auch für die Erstellung der Clientanwendungen wird mehr geboten als nur ein simples Connect, gerade, um ein Grundausfallsicherheit für die Anwender zu gewährleisten. Es ist daher durchaus sinnvoll, im nächsten Projekt nicht nur in Richtung Standard zu schauen, sondern auch dem Thema Socketprogrammierung eine Chance zu geben. Und ist einmal ein Grundgerüst erstellt, ist die Implementierung von Fachlichkeit nur noch "anzudocken", um eine neue Serveranwendung zu etablieren.
Neuen Kommentar schreiben