Was passiert eigentlich so den ganzen Tag (und bei Nacht) auf einem lokalen Netzwerk? Wer den heute vorgestellten Perl-Dämon laufend Daten sammeln und in einer Datenbank ablegen lässt, kann Alarme auslösen und hinterher feststellen, was vorgefallen ist.
In [2] hat mein Kolumnistenkollege Charly Künast vor einiger Zeit arpalert ([4]) vorgestellt, dessen Dämon ARP-Anfragen überwacht, mit einer Whitelist vergleicht und bei unbekannten MAC-Adressen einen Alarm auslöst. Sehr nützlich, das Ganze, allerdings löste das Programm teilweise multiple Alarme für den gleichen Vorfall aus und die beiliegende Dokumentation war teils französich und teils grottenschlecht.
Dank der Module Net::Pcap
und NetPacket::Ethernet
vom CPAN ist
es nicht schwer, die MAC-Adressen von im LAN herumschwimmenden Paketen
herauszufischen. Und mit Rose, dem neulich hier vorgestellten
objektorientierten Datenbankmapper, lassen sich die gewonnenen
Daten in eine MySQL-Datenbank einlesen, in der man später skriptgestützt
nach Herzenslust herumstöbern kann.
Um zum Beispiel festzustellen, welche Geräte während der letzten
24 Stunden auf dem LAN aktiv waren, genügt ein Aufruf des Skripts
lastaccess
wie in Abbildung 1 dargestellt.
Abbildung 1: Welche Geräte waren in den letzten 24 Stunden auf dem LAN aktiv? |
Ähnlich wie der schon einmal hier besprochene graphische
Netzwerkschnüffler capture
[3], schaltet das Skript arpcollect
in Listing 1 die erste Netzwerkkarte des Rechners in den
promiscuous mode. So schnappt sie nicht nur die für den
angeschlossenen Rechner bestimmten Pakete aus der Ethernetleitung,
sondern leitet einfach alle gefundenen Pakete an das Schnüffelskript
weiter. Hierzu sind root
-Rechte erforderlich, die Zeile 9
entweder bestätigt oder das Skript bricht ab.
Die in Zeile 13 aufgerufene Funktion lookupdev()
gibt den
Namen eines verfügbaren Netzwerk-Devices zurück. Bei nur einer
angeschlossenen Netzwerkkarte ist das "eth0"
. Das anschließende
open_live()
tritt dann in eine Endlosschleife ein (der Timeout
wurde mit -1 abgeschaltet), in der es jeweils die ersten 1024 bytes
jedes ankommenden Paketes liest und sofort die Callback-Funktion
callback
aufruft. Diese erhält nicht nur die vorher ermittelte
lokale Netzwerkadresse/maske als $user_data
, sondern die rohen
Paketdaten in $raw_packet
.
Das Modul NetPacket::Ethernet
dekodiert dieses Datalink-Layer-Paket
und stellt unter dem Hash-Key "src_mac"
die MAC-Addresse des
Senders im Hexformat bereit. Dieses enthält noch nicht die typischen
Doppelpunkte nach jedem zweiten Zeichen, sodass Zeile 40 sie mittels
eines regulären Ausdrucks hineinpflanzen muss.
In den Zeilen 48 bis 50 prüft arpcollect
dann anhand der
IP-Addresse, ob das Paket von einem an das lokale Netz
angeschlossenen Gerät stammt. Die IP-Addresse ermittelt es anhand
der Nutzdaten des Ethernetpakets, die es mit der Funktion strip
des Moduls NetPacket::Ethernet
aus dem Datalink-Layer-Paket extrahiert.
Das Ergebnis ist das rohe IP-Paket, das mittels der Funktion decode
des Moduls NetPacket::IP
entpackt wird. Unter dem Hash-Schlüssel
src_ip
findet sich dann die IP-Addresse des Absenders.
Falls ein bitweises 'und' der IP-Adresse mit der Netzwerkadresse wieder die Netzwerkadresse ergibt, wurde das Paket von einem Gerät auf dem lokalen Netzwerk abgesandt und es ist für die Weiterverarbeitung relevant.
Die Methode event_add()
des vorher instantierten
Datenbankobjekts vom Typ WatchLAN
nimmt die IP-Addresse und die
MAC-Adresse entgegen und pumpt sie zur späteren Analyse in die Datenbank.
01 #!/usr/bin/perl -w 02 use strict; 03 use Net::Pcap; 04 use NetPacket::IP; 05 use NetPacket::Ethernet; 06 use Socket; 07 use WatchLAN; 08 09 die "You need to be root to run this.\n" 10 if $> != 0; 11 12 my ( $err, $netaddr, $netmask ); 13 my $dev = Net::Pcap::lookupdev( \$err ); 14 15 Net::Pcap::lookupnet($dev, \$netaddr, 16 \$netmask, \$err) and 17 die "lookupnet $dev failed ($!)"; 18 19 my $object = 20 Net::Pcap::open_live( $dev, 1024, 1, 21 -1, \$err ); 22 23 my $db = WatchLAN->new(); 24 25 Net::Pcap::loop( $object, -1, \&callback, 26 [ $netaddr, $netmask ] ); 27 28 ########################################### 29 sub callback { 30 ########################################### 31 my ($user_data, $hdr, $raw_packet) = @_; 32 33 my ($netaddr, $netmask) = @$user_data; 34 35 my $packet = NetPacket::Ethernet-> 36 decode($raw_packet); 37 38 my $src_mac = $packet->{src_mac}; 39 # Add separating colons 40 $src_mac =~ s/(..)(?!$)/$1:/g; 41 42 my $edata = 43 NetPacket::Ethernet::strip($raw_packet); 44 45 my $ip = NetPacket::IP->decode($edata); 46 47 # Package coming from local network? 48 if ((inet_aton( $ip->{src_ip} ) & 49 pack( 'N', $netmask ) 50 ) eq pack( 'N', $netaddr )) { 51 $db->event_add( $src_mac, 52 $ip->{src_ip} ); 53 } 54 }
Das Modul WatchLAN.pm
implementiert die Speicherungsschicht. Jedes
Paket sofort in der Datenbank abzulegen, wäre nicht effektiv,
da so selbst auf einem nur leicht aktiven Netzwerk mehrere Schreibzugriffe
pro Sekunde fällig würden. Außerdem kosten mehreren Millionen
Tabellenzeilen nicht gerade wenig Plattenplatz und Rechner-Resourcen.
Aus diesem Grund speichert WatchLAN.pm
die ankommenden Paketadressen
zunächst in einem temporären Hash, dessen Inhalt jeweils zur vollen
Minute an die Datenbank übertragen wird. Ein Zähler wird mit jeder
IP/MAC-Kombination um eins erhöht und mit cache_flush
später in der
Datenbanktabelle activity
in der Spalte counter
abgelegt.
Der Parameter flush_interval
im WatchLAN-Konstruktor bestimmt, wie
oft diese Spülung erfolgt. Aus der aktuellen Zeit und
flush_interval
wird der Zeitpunkt des nächsten Spülvorgangs
berechnet und in der Instanzvariablen next_update
abgelegt.
1 DBNAME=watchlan 2 mysqladmin -f -uroot drop $DBNAME 3 mysqladmin -uroot create $DBNAME 4 mysql -uroot $DBNAME <sql.txt
Listing dbinit
zeigt die notwendigen Shell-Befehle, um eine neue
MySQL-Datenbank anzulegen. Die SQL-Befehle aus der Datei sql.txt
sind in Abbildung 2 zu sehen. Das so entstehende Tabellenschema der
Datenbank in Abbildung 3 verlinkt die Haupttabelle activity
über
Foreign Keys mit den Tabellen device
und ip_address
. Sie
speichern MAC-Adressen mitsamt Gerätedaten sowie IP-Adressen.
Stünden die Adressen in der Haupttabelle, würde nicht nur
Speicherplatz verschwendet, sondern auch Datenredundanz erzeugt.
Abbildung 2: Die drei Tabellen des definierten Datenbankschemas. |
Abbildung 3: SQL-Befehle, um die Datenbank anzulegen |
MySQL macht es dem Rose::DB
-Loader nicht gerade leicht, solche
Relationen zu erkennen. Laut Rose::DB
-Autor John Siracusa
ist es notwendig, FOREIG KEY
-Deklarationen
mit korrekten REFERENCES
-Klauseln anzubringen und sowohl die
referenzierenden als auch die referenzierten
Kolumnen mit einem Index zu versehen. Steht die SQL-Definition aber
einmal wie abgebildet, reicht ein Aufruf der Methode make_classes
wie
in Listing WatchLAN.pm
(Zeile 18) und Rose::DB
kontaktiert die
Datenbank
und definiert selbständig den kompletten Objekt-Wrapper auf alle
Tabellen und deren Kolumnen. Praktisch!
01 package WatchLAN; 02 ########################################### 03 use strict; 04 use Apache::DBI; # share a single DB conn 05 use Rose::DB::Object::Loader; 06 use Log::Log4perl qw(:easy); 07 use DateTime; 08 09 my $loader = Rose::DB::Object::Loader->new( 10 db_dsn => 'dbi:mysql:dbname=watchlan', 11 db_username => 'root', 12 db_password => undef, 13 db_options => { 14 AutoCommit => 1, RaiseError => 1 }, 15 class_prefix => 'WatchLAN' 16 ); 17 18 $loader->make_classes(); 19 20 ########################################### 21 sub new { 22 ########################################### 23 my ($class) = @_; 24 25 my $self = { 26 cache => {}, 27 flush_interval => 60, 28 next_update => undef, 29 }; 30 31 bless $self, $class; 32 $self->cache_flush(); 33 34 return $self; 35 } 36 37 ########################################### 38 sub event_add { 39 ########################################### 40 my($self, $mac, $ip)= @_; 41 42 $self->{cache}->{"$mac,$ip"}++; 43 $self->cache_flush() 44 if time() > $self->{next_update}; 45 } 46 47 ########################################### 48 sub cache_flush { 49 ########################################### 50 my ($self) = @_; 51 52 for my $key ( keys %{ $self->{cache} } ){ 53 my ($mac, $ip) = split /,/, $key; 54 my $counter = $self->{cache}->{$key}; 55 56 my $minute = DateTime->from_epoch( 57 epoch => $self->{next_update} - 58 $self->{flush_interval}, 59 time_zone => "local", 60 ); 61 62 my $activity = WatchLAN::Activity->new( 63 minute => $minute); 64 65 $activity->device( 66 { mac_address => $mac } ); 67 $activity->ip_address( 68 { string => $ip } ); 69 $activity->counter($counter); 70 $activity->save(); 71 } 72 73 $self->{cache} = {}; 74 $self->{next_update} = time() - 75 ( time() % $self->{flush_interval} ) + 76 $self->{flush_interval}; 77 } 78 79 ########################################### 80 sub device_add { 81 ########################################### 82 my ( $self, $name, $mac_address ) = @_; 83 84 my $device = WatchLAN::Device->new( 85 mac_address => $mac_address ); 86 $device->load( speculative => 1 ); 87 $device->name($name); 88 $device->save(); 89 } 90 91 1;
Das Modul WatchLAN
ruft den Rose-Loader auf, sobald eine Applikation
es mit use WatchLAN
einbindet. Die aus MySQL gelesenen Tabellen
und ihre Beziehungen legt es als Klassen
im Perl-Namespace unter WatchLAN::
ab.
Die Methode cache_flush()
schreibt den temporären Hash in die
Datenbank. Dank Rose wird hierzu lediglich ein neues Objekt $activity
der Klasse WatchLAN::Activity erzeugt. Es arbeitet mit der
Haupttabelle activity
, greift aber über Methoden auch auf die
referenzierten Tabellen devices
und ip_addresses
zu.
Das unscheinbare Konstrukt
$activity->device({ mac_address => $mac });
erledigt nach einem späteren save()
des Objekts zweierlei:
Falls in der Tabelle devices
noch kein Eintrag eines Geräts mit der
gegebenen MAC-Adresse besteht, legt es dort einen neuen Record an. Und
in die Haupttabelle activity
pflanzt es in das Feld device_id
einen neuen Integerwert, der auf den Eintrag in devices
verweist.
Klever! Einträge in der Tabelle activity
werden hingegen ohne
Angabe eines anonymen Hashes vorgenommen, hierfür stellt Rose
nach der entsprechenden Spalte benannte Methoden bereit. Der Aufruf
$activity->counter($counter);
setzt den Spaltenwert $counter
im aktuell bearbeiteten Record
auf den Wert $counter.
Nach dem save()
löscht cache_flush()
den Cache, berechnet die
nächste Auffrischzeit und kehrt zurück zum Aufrufer. Ähnliches
gilt für die Methode device_add()
, die entweder ein neues
Device mit einer MAC-Adresse einfügt oder den Eintrag eines bestehenden
Gerätes umschreibt. Der Aufruf von
$device->load(speculative => 1);
lädt einen zur vorher im Konstruktor von WatchLAN::Device
angegebenen MAC-Adresse passenden Record aus der Tabelle devices
.
Dies ist möglich, da die Spalte mac_address
beim Anlegen
der Datenbank mit UNIQUE(mac_address)
als eindeutiger Schlüssel
definiert wurde. Rose merkt dies und erlaubt das Laden des Records
aufgrund dieses Kriteriums. Wäre dies nicht der Fall, müsste der
Record mit einem Query gesucht werden. Der Parameter speculative
gibt an, dass es in Ordnung ist, wenn der Record noch nicht existiert.
Ein nachfolgendes save()
legt ihn dann an.
Rose geht relativ verschwenderisch mit Datenbankverbindungen um. Jedes
neue Objekt der Klasse WatchLAN::Activity ruft die Funktion connect
aus dem DBI-Modul auf, und jedesmal, wenn ein solches Objekt ausgedient
hat, löst Rose die Verbindung wieder. Das stellt sicher, dass keine
unerwünschten Nebeneffekte auftreten, wenn man mit Transaktionen arbeitet,
ist im vorliegenden Fall jedoch pure Verschwendung. Das Modul Apache::DBI
sorgt rein durch sein Hinzuladen
hinter den Kulissen dafür, dass genau eine persistente
DB-Verbindung genutzt wird.
Die Tabelle devices
speichert nicht nur MAC-Adressen, sondern ordnet
diesen auch gleich einprägsame Namen zu. Aus 00:11:11:5b:ed:46
wird
so ``Mike's Linux Box'' und gleichzeitig beweist dieser Eintrag, dass
es sich um ein auf dem lokalen Netz geduldetes Gerät handelt.
Hängt sich der Wohnungsnachbar hingegen unerlaubterweise
mit seinem Laptop über Wireless ins LAN und schnorrt wertvolle Bandbreite,
schnappt arpcollect
dies auf, trägt die MAC-Adresse in
die Tabelle devices
ein, lässt
aber das name
-Feld unberührt. So wird das weiter unten
vorgestellte Überwachungsskript arpemail
kurze Zeit später
auf diesen Missstand
aufmerksam und schickt eine Email an den Admin.
Um die MAC-Adressen bereits bekannter Geräte einzutragen, liest das
Skript in Listing namedev
die im DATA-Bereich stehenden Einträge
zeilenweise aus. Sie stehen dort im gleichen Format wie sie
das Original-arpalert
-Skript aus [4] in seiner
Konfigurationsdatei erwartet.
01 #!/usr/bin/perl 02 use strict; 03 use warnings; 04 use WatchLAN; 05 06 my $db = WatchLAN->new(); 07 08 while(<DATA>) { 09 if(/^#\s+(.*)/) { 10 my $name = $1; 11 my $nextline = <DATA>; 12 chomp $nextline; 13 my($mac, $ip, $ip_change) = 14 split ' ', $nextline; 15 $db->device_add($name, $mac); 16 } 17 } 18 19 __DATA__ 20 21 # Slimbox 22 00:04:20:03:00:0d 192.168.0.74 ip_change 23 24 # Laptop Wireless 25 00:16:6f:8d:58:db 192.168.0.75 ip_change 26 27 # Laptop Wired 28 00:15:60:c3:44:10 192.168.0.71 ip_change 29 30 # Mike's Linux Box 31 00:11:11:5b:ed:46 192.168.0.18 32 33 ...
Um festzustellen, ob es in den Datenbanktabellen einen activity
-Eintrag
eines Gerätes gibt, dessen name
-Eintrag in der device
-Tabelle
gleich NULL ist, ist ein JOIN von zwei Tabellen erforderlich. Muss noch
die IP-Adresse des Eintrags her, sind drei Tabellen betroffen.
Rose erledigt dies automatisch hinter den Kulissen.
Das Skript arpemail
benachrichtigt den Sysadmin bei neu
auftauchenden MAC-Adressen. Es nutzt die Klasse
WatchLAN::Activity::Manager
, um eine SQL-Abfrage an die Datenbank
abzuschicken. Die Methode get_activity()
fragt die Tabelle activity
ab und der Parameter with_objects
bestimmt, dass auch die in
den Tabellen device
und ip_address
referenzierten Daten extrahiert
werden. Die betroffenen Tabellen werden von Rose mit t1
(activity
),
t2
(device
), und t3
(ip_address
) durchnumeriert, sodass
sich die Abfrage
query => [ "t2.name" => undef ],
auf die Tabelle device
bezieht und Einträge abruft, deren
name
-Spaltenwert in der Datenbank gleich NULL sind.
Das Ergebnis des Queries ist eine Referenz auf einen Array mit passenden
Datenbankeinträgen. Jeder Eintrag ist ein Objekt vom Typ
WatchLAN::Activity
, das Methoden zum Erfragen seiner Spaltenwerte
(und auch der Werte der referenzierten Tabellen) bereitstellt.
arpemail
merkt sich einmal beanstandete
Geräte in einem dateibasierten Cache der Marke Cache::File
, damit
es nicht immer wieder Meldungen mit denselben Warnungen ausschickt.
Falls schon ein Cache-Eintrag zur MAC-Adresse $mac
existiert liefert
das Konstrukt
!$cache->get($mac) && ($cache->set($mac, 0) || 1);
einen falschen Wert zurück. Falls $mac
noch unbekannt ist, kommt
die nachgeschaltete set
-Methode zum Einsatz, die dem Cache den neuen
Wert unterjubelt, und das Konstrukt liefert einen wahren Wert zurück.
Diese Rückgabewerte macht sich der ab Zeile 18 herumgewickelte
grep
-Befehl zunutze und filtert bereits im Cache enthaltenen
MAC-Adressen aus den in $events
liegenden potentiellen
Bandbreitenschorrern aus. Ist der von $events
referenzierte Array
anschließend
leer, bricht das regelmäßig per Cronjob aufgerufene Programm ab.
Die Formatierung der Warnungsmeldung erfolgt mit dem Template-Toolkit.
Das im DATA
-Bereich am Ende des Skripts liegende Template erhält
die Arrayreferenz $events
als Parameter hereingereicht und iteriert
mit einer FOREACH
-Schleife über die Einträge. Die eigenwillige aber
praktische Syntax des Template-Toolkits erlaubt es,
die Methodenkette $e->ip_address()->string()
mittels
e.ip_address.sting
aufzurufen.
Anschließend verbindet sich arpemail
mittels des CPAN-Moduls
Mail::Mailer
mit dem lokalen Mailsystem und schickt die Nachricht per
Email an den im To
-Feld in Zeile 29 eingetragenen Sysadmin.
01 #!/usr/bin/perl -w 02 use strict; 03 use WatchLAN; 04 use Mail::Mailer; 05 use Cache::File; 06 use Template; 07 my $cache = Cache::File->new( 08 cache_root => "$ENV{HOME}/.arpemail"); 09 10 my $events = WatchLAN::Activity::Manager-> 11 get_activity( 12 with_objects => [ 'device', 13 'ip_address' ], 14 query => [ "t2.name" => undef ], 15 sort_by => ['minute'], 16 ); 17 18 $events = [ grep { 19 my $mac = $_->device()->mac_address(); 20 !$cache->get($mac) && 21 ($cache->set($mac, 0) || 1); 22 } @$events ]; 23 24 exit 0 unless @$events; 25 26 my $mailer = new Mail::Mailer; 27 $mailer->open({ 28 'From' => 'me@_foo.com', 29 'To' => 'oncall@_foo.com', 30 'Subject' => "*** New MAC detected ***", 31 }); 32 33 my $t = Template->new(); 34 $t->process( 35 \*DATA, { events => $events }, $mailer 36 ) or die $t->error(); 37 38 close($mailer); 39 40 __DATA__ 41 [% FOREACH e = events %] 42 When: [% e.minute %] 43 IP: [% e.ip_address.string %] 44 MAC: [% e.device.mac_address %] 45 46 [% END %]
Um festzustellen, welche Geräte sich in den letzten 24 Stunden auf
dem LAN getummelt haben, definiert das Skript lastaccess
mit
dem DateTime
-Modul vom CPAN einen genau 24 Stunden zurückliegenden
Zeitpunkt. Der Rose-Manager feuert dann eine SQL-Abfrage ab, die alle
seit diesem Zeitpunkt aufgetretenen Ereignisse aufsteigend nach der
auf Minuten gerundeten Ereigniszeit minute
sortiert liefert.
Der Hash %latest
speichert dann jeweils nur das letzte Ereignis für
verschiedene MAC-Adressen, indem es die Hashwerte für gleiche
MAC-Adressen wieder und wieder überschreibt. Eigentlich sollte man
solche Kalkulationen besser von der Datenbank erledigen lassen,
Aggregatsfunktionen wie MAX()
liefern mit GROUP BY
genau das
gewünschte Ergebnis. Leider funktioniert dies jedoch noch nicht
mit dem Rose Objekt-Wrapper, aber bei der rasant fortschreitenden
Entwicklung ist dieses Feature wahrscheinlich schon implementiert,
wenn dieser Beitrag erscheint.
In der ab Zeile 31 definierten Funktion time_diff
berechnet das
DateTime
-Modul dann noch die menschenlesbare Zeitdifferenz aus der
gegebenen Sekundendifferenz. Die Textersetzung in Zeile 42 transformiert
die im Plural gegebenen Zeiteinheiten in die Einzahl, falls das Ergebnis
genau eine Einheit ist.
Die Ausgabe von lastaccess
entspricht dann der anfangs in
Abbildung 1 gezeigten.
01 #!/usr/bin/perl -w 02 use strict; 03 use WatchLAN; 04 my $reachback = DateTime 05 ->now( time_zone => "local" ) 06 ->subtract( minutes => 60 * 24 ); 07 08 my $events = WatchLAN::Activity::Manager-> 09 get_activity( 10 query => [ minute => 11 { gt => $reachback }, 12 ], 13 sort_by => ['minute'], 14 ); 15 16 my %latest = (); 17 18 for my $event (@$events) { 19 $latest{$event->device_id()} = $event; 20 } 21 22 for my $id (keys %latest) { 23 my $event = $latest{$id}; 24 my $name = $event->device()->name(); 25 $name ||= "unknown (id=$id)"; 26 printf "%23s: %s ago\n", $name, 27 time_diff($event->minute()); 28 } 29 30 ########################################### 31 sub time_diff { 32 ########################################### 33 my ($dt) = @_; 34 35 my $duration = DateTime->now( 36 time_zone => "local" 37 ) - $dt; 38 39 for (qw(hours minutes seconds)) { 40 if(my $n = $duration->in_units($_)) { 41 my $unit = $_; 42 $unit =~ s/s$// if $n == 1; 43 return "$n $unit"; 44 } 45 } 46 }
Wer das Skript arpemail
noch erweitern möchte, kann,
wie das auch arpalert
([4]) implementiert, für bestimmte
Geräte noch eine statische IP in die device
-Tabelle setzen und Alarm
schlagen, falls ein unter statischen IP laufendes Gerät plötzlich unter
einer anderen IP daherkommt. Wie immer sind der Entwicklerfreude keine
Grenzen gesetzt, wenn erst einmal ein Framework steht und man die Daten
ohne großen Aufwand aus einer Datenbank abpumpen kann.
arpalert
-Skript: http://arpalert.org
Michael Schilliarbeitet 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. |