Add best-effort geolocation to the IP lookup from geojs and DB-IP City Lite (closes #239)
check / check (push) Successful in 4m19s
check / check (push) Successful in 4m19s
Each result gains `geo`. One request to geojs carries every address, 1 second in all. On a timeout, an error or an answer with no country, city or coordinates, the lookup reads DB-IP City Lite, downloaded monthly into the state directory. geojs answers stay in memory (250,000, least recently used dropped) and in a new `cache.db` table for 7 days. Judgement call: a reserved address goes to neither source, and its `geo` is null. Judgement call: `updated` for DB-IP is when the copy was downloaded. Deviation: the cache is SQLite, by sneak's words. Model: opus-5-5
This commit is contained in:
1 parent
2c3f6f71dc
commit
fe392db4fc
16 files changed
+2372
-22
No files matched your search
@@ -0,0 +1,349 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// DatabaseDownloadFormat is the URL of DB-IP's City Lite database, with %s
|
||||
// for the year and month.
|
||||
DatabaseDownloadFormat = "https://download.db-ip.com/free/" +
|
||||
"dbip-city-lite-%s.mmdb.gz"
|
||||
|
||||
// databaseMonthFormat is the year and month in the URL.
|
||||
databaseMonthFormat = "2006-01"
|
||||
|
||||
// databaseFileName is the copy of the database in the state directory.
|
||||
databaseFileName = "dbip-city-lite.mmdb"
|
||||
|
||||
// databaseDownloadSuffix ends the name of the file a download is written
|
||||
// to, until it has opened.
|
||||
databaseDownloadSuffix = ".download"
|
||||
|
||||
// maxDatabaseSize is the largest database accepted once unpacked, in
|
||||
// bytes.
|
||||
maxDatabaseSize = 200 << 20
|
||||
|
||||
// databaseDownloadTimeout limits one download.
|
||||
databaseDownloadTimeout = 10 * time.Minute
|
||||
|
||||
// databaseCheckInterval is how often RouteWatch checks whether a new
|
||||
// month's database is due. A new month's file appears some time on the
|
||||
// first of the month, so a failed download is tried again this much later.
|
||||
databaseCheckInterval = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
errDatabaseStatus = errors.New("unexpected HTTP status from DB-IP")
|
||||
errDatabaseTooLong = errors.New("database is over the size limit")
|
||||
)
|
||||
|
||||
// dbipName is the part of a database record that holds an English name.
|
||||
type dbipName struct {
|
||||
Names struct {
|
||||
English string `maxminddb:"en"`
|
||||
} `maxminddb:"names"`
|
||||
}
|
||||
|
||||
// dbipRecord is the part of a database record RouteWatch reads.
|
||||
type dbipRecord struct {
|
||||
City dbipName `maxminddb:"city"`
|
||||
Continent struct {
|
||||
Code string `maxminddb:"code"`
|
||||
} `maxminddb:"continent"`
|
||||
Country struct {
|
||||
dbipName
|
||||
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
} `maxminddb:"country"`
|
||||
Location struct {
|
||||
Latitude *float64 `maxminddb:"latitude"`
|
||||
Longitude *float64 `maxminddb:"longitude"`
|
||||
} `maxminddb:"location"`
|
||||
Subdivisions []dbipName `maxminddb:"subdivisions"`
|
||||
}
|
||||
|
||||
// location returns the record as a Location fetched at the time given. The
|
||||
// first subdivision is the region. The database has no accuracy radius and no
|
||||
// time zone.
|
||||
func (r *dbipRecord) location(fetched time.Time) *Location {
|
||||
found := &Location{
|
||||
CountryCode: nilIfEmpty(r.Country.ISOCode),
|
||||
Country: nilIfEmpty(r.Country.Names.English),
|
||||
ContinentCode: nilIfEmpty(r.Continent.Code),
|
||||
City: nilIfEmpty(r.City.Names.English),
|
||||
Latitude: r.Location.Latitude,
|
||||
Longitude: r.Location.Longitude,
|
||||
Source: sourceDBIP,
|
||||
Updated: fetched,
|
||||
}
|
||||
|
||||
if len(r.Subdivisions) > 0 {
|
||||
found.Region = nilIfEmpty(r.Subdivisions[0].Names.English)
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// nilIfEmpty returns a pointer to text, or nil when text is empty.
|
||||
func nilIfEmpty(text string) *string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &text
|
||||
}
|
||||
|
||||
// fromDatabase returns where the copy of the database places addr. It returns
|
||||
// nil when there is no copy yet or the copy has no record for addr. It also
|
||||
// returns nil, and logs the error, when reading the copy fails.
|
||||
func (l *Locator) fromDatabase(addr netip.Addr) *Location {
|
||||
l.databaseMu.RLock()
|
||||
defer l.databaseMu.RUnlock()
|
||||
|
||||
if l.database == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := l.database.Lookup(addr)
|
||||
|
||||
err := result.Err()
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to look up an address in the database",
|
||||
"address", addr, "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if !result.Found() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var record dbipRecord
|
||||
|
||||
err = result.Decode(&record)
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to read a database record",
|
||||
"address", addr, "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return record.location(l.databaseFetched)
|
||||
}
|
||||
|
||||
// keepDatabaseCurrent downloads the database when it is due, at once and then
|
||||
// every databaseCheckInterval, until ctx ends.
|
||||
func (l *Locator) keepDatabaseCurrent(ctx context.Context) {
|
||||
l.downloadDatabaseIfDue(ctx)
|
||||
|
||||
checks := time.NewTicker(databaseCheckInterval)
|
||||
defer checks.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-checks.C:
|
||||
l.downloadDatabaseIfDue(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// downloadDatabaseIfDue downloads this month's database, unless the copy in
|
||||
// use was downloaded this month. A failure leaves the copy as it is.
|
||||
func (l *Locator) downloadDatabaseIfDue(ctx context.Context) {
|
||||
now := l.now().UTC()
|
||||
|
||||
if !l.databaseDue(now) {
|
||||
return
|
||||
}
|
||||
|
||||
err := l.downloadDatabase(ctx, now)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
l.logger.Error("Database download failed; keeping the previous copy",
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// databaseDue says whether there is no open copy or it is from an earlier
|
||||
// month than now.
|
||||
func (l *Locator) databaseDue(now time.Time) bool {
|
||||
l.databaseMu.RLock()
|
||||
defer l.databaseMu.RUnlock()
|
||||
|
||||
return l.database == nil ||
|
||||
l.databaseFetched.Format(databaseMonthFormat) !=
|
||||
now.Format(databaseMonthFormat)
|
||||
}
|
||||
|
||||
// downloadDatabase fetches the database of now's month. The file replaces the
|
||||
// copy, on disk and in use, only once it has opened.
|
||||
func (l *Locator) downloadDatabase(ctx context.Context, now time.Time) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, databaseDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf(l.databaseURLFormat, now.Format(databaseMonthFormat))
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request.Header.Set(userAgentHeader, userAgent)
|
||||
|
||||
response, err := l.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: %s", errDatabaseStatus, response.Status)
|
||||
}
|
||||
|
||||
return l.replaceDatabase(response.Body, now)
|
||||
}
|
||||
|
||||
// replaceDatabase unpacks the gzip stream body beside the copy, then makes the
|
||||
// result the copy once it has opened. Whatever fails, the file it wrote is
|
||||
// removed and the copy stays.
|
||||
func (l *Locator) replaceDatabase(body io.Reader, now time.Time) error {
|
||||
root, err := os.OpenRoot(l.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
downloadName := databaseFileName + databaseDownloadSuffix
|
||||
|
||||
err = unpackDatabase(root, downloadName, body, l.maxDatabaseBytes)
|
||||
if err != nil {
|
||||
_ = root.Remove(downloadName)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
opened, err := maxminddb.Open(filepath.Join(l.dir, downloadName))
|
||||
if err != nil {
|
||||
_ = root.Remove(downloadName)
|
||||
|
||||
return fmt.Errorf("opening the downloaded database: %w", err)
|
||||
}
|
||||
|
||||
err = root.Rename(downloadName, databaseFileName)
|
||||
if err != nil {
|
||||
_ = opened.Close()
|
||||
_ = root.Remove(downloadName)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
l.useDatabase(opened, now.Truncate(time.Second))
|
||||
l.logger.Info("Downloaded a new copy of the database")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpackDatabase writes the gzip stream body to the file name in root, up to
|
||||
// limit bytes once unpacked.
|
||||
func unpackDatabase(
|
||||
root *os.Root, name string, body io.Reader, limit int64,
|
||||
) error {
|
||||
unpacked, err := gzip.NewReader(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpacking the database: %w", err)
|
||||
}
|
||||
|
||||
f, err := root.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
written, err := io.Copy(f, io.LimitReader(unpacked, limit+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpacking the database: %w", err)
|
||||
}
|
||||
|
||||
if written > limit {
|
||||
return errDatabaseTooLong
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// openDatabaseCopy opens the copy left by an earlier run, if there is one. A
|
||||
// copy that does not open is treated as no copy, so it is downloaded again.
|
||||
func (l *Locator) openDatabaseCopy() {
|
||||
path := filepath.Join(l.dir, databaseFileName)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to read the database copy", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
opened, err := maxminddb.Open(path)
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to open the database copy", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
l.useDatabase(opened, info.ModTime().UTC().Truncate(time.Second))
|
||||
}
|
||||
|
||||
// useDatabase makes opened the copy in use and closes the one it replaces.
|
||||
func (l *Locator) useDatabase(opened *maxminddb.Reader, fetched time.Time) {
|
||||
l.databaseMu.Lock()
|
||||
defer l.databaseMu.Unlock()
|
||||
|
||||
if l.database != nil {
|
||||
err := l.database.Close()
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to close the old database copy", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
l.database = opened
|
||||
l.databaseFetched = fetched
|
||||
}
|
||||
|
||||
// closeDatabase closes the copy in use, after which no source can answer from
|
||||
// it.
|
||||
func (l *Locator) closeDatabase() {
|
||||
l.databaseMu.Lock()
|
||||
defer l.databaseMu.Unlock()
|
||||
|
||||
if l.database == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := l.database.Close()
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to close the database copy", "error", err)
|
||||
}
|
||||
|
||||
l.database = nil
|
||||
}
|
||||
Reference in new issue
Block a user