Vorratsdaten speichern (Linux-Magazin, August 2024)

"Was ich nicht weiß, macht mich nicht heiß" sagt der Volksmund, aber auf meinen WLAN ist es genau umgekehrt: Was treiben all meine Haushalts-Gadgets, von denen keines mehr ohne Wifi-Anschluss auszukommen scheint, und welche Geräte tummeln sich dort, von denen ich gar nichts weiß? Das macht mich heiß und raubt mir den Schlaf.

Von Natur aus neugierig, interessiert mich aber nicht nur die aktuelle Lage. Vielmehr wäre es gut, zu wissen, seit wann ein einmal entdecktes Gerät schon auf dem Netz in Betrieb ist, wann kam es an, und ist es dauernd aktiv oder lässt es auch hie und da seine zugewiesen IP-Adresse verfallen und holt sich später wieder eine neue? Bauen wir uns eine Vorratsdatenspeicherung in Go, um das herauszufinden!

Abbildung 1: Nmap-Kommando zum Scannen des Subnets.

Um aktive Geräte auf dem WLAN aufzustöbern, lohnt sich der Aufruf des Scanners nmap. Das Hackertool liegt jeder guten Linux-Distro bei und klingelt bei allen potentiell nutzbaren IP-Adressen in einem Subnet an, um zu sehen, ob dort ein Host antwortet. Beim typischen Subnet 192.168.0.0/24 eines Heim-Routers sind 255 IP-Adressen nutzbar, und nmap klappert sie mit seinen Fühlern in Windeseile ab (Abbildung 1).

Simpel ist sicherer

Damit nmap auch Details wie die MAC-Adresse von auf dem WLAN gefundenen Geräten erfährt, muss es unter root laufen. Das ist natürlich in zweierlei Hinsicht lästig, denn erstens muss der User ein darumgewickeltes Programm mit sudo aufrufen, was zumindest beim ersten Mal in einer Shell-Session die Eingabe des root-Passworts erfordert. Zweitens öffnen sich so Angriffspfade, denn wer weiß schon, ob ein komplexes Go-Programm mit allerlei Funktionalität zu hundert Prozent wasserdicht programmiert wurde und nicht doch ein Schlupfloch für Angreifer bietet.

Listing 1: wifiscan.go

    01 package main
    02 import (
    03   "fmt"
    04   "os/exec"
    05 )
    06 const subnet = "192.168.0.0/24"
    07 func main() {
    08   cmd := exec.Command("/usr/local/bin/nmap", "-sn", subnet)
    09   output, err := cmd.Output()
    10   if err != nil {
    11     panic(err)
    12   }
    13   fmt.Println(string(output))
    14 }

Listing 1 wählt deshalb den defensiven Ansatz, das nmap-Kommando in ein simples Go-Binary zu verpacken, das nichts anderes macht, als den Netzwerkscanner aufzurufen. Das fertig gepackte Binary wird dann mit den Shell-Kommandos in Listing 2 dem User root zugeschanzt und das s-Bit mit chmod u+s gesetzt, sodass jeder beliebige User es ohne sudo aufrufen darf und es trotzdem als root läuft. Weil der Code überschaubar ist, lässt sich dieser Ansatz sicherheitstechnisch vertreten.

Listing 2: wifiscan.build

    1 $ go build wifiscan.go
    2 $ sudo chown root wifiscan
    3 Password:
    4 $ sudo chmod u+s wifiscan
    5 $ ls -l wifiscan
    6 -rwsr-xr-x  1 root  staff  2356144 May 27 11:40 wifiscan

Manche Geräte verstecken sich übrigens aktiv vor Scannern wie nmap, indem sie jedwege Port-Anfragen abblocken, und nmap somit im Dunkeln tappen lassen. In diesem Fall hilft es, den DHCP-Server des WLANs anzuzapfen, der weiß, welche IPs er welchen Geräten mit ihren MAC-Adressen zugewiesen hat. Wer auch noch inoffiziell agierende Geräte erwischen möchte, die sich einfach eine IP schnappen, kann versuchen, sie mit einem Paketscanner wie Wireshark zu erwischen.

Nuggets auswaschen

Die Funktion nmap() ab Zeile 10 in Listing 3 ruft nun das externe eben kompilierte wifiscan-Programm auf und zapft dessen Ausgabe mit einer Pipe an, die sie an ihren Aufrufer zurückreicht.

In parse() ab Zeile 24 geht es nun daran, die von nmap zeilenweise ausgespuckten Ergebnisse zu lesen, sie pro gefundenem Host in Strukturen zu pressen, die die einzelnen Messwerte repräsentieren. Eine Struktur vom Typ Probe soll einen Zeitstempel führen, die IP-Adresse des gefundenen Geräts, dessen MAC-Adresse und den ebenfalls von nmap berichteten Namen des Herstellers.

Die for-Schleife ab Zeile 28 rattert durch die Ausgabe, legt den Inhalt der aktuellen Ausgabezeile in line ab und versucht sie mit zweierlei regulären Ausdrücken in Einklang zu bringen: ipRegex für die Octets von IPv4-Adressen und macRegex für die Hex-Werte von MAC-Adressen. Beide berichtet nmap in aufeinanderfolgenden Zeilen, aber der simple Zustandsautomat prüft in Zeile 44, ob inzwischen beide Werte eingetrudelt sind, füllt die Struktur vom Typ Probe (später definiert in Listing 4) und schickt sie hoch in den Ausgabe-Channel outCh, wo der Aufrufer die eintrudelnden Objekte aufschnappt und weiterverarbeitet.

Listing 3: parse.go

    01 package main
    02 import (
    03   "bufio"
    04   "fmt"
    05   "io"
    06   "os/exec"
    07   "regexp"
    08   "time"
    09 )
    10 func nmap() (io.ReadCloser, error) {
    11   fmt.Printf("Running nmap\n")
    12   cmd := exec.Command("./wifiscan")
    13   stdoutPipe, err := cmd.StdoutPipe()
    14   if err != nil {
    15     return nil, err
    16   }
    17   err = cmd.Start()
    18   if err != nil {
    19     stdoutPipe.Close()
    20     return nil, err
    21   }
    22   return stdoutPipe, nil
    23 }
    24 func parse(f io.ReadCloser, t time.Time, outCh chan<- Probe) error {
    25   probe := Probe{Device: Device{}}
    26   defer f.Close()
    27   scan := bufio.NewScanner(f)
    28   for scan.Scan() {
    29     line := scan.Text()
    30     ipRegex := regexp.MustCompile(`Nmap scan report for ([\d\.]+)`)
    31     macRegex := regexp.MustCompile(`MAC Address: ([\w:]+) \((.*?)\)`)
    32     if matches := ipRegex.FindStringSubmatch(line); matches != nil {
    33       if probe.IP != "" {
    34         probe = Probe{Device: Device{}}
    35       }
    36       probe.IP = matches[1]
    37     } else if matches := macRegex.FindStringSubmatch(line); matches != nil {
    38       probe.Device.MAC = matches[1]
    39       probe.Device.Product = matches[2]
    40       if !t.IsZero() {
    41         probe.Timestamp = t
    42       }
    43     }
    44     if probe.IP != "" && probe.Device.MAC != "" {
    45       outCh <- probe
    46     }
    47   }
    48   return scan.Err() 
    49 }

Datenbank objektorientiert

Als Schema für die Datenbank mit den Messwerte bieten sich zwei Tabellen in SQLite an, von denen die erste probes die IP-Addressen mit Zeitstempel führt, sowie eine Referenz auf einen Eintrag in der Gerätetabelle devices, wo die MAC-Adressen und Produktnamen der WLAN-Nutzer liegen (Abbildung 2). So muss probes diese immer wiederkehrenden Daten nicht in der Haupttabelle über und über duplizieren.

Abbildung 2: Schema der SQLite-Datenbank für die Messwerte

Nun wäre es nicht weiter schwierig, das Schema mit SQL-Kommandos zusammenzuklopfen und neue Einträge einzufügen beziehungsweise bestehende abzufragen. Ein Join beider Tabellen macht aus zweien eine und im Ergebnis lägen pro Messwerte sowohl Mess- als auch Device-Daten vor. Nun fristen in anderen Sprachen aber auch sogenannte ORMs (Object-Relational-Mappers) ihr Dasein, die Datenstrukturen mehr oder weniger elegant in relationale Datenbankmodelle überführen und die dafür unter der Haube mit den dafür notwendigen SQL-Kommandos herumfuhrwerken ohne dass der User sich darum kümmern müsste. Was bietet Go in dieser Hinsicht?

Listing 4: gorm.go

    01 package main
    02 import (
    03   "gorm.io/driver/sqlite"
    04   "gorm.io/gorm"
    05   "time"
    06 )
    07 type Device struct {
    08   ID      uint   `gorm:"primaryKey"`
    09   MAC     string `gorm:"uniqueIndex"`
    10   Product string
    11 }
    12 type Probe struct {
    13   ID        uint `gorm:"primaryKey"`
    14   Timestamp time.Time
    15   IP        string
    16   DeviceID  uint
    17   Device    Device `gorm:"foreignKey:DeviceID"`
    18 }
    19 type DB struct {
    20   DB *gorm.DB
    21 }
    22 func NewDB() (*DB, error) {
    23   db, err := gorm.Open(sqlite.Open("wifiwatch.db"), &gorm.Config{})
    24   if err != nil {
    25     return nil, err
    26   }
    27   err = db.AutoMigrate(&Device{}, &Probe{})
    28   if err != nil {
    29     return nil, err
    30   }
    31   return &DB{DB: db}, nil
    32 }
    33 func (db *DB) Add(ip, mac, product string, timestamp time.Time) error {
    34   var device Device
    35   res := db.DB.Where(&Device{MAC: mac}).
    36     Attrs(Device{Product: product}).
    37     FirstOrCreate(&device)
    38   if res.Error != nil {
    39     return res.Error
    40   }
    41   probe := Probe{
    42     Timestamp: timestamp,
    43     IP:        ip,
    44     DeviceID:  device.ID,
    45   }
    46   return db.DB.Create(&probe).Error
    47 }
    48 func (db *DB) Probes() ([]Probe, error) {
    49   subquery := db.DB.Table("probes").
    50     Select("min(rowid), *").
    51     Group("IP, device_id")
    52   var probes []Probe
    53   err := db.DB.Preload("Device").
    54     Table("(?) AS sub_probes", subquery).
    55     Find(&probes).Error
    56   if err != nil {
    57     return nil, err
    58   }
    59   return probes, nil
    60 }

Die Library gorm schnappt sich dazu Go-Strukturen wie Device und Probe in Listing 4 und untersucht deren Tags nach Hinweisen darauf, wie die einzelnen Felder später in der Datenbank aussehen sollen. So zeigt das numerische Feld ID in in Zeile 8 mit `gorm:"primaryKey"` an, dass die Spalte id später in der Tabelle devices (automatisch abgeleitet aus der kleingeschriebenen Mehrzahl des Strukturnamens Device) als Primärschlüssel fungiert. Der SQLite-Engine macht daraus später einen mit jeder eingefügten neuen Zeile automatisch hochgezählten Integerwert.

Schnell weil eindeutig

Das Tag uniqueIndex des Feldes MAC in der Struktur Device in Zeile 9 bestimmt, dass die Tabellenspalte mac später eindeutige Werte enthalten wird. Das beschleunigt später die Suche nach eventuell bereits registrierten Geräten. Nun braucht es zwei Hinweise, damit gorm die beiden Tabellen probes und devices miteinander verknüpft, sodass SQLite später mittels eines Fremdschlüssels (Foreign Key) von einem Messwert in probes auf ein Gerät in devices zeigt. Erstens bekommt die Struktur Probe ab Zeile 17 einen Eintrag vom Typ Device spendiert, und zweitens bestimmt das Tag foreignKey:DeviceID dass der Fremdschlüssel in probes in der Spalte device_id zu finden ist. Die automatische Umwandlung von Groß- auf Kleinbuchstaben und Camel-Case zu Unterstrich findet wie in vergleichbaren ORMs automatisch statt.

Abbildung 3: Die Messwerte landen in einer SQLite-Datenbank.

Allein mit diesen Definitionen ist nun das ORM in der Lage, mit AutoMigrate() in Zeile 27 die notwendigen Datenbanktabellen zu erzeugen und später elegante objektorientierte CRUD-(Create/Read/Update/Delete)-Funktionen auszuführen. Der Befehl .schema in der SQLite-Shell in Abbildung 3 zeigt, dass der SQLite-Engine nach einem Programmlauf nun tatsächlich die entsprechenden Tabellen angelegt hat.

Objekte zu Tabellen

Neue Messwerte fügt die Funktion Add() ab Zeile 33 in Listing 4 in die Datenbank wifiwatch.db ein. Sie nimmt die IP-Adresse eines gefundenen Geräts, dessen MAC und Produktnamen, sowie einen Zeitstempel entgegen. Dann sucht Zeile 35 mit Where(), ob schon ein Geräteeintrag in der Tabelle devices für das Gerät existiert und gibt mit FirstOrCreate() einen bereits gefundenen zurück oder legt einen neuen an. Gewappnet mit dem Device-Eintrag legt Zeile 41 eine neue Struktur vom Typ Probe an und pumpt diese mit Create() in Zeile 46 in die Datenbank. Das geht ratz-fatz, ganz ohne SQL!

Das gorm-Paket schreckt selbst vor komplizierteren Queries nicht zurück. Die Funktion Probes() ab Zeile 48 soll alle Messwerte liefern, bei denen sich für eine MAC-Adresse die IP geändert hat. So spart sich die Anzeige später die Behandlung unzählige Baumzweige von Messpunkten, die zwar in der Datenbank liegen aber irrelevant sind, da die IP-Adresse exakt wie beim ersten Mal vorlag.

Dies in traditionelles SQL zu verpacken erfordert einen Subquery ab Zeile 49, der mit min(rowid) jeweils nur den ersten Treffer einer Gruppe von IPs zu einem Device-Eintrag liefert. Diesen Subquery wiederum importiert Zeile 54 unter der virtuellen Tabelle sub_probes in den Hauptquery, der mit Preload("Device") schon vorab einen Join der Tabellen probes und devices ausgeführt hat und gorm damit nur noch mit Find(&probes) die Ergebnisse aus der Verknüpfung beider Tabellen aufsammeln muss. Das reinste Hexenwerk!

Baum im Terminal

Ausgehend von den in der Datenbank abgelegten Messwerten kann nun die Funktion tree() in Listing 5 die historischen Aktivitäten auf dem WLAN in Baumform im Terminal anzeigen (Abbildung 4). Zu sehen sind der Google-Router auf Platz 1, eine intelligente Fernbedienung (Logitech) auf Platz 2, eine Überwachungskamera (Smart Innovation) auf Platz 3 sowie das letztens vorgestellte Ulanzi-Display auf Platz 4, das seine IP offensichtlich am 29.5.2024 von *.22 auf *.23 aufgefrischt hat.

Abbildung 4: Historische IP-Adressen von Geräten auf dem WLAN

Den Baum zeichnet die Library tview, die auf Github liegt. Die Darstellung erledigt ein Objekt vom Typ TreeView, der Baum selbst besteht aus Knoten vom Typ TreeNode. Die for-Schleife ab Zeile 20 iteriert sortiert über alle gefilterten Messwerte aus der Datenbank, und merkt sich jeweils in oldMac die letzte verarbeitete Gerätadresse. Ändert sich diese im folgenden Schleifendurchgang, legt Zeile 22 mit NewTreeNode() einen neuen grünen Gerätezweig im Baum an. Handelt es sich hingegen immer noch um das im letzten Durchgang gezeigte Gerät, das auf einer neuen IP-Adresse liegt, bleibt der Ast der Gleiche und die neue IP wird in weiß unter dem bestehenden Ast eingehängt.

Listing 5: tree.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/gdamore/tcell/v2"
    05   "github.com/rivo/tview"
    06 )
    07 func tree() {
    08   db, err := NewDB()
    09   if err != nil {
    10     panic(err)
    11   }
    12   root := tview.NewTreeNode("Wifiwatch v1.0").SetColor(tcell.ColorRed)
    13   tree := tview.NewTreeView().SetRoot(root).SetCurrentNode(root)
    14   oldMAC := ""
    15   var node *tview.TreeNode
    16   probes, err := db.Probes()
    17   if err != nil {
    18     panic(err)
    19   }
    20   for _, p := range probes {
    21     if node == nil || oldMAC != p.Device.MAC {
    22       node = tview.NewTreeNode(fmt.Sprintf("%s %s", p.Device.Product, p.Device.MAC))
    23       root.AddChild(node)
    24       node.SetColor(tcell.ColorGreen)
    25     }
    26     n := tview.NewTreeNode(fmt.Sprintf("%s %s", p.IP, p.Timestamp))
    27     node.AddChild(n)
    28     oldMAC = p.Device.MAC
    29   }
    30   err = tview.NewApplication().SetRoot(tree, true).Run()
    31   if err != nil {
    32     panic(err)
    33   }
    34 }

Zeile 30 wirft die UI der Applikation an und Run() führt diese aus bis der User Ctrl-C drückt. Das Hauptprogramm in Listing 6 prüft nach dem Start anhand der Kommandozeilenparameter, ober der User mit --update einen Update der Daten aus einem nmap-Lauf wünscht, und ruft in diesem Fall die Funktion updater() ab Zeile 15 auf. Diese wiederum ruft die Funktion nmap() auf, die den den Nmap-Wrapper wifiscan los schickt, und in f eine Pipe mit den davon eintrudelnden Datenzeilen zurückgibt.

Diese Pipe gibt Zeile 26 in einer parallel laufenden Go-Routine dem Parser parse() aus Listing 3 mit, der Probe-Objekte daraus formt und sie in den ihm überreichten Ausgabe-Channel ch schickt. Das Hauptprogramm liest sie ab Zeile 32 in einer for-Schleife aus dem Channel aus. Zeile 33 fügt jeden so erhaltenen Messwert mit Add() in die Datenbank ein.

Wird wifiwatch hingegen ohne Parameter aufgerufen, wünscht der User den Baum mit den eingetüteten Ergebnissen zu sehen und tree() in Zeile 13 wirft die Terminal-UI aus Listing 5 an. Fertig ist der Lack!

Listing 6: wifiwatch.go

    01 package main
    02 import (
    03   "flag"
    04   "time"
    05   "fmt"
    06 )
    07 func main() {
    08   update := flag.Bool("update", false, "update db")
    09   flag.Parse()
    10   if *update {
    11     updater()
    12     return
    13   }
    14   tree()
    15 }
    16 func updater() {
    17   db, err := NewDB()
    18   if err != nil {
    19     panic(err)
    20   }
    21   f, err := nmap()
    22   if err != nil {
    23     panic(err)
    24   }
    25   ch := make(chan Probe)
    26   go func() {
    27     err = parse(f, time.Now(), ch)
    28     if err != nil {
    29       panic(err)
    30     }
    31     close(ch)
    32   }()
    33   for probe := range ch {
    34     fmt.Printf("Updating %s\n", probe.IP)
    35     db.Add(probe.IP, probe.Device.MAC, probe.Device.Product, probe.Timestamp)
    36   }
    37 }

Mit dem üblichen Dreisprung in Listing 7 baut der Go-Compiler alle Sourcen dieser Ausgabe zu einem Binary wifiwatch zusammen, nachdem er alle 3rd-Party-Libraries von Github abgeholt und vorkompiliert hat.

Listing 7: wifiwatch.build

    1 $ go mod init wifiwatch
    2 $ go mod tidy
    3 $ go build wifiwatch.go gorm.go parse.go tree.go

Mit dem Flag --update aufgerufen, wirft wifiwatch das vorher kompilierte wifiscan mit seinem Setuid-Bit auf und führt den Scan des Netzwerks als root durch. Die Ergebnisse flitzen in die Datenbank wifiwatch.db. Spätere Aufrufe von wifiwatch lesen die Datenbankwerte und zeigen sie im Terminal an. Die Tastenkombination Ctrl-C beendet das Programm. Ein beruhigendes Gefühl, zu wissen, was los ist.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/08/snapshot/

[2]

Michael Schilli, "Projekt Blinklicht": Linux-Magazin 02/2024, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2024/02/snapshot/<U>

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc