Add best-effort geolocation to the IP lookup from geojs and DB-IP City Lite (closes #239)
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:
clawbot committed 2026-11-10 15:48:12 +01:00
1 parent 2c3f6f71dc
commit cca402c73d
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
@@ -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)