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