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
@@ -15,6 +15,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/geo"
|
||||
"git.eeqj.de/sneak/routewatch/internal/iplist"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
@@ -1613,6 +1615,131 @@ func TestIPLookupFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeGeoLocator answers every address with location, and keeps the addresses
|
||||
// of each call. It is safe for the concurrent subtests that share it.
|
||||
type fakeGeoLocator struct {
|
||||
location *geo.Location
|
||||
|
||||
mu sync.Mutex
|
||||
calls [][]netip.Addr
|
||||
}
|
||||
|
||||
func (f *fakeGeoLocator) Lookup(
|
||||
_ context.Context, addrs []netip.Addr,
|
||||
) []*geo.Location {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.calls = append(f.calls, addrs)
|
||||
|
||||
answers := make([]*geo.Location, len(addrs))
|
||||
for i := range addrs {
|
||||
answers[i] = f.location
|
||||
}
|
||||
|
||||
return answers
|
||||
}
|
||||
|
||||
// TestIPLookupGivesTheGeoObject looks up two public addresses. They go to the
|
||||
// locator in one call, and each result has the geo object under the keys the
|
||||
// API documents. These addresses are public, because a reserved one is not
|
||||
// looked up.
|
||||
func TestIPLookupGivesTheGeoObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
useFakeResolver(s, &fakeResolver{})
|
||||
|
||||
locator := &fakeGeoLocator{location: &geo.Location{
|
||||
CountryCode: new("DE"),
|
||||
City: new("Berlin"),
|
||||
Latitude: new(52.5),
|
||||
Source: "geojs",
|
||||
Updated: testRouteTime(0),
|
||||
}}
|
||||
s.SetGeoLocator(locator)
|
||||
|
||||
path := "/api/v1/ip/1.1.1.1,8.8.8.8"
|
||||
|
||||
results := resultFields(t, get(t, s, path).Body.Bytes())
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("%s: %d results, want 2", path, len(results))
|
||||
}
|
||||
|
||||
if len(locator.calls) != 1 || len(locator.calls[0]) != 2 {
|
||||
t.Errorf("locator calls %v, want one with both addresses", locator.calls)
|
||||
}
|
||||
|
||||
var got map[string]json.RawMessage
|
||||
|
||||
err := json.Unmarshal(results[0]["geo"], &got)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding geo %s: %v", results[0]["geo"], err)
|
||||
}
|
||||
|
||||
wantKeys := []string{
|
||||
"accuracy_radius_km", "city", "continent_code", "country",
|
||||
"country_code", "latitude", "longitude", "region", "source",
|
||||
"time_zone", "updated",
|
||||
}
|
||||
if keys := slices.Sorted(maps.Keys(got)); !slices.Equal(keys, wantKeys) {
|
||||
t.Errorf("geo keys %v, want %v", keys, wantKeys)
|
||||
}
|
||||
|
||||
if string(got["city"]) != `"Berlin"` || string(got["latitude"]) != "52.5" ||
|
||||
string(got["region"]) != jsonNull {
|
||||
t.Errorf("wrong geo %s", results[0]["geo"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPLookupOfAReservedAddressHasNoGeo looks up a reserved address alone and
|
||||
// beside a public one. The reserved one never reaches the locator, and its geo
|
||||
// is null.
|
||||
func TestIPLookupOfAReservedAddressHasNoGeo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
useFakeResolver(s, &fakeResolver{})
|
||||
|
||||
locator := &fakeGeoLocator{location: &geo.Location{Source: "geojs"}}
|
||||
s.SetGeoLocator(locator)
|
||||
|
||||
results := resultFields(t, get(t, s, "/api/v1/ip/"+addrInTestPrefixV4).Body.Bytes())
|
||||
if len(results) != 1 || string(results[0]["geo"]) != jsonNull {
|
||||
t.Fatalf("results %v, want one with geo null", results)
|
||||
}
|
||||
|
||||
if len(locator.calls) != 0 {
|
||||
t.Errorf("locator calls %v, want none", locator.calls)
|
||||
}
|
||||
|
||||
results = resultFields(t,
|
||||
get(t, s, "/api/v1/ip/"+addrInTestPrefixV4+",1.1.1.1").Body.Bytes())
|
||||
if len(results) != 2 || string(results[0]["geo"]) != jsonNull ||
|
||||
string(results[1]["geo"]) == jsonNull {
|
||||
t.Errorf("results %v, want geo only for the public address", results)
|
||||
}
|
||||
|
||||
if len(locator.calls) != 1 || len(locator.calls[0]) != 1 {
|
||||
t.Errorf("locator calls %v, want one with one address", locator.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPLookupGeoIsNullWhenNoSourceAnswers checks an address the locator has
|
||||
// no answer for.
|
||||
func TestIPLookupGeoIsNullWhenNoSourceAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
useFakeResolver(s, &fakeResolver{})
|
||||
s.SetGeoLocator(&fakeGeoLocator{})
|
||||
|
||||
results := resultFields(t, get(t, s, "/api/v1/ip/1.1.1.1").Body.Bytes())
|
||||
if len(results) != 1 || string(results[0]["geo"]) != jsonNull {
|
||||
t.Errorf("results %v, want one with geo null", results)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPLookupReportsVPNProviders looks up addresses on no list, on a list
|
||||
// that is not a VPN list, on one VPN list and on two. is_vpn says whether any
|
||||
// VPN list holds the address, and vpn_providers names only those lists. An
|
||||
|
||||
Reference in new issue
Block a user