Wir brauchen Bass (Linux-Magazin, November 2008)

Statt die Ergebnisse eingehender Webrequests nur in der Logdatei des Webservers zu verfolgen, hilft ein Soundserver dem Systemadministrator, die Aktivitäten akustisch zu untermalen.

Immer wenn ich eine neue Ausgabe unseres Blogs ``USA-Rundbrief'' hochlade, eine Ankündigungs-Email abschicke und das RSS-Feed auffrische, sehe ich anschließend gerne im access.log des Webservers zu, wie die ersten Infohungrigen mit ihren Browsern die Berichte lesen und die darin eingebauten Bilder vergrößern. Allerdings ist das Ansehen der Hits im Serverlog recht anstrengend und eigentlich sollte dies im Hintergrund passieren, sodass ich mich gleich neuen Aufgaben widmen kann.

Wie sonst könnte man den Webnutzern über die Schulter schauen? In dem Buch ``Netscape Time'' von Jim Clark, das ich vor vielen Jahren einmal gelesen habe, stand, dass die frühen Netscapeler nach dem Fertigstellen einer neuen Softwareversion die eintrudelnden Hits für den Download akustisch über den PC-Lautsprecher ausgaben. Ein Browser-Download für Windows löste ein Froschquaken aus, für Macs wurde ein Geräusch zerbrechenden Glases gespielt und für die Unix-Plattform löste sich ein Kanonenschuss [4]. So erfreuten sich die Internetpioniere in ihren Cubicles gemeinsam über den sicht- und letztlich auch hörbaren Erfolg ihrer Arbeit.

Ein ähnliches Verfahren lässt sich leicht in Perl implementieren, allerdings ist die Lage bei mir verzwickter: Der Webserver läuft bei einem Hosting-Service, der zwar den Shell-Zugang über ssh erlaubt, aber keinerlei Verständnis für meine akustischen Bedürfnisse zeigt.

Firewalltunnel

Aber das Skript boom-sender auf dem Hosting-Rechner, das die access.log-Datei des Webservers überwacht und bei ausgewählten URLs Nachrichten über einen Reverse-ssh-Tunnel an einen Soundserver boom-receiver auf meinem Rechner daheim schickt, schafft da schnell Abhilfe.

Abbildung 1: Das Skript, das die Accesslog-Datei des Webservers auf dem Hostingrechner überwacht, kommuniziert über den ssh-Tunnel mit dem sounderzeugenden Desktop.

Abbildung 1 zeigt den Aufbau: Der Soundserver ist ein mit dem CPAN-Modul POE geschriebenes Skript, das auf Port 8080 des heimischen Rechners auf Soundbefehle wartet. Auf der Seite des Hosting-Providers läuft ein weiteres POE-Skript, das bei Änderungen im access.log anspringt und als TCP-Client Nachrichten abschickt. Da mein Heimrechner hinter einer Firewall steckt, kann der Logchecker boom-sender ihn nicht direkt kontaktieren aber ein per

    home$ ssh -R 8080:localhost:8080 host.xyz-hosting.com

vom Heimrechner aus angelegter Reverse-Tunnel verbindet die beiden Gesprächsparter und das Logskript muss seine Nachrichten nur an den Port 8080 des Hostingrechners schicken, damit sie durch den Tunnel wie durch Zauberei auf Port 8080 des Heimrechners ankommen, ganz so, als gäbe es die Firewall gar nicht.

Auf Befehl Sound

Die Soundmaschine ist ein TCP-Server, der auf Port 8080 des heimischen Rechners von TCP-Clients die Namen von Sounddateien entgegennimmt und diese dann mit der play-Utility aus dem Fundus des Programmpakets sox auf dem Linux-Soundsystem abspielt. Das Programm play kommt standardmäßig mit Ubuntu und kann nicht nur .wav-Dateien, sondern auch .mp3s ausgeben, falls Ubuntu dafür konfiguriert ist.

Abbildung 2 zeigt die Interaktion eines Testclients mit dem laufenden Soundserver: Der Befehl telnet auf den eingestellten Port des Rechners localhost zeigt zunächst eine Grußnachricht des Servers und alle Sounddateien, die dieser anbietet. Schickt der Client den Namen einer dieser .wav-Files als Befehl an den Server, spielt dieser sie ab. Das Skript in Listing boom-receiver legt in Zeile 09 in $SOUND_DIR das Verzeichnis fest, in der die Sounddateien liegen müssen. Aus Sicherheitsgründen sind nur Dateinamen und keine Pfade erlaubt. Voreingestellt ist das aktuelle Verzeichnis (.).

POE bietet sich hier als Technologie sowohl für die Server wie auch die Clientseite an, denn man muss die standardmäßig bereitgestellten POE-Komponenten nur noch wie mit einem Lego-Baukasten zusammenstecken. Eine gute Einführung in POE gibt die Website poe.perl.org oder das POE-Kapitel des Buchs [5]. Der Server in boom_receiver definiert Callbacks für die Zustände ClientConnected (Client hat angedockt), ClientInput (Client hat eine Textzeile übermittelt), sowie in InlineStates einen weiteren Zustand, der für die weiter unten erklärten Abräumarbeiten nach dem Abspielen eines Geräusches verantwortlich zeichnet.

Der Server bearbeitet so viele Clientverbindungen quasi gleichzeitig. Die in der POE-Komponente versteckte Logik sorgt für den reibungslosen Ablauf hinter den Kulissen. Wie bei jedem POE-Skript legt der Programmcode zunächst das Verhalten bei allen möglicherweise eintretenden Events fest, um dann den POE-Kernel mit POE::Kernel->run() zu starten. Dieser läuft dann, bis das Programmende eintritt, entweder durch einen fatalen Fehler oder wenn der Benutzer es mit CTRL-C abbricht.

Abbildung 2: Der Test-Client telnet verbindet sich mit dem Empfänger-Server, der auf Kommando eine Sounddatei abspielt.

Nicht Trödeln

Die Funktion sound_play() ab Zeile 60 in boom_receiver spielt eine ihr überreichte Sounddatei ab. Hierzu erzeugt sie ein POE::Wheel, ein Rädchen im Getriebe des POE-Systems, mit dem der POE-Kernel mit der Außenwelt kommuniziert. Damit das System keine Zeit verplempert, darf Perl-Code in POE nur solange ohne Unterbrechnung vor sich hinwerkeln, wie er mit voller Geschwindigkeit läuft. Bei der Interaktion mit Dateien, Sockets oder anderen Prozessen kommt es naturgemäß zu Verzögerungen. Da der Zugriff auf die Festplatte oder das Netzwerk um Größenordungen langsamer ist als das Abarbeiten von CPU-Instruktionen oder der Zugriff auf den Hauptspeicher, wäre es äußerst uneffizient, auf die Erledigung derartiger Aufgaben blockierend zu warten. Statt dessen sorgen Wheels dafür, dass der POE-Kernel diese Tasks eventbasiert und asynchron abarbeitet. Einen neuen Prozess mit dem Programm play zu starten, ihm eine kurze Sounddatei zu übergeben und auf das Abspielen zu warten, kostet gut und gerne eine Sekunde. Wäre das Skript in dieser Zeit blockiert, würde es die Antwort an den aufgabenstellenden Client solange verzögern und könnte auch keine neuen Clientanfragen beantworten. Das Wheel hingegen sorgt dafür, dass der Callback sofort zurückkehrt, der POE-Kernel wieder die Kontrolle übernimmt und der Rest einfach im Hintergrund abläuft.

Das Wheel POE::Wheel::Run verlangt außer dem zu startenden externen Programm und dessen Argumenten auch noch die Definition eines StderrEvent. Dies ist eine Funktion, die das Wheel anspringt, falls Daten an dessen STDERR-Ausgabe ankommen. Für das Programm play, das normalerweise keine Fehlermeldungen ausgibt und sich nach jeder abgespielten Sounddatei wieder beendet, ist dies jedoch irrelevant und boom-receiver legt einfach einen nicht-existierenden Zustand ignore fest, den POE ignoriert. Stellt das Wheel fest, dass der gestartete play-Prozess sich beendet hat, löst es den in Zeile 75 gesetzten und in Zeile 48 definierten CloseEvent aus.

Idealerweise würde das Wheel einen dauerhaft laufenden Prozess wie xmms starten und ihm laufend Sounddateien unterschieben, aber die dafür vorgesehene POE-Komponenente auf dem CPAN ist etwas in die Jahre gekommen und lässt sich nicht mehr ohne Klimmzüge mit dem aktuellen <xmms> kompilieren. Schade!

Listing 1: boom-receiver

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use POE;
    04 use POE::Component::Server::TCP;
    05 use POE::Wheel::Run;
    06 use File::Basename;
    07 use Log::Log4perl qw(:easy);
    08 
    09 my $SOUND_DIR = ".";
    10 my @SOUND_FILES = map { basename $_ } 
    11                   <$SOUND_DIR/*.wav>;
    12 
    13 Log::Log4perl->easy_init($DEBUG);
    14 
    15 POE::Component::Server::TCP->new(
    16   Port  => 8080,
    17 
    18   ClientConnected => sub {
    19     $_[HEAP]{client}->put("Soundfiles: [".
    20      join(", ", @SOUND_FILES) . "]" );
    21 
    22     $_[HEAP]{client}->put(
    23      "Ready when you are.");
    24   },
    25 
    26   ClientInput => sub {
    27     my $client_input = $_[ARG0];
    28 
    29     if( $client_input !~ /^[\w.-]+$/ ) {
    30         $_[HEAP]{client}->put(
    31           "Illegal input.");
    32         return;
    33     }
    34 
    35     if( $client_input eq "q" ) {
    36         POE::Kernel->yield("shutdown");
    37         return;
    38     }
    39 
    40     my $msg = sound_play(
    41                 $_[HEAP],
    42                 basename($client_input));
    43 
    44     $_[HEAP]{client}->put( $msg );
    45   },
    46 
    47   InlineStates => {
    48     sound_ended => sub {
    49       my ($heap, $wid) = @_[HEAP, ARG0];
    50       DEBUG "Deleting wheel $wid";
    51       delete $heap->{players}->{$wid};
    52     },
    53   },
    54 );
    55 
    56 POE::Kernel->run();
    57 exit;
    58 
    59 ###########################################
    60 sub sound_play {
    61 ###########################################
    62   my($heap, $file) = @_;
    63 
    64   if(! -f "$SOUND_DIR/$file") {
    65     return "$file doesn't exist";
    66   }        
    67 
    68   POE::Kernel->sig(CHLD => "reaped");
    69 
    70   my $wheel =
    71     POE::Wheel::Run->new(
    72       Program     => "/usr/bin/play",
    73       ProgramArgs => ["$SOUND_DIR/$file"],
    74       StderrEvent => 'ignore',
    75       CloseEvent  => 'sound_ended',
    76   );
    77 
    78   DEBUG "Creating wheel ", $wheel->ID;
    79   $heap->{players}->{ $wheel->ID } = $wheel;
    80 
    81   return "Played $file";
    82 }

Holzauge sei wachsam!

Die gezeigte Implementierung geht zwar verschwenderisch mit den Resourcen des lokalen Rechners um, ist aber durchaus in der Lage, quasi-parallele Requests in Geräusche umzuwandeln. Das Skript muss hierzu eine Referenz auf das sounderzeugende Wheel-Objekt behalten, da POE es sofort abräumt, sobald sich niemand mehr um es kümmert. Am Ende von sound_play() ist allerdings die Aufgabe des Wheels noch nicht erledigt, da der POE-Kernel es scheibchenweise nach dem Beenden der Funktion abarbeitet. Um den verfrühten Tod zu vermeiden und gleichzeitig die Wheels nicht unnötig herumlungern zu lassen, legt Zeile 79 das jeweils erzeugte Wheel-Objekt mit seiner ID im POE-Session-Heap unter dem Schlüssel players ab. Da das Wheel einen CloseEvent mit dem Sprungziel sound_ended definiert, ruft POE beim Abschluss des Soundprozesses die ab Zeile 48 definierte Funktion auf, die die Wheel-Referenz wieder löscht, damit POE den Sensenmann vorbeischickt. Als weiteres Gotcha räumt POE::Wheel::Run terminierte Kindsprozesse nicht automatisch auf, sodass sie als Zombies im Unix-System herumlungern. Zeile 68 definiert deswegen einen SIGCHLD-Handler, der den Elternprozess dazu veranlasst, ein wait() auf das terminierte Kind abzusetzen und damit dessen Zombie-Werdung aktiv zu verhindern.

Sobald ein Client sich mit Port 8080 der Serverkomponente POE::Component::Server::TCP verbindet, springt deren Zustandsautomat den Zustand ClientConnected an. In $_[HEAP]{client} liegt dort ein Client-Objekt vor, über dessen put-Methode der Server Nachrichten an den Client schicken kann. In ClientConnected teilt der Server dem andockenden Client die verfügbaren Sounddateien mit und begrüßt ihn anschließend mit einem freundlichen ``Ready when you are''.

Wann immer der Client etwas an den Server schickt, springt letzerer die dem Zustand ClientInput zugeordnete Subroutine an. Die eingegangene Nachricht liegt dort in $_[ARG0], einer dieser POE-typischen Felder des Argumenten-Array @_. Damit der Client keinen Schabernack mit dem Server treibt und bösartige Unix-Kommandos statt der erwarteten Sounddatei schickt, prüft Zeile 29, ob außer den in Dateien üblichen Zeichen auch noch andere vorliegen und verwirft in diesem Fall den Request sofort mit einer Fehlermeldung.

Schickt der Client hingegen das Zeichen ``q'', möchte dieser die Session beenden und der Server springt den Zustand shutdown an, der die aktuelle Client-Verbindung löst, aber den Server weiterlaufen lässt. Schickt der Cient dagegen tatsächlich den Namen einer existierenden Sounddatei, spielt ihn die Funktion sound_play ab und gibt einen Statusstring zurück, den der Server dem Client zur Bestätigung mit put() schickt.

Ende des Tunnels

Auf der anderen Seite des Tunnels überwacht das POE-Skript aus Listing boom-sender das access.log-File des Webservers. Es läuft auf dem Rechner des Hostingservice, host.xyz-hosting.com und nutzt die TCP-Client-Komponente des POE-Frameworks, um mit dem Server Kontakt zu halten.

Diese bietet von Haus aus unter anderem die Events ServerInput und ConnectError an, deren Callbacks sie angespringt, falls der Server Text sendet oder die Verbindung fehlschlägt. Zum Senden definiert boom-sender über InlineStates den Zustand send, der eine ihm übergebene Nachricht mit put() an den Server schickt.

Die ab Zeile 29 definierte Logfileüberwachersession bekommt mittels eines FollowTail-Wheels aus der POE-Werkzeugkiste mit, wenn der Webserver eine neue Zeile an die in Zeile 35 definierte Logdatei anhängt. Auch hier ist es wichtig, dass sie eine Referenz auf das Wheel beibehält, denn sonst würde POE letzteres rücksichtslos abräumen, sobald der _start-Callback beendet wäre. Die Referenz bleibt im sogenannten Heap der POE-Session unter dem Eintrag tail bestehen, solange die Session läuft, also bis zum Ende von boom-sender.

Produktionssysteme rotieren ihre Logdateien oft einmal am Tag, und FollowTail ist darauf vorbereitet und springt in diesem Fall den dem ResetEvent zugeordneten got_log_rollover-Callback an. Dieser macht damit nichts Großartiges, sondern schreibt nur eine Debug-Meldung, damit der User Bescheid weiß.

Jedesmal, wenn das Wheel eine neu angehängte Logzeile findet, springt es den Status got_log_line an und führt den entsprechenden Callback aus. Dort analysiert es neue Zeilen, die im Format

    67.195.37.108 - - [01/Sep/2008:17:25:20 -0700] "GET /1/p3.html HTTP/1.0" 200 8678 "-" "Mozilla/5.0 (X11; U; Linux i686 (x86_64); en-US; rv:1.8.1.4) Gecko/20080721 BonEcho/2.0.0.4"

vorliegen mit dem Modul ApacheLog::Parser vom CPAN. Dessen exportierte Funktion parse_line_to_hash() liefert einen Hash zurück, der unter dem Eintrag file die per HTTP angeforderte Datei enthält.

Da der TCP-Client in Zeile 12 mit dem Alias-Namen "boom" definiert wurde, kann die in Zeile 29 definierte Session mit dem FollowTail-Wheel dem TCP-Server die zu sendende Sounddatei mit

      POE::Kernel->post("boom", "send", 
              file2sound($file));

mitteilen. Hier kommunizieren zwei verschiedene Sessions miteinander, deswegen wird das Ereignis nicht mit yield() eingeleitet sondern mit post() und dem Namen der empfangenden Session. Der Event "send" der Session boom bekommt dann in ARG0 den Namen der .wav-Datei übermittelt, die es in Zeile 21 mit put() an den TCP-Client übergibt, der diese wiederum als Kommando an den Soundserver schickt. Nicht direkt zwar, aber an Port 8080 des localhost, der wiederum über den Tunnel mit Port 8080 des Soundservers in Verbindung steht.

Keine Kakophonie

Löste jeder neue Eintrag im access.log einen Ton aus, entstünde bei einer Webseite mit 20 Bildern, die der Browser in kürzesten Abständen einholt, eine nervige Kakophonie von überlagerten Geräuschen. Deswegen filtert boom-sender die Ausgabe des Access Log und übermittelt nur bei aufgerufenen Hauptseiten, vergrößerten Bildern und Forumsaktivität Anfragen an den Soundserver boom-receiver. Die ab Zeile 57 definierte Funktion file2sound() nimmt den vom Browser angeforderten Dateipfad (z.B. /index.html) entgegen und gibt den Namen der abzuspielenden Sounddatei zurück. Hierzu macht er ein paar Annahmen, die eine Installation eventuell angepassen muss, wie zum Beispiel dass ein auf ``/'' endender Pfad eine index.html-Datei herausgibt.

Installation

Das Skript boom-sender wird auf dem Hostingrechner installiert, die notwendigen Perl-Module gibt's alle auf dem CPAN. AccessLog::Parser erfordert noch die Module Getopt::Helpful, Date::Piece, File::Fu und Class::Accessor::Classy. Sträubt sich der Hostingprovider, die Installation mit dem angebotenen perl durchzuführen, geht das auch mittels eines zusätzlichen Modulverzeichnisses in einem beschreibbaren Bereich auf dem Hostingrechner und der Direktive

    use lib "/home/name/perl-modules";

im Perl-Skript. Alternativ geht auch eine eigene Perlinstallation, ebenfalls in einem für den Hostingkunden beschreibbaren Bereich. Details hierzu finden sich in [2]. Und als weitere Alternative kommt das PAR-Toolkit in Betracht, mit dem sich ähnlich wie mit Javas JAR-Dateien Modularchive und sogar ausführbare Executables ohne Installationssorgen packen lassen ([3]).

Die in der Funktion file2sound() in boom-sender vorgenommenen Zuweisungen von URL-Dateien zu Soundfiles sollte noch an die lokalen Verhältnisse angepasst werden. Nur Sounddateien, die tatsächlich vom Soundserver angeboten werden, sollten hier Verwendung finden.

Auf dem Soundserver werden die Sounddateien im Verzeichnis $SOUND_DIR installiert. Eine gute Auswahl an kurzen Geräuschen bietet das Verzeichnis /usr/share/sounds/purple. Dort legt der IM-Client Pidgin (ehemals Gaim) die Daten von Geräuschen ab, die das Program von sich gibt, wenn sich Buddies an- oder abmelden oder Instant Messages eintrudeln oder abgesandt werden.

Nach dem Starten des Soundservers boom-receiver, einem kurzen Test mit dem telnet-Client und dem Einrichten des oben beschriebenen ssh-Tunnels, darf dann der Logdateiüberwacher boom-sender auf dem Hostingrechner gestartet werden und das Konzert beginnt.

Listing 2: boom-sender

    01 #!/home/mschilli/PERL/bin/perl -w
    02 use strict;
    03 use POE;
    04 use POE::Wheel::FollowTail;
    05 use POE::Component::Client::TCP;
    06 use ApacheLog::Parser 
    07                     qw(parse_line_to_hash);
    08 use Log::Log4perl qw(:easy);
    09 Log::Log4perl->easy_init($DEBUG);
    10 
    11 POE::Component::Client::TCP->new(
    12   Alias         => 'boom',
    13   RemoteAddress => 'localhost',
    14   RemotePort    => 8080,
    15   ServerInput   => sub {
    16       DEBUG "Server says: $_[ARG0]";
    17   },
    18   InlineStates => {
    19     send => sub { 
    20       DEBUG "Sending [$_[ARG0]] to server";
    21       $_[HEAP]->{server}->put($_[ARG0]);
    22     },
    23   },
    24   ConnectError => sub {
    25       LOGDIE "Cannot connect to server";
    26   }
    27 ); 
    28 
    29 POE::Session->create(
    30   inline_states => {
    31     _start => sub {
    32       $_[HEAP]->{tail} = 
    33         POE::Wheel::FollowTail->new(
    34           Filename => 
    35             "/var/log/apache2/access.log",
    36           InputEvent => "got_log_line",
    37           ResetEvent => "got_log_rollover",
    38       );
    39     },
    40     got_log_line => sub {
    41       my %fields = 
    42                parse_line_to_hash $_[ARG0];
    43       my $file = $fields{ file };
    44       if(my $sound = file2sound($file)) {
    45         POE::Kernel->post("boom", "send", 
    46              $sound);
    47       }
    48     },
    49     got_log_rollover => sub {
    50       DEBUG "Log rolled over.";
    51     },
    52   }
    53 );
    54 
    55 POE::Kernel->run();
    56 exit;
    57 
    58 ###########################################
    59 sub file2sound {
    60 ###########################################
    61     $_ = $_[0];
    62 
    63     DEBUG "Got $_";
    64 
    65     s#/$#/index.html#;
    66 
    67     m#/index.html$# and
    68         return "article-page.wav";
    69 
    70     m#/posting.php# and 
    71         return "forum-post.wav";
    72 
    73     m#/viewforum.php# and 
    74         return "forum-page.wav";
    75 
    76     m#/images/.*html# and 
    77         return "image.wav";
    78 
    79     return "";
    80 }

Erweiterungen

Zusätzlich zu den angeforderten URL-Pfaden könnte der Soundserver auch noch bei fehlschlagenden Requests Töne abspielen. Der Webserver legt auch den Returncode jedes Requests in access.log ab und der Logparser stellt ihn mit dem Hasheintrag $fields{code} zur Verfügung. Flatulenzgeräusche oder eine Explosion wären die geeignete Hintergrundmusik für derartige Fehlleistungen und zögen die Aufmerksamkeit des Systemadministrators auf sich.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2008/11/Perl

[2]
Michael Schilli, ``Da hab ich dann was eigenes'', Linux-Magazin, November 1999 (http://perlmeister.com/snapshots/199911/index.html) (Auf eurer Webseite leider im Zuge der Neuformatiertung verschlampt).

[3]
Michael Schilli, ``Gepackte Koffer'', Linux-Magazin, April 2004, http://www.linux-magazin.de/heft_abo/ausgaben/2004/09/gepackte_koffer

[4]
Jim Clark, ``Netscape Time'', http://www.amazon.com/gp/reader/0312263619/ref=sib_dp_ptu# (nach ``cannon shot'' suchen).

[5]
``Advanced Perl Programming (2nd edition)'', Simon Cozens, O'Reilly, 2005.

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.