mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
297 lines
7.2 KiB
Go
297 lines
7.2 KiB
Go
package geoip
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/therootcompany/golib/https"
|
|
"github.com/therootcompany/golib/net/httpcache"
|
|
"github.com/therootcompany/golib/sync/cacheable"
|
|
)
|
|
|
|
const (
|
|
DefaultFailureBackoff = 6 * time.Hour
|
|
DefaultRefreshTimeout = 24*time.Hour + time.Minute + 7*time.Second
|
|
)
|
|
|
|
// GeoIPDB downloads the GeoLite2 archives named by a GeoIP.conf. It
|
|
// implements dataset.Upstream and keeps freshness/backoff policy in the
|
|
// httpcache layer.
|
|
type GeoIPDB struct {
|
|
ConfPath string
|
|
Dir string
|
|
BaseURL string
|
|
FreshDays int
|
|
FailureBackoff time.Duration
|
|
RefreshTimeout time.Duration
|
|
HTTPClient *http.Client
|
|
|
|
conf *Conf
|
|
auth http.Header
|
|
maxAge time.Duration
|
|
cachers []*httpcache.Cacher
|
|
current atomic.Pointer[Databases]
|
|
start sync.Once
|
|
generation atomic.Uint64
|
|
cancel context.CancelFunc
|
|
loadedAt atomic.Pointer[time.Time]
|
|
lastErr atomic.Pointer[error]
|
|
refresh cacheable.Refresh
|
|
local bool
|
|
}
|
|
|
|
// NewGeoIPDB parses confPath and creates a fetcher for its editions.
|
|
func NewGeoIPDB(confPath string) (*GeoIPDB, error) {
|
|
data, err := os.ReadFile(confPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conf, err := ParseConf(string(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", confPath, err)
|
|
}
|
|
if len(conf.EditionIDs) == 0 {
|
|
return nil, fmt.Errorf("no EditionIDs found in %s", confPath)
|
|
}
|
|
dir := conf.DatabaseDirectory
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
return &GeoIPDB{ConfPath: confPath, Dir: dir, BaseURL: conf.BaseURL, conf: conf}, nil
|
|
}
|
|
|
|
func (f *GeoIPDB) ready() error {
|
|
if f.auth != nil {
|
|
return nil
|
|
}
|
|
f.auth = http.Header{"Authorization": []string{
|
|
"Basic " + base64.StdEncoding.EncodeToString([]byte(f.conf.AccountID+":"+f.conf.LicenseKey)),
|
|
}}
|
|
freshDays := f.FreshDays
|
|
if freshDays <= 0 {
|
|
freshDays = 3
|
|
}
|
|
f.maxAge = time.Duration(freshDays) * 24 * time.Hour
|
|
backoff := f.FailureBackoff
|
|
if backoff == 0 {
|
|
backoff = DefaultFailureBackoff
|
|
}
|
|
base := f.BaseURL
|
|
if base == "" {
|
|
base = DownloadBase
|
|
}
|
|
client := f.HTTPClient
|
|
if client == nil {
|
|
client = https.NewSlowClient(5 * time.Minute)
|
|
}
|
|
f.cachers = make([]*httpcache.Cacher, 0, len(f.conf.EditionIDs))
|
|
for _, edition := range f.conf.EditionIDs {
|
|
path := filepath.Join(f.Dir, TarGzName(edition))
|
|
cacher := httpcache.NewWith(base+"/"+edition+"/download?suffix=tar.gz", path, client)
|
|
cacher.MaxAge = f.maxAge
|
|
cacher.FailureBackoff = backoff
|
|
cacher.Header = f.auth
|
|
f.cachers = append(f.cachers, cacher)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Fetch downloads all configured editions and reports whether any changed.
|
|
var (
|
|
_ cacheable.Cacheable[Databases] = (*GeoIPDB)(nil)
|
|
_ cacheable.Mutable[Databases] = (*GeoIPDB)(nil)
|
|
_ cacheable.Inspectable = (*GeoIPDB)(nil)
|
|
_ cacheable.Tickable = (*GeoIPDB)(nil)
|
|
)
|
|
|
|
// Current returns the newest completed City+ASN snapshot without blocking.
|
|
func (f *GeoIPDB) Current() *Databases {
|
|
return f.current.Load()
|
|
}
|
|
|
|
// Due reports whether the source should be revalidated. The fetcher
|
|
// delegates freshness checks to httpcache, so this is deliberately cheap.
|
|
func (f *GeoIPDB) Due(ctx context.Context) (bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
status := f.Status()
|
|
return !status.HasValue || status.StaleAt.IsZero() || !time.Now().Before(status.StaleAt), nil
|
|
}
|
|
|
|
// Revalidate fetches archives when stale and publishes City and ASN together.
|
|
func (f *GeoIPDB) Revalidate(ctx context.Context) (started bool, err error) {
|
|
due, err := f.Due(ctx)
|
|
if err != nil || !due {
|
|
return false, err
|
|
}
|
|
if !f.refresh.Begin() {
|
|
return false, nil
|
|
}
|
|
go func() {
|
|
timeout := f.RefreshTimeout
|
|
if timeout <= 0 {
|
|
timeout = DefaultRefreshTimeout
|
|
}
|
|
refreshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
|
defer cancel()
|
|
f.refresh.Finish(f.update(refreshCtx))
|
|
}()
|
|
return true, nil
|
|
}
|
|
|
|
func (f *GeoIPDB) update(ctx context.Context) error {
|
|
generation := f.generation.Load()
|
|
changed, err := f.Update(ctx)
|
|
if err != nil {
|
|
errCopy := err
|
|
f.lastErr.Store(&errCopy)
|
|
return err
|
|
}
|
|
if !changed && f.current.Load() != nil {
|
|
return nil
|
|
}
|
|
databases, err := Open(f.Dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.generation.Load() != generation {
|
|
_ = databases.Close()
|
|
return nil
|
|
}
|
|
previous := f.current.Swap(databases)
|
|
if previous != nil {
|
|
_ = previous.Close()
|
|
}
|
|
now := time.Now()
|
|
f.loadedAt.Store(&now)
|
|
f.lastErr.Store(nil)
|
|
return nil
|
|
}
|
|
|
|
func (f *GeoIPDB) Set(databases *Databases) error {
|
|
f.generation.Add(1)
|
|
previous := f.current.Swap(databases)
|
|
if previous != nil && previous != databases {
|
|
_ = previous.Close()
|
|
}
|
|
if databases != nil {
|
|
now := time.Now()
|
|
f.loadedAt.Store(&now)
|
|
} else {
|
|
f.loadedAt.Store(nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *GeoIPDB) Clear() error {
|
|
return f.Set(nil)
|
|
}
|
|
|
|
func (f *GeoIPDB) Status() cacheable.Status {
|
|
loadedAt := time.Time{}
|
|
if t := f.loadedAt.Load(); t != nil {
|
|
loadedAt = *t
|
|
}
|
|
var lastErr error
|
|
if p := f.lastErr.Load(); p != nil {
|
|
lastErr = *p
|
|
}
|
|
status := cacheable.Status{LoadedAt: loadedAt, HasValue: f.Current() != nil, Refreshing: f.refresh.Running(), LastError: lastErr}
|
|
if !loadedAt.IsZero() && f.maxAge > 0 {
|
|
status.StaleAt = loadedAt.Add(f.maxAge)
|
|
}
|
|
return status
|
|
}
|
|
|
|
// Load returns the newest completed snapshot. A failed refresh keeps the last
|
|
// good snapshot, but is returned to the caller for observability.
|
|
func (f *GeoIPDB) Load(ctx context.Context, wait bool) (*Databases, error) {
|
|
if _, err := f.Revalidate(ctx); err != nil {
|
|
return f.Current(), err
|
|
}
|
|
if f.Current() == nil || wait {
|
|
if err := f.refresh.Wait(ctx); err != nil {
|
|
return f.Current(), err
|
|
}
|
|
}
|
|
return f.Current(), nil
|
|
}
|
|
|
|
// Start starts at most one optional background revalidation ticker.
|
|
func (f *GeoIPDB) Start(ctx context.Context, interval time.Duration) {
|
|
f.start.Do(func() {
|
|
if interval <= 0 {
|
|
interval = time.Hour
|
|
}
|
|
tickCtx, cancel := context.WithCancel(ctx)
|
|
f.cancel = cancel
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-tickCtx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
_, _ = f.Revalidate(tickCtx)
|
|
}
|
|
}
|
|
}()
|
|
})
|
|
}
|
|
|
|
// Stop stops the optional background ticker and waits for any in-flight
|
|
// refresh to finish or reach RefreshTimeout. It is safe to call repeatedly.
|
|
func (f *GeoIPDB) Stop() error {
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
if f.cancel != nil {
|
|
f.cancel()
|
|
}
|
|
timeout := f.RefreshTimeout
|
|
if timeout <= 0 {
|
|
timeout = DefaultRefreshTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
return f.refresh.Stop(ctx)
|
|
}
|
|
|
|
func NewLocalGeoIPDB(dir string) *GeoIPDB {
|
|
return &GeoIPDB{Dir: dir, local: true, maxAge: time.Hour}
|
|
}
|
|
|
|
func (f *GeoIPDB) Update(ctx context.Context) (bool, error) {
|
|
if f.local {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
if f.Current() != nil {
|
|
return true, nil
|
|
}
|
|
} else if err := f.ready(); err != nil {
|
|
return false, err
|
|
}
|
|
updated := false
|
|
for _, cacher := range f.cachers {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
u, err := cacher.Update(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
updated = updated || u
|
|
}
|
|
return updated, nil
|
|
}
|