Files
go-otel/internal/geoip/geoip.go
T
mats 8824af8699
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
fixes
2026-09-20 09:52:49 +02:00

141 lines
4.0 KiB
Go

// Package geoip resolves IP addresses against local MaxMind (GeoLite2 / GeoIP2)
// databases so the UI can show where an issue's client came from. Lookups are
// purely local; no IP ever leaves the process.
package geoip
import (
"encoding/json"
"log"
"net/http"
"net/netip"
"strings"
geoip2 "github.com/oschwald/geoip2-golang/v2"
)
// Lookup holds the opened databases. Either reader may be nil; a nil *Lookup
// means no database is configured.
type Lookup struct {
city *geoip2.Reader
asn *geoip2.Reader
}
// Result is the JSON shape returned to the UI. Fields the databases don't
// know are omitted.
type Result struct {
IP string `json:"ip"`
Configured bool `json:"configured"`
// Scope is set for addresses that can't be geolocated: "private",
// "loopback", "link-local" or "unspecified".
Scope string `json:"scope,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
InEU bool `json:"inEu,omitempty"`
Region string `json:"region,omitempty"`
City string `json:"city,omitempty"`
Postal string `json:"postal,omitempty"`
Timezone string `json:"timezone,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
Network string `json:"network,omitempty"`
ASN uint `json:"asn,omitempty"`
ASOrg string `json:"asOrg,omitempty"`
}
// Open opens the given database files; empty paths are skipped. It returns nil
// (and no error) when both are empty.
func Open(cityPath, asnPath string) (*Lookup, error) {
if cityPath == "" && asnPath == "" {
return nil, nil
}
l := &Lookup{}
var err error
if cityPath != "" {
if l.city, err = geoip2.Open(cityPath); err != nil {
return nil, err
}
}
if asnPath != "" {
if l.asn, err = geoip2.Open(asnPath); err != nil {
l.Close()
return nil, err
}
}
return l, nil
}
// Close releases the memory-mapped databases.
func (l *Lookup) Close() {
if l == nil {
return
}
if l.city != nil {
l.city.Close()
}
if l.asn != nil {
l.asn.Close()
}
}
// Lookup resolves ip. It never fails: an unparsable or unroutable address just
// yields a Result without geo fields.
func (l *Lookup) Lookup(ip netip.Addr) Result {
ip = ip.Unmap()
res := Result{IP: ip.String(), Configured: l != nil}
switch {
case ip.IsLoopback():
res.Scope = "loopback"
case ip.IsPrivate():
res.Scope = "private"
case ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast():
res.Scope = "link-local"
case ip.IsUnspecified():
res.Scope = "unspecified"
}
if l == nil || res.Scope != "" {
return res
}
if l.city != nil {
if c, err := l.city.City(ip); err == nil && c.HasData() {
res.Country = c.Country.Names.English
res.CountryCode = c.Country.ISOCode
res.InEU = c.Country.IsInEuropeanUnion
res.City = c.City.Names.English
res.Postal = c.Postal.Code
res.Timezone = c.Location.TimeZone
res.Latitude, res.Longitude = c.Location.Latitude, c.Location.Longitude
if n := len(c.Subdivisions); n > 0 {
res.Region = c.Subdivisions[0].Names.English
}
if c.Traits.Network.IsValid() {
res.Network = c.Traits.Network.String()
}
}
}
if l.asn != nil {
if a, err := l.asn.ASN(ip); err == nil && a.HasData() {
res.ASN, res.ASOrg = a.AutonomousSystemNumber, a.AutonomousSystemOrganization
if res.Network == "" && a.Network.IsValid() {
res.Network = a.Network.String()
}
}
}
return res
}
// Handler serves GET /api/ip/{ip}. Without a configured database it still
// answers (Configured=false) so the UI can explain how to enable it.
func (l *Lookup) Handler(w http.ResponseWriter, r *http.Request) {
ip, err := netip.ParseAddr(strings.TrimSpace(r.PathValue("ip")))
if err != nil {
http.Error(w, "invalid ip address", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "private, max-age=3600")
if err := json.NewEncoder(w).Encode(l.Lookup(ip)); err != nil {
log.Printf("geoip: encode: %v", err)
}
}