Wer sich am Rechner mit dem WLAN verbinden möchte, sieht in der Auswahlliste auch die Kennungen umliegender Wohnungen und Häuser. Die gewählten Namen offenbaren interessante Fakten: Haben die Nachbarn Humor? Schon wieder einen neuen Router? Es lohnt sich also, auf dem Laufenden zu bleiben, am besten mit einem Scanner, der alle fünf Minuten die Namen aller erreichbaren WLAN-Accesspoints einholt, sie mit einem Zeitstempel in einer SQLite-Datenbank speichert, und bei festgestellten Änderungen eine Text-Nachricht aufs Handy schickt.
Das fertige Binary der Sourcen dieser Ausgabe heißt wifiscan
, weil man in Amerika "Wifi" zum "WLAN" sagt. Ein weiterer lustiger Unterschied zwischen den beiden Kulturen ist übrigens die Aussprache des Wortes "Router". In Deutschland sagt man "Rooter", was wohl der Nähe zur englischen Insel geschuldet ist. In Amerika wäre dies eine humorvolle Anspielung auf die Firma "Roto-Rooter", die verstopfte Abflüsse reinigt. Den Router, der das Wifi aufbaut, spricht man hier "Rauter" aus. Kostenlos Amerikanisch lernen mit dem Programmier-Snapshot!
Als Hardware-Plattform für den Scanner bietet sich ein Raspberry Pi an. Das kompilierte Go-Binary sucht später lediglich eine WLAN-Schnittstelle und begnügt sich beim Scan mit einer langsamen CPU. Auch flüchtigen Speicher verbraucht es kaum, also bietet sich ein Raspberry Pi Zero 2 W mit 1GB RAM an, der um die 20 Euro kostet. Ein Plastik-Gehäuse für ein paar Euro schützt gegen Staub, ein Netzteil mit USB-C-Anschluss liefert die benötigten 2 Watt und eine SD-Karte mit mindestens 8gb Kapazität dient als Festplatte (Abbildung 1).
![]() |
Abbildung 1: Ein Raspberry Pi Zero 2 W überwacht das WLAN |
![]() |
Abbildung 2: Verfügbare WLAN-Accesspoints in der Umgebung |
Der in Go eingebaute Cross-Compiler baut mit Leichtigkeit Binaries für andere Hardware-Plattformen. So muss niemand die Build-Chain auf dem eher schwachbrüstigen Pi Zero installieren und anschließend dem Gras beim Wachsen zusehen, bis endlich das compilierte Binary vorliegt. Wir cross-compilieren also auf Linux mit einem Intel- oder AMD-Processor, und installieren dann das Binary auf dem Raspberry Pi mit seinem ARM-Chip.
![]() |
Abbildung 3: Vom Cronjob neu gefundene SSIDs kommen per SMS rein |
Aber der Reihe nach, wie findet Linux alle aktiven WLAN-Router in der Umgebung? Die Wireless-Komponenten liegen tief im Kernel verwurzelt, denn nur der kann (angeblich) die exakten Timing-Vorraussetzungen mit den WLAN-Karten über den Äther erfüllen. Eine im Raspi verbautet Wifi-Karte (das "W" im "Pi Zero 2 W" oder auch eine in den USB-Port eingestöpselte erkennt der Kernel bei den meisten Modellen, die ohne Treiber auskommen, automatisch und zeigt sie in ifconfig
unter "wlan0" an.
![]() |
Abbildung 4: Die Ausgabe von "iw wlan0 scan" enthält die Kennungen aller WLAN-Router |
Das Kommando "iw wlan0 scan" als Root funkt nun alle lauschenden WLAN-Router der Umgebung an und zeigt deren Kennungen (SSIDs) sowie eine Menge nützlicher Daten an, wie zum Beispiel auf welchen Kanälen das Modul funkt, wie stark das Signal ankommt oder welche Verschlüsselungsverfahren sie verstehen. Zwar bieten Pakete wie mdlayher/wifi
auf Github Wifi-Scanner auf Go-Basis an, aber meine Tests offenbarten, dass sie es in punkto Zuverlässigkeit nicht mit dem Original-Scanner aus der Wifi-Suite aufnehmen können.
01 package main 02 import ( 03 "bytes" 04 "os/exec" 05 "regexp" 06 "strings" 07 ) 08 func scanSSIDs() ([]string, error) { 09 cmd := exec.Command("/usr/sbin/iw", "wlan0", "scan") 10 var out bytes.Buffer 11 cmd.Stdout = &out 12 if err := cmd.Run(); err != nil { 13 return nil, err 14 } 15 var ssids []string 16 re := regexp.MustCompile(`^\s*SSID:\s*(.+)$`) 17 for _, line := range strings.Split(out.String(), "\n") { 18 if match := re.FindStringSubmatch(line); match != nil { 19 ssids = append(ssids, match[1]) 20 } 21 } 22 return ssids, nil 23 }
So ruft das Go-Programm in Listing 1 den von Haus aus installierten Scanner iw
einfach über die Shell auf und extrahiert aus der ausführlichen Ausgabe nur die SSID-Zeilen. Er muss unter root
laufen und liefert manche Einträge doppelt, aber wenn das Hauptprogramm diese später in SQLite einspeichert, fliegen Duplikate automatisch heraus.
Diesen Part übernimmt Listing 2, das eine objektorientierte Hülle über die SQLite-Bibliothek stülpt. Mit NewLedger()
legt es ein neues Logbuch an und legt das Handle der geöffneten Datenbank in der Struktur Ledger
ab, die wie in Go üblich als Behälter für die Instanzdaten dient. Existiert die Datenbank oder die Tabelle bislang noch nicht, legt sie Zeile 16 transparent an.
01 package main 02 import ( 03 "database/sql" 04 "time" 05 _ "github.com/mattn/go-sqlite3" 06 ) 07 type Ledger struct { 08 db *sql.DB 09 } 10 func NewLedger(dbPath string) (*Ledger, error) { 11 db, err := sql.Open("sqlite3", dbPath) 12 if err != nil { 13 return nil, err 14 } 15 createTable := ` 16 CREATE TABLE IF NOT EXISTS ssids ( 17 ssid TEXT PRIMARY KEY, 18 date TEXT NOT NULL 19 );` 20 _, err = db.Exec(createTable) 21 if err != nil { 22 return nil, err 23 } 24 return &Ledger{db: db}, nil 25 } 26 func (l *Ledger) Add(s string, d time.Time) error { 27 _, err := l.db.Exec(` 28 INSERT INTO ssids (ssid, date) VALUES (?, ?) 29 ON CONFLICT(ssid) DO UPDATE SET date = excluded.date; 30 `, s, d.Format(time.RFC3339)) 31 return err 32 } 33 func (l *Ledger) Reportable(s string) bool { 34 var dateStr string 35 err := l.db.QueryRow(`SELECT date FROM ssids WHERE ssid = ?`, s).Scan(&dateStr) 36 if err == sql.ErrNoRows { 37 return true // absent 38 } 39 if err != nil { 40 panic(err) 41 } 42 entryDate, err := time.Parse(time.RFC3339, dateStr) 43 if err != nil { 44 panic(err) 45 } 46 twoMonthsAgo := time.Now().AddDate(0, -2, 0) 47 return entryDate.Before(twoMonthsAgo) 48 }
Während des Scans gefundene WLAN-Accesspoints speichert Zeile 28 in der Tabelle ssids
als neue Reihe mitsamt der aktuellen Uhrzeit ab. Steht unter dem Kürzel bereits ein Eintrag von einem früheren Scan, verursacht dies wegen der Bedingung "primary key" in Zeile 17 einen Konflikt, den Zeile 29 mit dem SQLite-eigenen Kontrukt on conflict
abfängt und statt einen neuen Eintrag anzulegen einfach den Zeitstempel des bestehenden auf die akuelle Uhrzeit auffrischt.
![]() |
Abbildung 5: Gefundene Hotspots in SQLite |
Geht es später daran, neue WLAN-Router über SMS zu melden, muss die Funktion Reportable()
ab Zeile 33 ermitteln, ob der Eintrag entweder gerade neu erzeugt wird (also momentan noch nicht existiert) oder bereits in der Datenbank liegt und vor über zwei Monaten das letzte Mal aufgefrischt wurde. Auch dann soll eine Meldung rausgehen. Das passiert mit einer Select-Anweisung sowie einem Datumsparser und etwas Datumsarithmetik, denn SQLite speichert Datumsangaben als Strings.
01 package main 02 import ( 03 "flag" 04 "go.uber.org/zap" 05 "go.uber.org/zap/zapcore" 06 "os" 07 "regexp" 08 "strings" 09 "time" 10 ) 11 func main() { 12 notify := flag.Bool("notify", false, "pushover notification") 13 stdout := flag.Bool("stdout", false, "log to stdout") 14 flag.Parse() 15 log := initZap(!*stdout, "ssids.log") 16 defer log.Sync() 17 db, err := NewLedger("ssids.db") 18 if err != nil { 19 panic(err) 20 } 21 ssids, err := scanSSIDs() 22 if err != nil { 23 panic(err) 24 } 25 report := []string{} 26 noPrint := regexp.MustCompile(`[^\x20-\x7E]`) 27 for _, ssid := range ssids { 28 ssid = noPrint.ReplaceAllString(ssid, "") 29 log.Info("Scanned", zap.String("ssid", ssid)) 30 if db.Reportable(ssid) { 31 report = append(report, ssid) 32 } 33 db.Add(ssid, time.Now()) 34 } 35 if len(report) > 0 && *notify { 36 txt := "New ssids: " + strings.Join(report, ", ") 37 log.Info("Notify", zap.String("msg", txt)) 38 err := pushover(txt) 39 if err != nil { 40 panic(err) 41 } 42 } 43 } 44 func initZap(toFile bool, filename string) *zap.Logger { 45 ws := zapcore.Lock(os.Stdout) 46 if toFile { 47 f, err := os.Create(filename) 48 if err != nil { 49 panic(err) 50 } 51 ws = zapcore.AddSync(f) 52 } 53 enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) 54 core := zapcore.NewCore(enc, ws, zap.DebugLevel) 55 return zap.New(core) 56 }
Nun geht's ans Hauptprogramm in Listing 3, das zwei Kommandozeilenparameter aufnimmt, --notify
, damit es Änderungen per SMS meldet, und --stdout
, damit es gemachte Fortschritte nicht in einer Logdatei sonder per Stdout meldet. Letztere Option dient zum Debugging, später im Produktionsbetrieb als Cronjob bleibt sie weg. Hierzu initialisiert initZap()
ab Zeile 44 einen Zap-Logger, der entweder in die Datei mit dem übergebenen Namen schreibt, oder eben auf die Konsole.
Zeile 21 führt mit scanSSID()
den WLAN-Scan aus und liefert einen Array-Slice mit Strings zurück, die die Namen gefundener SSIDs enthalten. Dort kann natürlich abhängig von der Fantasie der Nachbarn aller möglicher Schmarrn stehen, und deshalb filtert der reguläre Ausdruck in Zeile 26 alle nicht-druckbaren Zeichen heraus.
Vor dem Ablegen eines Eintrags in der Datenbank in Zeile 33 mit Add()
prüft Reportable()
in Zeile 30, ob die gefundene SSID auf den Array-Slice report
wandern soll oder nicht. Ist die --notify
-Option aktiv, schickt Zeile 38 eine formatierte Kurznachricht an die Funktion pushover()
, die die SMS-Nachricht heraushaut.
01 package main 02 import ( 03 "github.com/mschilli/go-murmur" 04 "net/http" 05 "net/url" 06 "strings" 07 ) 08 func pushover(msg string) error { 09 mm := murmur.NewMurmur() 10 user, err := mm.Lookup("pushover-user") 11 if err != nil { 12 return err 13 } 14 token, err := mm.Lookup("pushover-token") 15 if err != nil { 16 return err 17 } 18 form := url.Values{} 19 form.Add("token", token) 20 form.Add("user", user) 21 form.Add("message", string(msg)) 22 purl := "https://api.pushover.net/1/messages.json" 23 req, err := http.NewRequest("POST", purl, strings.NewReader(form.Encode())) 24 if err != nil { 25 return err 26 } 27 req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 28 client := &http.Client{} 29 resp, err := client.Do(req) 30 if err != nil { 31 return err 32 } 33 defer resp.Body.Close() 34 return nil 35 }
Wie das geht, zeigt Listing 4. Der Pushover-Service verlangt nach einem User-Namen sowie einem Token. Beide findet es in der .murmur
-Datei im Homeverzeichnis des Users unter den Kürzeln pushover-user
beziehungsweise pushover-token
im Yaml-Format. An die Basis-URL der Pushover-Service-API auf pushover.net in Zeile 22 hängen die Add()
-Befehle in den Zeilen 19 bis 21 die erforderlichen Parameter an. Zeile 23 macht daraus einen POST-Request, den Zeile 29 abfeuert, nachdem Zeile 27 noch den erforderlichen Content-Type-Header dazugepackt hat. Zurück kommt normalerweise "OK", wenn die Parameter stimmen und der User einen gültigen Account auf pushover.net hat. Letzteres erfordert lediglich eine Einmalzahlung von etwa 5 Dollar und es fallen keinerlei Abo-Gebühren an ([2]).
![]() |
Abbildung 6: Der Einplatinencomputer Raspberry Pi 2 W mit SD-Karte |
Der verwendete Raspberry Pi, auf dem der Cronjob mit dem Scanner später laufen wird, benötigt ein WLAN-Modul, entweder fest eingebaut (Pi Zero W oder Raspberry 4 und höher) oder als Wifi-Stöpsel.
Wer moderne Rechner gewohnt ist, wird sich über die Performance des Raspberry Pi Zero vielleicht wundern, denn selbst einfache Tasks wie das Installieren des Python-Pakets dauern auf dem Minimal-Rechner gerne mal eine halbe Stunde. Dafür schluckt er aber im Leerlauf weniger als 1 Watt. Das Einsaugen der aktuellen SSIDs aus der Luft sowie das Speichern in einer SQLite-Datenbank benötigt später kaum Pferdestärken, deswegen ist der Pi Zero die richtige Wahl.
![]() |
Abbildung 7: Der grafische Imager brennt SD-Karten für den Raspberry Pi |
![]() |
Abbildung 8: Die Lite-Version ist ideal für den Raspi Zero. |
Abbildung 1 am Anfang des Artikesl zeigt den fertig installierten Minirechner. Seitlich spitzelt die Micro-SD-Karte heraus, als Stromversorgung dient ein USB-C-Stecker mit Netzteil. Sonst benötigt er keinerlei Kabel, da die Kommunikation über die eingebaute WLAN-Karte abläuft.
Ein neu gekaufter Raspberry Pi Zero 2 W hat noch kein Betriebssystem, das muss der User erst auf eine Micro-SD-Karte brennen. Dazu bietet die Website den Raspberry Pi Imager als downloadbares Desktop-Programm an. Erst fragt dieses nach dem Typ der verwendeten Hardware, "Pi Zero 2 W" ist die richtige Einstellung. Als Distribution aus der Auswahlliste des Imagers eignet sich die Lite-Version von Raspberry Pi OS, entweder als 32-bit oder 64-bit-Version. Der Minirechner braucht nun wirklich keinen grafischen Desktop, sondern nur eine Konsole mit Kommandozeile.
![]() |
Abbildung 9: Vor dem Brennen des OS auf die Karte ... |
![]() |
Abbildung 10: ... setzt der Imager noch das WLAN-Passwort. |
Damit der Raspi die eingebaute WLAN-Karte initialisiert und die dafür notwendig Firmware-Library bereitstellt, sollte der Raspberry-Pi-Imager die SD-Karte mit dem WLAN-Passwort ausstatten (Abbildung 10). Das Menü dazu erscheint, wenn der User "Yes" auf die Frage nach "custom settings" kurz vor dem Brennvorgang antwortet. Wer das weglässt, muss später auf dem Pi später von Pontius zu Pilatus, bis eine WLAN-Karte endlich eingebunden wird.
Auf dem Raspberry Pi tickt ein ARM-Prozessor, auf den meisten PCs hingegen eher andere Hardware, also muss ein Cross-Compiler die Sourcen in ein Binary compilieren. Das geht am einfachsten mit einem Docker-Container nach Listing 6 und den Build-Kommandos im Makefile nach Listing 5. Die Go-Version der Ubuntu-Welt hinkt der Zeit zu weit hinterher, als dass sie zu gebrauchen wäre, deshalb installieren die Zeilen 6 bis 9 im Dockerfile die relativ aktuelle Version 1.22.3 von der Go-Website.
1 SRCS=wifiscan.go scan.go ledger.go pushover.go 2 BIN=wifiscan 3 DOCKER_TAG=raspi 4 remote: $(SRCS) 5 docker run -v `pwd`:/build -it $(DOCKER_TAG) \ 6 bash -c "go build $(SRCS)" 7 docker: 8 docker build -t $(DOCKER_TAG) .
01 FROM ubuntu:18.04 02 ENV DEBIAN_FRONTEND noninteractive 03 RUN apt-get update 04 RUN apt-get install -y build-essential wget 05 RUN apt-get install -y gcc-arm-linux-gnueabihf libc6-dev-armhf-cross 06 ENV GOVER=go1.22.3.linux-amd64.tar.gz 07 RUN wget https://go.dev/dl/${GOVER} 08 RUN tar -C /usr/local -xzf ${GOVER} 09 ENV PATH="/usr/local/go/bin:${PATH}" 10 ENV GOOS=linux \ 11 GOARCH=arm \ 12 CGO_ENABLED=1 \ 13 CC=arm-linux-gnueabihf-gcc 14 WORKDIR /build 15 COPY *.go *.mod *.sum /build 16 RUN go mod tidy
Das Ubuntu-basierte Container-Image bekommt zum Cross-Compilieren die beiden Pakete gcc-arm-linux-gnueabihf
und libc6-dev-armhf-cross
spendiert. Gos eingebauter Cross-Compiler schafft dies normalerweise ohne externe Hilfe, aber im vorliegenden Fall benötigt das eingebundene Datenbankpaket go-sqlite3
die spezielle Option CGO_ENABLED=1
, da die SQLite-Sourcen als C-Code vorliegen, die der Go-Compiler erst als solche einbinden muss.
Die remote
-Target des Makefiles in Listing 5 springt also in den Container, nimmt das Verzeichnis mit den Sourcen mit, und wirft go build
an. Heraus kommt ein Binary, das wegen des eingebundenen Verzeichnisses nach Abschluss der Arbeiten auch außerhalb des Containers verfügbar ist. Mit scp
landet es anschließend auf einem Webserver, von wo der Raspberry Pi es mit curl
herunterladen und mit sudo chown root ssids
sowie sudo chmod 4755 ssids
ausführbar macht, sowie das s-bit setzt, damit das unter einem normalen User-Account als Cronjob gestartete Programm mit Root-Rechten läuft. Der WLAN-Scan besteht darauf.
Die Variablen GOOS=linux und GOARCH=arm bestimmen als Zielplattform einen 32-bittigen ARM-Chip, aber es spielt eine Rolle, ob das Binary auf dem ursprünglichen Pi Zero W oder dem Nachfolger Pi Zero 2 W laufen soll. In ersterem tickt eine ARMv6 CPU, und mit GOARM=6 produziert der Compiler passenden Code. Im Pi Zero 2 W hingegen rödelt eine quad-core ARM Cortex-A53 CPU, die ARMv8 (64-bit) fährt, doch auf dem Pi läuft normalerweise das 32-bittige Raspberry Pi OS, das ARMv7 braucht. Folglich ist GOARM=7 zu setzen. Wer sich hier vertut, merkt schnell, dass etwas nicht stimmt, wenn das auf der Zielplattform ausgeführte Binary mit illegal instruction
den Dienst einstellt.
Zum Debuggen lässt sich der Raspi mit HDMI an einen Monitor anschließen und der einzige USB-(Micro)-Anschluss neben dem USB-Stecker für die Stromversorgung kann einen Hub mit Tastatur und Maus aufnehmen. Bequemer geht's, wenn der Raspi eine statische IP-Adresse bekommt und einen sshd-Daemon hochfährt, dann einfach dort einloggen und herumstöbern.
![]() |
Abbildung 11: Alle 5 Minuten scannt der Cronjob das WLAN |
Nun fehlt noch ein Cronjob, der das Programm alle fünf Minuten aufruft, was mit crontab -e
und einem neuen Eintrag */5 * * * * $HOME/ssids --notify
schnell erledigt ist (Abbildung 11). Anschließend springt in jeder fünften Minute das Binary an, führt den Scan durch und speist alle gefundenen SSIDs in der automatisch zur Laufzeit angelegten SQLite-Datenbank ein. Bereits bestehende Einträge erhalten nur den aktuellen Zeitstempel. War ein Eintrag noch nicht vorhanden oder ist der letzte Update mehr als zwei Monate her, springt bei gesetzter Kommandozeilenoption --notify
der Pushover-Mechanismus an und schickt eine Zusammenfassung per SMS ans Telefon des erfreuten Users.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/09/snapshot/
SMS-Gateway Pushover.net: https://pushover.net/pricing
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc