"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).
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.
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.
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.
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.
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 }
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?
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.
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.
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!
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.
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!
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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/08/snapshot/
Michael Schilli, "Projekt Blinklicht": Linux-Magazin 02/2024, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2024/02/snapshot/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc