Wieso funktioniert das WLAN schon wieder nicht, was ist da los? Statt immer dieselben Schritte zur Diagnose einzutippen, schreibt Mike Schilli ein Go-Tool, das das drahtlose Netzwerk auf Herz und Nieren prüft und hilft, die Ursache einzukreisen.
Kaum im von Airbnb vermittelten Urlaubsresort angekommen, stellt sich heraus, dass das WLAN dort nicht funktioniert. Woran liegt es? Weist der DHCP-Server des Routers dem Laptop keine IP-Adresse zu? Liegt es am DNS? Ist nur der Durchsatz so mies, dass alles hängt?
All das lässt sich durch diverse Kommandozeilen-Tools in Erfahrung bringen, aber es ist ermüdend und nervig, das Verfahren jedes Mal zu wiederholen. Wie wäre es mit einem Tool, das die einzelnen Schritte in regelmäßigen Abständen wieder und wieder ausführt, die Ergebnisse grafisch anzeigt und so hoffentlich die Ursache einkreist?
Als Terminal-UI kommt dabei die Library Tview von Github zum Einsatz, die auch einige bekannte Projekte wie Kubernetes für ihre Kommandozeilenwerkzeuge verwenden. Mit nur wenigen Zeilen Code schaltet Tview das aktuelle Terminal in den Grafikmodus und zeigt simple Elemente wie Tabellen oder Formulare im Retro-Look weiß auf schwarz an (Abbildung 1). Tastatureingaben nimmt Tview im Raw-Modus entgegen, und Applikationen können sie zum Steuern der Oberfläche nutzen.
Von der Kommandozeile aus aufgerufen, startet das vorgestellte Go-Programm »wifi« vier verschiedene Tests gleichzeitig und zeigt die Ergebnisse in einer Tabelle an. Nach jeweils 10 Sekunden führt es die Tests erneut aus und gibt so dynamisch wieder, was sich im Netzwerk ändert. Tickt alles wie gewünscht, zeigt das Tool die Messergebnisse an (Abbildung 1). Schlagen hingegen Tests fehl, liefert es die entsprechenden Fehlermeldungen (Abbildung 2). Ein Druck auf [Strg]+[C] beendet das Tool, schaltet das Terminal wieder in den Normalmodus und springt zurück in die Shell.
Parallel testen
Die ersten beiden Tests des Tools schicken Ping-Requests an den Google-Server; einmal an den Hostnamen »www.google.com« und einmal an die IP-Adresse von Googles bekanntem DNS-Server (»8.8.8.8«). Schlägt beides fehl, ist wohl die Verbindung zum Internet vollständig unterbrochen. Wird nur der Host nicht gefunden, liegt das Problem eher an den DNS-Einstellungen.
Als Test Nummer drei sucht das Werkzeug unter dem Feldnamen »Ifconfig« nach allen Client-IP-Adressen, die dem Rechner vom DHCP-Server zugewiesen wurden. Steht dort nichts, hakt es wohl am Router oder der WLAN-Verbindung. In der fünften und letzten Zeile der Tabelle setzt das Tool schließlich einen HTTP-Request an den Youtube-Server ab und zeigt im Erfolgsfall die bis zu dessen Abarbeitung verstrichene Zeit in Millisekunden an. So lässt sich ein lahmer ISP diagnostizieren.
Tuchfühlung
Um erst einmal auf den Geschmack mit der Tview-Library zu kommen, implementiert Listing 1 eine laufende Stoppuhr. Deren aktuelle Uhrzeit trifft im Sekundentakt als String über einen Go-Channel ein. Sie erscheint dann dynamisch aufgefrischt in einem Widget vom Typ »TextView« im Interface des Programms.
Listing 1
clock-main.go
package main
import (
"fmt"
"github.com/rivo/tview"
)
func main() {
app := tview.NewApplication()
tv := tview.NewTextView()
tv.SetBorder(true).SetTitle("Test Clock")
ch := clock()
go func() {
for {
select {
case val := <-ch:
app.QueueUpdateDraw(func() {
tv.Clear()
fmt.Fprintf(tv, "%s ", val)
})
}
}
}()
err := app.SetRoot(tv, true).Run()
if err != nil {
panic(err)
}
}
Dazu zieht der Code in Zeile 4 das »tview«-Framework von Github herein. Zeile 7 erzeugt eine neue Terminalapplikation und legt eine Referenz darauf in der Variablen »app« ab. Als Fensterinhalt kommt das »TextView«-Widget zum Einsatz, das in der Variable »tv« liegt und dessen Repräsentation im Terminal wegen »SetBorder(true)« einen Rand aufweist. Außerdem bekommt es mit »SetTitle()« eine Überschrift spendiert.
Der Aufruf der Funktion »clock()« in Zeile 10 startet die eigentliche Stoppuhr. Die Funktion schubst nicht nur den Timer an und lässt ihn im Hintergrund immer weiterlaufen, sondern erzeugt auch einen Channel, den sie an den Aufrufer zurückreicht. Über diesen Kanal treffen anschließend im Sekundentakt die aktuellen Stoppuhr-Strings ein, die der Aufrufer aufschnappt, um die grafische Anzeige zu aktualisieren.
Ebenfalls nebenläufig fängt die Goroutine ab Zeile 11 in einer Endlosschleife mit einer Select-Anweisung aus dem Channel eintrudelnde Strings ab. Sobald in Zeile 14 ein neuer Wert ankommt, benachrichtigt das Programm mit »app.QueueUpdateDraw()« die Terminal-UI und weist sie an, erst die Uhranzeige mit »tv.Clear()« zu löschen und dann via »Fprintf()« den neuen, aktuellen Wert in das »TextView«-Widget zu schreiben.
Damit steht die grafische Anzeige. Bleibt nur noch, in Zeile 22 mit »app.SetRoot()« dem Applikationsfenster das »TextView«-Widget einzupflanzen und mit »Run()« die UI zu starten. Ab diesem Moment läuft sie (Abbildung 3). Drückt der Benutzer [Strg]+[C], faltet sie sich sofort sauber zusammen und gibt das Terminal wieder für die Shell frei.
Tickende Uhr
Die eigentliche Stoppuhr implementiert Listing 2 mit der Funktion »clock()«, die ein optionales String-Argument akzeptiert. Die Stoppuhr nutzt dieses zwar nicht, aber das Format der Funktion soll später auch komplexere Aktionen für die UI einleiten. Deswegen implementiert der Code sie als sogenannte variadische Funktion. Die drei Punkte zwischen dem Namen des Parameters »arg« und dessen Typ (»string«) bestimmen in Go, dass man die Funktion entweder gänzlich ohne Argumente aufruft oder mit einem oder mehreren Argumenten des angegebenen Typs.
Listing 2
clock.go
package main
import (
"time"
)
func clock(arg ...string) chan string {
ch := make(chan string)
start := time.Now()
go func() {
for {
z := time.Unix(0, 0).UTC()
ch <- z.Add(time.Since(start)).Format("15:04:05")
time.Sleep(1 * time.Second)
}
}()
return ch
}
In Zeile 6 erzeugt »clock()« den Channel, den die Funktion später ans Hauptprogramm zurückreicht und dadurch dann nebenläufig Updates für die Uhrzeit hochschickt.
Für die Anzeige der seit dem Startzeitpunkt verstrichenen Zeit in Stunden, Minuten und Sekunden nutzt Listing 2 einen Trick: Die Funktion »time.Since()« in Zeile 11 ermittelt die seit dem Startzeitpunkt verstrichene Zeit als Wert vom Typ »time.Duration«. Allerdings bietet Go für diesen Typ keine elegante Formatierung als String an. Der Typ »time.Time« hingegen, mit dem Go Werte für die Uhrzeit darstellt, kennt die Funktion »Format()«, die das interne Zeitformat für Menschen lesbar formatiert.
Falls Sie sich über den seltsamen String »15:04:05« als Argument im Code wundern: Go erwartet das Format von Stunden, Minuten und Sekunden als Platzhalter. Andere Programmiersprachen legen so etwas mit einem Template-String wie »HH:MM:SS« fest. Go hingegen wählt den seltsamen Weg, den magischen Zeitpunkt am Montag, den »2.1.2006« um »15:04:05« als Referenz zu verwenden [1].
Den aktuellen Stand der Stoppuhr schiebt Zeile 11 in den Channel »ch«, an dessen anderem Ende das aufrufende Hauptprogramm lauscht und seine Bildschirmanzeige mit den ankommenden Informationen auffrischt.
Zum Erzeugen des Binaries aus dem Source-Code holt der Dreisprung aus Listing 3 den Code abhängiger Libraries von Github herein, kompiliert das Gesamtkunstwerk und erzeugt schließlich ein Binary »clock-main«. Ruft man das Kompilat von der Kommandozeile aus auf, färbt es das Terminal schwarz und malt die dynamisch im Sekundentakt tickende Stoppuhr in eine gerahmte Box hinein (Abbildung 3). Achtung: Die Tview-Library benötigt mindestens Go 1.18. Fahren Sie noch eine ältere Version, müssen Sie vorab upgraden.
Listing 3
build-clock.sh
go mod init clock-main go mod tidy go build clock-main.go clock.go
Kein Spielzeug
Nun aber von der Spielzeuguhr hin zur eigentlichen Applikation, die im Hintergrund das Netzwerk prüft und die Ergebnisse aller Tests periodisch in der grafischen Oberfläche auffrischt.
Das aus Listing 4 später kompilierte Programm wird »wifi« heißen, wie man auf Amerikanisch zum WLAN sagt, obwohl sich die Applikation genauso auf verdrahtete Netzwerke ansetzen lässt. Zur Darstellung der Testergebnisse nutzt es in Zeile 8 das Tabellen-Widget des Tview-Projekts. Insgesamt fünf Reihen mit jeweils zwei Spalten enthalten links eine Beschreibung des Tests und rechts das dynamisch aufgefrischte Ergebnis.
Listing 4
wifi.go
package main
import (
"strings"
"github.com/rivo/tview"
)
func main() {
app := tview.NewApplication()
table := tview.NewTable().SetBorders(true)
table.SetBorder(true).SetTitle("Wifi Monitor v1.0")
newPlugin(app, table, "Time", clock)
newPlugin(app, table, "Ping", ping, "www.google.com")
newPlugin(app, table, "Ping", ping, "8.8.8.8")
newPlugin(app, table, "Ifconfig", nifs)
newPlugin(app, table, "HTTP", httpGet, "https://youtu.be")
err := app.SetRoot(table, true).SetFocus(table).Run()
if err != nil {
panic(err)
}
}
func newPlugin(app
*tview.Application, table *tview.Table, field string, fu func(...string) chan string, arg ...string) {
if len(arg) > 0 {
field += " " + strings.Join(arg, " ")
}
row := table.GetRowCount()
table.SetCell(row, 0, tview.NewTableCell(field))
ch := fu(arg...)
go func() {
for {
select {
case val := <-ch:
app.QueueUpdateDraw(func() {
table.SetCell(row, 1, tview.NewTableCell(val))
})
}
}
}()
}
Bei der Definition der unterschiedlichen Ränder des Fensters und der Tabelle gilt es, genau hinzusehen: Das Table-Widget verfügt über eine Funktion »SetBorders()«, die festlegt, ob die Tabelle Zeilen- und Spaltenlinien einzeichnet oder nicht. Andererseits ruft Zeile 9 auch noch »SetBorder()« (Singular) auf. Das bezieht sich nicht auf die Tabelle, sondern auf die sogenannte Box (einen Container), in der sie liegt. Der Aufruf zeichnet einen Rand um die Applikation, mitsamt einer Überschrift am oberen Ende.
Über einen Kamm geschert
Jede Tabellenzeile bekommt nun ein Testprogramm zugewiesen. So landet die tickende Uhr in der ersten Zeile, zwei Netzwerk-Pings in den Zeilen 2 und 3, die Anzeige lokaler IPs in Zeile 4 und ein HTTP-Request auf den Youtube-Server in Zeile 5. Die Integration dieser Plugins in die Tabelle erledigt die Funktion »newPlugin()«. Deren Aufrufe bekommen in den Zeilen 10 bis 14 jeweils eine Referenz auf die Applikation und deren Tabelle mitgeschickt. Hinzu kommen eine Beschreibung des jeweiligen Tests als String sowie eine Funktion, die den Test ausführt.
Wie sich an der Signatur von »newPlugin()« in Zeile 20 ablesen lässt, erwartet die Funktion die ihr übergebene Testfunktion »fu« in einem interessanten Format: Damit die Testfunktion allen Anwendungen gerecht wird, akzeptiert sie eine variable Anzahl von String-Argumenten (»…string«) und gibt einen Channel zurück, auf dem der Aufrufer später Ergebnisse vom Typ »string« abholen kann. Ein Beispiel für eine solche Testfunktion haben wir bereits in Listing 2 gesehen. Mit »clock()« entsteht eine Stoppuhr, deren aktuellen Zeitstempel die Tabelle nun im Sekundentakt in der ersten Zeile anzeigt.
Zum Verbandeln der Testfunktion mit einer Tabellenzeile hängt Zeile 25 bei jedem Aufruf eine neue Zeile an die Tabelle an. Dann ruft Zeile 26 die Testfunktion auf, die ihrerseits einen Channel zurückgibt und im Hintergrund ihren Netzwerktest vornimmt. Um dessen Ergebnisse abzufangen, startet Zeile 27 eine neue nebenläufige Goroutine mit einer Endlosschleife, die mittels einer »select«-Anweisung auf dem Channel lauscht. Kommt dort ein String an, frischt Zeile 32 mit »table.SetCell« den Inhalt des zugewiesenen Tabellenfelds auf.
Damit das auch tatsächlich auf dem Bildschirm erscheint, gilt es, die Anweisung an den GUI-Verwalter weiterzuleiten. Das erledigt die Funktion »app.QueueUpdateDraw()«, über die die GUI das Tabellenfeld beim nächsten Auffrischen neu zeichnet.
Palim-Palim
Nun gilt es, neue Netzwerktests nach Schema F in die Tabelle einzuhängen. Jeder Test besteht aus einer Funktion, die ein optionales String-Argument akzeptiert und einen Channel zurückreicht. Sie führt nebenläufig die ihr übertragene Testaufgabe wieder und wieder aus und reicht die Ergebnisse über den Channel an den Aufrufer zurück.
Das Anpingen von Servern oder deren IP-Adressen übernimmt Listing 5 mit der Funktion »ping()«, die entweder einen Host-Namen oder eine IP-Adresse als Argument entgegennimmt. An den Aufrufer gibt sie einen Channel zurück, in dem später Nachrichten der nebenläufig ausgeführten Tests eintreffen.
Listing 5
ping.go
package main
import (
"fmt"
"github.com/prometheus-community/pro-bing"
"time"
)
func ping(addr ...string) chan string {
ch := make(chan string)
firstTime := true
go func() {
for {
pinger, err := probing.NewPinger(addr[0])
pinger.Timeout, _ = time.ParseDuration("10s")
if err != nil {
ch <- err.Error()
time.Sleep(10 * time.Second)
continue
}
if firstTime {
ch <- "Pinging ..."
firstTime = false
}
pinger.Count = 3
err = pinger.Run()
if err != nil {
ch <- err.Error()
time.Sleep(10 * time.Second)
continue
}
stats := pinger.Statistics()
ch <- fmt.Sprintf("%v ", stats.Rtts)
time.Sleep(10 * time.Second)
}
}()
return ch
}
Ganz wie das Kommandozeilen-Utility »ping« schickt Listing 5 ICMP-Pakete an die angegebene Adresse und nutzt dafür das Paket pro-bing von Github, das es in Zeile 4 hereinzieht. Die neue Pinger-Instanz »pinger« aus Zeile 12 setzt in Zeile 13 einen Timeout von 10 Sekunden. Nach dessen Verstreichen nimmt der Pinger an, dass etwas schiefgelaufen ist und sich der Server nicht erreichen lässt. Klappt etwa die Namensauflösung für den Server nicht, posaunt Zeile 15 die Meldung in den Channel. Zeile 16 wartet anschließend 10 Sekunden. Dann springt »continue« in der folgenden Zeile in die nächste Iteration der Endlos-For-Schleife ab Zeile 11, um es erneut zu versuchen.
Erste Runde
Beim ersten Eintritt steht die Variable »firstTime« auf »true«. Zeile 20 schickt dann über den Channel »ch« den String »Pinging …« an den Aufrufer zurück, damit der weiß, dass der Test läuft. Die Funktion »Run()« in Zeile 24 führt gemäß Zeile 23 drei Pings auf das angegebene Netzwerkziel aus und blockiert den Programmfluss, solang der Vorgang läuft. Tritt ein Fehler auf, leitet Zeile 26 ihn per Channel an den Aufrufer weiter, und nach zehn Sekunden Pause geht »continue« in Zeile 28 in die nächste Runde.
Trifft eine Antwort auf die versendeten ICMP-Pakete ein, ist das Netzwerk offensichtlich intakt. Der Aufruf von »Statistics()« in Zeile 30 holt dann die statistischen Daten der absolvierten Tests ein. Die Antwortzeiten der einzelnen Ping-Requests liegen als Array-Slice von Sekundenwerten im Fließkommaformat in »stats.Rtts«. Zeile 31 verpackt alle drei Werte kurzerhand mit dem Platzhalter »%v« im Format-String in eine Zeichenkette, die Zeile 31 sofort in den Channel schiebt. Dort schnappt sie der Aufrufer am anderen Ende auf und zeigt sie in der grafischen Oberfläche an.
Verbindung steht?
Verbindet sich ein WLAN-Client mit dem Router, bekommt er eine IP-Adresse zugewiesen, die er im Erfolgsfall mit Kommandos wie »ifconfig« anzeigen kann. Es hilft bei der Fehlersuche, zu wissen, ob das geklappt hat. Daher forscht das Plugin aus Listing 6 auf den Netzwerkschnittstellen des Rechners nach lokalen IP-Adressen.
Listing 6
eth.go
package main
import (
"net"
"sort"
"strings"
"time"
)
func nifs(arg ...string) chan string {
ch := make(chan string)
go func() {
for {
eths, err := ifconfig()
if err != nil {
ch <- err.Error()
time.Sleep(10 * time.Second)
continue
}
ch <- strings.Join(eths, ", ")
time.Sleep(10 * time.Second)
}
}()
return ch
}
func ifconfig() ([]string, error) {
var list []string
ifaces, err := net.Interfaces()
if err != nil {
return list, err
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
return list, err
}
if len(addrs) == 0 {
continue
}
for _, addr := range addrs {
ip := strings.Split(addr.String(), "/")[0]
if net.ParseIP(ip).To4() != nil {
list = append(list, iface.Name+" "+ip)
}
}
}
sort.Strings(list)
return list, nil
}
Das Paket net aus dem Go-Standardfundus offeriert dazu die Funktion »Interfaces()«, die in Zeile 26 alle Netzwerkschnittstellen des Rechners zurückgibt. Bei einem Laptop im WLAN sind das normalerweise zwei: die WLAN-Karte und das Loopback-Interface. Bei einem zusätzlich verdrahteten Rechner können es auch mehr sein. Jedes dieser Interfaces besitzt nun, sofern verbunden, eine oder mehrere IP-Adressen. »Addrs()« in Zeile 31 holt sie ein, die For-Schleife ab Zeile 38 klappert sie ab.
In den USA hat kaum jemand daheim IPv6-Adressen. Daher filtert Zeile 40 alles heraus, was nicht nach IPv4 aussieht, bevor es den Namen der Schnittstelle (»name«, zum Beispiel »en0«) und die IP-Adresse (ohne den Subnetz-Zusatz) an den Array-Slice »list« anhängt. Den sortiert Zeile 45 alphabetisch, und Zeile 46 gibt ihn an den Aufrufer der Funktion »ifconfig()« in Zeile 12 zurück.
Dort passiert dasselbe wie schon zuvor: Ergebnisse wie etwa Fehlermeldungen oder erfolgreich eingeholte IP-Adresslisten werden als kommaseparierte Strings in den Channel eingespeist, auf dem das Hauptprogramm lauscht und eintrudelnde Nachrichten in der zugewiesenen Tabellenspalte anzeigt. Steht also in der »Ifconfig«-Zeile der Terminal-UI ein Eintrag im häuslich verwendeten IP-Bereich um 192.168.0.x, dann steht offensichtlich die Verbindung zum Router. Taucht in der Spalte hingegen nur das Loopback-Interface auf, stimmt etwas mit der Vergabe der IP-Adressen nicht.
Ganze Seiten holen
Schließlich bietet Listing 7 noch einen End-to-End-Test, indem es via Internet die Youtube-Titelseite lädt. Läuft dieser Test erfolgreich durch, ist alles in Butter. Da er in der letzten Zeile der UI auch noch die zum Einholen der Seite verstrichene Zeit in Sekunden angibt, lässt sich zudem die Geschwindigkeit der ISP-Verbindung abschätzen. Abbildung 1 zeigt, dass das Laden der Seite im Test nach 0,142 Sekunden erledigt war – vorbildlich.
Listing 7
www.go
package main
import (
"fmt"
"net/http"
"time"
)
func httpGet(arg ...string) chan string {
ch := make(chan string)
firstTime := true
go func() {
for {
if firstTime {
ch <- "Fetching ..."
firstTime = false
}
now := time.Now()
_, err := http.Get(arg[0])
if err != nil {
ch <- err.Error()
time.Sleep(10 * time.Second)
continue
}
dur := time.Since(now)
ch <- fmt.Sprintf("%.3f
OK ", dur.Seconds())
time.Sleep(10 * time.Second)
}
}()
return ch
}
Zur Ermittlung dieser Zahl setzt Listing 7 in Zeile 17 mit der Funktion »Get()« einen HTTP-Request ab, der so lang blockt, bis die Daten eintrudeln oder der Server einen Fehler liefert. Hängt die Anzeige in der Tabellenspalte später also bei »Fetching …«, dann stimmt etwas mit der Verbindung nicht. In dem Fall sollten die anderen Tests Hinweise auf die Ursache geben. Klappt hingegen die Auflösung des Host-Namens wegen fehlerhafter DNS-Konfiguration nicht, schiebt Zeile 19 die Fehlermeldung in den bereitgestellten Channel, von wo das Hauptprogramm den Fehler aufschnappt und ihn anzeigt.
Funktioniert alles, misst Zeile 23, wie lange der Vorgang gedauert hat. Dazu subtrahiert sie von der aktuellen Zeit die in Zeile 16 gesetzte Startzeit des Requests und schiebt den so erhaltenen Sekundenwert als Fließkommazahl in den Channel. Er erscheint dann mit der Meldung »OK« in der Tabellenspalte.
Der Dreisprung aus Listing 8 erstellt aus den Quellen des Hauptprogramms (Listing 4), den Test-Plugins (Listings 5 bis**7), der Uhr (Listing 1) sowie den Github-Paketen und deren Abhängigkeiten das Binary »wifi«. Dessen Aufruf startet die Terminal-UI und der User erfährt, wie es um das Netzwerk steht. Bei Bedarf lassen sich selbst geschriebene Plugins nach demselben Schema einhängen und in weiteren Tabellenzeilen anzeigen. (uba/jlu)
Listing 8
build-wifi.sh
$ go mod init wifi $ go mod tidy $ go build wifi.go clock.go eth.go ping.go www.go
Infos
- Datums- und Zeitangaben in Go formatieren: https://pkg.go.dev/time#pkg-constants










Danke für das Go-Tool!
Die Ausgabe von ifconfig reicht nicht ganz für alle Netzwerkschnittstellen aus.
Vielleicht untereinander auflisten.