Auf der Wacht (Linux-Magazin, November 2014)

Damit Clients blitzschnell auf die Daten des letzten Nmap-Scans des lokalen Netzwerks zugreifen können, speichert ein Perl-Dämon sie zwischen und gibt sie über ein eingebautes Webinterface heraus.

Der praktische Netzwerkscanner nmap dient nicht nur regelbrechenden Akteuren in spannenden Thrillern zum Aufspüren von Einbruchszielen ([1]), sondern zeigt auch dem Hobby-Admin an, welche Geräte tatsächlich über sein Heimnetzwerk erreichbar sind. Wer in regelmäßigen Abständen einen nmap-Lauf über alle bekannten Subnetze startet und die Ausgaben vergleicht, bleibt über neu hinzugekommene Geräte oder Abgänge auf dem Laufenden und baut bösen Überraschungen vor.

Dass nmap über eine Option -oX verfügt, die ihn zur Ergebnisausgabe im XML-Format veranlasst, erfuhr ich bei der Lektüre einer neu erschienenen firlefanzlosen Nmap-Bedienungsanleitung, die kürzlich als Kindle-Buch erschienen ist ([3]). Da ein nmap-Scan über mehrere Netze schon mal einige Minuten dauern kann, kam mir die Idee, einen Dämon zu bauen, der einmal pro Stunde alle Endknoten findet, die Daten im Speicher behält, und über einen eingebauten Webserver an anfragende Clients wie zum Beispiel ein Nagios-Skript verzögerungsfrei ausgibt.

Schlankes Skript

Das Skript in Listing 1 erledigt dies, allerdings hauptsächlich über das in Zeile 3 hereingezogene und weiter unten besprochene Modul NmapServer und dessen Methode start(). Vordem Aufruf legt es im Konstruktor den von nmap abzudeckenden IP-Bereich des Heimnetzwerks fest, im vorliegenden Beispiel sind das die Subnetze 192.168.14.x und 192.168.27.x, die Notation /24 gibt an, dass die ersten drei Oktette (24bit) die Maske des zu scannenden Subnetzes definieren, die hinten angehängte 1 wird nmap also durch alle Werte von 1 bis 254 ersetzen. Das CPAN-Modul App::Daemon sorgt mit der Methode daemonize() dafür, dass das Skript sich mit dem Parameter start starten lässt:

    $ ./nmap-server start
    $

Alles was im Skript unterhalb von daemonize() steht, läuft bis zum Sanktnimmerleinstag danach im Hintergrund weiter, auch wenn das Skript sofort wieder zurückkehrt und die Shell dem User schon den nächsten Prompt zeigt. Auch wenn letzterer sich ausloggt, läuft der Dämon weiter, weil App::Daemon hinter den Kulissen dafür gesorgt hat, dass er sein eigener Session-Leader ist und nicht mehr von der aufrufenden Shell abhängt.

Listing 1: nmap-server

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use NmapServer;
    04 use App::Daemon qw( daemonize );
    05 
    06 daemonize();
    07 
    08 my $nmap = NmapServer->new(
    09   scan_range => 
    10     [ "192.168.0.1/24", 
    11       "192.168.0.31/24" ] );
    12 
    13 $nmap->start();
    14 
    15 my $cv = AnyEvent->condvar();
    16 $cv->recv();

Um den Dämon wieder herunterzufahren, genügt das Kommando

    $ ./nmap-server stop

im gleichen Verzeichnis, denn App::Daemon speichert die pid des Dämon-Prozesses in der Datei nmap-server.pid ab. Was der Dämon treibt, steht in der Logdatei nmap-server.log, und mit der Verbose-Option -v landen dort auch Debug-Meldungen. Wer den Dämon im Vordergrund laufen lassen will, ruft das Skript genau wie den Apache-Server mit der Option -X auf.

Ereignisse in Schleifen

Das Modul NmapServer ist mit dem Event-Framework AnyEvent vom CPAN implementiert, mit dem zwar Multitasking möglich ist, aber in dem zu jedem Zeitpunkt nur ein Thread und normalerweise nur ein Prozess läuft. Der Programmfluss ist asynchron, die verschiedenen Programmteile laufen nur quasi-parallel und generieren und konsumieren Ereignisse, die eine alles steuernde Eventschleife verwaltet.

Die letzten beiden Zeilen in Listing 1 sind deswegen ein AnyEvent-Unikum. Sie definieren mit condvar() eine Variable, die mit recv() auf Events wartet. Da der Variablen jedoch niemand einen Event schickt, wartet das Skript am Ende, verzweigt zur Eventschleife, und verarbeitet dort eintreffende Events bis jemand den Dämon herunterfährt. Fehlten die letzten zwei Zeilen in nmap-server, würde sich das Skript nach Zeile 13 verabschieden, wenn die Variable $nmap dem Garbage-Collector zum Opfer fällt, weil sie das Ende ihres Gültigkeitsbereichs im Programm erreicht hat.

Das Modul NmapServer.pm in Listing 2 bringt nun das Kunststück fertig, mit Hilfe eines Timers vom Typ AnyEvent::Timer im Stundentakt das Programm nmap aufzurufen, dessen Ausgabe in eine mit File::Temp angelegte temporäre XML-Datei abzulegen und die Daten anschließend im JSON-Format im Speicher abzulegen. Quasi gleichzeitig läuft im Code ein Webserver vom Type AnyEvent::HTTPD, der auf Port 9090 lauscht und anfragenden Clients die JSON-Daten übermittelt. Abbildung 1 zeigt einen dort andockenden Browser, der die detaillierten Scan-Daten des letzten nmap-Laufs im JSON-Format anzeigt.

Abbildung 1: Die JSON-Ausgabe des Nmap-Daemons.

Da das externe Programm nmap nicht mit der von AnyEvent benutzten Eventschleife kooperiert sondern den Gesamtbetrieb des Servers aufhalten würde, muss Zeile 62 mit Hilfe der Anweisung fork() ein Prozesskind erzeugen, das AnyEvent mit der Methode child() in Zeile 70 verwaltet. Kommt der Child-Prozess zum Abschluss, wurde der nmap-Lauf also erfolgreich abgeschlossen, springt das Skript den ab Zeile 72 definierten Callback-Code an, verarbeitet das XML und wandelt es in JSON um. Um den internen Cache aufzufrischen, muss der Code nicht einmal ein Lock setzen, denn es läuft immer nur ein Thread und eine Zuweisung ist auch dann atomar, falls es sich um einen riesigen Datenwust handelt. Dank Eventschleife kommt in der Zwischenzeit keine weitere AnyEvent-Task an die Reihe. Das Prozesskind führt andererseits mit exec() in Zeile 81 lediglich das externe nmap-Programm aus und beendet sich anschließend. Definitionsgemäß kehrt der Prozess nie mehr aus seiner exec()-Anweisung zurück, es sei denn, beim Aufruf geht etwas schief.

Eingebauter Webserver

Der Timer ab Zeile 42 startet dank des auf den Wert 0 gesetzten Parameters after sofort, und nach dem ersten Lauf des Nmap-Scanners wieder im Stundentakt (interval steht auf 3600>). Es ist wichtig, die zurückgereichte Timerreferenz in einer Instanzvariablen des NmapServer-Objekts zu sichern, denn sonst gäbe der Timer sofort den Geist auf, nachdem der Programmfluss die Methode verlassen hat.

Der in der Funktion http_spawn ab Zeile 88 definierte und auf Port 9090 lauschende Webserver tickt zusammen mit dem Timer in der Eventschleife und liefert bei jeglichen Anfragen unter dem Pfad "/" immer die im Speicher bereitliegenden JSON-Daten aus. AnyEvent kann so eine ganze Reihe von Komponenten wie auf Ports lauschende Server, ins Netzwerk greifende Clients oder Timer in ein und demselben Skript laufen lassen. Das macht Integrationstest besonders einfach, denn man kann ohne Probleme einen Server und einen Client im gleichen Skript laufen und miteinander kommunizieren lassen, ohne dass externe Prozesse gestartet und später wieder gestoppt werden müssten.

Listing 2: NmapServer.pm

    001 ###########################################
    002 package NmapServer;
    003 ###########################################
    004 # Daemon running nmap periodically and 
    005 # serving JSON data via a web interface
    006 # Mike Schilli, 2014 (m@perlmeister.com)
    007 ###########################################
    008 use strict;
    009 use warnings;
    010 use Log::Log4perl qw(:easy);
    011 use AnyEvent;
    012 use AnyEvent::HTTPD;
    013 use JSON qw( to_json );
    014 use File::Temp qw( tempfile );
    015 use XML::Simple;
    016 
    017 ###########################################
    018 sub new {
    019 ###########################################
    020   my( $class, %options ) = @_;
    021 
    022   my( $fh, $tmp_file ) = 
    023      tempfile( UNLINK => 1 );
    024 
    025   my $self = {
    026     xml_file   => $tmp_file,
    027     fork       => undef,
    028     json       => "",
    029     child      => undef,
    030     scan_range => [],
    031     %options,
    032   };
    033 
    034   bless $self, $class;
    035 }
    036 
    037 ###########################################
    038 sub start {
    039 ###########################################
    040   my( $self ) = @_;
    041 
    042   $self->{ timer } = AnyEvent->timer(
    043     after    => 0, 
    044     interval => 3600, 
    045     cb => sub { 
    046       if( defined $self->{ fork } ) {
    047           DEBUG "nmap already running";
    048           return 1;
    049       }
    050       $self->nmap_spawn();
    051     },
    052   );
    053 
    054   $self->httpd_spawn();
    055 }
    056 
    057 ###########################################
    058 sub nmap_spawn {
    059 ###########################################
    060   my( $self ) = @_;
    061 
    062   $self->{ fork } = fork();
    063 
    064   if( !defined $self->{ fork } ) {
    065     LOGDIE "Waaaah, failed to fork!";
    066   }
    067 
    068   if( $self->{ fork } ) {
    069       # parent
    070     $self->{ child } = AnyEvent->child( 
    071       pid => $self->{ fork }, 
    072       cb  => sub {
    073         my $data = 
    074           XMLin( $self->{ xml_file });
    075         $self->{ json } = 
    076          to_json( $data, { pretty => 1 } ); 
    077         $self->{ fork } = undef;
    078       } );
    079   } else {
    080       # child
    081     exec "nmap", "-oX", 
    082       $self->{ xml_file }, 
    083       @{ $self->{ scan_range } },
    084   }
    085 }
    086 
    087 ###########################################
    088 sub httpd_spawn {
    089 ###########################################
    090   my( $self ) = @_;
    091 
    092   $self->{ httpd } = 
    093     AnyEvent::HTTPD->new( port => 9090 );
    094 
    095   $self->{ httpd }->reg_cb (
    096     '/' => sub {
    097       my ($httpd, $req) = @_;
    098 
    099       $req->respond({ content => 
    100         ['text/json', $self->{ json } ],
    101       });
    102     },
    103   );
    104 }
    105 
    106 1;

König Kunde

Möchte nun ein Client die Daten des letzten Nmap-Scans abfragen, holt er sie einfach mit einem normalen Webclient ab. Listing 3 verbindet sich mit Port 9090 auf dem lokalen Host, bekommt die JSON-Daten, wandelt diese mittels des CPAN-Moduls JSON und dessen Methode from_json() in Perl-Datenstrukturen um und iteriert dann über die dort gefundenen Hash- und Arrayeinträge. Eine kurze Analyse der JSON-Daten zeigt, dass die von Nmap gefundenen Hosts unter einem Hasheintrag mit dem Schlüssel host stehen und deren ipv4-Adresse jeweils unterhalb des Knotens address in einem Eintrag mit dem Namen addr liegen. Listing 3 iteriert über alle gefundenen Einträge und gibt sich aus:

    $ ./nmap-client 
    192.168.14.1
    192.168.14.10
    192.168.27.101

Der Scan brachte also insgesamt drei Geräte zum Vorschein, zwei im ersten Subnetz und eines im zweiten.

Listing 3: nmap-client

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use JSON qw( from_json );
    04 use LWP::UserAgent;
    05 
    06 my $ua = LWP::UserAgent->new();
    07 my $resp = 
    08   $ua->get( "http://localhost:9090" );
    09 
    10 if( $resp->is_error() ) {
    11   die "failed: ", $resp->message();
    12 }
    13 
    14 my $data = 
    15   from_json( $resp->decoded_content() );
    16 
    17 for my $host ( @{ $data->{ host } } ) {
    18   print "$host->{ address }->{ addr }\n";
    19 }

Wächter Nagios

Um nun das Ganze in ein Monitorprogramm wie Nagios einzubinden, das Alarm schlägt, falls mehr als die erwartete Anzahl von Knoten im Netz gefunden wurde, kommt das Skript in Listing 4 zum Einsatz. Es nutzt das CPAN-Modul Nagios::Clientstatus, das oft wiederholte Aufgaben von Nagios-Skripts abstrahiert, wie zum Beispiel das Entgegennehmen von Parametern oder das Beenden des Skripts mit einem Exit Code, den Nagios versteht. Das Skript nagios-check-nmap in Listing 4 nimmt zwei Parameter entgegen, --min-hosts und --max-hosts, die die Mindest- bzw. Maximalzahl eines Nmap-Scans vorgeben. Unter- bzw. überschreitet der Suchlauf die eingestellten Werte, signalisiert das Skript mit exitvalue("critical") einen Fehler und der Nagios-Dämon schlägt Alarm.

Listing 4: nagios-check-nmap

    01 #!/usr/local/bin/perl
    02 use strict;
    03 use Nagios::Clientstatus;
    04 
    05 my $version = "0.01";
    06 my $ncli    = Nagios::Clientstatus->new(
    07     help_subref    => 
    08       sub { print "usage: $0\n" },
    09     version        => $version,
    10     mandatory_args => 
    11       ['max-hosts', 'min-hosts'],
    12 );
    13 
    14 use JSON qw( from_json );
    15 use LWP::UserAgent;
    16 
    17 my $ua = LWP::UserAgent->new();
    18 my $resp = 
    19   $ua->get( "http://localhost:9090" );
    20 
    21 if( $resp->is_error() ) {
    22   die "failed: ", $resp->message();
    23 }
    24 
    25 my $data = 
    26   from_json( $resp->decoded_content() );
    27 
    28 my $nhosts = scalar @{ $data->{ host } };
    29 
    30 printf "Nmap found: %s\n", 
    31   join " ", 
    32     map { $_->{ address }->{ addr } } 
    33   @{ $data->{ host } };
    34 
    35 my $max_hosts = 
    36   $ncli->get_given_arg('max-hosts');
    37 
    38 my $min_hosts = 
    39   $ncli->get_given_arg('min-hosts');
    40 
    41 if( $nhosts > $max_hosts or 
    42     $nhosts < $min_hosts ) {
    43     exit $ncli->exitvalue("critical");
    44 }
    45 
    46 exit $ncli->exitvalue("ok");

Listing 5: nagios.cfg

    01 define service{
    02   use                 ez-service
    03   service_description Hosts in Nmap Scan
    04   check_command       nagios-check-nmap!5!5
    05   host_name           mybox
    06 }
    07 
    08 define command{
    09   command_name	check_nmap
    10   command_line	$USER1$/nagios-check-nmap --min-hosts $ARG1$ --max-hosts $ARG2$
    11 }
    12 

Um das Nagios-Skript in Listing 4 in die Nagios-Konfiguration einzubinden, sind die in Listing 5 gezeigten Zeilen notwendig. Nach einem Neustart des Nagios-Dämons schnappt sich dieser die neue Konfiguration und ruft das Nagios-Skript in festgesetzten Zeitabständen auf. Nach dem Lauf des nmap-server-Dämons bekommt das Skript die Scan-Daten und meldet entweder, dass alles in Ordnung ist oder sich ein neuer Host ins Netzwerk eingeschlichen hat. Das könnte entweder ein neu gekauftes Gerät oder ein Eindringling sein. Es benachrichtigt Nagios den User, und dieser sollte nach dem Rechten sehen und eventuell den in der Konfiguration eingestellten Wert für --max-hosts um eins nach oben korrigieren falls es sich tatsächlich um eine Neuanschaffung handelt.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2014/11/Perl

[2]

Nmap-Einsatz im Film "The Matrix": https://www.youtube.com/watch?v=0TJuipCrjZQ

[3]

"Nmap Cookbook: The Fat-free Guide to Network Scanning" (Kindle Edition), Nicholas Marsh, http://www.amazon.com/dp/B005ZK84NU

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.