Besucher aus aller Welt (Linux-Magazin, März 2026)

Damit Mike Schilli sieht, von woher die Zugriffe auf seine Website erfolgen, stellt er sie live auf einer interaktiven Weltkarte dar.

Jedes Mal, wenn ich eine neue Ausgabe meines USA-Blogs freigeschaltet und die Massenmail an meine Leser herausgeschickt habe, schaue ich wie gebannt auf die Logdatei des Webservers (Abbildung 2), um zu sehen, woher die Zugriffe kommen. Statt langer Apache-Logzeilen richte ich meine müden Augen dann aber lieber auf die bunte Grafik in Abbildung 1, die in Echtzeit auf einer Weltkarte anzeigt, in welchen Ländern die neueste Ausgabe gerade konsumiert wird.

Alle paar Sekunden markiert der Browser die Umrisse des Landes, aus denen gerade ein Request kommt, und eine Tafel schnellt hoch, die nochmal das Land, die Stadt und den Service-Provider des Nutzers auflistet. Außerdem steht unten der Pfad der Seite, die gerade abgerufen wurde. Beim nächsten Request poppt ein neues Land hoch, und so geht das weiter.

Geschwindigkeit drosseln

Nun prasseln Requests öfter mal in schneller Abfolge auf den Webserver ein, wie etwa, wenn jemand eine Seite mit 20 Fotos abruft, deren URLs der Browser binnen Sekundenbruchteilen einzeln abruft. Dann muss die Anzeige auf die Bremse drücken, bevor mir schwindelig wird. Maximal eine Nachricht pro IP-Adresse pro Minute sind erlaubt und der Browser frischt die Anzeige maximal alle zwei Sekunden auf.

Nun loggt der Apache-Webserver tatsächlich nur die IP-Adressen der zugreifenden User-Browser (Abbildung 2). Wie man von einer IP-Adresse auf dem Internet auf die Geodaten des Users schließt, habe ich in [2] schon einmal erläutert. Kurz gesagt, es genügt nicht, einen DNS-Reverse-Lookup durchzuführen, vielmehr muss ein Datenhändler angezapft werden, der Kenntnis über die ISPs weltweit zugewiesenen IP-Addressblöcke hat und sie wenn möglich kostenlos per API-Call ausliefert.

Abbildung 1: Die interaktive Weltkarte mit Live-Daten im Browser

Auf die lange Bank

Die in den Zeilen der Logdatei gefundenen IPs müssen also per API-Request aufgelöst werden, und das dauert schon ein paar Sekundennbruchteile. Außerdem sollte man im kostenlosen Service-Tier nicht zu vehement auf den Anbieter [3] einhämmern. Deswegen bremst die Applikation sich freiwillig und schickt nur alle 10 Sekunden eine Anfrage, auch wenn die Requests schneller hereinpurzeln. Einmal eingeholte Geodaten für eine IPs werden sich zukünftig nicht mehr ändern, deswegen darf die Applikation sie zukünftig in aus dem Cache holen. So kommen Hobbyisten höchstwahrscheinlich mit weniger als 1000 Abfragen pro Tag aus, was [3] kostenfrei mit einem API-Key erlaubt, den man nach Registrierung ohne Kreditkarte erhält.

Das bedeutet nun, dass die Verarbeitung einer IP der Logdatei entweder billig oder teuer wird. Billige IPs stehen bereits im Cache und können sofort aufgelöst werden. Teure erfordern eine API-Anfrage, die nur alle 10 Sekunden erfolgen soll. Damit teure IPs die billigen nicht blockieren, schiebt der Code teure IPs auf die lange Bank, einen Go-Channel, aus dem eine parallel laufende Goroutine alle 10 Sekunden einen Eintrag holt und die Abfrage durchführt.

Abbildung 2: Der Webserver notiert IPs eingehender Requests in der Log-Datei.

Tausendsassa

Das vorgestellte Go-Programm server bringt alles unter einen Hut: Es startet einen Webserver auf Port 8081 auf dem lokalen System, und ein dort andockender Browser bekommt die Weltkarte serviert, gezeichnet von der Library go-echarts ([4]) mit Hilfe von JavaScript. Nach dem Laden der Karte verbindet sich der JavaScript-Code im Browser mittels des Websocket-Protokolls erneut mit dem Server. Abbildung 3 zeigt den Informationsfluss. Durch diese stabile bidirektionale Verbindung schickt der Server nun Nachrichten zu einprasselnen Webrequests aus der Logdatei access.log an die Weltkarte.

Abbildung 3: Der Browser kontaktiert den Webserver auf zwei Kanälen.

Zeilenweise schürfen

Die Logzeilen aus dem Access-Log in Abbildung 2 nimmt das Steuerprogramm server auf der Standareingabe entgegen. Das geschieht entweder lokal mit cat access.log | ./server oder wahlweise mit ssh und einem cat auf dem Remote-Host, falls die Datei auf einem Shared Host eines Hosting-Services liegt.

Es fieselt zu jeder Zeile die IP-Adresse des Abrufers heraus und löst deren Geo-Information auf. Die Geodaten des Landes, der Stadt, des ISPs und des abgerufenen Webpfades pumpt es anschließend über den offenen Websocket-Kanal ins JavaScript des darstellenden Browsers, der die Animation anschubst, die das Land blau ausmalt und die Infotafel hochschnellen lässt.

Die Felder jeder Logzeile seziert das Paket apachelogs von Github in Listing 1. Ziel der Funktion Logtail() ab Zeile 15 ist es, für jede Logzeile einen Wert vom Typ LogEvent (definiert ab Zeile 8) zu generieren und durch den Go-Channel out zur Weiterverarbeitung zu schicken. Später werden Variablen dieses Typs auch durch den Websocket-Kanal an den lauschenden JavaScript-Code im Browser geschickt werden. Vorher wird die Go-Struktur in Json umgewandelt, und die `json:`-Annotations in der Typ-Definition geben an, wie die Felder später in Json heißen werden.

Listing 1: logtail.go

    01 package webspy
    02 import (
    03   "bufio"
    04   "github.com/lmorg/apachelogs"
    05   "io"
    06   "strings"
    07 )
    08 type LogEvent struct {
    09   IP      string
    10   Path    string `json:"path"`
    11   ISP     string `json:"isp"`
    12   City    string `json:"city"`
    13   Country string `json:"country"`
    14 }
    15 func Logtail(r io.Reader, out chan LogEvent) error {
    16   scanner := bufio.NewScanner(r)
    17   for scanner.Scan() {
    18     line := strings.TrimRight(scanner.Text(), " ")
    19     entry, err, _ := apachelogs.ParseAccessLine(line)
    20     if err != nil {
    21       return err
    22     }
    23     out <- LogEvent{IP: entry.IP, Path: entry.URI}
    24   }
    25   return scanner.Err()
    26 }

Interessanterweise stolpert das apachelogs-Paket über Zeilen im Access-Log, die mit einem Leerzeichen abschließen, was bei den mir vorliegenden Dateien offensichtlich vorkommt. Aber nicht verzagen! Die Funktion TrimRight() aus dem Standardpaket strings behebt das Problem. Zeile 23 baut aus den geparsten Einträgen IP und URI einen Wert vom Typ LogEvent und schickt ihn durch den hereingereichten offenen Channel out.

Ozapft is

Listing 2 widmet sich mit der Funktion Tap() der parallelen Abarbeitung eintrudelnder Requests aus dem von Listing 1 belieferten Eingangs-Channel inCh. Das passiert in einer nebenläufigen Goroutine ab Zeile 10, die Logtail() aus Listing 1 abruft.

Listing 2: tap.go

    01 package webspy
    02 import (
    03   "io"
    04   "time"
    05 )
    06 func Tap(r io.Reader) (chan LogEvent, error) {
    07   inCh := make(chan LogEvent)
    08   outCh := make(chan LogEvent)
    09   exCh := make(chan LogEvent, 100)
    10   go func() {
    11     err := Logtail(r, inCh)
    12     if err != nil {
    13       panic(err)
    14     }
    15   }()
    16   geo := NewGeo()
    17   if err := geo.Init(); err != nil {
    18     return outCh, err
    19   }
    20   go worker(geo, exCh, outCh)
    21   go func() {
    22       for ev := range inCh {
    23         if geo.IsExpensive(ev.IP) {
    24           select {
    25           case exCh <- ev:
    26           default: // dropped
    27           }
    28         } else {
    29           process(geo, ev, outCh)
    30         }
    31       }
    32   }()
    33   return outCh, nil
    34 }
    35 func process(geo *Geo, ev LogEvent, outCh chan LogEvent) {
    36   res, err := geo.Lookup(ev.IP)
    37   if err != nil {
    38     panic(err)
    39   }
    40   ev.Country = res["country_name"]
    41   ev.City = res["city"]
    42   ev.ISP = res["isp"]
    43   outCh <- ev
    44 }
    45 func worker(geo *Geo, inCh chan LogEvent, outCh chan LogEvent) {
    46   ticker := time.NewTicker(10 * time.Second)
    47   defer ticker.Stop()
    48   for ev := range inCh {
    49     if !geo.IsExpensive(ev.IP) {
    50       continue
    51     }
    52     process(geo, ev, outCh)
    53     <-ticker.C
    54   }
    55 }

Die for-Schleife ab Zeile 22 arbeitet alle ankommenden Log-Events ab. Enthält ein Event eine bislang unbekannte IP-Adresse, liefert isExpensive() in Zeile 23 einen wahren Wert, denn die Bearbeitung erfordert einen "teuren" API-Aufruf. Um die Weiterverarbeitung "billiger" IPs an dieser Stelle nicht zu blockieren, stopft Zeile 25 den Event in die Warteschlange für die nebenläufige Routine worker ab Zeile 45, die alle 10 Sekunden einen wartenden Request abarbeitet. Der für die Schlange zuständige Channel exCh wurde vorher in Zeile 9 mit 100 Plätzen im Wartebereich angelegt.

Bankrotterklärung

Versucht nun Zeile 25 eine 101te Position in den vollen Wartebereich einzufügen, weil der Webserver die Requests schneller anliefert als der Bearbeiter mit API-Anfragen hinterherkommt, schlägt dies fehl. Da Zeile 25 aber innerhalb eines select-Konstrukts steht, blockiert der Warte-Channel nicht, sondern die default-Klausel in Zeile 26 wird aktiv. Diese macht rein gar nichts, sorgt aber damit dafür, dass der angelieferte Request übersprungen wird. Kommen die Requests schneller an als der Code sie abarbeiten kann, hilft alles nichts und als Bankrotterklärung fängt die Logik an, Neuzugänge wegzuwerfen. Das ist allemal besser als eine Warteschlange zu erzeugen, die keine Chance hat, jemals auf Null zu schrumpfen.

Kommt hingegen ein Request mit einer bereits vorher aufgelösten IP-Adresse an, leitet ihn Zeile 29 direkt an die Verarbeitungsfunktion process() ab Zeile 35 weiter. Diese ruft mit geo.Lookup() die API des Geo-Anbieters auf und bekommt in beinahe allen Fällen Land, Stadt, und ISP zur gegebenen IP.

Request-Spürnase

Die Kommunikation mit dem Geo-Anbieter wickelt Listing 3 ab. Zeile 20 liest mit der Funktion Lookup aus dem go-murmur-Paket den geheimen API-Key aus der Datei .murmur im Home-Verzeichnis des Users aus. Mit diesem Credential setzt Zeile 54 später einen HTTP-Request auf die API ab und bekommt im Erfolgsfall Json-Daten mit der gesuchten Lokalität zurück. Zeile 62 dröselt mit Unmarshal() das ankommende Json-Format auf und steckt die eingeholten Werte in eine Hashmap m, die APILookup() an den Aufrufer zurückreicht.

Listing 3: geo.go

    01 package webspy
    02 import (
    03   "encoding/json"
    04   "errors"
    05   "github.com/mschilli/go-murmur"
    06   "io/ioutil"
    07   "net/http"
    08   "net/url"
    09 )
    10 type Geo struct {
    11   cache  map[string]map[string]string
    12   APIKey string
    13 }
    14 func NewGeo() *Geo {
    15   return &Geo{}
    16 }
    17 func (geo *Geo) Init() error {
    18   geo.cache = map[string]map[string]string{}
    19   m := murmur.NewMurmur()
    20   key, err := m.Lookup("api.ipgeolocation.io")
    21   if err != nil {
    22     return err
    23   }
    24   geo.APIKey = key
    25   return nil
    26 }
    27 func (geo *Geo) IsExpensive(ip string) bool {
    28   _, ok := geo.cache[ip]
    29   return !ok
    30 }
    31 func (geo *Geo) Lookup(ip string) (map[string]string, error) {
    32   d, ok := geo.cache[ip]
    33   if ok {
    34     return d, nil
    35   }
    36   d, err := geo.APILookup(ip)
    37   if err != nil {
    38     return d, err
    39   }
    40   geo.cache[ip] = d
    41   return d, nil
    42 }
    43 func (geo *Geo) APILookup(ip string) (map[string]string, error) {
    44   u := url.URL{
    45     Scheme: "https",
    46     Host:   "api.ipgeolocation.io",
    47     Path:   "ipgeo",
    48   }
    49   q := u.Query()
    50   q.Set("apiKey", geo.APIKey)
    51   q.Set("ip", ip)
    52   u.RawQuery = q.Encode()
    53   var m map[string]string
    54   resp, err := http.Get(u.String())
    55   if err != nil {
    56     return m, err
    57   }
    58   body, err := ioutil.ReadAll(resp.Body)
    59   if err != nil {
    60     return m, err
    61   }
    62   json.Unmarshal(body, &m)
    63   _, found := m["ip"]
    64   if !found {
    65     return m, errors.New(m["message"])
    66   }
    67   return m, nil
    68 }

Damit die Auflösung das nächsten Mal schneller geht und auch nicht an der Quota des API-Anbieters nagt, steckt Zeile 40 das Ergebnis unter der IP als Schlüssel in die Hashmap cache.

Halt, halt, mach langsam!

Requests auf einem Webserver kommen gern in Schüben an, da der Browser zum Beispiel Fotos auf einer Seite sofort nach dem Einholen des HTML-Codes nachfordert. Dies sähe in der Web-UI recht chaotisch aus, und deswegen bremst Listing 4 den Datenfluss auf eine Nachricht per unterschiedlicher IP pro Minute.

Listing 4: limit.go

    01 package webspy
    02 import (
    03   "sync"
    04   "time"
    05 )
    06 type Limiter struct {
    07   mu   sync.Mutex
    08   last map[string]int64
    09 }
    10 func NewLimiter() *Limiter {
    11   return &Limiter{
    12     last: make(map[string]int64),
    13   }
    14 }
    15 func (lim *Limiter) ShallProceed(ip string, now time.Time) bool {
    16   minute := now.Unix() / 60
    17   lim.mu.Lock()
    18   defer lim.mu.Unlock()
    19   if lastMinute, ok := lim.last[ip]; ok && lastMinute == minute {
    20     return false
    21   }
    22   lim.last[ip] = minute
    23   return true
    24 }

Dazu legt sie in Zeile 8 eine Hashtabelle last an, die IP-Adressen auf Minutenwerte abbildet. Die Minuten zwischen dem Beginn der Unix-Zeit 1970 und der aktuellen Uhrzeit berechnet Zeile 16, indem sie die Unix-Sekunden durch 60 teilt. Besteht nun zu einer vorgegebenen IP schon ein Eintrag, und entspricht der gespeicherte Wert dem Minutenwert der aktuellen Uhrzeit, gibt Zeile 20 von ShallProceed() einen falschen Wert zurück. Das ist das Signal für den Aufrufer, dass die aktuelle IP nicht zu melden ist. Da Gos Hashtabellen nicht threadsicher sind, schirmt der Mutex mu in Zeile 7 die Funktionen gegen parallele Zugriffe ab, die sonst die internen Strukturen der Map korrumpieren könnten.

Doppelt gemoppelt

Nun fehlt noch das Herzstück des Auswertungs-Servers in Listing 5. Dessen Hauptfunktion main() ab Zeile 52 startet gleich zwei Handler für den Webserver auf Port 8081 des Heimcomputers: einen normalen Webserver und einen Websocket-Server. Letzterer fusst ebenfalls auf einer HTTP-Verbindung, bietet aber eine stabile bidirektionale Kommunikation zwischen Client und Server. Nach dem Aufruf des compilierten Go-Programms server zeigt dieses http://localhost:8081 an, und das gilt es, in die URL-Zeile des Browsers einzutippen.

Listing 5: server.go

    01 package main
    02 import (
    03   "net/http"
    04   "time"
    05   "webspy"
    06   "fmt"
    07   "os"
    08   "github.com/go-echarts/go-echarts/v2/charts"
    09   "github.com/go-echarts/go-echarts/v2/opts"
    10   "github.com/go-echarts/go-echarts/v2/types"
    11   "github.com/gorilla/websocket"
    12 )
    13 var upgrader = websocket.Upgrader{
    14   CheckOrigin: func(r *http.Request) bool { return true },
    15 }
    16 func mapPage(w http.ResponseWriter, _ *http.Request) {
    17   m := charts.NewMap()
    18   m.RegisterMapType("world")
    19   m.SetGlobalOptions(
    20     charts.WithInitializationOpts(opts.Initialization{
    21       PageTitle: "Web Server Requests By Country",
    22       Theme:     types.ThemeWesteros,
    23       ChartID:   "0",
    24     }),
    25   )
    26   m.AddSeries("Hits", []opts.MapData{})
    27   b, _ := os.ReadFile("map.js")
    28   m.AddJSFuncs(string(b))
    29   m.Render(w)
    30 }
    31 func wsHandler(w http.ResponseWriter, r *http.Request) {
    32   limiter := webspy.NewLimiter()
    33   c, err := upgrader.Upgrade(w, r, nil)
    34   if err != nil {
    35     return
    36   }
    37   defer c.Close()
    38   evCh, err := webspy.Tap(os.Stdin)
    39   if err != nil {
    40     panic(err)
    41   }
    42   for ev := range evCh {
    43     if !limiter.ShallProceed(ev.IP, time.Now()) {
    44       continue // skip
    45     }
    46     if err := c.WriteJSON(ev); err != nil {
    47       return
    48     }
    49     time.Sleep(2 * time.Second)
    50   }
    51 }
    52 func main() {
    53   http.HandleFunc("/", mapPage)
    54   http.HandleFunc("/ws", wsHandler)
    55   fmt.Println("http://localhost:8081")
    56   http.ListenAndServe(":8081", nil)
    57 }

Dockt nun der Browser am Serverprogramm an, ist der Handler für "/" ab Zeile 53 zuständig, der auf die Funktion mapPage() ab Zeile 16 verzweigt. Dort erzeugt Zeile 17 mit der Funktion NewMap() des importierten Pakets go-echarts eine Weltkarte. Bestimmte Länder werden später anhand von dynamisch eingetrichterten Daten, einer sogenannte "Series" farbig animiert. Der JavaScript-Code, der die Animation im Browser steuert und den Kontakt zum Webserver über eine Websocket-Verbindung aufbaut, liegt in einer externen Datei map.js, die Zeile 27 in Listing 5 einliest und mit AddJsFuncs() in Zeile 28 an den Browser schickt.

Baut der Browser die Websocket-Verbindung auf, kommt sie auf der Serverseite im Websocket-Handler in Zeile 54 in Listing 54 heraus. Der verzweigt auf die Funktion wsHandler() ab Zeile 31, die zunächst in Zeile 33 einen Upgrade der HTTP-Verbindung auf das Websocket-Protokoll durchführt. Dann zapft Zeile 38 mit Tap() aus Listing 2 den LogEvent-Strom aus dem Access-Log an. Die for-Schleife ab Zeile 42 arbeitet jeden ankommenden Event ab und fragt jeweils den Limiter, ob sich die Bearbeitung überhaupt rentiert. Falls js, schickt Zeile 46 den Event mit WriteJson() über die Websocket-Verbindung zum Browser, wo der Callback onmessage() im JavaScript-Code in Zeile 3 in Listing 6 ihn entgegennimmt.

Listing 6: map.js

    01 window.addEventListener("load", function () {
    02 var ws = new WebSocket("ws://" + location.host + "/ws");
    03 ws.onmessage = function (ev) {
    04   const msg = JSON.parse(ev.data);
    05   const data = [{
    06     name: msg.country,
    07     value: msg.city + "<br>" +
    08       msg.isp + "<br>" + msg.path}];
    09   goecharts_0.dispatchAction({
    10     type: "showTip",
    11     seriesIndex: 0,
    12     name: msg.country,
    13   });
    14   goecharts_0.setOption({
    15     series: [{
    16       name: "Web Requests by Country",
    17       data: data,
    18       emphasis: {
    19         itemStyle: {
    20           areaColor: "#0080ff", 
    21         },
    22       },
    23     }],
    24     tooltip: {
    25       formatter: function (params) {
    26         goecharts_0.dispatchAction({
    27           type: "highlight",
    28           seriesIndex: 0,
    29           name: params.name,
    30         });
    31         let res = params.name;
    32         if(params.data) {
    33           res += "<br>" + params.data.value;
    34         }
    35         return res;
    36       },
    37     },
    38   });
    39 };
    40 });

Zeile 4 in Listing 6 entpackt den Json-Salat mit JSON.parse() und wandelt so die ehemalige Go-Struktur in ein JavaScript-Objekt um. Die dispatchAction() ab Zeile 9 zeichnet dafür verantwortlich, dass für ein gemeldetes Land die Infotafel hochschnellt. Dass das zuständige JavaScript-Objekt goecharts_0 heißt, liegt übrigens daran, dass die ChartID in Zeile 23 von Listing 5 vorher auf 0 gesetzt wurde.

Animiert zur Erfreuung

Was auf der Infotafel steht, legt der formatter für den tooltip ab Zeile 25 fest. Der Callback zeichnet auch dafür verantwortlich, dass für ein gemeldetes Land dessen Umrisse eingefärbt werden, eine weitere dispatchAction() macht's möglich.

Um das Binary server zu kompilieren und auszuführen, müssen die Listings 1-4 alle in ein Verzeichnis, in dem dann go mod init webspy; go mod tidy aufzurufen ist. Die Listings 5 und 6 wandern dann in ein Unterverzeichnis server, und wer dort go build aufruft, erhält nach einer Weile das Binary server. Mit cat access.log | ./server aufgerufen druckt dieses den URL aus, auf dem der Server lauscht. Dann noch einen Webbrowser darauf einnorden, zurücklehnen, und die Show genießen!

Infos

[1]

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

[2]

Michael Schilli, "Gästebuch": Linux-Magazin 07/2024, S.xxx, <U>https://www.linux-magazin.de/ausgaben/2024/07/snapshot/<U>

[3]

https://ipgeolocation.io, Anbieter einer API zur Ermittlung der Geo-Location von IPs

[4]

go-echarts, "The adorable charts library for Golang", https://github.com/go-echarts/go-echarts

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.