golib/net/geoip/databases.go
AJ ONeal 8f40bbf110
feat(geoip): Open falls back to lex-latest <edition>_*.tar.gz
Prefer <edition>_LATEST.tar.gz (what httpcache writes), but fall back
to the lexicographically greatest <edition>_*.tar.gz — MaxMind's dated
Content-Disposition names sort chronologically, so this picks the most
recent archive when the cache was populated by hand or by another tool.
Exposes FindTarGz for callers that need the resolved path.
2026-04-20 17:14:11 -06:00

142 lines
3.5 KiB
Go

package geoip
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"github.com/oschwald/geoip2-golang"
)
// Databases holds open GeoLite2 City + ASN readers.
type Databases struct {
City *geoip2.Reader
ASN *geoip2.Reader
}
// Open loads the City and ASN editions from dir. For each edition it
// prefers <edition>_LATEST.tar.gz and falls back to the
// lexicographically greatest <edition>_*.tar.gz match (MaxMind's
// Content-Disposition names sort chronologically by release date).
// Archives are extracted in memory — no .mmdb files are written to disk.
func Open(dir string) (*Databases, error) {
cityPath, err := FindTarGz(dir, CityEdition)
if err != nil {
return nil, fmt.Errorf("city: %w", err)
}
city, err := openMMDBTarGz(cityPath)
if err != nil {
return nil, fmt.Errorf("city: %w", err)
}
asnPath, err := FindTarGz(dir, ASNEdition)
if err != nil {
_ = city.Close()
return nil, fmt.Errorf("asn: %w", err)
}
asn, err := openMMDBTarGz(asnPath)
if err != nil {
_ = city.Close()
return nil, fmt.Errorf("asn: %w", err)
}
return &Databases{City: city, ASN: asn}, nil
}
// FindTarGz resolves the cached tarball path for edition inside dir,
// preferring <edition>_LATEST.tar.gz and falling back to the
// lexicographically greatest <edition>_*.tar.gz match.
func FindTarGz(dir, edition string) (string, error) {
preferred := filepath.Join(dir, TarGzName(edition))
if _, err := os.Stat(preferred); err == nil {
return preferred, nil
}
matches, err := filepath.Glob(filepath.Join(dir, edition+"_*.tar.gz"))
if err != nil {
return "", err
}
if len(matches) == 0 {
return "", fmt.Errorf("no %s_*.tar.gz in %s", edition, dir)
}
slices.Sort(matches)
return matches[len(matches)-1], nil
}
func openMMDBTarGz(path string) (*geoip2.Reader, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return nil, fmt.Errorf("gzip %s: %w", path, err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil, fmt.Errorf("no .mmdb entry in %s", path)
}
if err != nil {
return nil, err
}
if !strings.HasSuffix(hdr.Name, ".mmdb") {
continue
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
return geoip2.FromBytes(data)
}
}
// Close closes the city and ASN readers.
func (d *Databases) Close() error {
return errors.Join(d.City.Close(), d.ASN.Close())
}
// Info is the structured result of a GeoIP lookup.
type Info struct {
City string `json:"city,omitempty"`
Region string `json:"region,omitempty"`
Country string `json:"country,omitempty"`
CountryISO string `json:"country_iso,omitempty"`
ASN uint `json:"asn,omitzero"`
ASNOrg string `json:"asn_org,omitempty"`
}
// Lookup returns city + ASN info for ip. Returns a zero Info on unparseable
// IP or database miss.
func (d *Databases) Lookup(ip string) Info {
var info Info
addr, err := netip.ParseAddr(ip)
if err != nil {
return info
}
stdIP := addr.AsSlice()
if rec, err := d.City.City(stdIP); err == nil {
info.City = rec.City.Names["en"]
info.Country = rec.Country.Names["en"]
info.CountryISO = rec.Country.IsoCode
if len(rec.Subdivisions) > 0 {
if sub := rec.Subdivisions[0].Names["en"]; sub != "" && sub != info.City {
info.Region = sub
}
}
}
if rec, err := d.ASN.ASN(stdIP); err == nil {
info.ASN = rec.AutonomousSystemNumber
info.ASNOrg = rec.AutonomousSystemOrganization
}
return info
}