SSL/TLS-Netzwerkprogrammierung mit Boost.Asio, Teil 2: Server-Programmierung

Rubriken  –  0 Kommentare

Boost.Asio bietet plattformübergreifende Möglichkeiten zur Netzwerkprogrammierung in C++, inklusive der Implementierung von SSL/TLS. Beim Erstellen eines Servers gilt es einige Besonderheiten zu beachten.

Nach den Grundlagen von Boost.Asio im ersten Teil, geht es nun um die Praxis: Die Implementierung eines portablen, mittels SSL/TLS gesicherten Servers in C++ mit Boost.Asio und OpenSSL. Das Programm ist auf einer breiten Palette von Betriebssystemen unverändert lauffähig.

Zum Ausloten der Fähigkeiten von Boost.Asio im Bereich SSL/TLS-Netzwerkprogrammierung dient weiterhin das Beispiel aus der ursprünglichen OpenSSL-Reihe. Das Nachbilden des mehr oder weniger sinnvollen Dialogs aus dem Spielfilm "War Games" erzeugt ausreichend Kommunikation zwischen WOPR-Server und -Client, um ein praktisches Szenario für Experimente zu erhalten.

SSL/TLS-Netzwerkprogrammierung mit Boost.Asio

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

Neben dem Hauptakteur, der auf asynchrones I/O spezialisierten Boost.Asio-Library, nutzt das WOPR-Beispiel eine Reihe anderer unterstützender Bibliotheken aus dem Boost-Fundus. Zur Thread-Programmierung verwendet WOPR Boost.Thread statt C++11-Threads. Zum Verarbeiten der Kommandozeilenargumente kommt Boost.Program_Options zum Einsatz, des Weiteren Boost.Date_Time zum Darstellen von Datum und Zeit. Da eine ausführliche Behandlung der zusätzlichen Bibliotheken den Rahmen der Reihe sprengen würde, sei für Details auf die Dokumentation von Boost verwiesen.

Das WOPR-Beispiel – Server und Client – steht als Tarball zum Download bereit. Wie die beiden gebaut werden, beschreibt der erste Teil der Artikelserie. Der Server steckt im entpackten Tarball im Unterverzeichnis wopr. Jede Klasse ist in einer eigenen .cpp-Datei implementiert und über eine .h-Datei deklariert. Die Dateinamen entsprechen den Klassennamen. Zusätzlich existiert noch die Datei wopr.cpp mit der Hauptroutine main().

Der Server besteht im Wesentlichen aus der zentralen Klasse WoprServer, die auf eingehende Verbindungen lauscht, und der Klasse WoprSession, die das Client-Session-Handling übernimmt (s. Abb. 1). Die Klasse WoprLogger implementiert einfache Log-Funktionen über einen std::stringstream, der es ermöglicht, Log-Meldungen über den <<-Operator auszugeben.

Der WOPR-Server in UML-Notation (Abb. 1)

An der Stelle hätte auch Boost.Log zum Einsatz kommen können. Da die Bibliothek jedoch nicht auf allen getesteten Plattformen reibungslos arbeitet, bringt WOPR seinen eigenen, leichtgewichtigen Logger mit. WoprLogger nutzt Präprozessormakros der Form WOPR_LOG(level):

WOPR_LOG(kInfo) << "Eine Meldung";

Daraus resultiert folgender C++-Code:

::wopr::WoprLogger(::wopr::LogLevel::kInfo) << "Eine Meldung";

Die Lebenszeit des darin erzeugten Objekts der Klasse WoprLogger entspricht der zur Abarbeitung der Zeile benötigten Zeit. Das eröffnet den eleganten Weg, den erhaltenen Stream in einem std::stringstream zu speichern und im Destruktor mitsamt einem Zeitstempel auszugeben.

Der eigentliche Server ist folgendermaßen aufgebaut: Die main()-Routine hält ein io_service-Objekt, mit dem aussagekräftigen Namen io_service, das an sämtliche Objekte weitergegeben wird, die asynchrones I/O nutzen. main() instanziiert anhand der übergebenen Kommandozeilenparameter einen WoprServer. Er verfügt über ein Objekt acceptor_ der Klasse boost::asio::ip::tcp::acceptor. Das Objekt dient zum Lauschen auf den TCP/IP-Ports. Dafür initialisiert der WoprServer seinen acceptor_, indem er ihm io_service und einen Endpunkt übergibt. Letzteren repräsentiert boost::asio::ip::tcp::endpoint, das aus dem Protokoll (IPv4 oder IPv6) sowie folgendem Port besteht:

acceptor_(io_service,
boost::asio::ip::tcp::endpoint((ipv6 ? boost::asio::ip::tcp::v6()
: boost::asio::ip::tcp::v4()), port))

ipv6 ist ein einfaches Boolesches Flag, das angibt, ob das System auf IPv6 lauschen soll oder nicht. Der Nutzer kann es über die Kommandozeile beim WOPR-Server mit der Option -6 auf true setzen.

Nun existiert ein rudimentäres Objekt, das eingehende Verbindungen akzeptieren könnte. Damit das tatsächlich funktioniert, ist gemäß dem Proactor-Pattern eine asynchrone Operation "Accept" zu erzeugen. Das erfolgt im Konstruktor von WoprServer über den Aufruf der acceptor_-Methode async_accept():

WoprSession* new_session = 
new WoprSession(io_service_, context_, client_number_++,
join, multi_threading, delay);
acceptor_.async_accept(new_session->socket(),
boost::bind(&WoprServer::handle_accept,
this,
new_session,
boost::asio::placeholders::error)
);

Der erste Parameter ist der Socket, auf dem der Endpunkt lauschen soll. Der Acceptor erhält ihn über eine neue Instanz von WoprSession, der WOPR-spezifischen Klasse, die zum Bedienen einer Client-Sitzung zuständig ist.

Um einen Handler bei acceptor_.async_accept() anzugeben, kommt boost::bind zum Einsatz. Es erwartet als ersten Parameter die Adresse einer Methode, die das Handling übernimmt. Im vorliegenden Fall ist es WoprServer::handle_accept. Damit Boost.Asio später noch weiß, zu welchem Objekt der Handler gehört, ist als zweiter Parameter ein Zeiger auf das Objekt zu übergeben. Im Beispiel ist es schlicht this und somit die WoprServer-Instanz. Die weiteren Übergabewerte dienen zur Angabe von Parametern für den Handler. Das Beispiel übergibt einen Zeiger auf das WoprSession-Objekt und den Platzhalter für den Boost.Asio-Fehlerwert. An dessen Stelle erhält der Handler später den Fehlercode der Operation; im vorliegenden Fall des Accepts.

Der Server legt das WoprSession-Objekt zunächst auf Vorrat an, das bis jetzt mit keiner Client-Session verknüpft ist. Erst wenn ein Connect erfolgt und den Accept-Handler WoprServer::handle_accept() ausführt, ändert sich das. Sollte kein Fehler aufgetreten sein, startet der WoprServer die Behandlung der Client-Verbindung:

new_session->start();

Da auch WoprSession::start() seinerseits wieder nur eine asynchrone Operation auslöst, verweilt die Ausführung lediglich kurz in der Methode. start() kehrt also schnell wieder zurück. Der WoprServer legt eine WoprSession auf Vorrat an und verbindet sie mit einem Accept-Handler – exakt wie im Konstruktor. Der nächste Client kann kommen.

Im Fall eines Fehlers entfernt der WoprServer das Session-Objekt. Weitere Verbindungen sind dann nicht mehr möglich. Bestehende Verbindungen und deren asynchrone Operationen werden jedoch noch bedient, bis auch die Verbindung zu diesen Clients beendet ist. Eine Alternative wäre, auch an dieser Stelle unter Betrachtung des konkreten Fehlers einen neuen Accept zu erzeugen.