Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 9a6e33693f Report which public blocklists list an address in the IP lookup (closes #158)
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
2026-10-27 04:50:03 +00:00
9 changed files with 72 additions and 363 deletions

No files matched your search

+18 -27
View File
@@ -87,26 +87,20 @@ Every route answers `HEAD` as it answers `GET`, without the body.
collector, stream and handler queue statistics. `stream.route_churn_per_min` collector, stream and handler queue statistics. `stream.route_churn_per_min`
is the number of prefix announcements and withdrawals, from all peers, in the is the number of prefix announcements and withdrawals, from all peers, in the
last full clock minute. last full clock minute.
- `GET /api/v1/ip/{ip}` - Looks up each address. `{ip}` is an IP address, a host - `GET /api/v1/ip/{ip}` - Looks up each address: its most specific live route
name, or a comma-separated list of at most 10 of these. `results` has one (`netblock`), that route's origin AS with its WHOIS details, and the address's
entry for each address, with: PTR names. Each result also lists the public blocklists the address is on, in
- `ip` and `ptr`: the address and its PTR names `blocklists` (see Blocklists). `{ip}` is an IP address, a host name, or a
- `netblock` and the fields beside it: the address's most specific live comma-separated list of at most 10 of these. Of the addresses a host name
route, and that route's origin AS with its WHOIS details resolves to, only public ones are looked up: a name with none, such as one
- `blocklists`: the public blocklists the address is on (see Blocklists) that resolves to a private address, is reported as
`host name has no public address`, without its addresses. `results` has one
An address with no live route has a result without the route and AS fields. entry per address with a live route and `errors` one per address or name that
Of the addresses a host name resolves to, only public ones are looked up. A failed. The answer is 400 when `{ip}` lists more than 10, when it comes to
name with none, such as one that resolves to a private address, is reported more than 10 addresses once its names are resolved, or when nothing in it is
as `host name has no public address`, without its addresses. `errors` has an IP address or a host name with a public address; and 404 when none of the
one entry for each host name that failed. The answer is 400 when: addresses has a live route. A PTR lookup gets 2 seconds; an address whose
- `{ip}` lists more than 10 lookup takes longer is answered without PTR names.
- `{ip}` comes to more than 10 addresses once its names are resolved
- nothing in `{ip}` is an IP address or a host name with a public address
A PTR lookup gets 2 seconds. An address whose lookup takes longer is
answered without PTR names.
- `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6 - `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6
apart, each with when its newest route was updated. When the database has no apart, each with when its newest route was updated. When the database has no
record of the AS, its handle and description come from the AS list built into record of the AS, its handle and description come from the AS list built into
@@ -170,8 +164,8 @@ a `blocklists` list with one entry for each list the address is on:
- `updated`: when RouteWatch last downloaded the list, or last heard from its - `updated`: when RouteWatch last downloaded the list, or last heard from its
publisher that its copy was current publisher that its copy was current
An address on no list has an empty `blocklists`. An address with no live route An address on no list has an empty `blocklists`. Only an address with a live
still has a result with its `blocklists`. route has a result, so an address without one gets no blocklists either.
The lists: The lists:
@@ -205,13 +199,10 @@ How RouteWatch keeps the lists:
- It holds each list in memory, so a lookup makes no outside request. - 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 - 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. 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` the publisher sent with that copy.
- At startup it loads each list from its copy. It downloads a list at once when - At startup it loads each list from its copy. It downloads a list at once when
the copy is older than the list's interval, or when there is no copy. the copy is older than the list's interval, or when there is no copy.
- A download sends the copy's `Last-Modified` as `If-Modified-Since` and its - A download sends `If-Modified-Since`, so a publisher does not send a list that
`ETag` as `If-None-Match`. The publisher then sends the list only when it has has not changed.
changed. Without either, the download asks for the whole list.
- 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 16 MiB.
- When a download fails, or its file does not parse, RouteWatch keeps the - 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. previous copy and logs the failure. It tries again one interval later.
+1 -2
View File
@@ -38,8 +38,7 @@ tested.
FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each
list on its own schedule and answers from copies in memory. The copies are list on its own schedule and answers from copies in memory. The copies are
also kept in the state directory's `blocklists` directory. IP lookup answers also kept in the state directory's `blocklists` directory. IP lookup answers
carry `Cache-Control`. An address with no live route now has a result too, carry `Cache-Control` (closes #158)
without route fields, so the IP lookup no longer answers 404 (closes #158)
- 2026-10-27: the home page has an IP information API card. It shows the `curl` - 2026-10-27: the home page has an IP information API card. It shows the `curl`
command that looks up the visitor's own address at `/ip/{addr}`, and that command that looks up the visitor's own address at `/ip/{addr}`, and that
command's live answer (closes #156) command's live answer (closes #156)
-12
View File
@@ -64,9 +64,6 @@ type Listing struct {
type listCopy struct { type listCopy struct {
addresses *netipx.IPSet addresses *netipx.IPSet
updated time.Time updated time.Time
// validators identify the version of the list this copy holds.
validators validators
} }
// Params is what New needs. // Params is what New needs.
@@ -154,15 +151,6 @@ func (b *Blocklists) updated(name string) time.Time {
return b.copies[name].updated return b.copies[name].updated
} }
// 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 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.copies[name].validators
}
// setCopy makes c the named list's copy. // setCopy makes c the named list's copy.
func (b *Blocklists) setCopy(name string, c listCopy) { func (b *Blocklists) setCopy(name string, c listCopy) {
b.mu.Lock() b.mu.Lock()
+10 -118
View File
@@ -17,34 +17,24 @@ import (
"time" "time"
) )
// The test list's name and description, the address its good version lists, // The test list's name and description, and the address its good version
// and the address a newer version lists instead. // lists.
const ( const (
testListName = "test_list" testListName = "test_list"
testListDescription = "On the test list" testListDescription = "On the test list"
listedAddr = "192.0.2.1" listedAddr = "192.0.2.1"
goodListBody = "# A test list\n" + listedAddr + "\n" goodListBody = "# A test list\n" + listedAddr + "\n"
newListedAddr = "192.0.2.2"
newListBody = "# A test list\n" + newListedAddr + "\n"
) )
// testETag is the ETag a publisher sends with a version of the test list.
const testETag = `"v1"`
// maxWait is how long a test waits for the download goroutine. // maxWait is how long a test waits for the download goroutine.
const maxWait = 10 * time.Second const maxWait = 10 * time.Second
// publisher stands in for a list's publisher. It answers every request with // publisher stands in for a list's publisher. It answers every request with
// its status and body, and keeps the headers of each request. A 200 answer // its status and body, and keeps the headers of each request.
// carries lastModified and etag, when set, as Last-Modified and ETag. As
// from a real publisher, a request whose If-None-Match or If-Modified-Since
// matches them is answered "not modified".
type publisher struct { type publisher struct {
mu sync.Mutex mu sync.Mutex
status int status int
body string body string
lastModified time.Time
etag string
headers []http.Header headers []http.Header
} }
@@ -65,22 +55,8 @@ func (p *publisher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer p.mu.Unlock() defer p.mu.Unlock()
p.headers = append(p.headers, r.Header.Clone()) p.headers = append(p.headers, r.Header.Clone())
if p.status != http.StatusOK {
w.WriteHeader(p.status) w.WriteHeader(p.status)
_, _ = io.WriteString(w, p.body) _, _ = io.WriteString(w, p.body)
return
}
if p.etag != "" {
w.Header().Set(etagHeader, p.etag)
}
// ServeContent sends Last-Modified unless lastModified is zero, and
// answers "not modified" when the request's conditions match.
http.ServeContent(w, r, testListName, p.lastModified,
strings.NewReader(p.body))
} }
// answer makes the publisher answer later requests with status and body. // answer makes the publisher answer later requests with status and body.
@@ -91,17 +67,6 @@ func (p *publisher) answer(status int, body string) {
p.status, p.body = status, body p.status, p.body = status, body
} }
// publish makes the publisher answer later requests with a version of the
// list: body, with lastModified and etag. A zero lastModified or an empty
// etag is not sent.
func (p *publisher) publish(body string, lastModified time.Time, etag string) {
p.mu.Lock()
defer p.mu.Unlock()
p.status, p.body = http.StatusOK, body
p.lastModified, p.etag = lastModified, etag
}
// requests returns the headers of the requests the publisher has had. // requests returns the headers of the requests the publisher has had.
func (p *publisher) requests() []http.Header { func (p *publisher) requests() []http.Header {
p.mu.Lock() p.mu.Lock()
@@ -315,9 +280,8 @@ func TestRestartLoadsCopiesFromDisk(t *testing.T) {
// TestStartRefreshesAnOldCopy starts on a copy older than its list's // TestStartRefreshesAnOldCopy starts on a copy older than its list's
// interval. The list is downloaded at once, with routewatch's User-Agent and // interval. The list is downloaded at once, with routewatch's User-Agent and
// the Last-Modified and ETag the publisher sent with the copy, kept on disk. // the copy's time in If-Modified-Since. The publisher answers that the copy
// The publisher answers that the copy is still current, so the copy is kept // is still current, so the copy is kept and marked as updated now.
// and marked as updated now.
func TestStartRefreshesAnOldCopy(t *testing.T) { func TestStartRefreshesAnOldCopy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -325,9 +289,6 @@ func TestStartRefreshesAnOldCopy(t *testing.T) {
p, url := newPublisher(t, goodListBody) p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines) list := testList(url, FormatLines)
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 := newTestBlocklists(dir, list).download(t.Context(), list)
if err != nil { if err != nil {
t.Fatalf("download: %v", err) t.Fatalf("download: %v", err)
@@ -340,6 +301,8 @@ func TestStartRefreshesAnOldCopy(t *testing.T) {
t.Fatalf("Chtimes: %v", err) t.Fatalf("Chtimes: %v", err)
} }
p.answer(http.StatusNotModified, "")
b := newTestBlocklists(dir, list) b := newTestBlocklists(dir, list)
b.Start(t.Context()) b.Start(t.Context())
@@ -357,13 +320,9 @@ func TestStartRefreshesAnOldCopy(t *testing.T) {
requests := p.requests() requests := p.requests()
last := requests[len(requests)-1] last := requests[len(requests)-1]
want := lastModified.Format(http.TimeFormat) if got := last.Get(ifModifiedSinceHeader); got != old.Format(http.TimeFormat) {
if got := last.Get(ifModifiedSinceHeader); got != want { t.Errorf("If-Modified-Since %q, want %q", got,
t.Errorf("If-Modified-Since %q, want %q", got, want) old.Format(http.TimeFormat))
}
if got := last.Get(ifNoneMatchHeader); got != testETag {
t.Errorf("If-None-Match %q, want %q", got, testETag)
} }
if got := last.Get(userAgentHeader); got != userAgent { if got := last.Get(userAgentHeader); got != userAgent {
@@ -371,73 +330,6 @@ func TestStartRefreshesAnOldCopy(t *testing.T) {
} }
} }
// TestNewVersionOlderThanTheLastDownloadReplacesTheCopy downloads a list.
// The publisher then puts out a new version whose Last-Modified is earlier
// than that download, as when a cache in front of the publisher passes the
// version on late. The next download sends the Last-Modified of the copy,
// not the time of the download, so it gets the new version.
func TestNewVersionOlderThanTheLastDownloadReplacesTheCopy(t *testing.T) {
t.Parallel()
p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines)
b := newTestBlocklists(t.TempDir(), list)
firstVersion := time.Now().Add(-2 * list.Interval).UTC().Truncate(time.Second)
p.publish(goodListBody, firstVersion, "")
err := b.download(t.Context(), list)
if err != nil {
t.Fatalf("first download: %v", err)
}
p.publish(newListBody, firstVersion.Add(list.Interval), "")
err = b.download(t.Context(), list)
if err != nil {
t.Fatalf("second download: %v", err)
}
if listings := lookup(t, b, newListedAddr); len(listings) != 1 {
t.Errorf("%s: listings %+v, want one from the new version",
newListedAddr, listings)
}
want := firstVersion.Format(http.TimeFormat)
if got := p.requests()[1].Get(ifModifiedSinceHeader); got != want {
t.Errorf("If-Modified-Since %q, want %q", got, want)
}
}
// TestDownloadWithoutLastModifiedOrETagSendsNoCondition downloads a list
// twice from a publisher that sends neither Last-Modified nor ETag. Neither
// request carries If-Modified-Since or If-None-Match, so the second download
// gets the whole list again.
func TestDownloadWithoutLastModifiedOrETagSendsNoCondition(t *testing.T) {
t.Parallel()
p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines)
b := newTestBlocklists(t.TempDir(), list)
err := b.download(t.Context(), list)
if err != nil {
t.Fatalf("first download: %v", err)
}
err = b.download(t.Context(), list)
if err != nil {
t.Fatalf("second download: %v", err)
}
for i, request := range p.requests() {
if request.Get(ifModifiedSinceHeader) != "" ||
request.Get(ifNoneMatchHeader) != "" {
t.Errorf("request %d has a condition: %v", i, request)
}
}
}
// TestStopCancelsADownloadInProgress stops while the publisher has not // TestStopCancelsADownloadInProgress stops while the publisher has not
// answered. Stop cancels the download and returns once the download // answered. Stop cancels the download and returns once the download
// goroutine has stopped. // goroutine has stopped.
+14 -109
View File
@@ -2,7 +2,6 @@ package blocklist
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -29,46 +28,20 @@ const (
const ( const (
userAgentHeader = "User-Agent" userAgentHeader = "User-Agent"
ifModifiedSinceHeader = "If-Modified-Since" ifModifiedSinceHeader = "If-Modified-Since"
ifNoneMatchHeader = "If-None-Match"
// userAgent names routewatch to the publishers. // userAgent names routewatch to the publishers.
userAgent = "routewatch (+https://git.eeqj.de/sneak/routewatch)" userAgent = "routewatch (+https://git.eeqj.de/sneak/routewatch)"
) )
// The response headers that identify the version of a list a publisher sent.
const (
lastModifiedHeader = "Last-Modified"
etagHeader = "ETag"
)
const ( const (
// downloadSuffix ends the name of the file a download is written to, // downloadSuffix ends the name of the file a download is written to,
// beside the list's copy, until it has parsed. // beside the list's copy, until it has parsed.
downloadSuffix = ".download" downloadSuffix = ".download"
// validatorsSuffix ends the name of the file beside a list's copy that
// keeps the copy's validators.
validatorsSuffix = ".validators"
// dirPermissions are those of the directory that holds the copies. // dirPermissions are those of the directory that holds the copies.
dirPermissions = 0o750 dirPermissions = 0o750
// validatorsPermissions are those of the file that keeps a copy's
// validators.
validatorsPermissions = 0o600
) )
// validators are the Last-Modified and ETag headers a publisher sent with a
// version of a list. A download sends them back, as If-Modified-Since and
// If-None-Match, so that the publisher sends the list only when it has
// changed. They are kept as the publisher wrote them.
//
//nolint:tagliatelle // snake_case keys, as the Go style guide asks
type validators struct {
LastModified string `json:"last_modified,omitempty"`
ETag string `json:"etag,omitempty"`
}
var ( var (
errUnexpectedStatus = errors.New("unexpected HTTP status") errUnexpectedStatus = errors.New("unexpected HTTP status")
errTooLarge = errors.New("list is over the size limit") errTooLarge = errors.New("list is over the size limit")
@@ -164,11 +137,10 @@ func (b *Blocklists) run(ctx context.Context, nextDownload []time.Time) {
} }
} }
// download asks the publisher for list, sending the validators of the list's // download asks the publisher for list. A new version that parses becomes
// copy when it has them. A new version that parses becomes the list's copy, // the list's copy, on disk and in memory. An answer that the copy is still
// on disk and in memory. An answer that the copy is still current marks the // current marks the copy as updated now. Anything else leaves the copy as it
// copy as updated now. Anything else leaves the copy as it is and returns an // is and returns an error.
// error.
func (b *Blocklists) download(ctx context.Context, list List) error { func (b *Blocklists) download(ctx context.Context, list List) error {
ctx, cancel := context.WithTimeout(ctx, downloadTimeout) ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel() defer cancel()
@@ -180,13 +152,9 @@ func (b *Blocklists) download(ctx context.Context, list List) error {
req.Header.Set(userAgentHeader, userAgent) req.Header.Set(userAgentHeader, userAgent)
held := b.copyValidators(list.Name) updated := b.updated(list.Name)
if held.LastModified != "" { if !updated.IsZero() {
req.Header.Set(ifModifiedSinceHeader, held.LastModified) req.Header.Set(ifModifiedSinceHeader, updated.Format(http.TimeFormat))
}
if held.ETag != "" {
req.Header.Set(ifNoneMatchHeader, held.ETag)
} }
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -198,10 +166,7 @@ func (b *Blocklists) download(ctx context.Context, list List) error {
switch resp.StatusCode { switch resp.StatusCode {
case http.StatusOK: case http.StatusOK:
return b.replaceCopy(list, resp.Body, validators{ return b.replaceCopy(list, resp.Body)
LastModified: resp.Header.Get(lastModifiedHeader),
ETag: resp.Header.Get(etagHeader),
})
case http.StatusNotModified: case http.StatusNotModified:
return b.markCurrent(list) return b.markCurrent(list)
default: default:
@@ -211,8 +176,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 // 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. // 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) error {
func (b *Blocklists) replaceCopy(list List, body io.Reader, sent validators) error {
root, err := os.OpenRoot(b.dir) root, err := os.OpenRoot(b.dir)
if err != nil { if err != nil {
return err return err
@@ -229,31 +193,14 @@ func (b *Blocklists) replaceCopy(list List, body io.Reader, sent validators) err
return err return err
} }
// The old copy's validators are removed before the copy is replaced, so
// that they are never kept with the new copy.
err = root.Remove(list.Name + validatorsSuffix)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
err = root.Rename(downloadName, list.Name) err = root.Rename(downloadName, list.Name)
if err != nil { if err != nil {
return err return err
} }
b.setCopy(list.Name, listCopy{ b.setCopy(list.Name, listCopy{addresses: addresses, updated: time.Now().UTC()})
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 a blocklist", "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",
"list", list.Name, "error", err)
}
return nil return nil
} }
@@ -309,18 +256,10 @@ func (b *Blocklists) markCurrent(list List) error {
return nil return nil
} }
// loadCopy makes the list's copy on disk, with the validators kept beside it, // loadCopy makes the list's copy on disk its copy in memory. The file's
// its copy in memory. The file's modification time is when the copy was // modification time is when the copy was updated.
// updated.
func (b *Blocklists) loadCopy(list List) error { func (b *Blocklists) loadCopy(list List) error {
root, err := os.OpenRoot(b.dir) f, err := os.OpenInRoot(b.dir, list.Name)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
f, err := root.Open(list.Name)
if err != nil { if err != nil {
return err return err
} }
@@ -337,43 +276,9 @@ func (b *Blocklists) loadCopy(list List) error {
return err return err
} }
held, err := readValidators(root, list.Name)
if err != nil {
return err
}
b.setCopy(list.Name, listCopy{ b.setCopy(list.Name, listCopy{
addresses: addresses, updated: info.ModTime().UTC(), validators: held, addresses: addresses, updated: info.ModTime().UTC(),
}) })
return nil return nil
} }
// writeValidators keeps held in the file beside the named list's copy.
func writeValidators(root *os.Root, name string, held validators) error {
data, err := json.Marshal(held)
if err != nil {
return err
}
return root.WriteFile(name+validatorsSuffix, data, validatorsPermissions)
}
// readValidators returns the validators kept beside the named list's copy.
// They are empty when there is no such file.
func readValidators(root *os.Root, name string) (validators, error) {
var held validators
data, err := root.ReadFile(name + validatorsSuffix)
if errors.Is(err, fs.ErrNotExist) {
return held, nil
}
if err != nil {
return held, err
}
err = json.Unmarshal(data, &held)
return held, err
}
+1
View File
@@ -93,6 +93,7 @@ type PrefixDistribution struct {
//nolint:tagliatelle // field names are the existing JSON API //nolint:tagliatelle // field names are the existing JSON API
type IPInfo struct { type IPInfo struct {
IP string `json:"ip"` IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
Netblock string `json:"netblock"` Netblock string `json:"netblock"`
MaskLength int `json:"mask_length"` MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"` IPVersion int `json:"ip_version"`
+17 -17
View File
@@ -708,17 +708,11 @@ type IPLookupResponse struct {
Errors []string `json:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
} }
// IPLookupResult is the answer for one address: its PTR names, its live route // IPLookupResult is the answer for one address: its route and AS from the
// and that route's AS, and the blocklists that list it. IPInfo is nil when // database, and the blocklists that list it.
// 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 { type IPLookupResult struct {
*database.IPInfo *database.IPInfo
IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
// Blocklists has one entry for each blocklist the address is on. // Blocklists has one entry for each blocklist the address is on.
Blocklists []blocklist.Listing `json:"blocklists"` Blocklists []blocklist.Listing `json:"blocklists"`
} }
@@ -774,10 +768,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
} }
switch { switch {
case errors.Is(err, database.ErrNoRoute): case errors.Is(err, database.ErrNoRoute), errors.Is(err, database.ErrInvalidIP):
// 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()) response.Errors = append(response.Errors, ip+": "+err.Error())
continue continue
@@ -796,14 +787,16 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
return return
} }
if len(ptrs) > 0 {
ipInfo.PTR = ptrs
}
response.Results = append(response.Results, IPLookupResult{ response.Results = append(response.Results, IPLookupResult{
IP: ip,
PTR: ptrs,
IPInfo: ipInfo, IPInfo: ipInfo,
Blocklists: s.blocklistListings(ip), Blocklists: s.blocklistListings(ip),
}) })
if ipInfo != nil && ipInfo.NeedsWHOISRefresh { if ipInfo.NeedsWHOISRefresh {
refreshASNs[ipInfo.ASN] = true refreshASNs[ipInfo.ASN] = true
} }
} }
@@ -858,11 +851,18 @@ func noTargetsMessage(resolveErrors []string) string {
return message + ": " + resolveErrors[0] return message + ": " + resolveErrors[0]
} }
// writeIPLookupAnswer sends response with its Cache-Control header. // writeIPLookupAnswer sends response, or 404 with its first error when no
// ownAddress says the request asked about the caller's own address. // address had a route. ownAddress says the request asked about the caller's
// own address.
func (s *Server) writeIPLookupAnswer( func (s *Server) writeIPLookupAnswer(
w http.ResponseWriter, response IPLookupResponse, ownAddress bool, 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) s.setIPLookupCacheControl(w, ownAddress)
err := writeJSONSuccess(w, response) err := writeJSONSuccess(w, response)
+9 -76
View File
@@ -1042,15 +1042,14 @@ func (failingIPLookupDB) GetIPInfoContext(
// server's router with a store whose read of one address fails with the // 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 // 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 // JSON error body, as the AS and prefix JSON routes do; an address with no
// route answers 200. Before the fix the failed read was reported as if the // route still answers 404. Before the fix the failed read was reported as if
// address had no route: 404 for that address alone, and 200 beside a routed // the address had no route: 404 for that address alone, and 200 beside a
// address (https://git.eeqj.de/sneak/routewatch/issues/109). // routed address (https://git.eeqj.de/sneak/routewatch/issues/109).
func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) { func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
t.Parallel() t.Parallel()
s := New(failingIPLookupDB{}, streamer.New(logger.New(), metrics.New()), s := New(failingIPLookupDB{}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{}) logger.New(), &config.Config{})
s.resolver = &fakeResolver{}
tests := []struct { tests := []struct {
path string path string
@@ -1066,7 +1065,7 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
}, },
{ {
path: "/api/v1/ip/203.0.113.1", path: "/api/v1/ip/203.0.113.1",
wantCode: http.StatusOK, wantCode: http.StatusNotFound,
}, },
} }
@@ -1080,11 +1079,9 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
tt.path, rec.Code, tt.wantCode, rec.Body) tt.path, rec.Code, tt.wantCode, rec.Body)
} }
if tt.wantCode != http.StatusOK {
checkJSONErrorCode(t, tt.path, rec, tt.wantCode) checkJSONErrorCode(t, tt.path, rec, tt.wantCode)
} }
} }
}
// errNoSuchHost is what fakeResolver answers a lookup it has no answer for // errNoSuchHost is what fakeResolver answers a lookup it has no answer for
// with. // with.
@@ -1170,10 +1167,10 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
}{ }{
{ {
strings.Join(addrs[:maxLookupTargets], ","), strings.Join(addrs[:maxLookupTargets], ","),
http.StatusOK, maxLookupTargets, 0, http.StatusNotFound, maxLookupTargets, 0,
}, },
{strings.Join(addrs, ","), http.StatusBadRequest, 0, 0}, {strings.Join(addrs, ","), http.StatusBadRequest, 0, 0},
{"most.test", http.StatusOK, maxLookupTargets, 1}, {"most.test", http.StatusNotFound, maxLookupTargets, 1},
{"too-many.test", http.StatusBadRequest, 0, 1}, {"too-many.test", http.StatusBadRequest, 0, 1},
{strings.Join(names, ","), http.StatusBadRequest, 0, 0}, {strings.Join(names, ","), http.StatusBadRequest, 0, 0},
} }
@@ -1194,9 +1191,7 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
tt.target, rec.Code, tt.wantCode, rec.Body) tt.target, rec.Code, tt.wantCode, rec.Body)
} }
if tt.wantCode != http.StatusOK {
checkJSONErrorCode(t, tt.target, rec, tt.wantCode) checkJSONErrorCode(t, tt.target, rec, tt.wantCode)
}
got := db.lookups.Load() got := db.lookups.Load()
if got != tt.wantLookups { if got != tt.wantLookups {
@@ -1263,12 +1258,8 @@ const testBlocklistInterval = 15 * time.Minute
// ownAddressPath is the IP lookup of the caller's own address. // ownAddressPath is the IP lookup of the caller's own address.
const ownAddressPath = "/ip/" const ownAddressPath = "/ip/"
// listedUnroutedAddr is an address that fakeBlocklists lists, outside the // fakeBlocklists has one list, which lists addrInTestPrefixV4 and no other
// routes newServerWithTestRoutes stores. // address.
const listedUnroutedAddr = "203.0.113.1"
// fakeBlocklists has one list, which lists addrInTestPrefixV4 and
// listedUnroutedAddr and no other address.
type fakeBlocklists struct{} type fakeBlocklists struct{}
func testListing() blocklist.Listing { func testListing() blocklist.Listing {
@@ -1280,8 +1271,7 @@ func testListing() blocklist.Listing {
} }
func (fakeBlocklists) Lookup(addr netip.Addr) []blocklist.Listing { func (fakeBlocklists) Lookup(addr netip.Addr) []blocklist.Listing {
if addr == netip.MustParseAddr(addrInTestPrefixV4) || if addr == netip.MustParseAddr(addrInTestPrefixV4) {
addr == netip.MustParseAddr(listedUnroutedAddr) {
return []blocklist.Listing{testListing()} return []blocklist.Listing{testListing()}
} }
@@ -1340,63 +1330,6 @@ func TestIPLookupReportsBlocklists(t *testing.T) {
} }
} }
// 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 // 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 blocklists' shortest interval,
// and only the caller's own browser may keep the answer about the caller's // and only the caller's own browser may keep the answer about the caller's
+2 -2
View File
@@ -606,8 +606,8 @@
// IP information API card: show the curl command that asks /ip/ about // IP information API card: show the curl command that asks /ip/ about
// the visitor's own address, then make that same request and show the // the visitor's own address, then make that same request and show the
// JSON it answers, an error answer included. The answer holds outside // JSON it answers, an error answer such as a 404 included. The answer
// data, so it is set with textContent, never innerHTML. // holds outside data, so it is set with textContent, never innerHTML.
const ipAPICommand = document.getElementById('ip-api-command'); const ipAPICommand = document.getElementById('ip-api-command');
const ipAPIAnswer = document.getElementById('ip-api-answer'); const ipAPIAnswer = document.getElementById('ip-api-answer');
const ipAPIURL = window.location.origin + '/ip/' + ipAPICommand.dataset.clientIp; const ipAPIURL = window.location.origin + '/ip/' + ipAPICommand.dataset.clientIp;