SSL/TLS-Netzwerkprogrammierung mit Boost.Asio, Teil 3: Client-Programmierung und Fehlerbehandlung

Boost.Asio bietet plattformübergreifende Ansätze zur Netzwerkprogrammierung in C++, inklusive der Implementierung von SSL/TLS. Die Portabilität hat jedoch einige Grenzen, wie das Beispiel eines Clients zeigt.

In Pocket speichern vorlesen Druckansicht 1 Kommentar lesen
Lesezeit: 17 Min.
Von
  • Oliver Müller
Inhaltsverzeichnis


Nach den Grundlagen im ersten und dem Server im zweiten Beitrag fehlt nun noch ein mit Boost.Asio implementierter Client. Neben dessen Programmierung geht der letzte Teil der Artikelserie auf die Grenzen der Portabilität und die Fehlerbehandlung in der SSL/TLS-Entwicklung mit Boost.Asio ein.

Der WOPR-Client befindet sich im gleichen Tarball wie der Server. Eine Anleitung zum Kompilieren unter Unix/POSIX-kompatiblen Systemen und mit Visual Studio unter Windows steht im ersten Teil der Artikelreihe.

Mehr Infos

SSL/TLS-Netzwerkprogrammierung mit Boost.Asio

Teil 1: Grundlagen
Teil 2: Server-Programmierung
Teil 3: Client-Programmierung und Fehlerbehandlung

Im Unterverzeichnis des entpackten Tarball liegt der WOPR-Client in zwei Ausführungen: eine POSIX-kompatible und eine portable Version. Erstere arbeitet auf Linux, Unix und POSIX-Subsystemen wie Cygwin. Die zweite Variante arbeitet portabel auf allen Plattformen, auf denen Boost.Asio verfügbar ist, darunter auch natives Windows ohne POSIX-Aufsatz.

Der POSIX-Client (terminal.cpp) ist in der Lage, Tastatureingaben ebenso asynchron zu verarbeiten wie Schreib-/Leseereignisse auf dem Socket. Die portable Variante (sterminal.cpp) liest hingegen direkt vom Standardeingabekanal (STDIN), also ohne hierfür Boost.Asio zu nutzen. Dieser Client läuft somit auch auf Plattformen, die keinen einfachen Weg für asynchrones Lesen von STDIN bieten. Hinsichtlich der Netzwerkfunktionen unterscheiden sich die beiden Clients nicht.

Zunächst steht die portable Implementierung aus sterminal.cpp im Fokus. Für den Client ist das Instanziieren und Nutzen eines io_service-Objekts ebenso obligatorisch wie für den Server. Auch beim Starten von run() verhalten die Beiden sich gleich. Interessant sind die Unterschiede: Bereits in der Hauptroutine main() erzeugt der Client einen Endpunkt und einen SSL/TLS-Kontext:

boost::asio::ip::tcp::resolver resolver(io_service);
boost::asio::ip::tcp::resolver::query query(host, port);
boost::asio::ip::tcp::resolver::iterator iterator = resolver.resolve(query);
boost::asio::ssl::context ctx(io_service, boost::asio::ssl::context::sslv23);
ctx.set_verify_mode(boost::asio::ssl::context::verify_peer);
ctx.load_verify_file(cert_file);

Das Erzeugen des Endpoint übernimmt ein TCP-Resolver der Klasse boost::asio::ip::tcp::resolver. Er dient schlicht zur Namensauflösung per DNS, hosts-Datei und/oder NIS; je nachdem was als Resolver auf dem System eingestellt ist. Nach dem Erzeugen einer Query mit Hostname und Port löst der Resolver sie als Iterator auf, der eine Liste von Endpoints für den Hostnamen zurückgibt. Das können durchaus mehrere sein, beispielsweise durch die Round-Robin-Einträge im DNS.

Sollte die Auflösung fehlschlagen, wirft die oben verwendete Form von resolve() eine Exception vom Typ boost::system::system_error. Das ist an dieser Stelle OK, da sich der Programmlauf noch nicht in io_service::run() befindet. Zusätzlich existiert eine Variante, die keine Exception wirft, sondern einen Fehlercode zurückliefert, und somit innerhalb der asynchronen Verarbeitung genutzt werden kann.

Nach dem Erzeugen des Iterators entsteht der SSL-Kontext. Dabei aktiviert der set_verify_mode() die Verifikation des Server-Zertifikats. Um sie zu ermöglichen, lädt der Kontext die Zertifikatskette aus der Datei cert_file. Anschließend erfolgt die Instanziierung der Klasse WoprPortableTerminal, die den Client implementiert. Ähnlich wie beim Server stößt der Konstruktor bereits die erste asynchrone Operation an. Nach dem anschließenden io_service.run() kann die asynchrone Verarbeitung starten.

Zunächst ermittelt der Client im Konstruktor von WoprPortableTerminal den Endpunkt, auf den er sich verbinden soll:

boost::asio::ip::tcp::endpoint endpoint = *endpoint_iterator;

Da er dafür den Iterator des Resolvers dereferenziert, kommt zunächst die erste verfügbare Adresse aus der Adressauflösung zum Einsatz. Der nächste wesentliche Schritt ist das Auslösen von Connect als asynchrone Operation. Als Handler dient die Methode handle_connect():

socket_.lowest_layer().async_connect(endpoint,
boost::bind(&WoprPortableTerminal::handle_connect, this,
boost::asio::placeholders::error, ++endpoint_iterator));

Danach terminiert der Konstruktor, wodurch die Hauptroutine [i]main() io_service.run() ausführen kann. Erreicht die Verarbeitung den Handler WoprPortableTerminal::handle_connect(), unterscheidet sie drei Fälle:

  1. Kein Fehler ist aufgetreten.
  2. Die Verbindung konnte nicht hergestellt werden.
  3. Ein anderer Fehler ist aufgetreten.

Im ersten Fall initiiert der Client eine asynchrone Operation für den SSL-Handshake:

socket_.async_handshake(boost::asio::ssl::stream_base::client,
boost::bind(&WoprPortableTerminal::handle_handshake, this,
boost::asio::placeholders::error));

Der einzige wesentliche Unterschied zur Handshake-Operation im Server ist das Setzen des Typs als client. Der Handler steckt in der Methode handle_handshake().

Tritt der zweite Fall ein, startet der Client einen erneuten Versuch für eine Verbindung auf den nächsten Endpunkt des Resolver-Iterators. Das geht solange bis entweder eine Verbindung auf einen Endpunkt erfolgreich ist, oder die Endpunkte aufgebraucht sind. Im letzteren Fall evaluiert der Ausdruck zu false:

endpoint_iterator != boost::asio::ip::tcp::resolver::iterator()

In diesem dritten Fall protokolliert der Client, dass die Verbindung nicht erfolgreich war und der Handler terminiert. Da sich in diesem Fall keine weitere asynchrone Operation in der Operation-Queue des io_service befindet, endet der Aufruf von run() in der Hauptroutine und daraufhin auch das Programm.

Waren sowohl der Verbindungsaufbau als auch der Handshake erfolgreich, tritt die Verarbeitung in handle_handshake() ein. Dieser Handler löst mit der bekannten Methode async_read_some() des Sockets eine asynchrone Leseoperation mit dem Handler handle_read() aus.

Hat das Programm Daten vom Server gelesen und handle_read() aufgerufen, gibt dieser zunächst die gelesenen Daten auf dem Standardausgabekanal (STDOUT) aus. Danach folgt das Lesen eines neuen Kommandos über

std::cin.getline(request_, max_length);

Den eingelesenen Befehl sendet der Client
daraufhin an den Server:

boost::asio::async_write(socket_,
boost::asio::buffer(request_, request_length),
boost::bind(&WoprPortableTerminal::handle_write, this,
boost::asio::placeholders::error,
boost::asio::placeholders::bytes_transferred));

Der Aufruf von std::cin.getline() funktioniert unter jedem Betriebssystem, hat jedoch einen Nachteil: Er blockiert. Bei einem Client ist das auf den ersten Blick in Ordnung. Es entsteht eine vernünftige Abfolge zwischen Befehl lesen, an den Server senden, Antwort empfangen, Antwort ausgeben und von vorn beginnen.

Ein Nachteil ist, dass die Vorgehensweise immer nur eine Operation auf dem Server erzeugen und bis zu deren Abarbeitung keine weitere nachschieben kann. Damit ließe sich je nach Anwendungsszenario gut leben.

Ein anderes Problem stellt sich ein, wenn die Verbindung beim Lesen eines Befehls abbricht. Der Client – und damit auch der Benutzer – merkt das erst, nachdem ein Befehl mit der Return-Taste abgeschlossen wurde. Erst beim asynchronen Schreiben stellt der Client den Verbindungsabbruch fest. Das mag für den einen oder anderen Anwendungsfall irritierend wirken. Eine Lösung für beide Probleme skizziert der im späteren Artikelverlauf behandelte POSIX-Client.

Das asynchrone Schreiben erfolgt über den vom Server her bereits bekannten Aufruf von async_write(). Der zugehörige Handler erzeugt schlicht eine neue asynchrone Leseoperation – wiederum mit handle_read() als Handler.

Die Fehlerbehandlung erfolgt über einen Platzhalter vom Typ boost::system::error_code, den das Programm als Parameter des Handler beim boost::bind() gebunden hat. Das error_code-Objekt hält im Wesentlichen drei wichtige Methoden bereit:

  1. message() liefert eine aussagekräftige Fehlermeldung als std::string.
  2. category() gibt die Fehlerkategorie als Objekt der Klasse boost::system::error_category zurück. Die Kategorie gehört zum betriebssystemspezifischen Teil von Boost und dient zum näheren Eingrenzen der Fehlersituation. So vergleicht der WOPR-Client beispielsweise die Kategorie des aufgetretenen Fehlers mit boost::asio::error::get_ssl_category(), um SSL-spezifische Fehler zweifelsfrei identifizieren zu können.
  3. value() ermittelt den Fehlercode. Der Wert 0 bedeutet, dass kein Fehler aufgetreten ist. Alles andere lässt sich anhand der Kategorie zuverlässig zuordnen. Um die Werte einer nicht dokumentierten Fehlersituation zu ermitteln, genügt es, für einen unbekannten Fehler diesen Code auszuführen:
std::cerr << error.category().name() << ':' << error.value() << std::endl;

Wenn Entwickler das Programm starten und die Fehlersituation künstlich provozieren, erhalten sie den Namen der Kategorie und den Fehlerwert auf STDERR und können daraus den Fehler ableiten. Notfalls ist ein grep über die Header oder gar den Quelltext von Boost mit dem Kategorienamen notwendig.

Mit category() und value() lassen sich Fehler gut abfangen. Eine betriebssystemabhängige Unterscheidung mag bei portablen Programmen zusätzlich sinnvoll sein; beispielsweise über #ifdef-Konstrukte.

Den POSIX-Client implementiert die Klasse WoprTerminal in der Datei terminal.cpp. Der erste markante Unterschied zwischen den beiden Clients ist das Fehlen des blockierenden Aufrufs std::cin.getline() in der POSIX-Variante. An dessen Stelle treten unabhängige asynchrone Leseoperationen, die von STDIN beziehungsweise dem Terminal lesen. Auf diese Weise entkoppelt der POSIX-Client das Lesen der Befehle über das Terminal vom Lesen der Daten über den Socket.

Dafür nutzt WoprTerminal einen Stream vom Typ boost::asio::posix::stream_descriptor als Objektattribut input_. Der Konstruktor instanziiert das Objekt wie folgt:

input_(io_service, ::dup(STDIN_FILENO))

Der Stream ist mit dem io_service-Objekt verbunden. Als File Handle erhält er ein Duplikat von STDIN. Damit existiert nun ein Boost.Asio-kompatibler Stream – ähnlich dem bereits bekannten Socket – zum Lesen aus einer Datei; im vorliegenden Fall von STDIN.

Die weiteren Schritte und die ersten initiierten asynchronen Operationen ähneln denen des portablen Clients. Erst wenn die Verarbeitung im Handler handle_handshake() angekommen ist, unterscheiden sich die beiden WOPR-Clients deutlich. Die POSIX-Variante erzeugt zwei asynchrone Operationen:

boost::asio::async_read_until(input_, input_buffer_, '\n',
boost::bind(&WoprTerminal::handle_read_input, this,
boost::asio::placeholders::error,
boost::asio::placeholders::bytes_transferred));
socket_.async_read_some(boost::asio::buffer(reply_, max_length),
boost::bind(&WoprTerminal::listen_on_server, this,
boost::asio::placeholders::error,
boost::asio::placeholders::bytes_transferred));

Der Aufruf von async_read_until erzeugt eine asynchrone Leseoperation vom Stream input_, also von STDIN. Wie das "read until" im Namen erahnen lässt, liest die Methode bis zu einem bestimmten Zeichen. Dass es sich dabei um ein Newline '\n' handelt, legt der dritte Parameter fest. Das Einlesen der Daten erfolgt in einen Stream-Buffer vom Typ boost::asio::streambuf, übergeben im zweiten Parameter. Der Rest ist das bekannte Boost.Asio-Binding mit handle_read_input() als Handler.

Das zweite Statement ist bereits vom portablen Client bekannt. Es erzeugt mittels der Methode async_read_some() des Socket eine Leseoperation. Der Handler heißt im POSIX-Client jedoch abweichend listen_on_server. Der Name ist Programm: Er dient ausschließlich zum Einlesen der Daten vom Server. Anschließend gibt der Handler sie auf STDOUT aus. Danach erzeugt er eine neue asynchrone Leseoperation mit sich selbst als neuen Handler. Es handelt sich sozusagen um ein reines Polling in einer quasi endlosen Folge asynchroner, gleichförmiger Leseoperationen, die erst beim Verbindungsende aufhört.

Der Handler des Streams handle_read_input() bereitet zunächst das eingegebene Kommando auf:

input_buffer_.sgetn(request_, length - 1);
input_buffer_.consume(1);
request_[length - 1] = '\0';

Der Inhalt des Stream-Buffers input_buffer_ geht in ein char-Array request_ ein – length - 1 stellt sicher, dass nur das eingegebene Kommando, aber nicht das abschließende Newline im char-Array landet.

Da der input_buffer_ nach dieser Operation immer noch ein '\n' enthält, ist der Methodenruf consume(1) unbedingt notwendig. Ein erneuter Aufruf von async_read_until() mit dem gleichen Stream-Buffer würde unweigerlich sofort abbrechen, da der Stream-Buffer ein nicht konsumiertes Newline enthält. Daher ist es zum Wiederverwenden des Stream-Buffers unbedingt zu entfernen. Andernfalls würde der Client in einer ewigen Schleife mit leeren Kommandos festhängen.

Das dritte Statement stellt sicher, dass das char-Array mit einem Null-Byte abgeschlossen ist. Damit können die folgenden Schritte davon ausgehen, dass request_ in jedem Fall ein gültiger ASCII-String ist und kein Pufferüberlauf stattfindet.

Nach einigen administrativen Code-Zeilen erzeugt der Handler eine neue asynchrone Schreiboperation, um den Inhalt von request_ an den Server zur Verarbeitung zu senden. Der entsprechende Aufruf von async_write() ist bekannt. Als Handler ist die Methode handle_write() festgelegt.

Sie erzeugt lediglich wieder eine neue asynchrone Leseoperation über dem Stream. Einzig, wenn ein Disconnect stattgefunden hat, erfolgt dieser Schritt nicht mehr. io_service::run() kann auf diese Weise in einem Fade-Out "aushungern".

Die Architektur des POSIX-Clients befreit von den beiden Einschränkungen des portablen. Das Absetzen mehrerer Kommandos unabhängig vom Abschluss der Verarbeitung auf dem Server ist ohne Weiteres möglich. Schließlich erfolgt das Lesen vom Socket in einer unabhängigen Operation. Damit kann es passieren, dass Ausgaben vom Server ankommen, während die Eingabe auf dem Terminal noch in vollem Gange ist. Der POSIX-Client reagiert wie ein Unix-Terminal, bei dem von Hintergrundprozessen durchaus Ausgaben in den aktuellen Eingabeprozess einfließen.

Es ist ebenfalls möglich, bei laufender Eingabe einen Verbindungsabbruch zum Server festzustellen. Selbst wenn der asynchrone Lesevorgang von STDIN noch nicht im Handler angelangt ist, kann die Leseprozedur vom Socket zum Server bereits einen Verbindungsabbruch erkennen. Die Fehlerbehandlung im Handler listen_on_server() kann dann das Programm aus dem Hintergrund heraus beenden.

Entwickler können das Verhalten mit folgenden Schritten ausprobieren:

  • Zunächst starten sie einen WOPR-Server. Idealerweise mit simulierter langer Verarbeitung (Option -d).
  • Anschließend verbinden sie jeweils sterminal und terminal mit dem Server. Ein anschließender Logon als Joshua ist ideal.

Nun können die Entwickler in terminal mehrere Kommandos hintereinander absetzen. Die mehrfache Eingabe von Poker ist beispielsweise problemlos möglich, ohne die Ausgabe des Servers abzuwarten. In sterminal hingegen können sie nur ein Kommando eingeben, auf die Ausgabe warten und anschließend das nächste Kommando eingeben.

Nach dem Beenden des WOPR-Servers durch Ctrl-C reagiert terminal prompt und hält an. sterminal hingegen bleibt hängen, falls es gerade auf ein neues Kommando von STDIN wartet.

POSIX selbst eröffnet eine breite Palette von Plattformen. Boost.Asio bietet mit seinen speziellen POSIX-Funktionen interessante zusätzliche Möglichkeiten zu den plattformunabhängigen. Wer jedoch glaubt, das Entwickeln und Testen auf einer POSIX-Plattform reicht, um alle POSIX-konformen Systeme identisch abzudecken, irrt gewaltig.

Im Konstruktor von WoprTerminal fällt folgendes Präprozessorkonstrukt auf:

#ifdef sun
input_(io_service, (isatty(fileno(stdin)) ? ::open("/dev/tty",
O_RDONLY | O_NONBLOCK) : ::dup(STDIN_FILENO))),
#else
input_(io_service, ::dup(STDIN_FILENO)),
#endif

Der #else-Teil ist die typische POSIX-Variante mit dup() zum Duplizieren von STDIN. Die #ifdef-Klausel testet, ob das Zielsystem Solaris ist. Ein einfaches dup() findet nur statt, sofern STDIN kein Terminal ist. Das ist der Fall, wenn über Eingabeumlenkung eine Pipe oder eine Datei an STDIN gebunden ist. Ist STDIN hingegen ein Terminal, werden das zugehörige TTY geöffnet und die Eingaben direkt davon gelesen.

Das ist notwendig, da Boost.Asio mit Solaris offensichtlich ein paar Schwierigkeiten hat. Per Standard nutzt Boost.Asio auf dieser Plattform /dev/poll, um auf asynchrone Ereignisse zu testen. Das hat beim klassischen ::dup(STDIN_FILENO) einen eigenartigen Effekt: Beim Start von terminal beendet es sich sofort wieder. Darauf beendet sich die Shell, die terminal startete, weil sie offensichtlich ein EOF auf STDIN erhielt.

Dieser Effekt lässt sich mildern: Wenn beim Kompilieren des Sourcecodes das Makro BOOST_ASIO_DISABLE_DEV_POLL gesetzt ist, nutzt Boost.Asio nicht mehr /dev/poll, sondern fällt auf select() zurück. Damit startet terminal und lässt sich auch nutzen. Wird terminal jedoch beendet, schließt die zugehörige Shell ebenfalls.

Entwickler können das umgehen, indem sie statt STDIN das Terminal /dev/tty öffnen. Ein-/Ausgabeumlenkung lässt sich über das TTY jedoch nicht nutzen, sondern nur über STDIN. Das ist der Grund für die Fallunterscheidung beim Instanziieren von input_.

An der Stelle zeigen sich die Grenzen der Plattformunabhängigkeit von Boost.Asio. Soll ein Programm später auf mehreren Plattformen lauffähig sein, sollte es auf den Zielsystemen ausführlich getestet werden. Das gilt nicht nur bei POSIX.

Der POSIX-Client bietet interessante Möglichkeiten. Perfekt wäre es, dieselbe Funktionsweise ebenso auf dem reinen Windows ohne Cygwin nutzen zu können. Ein Ansatz wäre boost::asio::windows::stream_handle statt der POSIX-Variante zu verwenden. Ob sich damit eine Lösung implementieren lässt, sei dem Leser als Übung überlassen.

Boost.Asio bietet einen einfachen und portablen Weg, SSL/TLS-Programme zu implementieren. Im Vergleich zum reinen OpenSSL-Programm aus der ursprünglichen Artikelreihe lässt sich die Palette unterstützter Plattformen ohne große Änderung am Quelltext und ohne exzessive Fallunterscheidung leicht erweitern.

Auch wenn Boost.Asio vieles kapselt und die portable Programmierung vereinfacht, verstecken sich die Herausforderungen im Detail. Die Terminal-Probleme auf Solaris – einem ausgewiesen POSIX-kompatiblen System – zeigen, dass blindes Vertrauen in die POSIX-Kompatibilität fatal sein kann. Intensives Testen ist auf allen angestrebten Plattformen unerlässlich.

Oliver Müller
ist freiberuflicher IT-Berater und -Trainer mit den Schwerpunkten Software Engineering und Kryptographie. Er berät zu Java EE, Unix/Linux, Android und Mainframe-Systemen.
(rme)