package main import ( "fmt" "log" "math/big" "net" "net/http" "time" "github.com/oschwald/maxminddb-golang" ) // geoASN ist der optionale MaxMind-GeoLite2-ASN-Reader. Nil, wenn keine DB // konfiguriert ist (KVER_GEOIP_ASN leer) oder das Öffnen fehlschlug -> die // ASN-Auflösung degradiert dann still zu "" und die App läuft normal weiter. var geoASN *maxminddb.Reader // asnRecord deckt nur die beiden Felder der GeoLite2-ASN-DB ab, die wir brauchen. type asnRecord struct { Number uint `maxminddb:"autonomous_system_number"` Org string `maxminddb:"autonomous_system_organization"` } // initGeoASN öffnet die ASN-Datenbank einmalig beim Start. Fehlt der Pfad oder // die Datei, bleibt das Feature inaktiv (kein harter Fehler). func initGeoASN() { path := envOr("KVER_GEOIP_ASN", "") if path == "" { return } r, err := maxminddb.Open(path) if err != nil { log.Printf("GeoIP-ASN deaktiviert: %v", err) return } geoASN = r log.Printf("GeoIP-ASN aktiv: %s", path) } // closeGeoASN gibt den Reader frei (beim Shutdown). func closeGeoASN() { if geoASN != nil { geoASN.Close() } } // clientIP zieht die IP aus r.RemoteAddr (mit oder ohne Port). Nil, wenn nicht // parsebar. func clientIP(remoteAddr string) net.IP { host := remoteAddr if h, _, err := net.SplitHostPort(remoteAddr); err == nil { host = h } return net.ParseIP(host) } // maskIP kürzt eine Client-IP auf das Netz: IPv4 auf /16 (erste 16 Bit), IPv6 // auf /32 (erste 32 Bit). /32 statt /48, weil Endkunden oft ein ganzes /48 oder // /56 zugeteilt bekommen -> ein /48 könnte einen einzelnen Anschluss // identifizieren; /32 landet auf Provider-Ebene und ist so grob wie das IPv4- // /16. Liefert nil bei nil. func maskIP(ip net.IP) net.IP { if ip == nil { return nil } if v4 := ip.To4(); v4 != nil { return v4.Mask(net.CIDRMask(16, 32)) } return ip.Mask(net.CIDRMask(32, 128)) } // lookupASN löst das bereits anonymisierte Netz zum autonomen System auf. Die // volle IP wird dafür bewusst NICHT verwendet -> wir kennen nur das maskierte // Netz (/16 bei IPv4, /32 bei IPv6). // // Ein solches Netz gehört oft nicht einem einzigen AS: gerade im APNIC-Raum ist // ein /16 in viele kleine Blöcke zerstückelt. Ein Lookup auf die Basisadresse // liefert dann nur den (zufälligen) Inhaber von Offset 0. Stattdessen zählen wir // per NetworksWithin alle ASNs im Netz und wählen das mit der größten // Adress-Abdeckung; gibt es weitere, hängen wir "(n weitere Treffer)" an. So // steht bei einem homogenen Netz schlicht der Betreiber, bei einem // zersplitterten der dominante plus ein ehrlicher Unsicherheits-Hinweis. // // Liefert z.B. "AS3320 Deutsche Telekom AG", "AS8881 1&1 Versatel GmbH // (2 weitere Treffer)" oder "" (keine DB/kein Treffer). func lookupASN(ip net.IP) string { if geoASN == nil || ip == nil { return "" } // Das anonymisierte Netz rekonstruieren — gleiche Maske wie maskIP. var network *net.IPNet if v4 := ip.To4(); v4 != nil { network = &net.IPNet{IP: v4, Mask: net.CIDRMask(16, 32)} } else { network = &net.IPNet{IP: ip, Mask: net.CIDRMask(32, 128)} } // Adress-Abdeckung je AS aufsummieren. big.Int, weil ein IPv6-Teilnetz im // /32 bis zu 2^96 Adressen umfasst und int64 sprengen würde. type cover struct { org string count *big.Int } covers := map[uint]*cover{} nets := geoASN.NetworksWithin(network) for nets.Next() { var rec asnRecord subnet, err := nets.Network(&rec) if err != nil || rec.Number == 0 { continue } ones, bits := subnet.Mask.Size() size := new(big.Int).Lsh(big.NewInt(1), uint(bits-ones)) c := covers[rec.Number] if c == nil { c = &cover{org: rec.Org, count: new(big.Int)} covers[rec.Number] = c } c.count.Add(c.count, size) } if len(covers) == 0 { return "" } // AS mit der größten Abdeckung wählen. Bei Gleichstand das kleinere ASN, // damit das Ergebnis bei zufälliger Map-Reihenfolge stabil bleibt. var bestNum uint var best *cover for num, c := range covers { if best == nil { best, bestNum = c, num continue } if cmp := c.count.Cmp(best.count); cmp > 0 || (cmp == 0 && num < bestNum) { best, bestNum = c, num } } label := fmt.Sprintf("AS%d", bestNum) if best.org != "" { label += " " + best.org } if extra := len(covers) - 1; extra > 0 { label += fmt.Sprintf(" (%d weitere Treffer)", extra) } return label } // Impressions laufen über einen einzelnen Worker statt einer Goroutine pro // Request. Das deckelt die Nebenläufigkeit (ein Flood erzeugt keine unbegrenzten // Goroutinen mit je einem ASN-Lookup) und macht den Shutdown sicher: der Worker // wird gestoppt, BEVOR db und geoASN geschlossen werden -- eine freilaufende // Goroutine könnte sonst nach closeGeoASN() auf den unmap-ten Reader zugreifen // (SIGSEGV; middleware.Recoverer fängt Panics in eigenen Goroutinen nicht). // Bei vollem Puffer wird der Zählwert verworfen -- ein Seitenaufruf darf nie // auf die Statistik warten. type impressionEvent struct { remote, path string } var ( impressionCh = make(chan impressionEvent, 256) impressionDone = make(chan struct{}) ) // startImpressionWorker startet den Schreib-Worker (nach initDB/initGeoASN). func startImpressionWorker() { go func() { defer close(impressionDone) for ev := range impressionCh { netw := maskIP(clientIP(ev.remote)) anon := "" if netw != nil { anon = netw.String() } recordImpression(ev.path, anon, lookupASN(netw)) } }() } // stopImpressionWorker lässt den Worker die gepufferten Events noch schreiben // und kehrt erst zurück, wenn er fertig ist (vor db.Close/closeGeoASN rufen). func stopImpressionWorker() { close(impressionCh) <-impressionDone } // trackImpression zählt einen Seitenaufruf pro (Tag, anonymisiertes Netz, Pfad) // und vermerkt das autonome System. Die IP wird zuerst maskiert; sowohl die // Speicherung als auch die ASN-Auflösung arbeiten nur auf dem anonymisierten // Netz. Gezielt nur auf den HTML-Seiten-Routen, nicht auf API oder Assets. // // Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf // darf nicht auf den DB-Schreibvorgang warten. r ist // nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei // Werte vorher heraus. func trackImpression(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case impressionCh <- impressionEvent{remote: r.RemoteAddr, path: r.URL.Path}: default: // Puffer voll oder Worker nicht gestartet (Tests): verwerfen } next.ServeHTTP(w, r) }) } // recordImpression erhöht den Zähler für (heute, Netz, Pfad) per Upsert. Das asn // wird nur beim ersten Insert gesetzt (pro Netz stabil). Fehler werden bewusst // geschluckt: ein verlorener Zählwert darf nie eine Seite stören. func recordImpression(path, anonIP, asn string) { if anonIP == "" { return } day := time.Now().UTC().Format("2006-01-02") db.Exec( `INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES ($1, $2, $3, $4, 1) ON CONFLICT (day, anon_ip, path) DO UPDATE SET hits = impression.hits + 1`, day, anonIP, path, asn, ) } // handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als // /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate // rechnen. Öffentlich, kein Auth. func handleStatsDetail(w http.ResponseWriter, r *http.Request) error { var users, entries, toplevel, votesLeft, votesRight int64 if err := db.QueryRow( `SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0), (SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0), (SELECT COUNT(*) FROM vote WHERE mode = 'left'), (SELECT COUNT(*) FROM vote WHERE mode = 'right')`). Scan(&users, &entries, &toplevel, &votesLeft, &votesRight); err != nil { return Internal(err) } var imprTotal, visitorsTotal int64 if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`). Scan(&imprTotal, &visitorsTotal); err != nil { return Internal(err) } // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das // Frontend dreht für die Achse auf aufsteigend. daily := []map[string]any{} rows, err := db.Query( `SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip) FROM impression GROUP BY day ORDER BY day DESC LIMIT 30`) if err != nil { return Internal(err) } defer rows.Close() for rows.Next() { var day string var hits, visitors int64 if err := rows.Scan(&day, &hits, &visitors); err != nil { return Internal(err) } daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors}) } if err := rows.Err(); err != nil { return Internal(err) } // Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)". topASNs := []map[string]any{} rows, err = db.Query( `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, SUM(hits)::bigint AS h, COUNT(DISTINCT anon_ip) FROM impression GROUP BY a ORDER BY h DESC LIMIT 10`) if err != nil { return Internal(err) } defer rows.Close() for rows.Next() { var asn string var hits, networks int64 if err := rows.Scan(&asn, &hits, &networks); err != nil { return Internal(err) } topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks}) } if err := rows.Err(); err != nil { return Internal(err) } writeJSON(w, http.StatusOK, map[string]any{ "users": users, "entries": entries, "toplevel": toplevel, "replies": entries - toplevel, "votes": map[string]any{"left": votesLeft, "right": votesRight}, "impressions_total": imprTotal, "visitors_total": visitorsTotal, "daily": daily, "top_asns": topASNs, }) return nil } // serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All), // damit trackImpression nur echte Seitenaufrufe von "/" zählt und nicht jedes // Asset. func serveIndex(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "web/index.html") }