Files
Kontrollverlust/stats.go
T
irrlicht 63240a6cf7 ASN-Auflösung: dominanter Betreiber statt Offset-0-Treffer
Das anonymisierte /16 (bzw. /32 bei IPv6) gehört oft nicht einem
einzigen AS -- gerade im APNIC-Raum ist es in viele kleine Bloecke
zerstueckelt. Der bisherige Lookup auf die Basisadresse lieferte dann
nur den zufaelligen Inhaber von Offset 0 (z.B. faelschlich "Rakuten
Mobile" fuer einen Besucher, der irgendwo im selben /16 sitzt).

Jetzt: per NetworksWithin alle ASNs im Netz zaehlen, das mit der
groessten Adress-Abdeckung waehlen und bei weiteren "(n weitere
Treffer)" anhaengen. Homogene Netze stehen sauber mit Betreibername,
zersplitterte tragen den dominanten plus ehrlichen Unsicherheits-Marker.
Arbeitet weiter ausschliesslich auf dem maskierten Netz, nie auf der
vollen IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:50:33 +02:00

278 lines
8.8 KiB
Go

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")
}