Durchflussmesser (Linux-Magazin, Mai 2024)

Den aktiven Durchsatz auf einem Internetzugang zu messen ist nicht ganz trivial, denn schließlich sollte keine Messsonde den Datenverkehr bremsen. Doch der Router am Internetzugang muss eh alle Pakete anschauen und durchschaufeln, kann sie also genausogut auch zählen und das Ergebnis per API bereitstellen. Zuhause nutze ich eine Pfsense-Firewall (Abbildung 1) auf einem lüfterlosen Mini-PC, und darauf laufen einige Apps mit Zugriff auf den Paketdurchsatz (Abbildung 2). Eine davon ist NtopNG, die in im Browser anzeigt, welcher LAN-Client gerade mit welchem Server auf dem Internet kommuniziert und dergleichen mehr. Außerdem bietet die App eine API mit Token-Authentifizierung, die unter anderem Zähler liefert, die die transferierten Bits in beiden Richtungen angeben.

Die Anzeige soll aber nicht in einem Browser laufen, sondern auf einem separaten Raspberry Pi, dem ich eine 50 Dollar teures Farbdisplay für den Dauerbetrieb spendiert habe (Abbildung 3). So kann ich vom Schreibtisch aus aus dem Augenwinkel heraus sehen, wie viele Bits pro Sekunde gerade rein oder raus sausen. Als Nebeneffekt lässt sich so auch blitzschnell die Frage beantworten, ob "das Internet mal wieder nicht geht".

Abbildung 1: Eine Firewall-Appliance mit Pfsense

Abbildung 2: Das Pfsense-Dashboard

Abbildung 3: Das neue Display tut seinen Dienst

Himbeere als Helfer

Auf einem Raspberry Pi 4 mit Ethernetanschluss läuft dazu das Go-Programm aus den Sourcen dieser Ausgabe. Es holt alle 5 Sekunden den aktuellen Paketdurchsatz von der Pfsense-Firewall mittels deren API ab und speichert die Werte für Up- und Download mit dem aktuellen Zeitstempel in einem Ringpuffer. Aus den Daten der letzten zweieinhalb Minuten bereitet das Programm dann mittels der Chart-Bibliothek go-chart einen Graphen auf, der den Paketdurchsatz über die Zeit illustriert. Diese Grafik bringt eine GUI mittels der Fyne-Library auf den Desktop des Raspi, der sie alle fünf Sekunden ruckelfrei auffrischt.

Abbildung 4: Typisches Pattern beim Netflix-Gucken

Guckt zum Beispiel im Haushalt jemand Netflix, zeigt der Graph wie in Abbildung 4 die schubartigen Server-Requests des Streaming-Clients mit bis zu 10 Megabits pro Sekunde. Lasse ich hingegen den Lasttest meines ISPs laufen, der erst die maximale Down- und kurz darauf die Upload-Geschwindigkeit misst, sieht die Anzeige wie in Abbildung 5 aus.

Flach bei Problemen

Abbildung 5: Ein Lasttest saturiert die Internetleitung

Tritt hingegen ein Fehler auf, wie wenn jemand zu Testzwecken die Internet-Verbindung kappt, sinkt der Durchsatz praktisch auf Null wie in Abbildung 4. So lässt sich auf einen Blick sagen, ob irgendetwas nicht stimmt und die Fehlersuche kann beginnen.

Abbildung 6: Abbruch des Durchsatzes deutet auf einen Internetausfall hin

Wie holt der Raspi nun die aktuellen Lastwerte zur Anzeige von der Pfsense-App ab? Für den Zugriff benötigt er einen API-Token, den die NtopNG-App unter dem Menüpunkt Settings/Users unter dem Reiter "User Auth Token" generiert (Abbildung 7). Den Hex-String legt Listing 1 in Zeile 9 in einer Konstanten ab und kann damit in fetchJSON() ab Zeile 10 auf der Pfsense-Appliance unter der angegebenen IP-Adresse und dem Lua-Pfad der NtopNG-App die aktuellen JSON-Daten abholen. Auf [3] findet sich eine rudimentäre Dokumentation der Pfade, den JSON-Inhalt muss der Entwickler selbst entschlüsseln.

Abbildung 7: Die Pfsense-App NtopNG stellt API-Tokens aus

Die Internet-Schnittstelle des Routers gibt Zeile 17 im Parameter ifid mit 0 vor, also das erste und einzige Net-Interface der einfachen Firewall. Den API-Token schickt der Client nicht als Teil der URL, sondern packt ihn in Zeile 23 als HTTP-Header vor den eigentlichen URL-Request. Zurück kommen vom Server detaillierte Daten zum Status der Firewall (Abbildung 8), aus denen die Funktion fetchUpDown() ab Zeile 40 in Listing 1 die Bits-Per-Second-Werte für Upload und Download herausfieselt.

Listing 1: fetcher.go

    01 package main
    02 import (
    03   "crypto/tls"
    04   "github.com/tidwall/gjson"
    05   "io/ioutil"
    06   "net/http"
    07   "net/url"
    08 )
    09 const APIKEY = "35a3907943cdf9fdb85228627a06034c"
    10 func fetchJSON() (string, error) {
    11   u := url.URL{
    12     Scheme: "https",
    13     Host:   "192.168.0.1:3000",
    14     Path:   "/lua/rest/v2/get/interface/data.lua",
    15   }
    16   p := u.Query()
    17   p.Set("ifid", "0")
    18   u.RawQuery = p.Encode()
    19   req, err := http.NewRequest("GET", u.String(), nil)
    20   if err != nil {
    21     return "", err
    22   }
    23   req.Header.Add("Authorization", "Token "+APIKEY)
    24   client := &http.Client{
    25     Transport: &http.Transport{
    26       TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
    27     },
    28   }
    29   resp, err := client.Do(req)
    30   if err != nil {
    31     return "", err
    32   }
    33   defer resp.Body.Close()
    34   body, err := ioutil.ReadAll(resp.Body)
    35   if err != nil {
    36     return "", err
    37   }
    38   return string(body), nil
    39 }
    40 func fetchUpDown() (float64, float64, error) {
    41   json, err := fetchJSON()
    42   if err != nil {
    43     return 0, 0, err
    44   }
    45   down := gjson.Get(json, "rsp.throughput.download.bps").Float()
    46   up := gjson.Get(json, "rsp.throughput.upload.bps").Float()
    47   return down / 1000, up / 1000, nil
    48 }

Abbildung 8: In den JSON-Daten der NtopNG-API finden sich BPS-Werte für Up/Download

JSON mit Navigation

Dabei macht es sich der Code einfach, indem er die Library gjson von Github abholt und nach XPath-Manier über die Hierarchie rsp.throughput.download.bps runter in die Tiefen der JSON-Struktur navigiert. Dort stehen für upload und download jeweils eine Fließkommazahl, die gjson mit Float() als solche nach Go importiert. Die abschließende return-Anweisung teilt den Wert noch durch 1000, damit handlichere Kilobits pro Sekunde herauskommen.

Die nun alle fünf Sekunden anfallenden Einzelwerte sammelt die in Listing 2 definierte Datenstruktur eines Ringpuffers, bis 30 Messwerte vorliegen, aus denen später die Chart-Library einen Graphen erzeugt. Ältere Werte vergisst der Ringpuffer praktisch und ohne Zutun, sobald der Zeiger einmal im Kreis gelaufen ist (Abbildung 9). Dabei "weiß" der Puffer zu jedem Zeitpunkt nur, was gerade das aktuelle Element ist, wieviele Elemente auf der Kreisbahn liegen und wie er vom aktuellen zum nächsten (Next()) Element fährt oder in der anderen Richtung zum vorhergehenden (Prev()).

Abbildung 9: Der simple Ringpuffer navigiert vor und zurück im Kreis

OO in Go

Als Datenkübel definiert Listing 2 ab Zeile 9 die Struktur Dpoint, die für jeden Messwert den Zeitstempel, sowie Fließkommawerte für Up- und Download in KBps speichert. Den Ringpuffer aus Gos Standard-Library container/ring packt Zeile 6 in die Struktur Dpoints (Plural). Damit kann dann der Konstruktor NewRing() in Zeile 14 ein neues Ringobjekt erzeugen, Add() ab Zeile 17 über Gos Receiver-Mechanismus neue Werte einfüttern und All() ab Zeile 25 alle soweit auf dem Kreis vorhandenen Werte in drei Array-Slices zurückgeben. Der erste enthält alle Zeitstempel der Messwerte, der zweite die Fließkommawerte der Upload- und der dritte die der Download-Messungen. Grund dafür ist, dass die Chart-Library die Werte in diesem Format braucht, um den Graphen im X/Y-Koordinatensystem zu zeichnen.

Listing 2: ring.go

    01 package main
    02 import (
    03   "container/ring"
    04   "time"
    05 )
    06 type Dpoints struct {
    07   rp *ring.Ring
    08 }
    09 type Dpoint struct {
    10   dt   time.Time
    11   up   float64
    12   down float64
    13 }
    14 func NewRing(n int) *Dpoints {
    15   return &Dpoints{rp: ring.New(n)}
    16 }
    17 func (d *Dpoints) Add(up, down float64) {
    18   d.rp.Value = Dpoint{
    19     dt:   time.Now(),
    20     up:   up,
    21     down: down,
    22   }
    23   d.rp = d.rp.Next()
    24 }
    25 func (d Dpoints) All() ([]time.Time, []float64, []float64) {
    26   ups, downs := []float64{}, []float64{}
    27   times := []time.Time{}
    28   r := d.rp
    29   n := 0
    30   for i := 0; i < d.rp.Len(); i++ {
    31     r = r.Prev()
    32     if r.Value == nil {
    33       r = r.Next()
    34       break
    35     }
    36     n++
    37   }
    38   for i := 0; i < n; i++ {
    39     dp := r.Value.(Dpoint)
    40     times = append(times, dp.dt)
    41     ups = append(ups, dp.up)
    42     downs = append(downs, dp.down)
    43     r = r.Next()
    44   }
    45   return times, ups, downs
    46 }

Bei der Navigation macht sich der Code zunutze, dass uninitialisierte Elemente auf dem Ring den Null-Wert nil führen und Len() die Anzahl aller Elemente angibt. Die Funktion All() wandert also rückwärts, bis sie auf ein uninitialisiertes Element trifft oder einmal im Kreis gelaufen ist. Dann läuft sie wieder vorwärts und schnappt alle dabei gefundenen Messwerte auf, bis sie am in n erinnerten Startpunkt anlangt.

Schaubild in Farbe

Für die Schaubilder der Abbildungen 4-6 mit zwei Liniengraphen für Up- und Downloads kommt das go-chart-Projekt auf Github zum Einsatz. Die Funktion drawChart() aus Listing 3 ab Zeile 11 nimmt einen Ringpuffer entgegen und schreibt legt eine fertige Chart-Datei in netgraph.png ab.

Listing 3: chart.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/wcharczuk/go-chart/v2"
    05   "os"
    06   "time"
    07 )
    08 const GRAPH_FILE = "netgraph.png"
    09 const GRAPH_WIDTH = 1920
    10 const GRAPH_HEIGHT = 1000
    11 func drawChart(ring *Dpoints) {
    12   up, down, err := fetchUpDown()
    13   if err != nil {
    14     panic(err)
    15   }
    16   ring.Add(up, down)
    17   times, ups, downs := ring.All()
    18   xAxisCfg := chart.XAxis{
    19     ValueFormatter: func(v interface{}) string {
    20       return time.Unix(0, int64(v.(float64))).Format("03:04:05")
    21     },
    22   }
    23   yAxisCfg := chart.YAxis{
    24     Range: &chart.LogarithmicRange{
    25       Max:    100000,
    26     },
    27     ValueFormatter: func(v interface{}) string {
    28       return fmt.Sprintf("%.2f MBps", v.(float64)/1000.0)
    29     },
    30   }
    31   upseries := chart.TimeSeries{
    32     XValues: times,
    33     YValues: ups,
    34     Style: chart.Style{
    35       StrokeColor: chart.ColorCyan,
    36       StrokeWidth: 10,
    37       FillColor:   chart.ColorGreen.WithAlpha(64),
    38     },
    39   }
    40   downseries := chart.TimeSeries{
    41     XValues: times,
    42     YValues: downs,
    43     Style: chart.Style{
    44       StrokeColor: chart.ColorRed,
    45       StrokeWidth: 10,
    46       FillColor:   chart.ColorBlue.WithAlpha(64),
    47     },
    48   }
    49   graph := chart.Chart{
    50     XAxis:  xAxisCfg,
    51     YAxis:  yAxisCfg,
    52     Height: GRAPH_HEIGHT,
    53     Width:  GRAPH_WIDTH,
    54     Series: []chart.Series{
    55       upseries,
    56       downseries,
    57     },
    58   }
    59   f, _ := os.Create(GRAPH_FILE)
    60   defer f.Close()
    61   graph.Render(chart.PNG, f)
    62 }

Die Zeilen 31 und 40 definieren dazu zwei Zeitreihen vom Typ chart.TimeSeries, und jede bekommt in XValues ein Array-Slice der Zeitstempel in Unix-Sekunden, sowie in YValues die Messwerte als Fließkommawerte zugewiesen. Die Farbkombinationen Cyan/Grün (Upload) und Rot/Babyblau (Download) für die Graphen und deren Füllfläche scheinen willkürlich gewählt. Vorsicht, voreilig! Ich habe jahrzehntelang die Museen der Welt nach Bildern von Gerhard Richter abgeklappert, um diese erlesene Kombination zu erstellen.

Muss mit Logarithmus

Nun variiert die genutzte Bandbreite auf einer ISP-Verbindung oft um Größenordnungen. Ist fast nichts los, flitzen nur ein paar Kilobits hin und her, drückt aber jemand auf die Enter-Taste im Browser und der lädt ein paar Abbildungen vom Netz, schnellt der gemessene Wert schnell auf Megabits hoch. Eine Netflix-Verbindung zum Streamen eines Films gibt in regelmäßigen Schüben Vollgas und nutzt die ganze verfügbare Bandbreite von 50Mbit/Sekunde.

Auf einer linearen Skala, die bis 50MBps reicht, wäre eine Variation im Bereich von 1kBps aber überhaupt nicht wahrnehmbar sondern flach. Statt dessen sollte die Anzeige zwischen absolutem Stillstand und dümpelnder Verbindung sehr wohl zu unterscheiden wissen. Zu Hilfe kommt eine logarithmische Skala, die den Bereich von 100Kbps bis 1Mbps genauso hoch darstellt wie den Bereich zwischen 1MBps und 10MBps. Perfekt, um in jeder Größenordnung die Variationen zu beobachten, solange keine negativen Werte vorkommen, denn die kann der Logarithmus definitionsgemäß nicht.

Für die X-Achse mit den Zeitwerten definiert Zeile 18 in Listing 3 deswegen nichts besonderes, denn Zeitstempel wachsen linear mit der Zeit an. Die Messwerte auf der Y-Achse hingegen bekommen ab Zeile 24 mit LogarithmicRange eine exponentiell anwachsenden Darstellung verpasst. Der Maximalwert von 100000 entspricht 100Mbps, und darunter folgen 10Mbps, 1Mbps, 100Kbps, und so weiter in gleichen Abständen. So bleiben auch schmale Variationen im Dümpelbereich sichtbar, und auch bei Schüben mit hoher Bandbreite schießt der Graph nicht über das dargestellte Koordinatensystem hinaus.

Das Objekt vom Typ chart.Chart ab Zeile 49 verpackt beide Achsen und beide Zeitreihen, und die Funktion Render() formt daraus einen schönen Graphen in der angegebenen PNG-Datei. Damit die Library die X-Achse schön mit den Uhrzeiten der Messzeitpunkte beschriftet, definiert ValueFormatter in Zeile 19 eine Funktion, die die als Unix-Sekunden vorliegenden X-Werte erst in time.Time-Objekte umwandelt und diese anschließend mit Format("03:04:05") als Stunden, Minuten und Sekunden darstellt. Der Wertformatierer der Y-Achse in Zeile 27 hingegen teilt lediglich die eintrudelnden Kilobitwerte durch 1000, beschriftet also mit Mbit-Werten.

Das Hauptprogramm in Listing 4 muss nun nur noch die bereits definierten Utility-Funktionen zum Erzeugen der Chart-Datei aufrufen, sowie letztere in einem Applikationsfenster anzeigen und regelmäßig auffrischen. Dazu zieht es das universelle GUI-Framework Fyne heran und packt das von updateChart() ab Zeile 40 erzeugte Image-Objekt in einen Container, der wiederum im Applikationsfenster hängt.

Listing 4: netgraph.go

    01 package main
    02 import (
    03   "fyne.io/fyne/v2"
    04   "fyne.io/fyne/v2/app"
    05   "fyne.io/fyne/v2/canvas"
    06   "fyne.io/fyne/v2/container"
    07   "os"
    08   "time"
    09 )
    10 func main() {
    11   a := app.New()
    12   w := a.NewWindow("Netgraph")
    13   width := float32(GRAPH_WIDTH)
    14   height := float32(GRAPH_HEIGHT)
    15   w.Resize(fyne.NewSize(width, height))
    16   w.SetFixedSize(true)
    17   ring := NewRing(30)
    18   img := updateChart(ring, width, height)
    19   con := container.NewWithoutLayout(img)
    20   w.SetContent(con)
    21   w.Canvas().SetOnTypedKey(
    22     func(ev *fyne.KeyEvent) {
    23       key := string(ev.Name)
    24       switch key {
    25       case "Q":
    26         os.Exit(0)
    27       }
    28     })
    29   go func() {
    30     for {
    31       select {
    32       case <-time.After(5 * time.Second):
    33         img = updateChart(ring, width, height)
    34         con.Refresh()
    35       }
    36     }
    37   }()
    38   w.ShowAndRun()
    39 }
    40 func updateChart(ring *Dpoints, width, height float32) *canvas.Image {
    41   drawChart(ring)
    42   img := canvas.NewImageFromFile(GRAPH_FILE)
    43   img.FillMode = canvas.ImageFillOriginal
    44   img.Resize(fyne.NewSize(width, height))
    45   return img
    46 }

Die Go-Routine ab Zeile 29 nudelt in einer Endlosschleife, in der ein Timer jeweils 5 Sekunden wartet, mit updateChart() eine neue Bilddatei erzeugt und einliest, sowie mit Refresh() auf das Container-Objekt die GUI zum schlagartigen Auffrischen des gezeigten Bildes veranlasst. Tippt der User auf die "Q"-Taste, springt die GUI den Callback ab Zeile 23 an, faltet das Fenster zusammen und bricht das Programm ab.

Kreuz mit Go

Der übliche Zweisprung mit go mod init/tidy zieht alle abhängigen Libraries vom Netz und go build mit den Source-Dateien übersetzt alles lokal. Aber kommt nun das Go Programm auf den Raspi? Erschwerend kommt in diesem Fall hinzu, dass im Raspi ein ARM-Prozessor läuft und auf den meisten Linux-Kisten eine Intel-kompatible Architektur wie x86_64 oder amd64. Normalerweise macht es Go einfach, aus dem gleichen Source-Code Binaries für andere Betriebssyteme oder Hardware-Architekturen zu kompilieren. Dieser Spaß hat allerdings ein Loch, sobald eine Grafik-Library wie Fyne nativen C-Code einbindet, wie unter Linux zum Beispiel die X11-Library. Dann muss auch der C-Compiler die Cross-Compilation beherrschen, und damit der Entwickler sich nicht die Haare rauft ob der zahlreichen Einstellungen und Abhängigkeiten, stellt das Fyne-Team die Toolkette fyne-cross ([2]) bereit, die einen Docker-Container anlegt und darin dann den gewünschten Cross-Build ausführt.

Abbildung 10: Cross-Compile für ein Raspi-Binary

Wie Abbildung 10 zeigt, installiert sich der Cross-Compiler fyne-cross im Go-Verzeichnis des Users, und wer ihn von dort mit "linux" als Target und --arch=arm64 für die 64-bittige ARM-Architektur (wer einen 32-bit-Raspi hat, nutzt entsprechend arm) aufruft, erhält nach einigen Minuten, die hauptsächlich deswegen verstreichen, weil das Programm mehrere Layer eines Docker-Images herunterlädt, ein Binary für die Target-Plattform. Letztere muss dann nur noch das Binary in einen Pfad kopieren (auf dem Raspi mit Internetanschluss gerne von einem privaten Server zum Download) und schon steht dort die Applikation bereit. Der API-Key und die IP-Adresse zur Firewall sind an die lokalen Gegebenheiten anzupassen.

Listing 5: netgraph.desktop

    1 [Desktop Entry]
    2 Type=Application
    3 Name=Netgraph
    4 Exec=/bin/sh /home/pi/netgraph-startup.sh

Automatisch hochfahren

Damit der Raspi unter Raspberry Pi OS sich gleich nach dem Booten selbständig in den Desktop einloggt und die Applikation hochfährt, muss die Raspi-Konfiguration auf "Auto-Login" gestellt sein. Das sogenannte "Screen Blanking" sollte der User in der Raspberry-Konfiguration auf "Off" stellen, denn der Raspi soll nicht den Bildschirmschoner aktivieren. Um das dauernd laufende Display zu schonen, empfiehlt es sich, im Graphen mit Background und FillColor noch einen schwarzen Hintergrund einzustellen. Wer möchte, dass das Applikationsfenster im Fullscreen-Modus läuft, kann dies mit dem Tool wmctrl und dem Kommando wmctrl -r "Netgraph" -b toggle,fullscreen erledigen. Außerdem sollte im Home-Verzeichnis unter ~/.config/autostart eine neue Datei netgraph.desktop angelegt werden mit der Konfiguration in Listing 5. Das dort gestartete Shellskript kann entweder netgraph direkt aufrufen oder vielleicht erst die neueste Version vom Server laden und dann starten. Schon fängt der Raspi an, den Graphen anzuzeigen, erst mit wenigen, dann immer mehr Messwerten. Es macht Spaß, ihn aus dem Augenwinkel zu verfolgen.

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/05/snapshot/

[2]

Tool zur Cross-Compilierung von Fyne-Applikationen: https://docs.fyne.io/started/cross-compiling

[3]

API-Dokumentation der NtopNG-App: https://www.ntop.org/guides/ntopng/api/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc