Internet-Technologie

Prof. Jürgen Plate

3 Netzwerkprogrammierung

Internetanwendungen werden nach dem Client-Server-Prinzip erstellt. Der Client ist hier in den meisten Fällen das Benutzer-Interface und nimmt vom Server bestimmte Dienste in Anspruch. Er baut in Abhängigkeit vorher definierter Ereignisse (z.B. dem Starten einer Internetanwendung durch einen Anwender) die Verbindung zum Server auf und ist somit der aktive Teil.
Der Server stellt nun den gewünschten Dienst zur Verfügung. Er muß sich ständig in einem Zustand befinden, in dem er Verbindungsaufforderungen von Clients entgegennehmen kann - er ist der passive Teil. Ein Server darf niemals einen Dienst vom Client anfordern.
Client und Server müssen die gleiche Sprache sprechen: Sie müssen sich also an einem gemeinsamen Protokoll orientieren. Das unterschiedliche Verhalten von Client und Server läßt allerdings eine Asymetrie entstehen, die sich in der Verwendung unterschiedlicher Schnittstellen-Befehle bei der Realisierung einer Client- oder Serverapplikation niederschlägt.

3.1 Die Socket-Netzwerkschnittstelle

Anfang der 80er Jahre wurde mit 4.2BSD in UNIX-Systemen die sogenannte "Socket"-Schnittstelle für die Kommunikation zwischen Prozessen eingeführt. Ein "Socket" ist dabei der Name für einen Endpunkt einer Kommunikationsverbindung. Seine Schnittstelle ist im wesentlichen konzipiert für: Dabei gibt es normalerweise ein Programm, das Anfragen von anderen Programmen entgegennimmt und sie beantwortet (ein sog. Server-Socket) und beliebig viele andere Programme, die ihre Anfragen an das Server-Socket schicken und mit den Antworten weiterarbeiten (die sog. Client-Sockets). Das ganze System ist auch bekannt als Client-Server-Programmierung. Ein sehr typisches Beispiel sind Webserver: der Webserver wartet auf Anfragen von Browsern (oder User Agents o.ä.) und gibt Webseiten zurück. Die Browser arbeiten dann mit diesen Webseiten weiter, indem sie sie anzeigen und Operationen darauf erlauben (Anzeigen des Quelltextes etc.).

Die Socket-Schnittstelle ist zwar von keiner Institution genormt, stellt aber den Industriestandard dar. Wichtige Gründe sind u.a.:

Um mit einem bestimmten Dienst (Programm) auf einem anderen Rechner zu kommunizieren, reicht es allerdings nicht, einfach den anderen Rechner als solchen anzusprechen. Es ist vielmehr jedes Server-Programm auf einer bestimmten Port-Nummer zu erreichen. Jeder Client muß dem entfernten Rechner diese Port-Nummer mitteilen, damit dieser die Anfrage dem richtigen Programm zuleiten kann.

Die Netz-Ein- und Ausgabe wurde an die Datei-Ein- und Ausgabe angelehnt und etliche Ein- und Ausgabe-Systemaufrufe lassen sich auf Dateien und Sockets anwenden. Es gibt jedoch einige Unterschiede:

Verbindungsorientierte und verbindungslose Kommunikation

Sockets

Zur Kommunikation zwischen Prozessen, die auch auf verschiedenen Rechnern ablaufen können, wurde mit den Sockets im BSD-Unix ein leistungsfähiger Mechanismus der Datenübertragung definiert. Sockets sind heute Grundlage der meisten höheren Datenübertragungsprotokolle und in fast allen Betriebssystemen realisiert. Sie stellen die Schnittstelle zwischen Anwendungsprogramm und den Betriebssystemroutinen zur Datenkommunikation dar. Dabei besteht der Vorteil für den Benutzer darin, daß einem Socket ein Dateideskriptor zugeordnet wird, über den das Anwendungsprogramm fast genauso kommunizieren kann, wie über Pipes oder normale Dateien. Im Gegensatz zu einer Pipe, die grundsätzlich nur in einer Richtung betrieben werden kann, ist ein Socket-Deskriptor jedoch bidirektional - wie eine zum Lesen und Schreiben geöffnete Datei. Sockets sind, wie die Client-Server-Beziehung, unsymmetrisch: Einer der beiden beteiligten Prozesse ist "Server", der andere "Client". Der Server (Diensterbringer) wartet darauf, daß irgendein Client (Kunde) mit ihm Kontakt aufnehmen möchte. Der Client ist der aktive Part und veranlasst den Beginn der Kommunikation.

Über Sockets kann der Datenaustausch auf zweierlei Art erfolgen:

  1. Datenströme (Streams): Zwischen Client und Server wird eine Verbindung aufgebaut, die einzelnen Datenpakete werden gesichert und in korrekter Reihenfolge übertragen und zum Schluß wird die Verbindung wieder abgebaut. Dies entspricht dem Zyklus "open" - "read"/"write" - "close" bei einer normalen Datei. Bei einer Verbindung über IP wird dafür TCP benutzt.
  2. Einzelpakete (Datagrams): Datagramme werden gleichsam als "Pakete" mit Absender- und Empfängeradresse verschickt. Das entsprechende Internet-Protokoll heißt UDP. Es wird keine Verbindung zwischen den beiden Prozessen aufgebaut, weshalb UDP wesentlich schneller ist. Allerdings gibt es keine Garantie für das Ankommen des Paketes bei der Gegenseite und keine Gewähr für die Einhaltung der richtigen Reihenfolge.
Sockets sind noch über verschiedenen "Domänen" definiert: Es gibt neben der "Internet-Domäne" noch weitere Domänen, z. B.die "Unix-Domäne" für die Kommunikation zwischen reinen Unix-Prozessen. Thema der Vorlesung ist aber ausschließlich die Internet-Domäne.

Die Systemaufrufe im Überblick

Einige wichtige Socket-Primitive bzw. -Systemcalls sind:

Die folgende Tabelle gibt einen Überblick über die wichtigsten Socket-Systemcalls in Client- und Serverprogrammen:

Phase Client Server
Endpunkt erzeugen socket() socket()
Binden einer Adresse bind() bind()
Verbindung aufbauen connect()  
Warteschlange festlegen   listen()
Warten auf Verbindung   accept()
Daten senden write()
send()
sendto()
sendmsg()
write()
send()
sendto()
sendmsg()
Daten empfangen read()
recv()
recvfrom()
recvmsg()
read()
recv()
recvfrom()
recvmsg()
Verbindung schließen shutdown() shutdown()
Endpunkt abbauen close() close()
Ereignisse annehmen select() select()
Verschiedenes getpeername()
getsockname()
getsockopt()
setsockopt()
getpeername()
getsockname()
getsockopt()
setsockopt()

Für die Kommunikation bei verbindungslosen, d.h. UDP-basierten Socketanwendungen sind die speziellen send()- und receive()-Systemcalls empfehlenswert, während bei TCP-Verbindugen daneben die Standard-Systemcalls read() und write() einsetzbar sind.

Eine TCP/IP-Verbindung ist, wie wir gesehen haben, durch eine Client-Server-Architektur geprägt und damit asymetrisch. Vor der Programmierung muß die Verbindung stehen. Das betrifft einmal die Verbindung zwischen den Rechnern, als auch jene zwischen den Prozessen. Die Adressierung der Rechner erfolgt per Hostname, der vom System auf die IP-Nummer umgesetzt wird.

Vom Client aus muß nicht nur der richtige Rechner, sondern auch der richtige Serverprozeß angesprochen werden können. Dazu bindet sich der Serverprozeß an einen festen Port, über den er erreichbar ist. Damit die Nummer des Ports mit einem Namen versehen werden kann, verwendet man die Datei /etc/services. Im Programm wird die Servicenummer durch den Aufruf der Systemfunktion getservbyname() bestimmt.

Der Client braucht keinen festen Port. Er erbittet sich auf der lokalen Maschine eine freie Nummer und ruft damit den Port des Servers. Der Server erfährt die Nummer des Clients aus der Anfrage und kann ihm unter diesem Port antworten. Das Szenario zwischen Server und Client sieht wie folgt aus:

Betrachten wir beispielhaft einmal das Listing eines ganz einfachen Servers in der Programmiersprache C. Die einzelnen Systemaufrufe werden weiter unten genauer behandelt, das Listing soll zunächst nur einen Überblick des Ablaufs geben:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>

#define MAXPUF 1023

main()
  {
  int MySocket, ForeignSocket;
  struct sockaddr_in AdrMySock, AdrPartnerSocket;
  struct servent *Service;
  int AdrLen;

  char Puffer[MAXPUF];
  int MsgLen;

  /* Socket einrichten */
  MySocket = socket(AF_INET, SOCK_STREAM, 0);

  /* Socket an Port-Nummer binden */
  AdrMySock.sin_family = AF_INET;         /* Internet-Protokolle */
  AdrMySock.sin_addr.s_addr = INADDR_ANY; /* akzept. jeden Client-Host */
  Service = getservbyname("echo","tcp");   /* bestimme Port */
  AdrMySock.sin_port = Service->s_port;   /* (Get Service by Name) */
  bind(MySocket, &AdrMySock, sizeof(AdrMySock));

  /* Empfangsbereitschaft signalisieren und warten */
  listen(MySocket, 5);

  for (;;) /* forever */ 
    {
    /* Verbindungswunsch vom Client annehmen */
    ForeignSocket = accept(MySocket, &AdrPartnerSocket, &AdrLen);
    /* Datenaustausch zwischen Server und Client */
    MsgLen = recv(ForeignSocket, Puffer, MAXPUF, 0); /* String empfangen */
    send(ForeignSocket, Puffer, MsgLen, 0); /* und zuruecksenden */
    /* Verbindung beenden und wieder auf Client warten */
    close(ForeignSocket);
    }
  }
Dieser Server bearbeitet jede Anfrage, die über den Port "echo" an ihn gestellt wird. Nach jeder Anfrage wird die Verbindung wieder gelöst und ein anderer Client kann anfragen. Ein solcher Server dürfte auf jedem Betriebssystem arbeiten können, das TCP/IP unterstützt, selbst wenn es kein Multitasking beherrscht.

Es gibt zwei Variablen pro Socket. Die eine ist wie bei Dateizugriffen ein einfaches Handle (MySocket), die andere hält die Adresse der Verbindung (AdrSock), also die IP-Nummer des Rechners und die Nummer des verwendeten Ports. Der Server erlaubt Verbindungen von jedem Rechner aus, weil die Konstante INADDR_ANY benutzt wird.

Der zugehörige Client gibt dagegen die Adresse des anzusprechenden Servers an. Das Programm sieht wie folgt aus:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>

#define MAXPUF 1023

main()
  {
  int MySocket;               /* Socket-Handle */
  struct sockaddr_in AdrSock; /* Socketstruktur */
  int len;                    /* Die Laenge der Socketstruktur */

  struct hostent *RechnerID;  /* ferner Rechner */
  struct servent *Service;    /* Dienst auf dem fernen Rechner */

  char Puffer[MAXPUF] = "Wir erschrecken zu guten Zwecken!";

  MySocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

  /* Bestimme den Zielrechner */
  RechnerID = gethostbyname("server");
  bcopy(RechnerID->h_addr,&AdrSock.sin_addr.s_addr,RechnerID->h_length);

  /* Bestimme den Port */
  Service = getservbyname("echo","tcp");
  AdrSock.sin_port = Service->s_port;

  connect(MySocket, (struct sockaddr *)&AdrSock, sizeof(AdrSock));
  send(MySocket, Puffer, MAXPUF, 0);  /* String senden */
  recv(MySocket, Puffer, MAXPUF, 0);  /* und wieder empfangen */
  printf("%s\n", Puffer);             /* ausgeben */
  close(MySocket);
  }
Die recv-Funktion liefert als Rückgabewert die Größe des versandten Speicherbereichs (max. 1 KByte, siehe unten).

Grundsätzlich liefern fast alle Netz-Funktionen bei Fehlern den Wert 0 zurück. In den obigen Beispiel-Listing fehlt jegliche Fehlerbehandlung, damit das Prinzip übersichtlich dargestellt werden kann. Im "richtigen" Programm ist eine umfassende Fehlerbehandlung unumgänglich.

Das Server-Programm hat noch einen Nachteil. Nach dem Start des Servers ist die Konsole oder das Shell-Fenster für weitere Zwecke blockiert. Auch fehlt eine korrekte Möglichkeit, den Server zu beenden. Bei einem Abbruch des Programms wird es dem Betriebssystem überlassen, den Socket zu schließen. Zweckmässigerweise wird vom Programm ein Dämon erzeugt, der per fork-Aufruf in den Hintergrund gestellt.

3.2 Parallelität und Signale

Parallelität

Ein Server wird im allgemeinen in einer Multitasking-Umgebung gestartet werden. Er soll schließlich mehrere Anfragen parallel abarbeiten können (concurrent server). Unter UNIX gibt es dazu den fork-Mechanismus.

Die Systemaufrufe fork(), exec() und wait() haben mit der Generierung von Kindprozessen zu tun und erlauben die Synchronisation zwischen Eltern- und Kindprozessen. An dieser Stelle wird nur soweit darauf eingegangen, wie es zum Verständnis der folgenden Abschnitte nötig ist.

Dazu ein erstes Beispiel in Perl:
#!/usr/bin/perl -w

# Kindprozess starten
$chld_pid=fork();

if ($chld_pid < 0)
  {
  die "Fork fehlgeschlagen: $!\n";
  }

if($chld_pid == 0)          # I am the child.
  {
  print "CHILD: Here I am.\n";
  sleep 1;
  print "CHILD: terminating.\n";
  exit(1); # Exit-Status 1
  }

else                       # I am the parent
  {
  print "PARENT: Kind wurde erzeugt. Warte...\n";
  waitpid($chld_pid,0);
  print "PARENT: Kind terminiert. Exit status: $?\n";
  }
waitpid wartet darauf, daß der Kind-Prozeß mit der angegebenen Prozeß-ID terminiert und nimmt den Rückgabewert entgegen (in Perl in der Variablen $?). Die Null ist ein Flag-Byte; hier kann man angeben, ob waitpid auch für gestoppte Kindprozesse zurückkehren soll, oder ob waitpid einfach nur nachsehen soll, ob der Kindprozeß mit der angegebenen PID terminiert hat, ohne zu warten, falls das nicht der Fall war. Daneben gibt's noch die Funktion wait, die wartet, bis irgend ein Kindprozeß terminiert, und dann dessen PID zurückgibt.

In welchem Teil sich das Programm befinden, kann wir anhand des Rückgabewerts von fork() festgestellt werden. Beim Kindprozeß ist dieser Null, beim Elternprozeß die PID des Kind-Prozesses. Beide Prozesse haben zunächst denselben Eingabe- und Ausgabekanal, und teilen sich auch alle anderen Filedeskriptoren. Wenn sie nun beide wahllos auf den Ausgabekanal schreiben, werden die beiden Ausgaben einfach durcheinandergemischt. Wenn sie beide von der Eingabe lesen, gewinnt der Schnellere, wenn eine neue Eingabe ansteht. Um nun wirklich kommunizieren zu können, müssen vor dem fork() ein Paar (oder auch mehrere) von zusätzlichen Kanälen geschaffen werde, von denen einer benutzt wird, damit der Elternprozeß Daten an den Kindprozeß senden kann, und ein anderer, damit der Kindprozeß Daten an den Elternprozeß senden kann. Es gibt hierfür zwei verschiedene Systemfunktionen. Die erste heißt pipe() und erzeugt ein Paar von zusammengehörigen Filedeskriptoren, wobei auf dem ersten gelesen und auf dem zweiten geschrieben wird. Der zweite heißt socketpair() und erzeugt zwei Sockets für den gleichen Zweck.

Der fork-Mechanismus löst auf einfache Weise das Problem der Bearbeitung mehrerer paralleler Anfragen. Der Prozeß erzeugt einen Sohn, der auch den Socket erbt, über den die Verbindung zum Client erhalten bleibt. Die Endlosschleife des C-Serverprogramms muß dazu geändert werden:

 for (;;) 
   {
   ForeignSocket = accept(MySocket, &AdrPartnerSocket, &len);

   if (fork() == 0) /* Das ist der Kindprozess */ 
     {
     MsgLen = recv(ForeignSocket, Puffer, MAXPUF, 0);
     send(ForeignSocket, Puffer, MsgLen, 0);
     close(ForeignSocket);
     exit(0);       /* Kindprozess wird beendet */
     }

   close(ForeignSocket); /* der Elternprozess schliesst die Verbindung */
   }

Vorteile:

Dem stehen als Nachteile gegenüber:

Signale

Mit Signalen können Prozesse veranlaßt werden, von ihrem "normalen" Ablauf abzuweichen. Sie können beispielsweise durch Ausführung eines fehlerhaften Befehls - wie Division durch 0, Zugriff auf einen geschützten Speicherbereich, etc. - verursacht werden, aber auch durch "asynchrone" Ereignisse, wie das Drücken der Taste Ctrl-C, oder dadurch, daß ein Prozeß einem anderen ein Signal zusendet. Letzteres ist beispielsweise nötig, wenn ein Prozeß abgebrochen werden soll, da es in Unix grundsätzlich nicht möglich ist, den Zustand eines Prozesses "von außen" zu verändern. Der Prozeß muß über die gewünschte Zustandsänderung informiert werden, um diese dann selbst durchzuführen. In Linux sind beispielsweise 32 Signale definiert (/usr/include/signum.h). Einige wichtige sind hier aufgelistet:
#define SIGHUP   1  // "Auflegen" - z.B. bei einer Terminalleitung
#define SIGINT   2  // Interrupt - z.B. Ctrl-C
#define SIGILL   4  // Falscher Befehlscode
#define SIGBUS   7  // Busfehler
#define SIGKILL  9  // "Töten" eines Prozesses
#define SIGSEGV 11  // Fehlerhafter Speicherzugriff
#define SIGALRM 14  // Timer-Signal
#define SIGCHLD 17  // "Vater, eines deiner Kinder ist tot"
#define SIGCONT 18  // Prozeß fortsetzen (aus Zustand "stopped")
#define SIGSTOP 19  // Prozeß anhalten -> Zustand "stopped"
Das Senden eines Signales an einen Prozeß entspricht im Wesentlichen dem Setzen des entsprechenden Bits in einem dafür vorgesehenen Speicherwort des Prozeßkontrollblockes. Der Prozeß kann zu jedem beliebigen Zeitpunkt festlegen, ob beim Empfang eines bestimmten Signales Letzteres ist allerdings nicht bei allen Signalen möglich.

Was macht man, wenn beispielsweise 25 Kindprozesse aktiv sind, und sich im Prinzip jeder jederzeit beenden kann, man aber nicht die Übersicht verlieren will? Wenn der Kindprozeß stirbt, schickt er dem Elternprozeß ein Signal, SIGCHLD. Solange der Elternprozeß dieses Signal nicht annimmt, kann der Kindprozeß nicht aus der Prozeßtabelle entfernt werden, obwohl es nicht mehr aktiv ist. Solche Prozesse nennt man "Zombie-Prozesse". Erst wenn der Elternprozeß mit waitpid() oder wait() das Signal des Kindes beachtet und dessen Rückgabewert entgegengenommen hat, wird das Kind aus der Prozeßtabelle entfernt. Stirbt hingegen der Elternprozeß und verwaist das Kind, so erbt der Prozeß mit Prozeß-ID 1 - in aller Regel init - diesen Prozeß. Die PPID wird entsprechend abgeändert.

Wenn man für SIGCHLD einen Signalhandler setzt, kann das Problem ganz einfach gelöst werden. Dazu müssen wir uns aber zuerst mit Signalen beschäftigen. Signale sind die wohl einfachste Form der Prozeßkommunikation. Jeder Prozeß kann seinen Kindern und auch allen anderen Prozessen desselben Anwenders Signale schicken. Ein Prozeß mit Root-Rechten kann jedem Prozeß Signale schicken. Wann immer ein Prozeß vom Scheduler aktiviert wird oder vom Aufruf einer Systemfunktion zurückkehrt, wird nachgesehen, ob irgendwelche Signale angekommen sind, und gegebenenfalls die hierfür eingetragenen Signalhandler aktiviert. Man kann alle Signale mit Ausnahme von SIGKILL und SIGSTOP ignorieren. Signalhandler, laufen unter besonderen Bedingungen, weshalb sie so klein und einfach wie möglich gehalten werden sollten.

Sehen wir uns hierzu das folgende Perl-Programm an:

#!/usr/bin/perl -w

my $count = 0;

$SIG{INT} = sub
  {
  $count++;
  warn "Oops! Das ist schon die Unterbrechung $count\n";
  };

while ($count < 5)
  {
  print "Ratzepuehh!\n";
  sleep(3);
  }
Das Programm gibt alle drei Sekunden "Ratzepuehh!" aus und schläft dann weiter. Immer wenn die Taste Control-C gedrückt wird, löst dies einen Interrupt aus. Für diesen Interrupt (Signal INT) wurde ein Signalhandler installiert, der eine Warnung ausgibt und die ANzahl der Unterbrechungen hochzählt. Nach mehr als fünf Umterbrechungen beendet sich der Prozess. Der magische Hash %SIG enthält zu jedem Signal eine Subroutine, die aufgerufen wird, wenn dieses Signal ankommt. Mit der Anweisung $SIG{INT} = sub { ... } setzen wir einen eigenen Signalhandler für das Signal INT. Für die Aktionen eines Signalhandlers bieten sich folgende Möglichkeiten:

$SIG{INT} = 'IGNORE';Ignoriert SIGINT
$SIG{INT} = 'DEFAULT';Setzt die Default-Action für SIGINT
$SIG{INT} = \&catcher;führt den Code in sub catcher aus
$SIG{INT} = sub { $counter++; };führt den Code der anonymen sub aus

Signale sind asynchrone Ereignisse. Das laufende Programm wird unterbrochen und die Anweisungen im Signalhandler werden ausgeführt. Je nachdem wo sich Ihr Programm im Code gerade befindet, wenn ein Signal eintritt, können unterschiedliche Ereignisse auftreten. Perl ist nicht reentrant, zumindest nicht im Bereich der Low-Level-Systemzugriffe. Wenn ein Signal auftaucht, während Perl seine interne Datenstruktur ändert (z.B. malloc) ist ein Absturz die Regel. Auch deshalb sollten Signalhandler so kurz und einfach wie möglich sein. Probieren wir ein Beispiel (in Perl) mit mehreren Prozessen:

#!/usr/bin/perl -w
$|=1;

my ($i, $pid, $time);
my %child_pids = (); # Hash fuer Prozessnummern

# Signalhandler fuer Childs
$SIG{CHLD} = sub 
  {
  my $pid=wait();
  print "Terminated: $pid\n";
  delete $child_pids{$pid};
  };

# Machen wir mal 10 Kinder
for($i = 0; $i < 10; $i++)
  {
  $pid = fork();
  if($pid == 0) # KIND
    {
    sleep rand(20);
    exit(0);
    }
  else          # ELTERN
    {
    print "$pid wurde gestartet\n";
    $child_pids{$pid} = 1; # merken
    }
  }

# Warten, bis alle Kinder terminiert sind
$time = 0;
while(0 + keys(%child_pids)) 
  {
  print "TIME: $time\n";
  sleep 1;
  $time++;
  }
Der Signalhandler wird jedesmal beim Terminieren eines Kindes aufgerufen, da der Erlternprozeß ein SIGCHLD-Signal erhält. Der Aufruf von wait() beseitigt dann alle Spuren des Kindes (wobei hier auch der Rückgabewert ignoriert wird). wait() wartet ja auf das Ende eines Kindprozesses und liefert dessen ID zurück. Man sollte erwarten, daß dieses Programm korrekt arbeitet. Es kommt je nach Rechner häufiger oder seltener vor, daß der Elternprozeß nicht mitbekommt, daß ein Kind terminiert ist, und am Schluß ewig wartet. Das hängt damit zusammen, daß Man kann sich beispielsweise behelfen, indem der Signalhandler passend erweitert wird. Statt wait() kommt nun waitpid() zum Einsatz. Diese Funktion kann über einen Parameter im Verhalten gesteuert werden. Werte für diesen Parameter befinden sich im POSIX-Modul, weshalb dieses im folgenden Programm eingebunden wird. Der Parameter WNOHANG versetzt waitpid() in den "nonblocking mode". Die Funktion liefert entweder die ID eines terminierten Kindes oder -1. falls keines existiert. Ein anderer nützlicher Wert ist WUNTRACED, der PIDs von gestoppten und terminierten Kindern liefert. Im obigen Programm muß also nur der Signalhandler geändert werden:
#!/usr/bin/perl -w

use POSIX ":sys_wait_h";

$|=1;

my ($i, $pid, $time);
my %child_pids = ();

$SIG{CHLD} = sub 
  {
  my($pid);
  foreach $pid (keys(%child_pids))
    {
    if(waitpid($pid,WNOHANG))
	  {
	  print "Terminated: $pid\n";
	  delete $child_pids{$pid};
      }
    }
  };

# Machen wir mal 10 Kinder
for($i = 0; $i < 10; $i++)
  {
  $pid = fork();
  if($pid == 0) # KIND
    {
    sleep rand(20);
    exit(0);
    }
  else          # ELTERN
    {
    print "$pid wurde gestartet\n";
    $child_pids{$pid} = 1; # merken
    }
  }

# Warten, bis alle Kinder terminiert sind
$time = 0;
while(0 + keys(%child_pids)) 
  {
  print "TIME: $time\n";
  sleep 1;
  $time++;
  }
Ein grundsätzliches Problem mit Kindprozessen ist, daß man stets damit rechnen muß, daß Eltern- oder Kindprozeß aus unterschiedlichsten Gründen verstirbt, und sei es nur, weil der Anwender ihm ein SIGKILL geschickt hat. Für einen Elternprozeß ist es relativ einfach, verstorbene Kinder auszumachen. Verwaiste Kinder werden hingegen nicht per Signal benachrichtigt.

BSD und POSIX-konforme Systeme verfügen über verläßliche Signale. Manche Systeme, z. B. (ältere) System V verfügen über keine zuverlässige Bibliothek zur Signalbehandlung. Für solche Systeme (und ggf. aus Portabilitätsgründen) müssen Sie die Signalhandler nach jedem Auftreten des Signals neu installieren.

#!/bin/perl

# globale Variablen initalisieren
my $sig = '';

# ALLE Signale erhalten einen Signalhandler
@sigs = keys %SIG;
for (@sigs) 
  { $SIG{$_} = \&catcher; }

# Signalhandler
sub catcher 
  {
  $sig = shift; 
  print STDERR "SIGNAL $sig \n";
  # reinstall handler
  $SIG{$sig} = \&catcher;           
  }

Für Neugierige gibt es eine Einführung der C-Systemaufrufe zur Behandlung von Prozessen als Hintergrundinformation.

3.3 TCP/IP-Sockets: Die Funktionen

Da die Programmierung von Client und Server primär in Perl stattfinden wird, erfolgt die Vorstellung der Systemfunktionen recht kurz. Die Ähnlichkeit der später besprochenen Perl-Funktionen und -Methoden mit den C-Systemaufrufen ist jedoch nicht rein zufällig.

Mit Sockets läßt sich der Austausch von Nachrichten zwischen Prozessen verbindungsorientiert oder mit Datagrammen recht einfach programmieren. Durch Angabe eines Sockettyps wird die Art der Kommunikation festgelegt: SOCK STREAM: verbindungsorientiert, SOCK DGRAM: Datagramm (daneben gibt es noch weitere Typen). Die kommunizierenden Prozesse können auf demselben Rechner ablaufen oder auf vernetzten Maschinen. Die Programmierschnittstelle unterstützt verschiedene Protokollfamilien und, daran gekoppelt, verschiedene Adressierungsarten. Ein Beispiel für eine Adressfamilie ist AF_UNIX. Sie definiert einen Adressierungsmechanismus für die rechnerinterne Kommunikation zwischen UNIX-Prozessen. Als Adressobjekte werden Pfade im Dateisystem verwendet, genau wie bei Pipes. Welche Adressfamilien unterstützt werden, hängt davon ab, welche Netzwerkprotokolle das Betriebssystem beherrscht. UNIX-Systeme werden zumindest AF_UNIX und AF_INET unterstützen.

Kommunikationsendpunkt: socket

Um mit Sockets zu arbeiten, muß zuerst eine Verbindung geöffnet werden. Hier gibt es Analogien zu Dateizugriffen. Als erstes muß also ein Socket vom Betriebssystem angefordert werden. Dies geschieht mit dem socket()-Systemaufruf. Der Aufruf entspricht einem fopen bei Dateien. Die Funktion socket() hat drei Parameter:
int socket(int Family, int Sockettype, int Protocol);

Der socket()-Systemaufruf liefert einen Integerwert zurück, der einem Dateidescriptor ähnelt. Dieser Wert wird daher "Socketdeskriptor" oder "sockfd" genannt. Im Fehlerfall hat er den Wert -1. Beispiel:

#include <sys/types.h>
#include <sys/socket.h>

int MySocket, ForeignSocket;
...

MySocket = socket(AF_INET, SOCK_STREAM, IPPPROTO_TCP);
...

close(MySocket);
Nach Aufruf von "socket" ist der Socket jedoch noch nicht betriebsbereit. Es muss jetzt noch festgelegt werden, für welchen Port (d.h. für welches Protokoll der Anwendungsebene) der Socket zuständig sein soll, ob es sich um einen Server- oder Client-Socket handeln soll, etc.

Jeder eröffnete Socket muß auch wieder geschlossen werden. Eine Nachlässigkeit an dieser Stelle kann sich bitter rächen, da insbesondere bei Serverprozessen Verbindungen sehr oft eröffnet werden und die Systemresourcen für Netzverbindungen irgendwann zur Neige gehen, was meist zum Stillstand des Servers führt. Das Schließen des Sockets erfolgt unter UNIX mit dem close-Aufruf.

Socket einrichten: bind

Der Serverprozess muß von außen erreichbar sein. Dazu bekommt er einen sogenannten well known port. Diese Nummer ist also den Clientprozessen bekannt. Um einen Socket an diese Nummer zu binden, wird der bind-Aufruf verwendet. Als Parameter verwendet bind den Socket und eine Struktur sockaddr_in, die diesen Port beschreibt.
int bind (int sockfd, struct sockaddr *Myaddr, int Addrlen);
Mit dem Aufruf wird ein Speicherbereich bereitgestellt, der zur Festlegung der Protokoll-Familie und der Portnummer vorgesehen ist. Bei einem Server-Socket erfolgt damit die Zuordnung zu dem gewünschten Port - er erklärt sich damit zuständig für ein bestimmtes Anwender-Protokoll. Der Aufruf von "bind" ist sowohl bei Datenströmen als auch bei Datagrammen erforderlich. Der Parameter "sockfd" ist ein Dateideskriptor, der mit einem vorangegangenen "socket"-Aufruf erzeugt wurde. Der zweite Parameter ist ein Zeiger auf eine protokollspezifische Adresse und der dritte Parameter gibt die Größe der Adreßstruktur an. bind wird in drei Fällen angewendet:
  1. Server registrieren ihre eigene Adresse innerhalb des Systems.
  2. Ein Client kann eine spezifische Adresse selbst speichern.
  3. Ein verbindungsloser Client muß vom System eine individuelle Adresse anfordern, damit er eine gültige Adresse für die Rückantwort hat.
bind füllt also im oben angeführten Fünfertupel die Felder "lokale Adresse" und "lokaler Prozeß".

struct sockaddr ist eine allgemeingültige Datenstruktur, die für verschieden Protokollfamilien existiert. Bei Verwendung der Internet-Protokollfamilie kann sie vom Anwenderprogramm überlagert werden durch eine Struktur sockaddr_in, die ausschließlich für IP geeignet ist. Unter der Annahme, daß der Anwender eine Variable vom Typ "sockaddr_in" in der Form struct sockaddr_in adresse; deklariert hat, enthält "adresse" u. a. die folgenden Komponenten:

adresse.sin_family      /* vorzeichenlose 16bit-Ganzzahl (Protokoll-Familie) */
adresse.sin_port        /* vorzeichenlose 16bit-Ganzzahl (Portnummer) */
adresse.sin_addr.s_addr /* vorzeichenlose 32bit-Ganzzahl (Internetadresse) */
In die Komponente sin_family wird die Konstante AF_INET (2) eingetragen. In die Komponente sin_port ist die Portnummer einzutragen - allerdings in sog. "Netzwerk-Anordnung": Portnummer und Internetadresse sind Zahlen, die über das Netz verschickt werden und demnach unabhängig von der internen Zahlendarstellung des jeweiligen Rechners sein müssen (siehe später).

struct sockaddr_in adresse;

adresse.sin_family = AF_INET; /* Internet-Protokoll-Familie */
adresse.sin_port = htons(80); /* Port festlegen */
adresse.sin_addr.s_addr = 0;  /* Internetadresse irrelevant */

int ergebnis = bind(descriptor,(struct sockaddr *)&adresse,sizeof(adresse));
Der "Typecast"-Operator (struct sockaddr *) ist erforderlich, wenn der Compiler auf strenge Typprüfung eingestellt ist. Die Funktion bind() erwartet ja einen Zeiger vom Typ struct sockaddr *. Beispiel:
   .
   .
   .
  s = socket(AF_INET, SOCK_STREAM, 0);
  if (s < 0)
    {
    fprintf(stderr, "Error: Socket\n");
    return -1;
    }

  my_addr.sin_family = AF_INET;
  my_addr.sin_port = htons(4711); /* Portnummer */
  my_addr.sin_addr.s_addr = INADDR_ANY; /* An jedem Device warten */

  if (bind(s, (struct sockaddr *)&my_addr, sizeof(my_addr)) < 0)
    {
    close(s);
    fprintf(stderr, "Error: bind\n");
    return -1;
    }
   .
   .
   .

Warteschlange festlegen: listen

Der listen-Aufruf gibt an, wieviele Anfragen gepuffert werden können. In fast allen Programmen wird hier ein Wert von 5 für backlog verwendet (der derzeitige Höchstwert).
int listen(int sockfd, int backlog);
listen folgt normalerweise nach socket und bind und unmittelbar vor accept.

Falls die listen()-Warteschlange voll ist, werden weitere Verbindungswünsche von Clients abgewiesen. Ein Server für Datagramme (UDP) braucht listen() nicht aufzurufen, da er keine Verbindungen zu Clients einrichtet.

Verbindungswunsch entgegennehmen: accept

Der accept-Aufruf wartet auf eine Anfrage eines Clients. Der Aufruf von accept() liefert als Rückgabewert die Socket-ID des Partners. Des weiteren wird per Parameter in einer Variablen der Struktur sockaddr_in die Adresse des Partners geliefert.
int accept(int sockfd, struct sockaddr_in *Peer, int *Addrlen);
accept nimmt die erste Anforderung von der Warteschlange und generiert einen weiteren Socket mit der gleichen Eigenschaft wie sockfd. Der Parameter Peer verweist auf einen Speicherbereich, dessen Inhalt beim Aufruf undefiniert sein kann. In diesen trägt accept() die Internetadresse des Absenders eines eintreffenden Verbindungswunsches ein (die Adresse des Clients). Auf diese Datenstruktur kann genauso zugegriffen werden, wie dies bereits bei bind() erklärt wurde. Peer und Addrlen liefern also die Felder "ferne Adresse" und "ferner Prozeß" des Fünfertupels. Der Parameter "Addrlen" ist die Adresse eines Variablen-Parameters: Vor dem Aufruf muß dort die maximale Länge des Speicherbereiches stehen, auf den der Parameter Peer zeigt. Nach dem Aufruf enthält er die Anzahl Bytes, die das Betriebssystem tatsächlich dort eingetragen hat.

accept generiert (bei einem concurrent server) automatisch einen neuen Socketdescriptor für die aktuelle Verbindung. Der Rückgabewert von accept() ist also ein neuer Dateideskriptor, über den in der Folge die Kommunikation mit dem Client erfolgt (z.B. mit read() und write()). Der als erster Parameter angegebene Deskriptor bleibt für weitere Verbindungswünsche reserviert. Zum Beispiel:

#include <sys/types.h>
#include <sys/socket.h>

int MySocket, ForeignSocket, Partnerlen;
struct sockaddr_in AdrMySock, AdrPartnerSocket;
...

  MySocket = socket(AF_INET, SOCK_STREAM, IPPPROTO_TCP);
...

  AdrMySock.sin_family = AF_INET;
  AdrMySock.sin_addr.s_addr = INADDR_ANY; /* akzept. jeden */
  AdrMySock.sin_port = PortNr;	/* wird per getservbyname bestimmt */
  bind(MySocket, &AdrMySock, sizeof(AdrMySock));
  listen(MySock, 5);
  for(;;)
    {
    ForeignSocket = accept(MySocket, &AdrPartnerSocket, &Partnerlen);
    ...
    close(ForeignSocket);
    }
...
Nicht vergessen: ForeignSocket muß nach Ende der Kommunikation geschlossen werden, sonst gehen dem System nach einiger Zeit die Sockets aus.

Clientaufruf: connect

Sobald der Server läuft, kann der Client Verbindung zum well known port des Servers aufnehmen. Der entsprechende Aufruf lautet connect.
int connect(int sockfd, struct sockaddr_in *ServAddr, int Addrlen);
Der Parameter sockfd ist natürlich wieder der Socket-Deskriptor. Die weiteren Parameter entsprechen jenen von bind. Für die meisten verbindungsorientierten Protokolle richtet connect eine Verbindung vom lokalen zum fernen Rechner ein.
Allerdings muß diesmal in der Datenstruktur, auf die ServAddr zeigt, die Internetadresse des gewünschten Servers eingetragen werden. Die Verbindung erfolgt zu dem angegebenen Rechner. Weiterhin ist ein Port anzugeben. Der Ziel-Server wird durch seine IP-Nummer festgelegt. Diese steht in der Struktur sockaddr_in im Element sin_addr. Beispiel:
#include <sys/types.h>
#include <sys/socket.h>

struct sockaddr_in AdrSock;
...
  AdrSock.sin_family = AF_INET;
  AdrSock.sin_addr = HostID;
  AdrSock.sin_port = htons(PortNr);
  connect(MySocket, (struct sockaddr *)&AdrSock, sizeof(AdrSock));
...

HostID stellt eine 32-Bit-Ganzzahl dar. Im Normalfall liegt die Host-Adresse natürlich nicht Ganzzahl vor, sondern als Zeichenkette der Form "www.netzmafia.de" oder "192.168.234.77". Zu korrekten Umwandlung in eine ganze Zahl, die in Netzwerk-Anordnung vorliegen muss, stehen in der C-Bibliothek zwei Routinen zur Verfügung, gethostbyname() und inet_addr(), die weiter unten besprochen werden.

Das folgende Beispiel zeigt den kompletten Verbindungsaufbau eines Clients:

#include <sys/types.h>
#include <sys/socket.h>

struct sockaddr_in server;

/* Deklaration des Sockets */
int descr = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);

/* Datenstruktur "server" vorbereiten */
server.sin_family = AF_INET;    /* Internet-Protokoll-Familie */
server.sin_port = htons(80);    /* Port 80 festlegen (HTTP) */

/* 1.Versuch die Internetadresse zu ermitteln, Form: "a.b.c.d" */
server.sin_addr.s_addr = inet_addr(server_name);
if (server.sin_addr.s_addr == -1) /* keine korr. Punktnotation */
  {
  /* 2. Versuch: symbolisch */
  struct hostent *host = gethostbyname(server_name);
  if (host != NULL)
    {
    server.sin_addr.s_addr = *((unsigned long*)host->h_addr_list[0]);
    }
  else
    { 
    printf("Internetadresse nicht gefunden\n");
    exit(1);
    }
  }

/* jetzt kann verbunden werden */
int i = connect(descr, (struct sockaddr *)&server, sizeof(server));
if (i == 0)
  { /* jetzt steht die Verbindung! */
  ...
  }

Datenaustausch: send, sendto, recv und recvfrom

Mit diesen Aufrufen werden Daten über die bestehenden Verbindungen transportiert. Unter UNIX könnten dafür auch die Dateiaufrufe read und write verwendet werden.
int send(int sockfd, char *Buffer, int NBytes, int Flags);

int sendto(int sockfd, char *Buffer, int NBytes, int Flags,
           struct sockaddr_in *To, int AddrLen);

int recv(int sockfd, char *Buffer, int NBytes, int Flags);

int recvfrom(int sockfd, char *Buffer, int NBytes, int Flags,
           struct sockaddr_in *From, int AddrLen);

Die ersten drei Parameter dieser vier Systemaufrunfe sind den ersten drei Parametern von read und write ähnlich. Der Parameter sockfd identifiziert wieder den gewünschten Socket, Buffer ist ein Zeiger auf einen beliebigen Speicherpuffer, NBytes bestimmt die Anzahl der zu übertragenden Bytes und Flags hat im Normalfall den Wert Null oder er stellt das das Resultat einer Oder-Verknüpfung mit einer der folgenden Konstanten dar: Wird Flags beispielsweise auf den Wert 1 gesetzt (MSG_OOB), dann soll die Übertragung "out of band" erfolgen. Bei dieser Übertragung werden nach Möglichkeit bisher bereits abgeschickte Daten überholt. Es handelt sich dann beispielsweise um hochpriore Informationen, wie beispielsweise das Abbruchsignal Crtl-C beim Telnet-Protokoll.

Alle Funktionen liefern als Rückgabewert die Größe der empfangenen bzw. gesendeten Datenmenge. Die recv-Funktion liefert die Sendung in Blöcken von maximal 1 KByte Größe. Wurden größere Pakete verschickt, müssen sie stückweise gelesen werden. Das Senden ist nicht beschränkt. Da der Rückgabewert nichts über die Grösse des tatsächlich gesendeten Pakets aussagt, muß dies vom Programm geregelt werden. Wenn die Pakete nicht immer gleiche Größe besitzen, wird die Paketlänge meist in den ersten Bytes des ersten Paketes kodiert.

Für den Normalfall (Flags gleich Null) kann statt send() auch die Systemfunktion write verwendet werden. Zum Beispiel kann statt

send(sock,"Hello World",11,0);
auch wie folgt programmiert werden:
write(sock,"Hello World",11);
Darüberhinaus besteht natürlich die Möglichkeit, eine Datei für Standard-Ein/Ausgabe über dem betreffenden Deskriptor zu definieren:
FILE *f = fdopen(descr,"rw");
wodurch nun auch mit Routinen der "stdio"-Bibliothek auf den Socket zugegriffen werden kann, z. B.:
fprintf(f,"Hello World");
Dies ist insbesondere wichtig, wenn die Standardeingabe oder Standardausgabe eines beliebigen Programmes auf einen Socket umgeleitet werden soll.

Der Rückgabewert von recv() gibt Auskunft über die tatsächliche Anzahl empfangener Bytes. Ist dieser Wert -1, handelt es sich um einen Fehler, beim Wert 0 wurde die Verbindung von der Gegenseite geschlossen. Andernfalls ist der Wert immer größer 0 und kleiner gleich dem Parameter NBytes.
Bei Verwendung des Flags MSG_PEEK während des Empfangs werden die Daten zwar zum Anwenderprogramm übertragen, sie verbleiben jedoch auch noch in der Empfangswarteschlange, so daß sie mit einem nachfolgenden recv()-Aufruf nochmals gelesen werden können.
Ruft der Empfänger die recv()-Funktion mit NBytes > 0 auf und stehen im Empfangspuffer bereits Daten bereit (aber weniger als erwartet - beispielsweise weil der Rest noch nicht angekommen ist), dann kehrt die Funktion trotzdem sofort zurück und übergibt die tatsächliche Anzahl der übertragenen Bytes. Erfordert es die Logik des Anwenderprogrammes, daß vor einer Fortsetzung die Gesamtzahl der erwarteten Bytes eingetroffen ist, so muß der Aufruf von recv() so lange wiederholt werden, bis alle Daten eingetroffen sind. Die Daten müssen vom Empfänger in geeigneter Form zusammengesetzt werden.
Wird recv() mit Flags = 0 aufgerufen, kann stattdessen die Systemfunktion read() verwendet werden.

Die Funktionen sendto() und revcfrom() dienen dazu, Daten auf einer UDP-Verbindung zu senden und zu empfangen. Die ersten vier Parameter haben die gleiche Bedeutung wie bei send() und recv(). Beim vorletzten Parameter von sendto() muss man einen Pointer auf eine Variable vom Typ struct sockaddr_in, in der festgehalten ist, wohin das Paket genau gesendet werden soll. Der letzte Parameter gibt die genaue Länge der vorangegangenen Variablen an. In der Funktion recvfrom() dient der Parameter From als Platzhalter, in den bei einem empfangenen Paket Informationen über den Sender gespeichert und an das Programm zurückgegeben werden.

Wenn Sie für eine UDP-Verbindung vorher connect()aufgerufen haben, so können Sie einfach send() und recv() verwenden.

Socket schließen: close

Eine bidirektionale Socket-Verbindung kann mit dem Aufruf
int shutdown(int sockfd, int how);
geschlossen werden. Dabei legt der Parameter how fest, ob künftig keine Daten mehr empfangen werden sollen (how=0), keine mehr gesendet werden (how=1), oder beides (how=2). Wird statt shutdown() die Systemfunktion close() benutzt, dann entspricht dies einem shutdown(sock,2).
int close(int sockfd);

Zahlenformat: ntoh und hton

Portnummer und Internetadresse sind Zahlen, die über das Netz verschickt werden und demnach unabhängig von der internen Zahlendarstellung des jeweiligen Rechners sein müssen. Die Reihenfolge der Bytes eines Datenwortes ist auf den verschiedenen Computern unterschiedlich definiert. So besteht eine Variable vom Typ short aus zwei Byte. Auf einer Maschine mit Intel-Architektur kommt dabei das niederwerte Byte zuerst ("little endian"), während es auf einem 68000-Prozessor oder einer Sun genau umgekehrt ist. Aus diesem Grund wurde eine eindeutige Netzwerk-Anordnung der zu übertragenden Bytes definiert (höherwertige Bytes zuerst!).

Bei Rechnerarchitekturen, bei denen der Speicher nach der Host-Order ausgewertet wird (das niederwertige Byte also vor dem höherwertigen im Speicher steht), ist es notwendig, alle Werte mit dem Type LONG oder WORD vor der Übergabe an den Treiber in die Network-Order zu konvertieren. Um Zahlen der Maschine in die passende Form für das Netz zu bringen und die Programme portabel zu halten, gibt es die Makros ntoh() (Net to Host) und hton() (Host to Net). Beide wirken auf short-Variablen. Für long-Variablen gibt es die analog funktionierenden Makros htonl() und ntohl(). Vorsicht ist auch bei Vergleichen geboten: Sie liefern in Network- und Host-Order nicht das gleiche Ergebnis!

Das Socket-Interface stellt hier eine Reihe von Konvertierungs-Funktionen zur Verfügung. Die Funktionen inet_addr() und inet_ntoa() weichen etwas von den anderen ab. Sie wandeln eine Internetadresse, die als String im "dotted quad"-Format vorliegt, in einen 32-Bit-Wert und umgekehrt.

Um beispielsweise den Port des POP3-Dienstes (110) numerisch an die Struktur sock_add_in zu übergeben, würde man hton verwenden (eigentlich sollte man dazu getservbyname verwenden):

struct sockaddr_in AdrSock;
...
   AdrSock.sin_port = hton(110);
...

Byte-Operationen

In den verschiedenen Socket-Adreßstrukturen existieren unterschiedliche Byte-Felder, die alle behandelt werden müssen. Einige dieser Felder sind, wie auch immer, keine C-Integer-Felder, so daß hier andere Techniken angewandt werden müssen, um mit ihnen allen gleich operieren zu können. BSD definiert die folgenden drei Routinen, die auf benutzerdefinierten Byte-Strings basieren. Darunter ist zu verstehen, daß es sich um keine Standard-Strings in C handelt, die bekanntermaßen mit einem Nullbyte abgeschlossen werden, sondern die benutzerdefinierten Byte-Strings können innerhalb des Strings durchaus Nullbytes besitzen. Deshalb muß die Länge des Strings den Funktionen als Parameter mitgegeben werden.
bcopy (char *Src, char *Dest, int NBytes);
Kopiert NBytes vom Ursprung (SRC) zum Ziel (Dest). Achtung: Parameterreihenfolge anders als bei strcpy.
bzero (char *Dest, int NBytes);
Schreibt NBytes Null-Bytes an das angegebene Ziel.
int bcmp (char *Ptrl, char *Ptr2, int NBytes);
vergleicht zwei Byte-Strings. der Rückgabewert ist gleich Null, wenn beide Byte-Strings gleich sind, sonst ungleich Null (also auch anders als bei strcmp).

Namensauflösung

Computer und Dienste werden unter TCP/IP immer über die IP-Nummern angesprochen. Für den Menschen ist jedoch ein (Domain-)Name bequemer. Allerdings gibt es für beides Mechanismen zur Namensauflösung. Im Programm ruft man entsprechende Funktionen auf.

Normalerweise sind der gewünschte Dienst und der Name des Hosts bekannt, der bezüglich des Dienstes angesprochen werden soll. Daher zuerst ein Blick auf den Host.

#include <netdb.h>
...

struct hostent *gethostbyname (char *hostname);
...
Die gethostbyname-Funktion gibt einen Zeiger auf eine hostent-Struktur zurück:
struct hostent 
  {
  char *h_name;       /* official name of host */
  char **h_aliases;   /* alias list */
  int h_addrtype;     /* host address type */
  int h_length;       /* length of address */
  char **h_addr_list; /* a NULL terminates the list */
  };

#define h_addr h_addr_list[0]; /* first address in list */
Gegenwärtig enthält das Feld h_addrtype immer den Wert A_INET und analog das Feld h_length immer den Wert 4 (ist gleich der Länge der Internet-Adressse). Bei Internet-Adressen besteht die Matrix der Zeiger h_addr_list[0], h_addr_list [1], ... nicht aus Zeigern auf Zeichen, sondern aus Zeigern auf Strukturen vom Typ in_addr. Die hostent-Struktur ist sehr allgemein gehalten, wobei momentan vieles davon noch nicht verwendet wird.
Das wichtigste Element der hostent-Struktur ist das Feld h_addr_list, das in einem Array die IP-Nummer des Rechners enthält. Das Makro h_addr liefert die Nummer, wie sie in früheren Versionen üblich war. Das Feld h_length liefert die Größe einer IP-Nummer.
Ein Host kann mehr als einen Namen tragen, denn ein universell einsetzbarer Host kann mehr als eine Internet-Schnittstelle besitzen, jede mit einer eindeutigen IP-Adresse. Das folgende Beispiel zeigt die Verwendung der gethostbyname-Funktion.
/* Print the "hostent" information for every host whose name is
 * specified on the command line. (nach Stevens)
 */
#include <stdio.h>
#include <sys/types.h>
#include <netdb.h>       /* for struct hostent */
#include <sys/socket.h>  /* for AF-INET */
#include <netinet/in.h>  /* for struct in_addr */
#include <arpa/inet.h>   /* for inet_ntoa() */

void pr_inet(char **listptr, int length);

int main(int argc, char **argv)
  {
  char *ptr;
  struct hostent *hostptr;

  while (--argc > 0) 
    {
    ptr = *++argv;
    if ((hostptr = gethostbyname(ptr)) == NULL)
      {
      printf("gethostbyname error for host %s\n",ptr);
      continue;
      }
    printf ("official host name: %s\n", hostptr->h_name);
    /* go through the list of aliases */
    while ((ptr = *(hostptr->h_aliases)) != NULL)
      {
      printf("    alias: %s\n", ptr);
      hostptr->h_aliases++;
      }
    printf("    addr type = %d, addr length = %d\n",
           hostptr->h_addrtype, hostptr->h_length);
    switch (hostptr->h_addrtype) 
      {
      case AF_INET: pr_inet(hostptr->h_addr_list, hostptr->h_length);
                    break;
      default:      printf("unknown address type\n");
                    break;
      }
    }
  return 0;
  }
      
void pr_inet(char **listptr, int length)
/* Go through a list of internet addresses,
   printing each one in dotted-decimal notation. */
  {
  struct in_addr *ptr;
  while ( (ptr = (struct in_addr *) *listptr++) != NULL)
    printf (" Internet address: %s\n", inet_ntoa(*ptr));
  }
Es gibt auch den Fall, daß ein Server die Internet-Adresse des Clients weiß, aber dessen Namen wissen möchte. Die Funktion gethostbyaddr erledigt in diesem Fall die Konvertierung von Adresse zu Namen:
#include <netdb.h>
...

struct hostent *gethostbyaddr (char *Addr, int Len, int Type);
...
Der Addr-Parameter ist ein Zeiger auf eine sockaddr_in-Struktur, welche die Internet-Adresse enthält. Len ist die Größe dieser Struktur. Type muß mit AF_INET angegeben werden. Ähnlich wie bei der gethostbyname-Funktion gibt es auch hier viel Allgemeingültiges, von dem jedoch nicht viel verwendet wird.

Die Funktion getservbyname sucht nach einem Dienst - letztendlich nach einem Port:

#include <netdb.h>
...
struct servent *getservbyname(char *Servicename, char *Protname);

...
Diese Funktion gibt einen Zeiger auf folgende Struktur zurück:
struct servent 
  {
  char *s_name;      /* official service name */
  char **s_aliases;  /* alias list */
  int s_port;        /* port number, network byte order */
  char *s_proto;     /* protocol to use */
  }
Die Information für diese Funktion wird der Datei /etc/services entnommen. In dieser Datei wird eine Suche nach dem geforderten Service (Servicename) gestartet. Ist auch ein Protokoll angegeben (d. h. Protname != NULL), dann muß der entsprechende Eintrag für dieses Protokoll in der Datei vorliegen. Es gibt einige Internet-Dienste, die entweder von TCP oder UDP unterstützt werden (z. B. der Echodienst), und andere, die nur ein Protokoll unterstützen (FTP erfordert beispielsweise TCP). Das Hauptaugenmerk innerhalb der servent-Struktur liegt auf der Internet-Portnummer. Zu beachten ist, daß diese Struktur Integer-Portnummern handhaben kann, sogar Intenet-Portnummern in 16 bit-Größe. Beispiel:
struct hostent *RechnerID;
struct servent *Service;
...

  RechnerID = gethostbyname("server");    /* Bestimme den Rechner */
  Service = getservbyname("echo","tcp");  /* Bestimme den Port */
...

Das wichtigste Element der servent-Struktur ist das Feld s_port. es enthält die Nummer des Ports, wie sie von der Funktion connect verwendet wird.

Nichtprivilegierte Programme (d. h. Programme ohne Root-Rechte) dürfen keine Server-Sockets auf Ports kleiner 1024 öffnen. So wird ein minimaler Schutz davor gewährleistet, daß irgend welche Programme normaler Anwender Ports kidnappen oder auf Ports eigene Services hochfahren, die die Maschine normalerweise nicht bieten würde. Andererseits ist es aus Sicherheitsaspekten nicht sinnvoll, wenn alle Serverprozesse mit root-Privilegien laufen. Die Lösung des Problems ist einfach: sobald man die Server-Sockets gebunden hat, kann man mit setreuid(2) die Sonderprivilegien gegen "normale" Userprivilegien tauschen. Alternativ kann man Beispielsweise sicherheitsrelevante setuid-Programme in einem chroot(2)-Gefängnis ablaufen lassen, oder das Programm in zwei Prozesse aufteilen, so daß nicht alles mit root-Rechten laufen muß.

Wenn man kurz nachdem ein Programm eine Server-Socket geschlossen hat versucht, einen neuen Socket an denselben Port wie den alten Server-Socket zu binden, erhält man einen "Address already in use"-Fehler. Der Grund dafür ist, daß möglicherweise im Netz noch Pakete herumgeistern, die für den alten Socket bestimmt sind und es deshalb sinnvoll ist, erst einmal zu warten, bis sich das Netz beruhigt hat. Wenn man eine Socket sofort an einen Port binden will, verwendet man die "Reuse"-Option.

Beispiele:

3.4 Der Internet-Superserver

Rein theoretisch müßte jeder Daemon bei Systemstart hochgefahren werden, für die eine Anforderung eines entfernten Rechner (Host) auftreten könnte. Dies würde aber die Zahl der laufenden Prozesse unnötig in die Höhe treiben und Systemresourcen verbrauchen. Deshalb wurde der Daemon inetd, der Internet-Superserver, entwickelt. Er "lauscht" auf alle Diensteanforderungen, die an dem von ihm überwachten Ports eingehen. Tritt eine solche Anforderung auf, prüft der Daemon die Zugriffsberechtigung (exakt: Die Kontrolle wird an den TCP-Warapper tcpd übergeben und dieser macht weiter) und startet im positivem Fall den entsprechenden Daemon, der dann die Anforderungen des Clients bearbeitet. Die Konfigurationsdatei ist /etc/inetd.conf. In ihr sind alle Dienste und die entsprechenden Dämonen mit Parametern verzeichnet.

Der inetd vereinfacht zudem das Schreiben von Server-Daemonen, da etliche Start-Details bereits durch den inetd selbst abgehandelt werden. Der Nachteil besteht darin, daß der inetd für jede Anfrage sowohl ein fork als auch ein exec ausführen muß, um den aktuellen Serverprozeß zu starten. Der Ablauf entspricht in etwa folgendem Schema:

  1. Beim Starten liest der Daemon die Datei /etc/inetd.conf und generiert für jeden der angegebenen Server einen Socket.
  2. Danach wird für jeden Socket ein bind() ausgeführt.
  3. Für jeden Stream-Socket wird nun ein listen() ausgeführt.
  4. Nun wird auf einen Verbindungswunsch von außen gewartet (per select()-Aufruf).
  5. Kommt ein Verbindungswunsch, wird er mit accept() angenommen.
  6. Nun erzeugt der inetd einen Kondprozeß zum Bearbeiten der Anforderung. Das Kind schließt alle Dateideskriptoren (außer dem Socket). Mittels dup2() wird der Socket dupliziert und Deskriptoren für stdin, stdout und stderr angelegt. Anschließend wechselt der Prozeß die Benutzeridentität und startet schließlich mittels exec() den Prozeß für den gewünschten Dienst.
  7. Bei eine Stream-Socket wird der angeschlossene Socket geschlossen.

Der inetd und die von ihm gestarteten Server stüten sich auf folgenden Dateien.

3.5 Ein-/Ausgabe-Polling mit select()

In Unix steht ein leistungsfähiger Mechanismus zur Verfügung, der es einem Anwenderprogramm ermöglicht, verschiedene Eingabekanäle "abzuhorchen", darüber festzustellen, ob mindestens einer davon Daten "anbietet" (oder zur Aufnahme von Daten bereit ist - s.u.), und im Anschluss daran diesen zum Lesen bzw. Schreiben auszuwählen. Ein solches Szenario ist beispielsweise dann gegeben, wenn das Programm auf Daten wartet, die ihm ein anderes über eine Pipe liefern soll, und es gleichzeitig auf Tastatureingaben reagieren möchte. In diesem Fall sind ja zwei Dateideskriptoren betroffen: Der Deskriptor 0 für die Standardeingabe und der Eingabedeskriptor der Pipe. Schematisch könnte man dies wie folgt beschreiben:
Wiederhole:
  |  Warte bis an der Standardeingabe ODER an der Pipe Daten anliegen
  |
  |  WENN Tastaturdaten vorliegen
  |       lies diese ein
  |       verarbeite sie
  |
  |  WENN an der Pipe Daten anliegen
  |       lies diese ein
  |       verarbeite sie
bis "fertig";
Außerdem kann select() verwendet werden, wenn ein Server als einzelner Prozeß mehrere Clients bedienen soll, da hier der Server erkennen kann, auf welchem Socket etwas gesendet oder empfangen werden soll. Dies ist notwendig, da der Aufruf von recv() so lange wartet, bis etwas empfangen wurde (er ist also blockierend). Der Server würde nun stehen bleiben, und das beim ersten Socket den er überprüft. Eine weitere Möglichkeit wäre nichtblockierende Ein-/Ausgabe (siehe fcntl()), die jedoch mehr Ressourcen braucht. select() wartet, bis etwas auf einem Socket aus der Socket-Liste ankommt bzw. gesendet werden kann. Nicht zuletzt kann man select() verwenden, um den Programmfluss für eine bestimmte Zeit zu unterbrechen (wie sleep() respektive usleep()). Zuerst die Deklaration von select() und dazugehöriger Makros:
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int n, fd_set *readfds, fd_set *writefds, 
                   fd_set *exceptfds, struct timeval *timeout);

FD_CLR(int fd, fd_set *set);
FD_SET(int fd, fd_set *set);
FD_ZERO(fd_set *set);
FD_ISSET(int fd, fd_set *set);
Die Parameter dieser Funktion haben die folgende Bedeutung:

In der Manpage von select() unter Linux ist folgendes Beispiel angegeben:

#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
  {
  fd_set rfds;
  struct timeval tv;
  int retval;

  /* Watch stdin (fd 0) to see when it has input. */
  FD_ZERO(&rfds);
  FD_SET(0, &rfds);
  /* Wait up to five seconds. */
  tv.tv_sec = 5;
  tv.tv_usec = 0;

  retval = select(1, &rfds, NULL, NULL, &tv); 
  /* Don't rely on the value of tv now! */

  if (retval)
    printf("Data is available now.\n"); 
    /* FD_ISSET(0, &rfds) == true */
  else
    printf("No data within five seconds.\n");
  exit(0);
  }
Auch hier wird darauf hingewiesen, daß man nach dem Aufruf von select() nicht mehr auf den Wert in der Struktur timeval verlassen kann.

Nach Ausführung von select() enthalten auch die drei Bitmuster-Parameter nicht mehr die vor dem Aufruf gesetzten Bits, sondern es sind nur noch diejenigen gesetzt, deren zugeordnete Kanäle die Fortsetzung des Prozesses veranlasst haben.
Der Rückgabewert von select() enthält die Gesamtzahl der (noch) gesetzten Bits. Dieser Wert kann auch 0 sein, wenn der Timeout abgelaufen ist, ohne daß eine Verbindung eingegangen wurde. Bei einem Fehler wird -1 zurückgegeben.

Beispiel:

Bitnummer 7 6 5 4 3 2 1 0
Variable IBM: 0 0 0 1 0 0 0 1
Variable OBM: 1 0 0 0 0 0 0 0
Variable EBM: 1 0 0 0 0 0 0 0

Mit dem Aufruf

int i = select(8, &IBM, &OBM, &EBM, NULL);
wird ohne Zeitbeschränkung darauf gewartet, daß entweder auf den "Kanälen" 0 oder 4 Eingabedaten zur Verfügung stehen, oder daß auf "Kanal" 7 ein Schreiben möglich ist (oder ein Fehler auftrat). Nach dem Verlassen der Funktion mit Rückgabewert 1 (nur noch 1 Bit gesetzt) sehen die Variablen wie folgt aus:

Bitnummer 7 6 5 4 3 2 1 0
Variable IBM: 0 0 0 0 0 0 0 1
Variable OBM: 0 0 0 0 0 0 0 0
Variable EBM: 0 0 0 0 0 0 0 0

Die C-Bibliothek stellt Makros zur Verfügung, die das Setzen, Löschen und Abfragen von Bits in den bei select() benutzten Bitmuster erleichtern. Die Deklaration ist oben schon aufgelistet. Hier einige Beispiele:

Abschließend noch ein Beispiel: Ein Programm soll auf Eingaben von der Tastatur warten, aber alle 3 Sekunden den Benutzer zur Eingabe auffordern, wenn er nicht reagiert.

 
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/time.h>

fd_set EBM;
struct timeval Zeit;
char buffer[1000];

int main() 
  {
  do 
    {
    printf("\nGibs mir:");
    fflush(stdout);                    /* Ausgabepuffer leeren */
    
    FD_ZERO(&EBM);   /* Eingabebitmuster = 0 */
    FD_SET(0,&EBM);  /* Bit 0 setzen         */

    Zeit.tv_sec=3;                     /* Timeout = 3 Sekunden */
    Zeit.tv_usec=0;
    } 
  while (!select(1, &EBM, NULL, NULL, &Zeit));
    
  /* Wenn select() mit 0 zurückkommt, ist 
     die Uhr abgelaufen andernfalls steht eine Eingabe an */

  fgets(buffer,1000,stdin);
  printf("Eingabe war: %s\n",buffer);
}
Der Aufruf fflush(stdout) wird in diesem Beispiel eingesetzt, damit die Eingabeaufforderung sofort auf dem Bildschirm erscheint.

Noch ein Beispiel: select() erlaubt es beispielsweise, ein Programm zu schreiben, das auf einem Port wartet und alle engehenden Daten an einen anderen Port weitergibt. Fertig ist der Proxy-Server! Angenommen man hat einen Rechner der per Modem eine Verbindung zum Internet aufgebaut hat als Gateway für ein lokales Netz dient (mittels IP-Masquerading). Nur der Gateway ist von aussen sichtbar, die Rechner des lokalen Netzes jedoch dahinter versteckt. Nehmen wir weiter an, daß auf einem der lokalen Rechner ein Web-Server läuft, der nach aussen Daten anbieten soll. Man braucht also ein Programm, das die Anfragen die an Port 80 des Gateway gelangen, zum Web-Server weitergereicht werden sollen (was einen einfachen Portforwarder aus dem Rennen wirft). Um das zu verwirklichen, braucht man also ein Programm das zwei Sockets geöffnet hat: einen zum Benutzer ausserhalb des Netzes, und einen zweiten der zum Web-Server führt. Das Programm muß erkennen, auf welchem Socket gerade etwas ankommt und diese Daten dann über den anderen Socket schicken. Eine Lösungsmöglichkeit wäre es, einen Firewall zu installieren. Mit select() geht es aber auch.

Das folgende Programmfragment zeigt, wie es geht.

        
int data_interchange(int src, int dest)
  {
  /* Implementierung der Polling-Methode + select() um
   * Systemressourcen zu sparen */

  char buffer[BUFFER_SIZE];
  int src_sent, src_recvd, dest_sent, dest_recvd, max, total, i;
  fd_set rfds;
  struct timeval tv;
    
  if (src > dest) max = src;
  else            max = dest;
  
  total = 0;  
  fcntl(src, F_SETFL, O_NONBLOCK);
  fcntl(dest, F_SETFL, O_NONBLOCK);
  
  for (;;)
    {
    FD_SET(src, &rfds);
    FD_SET(dest, &rfds);
    tv.tv_sec = 300;
    tv.tv_usec = 0;
    select(max + 1, &rfds, NULL, NULL, &tv);
    
    src_recvd = recv(src, buffer, sizeof(buffer), 0);
    dest_recvd = recv(dest, buffer, sizeof(buffer), 0);
    
    if (src_recvd > 0)
      send(dest, buffer, src_recvd, 0);
    
    if (dest_recvd > 0)
      send(src, buffer, dest_recvd, 0);
    
    if ((src_recvd == 0) || (dest_recvd == 0))
      break;
    }
  return 0; 
  }
Wie man sieht, wartet select() darauf, daß von einem der beiden Sockets gelesen werden kann. Ist dies der Fall, wird gelesen. Man hätte auch mit FD_ISSET() testen können, von welchem Socket gelesen werden kann, doch so haben wir gleich noch ein Beispiel für nicht-blockierende Ein-/Ausgabe. Durch den Aufruf von fcntl() mit dem Attribut O_NONBLOCK blockiert ein recv()-Aufruf nicht, bis Daten eingetroffen sind, sondern kehrt sofort zurück. Die beiden if-Abfragen überprüfen, von welchem der Sockets eingetroffen sind (Wert > 0). Falls von keinem der beiden Sockets Daten kommen, ist ein Timeout aufgetreten. Das bedeutet, daß der Server-Prozeß nach fünf Minuten Inaktivität automatisch endet.

3.6 Erste Perl-Server und Clients

Die in diesem und den folgenden Kapiteln einfachen Beispielserver wird auch wieder die Fehlerbehandlung nur eingeschräkt verwendet. Das ist beim Erweitern der Programme für eigene Anwendungen zu berücksichtigen.

Mit den Perl-Modul IO::Socket::INET kann man relativ einfach Socketverbindungen programmieren. Zum Einbinden reicht der Perl-Befehl use IO::Socket. Die Generierung eines konkreten Sockets geschieht über den Konstruktor von IO::Socket::INET, dessen Argumente darüber entscheiden, ob es ein Server- oder ein Client-Socket wird.

Beispiel für das Einrichten eines Client-seitigen Socket:

my $socket = IO::Socket::INET -> new(PeerAddr => $remote_host,
                                     PeerPort => $remote_port,
                                     Proto    => "tcp",
                                     Type     => SOCK_STREAM)
  or die "Couldn't connect to $remote_host:$remote_port: $@\n";
Beispiel für das Einrichten eines Server-seitigen Socket:
$server = IO::Socket::INET -> new(LocalPort => $server_port,
                                  Type      => SOCK_STREAM,
                                  Reuse     => 1,
                                  Listen    => SOMAXCONN)
   or die "Couldn't be a tcp server on port $server_port : $@\n";

Beim Server-Socket braucht man keine Rechneradresse angeben, das ist ja automatisch die Adresse des Rechners, auf der das Socket läuft; nur einen Port muß man festlegen.
Wird der Server-Socket nicht ordnungsgemäß geschlossen, so kann normalerweise derselbe Port nicht sofort wieder benutzt, sondern auf einen Timeout gewartet werden. Dies kann man allerdings durch Setzen des Parameters Reuse umgehen, was insbesondere für die Phase der Programmentwicklung nützlich ist, wo man den Server öfter mal abbricht.
'Listen' gibt an, bis zu wie viele Anfragen in eine Warteschleife aufgenommen werden sollen. Ist diese Warteschleife voll, so wird die Anfrage nicht bearbeitet und der Client erhält eine entsprechende Meldung. SOMAXCONN ist eine Systemkonstante die angibt, wie viele Anfragen das System maximal in der Warteschleife zulässt.

Auf der Client-Seite ist das Öffnen des Socket praktisch schon alles. Man kann $socket jetzt wie ein Filehandle benutzen, d.h. hineinschreiben, auslesen und es schließen, mit denselben Befehlen, die man auch für Filehandles benutzt. Hier ein Beispiel für ein Client-Programm, das einen Socket einrichtet, in eine Meldung hineinschreibt, die Antwort ausliest und den Socket wieder schließt:

#!/usr/bin/perl -w

use IO::Socket;
use strict;

my $remote_host = "atlas.ee.fhm.edu";
my $remote_port = 2000;
my $socket = IO::Socket::INET->new(PeerAddr => $remote_host,
                                   PeerPort => $remote_port,
                                   Proto    => "tcp",
                                   Type     => SOCK_STREAM)
   or die "Couldn't connect to $remote_host:$remote_port: $@\n";

print $socket "Hallo\n";
my $answer = <$socket>;
print "Receiving: $answer\n";
close($socket);
Der Server wartet gewöhnlich in einer Endlos-Schleife auf hereinkommende Anfragen. Die Methode dafür ist accept. Solange keine Anfrage erfolgt, bleibt das Programm beim 'accept'-Aufruf stehen. Kommt dann eine Anfrage, gibt 'accept' die neue Verbindung zum anfragenden Client zurück und die Anfrage kann bearbeitet werden. Hier ein Beispiel:
#!/usr/bin/perl -w

use IO::Socket;
use strict;

my $server_port = 2000;
my $server = IO::Socket::INET->new(LocalPort => $server_port,
                                   Type      => SOCK_STREAM,
                                   Reuse     => 1,
                                   Listen    => SOMAXCONN )
      or die "Couldn't be a tcp server on port $server_port : $@\n";
while (my $client = $server->accept()) 
  {
  # $client is the new connection
  my $request = <$client>;
  chomp($request);
  print "Request: $request\n";
  print $client "Selber $request\n";
  close($client);
  print "\nWaiting for the next connection ...\n\n";
  }
close($server);
Beide Programme verlassen sich darauf, daß die zu erwartende Nachricht nur aus einer Zeile besteht. Sonst müsste man wie bei einem Filehandle auch eine Schleife über <client> laufen lassen.

Gepufferte Ein- und Ausgabe

Bevor Daten an ein Filehandle geschrieben werden, puffert sie das Betriebssystem. Es wird also gewartet, bis eine bestimmte Menge Daten zusammengekommen ist, und erst dann werden die Daten tatsächlich übertragen. Bei Sockets ist dies gewöhnlich so lange kein Problem, wie Zeilen übertragen werden. Sollen Zeichen oder Zeichenketten ohne abschließendes Newline übertragen werden, muß man dafür sorgen, daß die Puffer geleert werden. Dies kann man über Einstellen der Perl-Variablen $| erreichen. Ist diese Systemvariable auf 1 gesetzt, dann wird der Bufferinhalt nach jedem Ausgabe-Befehl losgeschickt. Für die Standardausgabe genügt also die Zeile
$| = 1;

Um andere Handles genauso zu behandeln, bedient man sich eines Tricks. Man wechselt das Handle und STDOUT, so daß die Zuweisung auf das Datei- oder Socket-Handle wirkt. Danach wird wieder die ursprüngliche Zuordnung hergestellt. Dazu wird das Filehandle von STDOUT zwischengespeichert. Den Wechsel erreicht man mit select():

my $old_fh = select(SOCK);                         # Ungepufferte Ausgabe 
$| = 1;                                            # fuer SOCK einstellen
select($old_fh);

Zum vorhergehenden Abschnitt Zum Inhaltsverzeichnis Zum nächsten Abschnitt


Copyright © FH München, FB 04, Prof. Jürgen Plate
Letzte Aktualisierung: 30. Oct 2004