Add best-effort geolocation to the IP lookup from geojs and DB-IP City Lite (closes #239)
check / check (push) Successful in 3m58s

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:
sneak committed 2026-11-10 13:38:19 +00:00
1 parent b2553f044d
commit c8ae355787
16 files changed
+2372 -22

No files matched your search

+51 -4
View File
@@ -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
@@ -839,6 +843,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
return
}
locations := s.geoLocations(ctx, ipsToLookup)
// Track ASNs that need WHOIS refresh
refreshASNs := make(map[int]bool)
@@ -874,7 +880,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
@@ -1029,6 +1037,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)