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 in the state directory's blocklists directory, and loads the copies at startup. Each list's interval follows its publisher's page, cited in the code. Each IP lookup result carries blocklists, one entry per list the address is on. A successful lookup 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 is contained in:
@@ -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"
|
||||
@@ -1246,6 +1248,129 @@ 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/"
|
||||
|
||||
// fakeBlocklists has one list, which lists addrInTestPrefixV4 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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
|
||||
Reference in New Issue
Block a user