Add best-effort geolocation to the IP lookup from geojs and DB-IP City Lite (closes #239)
check / check (push) Successful in 1m50s
check / check (push) Successful in 1m50s
Each result gains `geo`. One request to geojs carries every address, 1 second in all. On a timeout or error 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: an answer from geojs with every part unknown is still an answer. 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
0cbd9cd943
commit
6e903d511f
16 files changed
+2244
-22
No files matched your search
@@ -0,0 +1,253 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// entryLifetime is how long a geojs answer is kept after its fetch.
|
||||
entryLifetime = 7 * 24 * time.Hour
|
||||
|
||||
// flushInterval is how often new answers are written to cache.db.
|
||||
flushInterval = 5 * time.Second
|
||||
|
||||
// sweepInterval is how often expired answers are deleted.
|
||||
sweepInterval = 10 * time.Minute
|
||||
|
||||
// rowColumns are the columns of the geolocations table in the order
|
||||
// scanRow reads them.
|
||||
rowColumns = "address, country_code, country, continent_code, region, " +
|
||||
"city, latitude, longitude, accuracy_radius_km, time_zone, fetched_at"
|
||||
)
|
||||
|
||||
// rowScanner is the Scan method of *sql.Row and *sql.Rows.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// cached returns the current answer held for address, from memory, then from
|
||||
// the answers waiting to be written, then from cache.db. It returns nil when
|
||||
// there is none.
|
||||
func (l *Locator) cached(ctx context.Context, address string) *Location {
|
||||
found, ok := l.entries.Get(address)
|
||||
if ok {
|
||||
if !l.expired(found) {
|
||||
return found
|
||||
}
|
||||
|
||||
l.entries.Remove(address)
|
||||
}
|
||||
|
||||
l.pendingMu.Lock()
|
||||
waiting := l.pending[address]
|
||||
l.pendingMu.Unlock()
|
||||
|
||||
if waiting != nil && !l.expired(waiting) {
|
||||
l.entries.Add(address, waiting)
|
||||
|
||||
return waiting
|
||||
}
|
||||
|
||||
stored, err := l.readRow(ctx, address)
|
||||
if err != nil {
|
||||
l.logger.Warn("Failed to read a geolocation cache row",
|
||||
"address", address, "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if stored != nil {
|
||||
l.entries.Add(address, stored)
|
||||
}
|
||||
|
||||
return stored
|
||||
}
|
||||
|
||||
// expired says whether the answer is at the end of its lifetime.
|
||||
func (l *Locator) expired(answer *Location) bool {
|
||||
return !l.now().Before(answer.Updated.Add(entryLifetime))
|
||||
}
|
||||
|
||||
// cutoff returns the fetch time at or before which an answer has expired.
|
||||
func (l *Locator) cutoff() int64 {
|
||||
return l.now().Add(-entryLifetime).Unix()
|
||||
}
|
||||
|
||||
// store keeps an answer in memory and queues it for cache.db. While cache.db
|
||||
// cannot be written, the queue stops growing at maxEntries answers.
|
||||
func (l *Locator) store(address string, answer *Location) {
|
||||
l.entries.Add(address, answer)
|
||||
|
||||
l.pendingMu.Lock()
|
||||
defer l.pendingMu.Unlock()
|
||||
|
||||
if len(l.pending) < l.maxEntries {
|
||||
l.pending[address] = answer
|
||||
}
|
||||
}
|
||||
|
||||
// keepCache writes new answers and deletes expired ones until ctx ends.
|
||||
func (l *Locator) keepCache(ctx context.Context) {
|
||||
flushes := time.NewTicker(flushInterval)
|
||||
defer flushes.Stop()
|
||||
|
||||
sweeps := time.NewTicker(sweepInterval)
|
||||
defer sweeps.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
l.flush(context.WithoutCancel(ctx))
|
||||
|
||||
return
|
||||
case <-flushes.C:
|
||||
l.flush(ctx)
|
||||
case <-sweeps.C:
|
||||
l.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load reads the answers that have not expired into memory, at most maxEntries
|
||||
// of them, the newest last so that they are the last to be dropped.
|
||||
func (l *Locator) load(ctx context.Context) error {
|
||||
rows, err := l.db.QueryContext(ctx,
|
||||
"SELECT "+rowColumns+" FROM (SELECT "+rowColumns+" FROM geolocations "+
|
||||
"WHERE fetched_at > ? ORDER BY fetched_at DESC LIMIT ?) "+
|
||||
"ORDER BY fetched_at",
|
||||
l.cutoff(), l.maxEntries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the geolocation cache: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
address, answer, err := scanRow(rows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading a geolocation cache row: %w", err)
|
||||
}
|
||||
|
||||
l.entries.Add(address, answer)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the geolocation cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRow returns the current answer for address in cache.db, or nil when it
|
||||
// has none.
|
||||
func (l *Locator) readRow(
|
||||
ctx context.Context, address string,
|
||||
) (*Location, error) {
|
||||
row := l.db.QueryRowContext(ctx,
|
||||
"SELECT "+rowColumns+" FROM geolocations "+
|
||||
"WHERE address = ? AND fetched_at > ?",
|
||||
address, l.cutoff())
|
||||
|
||||
_, answer, err := scanRow(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil //nolint:nilnil // no row is not a failure
|
||||
}
|
||||
|
||||
return answer, err
|
||||
}
|
||||
|
||||
// scanRow reads one row of the geolocations table.
|
||||
func scanRow(row rowScanner) (string, *Location, error) {
|
||||
var (
|
||||
address string
|
||||
fetched int64
|
||||
answer Location
|
||||
)
|
||||
|
||||
err := row.Scan(&address, &answer.CountryCode, &answer.Country,
|
||||
&answer.ContinentCode, &answer.Region, &answer.City, &answer.Latitude,
|
||||
&answer.Longitude, &answer.AccuracyRadiusKm, &answer.TimeZone, &fetched)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
answer.Source = sourceGeoJS
|
||||
answer.Updated = time.Unix(fetched, 0).UTC()
|
||||
|
||||
return address, &answer, nil
|
||||
}
|
||||
|
||||
// flush writes the pending answers to cache.db. Those it cannot write stay
|
||||
// pending, unless a newer answer for the address has arrived meanwhile.
|
||||
func (l *Locator) flush(ctx context.Context) {
|
||||
l.pendingMu.Lock()
|
||||
batch := l.pending
|
||||
l.pending = make(map[string]*Location)
|
||||
l.pendingMu.Unlock()
|
||||
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := l.writeRows(ctx, batch)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.logger.Error("Failed to write the geolocation cache", "error", err)
|
||||
|
||||
l.pendingMu.Lock()
|
||||
defer l.pendingMu.Unlock()
|
||||
|
||||
maps.Insert(batch, maps.All(l.pending))
|
||||
l.pending = batch
|
||||
}
|
||||
|
||||
// writeRows writes batch to cache.db in one transaction.
|
||||
func (l *Locator) writeRows(
|
||||
ctx context.Context, batch map[string]*Location,
|
||||
) error {
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for address, answer := range batch {
|
||||
_, err = tx.ExecContext(ctx,
|
||||
"INSERT OR REPLACE INTO geolocations ("+rowColumns+") "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
address, answer.CountryCode, answer.Country, answer.ContinentCode,
|
||||
answer.Region, answer.City, answer.Latitude, answer.Longitude,
|
||||
answer.AccuracyRadiusKm, answer.TimeZone, answer.Updated.Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// sweep deletes the expired answers from memory and cache.db.
|
||||
func (l *Locator) sweep(ctx context.Context) {
|
||||
for _, address := range l.entries.Keys() {
|
||||
answer, ok := l.entries.Peek(address)
|
||||
if ok && l.expired(answer) {
|
||||
l.entries.Remove(address)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := l.db.ExecContext(ctx,
|
||||
"DELETE FROM geolocations WHERE fetched_at <= ?", l.cutoff())
|
||||
if err != nil {
|
||||
l.logger.Error("Failed to delete expired geolocation cache rows",
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
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.
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// GeoJSEndpoint is the URL of the geojs geo endpoint.
|
||||
GeoJSEndpoint = "https://get.geojs.io/v1/ip/geo.json"
|
||||
|
||||
// geojsTimeLimit limits one request to geojs.
|
||||
geojsTimeLimit = time.Second
|
||||
|
||||
// geojsAddressSeparator separates the addresses of one request.
|
||||
geojsAddressSeparator = ","
|
||||
|
||||
// geojsUnknown is what geojs sends for a value it does not know.
|
||||
geojsUnknown = "nil"
|
||||
|
||||
// maxGeoJSAnswerBytes is far more than an answer for the most addresses
|
||||
// an IP lookup can ask about.
|
||||
maxGeoJSAnswerBytes = 1 << 20
|
||||
)
|
||||
|
||||
var errGeoJSStatus = errors.New("unexpected HTTP status from geojs")
|
||||
|
||||
// geojsAnswer is one address's entry in an answer from geojs.
|
||||
//
|
||||
//nolint:tagliatelle // the keys geojs sends
|
||||
type geojsAnswer struct {
|
||||
IP string `json:"ip"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Country string `json:"country"`
|
||||
ContinentCode string `json:"continent_code"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
Accuracy *int `json:"accuracy"`
|
||||
TimeZone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// askGeoJS asks geojs about addresses in one request, and returns its answers
|
||||
// by address. An address geojs left out has no entry.
|
||||
func (l *Locator) askGeoJS(
|
||||
ctx context.Context, addresses []string,
|
||||
) (map[string]*Location, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, l.timeLimit)
|
||||
defer cancel()
|
||||
|
||||
// A comma and a colon are legal in a query, and the escaped forms are
|
||||
// not what geojs documents.
|
||||
url := l.geojsURL + "?ip=" + strings.Join(addresses, geojsAddressSeparator)
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(userAgentHeader, userAgent)
|
||||
|
||||
response, err := l.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: %d", errGeoJSStatus, response.StatusCode)
|
||||
}
|
||||
|
||||
var parsed []geojsAnswer
|
||||
|
||||
err = json.NewDecoder(io.LimitReader(response.Body, maxGeoJSAnswerBytes)).
|
||||
Decode(&parsed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the geojs answer: %w", err)
|
||||
}
|
||||
|
||||
fetched := l.now().UTC().Truncate(time.Second)
|
||||
answers := make(map[string]*Location, len(parsed))
|
||||
|
||||
for _, answer := range parsed {
|
||||
addr, err := netip.ParseAddr(answer.IP)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
answers[addr.Unmap().String()] = answer.location(fetched)
|
||||
}
|
||||
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// location returns the answer as a Location fetched at the time given.
|
||||
func (a geojsAnswer) location(fetched time.Time) *Location {
|
||||
return &Location{
|
||||
CountryCode: knownText(a.CountryCode),
|
||||
Country: knownText(a.Country),
|
||||
ContinentCode: knownText(a.ContinentCode),
|
||||
Region: knownText(a.Region),
|
||||
City: knownText(a.City),
|
||||
Latitude: knownNumber(a.Latitude),
|
||||
Longitude: knownNumber(a.Longitude),
|
||||
AccuracyRadiusKm: a.Accuracy,
|
||||
TimeZone: knownText(a.TimeZone),
|
||||
Source: sourceGeoJS,
|
||||
Updated: fetched,
|
||||
}
|
||||
}
|
||||
|
||||
// knownText returns a pointer to text, or nil when geojs does not know it.
|
||||
func knownText(text string) *string {
|
||||
if text == "" || text == geojsUnknown {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &text
|
||||
}
|
||||
|
||||
// knownNumber returns text as a number, or nil when geojs does not know it. NaN
|
||||
// and infinity parse, but JSON cannot carry them.
|
||||
func knownNumber(text string) *float64 {
|
||||
number, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &number
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package geo finds where IP addresses are. It asks geojs.io, and keeps those
|
||||
// answers in memory and in cache.db. When geojs.io does not answer in time, it
|
||||
// reads a copy of DB-IP's City Lite database, which it downloads once a month.
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
)
|
||||
|
||||
// The values of Location.Source.
|
||||
const (
|
||||
sourceGeoJS = "geojs"
|
||||
sourceDBIP = "dbip"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxEntries limits how many geojs answers are held in memory.
|
||||
maxEntries = 250_000
|
||||
|
||||
// userAgentHeader and userAgent name routewatch to geojs and DB-IP.
|
||||
userAgentHeader = "User-Agent"
|
||||
userAgent = "routewatch (+https://git.eeqj.de/sneak/routewatch)"
|
||||
)
|
||||
|
||||
// Params is what New needs.
|
||||
type Params struct {
|
||||
// DB is the open cache.db.
|
||||
DB *sql.DB
|
||||
|
||||
// Dir is the directory that holds the copy of the database.
|
||||
Dir string
|
||||
|
||||
// GeoJSURL is the URL of the geojs geo endpoint, GeoJSEndpoint in use.
|
||||
GeoJSURL string
|
||||
|
||||
// DatabaseURLFormat is the URL of the database with %s for the year and
|
||||
// month, DatabaseDownloadFormat in use.
|
||||
DatabaseURLFormat string
|
||||
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Location is where an address is. A part the source does not know is nil.
|
||||
//
|
||||
//nolint:tagliatelle // snake_case keys, as the Go style guide asks
|
||||
type Location struct {
|
||||
CountryCode *string `json:"country_code"`
|
||||
Country *string `json:"country"`
|
||||
ContinentCode *string `json:"continent_code"`
|
||||
Region *string `json:"region"`
|
||||
City *string `json:"city"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
AccuracyRadiusKm *int `json:"accuracy_radius_km"`
|
||||
TimeZone *string `json:"time_zone"`
|
||||
|
||||
// Source is geojs or dbip.
|
||||
Source string `json:"source"`
|
||||
|
||||
// Updated is when the value was fetched: the geojs answer, or the copy of
|
||||
// the database.
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// Locator answers where addresses are. Start loads its cache and starts its
|
||||
// goroutines.
|
||||
type Locator struct {
|
||||
db *sql.DB
|
||||
dir string
|
||||
geojsURL string
|
||||
databaseURLFormat string
|
||||
timeLimit time.Duration
|
||||
maxEntries int
|
||||
maxDatabaseBytes int64
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
|
||||
// entries holds geojs answers by address, and drops the least recently
|
||||
// used first.
|
||||
entries *lru.Cache[string, *Location]
|
||||
|
||||
// pending holds the answers that cache.db does not have yet.
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]*Location
|
||||
|
||||
// database is the open copy, nil until one has opened, and databaseFetched
|
||||
// is when that copy was downloaded. A lookup holds the read lock, so that
|
||||
// a new copy never closes the old one under it.
|
||||
databaseMu sync.RWMutex
|
||||
database *maxminddb.Reader
|
||||
databaseFetched time.Time
|
||||
|
||||
// stop ends the goroutines Start runs, and done is closed when they have
|
||||
// returned.
|
||||
stop context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// New returns a Locator with an empty cache. Start loads it.
|
||||
func New(params Params) (*Locator, error) {
|
||||
entries, err := lru.New[string, *Location](maxEntries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating the geolocation cache: %w", err)
|
||||
}
|
||||
|
||||
return &Locator{
|
||||
db: params.DB,
|
||||
dir: params.Dir,
|
||||
geojsURL: params.GeoJSURL,
|
||||
databaseURLFormat: params.DatabaseURLFormat,
|
||||
timeLimit: geojsTimeLimit,
|
||||
maxEntries: maxEntries,
|
||||
maxDatabaseBytes: maxDatabaseSize,
|
||||
logger: params.Logger.With("component", "geo"),
|
||||
client: &http.Client{},
|
||||
now: time.Now,
|
||||
entries: entries,
|
||||
pending: make(map[string]*Location),
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Lookup returns where each of addrs is, in the same order. The entry for an
|
||||
// address neither source answers for is nil. Addresses with no current cached
|
||||
// answer go to geojs in one request. The returned Locations are shared with
|
||||
// the cache and must not be changed.
|
||||
func (l *Locator) Lookup(ctx context.Context, addrs []netip.Addr) []*Location {
|
||||
locations := make([]*Location, len(addrs))
|
||||
|
||||
var unanswered []string
|
||||
|
||||
for i, addr := range addrs {
|
||||
address := addr.Unmap().String()
|
||||
|
||||
locations[i] = l.cached(ctx, address)
|
||||
if locations[i] == nil {
|
||||
unanswered = append(unanswered, address)
|
||||
}
|
||||
}
|
||||
|
||||
if len(unanswered) == 0 {
|
||||
return locations
|
||||
}
|
||||
|
||||
answers, err := l.askGeoJS(ctx, unanswered)
|
||||
if err != nil {
|
||||
l.logger.Warn("geojs lookup failed; using the database", "error", err)
|
||||
}
|
||||
|
||||
for i, addr := range addrs {
|
||||
if locations[i] != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
address := addr.Unmap().String()
|
||||
|
||||
answer, ok := answers[address]
|
||||
if ok {
|
||||
l.store(address, answer)
|
||||
locations[i] = answer
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
locations[i] = l.fromDatabase(addr)
|
||||
}
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
// Start loads the cached answers that have not expired and opens the copy of
|
||||
// the database, if there is one. It then starts the goroutines that write new
|
||||
// answers, delete expired ones and download the database. They run until Stop:
|
||||
// the end of ctx does not stop them.
|
||||
func (l *Locator) Start(ctx context.Context) error {
|
||||
err := l.load(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.logger.Info("Geolocation cache loaded", "entries", l.entries.Len())
|
||||
|
||||
l.openDatabaseCopy()
|
||||
|
||||
ctx, l.stop = context.WithCancel(context.WithoutCancel(ctx))
|
||||
|
||||
go func() {
|
||||
defer close(l.done)
|
||||
defer l.closeDatabase()
|
||||
|
||||
var workers sync.WaitGroup
|
||||
|
||||
workers.Go(func() { l.keepCache(ctx) })
|
||||
workers.Go(func() { l.keepDatabaseCurrent(ctx) })
|
||||
workers.Wait()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels a download in progress, writes the answers cache.db does not
|
||||
// have yet, and waits until the goroutines have returned or ctx ends.
|
||||
func (l *Locator) Stop(ctx context.Context) {
|
||||
l.stop()
|
||||
|
||||
select {
|
||||
case <-l.done:
|
||||
l.logger.Info("Geolocation stopped")
|
||||
case <-ctx.Done():
|
||||
l.logger.Warn("Stop deadline passed before the geolocation cache " +
|
||||
"was written")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/cachedb"
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"github.com/maxmind/mmdbwriter"
|
||||
"github.com/maxmind/mmdbwriter/mmdbtype"
|
||||
)
|
||||
|
||||
// The addresses and networks of the tests, from the documentation ranges.
|
||||
const (
|
||||
testAddress = "192.0.2.10"
|
||||
otherAddress = "2001:db8::10"
|
||||
thirdAddress = "203.0.113.10"
|
||||
coveredNetwork = "192.0.2.0/24"
|
||||
testCity = "Testville"
|
||||
databaseCity = "Databaseville"
|
||||
databaseMonth = "2026-01"
|
||||
databasePath = "/dbip-city-lite-" + databaseMonth + ".mmdb.gz"
|
||||
geojsPath = "/geo.json"
|
||||
|
||||
// testTimeLimit replaces geojsTimeLimit in the tests that use a real
|
||||
// server, so that a stall on a busy host cannot use it up.
|
||||
testTimeLimit = 2 * time.Minute
|
||||
|
||||
// shortTimeLimit is the limit of the test in which geojs never answers.
|
||||
shortTimeLimit = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// The bodies the fake geojs sends.
|
||||
const (
|
||||
geojsTwoAddresses = `[
|
||||
{"accuracy": 20, "city": "` + testCity + `", "continent_code": "EU",
|
||||
"country": "Germany", "country_code": "DE", "ip": "` + testAddress + `",
|
||||
"latitude": "52.6171", "longitude": "13.1207",
|
||||
"region": "Brandenburg", "timezone": "Europe/Berlin"},
|
||||
{"ip": "` + otherAddress + `", "latitude": "nil", "longitude": "nil"}
|
||||
]`
|
||||
geojsOneAddress = `[{"ip": "` + testAddress + `", "city": "` + testCity + `"}]`
|
||||
)
|
||||
|
||||
// fixedTime returns the time a test's clock starts at.
|
||||
func fixedTime() time.Time {
|
||||
return time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
}
|
||||
|
||||
// parseAddresses returns the given addresses parsed.
|
||||
func parseAddresses(t *testing.T, addresses ...string) []netip.Addr {
|
||||
t.Helper()
|
||||
|
||||
addrs := make([]netip.Addr, len(addresses))
|
||||
|
||||
for i, address := range addresses {
|
||||
addr, err := netip.ParseAddr(address)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing %s: %v", address, err)
|
||||
}
|
||||
|
||||
addrs[i] = addr
|
||||
}
|
||||
|
||||
return addrs
|
||||
}
|
||||
|
||||
// fakeGeoJS is a geojs server. It sends body, or status when that is set, or
|
||||
// waits for the request to end when hang is true. It keeps each request's
|
||||
// address list.
|
||||
type fakeGeoJS struct {
|
||||
server *httptest.Server
|
||||
body string
|
||||
status int
|
||||
hang bool
|
||||
|
||||
mu sync.Mutex
|
||||
requests []string
|
||||
}
|
||||
|
||||
// newFakeGeoJS starts a fake geojs that sends body.
|
||||
func newFakeGeoJS(t *testing.T, body string) *fakeGeoJS {
|
||||
t.Helper()
|
||||
|
||||
fake := &fakeGeoJS{body: body}
|
||||
fake.server = httptest.NewServer(http.HandlerFunc(fake.serve))
|
||||
t.Cleanup(fake.server.Close)
|
||||
|
||||
return fake
|
||||
}
|
||||
|
||||
func (f *fakeGeoJS) serve(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
f.requests = append(f.requests, r.URL.Query().Get("ip"))
|
||||
f.mu.Unlock()
|
||||
|
||||
if f.hang {
|
||||
<-r.Context().Done()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if f.status != 0 {
|
||||
w.WriteHeader(f.status)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(f.body))
|
||||
}
|
||||
|
||||
// requestsSeen returns the address list of each request so far.
|
||||
func (f *fakeGeoJS) requestsSeen() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
return append([]string{}, f.requests...)
|
||||
}
|
||||
|
||||
// openCacheDB opens a new cache.db in a temporary directory.
|
||||
func openCacheDB(t *testing.T, dir string) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := cachedb.Open(t.Context(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("opening cache.db: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// newTestLocator returns a Locator that asks fake and uses db and dir, with
|
||||
// its clock stopped at fixedTime.
|
||||
func newTestLocator(
|
||||
t *testing.T, fake *fakeGeoJS, db *sql.DB, dir string,
|
||||
) *Locator {
|
||||
t.Helper()
|
||||
|
||||
locator, err := New(Params{
|
||||
DB: db,
|
||||
Dir: dir,
|
||||
GeoJSURL: fake.server.URL + geojsPath,
|
||||
DatabaseURLFormat: fake.server.URL + "/dbip-city-lite-%s.mmdb.gz",
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
locator.timeLimit = testTimeLimit
|
||||
locator.now = fixedTime
|
||||
|
||||
t.Cleanup(locator.closeDatabase)
|
||||
|
||||
return locator
|
||||
}
|
||||
|
||||
// lookupOne returns the location of one address.
|
||||
func lookupOne(t *testing.T, locator *Locator, address string) *Location {
|
||||
t.Helper()
|
||||
|
||||
return locator.Lookup(t.Context(), parseAddresses(t, address))[0]
|
||||
}
|
||||
|
||||
// databaseFile returns an MMDB file with the shape of City Lite. It covers
|
||||
// coveredNetwork, with no time zone or accuracy radius, as the real file has
|
||||
// none.
|
||||
func databaseFile(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
tree, err := mmdbwriter.New(mmdbwriter.Options{
|
||||
DatabaseType: "DBIP-City-Lite",
|
||||
IncludeReservedNetworks: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("creating the test database: %v", err)
|
||||
}
|
||||
|
||||
_, network, err := net.ParseCIDR(coveredNetwork)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing the network: %v", err)
|
||||
}
|
||||
|
||||
names := func(name string) mmdbtype.Map {
|
||||
return mmdbtype.Map{"names": mmdbtype.Map{"en": mmdbtype.String(name)}}
|
||||
}
|
||||
|
||||
country := names("Germany")
|
||||
country["iso_code"] = mmdbtype.String("DE")
|
||||
|
||||
err = tree.Insert(network, mmdbtype.Map{
|
||||
"city": names(databaseCity),
|
||||
"continent": mmdbtype.Map{"code": mmdbtype.String("EU")},
|
||||
"country": country,
|
||||
"subdivisions": mmdbtype.Slice{names("Brandenburg")},
|
||||
"location": mmdbtype.Map{
|
||||
"latitude": mmdbtype.Float64(52.5),
|
||||
"longitude": mmdbtype.Float64(13.5),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("inserting the test record: %v", err)
|
||||
}
|
||||
|
||||
var file bytes.Buffer
|
||||
|
||||
_, err = tree.WriteTo(&file)
|
||||
if err != nil {
|
||||
t.Fatalf("writing the test database: %v", err)
|
||||
}
|
||||
|
||||
return file.Bytes()
|
||||
}
|
||||
|
||||
// gzipped returns data compressed with gzip.
|
||||
func gzipped(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var packed bytes.Buffer
|
||||
|
||||
writer := gzip.NewWriter(&packed)
|
||||
|
||||
_, err := writer.Write(data)
|
||||
if err != nil {
|
||||
t.Fatalf("compressing: %v", err)
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("compressing: %v", err)
|
||||
}
|
||||
|
||||
return packed.Bytes()
|
||||
}
|
||||
|
||||
// giveDatabase puts a copy of the test database in the locator's directory, and
|
||||
// opens it.
|
||||
func giveDatabase(t *testing.T, locator *Locator) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(locator.dir, databaseFileName)
|
||||
|
||||
err := os.WriteFile(path, databaseFile(t), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing the database copy: %v", err)
|
||||
}
|
||||
|
||||
locator.openDatabaseCopy()
|
||||
}
|
||||
|
||||
// requireCity checks where a location says it is, and its source.
|
||||
func requireCity(t *testing.T, got *Location, city, source string) {
|
||||
t.Helper()
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("no location")
|
||||
}
|
||||
|
||||
if got.City == nil || *got.City != city {
|
||||
t.Errorf("city %v, want %s", got.City, city)
|
||||
}
|
||||
|
||||
if got.Source != source {
|
||||
t.Errorf("source %s, want %s", got.Source, source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupAsksGeoJSOnceForEveryAddress looks up two addresses, and expects a
|
||||
// single request that names both, and each answer in its own place.
|
||||
func TestLookupAsksGeoJSOnceForEveryAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, geojsTwoAddresses)
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
|
||||
got := locator.Lookup(t.Context(),
|
||||
parseAddresses(t, otherAddress, testAddress))
|
||||
|
||||
requests := fake.requestsSeen()
|
||||
if len(requests) != 1 || requests[0] != otherAddress+","+testAddress {
|
||||
t.Fatalf("requests %v, want one naming both addresses", requests)
|
||||
}
|
||||
|
||||
first := got[1]
|
||||
requireCity(t, first, testCity, sourceGeoJS)
|
||||
|
||||
if *first.CountryCode != "DE" || *first.Country != "Germany" ||
|
||||
*first.ContinentCode != "EU" || *first.Region != "Brandenburg" ||
|
||||
*first.Latitude != 52.6171 || *first.Longitude != 13.1207 ||
|
||||
*first.AccuracyRadiusKm != 20 || *first.TimeZone != "Europe/Berlin" {
|
||||
t.Errorf("wrong location %+v", first)
|
||||
}
|
||||
|
||||
if !first.Updated.Equal(fixedTime()) {
|
||||
t.Errorf("updated %v, want %v", first.Updated, fixedTime())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupGivesUnknownPartsAsNil checks an answer whose coordinates are the
|
||||
// string "nil" and which has no other part: the parts are nil, and the answer
|
||||
// is still an answer.
|
||||
func TestLookupGivesUnknownPartsAsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, geojsTwoAddresses)
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
|
||||
got := lookupOne(t, locator, otherAddress)
|
||||
if got == nil {
|
||||
t.Fatal("no location for an address geojs answered")
|
||||
}
|
||||
|
||||
if got.Latitude != nil || got.Longitude != nil || got.City != nil ||
|
||||
got.CountryCode != nil || got.AccuracyRadiusKm != nil ||
|
||||
got.TimeZone != nil {
|
||||
t.Errorf("unknown parts are not nil: %+v", got)
|
||||
}
|
||||
|
||||
if got.Source != sourceGeoJS {
|
||||
t.Errorf("source %s, want %s", got.Source, sourceGeoJS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupRejectsNumbersJSONCannotCarry checks that a coordinate that is not
|
||||
// a finite number is nil.
|
||||
func TestLookupRejectsNumbersJSONCannotCarry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, text := range []string{"NaN", "Inf", "-Inf", "not a number", ""} {
|
||||
if got := knownNumber(text); got != nil {
|
||||
t.Errorf("knownNumber(%q) = %v, want nil", text, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupFallsBackToTheDatabase makes geojs fail in each way the fallback
|
||||
// covers, and expects the database's answer.
|
||||
func TestLookupFallsBackToTheDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
hang bool
|
||||
}{
|
||||
{name: "timeout", hang: true},
|
||||
{name: "error status", status: http.StatusBadGateway},
|
||||
{name: "unreadable answer", body: "not JSON"},
|
||||
{name: "address left out", body: "[]"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, tt.body)
|
||||
fake.status = tt.status
|
||||
fake.hang = tt.hang
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
locator.timeLimit = shortTimeLimit
|
||||
|
||||
if !tt.hang {
|
||||
locator.timeLimit = testTimeLimit
|
||||
}
|
||||
|
||||
giveDatabase(t, locator)
|
||||
|
||||
got := lookupOne(t, locator, testAddress)
|
||||
requireCity(t, got, databaseCity, sourceDBIP)
|
||||
|
||||
if *got.CountryCode != "DE" || *got.Country != "Germany" ||
|
||||
*got.ContinentCode != "EU" || *got.Region != "Brandenburg" ||
|
||||
*got.Latitude != 52.5 || *got.Longitude != 13.5 {
|
||||
t.Errorf("wrong location %+v", got)
|
||||
}
|
||||
|
||||
if got.TimeZone != nil || got.AccuracyRadiusKm != nil {
|
||||
t.Errorf("parts the database lacks are not nil: %+v", got)
|
||||
}
|
||||
|
||||
// The fallback is local, so it is not cached: the next lookup
|
||||
// asks geojs again.
|
||||
lookupOne(t, locator, testAddress)
|
||||
|
||||
if requests := fake.requestsSeen(); len(requests) != 2 {
|
||||
t.Errorf("%d requests, want 2", len(requests))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupIsNilWhenNeitherSourceAnswers makes geojs fail with no database
|
||||
// copy, and then with a copy that does not cover the address.
|
||||
func TestLookupIsNilWhenNeitherSourceAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, "")
|
||||
fake.status = http.StatusBadGateway
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
|
||||
if got := lookupOne(t, locator, testAddress); got != nil {
|
||||
t.Errorf("location %+v before the first download, want nil", got)
|
||||
}
|
||||
|
||||
giveDatabase(t, locator)
|
||||
|
||||
if got := lookupOne(t, locator, thirdAddress); got != nil {
|
||||
t.Errorf("location %+v for an address outside the database, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnsweredAddressesAreNotAskedAgain checks that an address held in memory
|
||||
// makes no request, and that only the others go to geojs.
|
||||
func TestAnsweredAddressesAreNotAskedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, geojsTwoAddresses)
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
|
||||
lookupOne(t, locator, testAddress)
|
||||
|
||||
second := locator.Lookup(t.Context(),
|
||||
parseAddresses(t, testAddress, otherAddress))
|
||||
|
||||
requireCity(t, second[0], testCity, sourceGeoJS)
|
||||
|
||||
requests := fake.requestsSeen()
|
||||
if len(requests) != 2 || requests[1] != otherAddress {
|
||||
t.Errorf("requests %v, want the second to name only %s",
|
||||
requests, otherAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheSurvivesARestart writes an answer, then starts a new Locator on the
|
||||
// same cache.db. It answers with no request, each part as it was.
|
||||
func TestCacheSurvivesARestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, geojsTwoAddresses)
|
||||
dir := t.TempDir()
|
||||
db := openCacheDB(t, dir)
|
||||
locator := newTestLocator(t, fake, db, dir)
|
||||
|
||||
want := locator.Lookup(t.Context(),
|
||||
parseAddresses(t, testAddress, otherAddress))
|
||||
locator.flush(t.Context())
|
||||
|
||||
restarted := newTestLocator(t, fake, db, dir)
|
||||
|
||||
err := restarted.load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
got := restarted.Lookup(t.Context(),
|
||||
parseAddresses(t, testAddress, otherAddress))
|
||||
|
||||
if requests := fake.requestsSeen(); len(requests) != 1 {
|
||||
t.Errorf("%d requests, want only the first", len(requests))
|
||||
}
|
||||
|
||||
requireCity(t, got[0], testCity, sourceGeoJS)
|
||||
|
||||
if *got[0].Latitude != *want[0].Latitude ||
|
||||
*got[0].AccuracyRadiusKm != *want[0].AccuracyRadiusKm ||
|
||||
!got[0].Updated.Equal(want[0].Updated) {
|
||||
t.Errorf("reloaded %+v, want %+v", got[0], want[0])
|
||||
}
|
||||
|
||||
if got[1].Latitude != nil || got[1].City != nil {
|
||||
t.Errorf("unknown parts came back set: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopWritesWhatIsPending starts a Locator, takes an answer and stops it.
|
||||
// The answer is in cache.db afterwards.
|
||||
func TestStopWritesWhatIsPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, geojsTwoAddresses)
|
||||
dir := t.TempDir()
|
||||
db := openCacheDB(t, dir)
|
||||
locator := newTestLocator(t, fake, db, dir)
|
||||
|
||||
err := locator.Start(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
lookupOne(t, locator, testAddress)
|
||||
locator.Stop(t.Context())
|
||||
|
||||
stored, err := locator.readRow(t.Context(), testAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("readRow: %v", err)
|
||||
}
|
||||
|
||||
requireCity(t, stored, testCity, sourceGeoJS)
|
||||
}
|
||||
|
||||
// TestAnswersExpireAfterSevenDays checks an answer just before and at the end
|
||||
// of its lifetime, and that the sweep deletes its row and memory entry.
|
||||
func TestAnswersExpireAfterSevenDays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, geojsOneAddress)
|
||||
dir := t.TempDir()
|
||||
db := openCacheDB(t, dir)
|
||||
locator := newTestLocator(t, fake, db, dir)
|
||||
|
||||
lookupOne(t, locator, testAddress)
|
||||
locator.flush(t.Context())
|
||||
|
||||
locator.now = func() time.Time { return fixedTime().Add(entryLifetime - time.Second) }
|
||||
lookupOne(t, locator, testAddress)
|
||||
|
||||
if requests := fake.requestsSeen(); len(requests) != 1 {
|
||||
t.Fatalf("%d requests just before expiry, want 1", len(requests))
|
||||
}
|
||||
|
||||
expiry := fixedTime().Add(entryLifetime)
|
||||
locator.now = func() time.Time { return expiry }
|
||||
|
||||
locator.sweep(t.Context())
|
||||
|
||||
if locator.entries.Len() != 0 {
|
||||
t.Errorf("%d entries in memory after the sweep, want 0", locator.entries.Len())
|
||||
}
|
||||
|
||||
var rows int
|
||||
|
||||
err := db.QueryRowContext(t.Context(),
|
||||
"SELECT COUNT(*) FROM geolocations").Scan(&rows)
|
||||
if err != nil {
|
||||
t.Fatalf("counting rows: %v", err)
|
||||
}
|
||||
|
||||
if rows != 0 {
|
||||
t.Errorf("%d rows after the sweep, want 0", rows)
|
||||
}
|
||||
|
||||
lookupOne(t, locator, testAddress)
|
||||
|
||||
if requests := fake.requestsSeen(); len(requests) != 2 {
|
||||
t.Errorf("%d requests after expiry, want 2", len(requests))
|
||||
}
|
||||
}
|
||||
|
||||
// limitMemory makes the locator's memory hold at most size answers.
|
||||
func limitMemory(t *testing.T, locator *Locator, size int) {
|
||||
t.Helper()
|
||||
|
||||
entries, err := lru.New[string, *Location](size)
|
||||
if err != nil {
|
||||
t.Fatalf("lru.New: %v", err)
|
||||
}
|
||||
|
||||
locator.entries = entries
|
||||
locator.maxEntries = size
|
||||
}
|
||||
|
||||
// answerFor returns a location for store to keep, fetched at the time given.
|
||||
func answerFor(fetched time.Time) *Location {
|
||||
return &Location{City: new(testCity), Source: sourceGeoJS, Updated: fetched}
|
||||
}
|
||||
|
||||
// TestMemoryDropsTheLeastRecentlyUsedAnswer fills memory with two answers,
|
||||
// uses the older one and stores a third. The unused one leaves memory, and its
|
||||
// row still answers without a request.
|
||||
func TestMemoryDropsTheLeastRecentlyUsedAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, "[]")
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
limitMemory(t, locator, 2)
|
||||
|
||||
locator.store(testAddress, answerFor(fixedTime()))
|
||||
locator.store(otherAddress, answerFor(fixedTime()))
|
||||
locator.flush(t.Context())
|
||||
|
||||
lookupOne(t, locator, testAddress)
|
||||
locator.store(thirdAddress, answerFor(fixedTime()))
|
||||
|
||||
if locator.entries.Contains(otherAddress) {
|
||||
t.Fatal("the least recently used answer stayed in memory")
|
||||
}
|
||||
|
||||
if !locator.entries.Contains(testAddress) {
|
||||
t.Fatal("the recently used answer left memory")
|
||||
}
|
||||
|
||||
requireCity(t, lookupOne(t, locator, otherAddress), testCity, sourceGeoJS)
|
||||
|
||||
if requests := fake.requestsSeen(); len(requests) != 0 {
|
||||
t.Errorf("requests %v, want the row to answer", requests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadKeepsTheNewestAnswers loads more rows than memory holds. The newest
|
||||
// are in memory, and the newest of all is the last to be dropped.
|
||||
func TestLoadKeepsTheNewestAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := newFakeGeoJS(t, "[]")
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
|
||||
locator.store(testAddress, answerFor(fixedTime().Add(-3*time.Hour)))
|
||||
locator.store(otherAddress, answerFor(fixedTime().Add(-2*time.Hour)))
|
||||
locator.store(thirdAddress, answerFor(fixedTime().Add(-time.Hour)))
|
||||
locator.flush(t.Context())
|
||||
|
||||
limitMemory(t, locator, 2)
|
||||
|
||||
err := locator.load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if locator.entries.Contains(testAddress) {
|
||||
t.Error("the oldest answer was loaded")
|
||||
}
|
||||
|
||||
locator.entries.Add("192.0.2.99", answerFor(fixedTime()))
|
||||
|
||||
if !locator.entries.Contains(thirdAddress) {
|
||||
t.Error("the newest answer was the first to be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDownload serves file for the January 2026 database and counts requests.
|
||||
func fakeDownload(t *testing.T, file []byte) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != databasePath {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(file)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// newDownloadLocator returns a locator that downloads file.
|
||||
func newDownloadLocator(t *testing.T, file []byte) *Locator {
|
||||
t.Helper()
|
||||
|
||||
fake := newFakeGeoJS(t, "[]")
|
||||
fake.status = http.StatusBadGateway
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
locator.databaseURLFormat = fakeDownload(t, file).URL +
|
||||
"/dbip-city-lite-%s.mmdb.gz"
|
||||
|
||||
return locator
|
||||
}
|
||||
|
||||
// requireNoDownloadFile checks that a download left no temporary file.
|
||||
func requireNoDownloadFile(t *testing.T, locator *Locator) {
|
||||
t.Helper()
|
||||
|
||||
_, err := os.Stat(filepath.Join(locator.dir,
|
||||
databaseFileName+databaseDownloadSuffix))
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("the download's file is still there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadPutsTheDatabaseInUse downloads this month's file, unpacks it, and
|
||||
// answers from it. A restart opens the copy.
|
||||
func TestDownloadPutsTheDatabaseInUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
file := gzipped(t, databaseFile(t))
|
||||
locator := newDownloadLocator(t, file)
|
||||
|
||||
err := locator.downloadDatabase(t.Context(), fixedTime())
|
||||
if err != nil {
|
||||
t.Fatalf("downloadDatabase: %v", err)
|
||||
}
|
||||
|
||||
requireNoDownloadFile(t, locator)
|
||||
|
||||
got := lookupOne(t, locator, testAddress)
|
||||
requireCity(t, got, databaseCity, sourceDBIP)
|
||||
|
||||
if !got.Updated.Equal(fixedTime().Truncate(time.Second)) {
|
||||
t.Errorf("updated %v, want the download's time", got.Updated)
|
||||
}
|
||||
|
||||
restarted := newDownloadLocator(t, file)
|
||||
restarted.dir = locator.dir
|
||||
restarted.openDatabaseCopy()
|
||||
requireCity(t, lookupOne(t, restarted, testAddress), databaseCity, sourceDBIP)
|
||||
}
|
||||
|
||||
// TestDownloadIsDueOnceAMonth checks when a copy needs replacing: with no copy,
|
||||
// not again in the month it was fetched, and in the next month.
|
||||
func TestDownloadIsDueOnceAMonth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
locator := newDownloadLocator(t, gzipped(t, databaseFile(t)))
|
||||
|
||||
if !locator.databaseDue(fixedTime()) {
|
||||
t.Error("no download due with no copy")
|
||||
}
|
||||
|
||||
err := locator.downloadDatabase(t.Context(), fixedTime())
|
||||
if err != nil {
|
||||
t.Fatalf("downloadDatabase: %v", err)
|
||||
}
|
||||
|
||||
if locator.databaseDue(fixedTime().AddDate(0, 0, 20)) {
|
||||
t.Error("a download is due in the same month")
|
||||
}
|
||||
|
||||
if !locator.databaseDue(fixedTime().AddDate(0, 1, 0)) {
|
||||
t.Error("no download due in the next month")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedDownloadKeepsTheCopy serves a copy that cannot replace the one in
|
||||
// use, in each way, and expects the old copy to go on answering.
|
||||
func TestFailedDownloadKeepsTheCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file func(t *testing.T) []byte
|
||||
max int64
|
||||
}{
|
||||
{
|
||||
name: "not gzip",
|
||||
file: func(*testing.T) []byte { return []byte("not gzip") },
|
||||
},
|
||||
{
|
||||
name: "not a database",
|
||||
file: func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
return gzipped(t, []byte("not a database"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cut off",
|
||||
file: func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
packed := gzipped(t, databaseFile(t))
|
||||
|
||||
return packed[:len(packed)/2]
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "over the size limit",
|
||||
file: func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
return gzipped(t, databaseFile(t))
|
||||
},
|
||||
max: 10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
locator := newDownloadLocator(t, tt.file(t))
|
||||
if tt.max != 0 {
|
||||
locator.maxDatabaseBytes = tt.max
|
||||
}
|
||||
|
||||
giveDatabase(t, locator)
|
||||
|
||||
err := locator.downloadDatabase(t.Context(), fixedTime())
|
||||
if err == nil {
|
||||
t.Fatal("the download succeeded")
|
||||
}
|
||||
|
||||
requireNoDownloadFile(t, locator)
|
||||
requireCity(t, lookupOne(t, locator, testAddress),
|
||||
databaseCity, sourceDBIP)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingFileIsAFailedDownload checks that a month whose file is not
|
||||
// published yet is an error and leaves no copy.
|
||||
func TestMissingFileIsAFailedDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
locator := newDownloadLocator(t, nil)
|
||||
|
||||
err := locator.downloadDatabase(t.Context(), fixedTime().AddDate(0, 1, 0))
|
||||
if !errors.Is(err, errDatabaseStatus) {
|
||||
t.Fatalf("error %v, want %v", err, errDatabaseStatus)
|
||||
}
|
||||
|
||||
if !locator.databaseDue(fixedTime()) {
|
||||
t.Error("a copy is in use after a failed download")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnopenableCopyIsDownloadedAgain leaves a damaged copy from this month. It
|
||||
// counts as no copy, so the download is due.
|
||||
func TestUnopenableCopyIsDownloadedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
locator := newDownloadLocator(t, nil)
|
||||
|
||||
err := os.WriteFile(filepath.Join(locator.dir, databaseFileName),
|
||||
[]byte("damaged"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing the copy: %v", err)
|
||||
}
|
||||
|
||||
locator.openDatabaseCopy()
|
||||
|
||||
if !locator.databaseDue(fixedTime()) {
|
||||
t.Error("no download due with a copy that does not open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupDuringReplaceIsSafe looks up addresses while the copy is replaced.
|
||||
// The race detector and the mapped file both catch a lookup of a closed copy.
|
||||
func TestLookupDuringReplaceIsSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
file := gzipped(t, databaseFile(t))
|
||||
locator := newDownloadLocator(t, file)
|
||||
giveDatabase(t, locator)
|
||||
|
||||
addrs := parseAddresses(t, testAddress)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
var readers sync.WaitGroup
|
||||
|
||||
readers.Go(func() {
|
||||
for ctx.Err() == nil {
|
||||
locator.fromDatabase(addrs[0])
|
||||
}
|
||||
})
|
||||
|
||||
for range 5 {
|
||||
err := locator.downloadDatabase(t.Context(), fixedTime())
|
||||
if err != nil {
|
||||
t.Fatalf("downloadDatabase: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
readers.Wait()
|
||||
}
|
||||
|
||||
// TestRequestNamesRouteWatch checks the header each outside request carries,
|
||||
// which tells geojs and DB-IP who is asking.
|
||||
func TestRequestNamesRouteWatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var seen atomic.Pointer[string]
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen.Store(new(r.Header.Get(userAgentHeader)))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
fake := newFakeGeoJS(t, "[]")
|
||||
dir := t.TempDir()
|
||||
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
|
||||
locator.geojsURL = server.URL + geojsPath
|
||||
|
||||
lookupOne(t, locator, testAddress)
|
||||
|
||||
got := seen.Load()
|
||||
if got == nil || !strings.HasPrefix(*got, "routewatch") {
|
||||
t.Errorf("User-Agent %v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user