Report which public blocklists list an address in the IP lookup (closes #158)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A new package, internal/blocklist, downloads seven public lists on a schedule: DShield, Feodo Tracker, FireHOL abusers and level 3, Spamhaus DROP for IPv4 and IPv6, and the Tor exit list. It keeps the last good copy of each in memory and on disk. A download sends back the Last-Modified and ETag the publisher gave for the copy, also kept on disk. Every IP lookup result carries blocklists. An address with no live route now gets a result too, without route fields, so the lookup no longer answers 404. A successful answer carries Cache-Control, private for the caller's own address. The address sets come from go4.org/netipx. Model: opus-5-5
This commit was merged in pull request #184.
This commit is contained in:
+81
-25
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
@@ -45,6 +46,15 @@ const (
|
||||
ptrLookupTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// The Cache-Control header of an IP lookup answer, such as
|
||||
// "public, max-age=900".
|
||||
const (
|
||||
cacheControlHeader = "Cache-Control"
|
||||
cacheControlPublic = "public"
|
||||
cacheControlPrivate = "private"
|
||||
cacheControlMaxAge = ", max-age="
|
||||
)
|
||||
|
||||
// Keys of the JSON envelope the handlers and middleware write, and the status
|
||||
// value of an error answer.
|
||||
const (
|
||||
@@ -693,9 +703,24 @@ func (s *Server) handleIPLookup() http.HandlerFunc {
|
||||
|
||||
// IPLookupResponse is the standard response for IP/hostname lookups.
|
||||
type IPLookupResponse struct {
|
||||
Query string `json:"query"`
|
||||
Results []*database.IPInfo `json:"results"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Query string `json:"query"`
|
||||
Results []IPLookupResult `json:"results"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// IPLookupResult is the answer for one address: its PTR names, its live route
|
||||
// and that route's AS, and the blocklists that list it. IPInfo is nil when
|
||||
// the address has no live route, and the answer then has no route or AS
|
||||
// fields. IP hides the IPInfo field of the same name, so that an address with
|
||||
// no route has it too.
|
||||
type IPLookupResult struct {
|
||||
*database.IPInfo
|
||||
|
||||
IP string `json:"ip"`
|
||||
PTR []string `json:"ptr,omitempty"`
|
||||
|
||||
// Blocklists has one entry for each blocklist the address is on.
|
||||
Blocklists []blocklist.Listing `json:"blocklists"`
|
||||
}
|
||||
|
||||
// handleIPInfo returns a handler that provides comprehensive IP information.
|
||||
@@ -704,7 +729,7 @@ type IPLookupResponse struct {
|
||||
// Always returns the same response structure with PTR records for each IP.
|
||||
func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
target := lookupTarget(r)
|
||||
target, ownAddress := lookupTarget(r)
|
||||
if target == "" {
|
||||
s.writeJSONError(w, http.StatusBadRequest, "Could not determine IP address")
|
||||
|
||||
@@ -712,7 +737,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
response := IPLookupResponse{Query: target, Results: make([]*database.IPInfo, 0)}
|
||||
response := IPLookupResponse{Query: target, Results: make([]IPLookupResult, 0)}
|
||||
|
||||
// Collect all IPs to look up
|
||||
ipsToLookup, resolveErrors, err := s.resolveLookupTargets(ctx, target)
|
||||
@@ -749,7 +774,10 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, database.ErrNoRoute), errors.Is(err, database.ErrInvalidIP):
|
||||
case errors.Is(err, database.ErrNoRoute):
|
||||
// The address still gets a result, without route fields.
|
||||
ipInfo = nil
|
||||
case errors.Is(err, database.ErrInvalidIP):
|
||||
response.Errors = append(response.Errors, ip+": "+err.Error())
|
||||
|
||||
continue
|
||||
@@ -768,13 +796,14 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if len(ptrs) > 0 {
|
||||
ipInfo.PTR = ptrs
|
||||
}
|
||||
response.Results = append(response.Results, IPLookupResult{
|
||||
IP: ip,
|
||||
PTR: ptrs,
|
||||
IPInfo: ipInfo,
|
||||
Blocklists: s.blocklistListings(ip),
|
||||
})
|
||||
|
||||
response.Results = append(response.Results, ipInfo)
|
||||
|
||||
if ipInfo.NeedsWHOISRefresh {
|
||||
if ipInfo != nil && ipInfo.NeedsWHOISRefresh {
|
||||
refreshASNs[ipInfo.ASN] = true
|
||||
}
|
||||
}
|
||||
@@ -782,10 +811,40 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
||||
// Queue WHOIS refresh for stale ASNs (non-blocking)
|
||||
s.queueWHOISRefresh(refreshASNs)
|
||||
|
||||
s.writeIPLookupAnswer(w, response)
|
||||
s.writeIPLookupAnswer(w, response, ownAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// blocklistListings returns the blocklists that list ip. It returns an empty
|
||||
// list, never nil, so that an answer's blocklists is [] rather than null.
|
||||
func (s *Server) blocklistListings(ip string) []blocklist.Listing {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if s.blocklists == nil || err != nil {
|
||||
return []blocklist.Listing{}
|
||||
}
|
||||
|
||||
return s.blocklists.Lookup(addr)
|
||||
}
|
||||
|
||||
// setIPLookupCacheControl lets caches keep an IP lookup answer for the
|
||||
// shortest blocklist interval, the soonest a blocklist copy can change. The
|
||||
// answer about the caller's own address is private, so that a shared cache
|
||||
// does not give it to another caller. Without blocklists it sets nothing.
|
||||
func (s *Server) setIPLookupCacheControl(w http.ResponseWriter, ownAddress bool) {
|
||||
if s.blocklists == nil {
|
||||
return
|
||||
}
|
||||
|
||||
visibility := cacheControlPublic
|
||||
if ownAddress {
|
||||
visibility = cacheControlPrivate
|
||||
}
|
||||
|
||||
maxAgeSeconds := int(s.blocklists.ShortestInterval().Seconds())
|
||||
w.Header().Set(cacheControlHeader,
|
||||
visibility+cacheControlMaxAge+strconv.Itoa(maxAgeSeconds))
|
||||
}
|
||||
|
||||
// noTargetsMessage returns the message of the 400 an IP lookup answers when
|
||||
// no address is left to look up, followed by the first of resolveErrors, which
|
||||
// says why, when there is one.
|
||||
@@ -799,16 +858,12 @@ func noTargetsMessage(resolveErrors []string) string {
|
||||
return message + ": " + resolveErrors[0]
|
||||
}
|
||||
|
||||
// writeIPLookupAnswer sends response, or 404 with its first error when no
|
||||
// address had a route.
|
||||
// writeIPLookupAnswer sends response with its Cache-Control header.
|
||||
// ownAddress says the request asked about the caller's own address.
|
||||
func (s *Server) writeIPLookupAnswer(
|
||||
w http.ResponseWriter, response IPLookupResponse,
|
||||
w http.ResponseWriter, response IPLookupResponse, ownAddress bool,
|
||||
) {
|
||||
if len(response.Results) == 0 && len(response.Errors) > 0 {
|
||||
s.writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
|
||||
|
||||
return
|
||||
}
|
||||
s.setIPLookupCacheControl(w, ownAddress)
|
||||
|
||||
err := writeJSONSuccess(w, response)
|
||||
if err != nil {
|
||||
@@ -817,8 +872,9 @@ func (s *Server) writeIPLookupAnswer(
|
||||
}
|
||||
|
||||
// lookupTarget returns what an IP lookup request asks for: the ip or addr URL
|
||||
// parameter, or else the client's own address.
|
||||
func lookupTarget(r *http.Request) string {
|
||||
// parameter, or else the client's own address. The bool is true for the
|
||||
// client's own address.
|
||||
func lookupTarget(r *http.Request) (string, bool) {
|
||||
target := chi.URLParam(r, "ip")
|
||||
if target == "" {
|
||||
target = chi.URLParam(r, "addr")
|
||||
@@ -826,10 +882,10 @@ func lookupTarget(r *http.Request) string {
|
||||
|
||||
if target == "" {
|
||||
// Use client IP (RealIP middleware has already processed this)
|
||||
target = extractClientIP(r)
|
||||
return extractClientIP(r), true
|
||||
}
|
||||
|
||||
return target
|
||||
return target, false
|
||||
}
|
||||
|
||||
// queueWHOISRefresh asks the WHOIS fetcher, when there is one, to refresh
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
@@ -1040,14 +1042,15 @@ func (failingIPLookupDB) GetIPInfoContext(
|
||||
// server's router with a store whose read of one address fails with the
|
||||
// driver's error. A lookup that asks for that address answers 500 with the
|
||||
// JSON error body, as the AS and prefix JSON routes do; an address with no
|
||||
// route still answers 404. Before the fix the failed read was reported as if
|
||||
// the address had no route: 404 for that address alone, and 200 beside a
|
||||
// routed address (https://git.eeqj.de/sneak/routewatch/issues/109).
|
||||
// route answers 200. Before the fix the failed read was reported as if the
|
||||
// address had no route: 404 for that address alone, and 200 beside a routed
|
||||
// address (https://git.eeqj.de/sneak/routewatch/issues/109).
|
||||
func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := New(failingIPLookupDB{}, streamer.New(logger.New(), metrics.New()),
|
||||
logger.New(), &config.Config{})
|
||||
s.resolver = &fakeResolver{}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
@@ -1063,7 +1066,7 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
|
||||
},
|
||||
{
|
||||
path: "/api/v1/ip/203.0.113.1",
|
||||
wantCode: http.StatusNotFound,
|
||||
wantCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1077,7 +1080,9 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
|
||||
tt.path, rec.Code, tt.wantCode, rec.Body)
|
||||
}
|
||||
|
||||
checkJSONErrorCode(t, tt.path, rec, tt.wantCode)
|
||||
if tt.wantCode != http.StatusOK {
|
||||
checkJSONErrorCode(t, tt.path, rec, tt.wantCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,10 +1170,10 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
strings.Join(addrs[:maxLookupTargets], ","),
|
||||
http.StatusNotFound, maxLookupTargets, 0,
|
||||
http.StatusOK, maxLookupTargets, 0,
|
||||
},
|
||||
{strings.Join(addrs, ","), http.StatusBadRequest, 0, 0},
|
||||
{"most.test", http.StatusNotFound, maxLookupTargets, 1},
|
||||
{"most.test", http.StatusOK, maxLookupTargets, 1},
|
||||
{"too-many.test", http.StatusBadRequest, 0, 1},
|
||||
{strings.Join(names, ","), http.StatusBadRequest, 0, 0},
|
||||
}
|
||||
@@ -1189,7 +1194,9 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
|
||||
tt.target, rec.Code, tt.wantCode, rec.Body)
|
||||
}
|
||||
|
||||
checkJSONErrorCode(t, tt.target, rec, tt.wantCode)
|
||||
if tt.wantCode != http.StatusOK {
|
||||
checkJSONErrorCode(t, tt.target, rec, tt.wantCode)
|
||||
}
|
||||
|
||||
got := db.lookups.Load()
|
||||
if got != tt.wantLookups {
|
||||
@@ -1246,6 +1253,191 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// addrInTestPrefixV6 is an address in testPrefixV6, which
|
||||
// newServerWithTestRoutes stores a route for.
|
||||
const addrInTestPrefixV6 = "2001:db8::1"
|
||||
|
||||
// testBlocklistInterval is fakeBlocklists' shortest interval.
|
||||
const testBlocklistInterval = 15 * time.Minute
|
||||
|
||||
// ownAddressPath is the IP lookup of the caller's own address.
|
||||
const ownAddressPath = "/ip/"
|
||||
|
||||
// listedUnroutedAddr is an address that fakeBlocklists lists, outside the
|
||||
// routes newServerWithTestRoutes stores.
|
||||
const listedUnroutedAddr = "203.0.113.1"
|
||||
|
||||
// fakeBlocklists has one list, which lists addrInTestPrefixV4 and
|
||||
// listedUnroutedAddr and no other address.
|
||||
type fakeBlocklists struct{}
|
||||
|
||||
func testListing() blocklist.Listing {
|
||||
return blocklist.Listing{
|
||||
Name: "test_list",
|
||||
Description: "On the test list",
|
||||
Updated: time.Date(2026, 10, 27, 4, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func (fakeBlocklists) Lookup(addr netip.Addr) []blocklist.Listing {
|
||||
if addr == netip.MustParseAddr(addrInTestPrefixV4) ||
|
||||
addr == netip.MustParseAddr(listedUnroutedAddr) {
|
||||
return []blocklist.Listing{testListing()}
|
||||
}
|
||||
|
||||
return []blocklist.Listing{}
|
||||
}
|
||||
|
||||
func (fakeBlocklists) ShortestInterval() time.Duration {
|
||||
return testBlocklistInterval
|
||||
}
|
||||
|
||||
// TestIPLookupReportsBlocklists looks up two routed addresses, one of them on
|
||||
// a blocklist. The listed address's result names the list, says what it means
|
||||
// and when it was updated. The other's result has an empty list.
|
||||
func TestIPLookupReportsBlocklists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
s.resolver = &fakeResolver{}
|
||||
s.SetBlocklists(fakeBlocklists{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet,
|
||||
"/api/v1/ip/"+addrInTestPrefixV4+","+addrInTestPrefixV6, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data IPLookupResponse `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
|
||||
}
|
||||
|
||||
results := body.Data.Results
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("answer %s, want two results", rec.Body)
|
||||
}
|
||||
|
||||
want := testListing()
|
||||
|
||||
listed := results[0].Blocklists
|
||||
if len(listed) != 1 || listed[0].Name != want.Name ||
|
||||
listed[0].Description != want.Description ||
|
||||
!listed[0].Updated.Equal(want.Updated) {
|
||||
t.Errorf("%s: blocklists %+v, want [%+v]", results[0].IP, listed, want)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), `"blocklists":[]`) {
|
||||
t.Errorf("answer %s, want an empty blocklists list for %s",
|
||||
rec.Body, addrInTestPrefixV6)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute looks up a listed
|
||||
// address that has no live route, alone and beside a routed address. Each
|
||||
// answer is 200 and has a result for the address, with its blocklists and no
|
||||
// route fields. Before the fix the lookup of that address alone answered
|
||||
// 404, and beside a routed address it was only named in errors, so its
|
||||
// blocklists were never reported.
|
||||
func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
s.resolver = &fakeResolver{}
|
||||
s.SetBlocklists(fakeBlocklists{})
|
||||
|
||||
for _, target := range []string{
|
||||
listedUnroutedAddr,
|
||||
addrInTestPrefixV4 + "," + listedUnroutedAddr,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodGet, "/api/v1/ip/"+target, nil))
|
||||
|
||||
var body struct {
|
||||
Data IPLookupResponse `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if rec.Code != http.StatusOK || err != nil {
|
||||
t.Errorf("%s: status %d (%v), want %d; body %s",
|
||||
target, rec.Code, err, http.StatusOK, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
i := slices.IndexFunc(body.Data.Results, func(r IPLookupResult) bool {
|
||||
return r.IP == listedUnroutedAddr
|
||||
})
|
||||
if i < 0 || len(body.Data.Errors) != 0 {
|
||||
t.Errorf("%s: answer %s, want a result for %s and no errors",
|
||||
target, rec.Body, listedUnroutedAddr)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
result := body.Data.Results[i]
|
||||
if result.IPInfo != nil {
|
||||
t.Errorf("%s: result for %s has route fields %+v",
|
||||
target, listedUnroutedAddr, result.IPInfo)
|
||||
}
|
||||
|
||||
if len(result.Blocklists) != 1 ||
|
||||
result.Blocklists[0].Name != testListing().Name {
|
||||
t.Errorf("%s: blocklists %+v, want [%+v]",
|
||||
target, result.Blocklists, testListing())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
|
||||
// lookup answers. Caches may keep them for the blocklists' shortest interval,
|
||||
// and only the caller's own browser may keep the answer about the caller's
|
||||
// own address.
|
||||
func TestIPLookupAnswersMayBeCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
s.resolver = &fakeResolver{}
|
||||
s.SetBlocklists(fakeBlocklists{})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/v1/ip/" + addrInTestPrefixV4, "public, max-age=900"},
|
||||
{ownAddressPath + addrInTestPrefixV4, "public, max-age=900"},
|
||||
{ownAddressPath, "private, max-age=900"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
tt.path, nil)
|
||||
req.RemoteAddr = net.JoinHostPort(addrInTestPrefixV4, "1234")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
tt.path, rec.Code, http.StatusOK, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if got := rec.Header().Get(cacheControlHeader); got != tt.want {
|
||||
t.Errorf("%s: Cache-Control %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The host name TestIPLookupHidesPrivateAddresses resolves to a private
|
||||
// address, and that address.
|
||||
const (
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
@@ -36,6 +37,13 @@ type ASNFetcher interface {
|
||||
GetStats() ASNFetcherStats
|
||||
}
|
||||
|
||||
// Blocklists tells which public blocklists list an address, from copies it
|
||||
// keeps in memory, as *blocklist.Blocklists does.
|
||||
type Blocklists interface {
|
||||
Lookup(addr netip.Addr) []blocklist.Listing
|
||||
ShortestInterval() time.Duration
|
||||
}
|
||||
|
||||
// dnsResolver looks up the addresses of host names and the names of addresses
|
||||
// in DNS, as *net.Resolver does.
|
||||
type dnsResolver interface {
|
||||
@@ -52,6 +60,7 @@ type Server struct {
|
||||
port int
|
||||
srv *http.Server
|
||||
asnFetcher ASNFetcher
|
||||
blocklists Blocklists
|
||||
|
||||
// trustedProxies are the reverse proxies whose X-Forwarded-For header
|
||||
// RealIPMiddleware believes
|
||||
@@ -148,3 +157,9 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
|
||||
s.asnFetcher = fetcher
|
||||
}
|
||||
|
||||
// SetBlocklists sets the blocklists the IP lookups report. Call it before
|
||||
// Start: the handlers read the blocklists without a lock.
|
||||
func (s *Server) SetBlocklists(blocklists Blocklists) {
|
||||
s.blocklists = blocklists
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user