Internet-Protokolle unter der Lupe

Axel-Tobias Schreiner, Fachbereich Mathematik-Informatik, Universität Osnabrück

Viele der gebräuchlichen Internet-Protokolle sind einfache Wechselgespräche, die man fast schon mit Shell-Skripten implementieren kann. Dieser Artikel beschreibt einige kleine Tools, die man zum Zuschauen und Mitspielen noch braucht, und zeigt, wie einfach FTP , GOPHER , HTTP und andere Protokolle tatsächlich aufgebaut sind.

Die Werkzeuge

Ob man ein anderes System erreichen kann, untersucht man in der Regel mit ping(8):

$ ping next 32 2
PING next: 32 data bytes
40 bytes from 131.173.161.254:
   icmp_seq=0. time=116. ms
40 bytes from 131.173.161.254:
   icmp_seq=1. time=1. ms

----next PING Statistics----
2 packets transmitted,
2 packets received,
0% packet loss
round-trip (ms)
  min/avg/max = 1/58/116

ping verschickt ECHO -Datagramme im ICMP und berichtet, ob sie der angesprochene Rechner quittiert -- eine minimale Voraussetzung für erfolgreichen Netzbetrieb.

Auch auf den höheren Protokollebenen gibt es triviale Anfragen, mit denen man untersuchen kann, ob ein Partner überhaupt antwortet. echo bezeichnet einen Service, nämlich Port 7, der ankommende Meldungen schlicht zurückschickt. TCP -, also sichere Byte-Strom-Verbindungen kann man direkt mit telnet(1) ausprobieren:

$ grep echo /etc/services
echo            7/tcp
echo            7/udp
$ telnet
telnet> open localhost echo
Trying 127.0.0.1...
     Connected to localhost.
Escape character is '^]'.
hello world
hello world
^]

telnet> quit
Connection closed.

udp

Zwar gibt es den echo-Service auch für UDP , also für Datagramme an Port-Nummern, die über File-Deskriptoren mit Benutzerprozessen verbunden sind, aber ein ``telnet für UDP '' fehlt im Werkzeugkasten.

Abbildung 1: Prinzip eines Terminal-Emulators

Abbildung 1 skizziert, daß man dies zu Testzwecken sehr leicht aus zwei cat-Prozessen herstellen kann, vorausgesetzt, man kann cat im Stil von Pipelines über Netzverbindungen lenken:

$ udp -p echo -0 cat -1 cat
[1411] cat
[1412] cat
hello world
hello world
^C

udp ist das erste der kleinen Werkzeuge, um die es in diesem Artikel geht. Das Kommando bereitet eine UDP -Verbindung vor, wobei Zielsystem und Port mit den Optionen -h und -p explizit angegeben werden können. Anschließend werden Prozesse erzeugt und mit der Netzverbindung verknüpft: Eine Option -n.. definiert mit jeder Ziffer n einen File-Deskriptor, der für das im folgenden Argument angegebene Kommando zu der Netzverbindung führt. Hier verwendet der erste cat-Prozeß mit der Prozeßnummer 1411 die Netzverbindung als File-Deskriptor 0, also als Standard-Eingabe, und Prozeß 1412 erhält die Netzverbindung als Standard-Ausgabe. Beide Prozesse erben die anderen File-Deskriptoren von udp, das heißt, es entsteht die Anordnung aus Abbildung 1.

udpd

Auch bei UDP -Verbindungen besteht ein deutlicher Unterschied zwischen Klient und Server -- der Server muß zuerst vorhanden sein, damit der Klient mit ihm Kontakt aufnehmen kann. Ein weiteres Werkzeug ist deshalb udpd, ein Kommando, das einen per Option wählbaren UDP -Port einrichtet und auf eingehende Datagramme wartet. Trifft eines ein, werden wieder Prozesse erzeugt und mit dem Port verbunden:

$ udpd -p 12345 \
> -01 'dd 2>&- count=1' &
1436
$ udp -p 12345 -0 cat -1 cat
[1438] cat
[1439] cat
hello
[1444] dd 2>&- count=1
hello
world
[1447] dd 2>&- count=1
world
^C
$ kill $!

Wie man sieht, entstehen relativ viele Prozesse, denn die Argumente zu udp und udpd werden mit system(3), also mit einer Shell, bearbeitet, damit beispielsweise weitere E/A-Umlenkungen möglich sind.

tcp und tcpd

Der Gedanke liegt natürlich nahe, analoge Kommandos tcp und tcpd auch für TCP , also für Byte-Ströme, zu realisieren, denn telnet kann zwar beliebige TCP -Ports anrufen, aber das etwas antiquierte Kommando kooperiert nicht in Pipelines und kann nicht als Server auf ankommende TCP -Verbindungen warten. Außerdem haben im TELNET -Protokoll einige Zeichen eine besondere Bedeutung, so daß binäre Dateien durch das telnet-Kommando nicht unbedingt vollständig übertragen werden.

$ tcpd -01 'cat -u' &
port 2698
     (131,173,161,254,10,138)
1454
$ telnet next 2698
Trying 131.173.161.254...
[1456] cat -u
Connected to next.
Escape character is '^]'.
hello world
hello world
^]

telnet> quit
Connection closed.
$ tcp -p 2698 -0 cat -1 cat
[1460] cat
[1461] cat
[1462] cat -u
hello
hello
world
world
^C
$ kill $!

Am Schluß muß man gelegentlich mit einem interrupt-Signal nachhelfen, denn alle vier Werkzeuge terminieren erst, wenn alle erzeugten Prozesse beendet sind.

Das Beispiel zeigt, daß bei einem TCP -echo-Server nur ein einziger Prozeß so lange arbeitet, wie die TCP -Verbindung besteht. Eine UDP -Verbindung ist zustandslos, folglich sorgte das Argument count=1 bei dd dafür, daß der echo-Server seine Bemühungen nach einem einzigen Datagramm sofort wieder einstellt.

Implementierung

Einfache Versionen der vier Kommandos sind sehr leicht zu realisieren. Da die Klienten tcp und udp und die Server tcpd und udpd jeweils gleiche Kommandoargumente erlauben, kann man die Hauptprogramme doppelt verwenden -- sie notieren, ob die optionalen Argumente verwendet werden, und rufen dann Funktionen auf, die sich um die Netzverbindungen und die Prozesse kümmern.

tcp

void client (const char * host,
        const char * port, const char * argv [])
{   int sock = inetTcpConnect(host, port);

    do
        switch (fork()) {
        case -1:
            perror("fork"), exit(1);
        case 0:
            exit(run(sock, argv[0]+1, argv[1]));
        }
    while (*(argv += 2));

    close(sock);

    while (wait(0) != -1)
        ;
}

Abbildung 2: Der ``tcp''-Klient

Abbildung 2 zeigt die zentrale Funktion client() für den TCP -Klienten tcp. host und port legen das Zielsystem fest, argv[] enthält die Argumente mit File-Deskriptoren und Kommandos. Die Hilfsfunktion inetTcpConnect() konstruiert aus Strings die nötigen Adreßstrukturen und erzeugt mit den Systemaufrufen socket(2) und connect(2) den File-Deskriptor sock für die Netzverbindung. Die do-Schleife verläßt sich darauf, daß die Funktion parse() aus Abbildung 3 die restlichen Argumente in argv[] kontrolliert hat, und startet in jedem mit fork(2) erzeugten neuen Prozeß mit der Funktion run() aus Abbildung 4 das gewünschte Programm mit Kopien der Netzverbindung. parse() und run() werden in allen vier Werkzeugen benutzt.

#define NUM     "0123456789"

int parse (const char * argv [])
{   int len;

    while (argv[0] && argv[1])
        if (argv[0][0] != '-')
            return -1;
        else if ((len = strlen(argv[0]+1)) == 0
                 || len != strspn(argv[0]+1, NUM))
            return -1;
        else if (! argv[1][0])
            return -1;
        else
            argv += 2;
    return argv[0] ? -1 : 0;
}

Abbildung 3: Kontrolle der Argumente

int run (int sock, const char * fds, const char * cmd)
{   int n;

    while (isdigit(n = *fds++))
        if (dup2(sock, n-'0') == -1)
            perror("dup2"), exit(1);
    close(sock);
    fprintf(stderr, "[%d] %s\n", getpid(), cmd);
    return system(cmd);
}

Abbildung 4: Start eines Kommandos

udp

udp unterscheidet sich von tcp nur durch eine eigene Version von client(), die als Netzverbindung einen UDP -Socket an Stelle eines TCP -Sockets erzeugt und mit connect(2) mit der Zieladresse verbindet.

tcpd

Die Implementierung der Server tcpd und udpd ist ein bißchen komplizierter. Auch hier notiert ein gemeinsam verwendetes Hauptprogramm die optionalen Argumente und ruft dann eine Funktion server() auf, die Netzverbindungen und Prozesse managt.

void server (const char * port,
        const char * argv [], int qflag)
{   int calls = inetTcpBind(port);

    if (! port)
        puts(inetPort(calls)), fflush(stdout);
    if (listen(calls, qflag ? 1 : 5) == -1)
        perror("listen"), exit(1);

    do
    {   int client = tcpAccept(calls);
        const char ** av = argv;

        do
            switch (fork()) {
            case -1:
                perror("fork"), exit(1);
            case 0:
                exit(run(client, av[0]+1, av[1]));
            }
        while (*(av += 2));

        close(client);
    } while (! qflag);

    while (wait(0) != -1)
        ;
}

Abbildung 5: Der ``tcpd''-Server

Abbildung 5 zeigt server() für den TCP -Server tcpd. Die Hilfsfunktion tcpBind() richtet mit socket(2) und bind(2) einen lokalen TCP -Port ein. inetPort() zeigt mit Hilfe von getsockname(2) die lokale Port-Nummer, die man vom System zuweisen lassen kann. Der Port wird mit listen(2) darauf eingerichtet, daß er connect(2)-Anfragen eines Klienten akzeptiert und in eine Warteschlange einreiht.

In der äußeren do-Schleife erzeugt die Hilfsfunktion tcpAccept() mit accept(2) jeweils einen neuen Socket, der mit einem Klienten verbunden ist. Wird tcpd mit der Option -q aufgerufen, bricht die äußere Schleife nach einem Durchgang ab, das heißt, tcpd akzeptiert dann nur eine Verbindung.

Die innere do-Schleife wird nahezu unverändert von den Klienten übernommen. Sie muß allerdings die Argumente in argv[] je einmal für jede neue Verbindung durchlaufen können. Der Server bearbeitet alle Anrufe möglichst parallel, das heißt, er wartet nur bei Abbruch der äußeren Schleife auf die erzeugten Prozesse.

udpd

Der UDP -Server udpd ist noch etwas komplizierter, denn es kann keinen Mechanismus geben, um auf eine Verbindung zu warten, über die dann Datagramme verschickt werden sollen, da es diese Verbindung beim zustandslosen UDP nicht gibt.

void server (const char * port,
        const char * argv [], int qflag)
{   int sock = inetUdpBind(port);

    if (! port)
        puts(inetPort(sock)), fflush(stdout);

    do
    {   char buf [BUFSIZ];
        struct sockaddr from;
        int fromlen = sizeof from;
        const char ** av = argv;

        if (recvfrom(sock, buf, sizeof buf, MSG_PEEK,
                                &from, &fromlen) >= 0)
        {   connect(sock, &from, fromlen);
            do
                switch (fork()) {
                case -1:
                    perror("fork"), exit(1);
                case 0:
                    exit(run(sock, av[0]+1, av[1]));
                }
            while (*(av += 2));

            while (wait(0) != -1)
                ;
        }
        else
            perror("recvfrom");

        memset(&from, 0, sizeof from);
        connect(sock, &from, sizeof from);
    } while (! qflag);
}

Abbildung 6: Der ``udpd''-Server

Abbildung 6 zeigt, daß man auf das erste Datagramm selbst warten muß. inetUdpBind() richtet mit socket(2) und bind(2) den UDP -Port ein, und inetPort() zeigt die Port-Nummer, falls sie vom System vergeben wurde.

recvfrom(2) wartet auf den Eingang eines Datagramms. MSG_PEEK sorgt dafür, daß das Datagramm nur betrachtet, aber im System noch nicht gelöscht wird. udpd benötigt nämlich nur die Absenderadresse from, die dann mit connect(2) als fester Partner der Verbindung eingestellt wird. Die innere do-Schleife kann dann wieder Prozesse starten, die ganz normal mit read(2) und write(2) über diese Verbindung kommunizieren können und die dabei das vom Server erspähte Datagramm als erstes verarbeiten.

Der Schönheitsfehler ist, daß der Aufruf von connect(2) den UDP -Port für andere Verbindungen als from sperrt. Eigentlich sollte ein vom Server erzeugter Prozeß sich wie tftpd benehmen und nur das erste Datagramm und die Partneradresse mit recvfrom(2) von diesem Port holen und über einen neuen Port sein Gespräch führen. Ein derartiger Prozeß weiß aber dann leider zwangsläufig, daß er mit einer Netzverbindung arbeitet.

Bei unserer transparenten Lösung bleibt nichts anderes übrig, als zu warten, bis alle erzeugten Prozesse fertig sind. Anschließend kann man im ursprünglichen Port mit connect(2) Null als Partneradresse setzen und den Port damit wieder freigeben. Wie das Beispiel zeigte, kann udpd in der äußeren Schleife dann wieder ein neues Datagramm abwarten und neue Prozesse erzeugen.

Der Internet-Dämon

tcpd und udpd realisieren ähnliche Mechanismen wie der Internet-Dämon inetd, der in vielen Systemen verwendet wird. inetd liest seine Konfigurationsdatei /etc/inetd.conf und bewacht die dort codierten Ports mit select(2). Kommt ein TCP -Anruf oder ein Datagramm, erzeugt inetd einen Prozeß, der das in /etc/inetd.conf angegebene Programm ausführt und den Port als Standard-Ein- und -Ausgabe erhält.

Zur Konfiguration gehört ein Parameter mit Wert wait oder nowait, der mindestens bei NeXTSTEP falsch dokumentiert ist. Die Implementierungen von tcpd und udpd deuten an, was es damit auf sich hat: wait entspricht dem Verhalten von udpd, das heißt, inetd bewacht den fraglichen Port erst dann wieder selbst, wenn der erzeugte Prozeß beendet ist. nowait entspricht tcpd, das heißt, inetd wartet sofort auf eine neue Verbindung, und der erzeugte Prozeß muß einen privaten Port für seine Unterhaltung verwenden.

Zwar wartet inetd mit Hilfe von select(2) und udpd mit recvfrom(2), aber unser Server illustriert trotzdem, warum in /etc/inetd.conf entgegen der Dokumentation udp nur mit wait kombiniert werden kann: Würde in Abbildung 6 bei udpd die äußere Schleife sofort fortgesetzt werden, hätte der erzeugte Prozeß kaum eine Chance, das eingegangene Datagramm zu verarbeiten, bevor es udpd nochmals entdeckt und fälschlicherweise einen weiteren Prozeß startet.

FTP

Was kann man nun mit den Werkzeugen machen?

Abbildung 7: Prinzip eines FTP-Transfers

Abbildung 7 zeigt, daß bei FTP zum eigentlichen Datentransfer eine zusätzliche TCP -Verbindung aufgebaut wird. Dies kann man in Handarbeit mit tcpd nachvollziehen (die Ausgaben des Servers sind hier leicht gekürzt):

$ tcpd -q -0 'cat >copy' &
port 2630
     (131,173,161,254,10,70)
488
$ telnet next ftp
Trying 131.173.161.254...
Connected to next.
Escape character is '^]'.
220 next FTP server ready.
user ftp
331 Ok, send e-mail address
pass axel@informatik.de
230 Guest login ok
cwd pub
250 CWD command successful.
port 131,173,161,254,10,70
200 PORT command successful.
retr menu
150 Opening ASCII mode data
    connection for menu
[493] cat >copy
226 Transfer complete.
quit
221 Goodbye.
Connection closed.

Die Kontrollverbindung wird mit telnet betrieben. Da eine Datei vom Server geholt werden soll, wird mit tcpd ein Port vorbereitet, der dann eine TCP -Verbindung vom FTP -Server akzeptiert. Über telnet wird die Port-Nummer mit der port-Anweisung als Byte-Folge übermittelt, bevor retr den FTP -Server anweist, den Inhalt einer Datei an diesen Port zu transferieren.

Eigentlich sollte tcpd so codiert werden, daß die Port-Nummer als Standard-Ausgabe leicht in eine Shell-Variable transferiert werden kann. Mit der offensichtlichen Zuweisung

port=`tcpd ...`

funktioniert das aber nicht, denn wenn irgendeine Kopie des File-Deskriptors 1 des Kommandos offenbleibt, erhält die Shell kein Datei-Ende und nimmt daher die Zuweisung noch nicht vor. Es geht nur auf dem Umweg über eine Datei

tcpd ... >xx &
port=`cat xx`; rm xx

und auch hier besteht die Gefahr, daß cat die Datei liest, bevor tcpd die entscheidende Zeile hinterlegt hat. Immerhin sorgt fflush(3) in den Abbildungen 5 und 6 dafür, daß diese Zeile nicht zunächst in einem Puffer hängenbleibt.

Mit tcp und tcpd kann man durchaus Dateitransfer auf der Basis von FTP als Shell-Skripte implementieren. Abbildung 8 zeigt einen primitiven Klienten und Abbildung 9 einen primitiven Server für anonymen Dateitransfer, die miteinander und mit den Original-Programmen kommunizieren können.

#!/bin/sh
#       tcp -h host -p port -9 this-script

    PATH=`pwd`:/usr/ucb:/usr/bin:/bin

cat <&9 &

echo >&9 USER ftp
echo >&9 PASS client@informatik.uni-osnabrueck.de

while read cmd a
do  case "$cmd"
    in [cC]*)                           # cd dir
        [ "$a" ] && echo >&9 CWD "$a"
        echo >&9 PWD
        continue
    ;; [dD]*)                           # dir
        tcpd -q -0 'cat >&2' > /tmp/ftp$$ &
        sleep 1
        port=`sed -n 's/.*(\(.*\))/\1/p' /tmp/ftp$$`
        rm /tmp/ftp$$
        echo >&9 PORT $port
        echo >&9 LIST
        continue
    ;; [gG]*)                           # get remote
        tcpd -q -0 "cat >$a" > /tmp/ftp$$ &
        sleep 1
        port=`sed -n 's/.*(\(.*\))/\1/p' /tmp/ftp$$`
        rm /tmp/ftp$$
        echo >&9 PORT $port
        echo >&9 RETR $a
        continue
    ;; [lL]*)                           # ls
        echo >&9 STAT .
        continue
    ;; [pP]*)                           # pwd
        echo >&9 PWD
        continue
    ;; [qQ]*)                           # quit
        break
    esac
    echo >&2 'Cd dir  # change directory'
    echo >&2 'Dir     # verbose directory listing'
    echo >&2 'Get remote # retrieve a file'
    echo >&2 'Ls      # short directory listing'
    echo >&2 'Pwd     # show current directory'
    echo >&2 'Quit    # done'
done
echo >&9 QUIT; sleep 1

Abbildung 8: Ein rudimentärer FTP-Klient

#!/bin/sh
#       tcpd -p ftp -01 this-script

    PATH=`pwd`:/usr/ucb:/usr/bin:/bin
    r="`echo -n '\r'`"
    IFS="$IFS$r"
    ifs="$IFS"

echo 220
while read cmd a
do  case "$cmd"
    in [cC]*)                           # Cwd dir
        if cd "$a"
        then    echo 250
        else    echo 550
        fi
    ;; [lL]*)                           # List [dir]
        echo 150
        if tcp -h $1.$2.$3.$4 \
            -p `expr $5 256 + $6` -1 "ls -l $a"
        then    echo 226
        else    echo 425
        fi
    ;; [pP][aA]*)                       # PAss x
        echo 230
    ;; [pP][oO]*)                       # POrt h,p
        IFS=,; set $a; IFS="$ifs"
        echo 200
    ;; [pP][wW]*)                       # PWd
        echo 257 \"`pwd`\"
    ;; [qQ]*)                           # Quit
        echo 221
        break
    ;; [rR]*)                           # Retr file
        if [ ! -r "$a" ]
        then  echo 550
        elif  echo 150
              tcp -h $1.$2.$3.$4 \
                -p `expr $5 256 + $6` -1 "cat $a"
        then  echo 226
        else  echo 425
        fi
    ;; [sS]*)                           # Stat [dir]
        echo 211
        ls -l $a
        echo 211
    ;; [uU]*)                           # User x
        echo 331
    ;; *)
        echo 500
    esac
done

Abbildung 9: Ein rudimentärer FTP-Server

Der Klient verzichtet auf das Einfügen von return am Schluß der Kommandozeilen; im Server wird dieses Zeichen in IFS mit als Trenner deklariert und dadurch bereits im read-Kommando unterschlagen. PATH enthält in beiden Fällen den aktuellen Katalog, in der Annahme, daß dort tcp beziehungsweise tcpd verfügbar sind. Der Klient verwendet einen cat-Prozeß im Hintergrund dazu, die Meldungen vom Server asynchron auszugeben; dieser Prozeß terminiert, wenn der Server terminiert. Der Server schickt lediglich die Meldungsnummern als Quittungen, ohne weitere Kommentare. Da der Server manche Meldungen aus mehreren write-Aufrufen zusammensetzt, muß man beim Original-Klienten ftp gelegentlich mit einer reset-Anweisung Server und Klient wieder synchronisieren.

GOPHER

GOPHER ist ein an der Universität von Minnesota entwickeltes Protokoll, mit dem Menüs angeboten werden, aus denen man weitere Menüs, Texte und andere Dateien zum Import oder Dialoge für Suchzugriffe wählen kann. Im Gegensatz zum Web-Protokoll HTTP ist GOPHER wirklich elegant konzipiert und sehr leicht mit unseren Werkzeugen zu implementieren.

0menu   0/gopher-menu   localhost       12345
1menu   1/gopher-menu   localhost       12345
7grep   7/.     localhost       12345

Abbildung 10: Ein GOPHER-Menü

Abbildung 10 zeigt ein einfaches Menü, das der GOPHER -Server an seinen Klienten schickt. Jede Zeile hat vier durch einzelne Tabs getrennte Felder und ist ein Menüeintrag. (Bei GOPHER+ kommt ein fünftes Feld mit weiteren Informationen hinzu.) Das erste Feld enthält ein Typzeichen und den Menütext; 0 steht für eine Textdatei, 1 für ein Menü, 7 für eine Suche etc. Das zweite Feld ist der für den Klienten opake Selektor; er besteht normalerweise aus dem Typ und einem absoluten Pfad relativ zur Wurzel des für GOPHER zugänglichen Dateibaums. Das dritte und vierte Feld sind Host und Port, an die der Selektor geschickt werden muß, um die gewünschte Information als Antwort zu erhalten; dadurch ist GOPHER ein verteiltes System. Im Fall eines Suchzugriffs wird zusätzlich zum Selektor, durch Tab getrennt, der Suchbegriff an den Server geschickt.

#!/bin/sh
#       tcpd -p port -01 'this-script port root-menu'

    PATH=/usr/ucb:/usr/bin:/bin
    r="`echo -n '\r'`"
    IFS="$IFS$r"

read sel query
case "$sel"
in 0/?*)                                # text
    path=`expr "$sel" : './\(.*\)'`     # remove type
    sed "s/\$/$r/" "$path"
;; 1/?*)                                # menu
    path=`expr "$sel" : './\(.*\)'`
    sed "s/localhost.*/`hostname`       $1$r/" "$path"
;; 7/?*)                                # selector query
    path=`expr "$sel" : './\(.*\)'`
    [ -d "$path" ] &&           # grep files in selector
    grep -l "$query" `echo "$path"/*` 2>/dev/null |
    while read loc              # make menu
    do  [ -f $loc ] &&
        echo "0$loc     0/$loc  `hostname`      $1$r"
    done
;; ?/??*)                               # other file
    path=`expr "$sel" : './\(.*\)'`
    cat $path
    exit
;; *)                                   # default: root-menu
    sed "s/localhost.*/`hostname`       $1$r/" "$2"
esac
echo ".$r"

Abbildung 11: Ein rudimentärer GOPHER-Server

Abbildung 11 skizziert, wie man einen GOPHER -Server als Shell-Skript implementieren kann. IFS wird wieder so gesetzt, daß auch return als Zwischenraum zählt. Je nach Typ im Selektor werden eine Textdatei oder ein Menü verschickt, wobei an jede Zeile return angefügt wird, oder es wird eine Datei unverändert kopiert. Für einen unbekannten (oder leeren) Selektor liefert der Server sein Wurzel-Menü, das zusammen mit dem Port als Argument angegeben werden muß.

Typ 7 ist eine Suchanfrage. Der hier vorgestellte Server interpretiert den Rest des Selektors als Katalog und sucht mit grep in allen Dateien nach dem Suchbegriff. Er liefert dann die Dateinamen als Menü -- nicht unbedingt korrekt sind sie darin als Textdateien markiert. Man ahnt, daß das GOPHER -Protokoll in diesem Typ recht leicht zu erweitern ist.

Eine Stärke des GOPHER -Protokolls besteht darin, daß der Selektor vom Klienten nicht verändert werden muß. Geht man davon aus, daß die Menüs selbst korrekt sind, müßte man eigentlich nichts prüfen. Ein robuster Server kann sich allerdings nicht darauf verlassen, daß ein Klient nicht mogelt. In einem Shell-Server könnte man die möglichen Selektoren in einer Datei zusammenfassen und darin die angeforderten Selektoren mit grep oder look verifizieren.

Mit tcp kann man sogar einen GOPHER -Klienten als Shell-Skript implementieren, siehe Abbildung 12.

#!/usr/local/gnu/bin/bash

    PATH=`pwd`:/usr/ucb:/usr/bin:/bin
    ifs="`sh -c \"echo -n '\t\r'\"`"

menu () {       # convert menu to shell assignments
IFS= n=1
while read line
do  case "$line"
    in .*)
        echo n=$n
        return
    ;; [017]*)
        echo menu_$n=\'"`echo \"$line\" | tr -d \'`"\'
        n=`expr $n + 1`
    esac
done
}

show () {       # display menu
IFS="$ifs" i=1
while [ $i -lt $n ]
do  eval set \$menu_$i
    case "$1"
    in 0*)      type=text
    ;; 1*)      type=menu
    ;; 7*)      type=search
    esac
    case $i
    in ?)
        echo ' ' $i `expr "$1" : '.\(.*\)'` "[$type]"
    ;; *)
        echo ''  $i `expr "$1" : '.\(.*\)'` "[$type]"
    esac
    i=`expr $i + 1`
done
}

Abbildung 12 a: Ein einfacher GOPHER-Klient -- Funktionen

IFS=                            # root menu
eval `tcp -h ${1:-localhost} -p 70 -0 cat -1 echo | menu`

while show; echo -n '> '; read reply
do  case "$reply"
    in [0-9] | [0-9][0-9])
        [ 1 -le "$reply" -a $n -gt "$reply" ] || continue
        IFS="$ifs"              # get menu entry
        eval set \$menu_$reply
        case "$1"
        in 0*)                  # text
            echo "$2" | tcp -h "$3" -p "$4" -0 cat -1 cat
        ;; 1*)                  # menu
            IFS=
            eval `tcp -h "$3" -p "$4" \
                -0 cat -1 echo\ \"$2\" | menu`
        ;; 7*)                  # search
            echo -n '? '
            read query || exit
            IFS=
            eval `tcp -h "$3" -p "$4" \
                -0 cat -1 echo\ \"$2\   $query\" | menu`
        esac
    ;; [qQ])
        break
    esac
done

Abbildung 12 b: Ein einfacher GOPHER-Klient

Der IFS-Mechanismus muß zwar sorgfältig verwendet werden, aber er genügt, um die Menüzeilen so zu zerlegen, daß kein Zwischenraum im Text verlorengeht. Die Zeilen werden mit Hilfe der Funktion menu an Variablen der Form menu_n zugewiesen, aus denen die Funktion show das Menü darstellt. (Dieser Code sprengt zumindest die Speicherverwaltung der sh von NeXTSTEP . GNU 's bash ist stabiler.) menu erscheint am Ende einer Pipeline und ist daher ein eigener Prozeß; die Funktion gibt deshalb Zuweisungen aus, die mit eval in der ursprünglichen Shell ausgeführt werden.

Der Klient holt zunächst mit tcp das Wurzel-Menü eines Servers. In der letzten Schleife wird jeweils ein Menü dargestellt. Je nach Benutzereingabe wird dann eine Menüzeile zerlegt und der Selektor wird, eventuell zusammen mit einem Suchbegriff, wieder per tcp an den zuständigen Server geschickt.

Primär durch die vielen Aufrufe von expr zur Abspaltung der Dateitypen ist die Lösung zwar langsam, aber sie funktioniert sowohl mit dem hier vorgestellten als auch mit einem ``echten'' GOPHER -Server. Der Klient hat allerdings keinen Navigationsspeicher, über den man zu den Selektoren früherer Menüs zurückfinden könnte.

HTTP und snoop

HTTP ist das Protokoll, mit dem Web-Klienten mit den eigentlichen Web-Servern verkehren. Dank einer reichlich rudimentären Dokumentation, die zudem nur online vorliegt und nicht ohne weiteres gedruckt werden kann, tut man gut daran, diesen Unterhaltungen zunächst einmal zuzuschauen. Hier hilft ein weiteres kleines Werkzeug snoop:

$ snoop -p daytime &
cloning next:daytime to port
  2779 (131,173,161,254,10,219)
8108
$ telnet next 2779
Trying 131.173.161.254...
Connected to next.
Escape character is '^]'.
[local] host next port 2779
[peer] host next port 2780
[local] host next port 2781
[peer] host next service daytime
7->6: 26 "Mon Jul 24 23:12:29 1995
"
7->6: 0 ""
Mon Jul 24 23:12:29 1995
done
Connection closed.

snoop wartet auf eine TCP -Verbindung auf einem lokalen Port. Trifft sie ein, richtet snoop eine weitere TCP -Verbindung zu einem vorher angegebenen Ziel ein und transferiert die Daten in beide Richtungen von einer Verbindung zur anderen. Die Pointe ist natürlich, daß snoop die transferierten Daten ähnlich wie tee in einer Pipeline auch noch zur Standard-Ausgabe kopiert und auf Wunsch binäre Daten durch C-Escapes erklärt.

Nach einigem Zuschauen implementierte ich zu Testzwecken den HTTP -Server in Abbildung 13.

#!/bin/sh
#       tcpd -p http -01 this-script

    PATH=/usr/bin:/bin
    r="`echo -n '\r'`"
    IFS="$IFS$r"

read method path protocol
case "$method"
in GET)
    if [ -r .$path ]
    then    cat .$path  # BUG: no MIME header
    else    echo .$path: cannot read
    fi
;; POST)
    if [ -z "$protocol" ]
    then
        echo POST: need protocol
    else
        length=
        while read info
        do  echo $info'<br>'
            case $info
            in *ength*)         # Content-Length:
                set $info; length=$2
            ;; '')              # end of MIME header
                if [ "$length" ]
                then
                    dd bs=$length count=1 2>/dev/null
                else
                    echo POST: need length
                fi
                break
            esac
        done
    fi
esac

Abbildung 13: Ein HTTP-Server zum Echo von ``Forms''

Er akzeptiert mit der Methode GET einen beliebigen (absoluten) Pfad, interpretiert ihn relativ zum aktuellen Katalog und liefert die gewünschte Datei -- allerdings ohne den eigentlich nötigen MIME -Header davor zu generieren. Wird die Methode POST aufgerufen, schickt der Server schlicht den angelieferten MIME -Header an den Klienten zurück, gefolgt von genau so vielen Bytes, wie der Klient in der Content-length: im Header zu liefern verspricht.

Der Server ist nicht so sinnlos, wie das vielleicht auf den ersten Blick erscheint. Wenn man eine Seite mit einem form-Element, also ein Eingabeformular, in einen geeigneten Web-Klienten lädt und die form-Eingaben mit POST an diesen Server schickt, antwortet er mit einer Web-Seite, die der Klient darstellen kann und aus der ersichtlich wird, was eigentlich für das form-Element vom Klienten an den Server geht. Zum httpd-Server von CERN gibt es verschiedene Dienstprogramme, mit denen man diesen Shell-Server leicht ausbauen kann, denn in dieser Form ist er noch nicht ganz HTTP -konform.

Die Quellen

snoop stammt aus meinen Bemühungen, im World Wide Web angebotene Dokumente samt Illustrationen zu importieren. Als sich herausstellte, daß sich gängige Klienten wie lynx oder www kommentarlos auch nur mit Fragmenten zufriedengeben, mußte ich einen etwas robusteren Klienten konstruieren. Dazu brauchte ich eine klare Vorstellung des HTTP , und so kam es zu snoop.

Beim Zusehen wurde mir klar, daß HTTP eigentlich sehr primitiv aufgebaut ist. In einer Vorlesung über Internet-Dienste wollte ich deshalb auch die Grundlagen anderer Protokolle sozusagen in Handarbeit vorführen. Ohne Super-User-Privilegien kann man die Hilfe von inetd nicht in Anspruch nehmen. Kenntnisse von perl oder TCL-DP wollte ich nicht voraussetzen, und deshalb verfiel ich darauf, Shell-Skripte von Programmen aus zu starten, die die nötigen Verbindungen zwischen File-Deskriptoren in den Shell-Skripten und Sockets zur Netzverbindung herstellten -- sozusagen ein inetd oder ``Netz-Pipelines'' für Unterprivilegierte.

Die Prototypen dieser Programme waren in TCL-DP implementiert, schon um das Konzept schnell zu untersuchen. Damit ist der Installationsaufwand auf einem neuen System allerdings relativ hoch. Außerdem ist nicht einzusehen, warum man Shell-Programmierung einsetzt, wenn man ohnehin schon mit TCL arbeitet. Für diesen Artikel habe ich deshalb die verschiedenen TCL-DP -Wrapper als einfache C-Programme zusammengefaßt.

Die Quellen zu den Werkzeugen in diesem Artikel befinden sich wie üblich als pub/hanser/um/um-95.4 auf unserem FTP -Server ftp.informatik.uni-osnabrueck.de.