golib/net/geoip/geoip.go
AJ ONeal da33660c7c
feat: add net/geoip for MaxMind GeoLite2 database downloads
Downloader checks file mtime before fetching (30/day rate limit).
Extracts .mmdb atomically from tar.gz, preserving MaxMind's release
date as mtime so freshness checks survive restarts. Strips auth header
on redirects (302 → Cloudflare R2 presigned URL). Default: 3-day
threshold, 5-minute timeout.

Also ignores GeoIP.conf and *.mmdb in .gitignore.
2026-04-20 00:21:31 -06:00

142 lines
3.3 KiB
Go

package geoip
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
CityEdition = "GeoLite2-City"
ASNEdition = "GeoLite2-ASN"
CountryEdition = "GeoLite2-Country"
downloadBase = "https://download.maxmind.com/geoip/databases"
defaultFreshDays = 3
defaultTimeout = 5 * time.Minute
)
// Downloader fetches MaxMind GeoLite2 .mmdb files from the download API.
// It checks file mtime before downloading to stay within the 30/day rate limit.
//
// MaxMind preserves the database release date as the mtime of the .mmdb entry
// inside the tar archive. After extraction, mtime reflects data age — not
// download time — so it is reliable for freshness checks across restarts.
type Downloader struct {
AccountID string
LicenseKey string
FreshDays int // 0 uses 3
Timeout time.Duration // 0 uses 5m
}
// New returns a Downloader configured with the given credentials.
func New(accountID, licenseKey string) *Downloader {
return &Downloader{AccountID: accountID, LicenseKey: licenseKey}
}
// Fetch downloads the named edition to path if the file is stale (mtime older
// than FreshDays). Returns whether the file was updated.
func (d *Downloader) Fetch(edition, path string) (bool, error) {
freshDays := d.FreshDays
if freshDays == 0 {
freshDays = defaultFreshDays
}
if info, err := os.Stat(path); err == nil {
if time.Since(info.ModTime()) < time.Duration(freshDays)*24*time.Hour {
return false, nil
}
}
timeout := d.Timeout
if timeout == 0 {
timeout = defaultTimeout
}
url := fmt.Sprintf("%s/%s/download?suffix=tar.gz", downloadBase, edition)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.SetBasicAuth(d.AccountID, d.LicenseKey)
// Strip auth on redirects: MaxMind issues a 302 to a Cloudflare R2 presigned
// URL that must not receive our credentials.
client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
req.Header.Del("Authorization")
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("unexpected status %d fetching %s", resp.StatusCode, url)
}
if err := extractMMDB(resp.Body, path); err != nil {
return false, fmt.Errorf("%s: %w", edition, err)
}
return true, nil
}
// extractMMDB reads a MaxMind tar.gz archive, writes the .mmdb entry to path
// atomically (via tmp+rename), and sets its mtime to MaxMind's release date.
func extractMMDB(r io.Reader, path string) error {
gr, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return fmt.Errorf("no .mmdb file found in archive")
}
if err != nil {
return err
}
if !strings.HasSuffix(hdr.Name, ".mmdb") {
continue
}
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
os.Remove(tmp)
return err
}
f.Close()
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
// Preserve MaxMind's release date so mtime == data age, not download time.
if !hdr.ModTime.IsZero() {
os.Chtimes(path, hdr.ModTime, hdr.ModTime)
}
return nil
}
}