141 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|