Wer spricht da bitte? (Linux-Magazin, Juli 2024)

Ob User in meinem USA-Blog [2] schmökern oder sich über von mir persönlich zusammengestellte Stadtwanderwege in meiner Wahlheimat San Francisco informieren ([3]), es macht mir eine diebische Freude, anhand der Live-Logdaten auf dem zugehörigen Webserver zu mitverfolgen, wie meine Lesefröschchen durch die Inhalte navigieren. Die Terminal-UI in dieser Ausgabe hängt sich auf dem Webhost an eine oder mehrere Access-Log-Dateien an, liest mit, welche Seiten abgefragt wurden, und zeigt die aktuellen Zugriffe in einer ewig scrollenden Listbox an. Die unterste Zeile der Terminal-UI wirft gar das Röntgengerät an und zeigt das Ursprungsland eines anfragenden Web-Clients und noch einige Geo-Daten des Users mehr an (Abbildung 1).

Abbildung 1: Die UI des Log-Drills in Aktion

Woher ein Web-Request kommt, lässt sich über dessen Source-IP sagen, und bevor der Webserver die angeforderte Seite dorthin ausliefert, schreibt er die Adresse zuammen mit dem Datum und Informationen über den Pfad zur abgerufenen Seite in die Logdatei access.log (Abbildung 2).

Neugierig nachgeschnüffelt

Über diese IP lässt sich nun ermitteln, aus welchem Teil der Welt die Anfrage an den Webserver kam. Das geht über einen Reverse-Lookup des DNS-Eintrags des Clients hinaus, der im optimalen Fall nur auf die Domain kommt.

Abbildung 2: Der Webserver schreibt zu jedem Zugriff eine Zeile in die Log-Datei.

Abbildung 3 zeigt die Seite des Anbieters whatismyipaddress.com, der zur IP des abrufenden Browsers Informationen zum geografischen Standort und den Namen des benutzten ISPs anzeigt. Da ich diesen Screenshot im Urlaub im Ort Savannah im Bundesstaat Georgia aufgenommen habe, zeigt der Service den ISP der Ferienwohnung ("Comcast") an, und auch der Ort sowie der Bundestaat (Savannah, Georgia) stimmen akkurat.

Abbildung 3: Zur eigenen IP-Adresse zeigt ein Service Geo-Informationen an.

Wie funktioniert das nun? Für ihr Geschäft erwerben Internet-Provider (ISPs) über regionale Internet-Registries ganze Blöcke von IP-Adressen, die sie im normalen Betrieb üblicherweise dynamisch ihren Kunden zuweisen. Service-Anbieter wie zum Beispiel ipgeolocation.io ([4]) sammeln nun die den ISPs zugewiesenen IP-Blöcke in Datenbanken und bieten wiederum ihren Kunden die ISP-Informationen zu einer gegebenen IP-Adresse. Der "Developer"-Plan von ipgeolocation.io erlaubt bis zu kostenlose 1.000 API-Zugriffe pro Tag (30.000 im Monat) und liefert zu einer vorgegebenen IP-Adresse das Ursprungsland, den Bundesstaat, und oft den Ort mit Adresse sowie den Namen des ISPs oder VPN-Providers.

Zur Not als Eigenbau

Wie komplex ist nun die Konstruktion eines solchen Tools? Man sollte meinen, dass es für eine alltägliche Aufgabe wie das Auslesen einer Apache-Logdatei ein brauchbares Go-Paket gäbe, aber dem ist nicht so. Ich leide normalerweise nicht unter dem "Not Invented Here"-Syndrom, aber auf der Suche nach einem Paket zum Runterladen stieß ich nur auf närrisches Design und schlechten Code, also schrieb ich zähneknirschend Listing 1.

Listing 1: tap.go

    01 package main
    02 import (
    03   "github.com/hpcloud/tail"
    04   "log"
    05   "regexp"
    06   "time"
    07 )
    08 type LogEvent struct {
    09   dt     time.Time
    10   fields []string
    11 }
    12 func tapLog(fileName string, ch chan LogEvent) {
    13   t, err := tail.TailFile(fileName, tail.Config{Follow: true})
    14   if err != nil {
    15     log.Fatalf("%v", err)
    16   }
    17   re := regexp.MustCompile(`(\S+) \S+ \S+ \[(.*?)\] "[^/]*(/.*?)\s`)
    18   go func() {
    19     for {
    20       line := <-t.Lines
    21       matches := re.FindStringSubmatch(line.Text)
    22       if len(matches) != 4 {
    23         log.Fatalf("Invalid line: %s", line.Text)
    24       }
    25       layout := "02/Jan/2006:15:04:05 -0700"
    26       dt, err := time.Parse(layout, matches[2])
    27       if err != nil {
    28         log.Fatalf("Invalid time: %s", matches[2])
    29       }
    30       ch <- LogEvent{dt: dt, fields: matches[1:]}
    31     }
    32   }()
    33 }

Die Funktion tapLog() ab Zeile 12 nimmt den Pfad der Access-Log-Datei des Webservers und einen Channel entgegen, öffnet das Log und folgt ihm wie die Unix-Utility tail -f, falls der Server im Live-Betrieb weitere Zeilen anhängt. Diese schiebt Zeile 30 dann als Wertepaare aus Zeitstempel und Einzelfeldern, verpackt in eine Struktur vom Typ LogEvent in den Channel, aus dem der Aufrufer sie später bequem herausholt.

Wird tapLog() mit dem gleichen Ausgabe-Channel aber einer weiteren Logdatei aufgerufen, schiebt es die in der zweiten Datei ankommenden Logdaten ebenfalls in den Channel, so verarbeitet das Hauptprogramm später ohne Mühe ein Dutzend Logfiles verschiedener Webserver auf demselben Host.

Die Logdatei in Abbildung 2 zeigt das von Apache und ähnlichen Webservern genutzte recht simple Format. Am Anfang steht die IP-Addresse, es folgen zwei Felder mit Fehlercodes, dann der Zeitstempel und der Zugriffspfad relativ zur Server-Root. Diese Zeilen kann der reguläre Ausdruck in Listing 1 locker parsen.

Listing 2: ip.go

    01 package main
    02 import (
    03   "encoding/json"
    04   "errors"
    05   "io/ioutil"
    06   "net/http"
    07   "net/url"
    08 )
    09 func ipLookup(ip string) (string, error) {
    10   key := "57961c3db9ee0883c1893"
    11   u := url.URL{
    12     Scheme: "https",
    13     Host:   "api.ipgeolocation.io",
    14     Path:   "ipgeo",
    15   }
    16   q := u.Query()
    17   q.Set("apiKey", key)
    18   q.Set("ip", ip)
    19   u.RawQuery = q.Encode()
    20   resp, err := http.Get(u.String())
    21   if err != nil {
    22     return "", err
    23   }
    24   body, err := ioutil.ReadAll(resp.Body)
    25   if err != nil {
    26     return "", err
    27   }
    28   data := map[string]string{}
    29   json.Unmarshal(body, &data)
    30   _, found := data["ip"]
    31   if !found {
    32     return "", errors.New(data["message"])
    33   }
    34   return ip + " " +
    35     data["country_name"] + " " +
    36     data["state_prov"] + " " +
    37     data["city"] + " " +
    38     data["isp"], nil
    39 }

Listing 2 nimmt eine IP als String entgegen und kontaktiert den API-Service ipgeolocation.io unter dem Pfad /ipgeo. Mit einem als URL-Parameter mitgeschickten gültigen API-Key antwortet der Server mit den Geo-Daten zur IP im Json-Format. Mit dem Standardpaket net/http holt die Funktion Get() in Zeile 20 in Listing 2 die Antwort vom API-Server und Zeile 24 liest die übers Netz eintrudelnden Daten ein. Das im einfachen Key-Value-Format strukturierte Json kann Go mit json.Unmarshal() in Zeile 29 in die bereitgestellte Map data einlesen und der von ipLookup() zurückgereichte String mit den Geodaten enthält das Land, den Bundesstaat/Provinz, die Stadt, sowie den Namen des ISPs des Users.

Abbildung 4: Die kostenlose ipgeo-API auf ipgeolocation.io

Sparen durch Gedächtnis

Nun sind Abfragen im Sparplan des API-Providers wie gesagt begrenzt und damit das Tool bei mehreren Zugriffen desselben Nutzers nicht die gleiche IP mehrmals abfragen, bietet es sich an, einmal eingeholte Geo-Informationen in einem Cache zu speichern. Findet dann das Auswertungsprogramm Informationen zu einer IP im Cache, braucht es diese nicht aufwändig einzuholen sondern kann sie sogleich anzeigen.

Und noch eine weitere Sparmaßnahme kommt zum Einsatz. Auf einem ausgelasteten Webserver kommen die Anfragen oft in sehr kurzen Zeitabständen an, und würde das Tool jede einzelne Request-IP analysieren, wäre erstens das kostenlose Kontingent des Geo-API-Service ruckzuck aufgebraucht und zweitens käme das Tool gar nicht hinterher, denn jede Anfrage an den Geo-Service dauert ein Weilchen. Außerdem könnte kein Mensch die blitzschnell eintrudelnden Ergebnisse verfolgen, und deshalb begrenzt die Funktion limiter() ab Zeile 20 in Listing 3 die Anzahl der Requests, deren IPs tatsächlich analysiert werden.

Bremskanal

So soll das Tool nur etwa alle fünf Sekunden eine IP nachsehen und die in der Zwischenzeit ankommenden einfach ignorieren. Das macht limiter() mit Hilfe zweier Channels in und out. Aus in liest das select-Kommando in Zeile 30 in einer Endlosschleife ankommenden Einzelwerte und speichert sie in der Variablen queue ab, wobei ein eventuell dort bereits bestehender Wert überschrieben wird.

Alle fünf Sekunden feuert der Channel eines Tickers aus Gos Standard-Paket time die aktuelle Uhrzeit ab, select bekommt das Ergeignis in Zeile 33 mit und setzt die Variable <pause> auf einen falschen Wert, beendet also den Sekundenschlaf des Tools. In der nächsten Runde der for-Schleife bekommt dies die if-Bedingung in Zeile 25 spitz und Zeile 26 schiebt den zwischengespeicherten Wert in queue in den Ausgangs-Channel out. Dies hat den gewünschten Effekt, dass die letzte eingetrudelte IP analysiert wird, wenn der Timer abläuft und nicht erst wenn die nächste IP kommt, was bei einem kaum genutzten Webauftritt ein Weilchen dauern kann.

Listing 3: limiter.go

    01 package main
    02 import (
    03   "fmt"
    04   "time"
    05 )
    06 func Memoize(fn func(string) (string, error)) func(string) string {
    07   cache := map[string]string{}
    08   return func(n string) string {
    09     if val, ok := cache[n]; ok {
    10       return val + " (cached)"
    11     }
    12     val, err := fn(n)
    13     if err != nil {
    14       return fmt.Sprintf("err=%v", err)
    15     }
    16     cache[n] = val
    17     return val
    18   }
    19 }
    20 func limiter(in <-chan string, out chan<- string) {
    21   pause := false
    22   ticker := time.NewTicker(5 * time.Second)
    23   queue := ""
    24   for {
    25     if queue != "" && !pause {
    26       out <- queue
    27       queue = ""
    28       pause = true
    29     }
    30     select {
    31     case item := <-in:
    32       queue = item
    33     case <-ticker.C:
    34       pause = false
    35     }
    36   }
    37 }

So bekommt der Aufrufer im Ausgangs-Channel out alle 5 Sekunden einen Wert zugeschoben, egal wieviele Werte im Eingangs-Channel in in der Zwischenzeit eingetrudelt sind.

Hülle mit Gedächtnis

Das Caching bereits eingeholter Werte implementiert die Funktion Memoize() ab Zeile 6 in Listing 3. Da sich die Geo-Daten für eine IP im allgemeinen nicht sporadisch ändern, wäre es verschwenderisch, bei 20 eingehenden Requests von einer IP 20 mal den Geo-Service zu befragen. Vielmehr nimmt Memoize() später die Funktion ipGeo() entgegen, wickelt einen Wrapper darum und merkt sich einmal gelieferte Ergebnisse für spätere Aufrufe. Kommt dann später die gleiche IP wieder an, hat Memoize() das Ergebnis bereits im Cache und braucht den kostenintensiven Lookup gar nicht erst auszuführen, sondern gibt dem Aufrufer das vorher gespeicherte Ergebnis zurück.

Als Cache nutzt Zeile 7 in Listing 3 eine Hashtabelle vom Typ map, die die IP-Strings auf Geo-Strings abbildet. Ohne viel Federlesens ließe sich der Cache auf eine persistente Lösung umschreiben, unter Zuhilfenahme eines Key-Value-Store-Pakets wie zum Beispiel bolt auf Github, das Werte zu Schlüsseln performant in eine binäre Key/Value-Datenbank schreibt.

Grafisch im Terminal

Für die top-ähnliche Anzeige in Abbildung 1 spannt das Tool im Terminal eine Text-GUI auf. Listing 4 zieht dazu das schon öfter im Snapshot genutzte Paket termui von Github heran. Zeile 3 importiert das Hauptpaket unter dem Kürzel t, damit Zeile 8 in der Funktion uiStart() die GUI hochfahren kann. Klappt das, registriert Zeile 12 mit defer einen Handler zum Zuklappen der GUI am Ende der Funktion, damit das Terminal auch nach Abschluss der Applikation den User in die Shell tippen lasst und nicht im Raw-Modus aussetzt.

Listing 4: ui.go

    01 package main
    02 import (
    03   t "github.com/gizak/termui/v3"
    04   "github.com/gizak/termui/v3/widgets"
    05   "log"
    06 )
    07 func uiStart(rowCh chan string, geoCh chan string) {
    08   err := t.Init()
    09   if err != nil {
    10     log.Fatalln("Termui init failed")
    11   }
    12   defer t.Close()
    13   lb := widgets.NewList()
    14   lb.Title = "Logdrill"
    15   lb.TextStyle.Fg = t.ColorBlack
    16   geo := widgets.NewParagraph()
    17   geo.TextStyle.Fg = t.ColorGreen
    18   geo.Text = ""
    19   // window resizing
    20   var width, height int
    21   listSize := func() int { return height - 3 }
    22   resize := func() {
    23     width, height = t.TerminalDimensions()
    24     lb.SetRect(0, 0, width, listSize())
    25     geo.SetRect(0, listSize(), width, height)
    26     t.Render(lb, geo)
    27   }
    28   resize()
    29   uiEvents := t.PollEvents()
    30   for {
    31     select {
    32     case e := <-uiEvents:
    33       switch e.ID {
    34       case "<Resize>":
    35         resize()
    36       case "q", "<C-c>":
    37         return
    38       }
    39     case line := <-geoCh:
    40       geo.Text = line
    41       t.Render(geo)
    42     case line := <-rowCh:
    43       if len(lb.Rows) >= listSize() {
    44         lb.Rows = lb.Rows[0:listSize()]
    45       }
    46       lb.Rows = append([]string{line}, lb.Rows...)
    47       t.Render(lb)
    48     }
    49   }
    50 }

Die Anzeige in Abbildung 1 besteht aus zwei Komponenten, einem mehrzeiligen List-Widget am oberen Ende des Fensters und einem Paragraph-Widget als Statuszeile am unteren Rand, in der später die Geo-Daten der letzten IP-Adresse stehen. Listing 4 definiert dafür in Zeile 13 mit NewList() ein List-Widget in lb und Zeile 16 erzeugt mit NewParagraph() das einzeilige geo-Widget.

Fenster flutscht

Falls der User mit der Maus das Terminalfenster vergrößert oder verkleinert, sollte die GUI dynamisch reagieren und die Dimensionen der Widgets entsprechend anpassen. Dazu bietet das termui-Paket die Funktion TerminalDimensions(), die die aktuelle Höhe und Breite des Shellfensters in Zeichen angibt.

Zusammen mit einem Resize-Event, den Zeile 34 später abfängt und der Funktion resize() ab Zeile 22 flutscht das auch wie gewünscht. Und fährt die GUI hoch, löst sie einfach in Zeile 28 selbst die <resize()>-Funktion aus und bestimmt damit die Ausgangspositionen der Widgets. Die Eckpunkte dieser Widget-Rechtecke wünscht termui als X/Y-Koordinaten, die die linke obere Ecke bestimmen, zusammen mit der Breite und Höhe des Rechtecks. Da das Einzeiler-Widget am unteren Fensterrand nicht nur eine Textzeile zur Anzeige enthält, sondern auch noch mit einem Rand verziert wird, berechnet listSize() die Anzahl der im oberen List-Widget angezeigten Logreihen in Zeile 21 als die um drei Einheiten geschrumpfte Terminalhöhe.

Dynamisch befüllt

Dynamisch befüllt die UI die Widgets mit Daten aus den Channels rowCh und geoCh, die uiStart() anfangs als Parameter entgegennimmt, und über die später das Hauptprogramm laufend Updates zur Anzeige schicken wird. Neben Signalen wie dem erwähnten Resize-Event fängt die Haupt-Eventschleife ab Zeile 30 mit ihrer select-Anweisung auch die Tastatureingaben des Users ab, sowohl Ctrl-C als auch Q beenden das Programm.

Der zweite Event-Handler ab Zeile 39 bekommt neue Textzeilen zur Anzeige in der Geo-Sektion am unteren Fensterrand zugespielt. Wann immer neue Geodaten vorliegen, schnappt der Handler sie sich als String aus dem Channel geoCh, setzt das entsprechende Textattribut im geo-Widget und ruft dann die Render()-Methode der GUI auf, die das ihr überreichte Widget neu zeichnet.

Kommt hingegen eine neue Logzeile durch den Channel rowCh an, schnappt sie sich der Handler ab Zeile 42 und fügt sie vor dem ersten Element in die Listbox ein. Dazu verkettet append() in Zeile 46 ein neues einelementiges String-Array-Slice mit dem Rest der um Eins verkürzten alten Liste.

Damit die Listbox bei langen Logdateien nicht unnötig Speicher frisst, verkürzt die Array-Slice-Operation in Zeile 44 ihren Zeilenspeicher um eins, falls dieser über die angezeigte Länge hinausschießt.

Listing 5: logdrill.go

    01 package main
    02 import (
    03   "flag"
    04   "fmt"
    05   "os"
    06 )
    07 func main() {
    08   flag.Parse()
    09   files := flag.Args()
    10   if len(files) == 0 {
    11     fmt.Printf("No input file\n")
    12     os.Exit(1)
    13   }
    14   logCh := make(chan LogEvent)
    15   for _, file := range files {
    16     tapLog(file, logCh)
    17   }
    18   rowCh := make(chan string)
    19   limitInCh := make(chan string)
    20   limitOutCh := make(chan string)
    21   geoCh := make(chan string)
    22   geoCached := Memoize(ipLookup)
    23   go limiter(limitInCh, limitOutCh)
    24   go func() {
    25     for {
    26       select {
    27       case ip := <-limitOutCh:
    28         geoCh <- geoCached(ip)
    29       }
    30     }
    31   }()
    32   go func() {
    33     for ev := range logCh {
    34       limitInCh <- ev.fields[0]
    35       rowCh <- fmt.Sprintf("%s %s %s",
    36         ev.dt.Format("15:04:05"), ev.fields[0], ev.fields[2])
    37     }
    38   }()
    39   uiStart(rowCh, geoCh)
    40 }

Channel-Rudel

Das Hauptprogramm in Listing 5 verpflichtet die verschiedenen Programmteile in den anderen Listings zur Zusammenarbeit, indem es ein regelrechtes Rudel von Channels erzeugt und befüllt. So kommen im Channel logCh Log-Events an, die die for-Schleife ab Zeile 33 in einer parallel laufenden Goroutine ausliest und formatiert in den Channel rowCh einspeist, an dessen Ausgang wiederum die GUI lauscht. Die IP-Adresse pumpt Zeile 34 gesondert in den Channel limitInCh, wo sie der Limiter aufschnappt und entweder fallen lässt oder in den Ausgabe-Channel limitOutCh pumpt. Von dort holt sich Zeile 27 geowürdige IPs und ruft den gecachten Geo-Lookup geoCached() auf, dessen Ergebnis Zeile 28 wiederum in den Channel geoCh pumpt, wo die GUI nur darauf wartet, den String anzuzeigen. Ein wahres Powertool für dynamische Programmflüsse, diese Go-Channels!

Listing 6: build.sh

    1 $ go mod init logdrill
    2 $ go mod tidy
    3 $ go build logdrill.go ip.go ui.go tap.go limiter.go

Der Dreisprung in Listing 6 baut die Sourcen zu einem Binary logdrill zusammen, nachdem go mod tidy die für die Zusatzpakete notwendigen Sourcen von Github eingeholt und vorcompiliert hat. So ist zwar der Build-Prozess abhängig von einer Internetverbindung und funktionierenden Versionen der verwendeten Pakete auf Github, aber ein einmal fertiges Binary wird bis in alle Ewigkeit laufen. Wer mehrere Webserver auf demselben Host laufen hat, kann deren Access-Logs logdrill alle auf einmal als Parameter mitgeben, gerne mit Wildcard über die Shell. So ist auch bei nicht so populären Webauftritten immer was geboten.

Infos

[1]

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

[2]

"Zwei Deutsche in San Francisco", https://usarundbrief.com

[3]

"Hike This City", San Francisco Stadtwanderwege, https://hikethiscity.com

[4]

IP Geolocation API, https://ipgeolocation.io

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