Add best-effort geolocation to the IP lookup from geojs and DB-IP City Lite (closes #239)
check / check (push) Successful in 1m41s
check / check (push) Successful in 1m41s
Every IP lookup result gains `geo`. One request to geojs carries all uncached addresses of a lookup, within 1 second. On a timeout, an error or an answer with no location, the lookup reads DB-IP City Lite, downloaded monthly into the state directory. Geojs answers stay in memory, at most 250,000, and in `cache.db` for 7 days. The home page footer credits DB-IP. Judgement call: a reserved address goes to neither source, and its `geo` is null. Judgement call: `updated` for a DB-IP answer is when the copy was downloaded. Deviation: the cache is SQLite, by sneak's words, not the JSON file the standing rule asks for. Model: opus-5-5
This commit was merged in pull request #249.
This commit is contained in:
1 parent
2c3f6f71dc
commit
cca402c73d
16 files changed
+2372
-22
No files matched your search
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/bogon"
|
||||
"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/metrics"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
@@ -764,9 +765,9 @@ type IPLookupResponse struct {
|
||||
}
|
||||
|
||||
// IPLookupResult is the answer for one address: its PTR names, its flags, its
|
||||
// live route and that route's AS, and the IP lists that contain it. Every
|
||||
// result has every field. The fields of IPInfo are null when the address has
|
||||
// no live route.
|
||||
// live route and that route's AS, the IP lists that contain it, and where it
|
||||
// is. Every result has every field. The fields of IPInfo are null when the
|
||||
// address has no live route.
|
||||
//
|
||||
//nolint:tagliatelle // snake_case keys, as the Go style guide asks
|
||||
type IPLookupResult struct {
|
||||
@@ -784,6 +785,9 @@ type IPLookupResult struct {
|
||||
|
||||
// IPLists has one entry for each IP list the address is on.
|
||||
IPLists []iplist.Listing `json:"ip_lists"`
|
||||
|
||||
// Geo is where the address is. It is nil when no source answers.
|
||||
Geo *geo.Location `json:"geo"`
|
||||
}
|
||||
|
||||
// IPLookupFlags are the yes-or-no facts about an address. Every result has
|
||||
@@ -842,6 +846,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
locations := s.geoLocations(ctx, ipsToLookup)
|
||||
|
||||
// Track ASNs that need WHOIS refresh
|
||||
refreshASNs := make(map[int]bool)
|
||||
|
||||
@@ -877,7 +883,9 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
response.Results = append(response.Results, s.ipLookupResult(ip, ptrs, ipInfo))
|
||||
result := s.ipLookupResult(ip, ptrs, ipInfo)
|
||||
result.Geo = locations[ip]
|
||||
response.Results = append(response.Results, result)
|
||||
|
||||
if ipInfo.NeedsWHOISRefresh {
|
||||
refreshASNs[*ipInfo.ASN] = true
|
||||
@@ -1035,6 +1043,45 @@ func ipLookupFlags(ip string, listings []iplist.Listing) IPLookupFlags {
|
||||
}
|
||||
}
|
||||
|
||||
// geoLocations returns where ips are, by address. An address that does not
|
||||
// parse has no entry. Nor has a reserved one, which is sent to no outside
|
||||
// source and has no location.
|
||||
func (s *Server) geoLocations(
|
||||
ctx context.Context, ips []string,
|
||||
) map[string]*geo.Location {
|
||||
if s.geoLocator == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
kept []string
|
||||
addrs []netip.Addr
|
||||
)
|
||||
|
||||
for _, ip := range ips {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil || isBogon(ip) {
|
||||
continue
|
||||
}
|
||||
|
||||
kept = append(kept, ip)
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
|
||||
if len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
answers := s.geoLocator.Lookup(ctx, addrs)
|
||||
locations := make(map[string]*geo.Location, len(kept))
|
||||
|
||||
for i, ip := range kept {
|
||||
locations[ip] = answers[i]
|
||||
}
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
// isBogon says whether the address ip is reserved, as package bogon tells.
|
||||
func isBogon(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
|
||||
@@ -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"
|
||||
@@ -1625,6 +1627,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
|
||||
|
||||
@@ -24,6 +24,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/routetable"
|
||||
@@ -63,6 +64,12 @@ type PTRResolver interface {
|
||||
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
||||
}
|
||||
|
||||
// GeoLocator finds where addresses are, as *geo.Locator does. It returns one
|
||||
// entry for each address, nil when no source answers.
|
||||
type GeoLocator interface {
|
||||
Lookup(ctx context.Context, addrs []netip.Addr) []*geo.Location
|
||||
}
|
||||
|
||||
// Server provides HTTP endpoints for status monitoring
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
@@ -86,6 +93,10 @@ type Server struct {
|
||||
// ptrResolver looks up the IP lookup's PTR names. SetPTRResolver sets it.
|
||||
ptrResolver PTRResolver
|
||||
|
||||
// geoLocator finds where the IP lookup's addresses are. SetGeoLocator
|
||||
// sets it.
|
||||
geoLocator GeoLocator
|
||||
|
||||
// cards is the copy of the home page's cards that the pages serve, nil
|
||||
// until the server first works them out
|
||||
cards atomic.Pointer[homeCards]
|
||||
@@ -209,6 +220,12 @@ func (s *Server) SetPTRResolver(resolver PTRResolver) {
|
||||
s.ptrResolver = resolver
|
||||
}
|
||||
|
||||
// SetGeoLocator sets what finds where the IP lookup's addresses are. Call it
|
||||
// before Start: the handlers read it without a lock.
|
||||
func (s *Server) SetGeoLocator(locator GeoLocator) {
|
||||
s.geoLocator = locator
|
||||
}
|
||||
|
||||
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it
|
||||
// before Start: the handlers read the fetcher without a lock.
|
||||
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
|
||||
|
||||
Reference in new issue
Block a user