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 } // 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: der Seitenaufruf darf nicht auf den DB-Schreib // warten (SQLite serialisiert Schreiber, synchronous=FULL macht pro Commit ein // fsync). 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) { remote, path := r.RemoteAddr, r.URL.Path go func() { netw := maskIP(clientIP(remote)) anon := "" if netw != nil { anon = netw.String() } recordImpression(path, anon, lookupASN(netw)) }() 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) ON CONFLICT(day, anon_ip, path) DO UPDATE SET hits = 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) { var users, entries, toplevel, votesLeft, votesRight int64 db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users) db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) var imprTotal, visitorsTotal int64 db.QueryRow(`SELECT COALESCE(SUM(hits), 0), COUNT(DISTINCT anon_ip) FROM impression`). Scan(&imprTotal, &visitorsTotal) // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das // Frontend dreht für die Achse auf aufsteigend. daily := []map[string]any{} if rows, err := db.Query( `SELECT day, SUM(hits), COUNT(DISTINCT anon_ip) FROM impression GROUP BY day ORDER BY day DESC LIMIT 30`); err == nil { defer rows.Close() for rows.Next() { var day string var hits, visitors int64 rows.Scan(&day, &hits, &visitors) daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors}) } } // Beliebteste Seiten nach Impressions. topPages := []map[string]any{} if rows, err := db.Query( `SELECT path, SUM(hits) AS h FROM impression GROUP BY path ORDER BY h DESC LIMIT 10`); err == nil { defer rows.Close() for rows.Next() { var path string var hits int64 rows.Scan(&path, &hits) topPages = append(topPages, map[string]any{"path": path, "hits": hits}) } } // Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)". topASNs := []map[string]any{} if rows, err := db.Query( `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, SUM(hits) AS h, COUNT(DISTINCT anon_ip) FROM impression GROUP BY a ORDER BY h DESC LIMIT 10`); err == nil { defer rows.Close() for rows.Next() { var asn string var hits, networks int64 rows.Scan(&asn, &hits, &networks) topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks}) } } 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_pages": topPages, "top_asns": topASNs, }) } // 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") }