1 Commits
Author SHA1 Message Date
sneak 6e903d511f Add best-effort geolocation to the IP lookup from geojs and DB-IP City Lite (closes #239)
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
2026-11-10 13:03:58 +00:00
6 changed files with 19 additions and 147 deletions

No files matched your search

+5 -9
View File
@@ -510,13 +510,9 @@ sources:
- [geojs](https://www.geojs.io/) is asked first. One request carries every
address of the lookup, and it has 1 second to answer.
- [DB-IP City Lite](https://db-ip.com/db/download/ip-to-city-lite) answers when
geojs has no answer for the address. It is a database file that RouteWatch
geojs fails or does not answer in time. It is a database file that RouteWatch
reads itself.
geojs has no answer when its request fails or runs out of time. An answer with
no country, city or coordinates also counts as no answer, and RouteWatch does
not cache it.
The `geo` object of a result has these fields:
- `country_code`: the two-letter country code
@@ -538,8 +534,8 @@ goes to neither source, so its `geo` is null.
What goes outside: geojs receives each address that the cache does not hold. For
`/ip/` and the home page, that address is the visitor's own. A lookup answered
from the cache sends nothing. DB-IP receives only the monthly request for its
file.
from the cache or from the database sends nothing. DB-IP receives only the
monthly request for its file.
How RouteWatch keeps the answers of geojs:
@@ -565,8 +561,8 @@ How RouteWatch keeps the database:
The kernel loads the parts that are read, and can drop them again when memory
is short.
The home page's footer says "IP Geolocation by DB-IP" with a link to
[db-ip.com](https://db-ip.com).
DB-IP's licence requires an attribution, so the home page's footer says "IP
Geolocation by DB-IP" with a link to [db-ip.com](https://db-ip.com).
## Configuration
+2 -12
View File
@@ -107,8 +107,7 @@ func nilIfEmpty(text string) *string {
}
// 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.
// 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()
@@ -118,22 +117,13 @@ func (l *Locator) fromDatabase(addr netip.Addr) *Location {
}
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)
err := result.Decode(&record)
if err != nil {
l.logger.Error("Failed to read a database record",
"address", addr, "error", err)
+2 -12
View File
@@ -51,8 +51,7 @@ type geojsAnswer struct {
}
// askGeoJS asks geojs about addresses in one request, and returns its answers
// by address. An address geojs left out, or gave no country, city or
// coordinates for, has no entry.
// by address. An address geojs left out has no entry.
func (l *Locator) askGeoJS(
ctx context.Context, addresses []string,
) (map[string]*Location, error) {
@@ -98,21 +97,12 @@ func (l *Locator) askGeoJS(
continue
}
location := answer.location(fetched)
if hasLocation(location) {
answers[plainAddress(addr)] = location
}
answers[addr.Unmap().String()] = answer.location(fetched)
}
return answers, nil
}
// hasLocation says whether found has a country, a city or a coordinate.
func hasLocation(found *Location) bool {
return found.CountryCode != nil || found.Country != nil ||
found.City != nil || found.Latitude != nil || found.Longitude != nil
}
// location returns the answer as a Location fetched at the time given.
func (a geojsAnswer) location(fetched time.Time) *Location {
return &Location{
+4 -12
View File
@@ -1,7 +1,6 @@
// Package geo finds where IP addresses are. It asks geojs.io, and keeps those
// answers in memory and in cache.db. When geojs.io gives no location for an
// address, it reads a copy of DB-IP's City Lite database, which it downloads
// once a month.
// 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 (
@@ -142,7 +141,7 @@ func (l *Locator) Lookup(ctx context.Context, addrs []netip.Addr) []*Location {
var unanswered []string
for i, addr := range addrs {
address := plainAddress(addr)
address := addr.Unmap().String()
locations[i] = l.cached(ctx, address)
if locations[i] == nil {
@@ -164,7 +163,7 @@ func (l *Locator) Lookup(ctx context.Context, addrs []netip.Addr) []*Location {
continue
}
address := plainAddress(addr)
address := addr.Unmap().String()
answer, ok := answers[address]
if ok {
@@ -180,13 +179,6 @@ func (l *Locator) Lookup(ctx context.Context, addrs []netip.Addr) []*Location {
return locations
}
// plainAddress returns addr as the requests to geojs and the cache name it: an
// IPv4 address mapped into IPv6 as plain IPv4, and with no zone. A client
// chooses the zone, so it must not reach the request.
func plainAddress(addr netip.Addr) string {
return addr.Unmap().WithZone("").String()
}
// 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:
+4 -100
View File
@@ -53,18 +53,9 @@ const (
"country": "Germany", "country_code": "DE", "ip": "` + testAddress + `",
"latitude": "52.6171", "longitude": "13.1207",
"region": "Brandenburg", "timezone": "Europe/Berlin"},
{"country": "Germany", "ip": "` + otherAddress + `",
"latitude": "nil", "longitude": "nil"}
{"ip": "` + otherAddress + `", "latitude": "nil", "longitude": "nil"}
]`
geojsOneAddress = `[{"ip": "` + testAddress + `", "city": "` + testCity + `"}]`
// geojsNoLocation has no country, city or coordinates, as geojs sends for
// some addresses. Its other parts do not count as a location.
geojsNoLocation = `[{"accuracy": 1000, "area_code": "0", "asn": 64496,
"continent_code": "EU", "ip": "` + testAddress + `",
"latitude": "nil", "longitude": "nil",
"organization": "AS64496 Example", "organization_name": "Example",
"region": "Brandenburg", "timezone": "Europe/Berlin"}]`
)
// fixedTime returns the time a test's clock starts at.
@@ -324,8 +315,9 @@ func TestLookupAsksGeoJSOnceForEveryAddress(t *testing.T) {
}
}
// TestLookupGivesUnknownPartsAsNil checks an answer with only a country name,
// whose coordinates are the string "nil": the other parts are nil.
// 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()
@@ -376,7 +368,6 @@ func TestLookupFallsBackToTheDatabase(t *testing.T) {
{name: "error status", status: http.StatusBadGateway},
{name: "unreadable answer", body: "not JSON"},
{name: "address left out", body: "[]"},
{name: "answer with no location", body: geojsNoLocation},
}
for _, tt := range tests {
@@ -420,93 +411,6 @@ func TestLookupFallsBackToTheDatabase(t *testing.T) {
}
}
// TestAnyCountryCityOrCoordinateIsAnAnswer checks that each of those parts,
// alone, makes an answer from geojs that is returned and kept.
func TestAnyCountryCityOrCoordinateIsAnAnswer(t *testing.T) {
t.Parallel()
parts := []string{
`"country": "Germany"`,
`"country_code": "DE"`,
`"city": "` + testCity + `"`,
`"latitude": "52.5"`,
`"longitude": "13.5"`,
}
for _, part := range parts {
t.Run(part, func(t *testing.T) {
t.Parallel()
fake := newFakeGeoJS(t, `[{"ip": "`+testAddress+`", `+part+`}]`)
dir := t.TempDir()
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
got := lookupOne(t, locator, testAddress)
if got == nil || got.Source != sourceGeoJS {
t.Fatalf("location %+v, want the answer from geojs", got)
}
if !locator.entries.Contains(testAddress) {
t.Error("the answer is not in memory")
}
})
}
}
// TestLookupSendsNoZoneToGeoJS looks up an address with a zone, which is text
// a client chooses. geojs receives only the plain address, and the answer is
// kept under it.
func TestLookupSendsNoZoneToGeoJS(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+"%x&callback=evil")
requests := fake.requestsSeen()
if len(requests) != 1 || requests[0] != otherAddress {
t.Errorf("requests %v, want one naming only %s", requests, otherAddress)
}
if got == nil || got.Source != sourceGeoJS {
t.Errorf("location %+v, want the answer from geojs", got)
}
if !locator.entries.Contains(otherAddress) {
t.Error("the answer is not kept under the plain address")
}
}
// TestFailedDatabaseLookupIsLogged closes the copy under the locator, so that
// the lookup in it fails. The location is nil and the failure is logged.
func TestFailedDatabaseLookupIsLogged(t *testing.T) {
t.Parallel()
fake := newFakeGeoJS(t, "[]")
dir := t.TempDir()
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
giveDatabase(t, locator)
err := locator.database.Close()
if err != nil {
t.Fatalf("closing the copy: %v", err)
}
var logged bytes.Buffer
locator.logger = slog.New(slog.NewTextHandler(&logged, nil))
if got := locator.fromDatabase(parseAddresses(t, testAddress)[0]); got != nil {
t.Errorf("location %+v from a failed lookup, want nil", got)
}
if !strings.Contains(logged.String(), "address="+testAddress) {
t.Errorf("the failure was not logged: %q", logged.String())
}
}
// TestLookupIsNilWhenNeitherSourceAnswers makes geojs fail with no database
// copy, and then with a copy that does not cover the address.
func TestLookupIsNilWhenNeitherSourceAnswers(t *testing.T) {
+2 -2
View File
@@ -67,8 +67,8 @@ func TestFooterLicense(t *testing.T) {
}
}
// TestHomePageCreditsDBIP expects the home page's footer to carry the credit
// to DB-IP.
// TestHomePageCreditsDBIP expects the home page's footer to carry the
// attribution the DB-IP licence requires.
func TestHomePageCreditsDBIP(t *testing.T) {
t.Parallel()