Wie von Geisterhand (Linux-Magazin, Februar 98)

Erinnert sich noch jemand an expect? Zeichenketten zu senden, um auf Zeichenketten zu warten, um wiederum ... ideal, um über telnet Routinearbeiten auf einem Dutzend Rechner automatisch durchzuführen.

expect basierte auf Tcl, und, wie soll ich es sagen, Tcl und ich, wir kamen uns nicht näher, etwas stand immer zwischen uns: Mal war's eine geschweifte Klammer in der falschen Zeile, mal ein eval zuviel oder zuwenig - es wollte nicht klappen mit uns zwei'n. Wie froh war ich daher, zu sehen, daß es das Perl-Modul Net::Telnet gibt und alles viel einfacher geht!

Expect mit Perl

Heute stelle ich ein Skript vor, das sich der Reihe nach auf jedem Rechner eines Maschinenparks einloggt, dort die Auslastung und die Größe einer Log-Datei abfragt und die gesammelten Daten schließlich schön formatiert anzeigt. Und, zwecks Komfortsteigerung verpacken wir das Ganze in ein CGI-Skript, das einen handelsüblichen Web-Server dazu bewegt, auf Knopfdruck den Zustand unserer Rechner anzuzeigen. Abbildung 1 zeigt das Ergebnis des CGI-Skripts checkload.pl (Listing 1) im Browser.

Zeile 3 bindet Lincoln Steins praktisches CGI-Modul ein, mit den Tags :standard und :html exportiert es die weiter unten benötigten Funktionen ohne überflüssige Objekt-Huberei. Cool!

Zeile 4 zieht das Modul Net::Telnet, dessen Distribution auf dem CPAN unter

    CPAN/modules/by-module/Net/Net-Telnet-3.00.tar.gz

liegt und sich wie alle Perl-Module am schnellsten mit dem im Oktober vorgestellten CPAN-Saugrüssel installieren läßt. Die Benutzerkennung und das Paßwort aus den Zeilen 7 und 8 gewähren Zugang zu allen nachfolgend abgefragten Rechnern.

Jeder Eintrag der @hosts-Liste ab Zeile 11 enthält eine Referenz auf eine Liste, die als erstes Element den Host-Namen und als zweites den 'Prompt' enthält, jene Zeichenkette, die der jeweilige Rechner zur Eingabeaufforderung anzeigt. So steht bei machine2.domain.com das für die Bourne-Shell typische $, machine1.domain.com bevorzugt hingegen machine1> - jedem das Seine.

Zeile 19 iteriert über diese LoL (List of Lists). Für jeden Schleifendurchgang liegt eine Referenz auf die aktuelle Unterliste in $_. Zeile 20 dereferenziert dies mittels @$_ und kopiert die Werte für den Rechnernamen und den Prompt in die augenfreundlicheren Variablen $host und $prompt.

Damit die nachfolgende Telnet-Session auch wirklich nur auf den Prompt reagiert und nicht etwa auf eine aufgelistete Datei gleichen Namens, formt Zeile 21 aus der angegebenen Zeichenkette einen regulären Ausdruck, der festlegt, daß der Chatter auf eine Zeile als Antwort wartet, die mit dem Prompt beginnt und dann eventuell mit einer Reihe von Leerzeichen endet. Die quotemeta-Funktion aus der Perl-Standard-Bibliothek übernimmt dabei die Maskierung gefährlicher Zeichen wie *, denen in regulären Ausdrücken eine Sonderbedeutung zukommt.

Zeile 29 versucht dann, mittels der login-Methode des vorher erzeugten Telnet-Objekts Kontakt mit dem aktuellen Rechner aufzunehmen und den Login-Prozeß durchzuführen. Da login leider innerhalb des Telnet-Moduls mit einer die-Anweisung abbricht, falls der fremde Rechner sie zurückweist oder nicht erreichbar ist, fängt Zeile 29 diesen Fehlerfall mit Hilfe eines eval-Konstruktes ab. Ging innerhalb des eval-Blockes etwas schief, ist $@ gesetzt, was Zeile 31 abprüft und gegebenenfalls die Ergebnis-Liste @hostinfo um einen Fehlereintrag bereichert.

Andernfalls führt Zeile 35 ein uptime-Kommando auf dem fremden Rechner aus. Die cmd-Methode aus dem Telnet-Modul setzt das angegebene Kommando ab, wartet auf den Prompt und liefert die empfangenen Zeilen als Liste zurück. Da uptime nur eine Zeile zurückliefert, enthält $lines[0] einen String a la

    11:31am  up  2:28,  3 users,  load average: 0.07, 0.11, 0.15

Der reguläre Ausdruck aus Zeile 36 filtert daraus 0.07, den Wert der Rechnerlast, gemittelt über die Zeitspanne einer Minute. Entsprechend startet Zeile 39 ein ls-Kommando, welches folgende Informationen über die Logdatei zurückliefert:

    -rw-r--r-- 1 nobody nogroup 43896 Nov 22 13:39 /log/error_log

Zeile 40 trennt die ausgebenen Felder an den Zwischenräumen und filtert daraus das fünfte Element, die Größe der Datei, 43896 Bytes. In Zeile 41 kommt der bewährte Trick zum Einsatz, der große Zahlen durch Punkte in Dreier-Gruppen aufspaltet (Perl FAQ).

Zeile 43 schiebt eine Referenz der Liste gewonnener Informationen ans Ende des Behälters (LoL) @hostinfo.

Statt print verwendet checkload.pl, wie z.B. in Zeile 50, cgiprint. Diese ab Zeile 70 definierte Funktion unterhält eine statische Variable, die ihr anzeigt, ob der vor allen anderen Ausgaben notwendige CGI-Header schon ausgegeben wurde. So muß sich niemand mehr um den lästigen Header kümmern. Egal welchen Weg das Programm im Fehler- oder Gutfall nimmt - cgiprint wird's schon richten.

Zeile 50 gibt die HTML-Startsequenz aus und setzt dabei den Titel des angezeigten Dokuments. Nach der Ausgabe der Überschrift in Zeile 54 beginnt Zeile 56, den Inhalt einer HTML-Tabelle im String $tablecontent aufzubauen. Die foreach-Schleife zwischen den Zeilen 60 und 62 transformiert die LoL @hostinfo in eine HTML-Tabelle - für jeden Schleifendurchlauf erzeugt TR() eine neue Tabellenzeile, die wiederum mittels der map-Funktion in <TD>-Tags gepreßte Unterlisten-Elemente als Spalten enthält. Uff! Ohne Abitur geht's halt nicht.

Zeile 65 gibt das Monstrum schließlich aus, Zeile 66 schließt alle HTML-Ausgaben mit </HTML> ab.

Sicherheit

Noch ein ernstes Wort zum Thema Sicherheit: Natürlich kann ein Skript, das eine Benutzerkennung für mehrere Rechner samt gültigem Paßwort im Klartext enthält, ein gewaltiges Loch in ein sonst gut abgesichertes System reißen. Ohne weitere Sicherungsmaßnahmen sollte man das Skript deswegen nur innerhalb einer Firewall betreiben (Browser und Webserver!), die rigoros alle von außen kommenden telnet-Anfragen abblockt.

Weiter darf nur der Server selbst das Skript lesen. Im Falle eines Apache-Servers, der nach der Initialisierung als nobody laüft, muß checkload.pl also nobody gehören und die Rechte -rwx------ gesetzt haben. Entdeckt allerdings ein pickliger 14-jähriger ein Sicherheitsloch im Apache, bringt uns gerade das ins Grab.

Mehr Sicherheit

Für mehr Sicherheit muß auf allen Rechnern, die checkload.pl abklappert, ein spezieller Account 'ran. Die Shell dort läuft in einem Sandkasten und bietet nur ls und uptime als Funktionalität. Keinen vi, kein cat, kein gar nichts! Das bringt zusätzlichen Schutz vor ungebetenen Gästen, ist aber ein wenig aufwendig:

So wie man beim anonymen FTP-Zugriff nicht in den Top-Verzeichnissen (z.B. /etc) eines Servers herumwühlen darf, beschränkt ein speziell eingerichteter watch-Account den Zugriffsbereich des Benutzers mit chroot auf einen kleinen Unterbereich des Dateisystems. chroot legt ein Verzeichnis als neue Wurzel des Dateisystems fest und unterbindet rigoros Zugriffe oberhalb dieses ``Käfigs''.

Hierzu richtet man in /etc/passwd einen neuen Benutzer ein:

    watch::9999:9999::/home/watch:/home/watch/bin/cage

und erzeugt mit

    mkdir /home/watch

dessen neues Verzeichnis. Das neue Paßwort wird als root mit passwd watch gesetzt. Die Käfig-Shell /home/watch/bin/cage stricken wir mit

    #include <stdio.h>
    /*
     * Michael Schilli, 1998 (mschilli@perlmeister.com)
     */
     
    main() {
     
        char *sandboxdir = "/home/watch";
    
        putenv("LD_LIBRARY_PATH=/lib");
        putenv("PATH=.:/bin");
     
        if(chdir(sandboxdir)) { 
            perror("Chdir failed"); 
            exit(1); 
        }
     
        if(chroot(sandboxdir)) { 
           perror("Chroot failed"); 
           exit(1); 
        }
     
        setuid(9999);
    
        execl("/bin/bash", "-cs", NULL);
     
        perror("Shell did not start");
    }

selber und kompilieren das Ergebnis nach /home/watch/bin/cage. Das Executable cage muß dafür root gehören und das setuid-Bit (mit chmod 4755 cage) gesetzt haben. Die aus dem C-Programm aufgerufene bash-Shell liegt aber normalerweise in /bin, ganz zu schweigen von den shared libraries, die sie benötigt - alles völlig außer Reichweite, sind wir einmal in /home/watch gefangen, ohne Ausweg nach oben! Da hilft nur Kopieren: Die Shell, ls und uptime, den Linux-Loader und die Bibliotheken:

    cd /home/watch
    mkdir lib bin usr usr/lib dev etc log
    cp /bin/ls bin
    cp /usr/bin/uptime bin
    cp /lib/ld-linux.so.1 lib
    cp /etc/ld.so.cache etc
    cp /lib/libtermcap.so.2 lib
    cp /lib/libc.so.5 lib
    cp /bin/bash bin

(Andere Linux-Versionen als 2.0.X erfordern unter Umständen andere Dateien, einfach cage als root aufrufen und eventuell ausgegebene Fehlermeldungen prüfen).

Und: Oh Jammer oh Not! Auch das zu untersuchende Logfile liegt außerhalb des Sicherheitsbereichs. Diese Schranke überwindet der 'harte' Link:

    ln /log/error_log log/error_log

im Verzeichnis /home/watch. Damit greift man auch aus dem Käfig über /log/error_log auf die Log-Datei zu. Ein symbolischer Link genügt übrigens nicht, der darf aus Sicherheitsgründen nicht über die gesetzte Grenze hinwegschauen.

Das uptime-Kommando benötigt weiter einen mount auf /proc, was manuell mit

    mount /proc /home/watch/proc -t proc

geht, für automatisches Mounten beim Startup sorgt folgender Eintrag in /etc/fstab:

    /proc   /home/watch/proc   proc    defaults

Außerdem nutzt uptime /var/run/utmp, also spendieren wir auch dafür einen Link. In /home/watch geht das mit

    mkdir var var/run
    ln /var/run/utmp var/run/utmp

Was noch bleibt ...

Wer noch etwas Zeit und Muße hat: Das letzten Monat vorstellte Chart-Paket eignet sich hervorragend zur optisch ansprechenden Aufbereitung der Daten.

Sonst: Ab mit dem Skript ins cgi-bin-Verzeichnis des Webservers, den URL http://my.server.com/cgi-bin/checkload.pl in die Bookmarks/Favourites-Liste des Browsers aufgenommen und und bei Arbeitsbeginn einmal draufgeklickt. Alle Server laufen wie die Nähmaschinen? Na, da schmeckt der Morgenkaffee doch gleich viel besser. Mmmmhh. Scheint ein erfolgreicher Tag zu werden ...

Abb.1: Alles läuft, einer schläft ...

Listing1: checkload.pl

    01 #!/usr/bin/perl -w
    02 ######################################################################
    03 # Michael Schilli, 1998 (mschilli@perlmeister.com)
    04 ######################################################################
    05 
    06 use CGI qw/:standard :html/;
    07 use Net::Telnet;
    08 
    09     # Konfiguration
    10 $userid = "watch";
    11 $passwd = "topsecret!";
    12 
    13     # Server-Namen und deren Prompts
    14 @hosts = (['machine1.domain.com', 'machine1>'],
    15           ['machine2.domain.com', '$'],
    16           ['machine3.domain.com', '>'],
    17           ['murkshost', '$'],
    18          );
    19 $logfilename = "/log/error_log";
    20 
    21     # Alle Rechner abklappern
    22 for (@hosts) {
    23     my ($host, $prompt) = @$_;
    24 
    25     $prompt = sprintf('^%s\s*$', quotemeta($prompt));
    26 
    27     $telnet = new Net::Telnet (Host    => $host,
    28                                Timeout => 60,
    29                                Prompt  => "/$prompt/m");
    30 
    31         # Einloggen, 'die' abfangen
    32     eval { $telnet->login($userid, $passwd); };
    33 
    34     if($@) {    # Fehler aufgetreten?
    35         push(@hostinfo, [$host, "Login FAILED"]);
    36     } else {
    37                 # Last abfragen
    38         @lines = $telnet->cmd("uptime");
    39         ($load) = ($lines[0] =~ /average:\s*([0-9.]+)/); 
    40 
    41                 # Logfile-Länge abfragen
    42         @lines = $telnet->cmd("ls -l $logfilename");
    43         $logsize = (split(' ', $lines[0]))[4];
    44         1 while ($logsize =~ s/^(\d+)(\d\d\d)/$1.$2/);
    45 
    46         push(@hostinfo, [$host, "OK", $load, $logsize]);
    47     }
    48 
    49     $telnet->close;
    50 }
    51 
    52     # HTML ausgeben
    53 cgiprint(start_html(-BGCOLOR => "bisque",
    54                     "title"  => "Watch your Servers!"));
    55 
    56     # Überschrift
    57 cgiprint(h1("Watch your Servers!"), "\n");
    58 
    59 $tablecontent = TR( th("Host"), th("Status"), 
    60                     th("Load"), th("Logfile Size"), "\n" );
    61 
    62     # @hostinfo LoL -> HTML Tabelle 
    63 foreach $lref (@hostinfo) {
    64     $tablecontent .= TR( map { td($_) } @$lref ) . "\n";
    65 }
    66 
    67     # Tabelle ausgeben
    68 cgiprint(table({border => 1}, $tablecontent));
    69 cgiprint(end_html());
    70 
    71 
    72 #########################################################
    73 sub cgiprint {
    74 #########################################################
    75 # Text ausgeben, Header falls notwendig
    76 #########################################################
    77     print header() unless defined $header_printed;
    78     print "@_";
    79     $header_printed = 1;
    80 }

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.