Rename blocklists to IP lists and add IP lookup flags (closes #233)
check / check (push) Failing after 1m7s

The answer field is now ip_lists, the package internal/iplist and the
state directory iplists. No compatibility is kept for the old names.

Every IP lookup result gains a flags object with is_tor and is_bogon.
The largest list file accepted is now 200 MiB.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-10 09:27:12 +00:00
1 parent e58c6635e7
commit 9e8f687511
12 files changed
+375 -176

No files matched your search

+19 -16
View File
@@ -44,8 +44,7 @@ its abuse contact. RouteWatch keeps them in its database and fetches them again
once they are 30 days old. An IP lookup answers from the stored details and
never waits for WHOIS.
An IP lookup also says which public blocklists list the address (see
Blocklists).
An IP lookup also says which public IP lists hold the address (see IP lists).
## Getting started
@@ -65,7 +64,7 @@ docker run --memory=5g --memory-swap=5g -p 8080:8080 \
```
The memory limit is explained in Memory. The volume holds the database and the
copies of the blocklists.
copies of the IP lists.
To run the binary on the host instead, build it with `make build`. The build
needs Go and a C compiler, because the SQLite library is written in C:
@@ -284,8 +283,12 @@ address. Every entry has the same fields:
- `ip`: the address
- `ptr`: the address's PTR names, the host names that DNS gives for it, or an
empty list when it has none
- `blocklists`: the public blocklists the address is on (see Blocklists), or an
empty list when it is on none
- `flags`: yes-or-no facts about the address:
- `is_tor`: true when the address is on the `tor_exits` list
- `is_bogon`: true when the address is reserved, so that no network should
announce it on the internet
- `ip_lists`: the public IP lists the address is on (see IP lists), or an empty
list when it is on none
Every other field is about the prefix in the routing table that holds the
address. Each is null when no prefix in the routing table holds the address:
@@ -331,7 +334,7 @@ Each PTR lookup gets 2 seconds. An address whose lookup takes longer is answered
without PTR names.
A successful answer carries `Cache-Control` with a `max-age` of 15 minutes, the
shortest blocklist interval. The answer for the caller's own address (`/ip/`) is
shortest IP list interval. The answer for the caller's own address (`/ip/`) is
`private`, and the others are `public`.
### Errors
@@ -391,10 +394,10 @@ inline `<style>` and `<script>` blocks. Each answer's policy names a random
value, a nonce, made for that answer. The browser applies or runs only the
blocks that carry it.
## Blocklists
## IP lists
An IP lookup reports which public blocklists list each address. Each entry in
`blocklists` has:
An IP lookup reports which public IP lists hold each address. Each entry in
`ip_lists` has:
- `name`: the list's name, from the lists below
- `description`: what being on the list means
@@ -431,8 +434,8 @@ can be downloaded whole without an account or a key.
How RouteWatch keeps the lists:
- It holds each list in memory, so a lookup makes no outside request.
- It keeps a copy of each list in the `blocklists` directory of the state
directory (see Configuration). Each copy is a file named after its list.
- It keeps a copy of each list in the `iplists` directory of the state directory
(see Configuration). Each copy is a file named after its list.
- Beside each copy, a file ending in `.validators` keeps the `Last-Modified` and
`ETag` headers the publisher sent with that copy.
- At startup it loads each list from its copy. It downloads a list at once when
@@ -440,7 +443,7 @@ How RouteWatch keeps the lists:
- A download sends the copy's `Last-Modified` as `If-Modified-Since` and its
`ETag` as `If-None-Match`. The publisher then sends the list only when it has
changed.
- A download fails when it takes over a minute or its file is over 16 MiB.
- A download fails when it takes over a minute or its file is over 200 MiB.
- When a download fails, or its file does not parse, RouteWatch keeps the
previous copy and logs the failure. It tries again one interval later.
@@ -492,7 +495,7 @@ by the client. For a request from any other address, they use the address the
request came from, whatever headers it carries. `X-Real-IP` and `True-Client-IP`
are not read.
The state directory holds the database, `db.sqlite`, and the blocklist copies:
The state directory holds the database, `db.sqlite`, and the IP list copies:
- macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/`
- Linux and the BSDs, as root: `/var/lib/berlin.sneak.app.routewatch/`
@@ -647,7 +650,7 @@ The parts:
wait starts at 15 seconds. It halves after a success, down to 1 second, and
grows by half after a failure, up to a minute. An IP lookup that finds an AS
with old or missing details asks the fetcher for it at once, without the wait.
- The blocklists part downloads the blocklists and answers which lists hold an
- The IP lists part downloads the IP lists and answers which lists hold an
address.
- The database maintainer checkpoints the database every 5 seconds: it folds the
write-ahead log, where SQLite first writes changes, into the database file. It
@@ -756,7 +759,7 @@ On `SIGTERM` or `SIGINT` the daemon stops its parts in this order:
2. the handlers, each of which writes what it holds
3. the WHOIS fetcher
4. the database maintainer
5. the blocklist downloads
5. the IP list downloads
6. the HTTP server
Last, the daemon checkpoints and closes the database. It gives all this 8
@@ -790,10 +793,10 @@ routewatch/
├── docs/
│ └── message-examples.json # Examples of RIS Live messages
├── internal/
│ ├── blocklist/ # Public IP blocklists: downloads and lookups
│ ├── bogon/ # Reserved prefixes and AS numbers
│ ├── config/ # Settings from the environment
│ ├── database/ # SQLite storage
│ ├── iplist/ # Public IP lists: downloads and lookups
│ ├── logger/ # Structured logging
│ ├── metrics/ # Feed and route update figures
│ ├── ristypes/ # The RIS Live message types
+5 -3
View File
@@ -41,9 +41,6 @@ https://git.eeqj.de/sneak/routewatch/issues/225, also on `next`, takes the peers
whose sessions go down together out of the table in one walk. A 24-hour run of
`9bd0e91` on the live feed is checking whether any handler still drops updates.
One question waits for sneak: which fields the IP lookup answer gains
(https://git.eeqj.de/sneak/routewatch/issues/203).
The order of the project's remaining work is kept on
https://git.eeqj.de/sneak/routewatch/issues/86.
@@ -56,6 +53,11 @@ problem the run finds.
# Completed Steps
- 2026-11-10: each result of the IP lookup has a `flags` object with `is_tor`
and `is_bogon`. The public IP lists are named `ip_lists` in the answer,
`internal/iplist` in the code and `iplists` in the state directory. A list
file may be up to 200 MiB (closes
https://git.eeqj.de/sneak/routewatch/issues/233).
- 2026-11-05: the prefix page lists the prefix's origin changes, newest first.
The routing table records each change, and the prefix handler writes them to a
new `origin_changes` table. The database maintainer prunes each prefix to its
@@ -1,4 +1,4 @@
package blocklist
package iplist
import (
"context"
@@ -22,7 +22,7 @@ const (
// maxDownloadSize is the size of the largest list file accepted, in
// bytes. The largest list, FireHOL abusers, is about 2.5 MiB.
maxDownloadSize = 16 << 20
maxDownloadSize = 200 << 20
)
// The request headers a download sets.
@@ -78,10 +78,10 @@ var (
// downloads each list once its copy is older than its Interval, and a list
// with no copy at once. The goroutine runs until Stop: the end of ctx does
// not stop it.
func (b *Blocklists) Start(ctx context.Context) {
func (b *IPLists) Start(ctx context.Context) {
err := os.MkdirAll(b.dir, dirPermissions)
if err != nil {
b.logger.Error("Failed to create the blocklists directory",
b.logger.Error("Failed to create the IP lists directory",
"dir", b.dir, "error", err)
}
@@ -90,7 +90,7 @@ func (b *Blocklists) Start(ctx context.Context) {
for i, list := range b.lists {
loadErr := b.loadCopy(list)
if loadErr != nil && !errors.Is(loadErr, fs.ErrNotExist) {
b.logger.Error("Failed to load a blocklist copy",
b.logger.Error("Failed to load an IP list copy",
"list", list.Name, "error", loadErr)
}
@@ -104,20 +104,20 @@ func (b *Blocklists) Start(ctx context.Context) {
b.run(ctx, nextDownload)
}()
b.logger.Info("Blocklist downloads started", "dir", b.dir)
b.logger.Info("IP list downloads started", "dir", b.dir)
}
// Stop stops the downloads, cancelling one in progress, and waits until the
// goroutine has returned or ctx ends. The copies in memory stay, so Lookup
// still answers.
func (b *Blocklists) Stop(ctx context.Context) {
func (b *IPLists) Stop(ctx context.Context) {
b.stop()
select {
case <-b.done:
b.logger.Info("Blocklist downloads stopped")
b.logger.Info("IP list downloads stopped")
case <-ctx.Done():
b.logger.Warn("Stop deadline passed before the blocklist downloads " +
b.logger.Warn("Stop deadline passed before the IP list downloads " +
"stopped")
}
}
@@ -125,7 +125,7 @@ func (b *Blocklists) Stop(ctx context.Context) {
// run downloads each list when nextDownload says, until ctx ends. After each
// try, whether it worked or not, the list's next download is an Interval
// later.
func (b *Blocklists) run(ctx context.Context, nextDownload []time.Time) {
func (b *IPLists) run(ctx context.Context, nextDownload []time.Time) {
defer close(b.done)
if len(nextDownload) == 0 {
@@ -155,7 +155,7 @@ func (b *Blocklists) run(ctx context.Context, nextDownload []time.Time) {
}
if err != nil {
b.logger.Error("Blocklist download failed; keeping the "+
b.logger.Error("IP list download failed; keeping the "+
"previous copy", "list", list.Name, "error", err)
}
@@ -169,7 +169,7 @@ func (b *Blocklists) run(ctx context.Context, nextDownload []time.Time) {
// on disk and in memory. An answer that the copy is still current marks the
// copy as updated now. Anything else leaves the copy as it is and returns an
// error.
func (b *Blocklists) download(ctx context.Context, list List) error {
func (b *IPLists) download(ctx context.Context, list List) error {
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
@@ -212,7 +212,7 @@ func (b *Blocklists) download(ctx context.Context, list List) error {
// replaceCopy writes a new version of list, read from body, beside the list's
// copy. Only when it parses does it replace the copy, on disk and in memory.
// sent are the validators the publisher sent with the new version.
func (b *Blocklists) replaceCopy(list List, body io.Reader, sent validators) error {
func (b *IPLists) replaceCopy(list List, body io.Reader, sent validators) error {
root, err := os.OpenRoot(b.dir)
if err != nil {
return err
@@ -244,13 +244,13 @@ func (b *Blocklists) replaceCopy(list List, body io.Reader, sent validators) err
b.setCopy(list.Name, listCopy{
addresses: addresses, updated: time.Now().UTC(), validators: sent,
})
b.logger.Info("Downloaded a new version of a blocklist", "list", list.Name)
b.logger.Info("Downloaded a new version of an IP list", "list", list.Name)
// Without this file, the first download after a restart asks for the
// whole list.
err = writeValidators(root, list.Name, sent)
if err != nil {
b.logger.Error("Failed to keep the validators of a blocklist copy",
b.logger.Error("Failed to keep the validators of an IP list copy",
"list", list.Name, "error", err)
}
@@ -288,7 +288,7 @@ func writeAndParse(
// markCurrent records that the publisher says the list's copy is still
// current: the copy, on disk and in memory, is marked as updated now.
func (b *Blocklists) markCurrent(list List) error {
func (b *IPLists) markCurrent(list List) error {
root, err := os.OpenRoot(b.dir)
if err != nil {
return err
@@ -304,7 +304,7 @@ func (b *Blocklists) markCurrent(list List) error {
}
b.setUpdated(list.Name, now)
b.logger.Debug("Blocklist copy is still current", "list", list.Name)
b.logger.Debug("IP list copy is still current", "list", list.Name)
return nil
}
@@ -312,7 +312,7 @@ func (b *Blocklists) markCurrent(list List) error {
// loadCopy makes the list's copy on disk, with the validators kept beside it,
// its copy in memory. The file's modification time is when the copy was
// updated.
func (b *Blocklists) loadCopy(list List) error {
func (b *IPLists) loadCopy(list List) error {
root, err := os.OpenRoot(b.dir)
if err != nil {
return err
@@ -1,7 +1,7 @@
// Package blocklist downloads public IP blocklists on a schedule, keeps the
// last good copy of each on disk and in memory, and answers which lists
// contain an address.
package blocklist
// Package iplist downloads public IP lists on a schedule, keeps the last good
// copy of each on disk and in memory, and answers which lists contain an
// address.
package iplist
import (
"context"
@@ -31,7 +31,7 @@ const (
FormatDShield
)
// List is one public blocklist.
// List is one public IP list.
type List struct {
// Name names the list in lookup answers, and names its copy on disk.
Name string
@@ -80,9 +80,9 @@ type Params struct {
Logger *slog.Logger
}
// Blocklists keeps a copy of each list and answers which lists contain an
// IPLists keeps a copy of each list and answers which lists contain an
// address. Start loads the copies from disk and starts downloading the lists.
type Blocklists struct {
type IPLists struct {
dir string
lists []List
logger *slog.Logger
@@ -97,12 +97,12 @@ type Blocklists struct {
done chan struct{}
}
// New returns a Blocklists with no copies. Start loads them.
func New(params Params) *Blocklists {
return &Blocklists{
// New returns an IPLists with no copies. Start loads them.
func New(params Params) *IPLists {
return &IPLists{
dir: params.Dir,
lists: params.Lists,
logger: params.Logger.With("component", "blocklists"),
logger: params.Logger.With("component", "iplists"),
copies: make(map[string]listCopy),
done: make(chan struct{}),
}
@@ -111,7 +111,7 @@ func New(params Params) *Blocklists {
// Lookup returns a Listing for each list whose copy contains addr, in the
// order of the lists given to New. It makes no outside request. When no list
// contains addr, it returns an empty list, not nil.
func (b *Blocklists) Lookup(addr netip.Addr) []Listing {
func (b *IPLists) Lookup(addr netip.Addr) []Listing {
b.mu.RLock()
defer b.mu.RUnlock()
@@ -133,7 +133,7 @@ func (b *Blocklists) Lookup(addr netip.Addr) []Listing {
// ShortestInterval returns the shortest Interval of the lists. That is the
// soonest a copy can change, and so how long a lookup answer stays current.
func (b *Blocklists) ShortestInterval() time.Duration {
func (b *IPLists) ShortestInterval() time.Duration {
var shortest time.Duration
for i, list := range b.lists {
@@ -147,7 +147,7 @@ func (b *Blocklists) ShortestInterval() time.Duration {
// updated returns when the named list's copy was last downloaded or found
// current, or the zero time when the list has no copy.
func (b *Blocklists) updated(name string) time.Time {
func (b *IPLists) updated(name string) time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
@@ -156,7 +156,7 @@ func (b *Blocklists) updated(name string) time.Time {
// copyValidators returns the validators of the named list's copy. They are
// empty when the list has no copy.
func (b *Blocklists) copyValidators(name string) validators {
func (b *IPLists) copyValidators(name string) validators {
b.mu.RLock()
defer b.mu.RUnlock()
@@ -164,7 +164,7 @@ func (b *Blocklists) copyValidators(name string) validators {
}
// setCopy makes c the named list's copy.
func (b *Blocklists) setCopy(name string, c listCopy) {
func (b *IPLists) setCopy(name string, c listCopy) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -172,7 +172,7 @@ func (b *Blocklists) setCopy(name string, c listCopy) {
}
// setUpdated records that the named list's copy was found current at t.
func (b *Blocklists) setUpdated(name string, t time.Time) {
func (b *IPLists) setUpdated(name string, t time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -1,4 +1,4 @@
package blocklist
package iplist
import (
"context"
@@ -121,7 +121,7 @@ func testList(url string, format Format) List {
}
}
func newTestBlocklists(dir string, list List) *Blocklists {
func newTestIPLists(dir string, list List) *IPLists {
return New(Params{
Dir: dir,
Lists: []List{list},
@@ -130,7 +130,7 @@ func newTestBlocklists(dir string, list List) *Blocklists {
}
// lookup looks addr up in b.
func lookup(t *testing.T, b *Blocklists, addr string) []Listing {
func lookup(t *testing.T, b *IPLists, addr string) []Listing {
t.Helper()
return b.Lookup(netip.MustParseAddr(addr))
@@ -188,7 +188,7 @@ func TestLookupFindsListedAddresses(t *testing.T) {
t.Parallel()
_, url := newPublisher(t, tt.body)
b := newTestBlocklists(t.TempDir(), testList(url, tt.format))
b := newTestIPLists(t.TempDir(), testList(url, tt.format))
err := b.download(t.Context(), b.lists[0])
if err != nil {
@@ -228,10 +228,6 @@ func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
}{
{"server error", http.StatusInternalServerError, "unavailable"},
{"not a list", http.StatusOK, "<!DOCTYPE html>\n<html></html>\n"},
{
"over the size limit", http.StatusOK,
strings.Repeat(listedAddr+"\n", maxDownloadSize/len(listedAddr)),
},
}
for _, tt := range tests {
@@ -240,7 +236,7 @@ func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
dir := t.TempDir()
p, url := newPublisher(t, goodListBody)
b := newTestBlocklists(dir, testList(url, FormatLines))
b := newTestIPLists(dir, testList(url, FormatLines))
err := b.download(t.Context(), b.lists[0])
if err != nil {
@@ -262,23 +258,106 @@ func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
t.Errorf("listings %+v after the failure, want %+v", after, before)
}
copies := os.DirFS(dir)
onDisk, err := fs.ReadFile(copies, testListName)
if err != nil || string(onDisk) != goodListBody {
t.Errorf("copy on disk %q (%v), want %q", onDisk, err, goodListBody)
}
_, err = fs.Stat(copies, testListName+downloadSuffix)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("the failed download's file is still there (%v)", err)
}
checkGoodCopyOnDisk(t, dir)
})
}
}
// checkGoodCopyOnDisk checks that dir holds the good version of the test list
// and no part of a failed download.
func checkGoodCopyOnDisk(t *testing.T, dir string) {
t.Helper()
copies := os.DirFS(dir)
onDisk, err := fs.ReadFile(copies, testListName)
if err != nil || string(onDisk) != goodListBody {
t.Errorf("copy on disk %q (%v), want %q", onDisk, err, goodListBody)
}
_, err = fs.Stat(copies, testListName+downloadSuffix)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("the failed download's file is still there (%v)", err)
}
}
// commentLines is an endless io.Reader of comment lines. It lets a test send
// a large list file without holding the file in memory.
type commentLines struct {
offset int
}
func (c *commentLines) Read(p []byte) (int, error) {
const line = "# a comment line that pads a large list file\n"
for filled := 0; filled < len(p); {
copied := copy(p[filled:], line[c.offset:])
filled += copied
c.offset = (c.offset + copied) % len(line)
}
return len(p), nil
}
// largeListFile returns a reader of a list file of size bytes: comment lines,
// then newListedAddr on the last line.
func largeListFile(size int64) io.Reader {
lastLine := "\n" + newListedAddr + "\n"
return io.MultiReader(
io.LimitReader(&commentLines{}, size-int64(len(lastLine))),
strings.NewReader(lastLine),
)
}
// TestLargeFileUnderTheSizeLimitIsAccepted replaces a list with a 199 MiB
// file. The address on its last line is listed, so the whole file was read.
func TestLargeFileUnderTheSizeLimitIsAccepted(t *testing.T) {
t.Parallel()
const size = 199 << 20
b := newTestIPLists(t.TempDir(), testList("", FormatLines))
err := b.replaceCopy(b.lists[0], largeListFile(size), validators{})
if err != nil {
t.Fatalf("replaceCopy: %v", err)
}
if listings := lookup(t, b, newListedAddr); len(listings) != 1 {
t.Errorf("listings %+v, want one", listings)
}
}
// TestFileOverTheSizeLimitKeepsThePreviousCopy downloads a good version of a
// list, then replaces it with a file one byte over the limit. The attempt
// fails, and the good copy stays in memory and on disk.
func TestFileOverTheSizeLimitKeepsThePreviousCopy(t *testing.T) {
t.Parallel()
dir := t.TempDir()
_, url := newPublisher(t, goodListBody)
b := newTestIPLists(dir, testList(url, FormatLines))
err := b.download(t.Context(), b.lists[0])
if err != nil {
t.Fatalf("download: %v", err)
}
err = b.replaceCopy(b.lists[0], largeListFile(maxDownloadSize+1), validators{})
if !errors.Is(err, errTooLarge) {
t.Fatalf("replaceCopy: %v, want %v", err, errTooLarge)
}
if listings := lookup(t, b, listedAddr); len(listings) != 1 {
t.Errorf("listings of %s %+v, want one", listedAddr, listings)
}
checkGoodCopyOnDisk(t, dir)
}
// TestRestartLoadsCopiesFromDisk downloads a list, then starts a new
// Blocklists on the same directory, as a restart does. The new one answers
// IPLists on the same directory, as a restart does. The new one answers
// from the copy on disk at once, and does not download the list again while
// the copy is younger than the list's interval.
func TestRestartLoadsCopiesFromDisk(t *testing.T) {
@@ -288,7 +367,7 @@ func TestRestartLoadsCopiesFromDisk(t *testing.T) {
p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines)
first := newTestBlocklists(dir, list)
first := newTestIPLists(dir, list)
err := first.download(t.Context(), list)
if err != nil {
@@ -297,7 +376,7 @@ func TestRestartLoadsCopiesFromDisk(t *testing.T) {
downloaded := lookup(t, first, listedAddr)[0].Updated
second := newTestBlocklists(dir, list)
second := newTestIPLists(dir, list)
second.Start(t.Context())
second.Stop(t.Context())
@@ -328,7 +407,7 @@ func TestStartRefreshesAnOldCopy(t *testing.T) {
lastModified := time.Now().Add(-3 * list.Interval).UTC().Truncate(time.Second)
p.publish(goodListBody, lastModified, testETag)
err := newTestBlocklists(dir, list).download(t.Context(), list)
err := newTestIPLists(dir, list).download(t.Context(), list)
if err != nil {
t.Fatalf("download: %v", err)
}
@@ -340,7 +419,7 @@ func TestStartRefreshesAnOldCopy(t *testing.T) {
t.Fatalf("Chtimes: %v", err)
}
b := newTestBlocklists(dir, list)
b := newTestIPLists(dir, list)
b.Start(t.Context())
deadline := time.Now().Add(maxWait)
@@ -381,7 +460,7 @@ func TestNewVersionOlderThanTheLastDownloadReplacesTheCopy(t *testing.T) {
p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines)
b := newTestBlocklists(t.TempDir(), list)
b := newTestIPLists(t.TempDir(), list)
firstVersion := time.Now().Add(-2 * list.Interval).UTC().Truncate(time.Second)
p.publish(goodListBody, firstVersion, "")
@@ -418,7 +497,7 @@ func TestDownloadWithoutLastModifiedOrETagSendsNoCondition(t *testing.T) {
p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines)
b := newTestBlocklists(t.TempDir(), list)
b := newTestIPLists(t.TempDir(), list)
err := b.download(t.Context(), list)
if err != nil {
@@ -455,7 +534,7 @@ func TestStopCancelsADownloadInProgress(t *testing.T) {
}))
t.Cleanup(srv.Close)
b := newTestBlocklists(t.TempDir(), testList(srv.URL, FormatLines))
b := newTestIPLists(t.TempDir(), testList(srv.URL, FormatLines))
b.Start(t.Context())
select {
@@ -1,7 +1,10 @@
package blocklist
package iplist
import "time"
// TorExitsList is the name of the list of Tor exit nodes.
const TorExitsList = "tor_exits"
// How often each list is downloaded. No list is downloaded more often than
// its publisher updates it or allows.
const (
@@ -30,7 +33,7 @@ const (
torExitsInterval = time.Hour
)
// Lists returns the public blocklists routewatch downloads. Each can be
// Lists returns the public IP lists routewatch downloads. Each can be
// downloaded whole without an account or a key. Spamhaus merged its EDROP
// list into DROP, so DROP covers both.
func Lists() []List {
@@ -83,7 +86,7 @@ func Lists() []List {
Format: FormatSpamhausJSON,
},
{
Name: "tor_exits",
Name: TorExitsList,
Description: "A Tor exit node, where traffic from the Tor network leaves",
URL: "https://check.torproject.org/torbulkexitlist",
Interval: torExitsInterval,
@@ -1,4 +1,4 @@
package blocklist
package iplist
import (
"bufio"
+16 -16
View File
@@ -15,9 +15,9 @@ import (
"sync"
"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/iplist"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/routetable"
@@ -61,12 +61,12 @@ type RouteWatch struct {
peeringHandler *PeeringHandler
asnFetcher *ASNFetcher
dbMaintainer *DBMaintainer
blocklists *blocklist.Blocklists
ipLists *iplist.IPLists
}
// blocklistsDirName is the directory in the state directory that holds the
// blocklist copies.
const blocklistsDirName = "blocklists"
// ipListsDirName is the directory in the state directory that holds the IP
// list copies.
const ipListsDirName = "iplists"
// New creates a new RouteWatch instance
func New(deps Dependencies) *RouteWatch {
@@ -138,17 +138,17 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
rw.asnFetcher.Start(ctx)
rw.server.SetASNFetcher(rw.asnFetcher)
// Load the blocklist copies from disk and start downloading the lists.
// The HTTP handlers read the server's blocklists without a lock, so they
// Load the IP list copies from disk and start downloading the lists.
// The HTTP handlers read the server's IP lists without a lock, so they
// are set before the handlers run.
blocklists := blocklist.New(blocklist.Params{
Dir: filepath.Join(rw.config.StateDir, blocklistsDirName),
Lists: blocklist.Lists(),
ipLists := iplist.New(iplist.Params{
Dir: filepath.Join(rw.config.StateDir, ipListsDirName),
Lists: iplist.Lists(),
Logger: rw.logger.Logger,
})
blocklists.Start(ctx)
rw.blocklists = blocklists
rw.server.SetBlocklists(blocklists)
ipLists.Start(ctx)
rw.ipLists = ipLists
rw.server.SetIPLists(ipLists)
// Start HTTP server
err = rw.server.Start(ctx)
@@ -168,7 +168,7 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
// Shutdown stops all services in order: the streamer, the handlers, which
// write what they hold, the WHOIS fetcher, the database maintainer, the
// blocklist downloads and the HTTP server, and last the database, which it
// IP list downloads and the HTTP server, and last the database, which it
// checkpoints and closes. Each stage waits at most until ctx ends, so
// Shutdown returns soon after: a handler logs what it could not write by
// then, and the HTTP server closes the connections of requests still
@@ -219,8 +219,8 @@ func (rw *RouteWatch) Shutdown(ctx context.Context) {
rw.dbMaintainer.Stop(ctx)
}
if rw.blocklists != nil {
rw.blocklists.Stop(ctx)
if rw.ipLists != nil {
rw.ipLists.Stop(ctx)
}
err := rw.server.Stop(ctx)
@@ -418,7 +418,7 @@ func TestRouteWatchLiveFeed(t *testing.T) {
// Create streamer
s := streamer.New(logger, metricsTracker)
// The state directory holds the blocklist copies Run downloads.
// The state directory holds the IP list copies Run downloads.
cfg := &config.Config{
StateDir: t.TempDir(),
MaxRuntime: 5 * time.Second,
+73 -22
View File
@@ -15,8 +15,9 @@ import (
"strings"
"time"
"git.eeqj.de/sneak/routewatch/internal/blocklist"
"git.eeqj.de/sneak/routewatch/internal/bogon"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/iplist"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
@@ -765,9 +766,12 @@ type IPLookupResponse struct {
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. Every result has every
// field. The fields of IPInfo are null when the address has no live route.
// 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.
//
//nolint:tagliatelle // snake_case keys, as the Go style guide asks
type IPLookupResult struct {
IPInfo
@@ -775,8 +779,22 @@ type IPLookupResult struct {
// PTR is empty, never nil, when the address has no PTR names.
PTR []string `json:"ptr"`
// Blocklists has one entry for each blocklist the address is on.
Blocklists []blocklist.Listing `json:"blocklists"`
Flags IPLookupFlags `json:"flags"`
// IPLists has one entry for each IP list the address is on.
IPLists []iplist.Listing `json:"ip_lists"`
}
// IPLookupFlags are the yes-or-no facts about an address. Every result has
// them, whether or not the address has a live route.
//
//nolint:tagliatelle // snake_case keys, as the Go style guide asks
type IPLookupFlags struct {
// IsTor says that the address is on the Tor exit list.
IsTor bool `json:"is_tor"`
// IsBogon says that the address is reserved, as package bogon tells.
IsBogon bool `json:"is_bogon"`
}
// handleIPInfo returns a handler that provides comprehensive IP information.
@@ -852,12 +870,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
return
}
response.Results = append(response.Results, IPLookupResult{
IP: ip,
PTR: ptrs,
IPInfo: ipInfo,
Blocklists: s.blocklistListings(ip),
})
response.Results = append(response.Results, s.ipLookupResult(ip, ptrs, ipInfo))
if ipInfo.NeedsWHOISRefresh {
refreshASNs[*ipInfo.ASN] = true
@@ -958,23 +971,61 @@ func ipVersionOf(prefix netip.Prefix) int {
return ipVersionV6
}
// 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 {
// ipLookupResult returns the result for ip, adding its flags and IP lists to
// its PTR names and route.
func (s *Server) ipLookupResult(ip string, ptrs []string, info IPInfo) IPLookupResult {
listings := s.ipListListings(ip)
return IPLookupResult{
IP: ip,
PTR: ptrs,
IPInfo: info,
Flags: ipLookupFlags(ip, listings),
IPLists: listings,
}
}
// ipListListings returns the IP lists that contain ip. It returns an empty
// list, never nil, so that an answer's ip_lists is [] rather than null.
func (s *Server) ipListListings(ip string) []iplist.Listing {
addr, err := netip.ParseAddr(ip)
if s.blocklists == nil || err != nil {
return []blocklist.Listing{}
if s.ipLists == nil || err != nil {
return []iplist.Listing{}
}
return s.blocklists.Lookup(addr)
return s.ipLists.Lookup(addr)
}
// ipLookupFlags returns the flags of ip, whose IP list entries are listings.
func ipLookupFlags(ip string, listings []iplist.Listing) IPLookupFlags {
return IPLookupFlags{
IsTor: slices.ContainsFunc(listings, func(l iplist.Listing) bool {
return l.Name == iplist.TorExitsList
}),
IsBogon: isBogon(ip),
}
}
// isBogon says whether the address ip is reserved. An IPv4 address written in
// IPv6 form counts as the IPv4 address it holds.
func isBogon(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
addr = addr.Unmap()
_, reserved := bogon.Prefix(netip.PrefixFrom(addr, addr.BitLen()))
return reserved
}
// setIPLookupCacheControl lets caches keep an IP lookup answer for the
// shortest blocklist interval, the soonest a blocklist copy can change. The
// shortest IP list interval, the soonest an IP list 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.
// does not give it to another caller. Without IP lists it sets nothing.
func (s *Server) setIPLookupCacheControl(w http.ResponseWriter, ownAddress bool) {
if s.blocklists == nil {
if s.ipLists == nil {
return
}
@@ -983,7 +1034,7 @@ func (s *Server) setIPLookupCacheControl(w http.ResponseWriter, ownAddress bool)
visibility = cacheControlPrivate
}
maxAgeSeconds := int(s.blocklists.ShortestInterval().Seconds())
maxAgeSeconds := int(s.ipLists.ShortestInterval().Seconds())
w.Header().Set(cacheControlHeader,
visibility+cacheControlMaxAge+strconv.Itoa(maxAgeSeconds))
}
+102 -41
View File
@@ -19,9 +19,9 @@ 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/iplist"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
@@ -1428,50 +1428,112 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
// newServerWithTestRoutes stores a route for.
const addrInTestPrefixV6 = "2001:db8::1"
// testBlocklistInterval is fakeBlocklists' shortest interval.
const testBlocklistInterval = 15 * time.Minute
// testIPListInterval is fakeIPLists' shortest interval.
const testIPListInterval = 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.
// listedUnroutedAddr is an address that fakeIPLists 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{}
// torExitAddr is the address that fakeIPLists lists on the Tor exit list.
const torExitAddr = "203.0.113.2"
func testListing() blocklist.Listing {
return blocklist.Listing{
// fakeIPLists has two lists. The test list holds addrInTestPrefixV4 and
// listedUnroutedAddr, and the Tor exit list holds torExitAddr.
type fakeIPLists struct{}
func testListing() iplist.Listing {
return iplist.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 {
func (fakeIPLists) Lookup(addr netip.Addr) []iplist.Listing {
if addr == netip.MustParseAddr(addrInTestPrefixV4) ||
addr == netip.MustParseAddr(listedUnroutedAddr) {
return []blocklist.Listing{testListing()}
return []iplist.Listing{testListing()}
}
return []blocklist.Listing{}
if addr == netip.MustParseAddr(torExitAddr) {
return []iplist.Listing{{Name: iplist.TorExitsList}}
}
return []iplist.Listing{}
}
func (fakeBlocklists) ShortestInterval() time.Duration {
return testBlocklistInterval
func (fakeIPLists) ShortestInterval() time.Duration {
return testIPListInterval
}
// 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) {
// TestIPLookupFlags looks up addresses with and without a route. Every result
// has both flags, under the JSON keys the API documents.
func TestIPLookupFlags(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetBlocklists(fakeBlocklists{})
s.SetIPLists(fakeIPLists{})
tests := []struct {
name string
addr string
tor bool
bogon bool
}{
{"on the Tor exit list", torExitAddr, true, true},
{"on another list", listedUnroutedAddr, false, true},
{"routed IPv4", addrInTestPrefixV4, false, true},
{"routed IPv6", addrInTestPrefixV6, false, true},
{"private IPv4", "10.0.0.1", false, true},
{"loopback IPv4", "127.0.0.1", false, true},
{"public IPv4", "1.1.1.1", false, false},
{"private IPv6", "fd00::1", false, true},
{"loopback IPv6", "::1", false, true},
{"public IPv6", "2606:4700:4700::1111", false, false},
{"public IPv4 in IPv6 form", "::ffff:1.1.1.1", false, false},
{"private IPv4 in IPv6 form", "::ffff:10.0.0.1", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := "/api/v1/ip/" + tt.addr
results := resultFields(t, get(t, s, path).Body.Bytes())
if len(results) != 1 {
t.Fatalf("%s: %d results, want 1", path, len(results))
}
var got map[string]bool
err := json.Unmarshal(results[0]["flags"], &got)
if err != nil {
t.Fatalf("%s: decoding flags %s: %v", path, results[0]["flags"], err)
}
want := map[string]bool{"is_tor": tt.tor, "is_bogon": tt.bogon}
if !maps.Equal(got, want) {
t.Errorf("%s: flags %v, want %v", path, got, want)
}
})
}
}
// TestIPLookupReportsIPLists looks up two routed addresses, one of them on an
// IP list. 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 TestIPLookupReportsIPLists(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetIPLists(fakeIPLists{})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
@@ -1498,15 +1560,15 @@ func TestIPLookupReportsBlocklists(t *testing.T) {
want := testListing()
listed := results[0].Blocklists
listed := results[0].IPLists
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)
t.Errorf("%s: ip_lists %+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",
if !strings.Contains(rec.Body.String(), `"ip_lists":[]`) {
t.Errorf("answer %s, want an empty ip_lists list for %s",
rec.Body, addrInTestPrefixV6)
}
}
@@ -1536,20 +1598,19 @@ func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
return body.Data.Results
}
// 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
// every route field set to null. 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. After that fix the result
// still left out the route fields
// (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
// TestIPLookupReportsIPListsOfAnAddressWithNoRoute 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 IP lists and every route
// field set to null. Before the fix the lookup of that address alone answered
// 404, and beside a routed address it was only named in errors, so its IP
// lists were never reported. After that fix the result still left out the
// route fields (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupReportsIPListsOfAnAddressWithNoRoute(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetBlocklists(fakeBlocklists{})
s.SetIPLists(fakeIPLists{})
// routeFields are the fields of a result that come from the address's
// route and that route's origin AS.
@@ -1605,10 +1666,10 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
}
result := body.Data.Results[i]
if len(result.Blocklists) != 1 ||
result.Blocklists[0].Name != testListing().Name {
t.Errorf("%s: blocklists %+v, want [%+v]",
target, result.Blocklists, testListing())
if len(result.IPLists) != 1 ||
result.IPLists[0].Name != testListing().Name {
t.Errorf("%s: ip_lists %+v, want [%+v]",
target, result.IPLists, testListing())
}
}
}
@@ -1629,7 +1690,7 @@ func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
s.resolver = &fakeResolver{hosts: map[string][]string{
twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr},
}}
s.SetBlocklists(fakeBlocklists{})
s.SetIPLists(fakeIPLists{})
// want is the fields of the first result.
var want []string
@@ -1684,7 +1745,7 @@ func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
}
// TestIPLookupAnswersMayBeCached checks the Cache-Control header of the IP
// lookup answers. Caches may keep them for the blocklists' shortest interval,
// lookup answers. Caches may keep them for the IP lists' shortest interval,
// and only the caller's own browser may keep the answer about the caller's
// own address.
func TestIPLookupAnswersMayBeCached(t *testing.T) {
@@ -1692,7 +1753,7 @@ func TestIPLookupAnswersMayBeCached(t *testing.T) {
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetBlocklists(fakeBlocklists{})
s.SetIPLists(fakeIPLists{})
tests := []struct {
path string
+10 -10
View File
@@ -22,9 +22,9 @@ import (
"sync/atomic"
"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/iplist"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
@@ -45,10 +45,10 @@ 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
// IPLists tells which public IP lists contain an address, from copies it keeps
// in memory, as *iplist.IPLists does.
type IPLists interface {
Lookup(addr netip.Addr) []iplist.Listing
ShortestInterval() time.Duration
}
@@ -69,7 +69,7 @@ type Server struct {
port int
srv *http.Server
asnFetcher ASNFetcher
blocklists Blocklists
ipLists IPLists
// trustedProxies are the reverse proxies whose X-Forwarded-For header
// RealIPMiddleware believes
@@ -202,8 +202,8 @@ 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
// SetIPLists sets the IP lists the IP lookups report. Call it before Start:
// the handlers read the IP lists without a lock.
func (s *Server) SetIPLists(ipLists IPLists) {
s.ipLists = ipLists
}