Aus Linux-Magazin 05/2024

Go-Programm zeigt Internet-Messwerte an

© pat138241 / 123RF.com

Damit Mike Schilli die Auslastung seiner Internet-Verbindung im Blick hat, stibitzt ein auf einem Raspberry Pi laufendes Go-Programm Messwerte von einer Pfsense-Firewall und zeigt sie auf einem Mini-Display an.

Den aktiven Durchsatz auf einem Internet-Zugang zu messen, ist nicht ganz trivial, denn schließlich sollte keine Messsonde den Datenverkehr bremsen. Doch der Router am Internet-Zugang muss ohnehin alle Pakete ansehen und durchschaufeln, kann sie also genauso gut auch zählen und das Ergebnis per API bereitstellen.

Zu Hause nutze ich eine Pfsense-Firewall auf einem lüfterlosen Mini-PC, und darauf laufen einige Apps mit Zugriff auf den Paketdurchsatz (Abbildung 1). Dazu gehört Ntopng, das im Browser anzeigt, welcher LAN-Client gerade mit welchem Server im Internet kommuniziert und dergleichen mehr. Außerdem bietet die Software eine API mit Token-Authentifizierung, die unter anderem Zähler für die in beiden Richtungen transferierten Bits bereitstellt.

Die Anzeige soll aber nicht in einem Webbrowser laufen, sondern auf einem separaten Raspberry Pi, dem ich ein 50 Dollar teures Farb-Display für den Dauerbetrieb spendiert habe (Abbildung 2). So kann ich vom Schreibtisch aus dem Augenwinkel heraus sehen, wie viele Bits pro Sekunde gerade herein oder hinaus sausen. Als Nebeneffekt lässt sich blitzschnell die Frage beantworten, ob “das Internet mal wieder nicht geht”.

Abbildung 1: Das Pfsense-Dashboard.

Abbildung 1: Das Pfsense-Dashboard.

Abbildung 2: Das neue Display tut seinen Dienst.

Abbildung 2: Das neue Display tut seinen Dienst.

Himbeere als Helfer

Auf einem Raspberry Pi 4 mit Ethernet-Anschluss läuft dazu das Go-Programm aus den Sourcen dieser Ausgabe. Es holt alle fünf Sekunden den aktuellen Paketdurchsatz von der Pfsense-Firewall über 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.

Verwendet zum Beispiel im Haushalt jemand Netflix, zeigt der Graph (Abbildung 3) die schubartigen Server-Requests des Streaming-Clients mit bis zu 10 Mbit/s. 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 4 aus.

Abbildung 3: Ein typisches Durchsatzmuster beim Netflixen.

Abbildung 3: Ein typisches Durchsatzmuster beim Netflixen.

Abbildung 4: Ein Lasttest saturiert die Internet-Leitung.

Abbildung 4: Ein Lasttest saturiert die Internet-Leitung.

Flach bei Problemen

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

Abbildung 5: Ein Abbruch des Durchsatzes deutet auf einen Internet-Ausfall hin.

Abbildung 5: Ein Abbruch des Durchsatzes deutet auf einen Internet-Ausfall hin.

Wie holt der RasPi nun die aktuellen Lastwerte zur Anzeige von der Pfsense-App ab? Für den Zugriff benötigt er ein API-Token, das Ntopng unter dem Menüpunkt Settings | Users im Reiter User Auth Token generiert (Abbildung 6). Listing 1 legt den Hex-String in Zeile 9 in einer Konstante ab und kann damit via »fetchJSON()« ab Zeile 10 von der Pfsense-Appliance unter der angegebenen IP-Adresse und dem Lua-Pfad der Ntopng-App die aktuellen JSON-Daten abholen. In der API-Dokumentation [1] zu Ntopng findet sich eine rudimentäre Dokumentation der Pfade, den JSON-Inhalt muss der Entwickler selbst entschlüsseln.

Abbildung 6: Die Pfsense-App Ntopng stellt API-Tokens aus.

Abbildung 6: Die Pfsense-App Ntopng stellt API-Tokens aus.

Die Internet-Schnittstelle des Routers gibt Zeile 17 im Parameter »ifid« mit »0« vor, also als das erste und einzige Netzwerk-Interface der simplen Firewall. Das API-Token schickt der Client nicht als Teil der URL, sondern packt es in Zeile 23 als HTTP-Header vor den eigentlichen URL-Request. Zurück kommen vom Server detaillierte Daten zum Status der Firewall (Abbildung 7), aus denen die Funktion »fetchUpDown()« ab Zeile 40 in Listing 1 die Bits-per-second-Werte für Up- und Download herausfieselt.

Abbildung 7: In den JSON-Daten der Ntopng-API finden sich BPS-Werte für Up- und Download.

Abbildung 7: In den JSON-Daten der Ntopng-API finden sich BPS-Werte für Up- und Download.

Listing 1

fetcher.go

package main
import (
  "crypto/tls"
  "github.com/tidwall/gjson"
  "io/ioutil"
  "net/http"
  "net/url"
)
const APIKEY = "c82f1f7b3d65eaaa639fc229a8afcc62"
func fetchJSON() (string, error) {
  u := url.URL{
    Scheme: "https",
    Host:   "192.168.133.1:3000",
    Path:   "/lua/rest/v2/get/interface/data.lua",
  }
  p := u.Query()
  p.Set("ifid", "0")
  u.RawQuery = p.Encode()
  req, err := http.NewRequest("GET", u.String(), nil)
  if err != nil {
    return "", err
  }
  req.Header.Add("Authorization", "Token "+APIKEY)
  client := &http.Client{
    Transport: &http.Transport{
      TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
    },
  }
  resp, err := client.Do(req)
  if err != nil {
    return "", err
  }
  defer resp.Body.Close()
  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    return "", err
  }
  return string(body), nil
}
func fetchUpDown() (float64, float64, error) {
  json, err := fetchJSON()
  if err != nil {
    return 0, 0, err
  }
  down := gjson.Get(json, "rsp.throughput.download.bps").Float()
  up := gjson.Get(json, "rsp.throughput.upload.bps").Float()
  return down / 1000, up / 1000, nil
}

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« hinab in die Tiefen der JSON-Struktur navigiert. Dort steht 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 kbit/s herauskommen.

Die nun alle fünf Sekunden anfallenden Einzelwerte sammelt die in Listing 2 definierte Datenstruktur eines Ringpuffers, bis 30 Messwerte vorliegen, aus denen die Chart-Library später einen Graphen erzeugt. Ältere Werte vergisst der Ringpuffer praktisch und ohne Zutun, sobald der Zeiger einmal im Kreis gelaufen ist (Abbildung 8). Dabei weiß der Puffer zu jedem Zeitpunkt nur, was gerade das aktuelle Element ist, wie viele 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 8: Der simple Ringpuffer navigiert vor und zurück im Kreis.

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

Datenspeicher 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 kbit/s 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 Ring-Objekt erzeugen. »Add()« füttert ab Zeile 17 über Gos Receiver-Mechanismus neue Werte ein, »All()« gibt ab Zeile 25 alle auf dem Kreis vorhandenen Werte in drei Array-Slices zurück. Der erste enthält alle Zeitstempel der Messwerte, der zweite die Fließkommawerte der Upload-Messungen 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.

Bei der Navigation macht sich der Code zunutze, dass uninitialisierte Elemente auf dem Ring den Nullwert »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 den in »n« erinnerten Startpunkt erreicht.

Listing 2

ring.go

package main
import (
  "container/ring"
  "time"
)
type Dpoints struct {
  rp *ring.Ring
}
type Dpoint struct {
  dt   time.Time
  up   float64
  down float64
}
func NewRing(n int) *Dpoints {
  return &Dpoints{rp: ring.New(n)}
}
func (d *Dpoints) Add(up, down float64) {
  d.rp.Value = Dpoint{
    dt:   time.Now(),
    up:   up,
    down: down,
  }
  d.rp = d.rp.Next()
}
func (d Dpoints) All() ([]time.Time, []float64, []float64) {
  ups, downs := []float64{}, []float64{}
  times := []time.Time{}
  r := d.rp
  n := 0
  for i := 0; i < d.rp.Len(); i++ {
    r = r.Prev()
    if r.Value == nil {
      r = r.Next()
      break
    }
    n++
  }
  for i := 0; i < n; i++ {
    dp := r.Value.(Dpoint)
    times = append(times, dp.dt)
    ups = append(ups, dp.up)
    downs = append(downs, dp.down)
    r = r.Next()
  }
  return times, ups, downs
}

Schaubild in Farbe

Für die Schaubilder von Abbildung 3, Abbildung 4 und Abbildung 5 mit zwei Liniengraphen für Up- und Downloads kommt das Go-chart-Projekt von Github zum Einsatz. Die Funktion »drawChart()« (Listing 3, ab Zeile 11) nimmt einen Ringpuffer entgegen und legt eine fertige Chart-Datei in »netgraph.png« ab.

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

Listing 3

chart.go

package main
import (
  "fmt"
  "github.com/wcharczuk/go-chart/v2"
  "os"
  "time"
)
const GRAPH_FILE = "netgraph.png"
const GRAPH_WIDTH = 1920
const GRAPH_HEIGHT = 1000
func drawChart(ring *Dpoints) {
  up, down, err := fetchUpDown()
  if err != nil {
    panic(err)
  }
  ring.Add(up, down)
  times, ups, downs := ring.All()
  xAxisCfg := chart.XAxis{
    ValueFormatter: func(v interface{}) string {
      return time.Unix(0, int64(v.(float64))).Format("03:04:05")
    },
  }
  yAxisCfg := chart.YAxis{
    Range: &chart.LogarithmicRange{
      Max: 100000,
    },
    ValueFormatter: func(v interface{}) string {
      return fmt.Sprintf("%.2f MBps", v.(float64)/1000.0)
    },
  }
  upseries := chart.TimeSeries{
    XValues: times,
    YValues: ups,
    Style: chart.Style{
      StrokeColor: chart.ColorCyan,
      StrokeWidth: 10,
      FillColor:   chart.ColorGreen.WithAlpha(64),
    },
  }
  downseries := chart.TimeSeries{
    XValues: times,
    YValues: downs,
    Style: chart.Style{
      StrokeColor: chart.ColorRed,
      StrokeWidth: 10,
      FillColor:   chart.ColorBlue.WithAlpha(64),
    },
  }
  graph := chart.Chart{
    XAxis:  xAxisCfg,
    YAxis:  yAxisCfg,
    Height: GRAPH_HEIGHT,
    Width:  GRAPH_WIDTH,
    Series: []chart.Series{
      upseries,
      downseries,
    },
  }
  f, _ := os.Create(GRAPH_FILE)
  defer f.Close()
  graph.Render(chart.PNG, f)
}

Skala mit Logarithmus

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

Auf einer linearen Skala, die bis 50 Mbit/s reicht, wäre eine Variation im Bereich von 1 kbit/s aber nicht wahrnehmbar, sondern flach. Stattdessen 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 100 kbit/s bis 1 Mbit/s genauso hoch darstellt wie den Bereich zwischen 1 Mbit/s und 10 Mbit/s. Das ist 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 ordnet der Code ab Zeile 24 mit »LogarithmicRange« einer exponentiell anwachsenden Darstellung zu. Der Maximalwert von 100 000 entspricht 100 Mbit/s, und darunter folgen 10 Mbit/s, 1 Mbit/s, 100 kbit/s und so weiter in gleichen Abständen. So bleiben auch geringfügige Variationen sichtbar, und 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 Zeitreihen, die Funktion »Render()« formt daraus einen schönen Graphen in der angegebenen PNG-Datei. Damit die Library die X-Achse sauber 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 Werteformatierer der Y-Achse in Zeile 27 hingegen teilt lediglich die eintrudelnden Kilobit-Werte durch 1000, beschriftet also mit Mbit/s.

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

Die Go-Routine ab Zeile 29 läuft in einer Endlosschleife, in der ein Timer jeweils fünf Sekunden wartet. Dann erzeugt der Code mit »updateChart()« eine neue Bilddatei, liest sie ein und veranlasst mit einem »Refresh()« auf das Container-Objekt die GUI zum schlagartigen Auffrischen des gezeigten Bilds. Drückt der User [Q], springt die GUI den Callback ab Zeile 23 an, faltet das Fenster zusammen und bricht das Programm ab.

Listing 4

netgraph.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "os"
  "time"
)
func main() {
  a := app.New()
  w := a.NewWindow("Netgraph")
  width := float32(GRAPH_WIDTH)
  height := float32(GRAPH_HEIGHT)
  w.Resize(fyne.NewSize(width, height))
  w.SetFixedSize(true)
  ring := NewRing(30)
  img := updateChart(ring, width, height)
  con := container.NewWithoutLayout(img)
  w.SetContent(con)
  w.Canvas().SetOnTypedKey(
    func(ev *fyne.KeyEvent) {
      key := string(ev.Name)
      switch key {
      case "Q":
        os.Exit(0)
      }
    })
  go func() {
    for {
      select {
      case <-time.After(5 * time.Second):
        img = updateChart(ring, width, height)
        con.Refresh()
      }
    }
  }()
  w.ShowAndRun()
}
func updateChart(ring *Dpoints, width, height float32) *canvas.Image {
  drawChart(ring)
  img := canvas.NewImageFromFile(GRAPH_FILE)
  img.FillMode = canvas.ImageFillOriginal
  img.Resize(fyne.NewSize(width, height))
  return img
}

Kreuz mit Go

Der Zweisprung »go mod init/tidy« zieht alle abhängigen Libraries vom Netz, »go build« mit den Source-Dateien übersetzt alles lokal. Aber wie kommt nun das Go-Programm auf den Raspberry Pi? Erschwerend kommt in diesem Fall hinzu, dass im RasPi ein ARM-Prozessor seinen Dienst versieht, auf den meisten Linux-Kisten aber eine Intel-kompatible CPU.

Normalerweise macht es Go einfach, aus demselben Quellcode Binaries für andere Betriebssysteme oder 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 das Cross-Kompilieren beherrschen. Damit der Entwickler sich nicht ob der zahlreichen Einstellungen und Abhängigkeiten die Haare rauft, stellt das Fyne-Team die Toolchain Fyne-cross [2] bereit, die einen Docker-Container anlegt und darin dann den gewünschten Cross-Build ausführt.

Wie Abbildung 9 zeigt, richtet sich der Cross-Compiler »fyne-cross« im Go-Verzeichnis des Users häuslich ein. Wer ihn von dort mit »linux« als Target und »–arch=arm64« für die 64-Bit-ARM-Architektur (für einen 32-Bit-RasPi: »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. Nun noch das Binary in einen Pfad kopiert, auf dem RasPi mit Internet-Anschluss gern von einem privaten Server zum Herunterladen, und schon steht dort die Applikation bereit. Den API-Key und die IP-Adresse zur Firewall gilt es noch an die lokalen Gegebenheiten des Anwenders anzupassen.

Abbildung 9: Cross-Compile f&uuml;r ein RasPi-Binary.

Abbildung 9: Cross-Compile für ein RasPi-Binary.

Automatisch hochfahren

Damit der Raspberry Pi unter Pi OS sich gleich nach dem Booten selbststä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-Pi-Konfiguration auf Off stellen, damit der Minirechner nicht den Bildschirmschoner aktiviert.

Um das dauernd aktive Display zu schonen, empfiehlt es sich, im Graph mit »Background« und »FillColor« noch einen schwarzen Hintergrund einzustellen. Soll das Applikationsfenster im Fullscreen-Modus laufen, kann man das mit dem Tool Wmctrl über das Kommando »wmctrl -r “Netgraph” -b toggle,fullscreen« erledigen. Außerdem sollte man im Home-Verzeichnis unter »~/.config/autostart« eine neue Datei »netgraph.desktop« mit der Konfiguration aus Listing 5 anlegen.

Das darin gestartete Shell-Skript 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 – zuerst mit wenigen, dann mit immer mehr Messwerten. Es macht Spaß, ihn aus dem Augenwinkel zu verfolgen. (uba)

Listing 5

netgraph.desktop

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

Infos

  1. API-Dokumentation zu Ntopng: https://www.ntop.org/guides/ntopng/api/
  2. Tool zum Cross-Kompilieren von Fyne-Applikationen: https://docs.fyne.io/started/cross-compiling
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 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