Weil Mike Schilli hoch über der Stadt wohnt und von Natur aus neugierig ist, schreibt er ein Go-Programm für den Raspberry Pi Zero, das neu auftauchende WLAN-Router erkennt und per SMS meldet.
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 oft interessante Fakten. Haben die Nachbarn Humor und schon wieder einen neuen Router? Um auf dem Laufenden zu bleiben, soll ein Scanner alle fünf Minuten die Namen aller erreichbaren WLAN-Access-Points einholen, mit einem Zeitstempel in einer SQLite-Datenbank speichern und bei Änderungen eine Text-Nachricht aufs Handy schicken (Abbildung 1).
Das fertige Binary der Sourcen dieser Ausgabe heißt »wifiscan«, weil man in Nordamerika Wi-Fi zum WLAN sagt. Ein weiterer lustiger Unterschied zwischen den beiden Kulturen ist übrigens die Aussprache des Wortes Router. In Deutschland sagt man “Ruter”, was wohl der Nähe zur englischen Insel geschuldet ist. In den USA wäre das eine buchstäblich anrüchige Anspielung auf die Firma Roto-Rooter, die verstopfte Abflüsse reinigt. Den Router, der das WLAN aufbaut, spricht der US-Amerikaner als “Rauter” aus.
Minimale Hardware
Als Hardwareplattform 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 und wenig Hauptspeicher. Ein RasPi Zero 2W mit 1 GByte RAM, der um die 20 Euro kostet, genügt also. Ein Plastikgehä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 8 GByte Kapazität dient als Massenspeicher.
Der in Go eingebaute Cross-Compiler baut mit Leichtigkeit Binärdateien für andere Hardwareplattformen. So muss niemand die Build Chain auf dem schwachbrüstigen Pi Zero installieren und anschließend dem Gras beim Wachsen zusehen, bis endlich das kompilierte Binary vorliegt. Wir crosskompilieren also auf Linux mit einem Intel- oder AMD-Prozessor und installieren dann das Binärprogramm auf dem Raspberry Pi mit seinem ARM-Chip.
Hallo, wer da?
Aber der Reihe nach, wie findet Linux alle aktiven WLAN-Router in der Umgebung? Die Wireless-Komponenten liegen tief im Kernel verwurzelt, denn nur er kann (angeblich) die exakten Timing-Voraussetzungen mit den WLAN-Karten über den Äther erfüllen. Der Kernel erkennt den im RasPi verbaute WLAN-Chip (das W in Zero 2W) oder eine in den USB-Port gestöpselte WLAN-Karte (bei den meisten Modellen, die ohne Treiber auskommen) automatisch und zeigt sie über Ifconfig als »wlan0« an.
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 (Abbildung 2). Dazu gehört zum Beispiel, auf welchen Kanälen das Modul funkt, wie stark das Signal ankommt und welche Verschlüsselungsverfahren es versteht. Zwar bieten Pakete wie Wifi [1] von Matt Layher WLAN-Scanner auf Go-Basis an, aber meine Tests offenbarten, dass sie es hinsichtlich der Zuverlässigkeit nicht mit dem Original-Scanner aus der WLAN-Suite aufnehmen können.
Das Go-Programm in Listing 1 ruft den von Haus aus installierten Scanner Iw einfach über die Shell auf und extrahiert aus der ausführlichen Ausgabe nur die SSID-Zeilen. Es muss mit Root-Rechten laufen und liefert manche Einträge doppelt, aber das Hauptprogramm filtert später beim Einspeisen in SQLite Duplikate automatisch heraus.
Listing 1
scan.go
package main
import (
"bytes"
"os/exec"
"regexp"
"strings"
)
func scanSSIDs() ([]string, error) {
cmd := exec.Command("/usr/sbin/iw", "wlan0", "scan")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var ssids []string
re := regexp.MustCompile(`^\s*SSID:\s*(.+)$`)
for _, line := range strings.Split(out.String(), "\n") {
if match := re.FindStringSubmatch(line); match != nil {
ssids = append(ssids, match[1])
}
}
return ssids, nil
}
Übergestülpt
Diesen Part übernimmt Listing 2, das eine objektorientierte Hülle über die SQLite-Bibliothek stülpt. Mit »NewLedger()« erzeugt es ein neues Logbuch und legt das Handle der geöffneten Datenbank in der Struktur »Ledger« ab. Die dient wie in Go üblich als Behälter für die Instanzdaten. Existiert die Datenbank oder Tabelle noch nicht, legt Zeile 16 sie transparent an.
Während des Scans gefundene WLAN-Access-Points speichert Zeile 28 in der Tabelle »ssids« als neue Reihe mitsamt der aktuellen Uhrzeit ab (Abbildung 3). Gibt es unter demselben Kürzel bereits einen Eintrag von einem früheren Scan, verursacht das wegen der Bedingung »primary key« in Zeile 17 einen Konflikt, den Zeile 29 mit dem SQLite-eigenen Konstrukt »on conflict« abfängt. Statt einen neuen Eintrag anzulegen, frischt es einfach den Zeitstempel des bestehenden auf die aktuelle Uhrzeit auf.
Geht es später daran, neue WLAN-Router über eine SMS zu melden, muss die Funktion »Reportable()« ab Zeile 33 eine Fallunterscheidung treffen: Wird der Eintrag gerade neu erzeugt, existiert also momentan noch nicht? Oder liegt er bereits in der Datenbank und wurde vor mehr als zwei Monaten das letzte Mal aufgefrischt? Auch dann soll eine Meldung hinausgehen. Das erfolgt mit einer SELECT-Anweisung sowie einem Datumsparser und etwas Datumsarithmetik, denn SQLite speichert Datumsangaben als Strings.
Listing 2
ledger.go
package main
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Ledger struct {
db *sql.DB
}
func NewLedger(dbPath string) (*Ledger, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
createTable := `
CREATE TABLE IF NOT EXISTS ssids (
ssid TEXT PRIMARY KEY,
date TEXT NOT NULL
);`
_, err = db.Exec(createTable)
if err != nil {
return nil, err
}
return &Ledger{db: db}, nil
}
func (l *Ledger) Add(s string, d time.Time) error {
_, err := l.db.Exec(`
INSERT INTO ssids (ssid, date) VALUES (?, ?)
ON CONFLICT(ssid) DO UPDATE SET date = excluded.date;
`, s, d.Format(time.RFC3339))
return err
}
func (l *Ledger) Reportable(s string) bool {
var dateStr string
err := l.db.QueryRow(`SELECT date FROM ssids WHERE ssid = ?`, s).Scan(&dateStr)
if err == sql.ErrNoRows {
return true // absent
}
if err != nil {
panic(err)
}
entryDate, err := time.Parse(time.RFC3339, dateStr)
if err != nil {
panic(err)
}
twoMonthsAgo := time.Now().AddDate(0, -2, 0)
return entryDate.Before(twoMonthsAgo)
}
Flexibler Zap-Logger
Nun geht es ans Hauptprogramm in Listing 3, das zwei Kommandozeilenparameter aufnimmt: Mit »–notify« aufgerufen, meldet es Änderungen per SMS. Beim Aufruf mit »–stdout« speichert es Fortschritte nicht in einer Log-Datei, sondern gibt sie per Stdout aus. Letzteres dient dem Debugging, später im Produktionsbetrieb als Cronjob spielt es keine Rolle.
Die Funktion »initZap()« ab Zeile 44 initialisiert einen Zap-Logger, der entweder in die Datei mit dem übergebenen Namen schreibt oder eben auf die Konsole. Zeile 21 erledigt via »scanSSIDs()« den WLAN-Scan und liefert einen Array-Slice mit Strings zurück, der die Namen gefundener SSIDs enthält. Dort kann – abhängig von der Fantasie der Nachbarn – aller mögliche Unsinn stehen. 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 mittels »Add()« prüft »Reportable()« in Zeile 30, ob die gefundene SSID in den Array-Slice »report« wandern soll oder nicht. Ist die »–notify«-Option aktiv, schickt Zeile 38 eine formatierte Kurznachricht an die Funktion »pushover()«, die eine SMS-Nachricht erzeugt.
Listing 3
wifiscan.go
package main
import (
"flag"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"regexp"
"strings"
"time"
)
func main() {
notify := flag.Bool("notify", false, "pushover notification")
stdout := flag.Bool("stdout", false, "log to stdout")
flag.Parse()
log := initZap(!*stdout, "ssids.log")
defer log.Sync()
db, err := NewLedger("ssids.db")
if err != nil {
panic(err)
}
ssids, err := scanSSIDs()
if err != nil {
panic(err)
}
report := []string{}
noPrint := regexp.MustCompile(`[^\x20-\x7E]`)
for _, ssid := range ssids {
ssid = noPrint.ReplaceAllString(ssid, "")
log.Info("Scanned", zap.String("ssid", ssid))
if db.Reportable(ssid) {
report = append(report, ssid)
}
db.Add(ssid, time.Now())
}
if len(report) > 0 && *notify {
txt := "New ssids: " + strings.Join(report, ", ")
log.Info("Notify", zap.String("msg", txt))
err := pushover(txt)
if err != nil {
panic(err)
}
}
}
func initZap(toFile bool, filename string) *zap.Logger {
ws := zapcore.Lock(os.Stdout)
if toFile {
f, err := os.Create(filename)
if err != nil {
panic(err)
}
ws = zapcore.AddSync(f)
}
enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core := zapcore.NewCore(enc, ws, zap.DebugLevel)
return zap.New(core)
}
Nachricht per SMS
Wie das gelingt, zeigt Listing 4. Der Pushover-Service verlangt nach einem Benutzernamen sowie einem Token. Beide findet es in der Datei ».murmur« im Home-Verzeichnis des Users unter den Kürzeln »pushover-user« beziehungsweise »pushover-token« im YAML-Format.
Die »Add()«-Befehle in den Zeilen 19 bis 21 hängen die erforderlichen Parameter an die Basis-URL der Pushover-Service-API auf http://pushover.net an (Zeile 22). 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«, sofern die Parameter stimmen und der Benutzer ein gültiges Konto beim SMS-Dienst Pushover hat. Letzteres erfordert eine Einmalzahlung von rund 5 US-Dollar, es fallen keine weiteren Abo-Gebühren an [2].
Listing 4
pushover.go
package main
import (
"github.com/mschilli/go-murmur"
"net/http"
"net/url"
"strings"
)
func pushover(msg string) error {
mm := murmur.NewMurmur()
user, err := mm.Lookup("pushover-user")
if err != nil {
return err
}
token, err := mm.Lookup("pushover-token")
if err != nil {
return err
}
form := url.Values{}
form.Add("token", token)
form.Add("user", user)
form.Add("message", string(msg))
purl := "https://api.pushover.net/1/messages.json"
req, err := http.NewRequest("POST", purl, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
Winzig und sparsam
Der Raspberry Pi, auf dem der Cronjob mit dem Scanner später läuft, benötigt ein WLAN-Modul, entweder fest eingebaut (Pi Zero W oder RasPi 4 und höher) oder als WLAN-Stick.
Wer die Arbeit an modernen Rechnern gewohnt ist, wundert sich vielleicht über die geringe Performance des Pi Zero: Selbst einfache Tasks wie das Installieren eines Python-Pakets dauern dort schon mal eine halbe Stunde. Dafür schluckt der Mini-Rechner aber im Leerlauf weniger als 1 Watt. Das Einsaugen der aktuellen SSIDs aus der Luft sowie das Speichern in einer SQLite-Datenbank benötigen später kaum Pferdestärken – das macht den Pi Zero (Abbildung 4) zur richtigen Wahl.
Abbildung 5 zeigt den fertig installierten Mini-Rechner. Seitlich spitzt die MicroSD-Karte heraus, als Stromversorgung dient ein USB-C-Stecker mit Netzteil. Sonst benötigt der Zero 2W keine Kabel, da die Kommunikation über die eingebaute WLAN-Karte abläuft.
Dem neu gekauften Rechenzwerg fehlt noch ein Betriebssystem. Das müssen Sie erst auf eine MicroSD-Karte brennen. Dazu bietet die Website den Raspberry Pi Imager (Abbildung 6) als herunterladbares Desktop-Programm an. Als Erstes fragt es 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- oder 64-Bit-Version (Abbildung 7). Der Kleinstrechner braucht keinen grafischen Desktop, sondern nur eine Konsole mit Kommandozeile.
Damit der Pi Zero die eingebaute WLAN-Karte initialisiert und die dafür notwendige Firmware-Library bereitstellt, sollte der Imager die SD-Karte mit dem WLAN-Passwort ausstatten (Abbildung 8). Das Menü dazu erscheint, wenn Sie kurz vor dem Brennvorgang mit Yes auf die Frage antworten, ob OS customisation settings anstehen. Überspringen Sie diesen Schritt, handeln Sie sich eine Menge Zusatzarbeit ein, um später die WLAN-Karte einzubinden.
Als Herz des Pi Zero dient ein ARM-Prozessor, die meisten PCs hingegen basieren auf der x86-Architektur. Daher muss ein Cross-Compiler die Sourcen in ein Binärprogramm übersetzen. Das klappt am einfachsten mit einem Docker-Container (Listing 5) und den Build-Kommandos aus dem Makefile in Listing*6. 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.
Listing 5
Dockerfile
FROM ubuntu:18.04
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update
RUN apt-get install -y build-essential wget
RUN apt-get install -y gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
ENV GOVER=go1.22.3.linux-amd64.tar.gz
RUN wget https://go.dev/dl/${GOVER}
RUN tar -C /usr/local -xzf ${GOVER}
ENV PATH="/usr/local/go/bin:${PATH}"
ENV GOOS=linux \
GOARCH=arm \
CGO_ENABLED=1 \
CC=arm-linux-gnueabihf-gcc
WORKDIR /build
COPY *.go *.mod *.sum /build
RUN go mod tidy
Listing 6
Makefile.build
SRCS=wifiscan.go scan.go ledger.go pushover.go
BIN=wifiscan
DOCKER_TAG=raspi
remote: $(SRCS)
docker run -v `pwd`:/build -it $(DOCKER_TAG) \
bash -c "go build $(SRCS)"
docker:
docker build -t $(DOCKER_TAG) .
Das Ubuntu-basierte Container-Image bekommt zum Crosskompilieren die beiden Pakete gcc-arm-linux-gnueabihf und libc6-dev-armhf-cross spendiert. Gos eingebauter Cross-Compiler schafft das normalerweise ohne externe Hilfe. Im vorliegenden Fall jedoch 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.
Das »remote«-Target des Makefiles in Listing 6 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 zur Verfügung steht. Via Scp landet es anschließend auf einem Webserver, von wo der Raspberry Pi es mit Curl herunterlädt und mit »sudo chown root wifiscan« sowie »sudo chmod 4755 wifiscan« ausführbar macht. Außerdem setzt er das Sticky-Bit, damit das unter einem normalen Benutzerkonto 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-Bit-ARM-Chip. Dabei spielt es allerdings eine Rolle, ob das Binary auf dem ursprünglichen Pi Zero W oder auf dem Nachfolger Pi Zero 2W laufen soll. Ersterer verwendet eine ARMv6-CPU, mit »GOARM=6« produziert der Compiler passenden Code. Im Zero 2W dagegen rödelt eine 64-Bit-CPU des Typs ARM Cortex-A53 mit vier Kernen. Darauf läuft normalerweise aber das 32-Bit-System Pi OS, das ARMv7 braucht. Folglich gilt es, »GOARM=7« zu setzen. Wer sich hier vertut, merkt schnell, dass etwas nicht stimmt: Das auf der Zielplattform ausgeführte Binary quittiert dann mit einem schmallippigen »illegal instruction« den Dienst.
Zum Debuggen lässt sich der Pi Zero via HDMI an einen Monitor anschließen. Der einzige USB-(Micro)-Anschluss neben dem USB-Stecker für die Stromversorgung nimmt einen Hub mit Tastatur und Maus auf. Bequemer geht es, bekommt der Mini-Rechner eine statische IP-Adresse und fährt einen SSH-Daemon hoch. Dann können Sie sich einfach darauf einloggen und herumstöbern.
Cronjob und Go
Nun fehlt noch ein Cronjob, der das Programm alle fünf Minuten aufruft. Sie richten ihn mit dem Kommando »crontab -e« und einem neuen Eintrag »*/5 * * * * $HOME/wifiscan –notify« schnell ein (Abbildung 9).
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 (Abbildung 10). Bereits bestehende Einträge erhalten nur den aktuellen Zeitstempel. War ein Eintrag noch nicht vorhanden oder liegt das letzte Update mehr als zwei Monate zurück, springt bei gesetzter Kommandozeilenoption »–notify« der Pushover-Mechanismus an und schickt eine Zusammenfassung per SMS ans Telefon des erfreuten Anwenders. (uba)
Infos
- Go-Package Wifi: https://github.com/mdlayher/wifi
- SMS-Gateway Pushover.net: https://pushover.net/pricing

















An und für sich ja recht interessant. Allerdings finde ich, dass der Titel und die Einleitung nicht zur eigentlichen Umsetzung passt. Es werden weder die “WLAN-Router” noch “-Accesspoints” ermittelt, sondern nur die SSIDs. Ob eine SSID dann durch einen Router oder einen Accesspoint, Dongle oder Smartphone-Hotspost kommen, wird genauso wenig ermittelt, wie ob gleichlautende SSIDs über verschiedene Accespoints kommt, oder mehrere SSIDs durch ein und den selben Accesspoint bereitgestellt werden. Insofern werden nicht “neu auftauchende WLAN-Router” erkannt, sondern nur neue, eindeutige SSIDs. Das ist ja grundsätzlich OK, nur sollte das im Text halt eben auch entsprechend berücksichtigt werden und nicht… Mehr »