Aus Linux-Magazin 08/2024

Kommandozeilen-Tool protokolliert heimisches WLAN

© fotokostic / 123RF.com

Um zu sehen, welche Clients im WLAN ein- und ausgehen, schreibt Mike Schilli eine Kommandozeilen-Utility, die Messwerte über eine ORM-Schnittstelle in SQLite ablegt und deren Historie anzeigt.

“Was ich nicht weiß, macht mich nicht heiß”, sagt der Volksmund, aber für mein WLAN gilt der umgekehrte Fall: Was treiben all meine Haushalts-Gadgets, von denen keines mehr ohne WLAN-Anbindung 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 es ankam und ob es dauernd aktiv ist oder ob es hie und da seine zugewiesen IP-Adresse verfallen lässt und sich später wieder eine neue holt. Bauen wir uns einen Vorratsdatenspeicher in Go, um das herauszufinden.

Abbildung 1: Nmap-Kommando zum Scannen des Subnets.

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 läutet bei allen potenziell 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« nebenbei Details wie die MAC-Adresse der im WLAN gefundenen Geräte erfährt, muss es unter »root« laufen. Das ist in zweierlei Hinsicht lästig: Erstens muss der User ein darum herum gewickeltes 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 ist oder nicht doch ein Schlupfloch für Angreifer bietet.

Listing 1

wifiscan.go

package main
import (
  "fmt"
  "os/exec"
)
const subnet = "192.168.0.0/24"
func main() {
  cmd := exec.Command("/usr/local/bin/nmap", "-sn", subnet)
  output, err := cmd.Output()
  if err != nil {
    panic(err)
  }
  fmt.Println(string(output))
}

Listing 1 wählt deshalb den defensiven Ansatz, das Kommando »nmap« in ein simples Go-Binary zu verpacken (Zeile 8), das nichts anderes tut, als den Netzwerkscanner aufzurufen. Das fertig gepackte Binary schanzt das Shell-Kommandos in Listing 2 dem User »root« zu und setzt das s-Bit mit »chmod u+s« (Zeile 4). Damit darf es jeder beliebige User ohne »sudo« aufrufen, und es läuft trotzdem als »root«. Weil der nötige Code überschaubar bleibt, lässt sich dieser Ansatz sicherheitstechnisch vertreten.

Listing 2

wifiscan.build

$ go build wifiscan.go
$ sudo chown root wifiscan
Password:
$ sudo chmod u+s wifiscan
$ ls -l wifiscan
-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 jegliche Port-Anfragen abblocken. Damit tappt »nmap« im Dunkeln. In dem Fall hilft es, den DHCP-Server des WLANs anzuzapfen, der weiß, welche IPs er welchen Geräten mit ihren MAC-Adressen zugewiesen hat. Wer zusätzlich inoffiziell agierende Geräte erwischen möchte, die sich einfach eine IP schnappen, darf versuchen, sie mit einem Paketscanner wie Wireshark an den Haken zu bekommen.

Nuggets auswaschen

Die Funktion »nmap()« ab Zeile 10 in Listing 3 ruft das externe eben kompilierte Programm »wifiscan« auf und zapft dessen Ausgabe mit einer Pipe an, die sie an ihren Aufrufer zurückreicht. In »parse()« ab Zeile 24 geht es daran, die von »nmap« zeilenweise ausgespuckten Ergebnisse zu lesen und sie pro gefundenem Host in die einzelnen Messwerte repräsentierenden Strukturen zu pressen. Eine Struktur vom Typ »Probe« soll einen Zeitstempel führen, die IP-Adresse des gefundenen Geräts, dessen MAC-Adresse und den von »nmap« übermittelten 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 liefert »nmap« in aufeinanderfolgenden Zeilen. Der simple Zustandsautomat prüft in Zeile 44, ob 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

package main
import (
  "bufio"
  "fmt"
  "io"
  "os/exec"
  "regexp"
  "time"
)
func nmap(subnet string) (io.ReadCloser, error) {
  fmt.Printf("Running nmap in %s\n", subnet)
  cmd := exec.Command("./wifiscan")
  stdoutPipe, err := cmd.StdoutPipe()
  if err != nil {
    return nil, err
  }
  err = cmd.Start()
  if err != nil {
    stdoutPipe.Close()
    return nil, err
  }
  return stdoutPipe, nil
}
func parse(f io.ReadCloser, t time.Time, outCh chan<- Probe) error {
  probe := Probe{Device: Device{}}
  defer f.Close()
  scan := bufio.NewScanner(f)
  for scan.Scan() {
    line := scan.Text()
    ipRegex := regexp.MustCompile(`Nmap scan report for ([\d\.]+)`)
    macRegex := regexp.MustCompile(`MAC Address: ([\w:]+) \((.*?)\)`)
    if matches := ipRegex.FindStringSubmatch(line); matches != nil {
      if probe.IP != "" {
        probe = Probe{Device: Device{}}
      }
      probe.IP = matches[1]
    } else if matches := macRegex.FindStringSubmatch(line); matches != nil {
      probe.Device.MAC = matches[1]
      probe.Device.Product = matches[2]
      if !t.IsZero() {
        probe.Timestamp = t
      }
    }
    if probe.IP != "" && probe.Device.MAC != "" {
      outCh <- probe
    }
  }
  return scan.Err()
}

Datenbank objektorientiert

Als Schema für die Datenbank mit den Messwerten bieten sich zwei Tabellen in SQLite an. Die erste davon, »probes« (Zeile 48ff), führt die IP-Adressen mit Zeitstempel 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: F&uuml;r die Datenbank mit den Messwerten kommen als Schema zwei Tabellen in SQLite zum Einsatz.

Abbildung 2: Für die Datenbank mit den Messwerten kommen als Schema zwei Tabellen in SQLite zum Einsatz.

Jetzt 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 Messwert sowohl Mess- als auch Device-Daten vor. Nun fristen in anderen Sprachen sogenannte Object-Relational-Mappers (ORM) ihr Dasein, die Datenstrukturen mehr oder weniger elegant in relationale Datenbankmodelle überführen. Dafür fuhrwerken sie unter der Haube mit den notwendigen SQL-Kommandos herum, ohne dass der User sich darum kümmern müsste. Was bietet Go in dieser Hinsicht?

Listing 4

gorm.go

package main
import (
  "gorm.io/driver/sqlite"
  "gorm.io/gorm"
  "time"
)
type Device struct {
  ID      uint   `gorm:"primaryKey"`
  MAC     string `gorm:"uniqueIndex"`
  Product string
}
type Probe struct {
  ID        uint `gorm:"primaryKey"`
  Timestamp time.Time
  IP        string
  DeviceID  uint
  Device    Device `gorm:"foreignKey:DeviceID"`
}
type DB struct {
  DB *gorm.DB
}
func NewDB() (*DB, error) {
  db, err := gorm.Open(sqlite.Open("wifiwatch.db"), &gorm.Config{})
  if err != nil {
    return nil, err
  }
  err = db.AutoMigrate(&Device{}, &Probe{})
  if err != nil {
    return nil, err
  }
  return &DB{DB: db}, nil
}
func (db *DB) Add(ip, mac, product string, timestamp time.Time) error {
  var device Device
  res := db.DB.Where(&Device{MAC: mac}).
    Attrs(Device{Product: product}).
    FirstOrCreate(&device)
  if res.Error != nil {
    return res.Error
  }
  probe := Probe{
    Timestamp: timestamp,
    IP:        ip,
    DeviceID:  device.ID,
  }
  return db.DB.Create(&probe).Error
}
func (db *DB) Probes() ([]Probe, error) {
  subquery := db.DB.Table("probes").
    Select("min(rowid), *").
    Group("IP, device_id")
  var probes []Probe
  err := db.DB.Preload("Device").
    Table("(?) AS sub_probes", subquery).
    Find(&probes).Error
  if err != nil {
    return nil, err
  }
  return probes, nil
}

Die Library »gorm« bedient 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 Zeile 8 mit »`gorm:”primaryKey”`« an, dass die Spalte »id« in der Tabelle »devices« (automatisch abgeleitet aus der kleingeschriebenen Mehrzahl des Strukturnamens »Device«) als Primärschlüssel fungiert. Die SQLite-Engine macht daraus später einen mit jeder eingefügten neuen Zeile automatisch hochgezählten Integerwert.

Eindeutig schnell

Das Tag »uniqueIndex« des Felds »MAC« in der Struktur »Device« in Zeile 9 definiert, dass die Tabellenspalte »mac« später eindeutige Werte enthält. Das beschleunigt die Suche nach eventuell bereits registrierten Geräten. Daraufhin 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 läuft wie in vergleichbaren ORMs automatisch ab.

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

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

Allein diese Definitionen versetzen den ORM in die Lage, mit »AutoMigrate()« in Zeile 27 die notwendigen Datenbanktabellen zu erzeugen und später elegante objektorientierte CRUD-Funktionen (Create/Read/Update/Delete) auszuführen. Der Befehl ».schema« in der SQLite-Shell in Abbildung 3 zeigt, dass die 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 flott, ganz ohne SQL.

Das Paket »gorm« schreckt selbst vor komplizierteren Abfragen 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ähliger Baumzweige von Messpunkten, die zwar in der Datenbank liegen, aber irrelevant sind, da die IP-Adresse exakt wie beim ersten Mal vorlag.

Das 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 hat mit »Preload(“Device”)« schon vorab einen »Join« der Tabellen »probes« und »devices« ausgeführt. Damit muss »gorm« lediglich noch mit »Find(&probes)« die Ergebnisse aus der Verknüpfung beider Tabellen aufsammeln. Das reinste Hexenwerk!

Baum im Terminal

Ausgehend von den in der Datenbank abgelegten Messwerten zeigt die Funktion »tree()« in Listing 5 ab Zeile 7 die historischen Aktivitäten auf dem WLAN in Baumform im Terminal an (Abbildung 4). Zu sehen sind der Google-Router auf Platz eins, eine intelligente Fernbedienung (Logitech) auf Platz zwei, eine Überwachungskamera (Smart Innovation) auf Platz drei sowie das unlängst vorgestellte Ulanzi-Display [1] auf Platz vier. Letzteres hat seine IP offensichtlich am “29.5.2024” von “*.22” auf “*.23” aufgefrischt.

Abbildung 4: Die Funktion &raquo;tree()&laquo; liefert historische IP-Adressen von Ger&auml;ten auf dem WLAN.

Abbildung 4: Die Funktion »tree()« liefert 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äteadresse. Ä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 gleich, und die neue IP landet in Weiß unter dem bestehenden Ast.

Listing 5

tree.go

package main
import (
  "fmt"
  "github.com/gdamore/tcell/v2"
  "github.com/rivo/tview"
)
func tree() {
  db, err := NewDB()
  if err != nil {
    panic(err)
  }
  root := tview.NewTreeNode("Wifiwatch v1.0").SetColor(tcell.ColorRed)
  tree := tview.NewTreeView().SetRoot(root).SetCurrentNode(root)
  oldMAC := ""
  var node *tview.TreeNode
  probes, err := db.Probes()
  if err != nil {
    panic(err)
  }
  for _, p := range probes {
    if node == nil || oldMAC != p.Device.MAC {
      node = tview.NewTreeNode(fmt.Sprintf("%s %s", p.Device.Product, p.Device.MAC))
      root.AddChild(node)
      node.SetColor(tcell.ColorGreen)
    }
    n := tview.NewTreeNode(fmt.Sprintf("%s %s", p.IP, p.Timestamp))
    node.AddChild(n)
    oldMAC = p.Device.MAC
  }
  err = tview.NewApplication().SetRoot(tree, true).Run()
  if err != nil {
    panic(err)
  }
}

Zeile 30 wirft die UI der Applikation an, und »Run()« führt diese aus, bis der User [Strg]+[C] drückt. Das Hauptprogramm in Listing 6 prüft nach dem Start anhand der Kommandozeilenparameter, ob der Anwender mit »–update« ein Update der Daten aus einem »nmap«-Lauf wünscht. Ist das der Fall, ruft es die Funktion »updater()« ab Zeile 15 auf. Diese startet die Funktion »nmap()«, die den Nmap-Wrapper »wifiscan« losschickt 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 formt »Probe«-Objekte daraus und schickt sie in den ihm überreichten Ausgabe-Channel »ch«. 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. Ruft er »wifiwatch« hingegen ohne Parameter auf, 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.

Listing 6

wifiwatch.go

@LI:package main
import (
  "flag"
  "time"
)
func main() {
  update := flag.Bool("update", false, "update db")
  flag.Parse()
  if *update {
    updater()
    return
  }
  tree()
}
func updater() {
  db, err := NewDB()
  if err != nil {
    panic(err)
  }
  f, err := nmap("192.168.0.0/24")
  if err != nil {
    panic(err)
  }
  ch := make(chan Probe)
  go func() {
    err = parse(f, time.Now(), ch)
    if err != nil {
      panic(err)
    }
    close(ch)
  }()
  for probe := range ch {
    db.Add(probe.IP, probe.Device.MAC, probe.Device.Product, probe.Timestamp)
  }
}

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

Listing 7

wifiwatch.build

$ go mod init wifiwatch
$ go mod tidy
$ 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 an 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 [Strg]+[C] beendet das Programm. Ein beruhigendes Gefühl zu wissen, was los ist. (uba)

Infos

  1. Snapshot: Mike Schilli, “Projekt Blinklicht”, LM 02/2024, S. 86, https://www.lm-online.de/48806
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben