14 Commits
Author SHA1 Message Date
clawbot 7a34a46c4d Add internal/routetable, the routing table in memory (part of #122)
check / check (push) Waiting to run
A new package holds every prefix some peer carries. For each prefix and
origin AS it keeps the set of peers that carry it, one bit per peer,
and the two newest of their routes in full. It applies announcements,
withdrawals and peers' sessions going down by the rule confirmed on the
issue, and records which pairs changed for the database writes to come.
It answers the IP lookup, an AS's prefixes, a prefix's origins, a sample
of one mask length, and the counts the stats show. Nothing uses it yet.

The peer sets use github.com/bits-and-blooms/bitset. The kept routes'
times are held in order in github.com/google/btree, so the oldest and
newest need no scan.

Model: opus-5-5
2026-10-27 07:51:55 +01:00
clawbot 7e13f28aa0 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 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
2026-10-27 06:35:17 +01:00
clawbot ad67e3ca1e Show the IP information API on the home page (closes #156)
check / check (push) Canceled after 0s
The home page gets an IP information API card. It shows the curl
command that looks up the visitor's own address at /ip/{addr}. The page
then makes that same request and shows the JSON answer, pretty-printed.
An error answer, such as a 404 for an address with no route, is shown
the same way. A request that fails shows an error line.

The server gives the page the visitor's address, found as /ip/ finds
it, so a trusted proxy's X-Forwarded-For is honoured. The command takes
its origin from the browser, so it names the host the visitor used.

Model: opus-5-5
2026-10-27 05:27:23 +01:00
clawbot 203a4e13a5 Let the AS page's prefix lists fold (closes #155)
check / check (push) Canceled after 0s
The IPv4 and IPv6 prefix lists on the AS page are each a <details>
element. Its <summary> holds the list's heading and count, so a click on
the heading folds or unfolds the list without a script. Both lists start
open. The peer list is unchanged.

The summary keeps the look of the other section headings. It hides the
browser's own marker, which not every browser shows on a flex element,
and draws a triangle before the heading that points down while the list
is open.

Model: opus-5-5
2026-10-27 04:56:58 +01:00
clawbot 2375480e2e List an AS's prefixes by mask length, shortest first (closes #153)
check / check (push) Canceled after 0s
The AS page sorted its IPv4 and IPv6 prefix lists by network address,
then by mask length. It now sorts them by mask length, shortest first,
and by network address within one mask length, so a /16 comes before a
/24. The sort function is renamed from sortRoutesByNetwork to
sortRoutesByMaskLength to say so.

The handler of /api/v1/as/{asn} does not sort its lists, so it is
unchanged.

Model: opus-5-5
2026-10-27 04:37:17 +01:00
clawbot a5bff4e13c Show whether the status on / and /status is fresh (closes #152)
check / check (push) Canceled after 0s
Each page's script turns a one-character braille spinner next to the
status whenever a stats answer counts more messages than the one
before. Its colour cycles as rtnetmon's does. A separate check runs
every second. Once no such answer has come for three refresh periods,
15 seconds on / and 6 on /status, it shows "stale" and the seconds
since, in the page's disconnected colour. A failed or hung request
counts as no fresh answer. On / the card has no room beside
"Connected", so the indicator sits on the line under it. A test renders
both pages and checks the indicator follows the status element.

Model: opus-5-5
2026-10-27 04:24:53 +01:00
clawbot 73f53c8220 Take Brazilian AS details from NIC.br's WHOIS server (closes #174)
check / check (push) Canceled after 0s
LACNIC's answer for an AS that NIC.br registers is NIC.br's answer with
each accented letter replaced by U+FFFD, so a name such as AS52751's
cannot be recovered from it. When LACNIC's answer carries NIC.br's
copyright notice, the client now also asks whois.registro.br for the AS
and takes the fields from that answer, which is Latin-1 and decoded as
before. When that query fails, when the answer has no fields, or when
NIC.br says the client is over its query limit, LACNIC's fields are kept
and a warning is logged. After an over-limit answer the client skips
NIC.br for an hour. The tests use recorded answers for AS52751.

Model: opus-5-5
2026-10-27 03:59:11 +01:00
clawbot cd1b9b94a4 Stop asinfo-gen when the as.csv download breaks off (closes #177)
check / check (push) Canceled after 0s
parseCSV skipped a record on any read error and asked for the next one.
When the download of as.csv broke off, the response body returned the
same error on every read, so the generator logged it forever and make
asupdate never finished.

parseCSV now skips only a record that does not parse, a csv.ParseError.
Any other read error ends it with that error, so the generator exits
non-zero and script/asupdate keeps the old list.

Model: opus-5-5
2026-10-27 03:10:40 +01:00
clawbot 023c3f9b41 Keep the AS list when the asupdate generator fails (closes #173)
check / check (push) Canceled after 0s
make asupdate piped the generator into gzip, so the target saw gzip's
exit status. A failed download replaced pkg/asinfo/asdata.json.gz with
an empty file and the target still exited 0.

The target now runs the new script/asupdate. It writes the output of
cmd/asinfo-gen to a temporary file. Only when the generator succeeds
does it compress the list and move it into place. Otherwise it leaves
the file alone and exits non-zero. The generator now fails when as.csv
holds no AS.

The generator reads as.csv from ipverse/as-metadata, the source's new
name. The list it builds there is unchanged, so no new list is
committed.

Model: opus-5-5
2026-10-27 02:43:35 +01:00
clawbot 57469b6370 Read only the AS's objects in RIPE, APNIC and AFRINIC answers (closes #168)
check / check (push) Canceled after 0s
APNIC and RIPE start their answer for an AS with the as-block object of
the block the AS is in. The parser kept the first value of each field
across the whole answer, so it stored the block's dates as the AS's.

The parser now reads an answer from RIPE, APNIC or AFRINIC object by
object. It takes the fields from the aut-num object and the organisation
object it names, and leaves out every other object. The irt object's
address therefore no longer joins the organisation's. ARIN and LACNIC
answers are parsed as before.

Model: opus-5-5
2026-10-27 02:12:48 +01:00
clawbot 6d55d39737 Keep accented letters in AS names (closes #165)
check / check (push) Canceled after 0s
The built-in AS list had no letter outside ASCII, and make asupdate
rejected today's as.csv, which added a country code column.

The generator now checks only the first three header columns and reads
past the rest; the parsing moved into parseCSV. The list is regenerated.
The new source spells some names differently and no longer lists AS 0
or the documentation AS numbers, so the tests that relied on those
entries changed.

A WHOIS answer that is not valid UTF-8 is now decoded as Latin-1, which
RIPE and APNIC send, before it is parsed and stored. LACNIC answers in
UTF-8.

Model: opus-5-5
2026-10-27 01:50:34 +01:00
clawbot 86e1f20e11 Show ages on the AS and prefix pages in words (closes #163)
check / check (push) Canceled after 0s
The Age column printed Go's duration format for every plural age, such
as "5m0s ago" or "360h0m0s ago".

The templates now register humanize.Time from go-humanize, which the
server already uses, as the timeSince function, and the hand-written
function is gone. Ages read "5 minutes ago", "3 hours ago" or "2 weeks
ago". An age under a minute reads "now" or in seconds instead of "just
now". An age over a month reads in months or years instead of a date.

Model: opus-5-5
2026-10-27 01:23:50 +01:00
clawbot a1de67ebe1 Never move last seen back or first seen forward (closes #162)
check / check (push) Canceled after 0s
Some RIS collectors send updates days old while they catch up on a
backlog. Every update to a stored AS, peering or peer set last_seen to
the update's time, even an older one, and never lowered first_seen. An
AS page could then list a peer first seen after it was last seen.

The update statements for asns, peerings and bgp_peers now keep the
earlier of the stored and new first_seen and the later of the stored
and new last_seen, with MIN and MAX in the SQL. The batch and the
single-row writes share each statement. GetOrCreateASN returns the
times the row now holds. Route times are unchanged.

Model: opus-5-5
2026-10-27 01:10:01 +01:00
clawbot 355cd37496 Show route churn as a whole count per minute (closes #160)
check / check (push) Canceled after 0s
The status page showed route churn per second as a one-minute moving
average, so a quiet feed gave a fraction such as 0.1. The average was
correct.

The metrics tracker now also counts prefix announcements and
withdrawals, from all peers, in each clock minute. /api/v1/stats
reports the count for the last full minute as route_churn_per_min in
stream, in place of route_churn_per_sec. The status page shows it as
"Route Churn/min".

The status and home pages round messages and updates per second to
whole numbers.

Model: opus-5-5
2026-10-27 00:14:24 +01:00
42 changed files with 5299 additions and 346 deletions
+2 -3
View File
@@ -52,7 +52,6 @@ clean:
run: build
DEBUG=routewatch ./bin/routewatch 2>&1 | tee log.txt
# Rebuild the AS list built into RouteWatch from as.csv (needs the network).
asupdate:
@echo "Updating AS info data..."
@go run cmd/asinfo-gen/main.go | gzip > pkg/asinfo/asdata.json.gz.tmp && \
mv pkg/asinfo/asdata.json.gz.tmp pkg/asinfo/asdata.json.gz
@script/asupdate
+103 -22
View File
@@ -1,9 +1,11 @@
# RouteWatch
RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing
table in real time: it streams BGP UPDATE messages from the RIPE RIS Live
service, maintains a live routing table in SQLite, and provides HTTP APIs for
querying routing information.
table in real time. It works like this:
- It streams BGP UPDATE messages from the RIPE RIS Live service.
- It keeps a live routing table in SQLite.
- It serves web pages and a JSON API for looking routes up.
## Rationale
@@ -27,6 +29,8 @@ route the feed has withdrawn.
- Maintains live IPv4 and IPv6 routing tables
- Tracks AS peering relationships
- HTTP API for IP-to-AS lookups, prefix details, and AS information
- IP lookups report the public blocklists an address is on, from copies kept in
memory and on disk
- Automatic reconnection with exponential backoff
- Batched database writes for high performance
- Backpressure handling to prevent memory exhaustion
@@ -57,7 +61,8 @@ Every route answers `HEAD` as it answers `GET`, without the body.
### Web Interface
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
address, and a search of AS names
address, and a search of AS names. A card shows the `curl` command that looks
up the visitor's own address at `/ip/{addr}`, and that command's live answer.
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
- `GET /status.json` - JSON statistics: feed, memory, database and route figures
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
@@ -82,20 +87,28 @@ Every route answers `HEAD` as it answers `GET`, without the body.
- `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage
collector, stream and handler queue statistics. `stream.route_churn_per_min`
counts the prefixes announced or withdrawn in the last full minute of the
clock.
- `GET /api/v1/ip/{ip}` - Looks up each address: its most specific live route
(`netblock`), that route's origin AS with its WHOIS details, and the address's
PTR names. `{ip}` is an IP address, a host name, or a comma-separated list of
at most 10 of these. Of the addresses a host name resolves to, only public
ones are looked up: a name with none, such as one that resolves to a private
address, is reported as `host name has no public address`, without its
addresses. `results` has one entry per address with a live route and `errors`
one per address or name that failed. The answer is 400 when `{ip}` lists more
than 10, when it comes to more than 10 addresses once its names are resolved,
or when nothing in it is an IP address or a host name with a public address;
and 404 when none of the addresses has a live route. A PTR lookup gets 2
seconds; an address whose lookup takes longer is answered without PTR names.
is the number of prefix announcements and withdrawals, from all peers, in the
last full clock minute.
- `GET /api/v1/ip/{ip}` - Looks up each address. `{ip}` is an IP address, a host
name, or a comma-separated list of at most 10 of these. `results` has one
entry for each address, with:
- `ip` and `ptr`: the address and its PTR names
- `netblock` and the fields beside it: the address's most specific live
route, and that route's origin AS with its WHOIS details
- `blocklists`: the public blocklists the address is on (see Blocklists)
An address with no live route has a result without the route and AS fields.
Of the addresses a host name resolves to, only public ones are looked up. A
name with none, such as one that resolves to a private address, is reported
as `host name has no public address`, without its addresses. `errors` has
one entry for each host name that failed. The answer is 400 when:
- `{ip}` lists more than 10
- `{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
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
@@ -110,9 +123,10 @@ Every route answers `HEAD` as it answers `GET`, without the body.
`handle` and `description`
The search reads the AS list built into RouteWatch, not the database. The
list comes from [ipverse/asn-info](https://github.com/ipverse/asn-info). A
match can therefore be an AS that the database has no record of. A `q` that
is missing, empty or only spaces answers 400.
list comes from
[ipverse/as-metadata](https://github.com/ipverse/as-metadata). A match can
therefore be an AS that the database has no record of. A `q` that is
missing, empty or only spaces answers 400.
A JSON answer is `{"status": "ok", "data": ...}`, or for an error
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
@@ -148,6 +162,66 @@ in a prefix's `origins`) means the route has no known origin: its AS path ends
in an AS set of several members, any of which may have originated it. The pages
show such a route as having no known origin.
## Blocklists
The IP lookup reports which public blocklists list each address. Each result has
a `blocklists` list with one entry for each list the address is on:
- `name`: the list's name, from the lists below
- `description`: what being on the list means
- `updated`: when RouteWatch last downloaded the list, or last heard from its
publisher that its copy was current
An address on no list has an empty `blocklists`. An address with no live route
still has a result with its `blocklists`.
The lists:
- `dshield`: the 20 networks that DShield saw scan the most targets in the last
three days. From [DShield](https://isc.sans.edu/feeds_doc.html), downloaded
every hour.
- `feodo_tracker`: botnet command-and-control servers. From abuse.ch
[Feodo Tracker](https://feodotracker.abuse.ch/blocklist/), downloaded every 15
minutes.
- `firehol_abusers_30d`: addresses reported for comment, form or forum spam in
the last 30 days. From
[FireHOL](https://iplists.firehol.org/?ipset=firehol_abusers_30d), downloaded
every 5 hours.
- `firehol_level3`: addresses reported for attacks, spyware or viruses in the
last 30 days. From
[FireHOL](https://iplists.firehol.org/?ipset=firehol_level3), downloaded every
3 hours.
- `spamhaus_drop` and `spamhaus_dropv6`: IPv4 and IPv6 networks that Spamhaus
says spammers or cyber criminals have hijacked or lease. From
[Spamhaus DROP](https://www.spamhaus.org/blocklists/do-not-route-or-peer/),
downloaded once a day. Spamhaus merged its EDROP list into DROP.
- `tor_exits`: Tor exit nodes, where traffic from the Tor network leaves. From
the [Tor Project](https://check.torproject.org/torbulkexitlist), downloaded
every hour.
No list is downloaded more often than its publisher updates it or allows. Each
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.
- 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
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
`ETag` as `If-None-Match`. The publisher then sends the list only when it has
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.
- 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.
A successful IP lookup answer carries `Cache-Control` with a `max-age` of 15
minutes, the shortest list interval. The answer for the caller's own address
(`/ip/`) is `private`, and the others are `public`.
## Code Structure
```
@@ -157,9 +231,11 @@ routewatch/
│ ├── asinfo-gen/ # Utility to generate AS info data
│ └── streamdumper/ # Debug utility for raw stream output
├── internal/
│ ├── blocklist/ # Public IP blocklists: downloads and lookups
│ ├── routewatch/ # Core application logic
│ ├── server/ # HTTP server and handlers
│ ├── database/ # SQLite storage layer
│ ├── routetable/ # The whole routing table in memory
│ ├── streamer/ # RIPE RIS Live client
│ ├── ristypes/ # BGP message data structures
│ ├── logger/ # Structured logging wrapper
@@ -323,7 +399,7 @@ client sent. For a request from any other address they use the address the
request came from, whatever forwarding headers it carries. `X-Real-IP` and
`True-Client-IP` are not read.
State directory (database location):
State directory (the database and the blocklist copies):
- macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/`
- Linux: `/var/lib/berlin.sneak.app.routewatch/` when running as root, otherwise
@@ -485,6 +561,11 @@ provide:
or `go.sum`) and `go fmt` (writes), then `script/check`
- `script/install-precommit` — install the git pre-commit hook that runs
`script/precommit`
- `script/asupdate` — rebuild the AS list built into RouteWatch,
`pkg/asinfo/asdata.json.gz`, from the `as.csv` file of
[ipverse/as-metadata](https://github.com/ipverse/as-metadata). It needs the
network. It runs `cmd/asinfo-gen`, which fails when it finds no AS. When
`cmd/asinfo-gen` fails, the old list stays and the script exits non-zero.
Tests and the linter run only in Docker, as the `test` and `lint` phases of the
`Dockerfile`; the image cannot be built unless both pass. Every `docker build`
+62 -6
View File
@@ -33,11 +33,65 @@ tested.
# Completed Steps
- 2026-10-26: the status page shows "Route Churn/min", the whole number of
prefixes announced or withdrawn in the last full minute. `/api/v1/stats`
reports it as `route_churn_per_min` in `stream`, which replaces the moving
average `route_churn_per_sec`. The messages and updates per second on `/` and
`/status` are whole numbers (closes #160)
- 2026-10-27: a new package, `internal/routetable`, holds the whole routing
table in memory. For each prefix and origin AS it keeps the peers that carry
it, one bit per peer, and the two newest of their routes. Nothing uses it yet
(part of #122)
- 2026-10-27: each result of the IP lookup lists the public blocklists the
address is on, in `blocklists`. The lists are DShield, Feodo Tracker, two
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
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,
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`
command that looks up the visitor's own address at `/ip/{addr}`, and that
command's live answer (closes #156)
- 2026-10-27: on the AS page, a click on the heading of the IPv4 or the IPv6
prefix list folds or unfolds the list. Both lists start open (closes #155)
- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
shortest first, and prefixes of one mask length by address. They used to be
listed by address (closes #153)
- 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame
each time the stats show more messages than the time before. A page that has
not seen more messages for three refreshes shows "stale" and the time since
then instead, in red (closes #152)
- 2026-10-27: the WHOIS details of a Brazilian AS keep their accented letters.
LACNIC's answer for such an AS comes from NIC.br, Brazil's registry, with each
accented letter replaced. The details now come from NIC.br's own server,
`whois.registro.br`. When that query fails, the details from LACNIC's answer
are kept and a warning is logged. After NIC.br answers that RouteWatch is over
its query limit, RouteWatch stops asking NIC.br for an hour (closes #174)
- 2026-10-27: `make asupdate` stops with an error when the download of `as.csv`
breaks off. The generator used to retry the failed read forever. A record that
does not parse is still logged and skipped (closes #177)
- 2026-10-27: `make asupdate` runs the new `script/asupdate`. It replaces the AS
list built into RouteWatch only when the generator succeeds and finds at least
one AS. A failed download used to replace the list with an empty file. The
generator reads `as.csv` from `ipverse/as-metadata`, the source's new name
(closes #173)
- 2026-10-27: the WHOIS details of an AS at RIPE, APNIC or AFRINIC come only
from its `aut-num` object and the organisation object it names. RIPE and APNIC
start their answer with the `as-block` object of the AS's block. Its dates
were stored as the AS's (closes #168)
- 2026-10-27: AS names keep their accented letters. The AS list built into
RouteWatch is rebuilt from today's `as.csv`, which added a country code
column. That file no longer lists AS 0 or the documentation AS numbers. A
WHOIS answer that is not valid UTF-8 is decoded as Latin-1, which RIPE and
APNIC send, before it is parsed and stored (closes #165)
- 2026-10-27: the Age column on the AS and prefix pages reads in words, such as
"5 minutes ago" or "2 weeks ago", instead of Go durations such as "360h0m0s
ago". An age under a minute reads in seconds, and one over a month in months
or years instead of a date (closes #163)
- 2026-10-26: a write to a stored AS, peering or peer keeps the earlier of the
two first-seen times and the later of the two last-seen times. An old update
from a collector catching up on a backlog no longer moves last seen back
(closes #162)
- 2026-10-26: the status page shows "Route Churn/min", the number of prefix
announcements and withdrawals, from all peers, in the last full clock minute.
`/api/v1/stats` reports it as `route_churn_per_min` in `stream`, which
replaces the moving average `route_churn_per_sec`. The messages and updates
per second on `/` and `/status` are whole numbers (closes #160)
- 2026-10-26: the WHOIS fetcher backs off more slowly. A failure makes its wait
1.5 times as long, up to one minute instead of five. Its queries to RIPE,
APNIC and AFRINIC leave out person and role objects, which RIPE counts against
@@ -194,4 +248,6 @@ tested.
# Future Steps
None open.
- the rest of https://git.eeqj.de/sneak/routewatch/issues/122, one PR each: the
routing table in the database, the prefix handler writing to it, the pages and
the API reading from it, then removing the per-peer route tables
+60 -28
View File
@@ -6,6 +6,7 @@ import (
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
@@ -14,7 +15,21 @@ import (
"strings"
)
const asCsvURL = "https://github.com/ipverse/asn-info/raw/refs/heads/master/as.csv"
const asCsvURL = "https://github.com/ipverse/as-metadata/raw/refs/heads/master/as.csv"
// The first columns of as.csv, the ones the generator reads. It reads past
// the columns after them, such as the country code.
const (
asnColumn = "asn"
handleColumn = "handle"
descriptionColumn = "description"
columnsRead = 3
)
var (
errUnexpectedHeader = errors.New("unexpected CSV header")
errNoASes = errors.New("no AS in the CSV")
)
// ASInfo represents information about an Autonomous System
type ASInfo struct {
@@ -45,40 +60,66 @@ func main() {
log.Fatalf("Failed to fetch CSV: HTTP %d", resp.StatusCode)
}
// Parse CSV
reader := csv.NewReader(resp.Body)
asInfos, err := parseCSV(resp.Body)
// Closed here rather than deferred: log.Fatalf exits without running
// deferred calls.
_ = resp.Body.Close()
if err != nil {
log.Fatalf("Failed to parse CSV: %v", err)
}
log.Printf("Parsed %d AS records", len(asInfos))
// Convert to JSON and output to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", "\t")
err = encoder.Encode(asInfos)
if err != nil {
log.Fatalf("Failed to encode JSON: %v", err)
}
}
// parseCSV reads as.csv from r and returns its ASes, with the handle and the
// description as the file spells them. A header whose first columns are not
// the AS number, the handle and the description is an error. A record that
// does not parse is logged and skipped. A read that fails, such as on a broken
// download, is an error. A file left with no AS is an error.
func parseCSV(r io.Reader) ([]ASInfo, error) {
reader := csv.NewReader(r)
// Read header
header, err := reader.Read()
if err != nil {
log.Fatalf("Failed to read CSV header: %v", err)
return nil, fmt.Errorf("read CSV header: %w", err)
}
if len(header) != 3 || header[0] != "asn" || header[1] != "handle" ||
header[2] != "description" {
log.Fatalf("Unexpected CSV header: %v", header)
if len(header) < columnsRead || header[0] != asnColumn ||
header[1] != handleColumn || header[2] != descriptionColumn {
return nil, fmt.Errorf("%w: %v", errUnexpectedHeader, header)
}
// Read all records
var asInfos []ASInfo
for {
// The reader returns an error for a record with a different number
// of fields than the header.
record, err := reader.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
if _, ok := errors.AsType[*csv.ParseError](err); ok {
log.Printf("Error reading CSV record: %v", err)
continue
}
const expectedFields = 3
if len(record) != expectedFields {
log.Printf("Skipping invalid record: %v", record)
continue
// Any other error comes from the download, such as a broken
// connection. Every later read returns it again.
if err != nil {
return nil, fmt.Errorf("read CSV record: %w", err)
}
asn, err := strconv.Atoi(record[0])
@@ -95,18 +136,9 @@ func main() {
})
}
// Closed here rather than deferred: log.Fatalf exits without running
// deferred calls.
_ = resp.Body.Close()
log.Printf("Parsed %d AS records", len(asInfos))
// Convert to JSON and output to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", "\t")
err = encoder.Encode(asInfos)
if err != nil {
log.Fatalf("Failed to encode JSON: %v", err)
if len(asInfos) == 0 {
return nil, errNoASes
}
return asInfos, nil
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"errors"
"io"
"slices"
"strings"
"testing"
"testing/iotest"
"time"
)
// parseTimeLimit is how long a test waits for parseCSV to return before it
// fails.
const parseTimeLimit = 2 * time.Second
// TestParseCSVReadsTodaysLayout parses an as.csv with the country code column
// that the source added after the description. It expects each AS with its
// handle and description spelled exactly as in the file, accents included.
func TestParseCSVReadsTodaysLayout(t *testing.T) {
t.Parallel()
const asCSV = `asn,handle,description,country-code
1,LVLT-1,Level 3 Parent LLC,US
1251,FUNDACAO-AMPARO-PESQUISA,FUNDAÇÃO DE AMPARO À PESQUISA DO ESTADO SÃO PAULO,BR
`
got, err := parseCSV(strings.NewReader(asCSV))
if err != nil {
t.Fatalf("parseCSV: %v", err)
}
want := []ASInfo{
{ASN: 1, Handle: "LVLT-1", Description: "Level 3 Parent LLC"},
{
ASN: 1251,
Handle: "FUNDACAO-AMPARO-PESQUISA",
Description: "FUNDAÇÃO DE AMPARO À PESQUISA DO ESTADO " +
"SÃO PAULO",
},
}
if !slices.Equal(got, want) {
t.Errorf("parseCSV = %+v, want %+v", got, want)
}
}
// TestParseCSVRejectsAnotherHeader parses files whose first columns are not
// the AS number, the handle and the description. It expects an error rather
// than a list read from the wrong columns.
func TestParseCSVRejectsAnotherHeader(t *testing.T) {
t.Parallel()
for _, asCSV := range []string{
"asn,handle\n1,LVLT-1\n",
"asn,description,handle\n1,Level 3 Parent LLC,LVLT-1\n",
} {
_, err := parseCSV(strings.NewReader(asCSV))
if err == nil {
t.Errorf("parseCSV(%q): no error", asCSV)
}
}
}
// TestParseCSVRejectsAFileWithNoASes parses files that hold no AS, one with
// only the header and one whose only record has no valid AS number. It
// expects errNoASes, so that make asupdate keeps the old list.
func TestParseCSVRejectsAFileWithNoASes(t *testing.T) {
t.Parallel()
for _, asCSV := range []string{
"asn,handle,description,country-code\n",
"asn,handle,description,country-code\nAS1,LVLT-1,Level 3,US\n",
} {
_, err := parseCSV(strings.NewReader(asCSV))
if !errors.Is(err, errNoASes) {
t.Errorf("parseCSV(%q) = %v, want %v", asCSV, err, errNoASes)
}
}
}
// TestParseCSVSkipsARecordThatDoesNotParse parses an as.csv with a record
// that has too few fields between two valid records. It expects both valid
// ASes and no error, so that one bad record does not stop make asupdate.
func TestParseCSVSkipsARecordThatDoesNotParse(t *testing.T) {
t.Parallel()
const asCSV = "asn,handle,description,country-code\n" +
"1,LVLT-1,Level 3 Parent LLC,US\n" +
"2,UDEL-DCN\n" +
"3,MIT-GATEWAYS,Massachusetts Institute of Technology,US\n"
got, err := parseCSV(strings.NewReader(asCSV))
if err != nil {
t.Fatalf("parseCSV: %v", err)
}
want := []ASInfo{
{ASN: 1, Handle: "LVLT-1", Description: "Level 3 Parent LLC"},
{
ASN: 3,
Handle: "MIT-GATEWAYS",
Description: "Massachusetts Institute of Technology",
},
}
if !slices.Equal(got, want) {
t.Errorf("parseCSV = %+v, want %+v", got, want)
}
}
// TestParseCSVStopsWhenTheDownloadBreaksOff parses an as.csv whose download
// breaks off after the first record. From then on every read fails, as on a
// broken connection. It expects parseCSV to return that error, so that make
// asupdate keeps the old list.
func TestParseCSVStopsWhenTheDownloadBreaksOff(t *testing.T) {
t.Parallel()
const asCSV = "asn,handle,description,country-code\n" +
"1,LVLT-1,Level 3 Parent LLC,US\n"
download := io.MultiReader(
strings.NewReader(asCSV), iotest.ErrReader(io.ErrUnexpectedEOF))
// parseCSV runs on its own goroutine, so that a parseCSV that never
// returns fails the test instead of hanging it.
parsed := make(chan error, 1)
go func() {
_, err := parseCSV(download)
parsed <- err
}()
select {
case err := <-parsed:
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("parseCSV = %v, want %v", err, io.ErrUnexpectedEOF)
}
case <-time.After(parseTimeLimit):
t.Fatalf("parseCSV did not return within %v", parseTimeLimit)
}
}
+4
View File
@@ -3,13 +3,17 @@ module git.eeqj.de/sneak/routewatch
go 1.27.1
require (
github.com/bits-and-blooms/bitset v1.26.0
github.com/dustin/go-humanize v1.0.1
github.com/go-chi/chi/v5 v5.2.2
github.com/google/btree v1.1.3
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.29
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9
go.uber.org/fx v1.24.0
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb
golang.org/x/term v0.33.0
golang.org/x/text v0.43.0
)
require (
+8
View File
@@ -1,9 +1,13 @@
github.com/bits-and-blooms/bitset v1.26.0 h1:WPvcPZbP1yuKofmjs2SYIFO1EjzIGhCP9ducElLsh5Q=
github.com/bits-and-blooms/bitset v1.26.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
@@ -24,9 +28,13 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+184
View File
@@ -0,0 +1,184 @@
// 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
import (
"context"
"log/slog"
"net/netip"
"sync"
"time"
"go4.org/netipx"
)
// Format is how a list's file is written.
type Format int
const (
// FormatLines has one address or network per line, such as 192.0.2.1 or
// 192.0.2.0/24. Lines that start with # are comments.
FormatLines Format = iota
// FormatSpamhausJSON has one JSON object per line, with a network in its
// cidr field. The last line holds the file's metadata and no network.
FormatSpamhausJSON
// FormatDShield has one network per line, as tab-separated fields that
// start with the network's first and last address. Lines that start
// with # are comments.
FormatDShield
)
// List is one public blocklist.
type List struct {
// Name names the list in lookup answers, and names its copy on disk.
Name string
// Description says in one line what being on the list means.
Description string
// URL is where the publisher serves the whole list.
URL string
// Interval is how long a copy is used before the list is downloaded
// again.
Interval time.Duration
// Format is how the list's file is written.
Format Format
}
// Listing says that a list contains an address.
type Listing struct {
Name string `json:"name"`
Description string `json:"description"`
// Updated is when routewatch last downloaded the list, or last heard from
// its publisher that its copy was still current.
Updated time.Time `json:"updated"`
}
// listCopy is a list's last good copy in memory.
type listCopy struct {
addresses *netipx.IPSet
updated time.Time
// validators identify the version of the list this copy holds.
validators validators
}
// Params is what New needs.
type Params struct {
// Dir is the directory that holds the copies on disk.
Dir string
// Lists are the lists to download and look addresses up in.
Lists []List
Logger *slog.Logger
}
// Blocklists 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 {
dir string
lists []List
logger *slog.Logger
// copies holds each list's last good copy, by list name. A list with no
// good copy yet has none.
mu sync.RWMutex
copies map[string]listCopy
// stop ends the goroutine Start runs, and done is closed when it returns.
stop context.CancelFunc
done chan struct{}
}
// New returns a Blocklists with no copies. Start loads them.
func New(params Params) *Blocklists {
return &Blocklists{
dir: params.Dir,
lists: params.Lists,
logger: params.Logger.With("component", "blocklists"),
copies: make(map[string]listCopy),
done: make(chan struct{}),
}
}
// 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 {
b.mu.RLock()
defer b.mu.RUnlock()
listings := []Listing{}
for _, list := range b.lists {
c, ok := b.copies[list.Name]
if ok && c.addresses.Contains(addr.Unmap()) {
listings = append(listings, Listing{
Name: list.Name,
Description: list.Description,
Updated: c.updated,
})
}
}
return listings
}
// 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 {
var shortest time.Duration
for i, list := range b.lists {
if i == 0 || list.Interval < shortest {
shortest = list.Interval
}
}
return shortest
}
// 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 {
b.mu.RLock()
defer b.mu.RUnlock()
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.
func (b *Blocklists) setCopy(name string, c listCopy) {
b.mu.Lock()
defer b.mu.Unlock()
b.copies[name] = c
}
// setUpdated records that the named list's copy was found current at t.
func (b *Blocklists) setUpdated(name string, t time.Time) {
b.mu.Lock()
defer b.mu.Unlock()
c, ok := b.copies[name]
if ok {
c.updated = t
b.copies[name] = c
}
}
@@ -0,0 +1,494 @@
package blocklist
import (
"context"
"errors"
"io"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// The test list's name and description, the address its good version lists,
// and the address a newer version lists instead.
const (
testListName = "test_list"
testListDescription = "On the test list"
listedAddr = "192.0.2.1"
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.
const maxWait = 10 * time.Second
// 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
// 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 {
mu sync.Mutex
status int
body string
lastModified time.Time
etag string
headers []http.Header
}
// newPublisher starts a publisher that answers 200 with body, and returns it
// and its URL.
func newPublisher(t *testing.T, body string) (*publisher, string) {
t.Helper()
p := &publisher{status: http.StatusOK, body: body}
srv := httptest.NewServer(p)
t.Cleanup(srv.Close)
return p, srv.URL
}
func (p *publisher) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
defer p.mu.Unlock()
p.headers = append(p.headers, r.Header.Clone())
if p.status != http.StatusOK {
w.WriteHeader(p.status)
_, _ = 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.
func (p *publisher) answer(status int, body string) {
p.mu.Lock()
defer p.mu.Unlock()
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.
func (p *publisher) requests() []http.Header {
p.mu.Lock()
defer p.mu.Unlock()
return append([]http.Header(nil), p.headers...)
}
// testList returns a list served at url, written in format.
func testList(url string, format Format) List {
return List{
Name: testListName,
Description: testListDescription,
URL: url,
Interval: time.Hour,
Format: format,
}
}
func newTestBlocklists(dir string, list List) *Blocklists {
return New(Params{
Dir: dir,
Lists: []List{list},
Logger: slog.New(slog.DiscardHandler),
})
}
// lookup looks addr up in b.
func lookup(t *testing.T, b *Blocklists, addr string) []Listing {
t.Helper()
return b.Lookup(netip.MustParseAddr(addr))
}
// TestLookupFindsListedAddresses downloads a list in each format and looks
// up addresses on it, inside its networks, and off it, in IPv4 and IPv6.
// A listed address has one listing that names the list; any other has an
// empty list of listings.
func TestLookupFindsListedAddresses(t *testing.T) {
t.Parallel()
tests := []struct {
name string
format Format
body string
listed []string
unlisted []string
}{
{
name: "single addresses",
format: FormatLines,
body: "# comment\n192.0.2.1\n\n2001:db8::1\n",
listed: []string{"192.0.2.1", "::ffff:192.0.2.1", "2001:db8::1"},
unlisted: []string{"192.0.2.2", "2001:db8::2"},
},
{
name: "networks",
format: FormatLines,
body: "# comment\n198.51.100.0/24\n2001:db8:1::/48\n",
listed: []string{"198.51.100.0", "198.51.100.255", "2001:db8:1::5"},
unlisted: []string{"198.51.101.0", "2001:db8:2::1"},
},
{
name: "Spamhaus networks",
format: FormatSpamhausJSON,
body: `{"cidr":"203.0.113.0/25","sblid":"SBL1","rir":"arin"}` + "\n" +
`{"cidr":"2001:db8:3::/48","sblid":"SBL2","rir":"ripencc"}` + "\n" +
`{"type":"metadata","timestamp":1793074442,"records":2}` + "\n",
listed: []string{"203.0.113.1", "2001:db8:3::1"},
unlisted: []string{"203.0.113.200", "2001:db8:4::1"},
},
{
name: "DShield networks",
format: FormatDShield,
body: "# DShield.org Recommended Block List\n" +
"192.0.2.0\t192.0.2.255\t24\t327\tEXAMPLE\tUS\tNone\n",
listed: []string{"192.0.2.0", "192.0.2.77"},
unlisted: []string{"192.0.3.1", "2001:db8::1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, url := newPublisher(t, tt.body)
b := newTestBlocklists(t.TempDir(), testList(url, tt.format))
err := b.download(t.Context(), b.lists[0])
if err != nil {
t.Fatalf("download: %v", err)
}
for _, addr := range tt.listed {
listings := lookup(t, b, addr)
if len(listings) != 1 || listings[0].Name != testListName ||
listings[0].Description != testListDescription ||
listings[0].Updated.IsZero() {
t.Errorf("%s: listings %+v, want one for %s", addr,
listings, testListName)
}
}
for _, addr := range tt.unlisted {
listings := lookup(t, b, addr)
if listings == nil || len(listings) != 0 {
t.Errorf("%s: listings %#v, want an empty list", addr, listings)
}
}
})
}
}
// TestFailedDownloadKeepsThePreviousCopy downloads a good version of a list,
// then one that fails. The good copy stays in memory and on disk, and no
// part of the failed download is left beside it.
func TestFailedDownloadKeepsThePreviousCopy(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status int
body string
}{
{"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 {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
p, url := newPublisher(t, goodListBody)
b := newTestBlocklists(dir, testList(url, FormatLines))
err := b.download(t.Context(), b.lists[0])
if err != nil {
t.Fatalf("first download: %v", err)
}
before := lookup(t, b, listedAddr)
p.answer(tt.status, tt.body)
err = b.download(t.Context(), b.lists[0])
if err == nil {
t.Fatal("second download returned no error")
}
after := lookup(t, b, listedAddr)
if len(after) != 1 || len(before) != 1 ||
!after[0].Updated.Equal(before[0].Updated) {
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)
}
})
}
}
// TestRestartLoadsCopiesFromDisk downloads a list, then starts a new
// Blocklists 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) {
t.Parallel()
dir := t.TempDir()
p, url := newPublisher(t, goodListBody)
list := testList(url, FormatLines)
first := newTestBlocklists(dir, list)
err := first.download(t.Context(), list)
if err != nil {
t.Fatalf("download: %v", err)
}
downloaded := lookup(t, first, listedAddr)[0].Updated
second := newTestBlocklists(dir, list)
second.Start(t.Context())
second.Stop(t.Context())
listings := lookup(t, second, listedAddr)
if len(listings) != 1 ||
listings[0].Updated.Sub(downloaded).Abs() > time.Second {
t.Errorf("after the restart, listings %+v, want one updated at %s",
listings, downloaded)
}
if got := len(p.requests()); got != 1 {
t.Errorf("the publisher had %d requests, want 1", got)
}
}
// TestStartRefreshesAnOldCopy starts on a copy older than its list's
// 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 publisher answers that the copy is still current, so the copy is kept
// and marked as updated now.
func TestStartRefreshesAnOldCopy(t *testing.T) {
t.Parallel()
dir := t.TempDir()
p, url := newPublisher(t, goodListBody)
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)
if err != nil {
t.Fatalf("download: %v", err)
}
old := time.Now().Add(-2 * list.Interval).UTC().Truncate(time.Second)
err = os.Chtimes(filepath.Join(dir, testListName), old, old)
if err != nil {
t.Fatalf("Chtimes: %v", err)
}
b := newTestBlocklists(dir, list)
b.Start(t.Context())
deadline := time.Now().Add(maxWait)
for !lookup(t, b, listedAddr)[0].Updated.After(old) {
if time.Now().After(deadline) {
t.Fatalf("the copy was not marked updated within %s", maxWait)
}
time.Sleep(time.Millisecond)
}
b.Stop(t.Context())
requests := p.requests()
last := requests[len(requests)-1]
want := lastModified.Format(http.TimeFormat)
if got := last.Get(ifModifiedSinceHeader); got != want {
t.Errorf("If-Modified-Since %q, want %q", got, want)
}
if got := last.Get(ifNoneMatchHeader); got != testETag {
t.Errorf("If-None-Match %q, want %q", got, testETag)
}
if got := last.Get(userAgentHeader); got != userAgent {
t.Errorf("User-Agent %q, want %q", got, userAgent)
}
}
// 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
// answered. Stop cancels the download and returns once the download
// goroutine has stopped.
func TestStopCancelsADownloadInProgress(t *testing.T) {
t.Parallel()
arrived := make(chan struct{})
var once sync.Once
srv := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
once.Do(func() { close(arrived) })
<-r.Context().Done()
}))
t.Cleanup(srv.Close)
b := newTestBlocklists(t.TempDir(), testList(srv.URL, FormatLines))
b.Start(t.Context())
select {
case <-arrived:
case <-time.After(maxWait):
t.Fatalf("no download began within %s", maxWait)
}
ctx, cancel := context.WithTimeout(t.Context(), maxWait)
defer cancel()
b.Stop(ctx)
select {
case <-b.done:
default:
t.Error("Stop returned before the download goroutine stopped")
}
}
// TestPublicListsAreComplete checks that every public list has what a
// download and a lookup answer need, and a unique name.
func TestPublicListsAreComplete(t *testing.T) {
t.Parallel()
names := make(map[string]bool)
for _, list := range Lists() {
if list.Name == "" || list.Description == "" || list.URL == "" ||
list.Interval <= 0 || names[list.Name] {
t.Errorf("list %+v is incomplete or has a name used before", list)
}
names[list.Name] = true
}
}
+379
View File
@@ -0,0 +1,379 @@
package blocklist
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"slices"
"time"
"go4.org/netipx"
)
// Limits of one download.
const (
// downloadTimeout is how long one download may take.
downloadTimeout = time.Minute
// 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
)
// The request headers a download sets.
const (
userAgentHeader = "User-Agent"
ifModifiedSinceHeader = "If-Modified-Since"
ifNoneMatchHeader = "If-None-Match"
// userAgent names routewatch to the publishers.
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 (
// downloadSuffix ends the name of the file a download is written to,
// beside the list's copy, until it has parsed.
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 = 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 (
errUnexpectedStatus = errors.New("unexpected HTTP status")
errTooLarge = errors.New("list is over the size limit")
)
// Start loads each list's copy from disk. It then starts the goroutine that
// 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) {
err := os.MkdirAll(b.dir, dirPermissions)
if err != nil {
b.logger.Error("Failed to create the blocklists directory",
"dir", b.dir, "error", err)
}
nextDownload := make([]time.Time, len(b.lists))
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",
"list", list.Name, "error", loadErr)
}
// A list with no copy has the zero time, so it is due at once.
nextDownload[i] = b.updated(list.Name).Add(list.Interval)
}
ctx, b.stop = context.WithCancel(context.WithoutCancel(ctx))
go func() {
b.run(ctx, nextDownload)
}()
b.logger.Info("Blocklist 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) {
b.stop()
select {
case <-b.done:
b.logger.Info("Blocklist downloads stopped")
case <-ctx.Done():
b.logger.Warn("Stop deadline passed before the blocklist downloads " +
"stopped")
}
}
// 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) {
defer close(b.done)
if len(nextDownload) == 0 {
return
}
for {
soonest := slices.MinFunc(nextDownload, time.Time.Compare)
timer := time.NewTimer(time.Until(soonest))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
for i, list := range b.lists {
if time.Now().Before(nextDownload[i]) {
continue
}
err := b.download(ctx, list)
if ctx.Err() != nil {
return
}
if err != nil {
b.logger.Error("Blocklist download failed; keeping the "+
"previous copy", "list", list.Name, "error", err)
}
nextDownload[i] = time.Now().Add(list.Interval)
}
}
}
// download asks the publisher for list, sending the validators of the list's
// copy when it has them. A new version that parses becomes the list's copy,
// 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 {
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return err
}
req.Header.Set(userAgentHeader, userAgent)
held := b.copyValidators(list.Name)
if held.LastModified != "" {
req.Header.Set(ifModifiedSinceHeader, held.LastModified)
}
if held.ETag != "" {
req.Header.Set(ifNoneMatchHeader, held.ETag)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
return b.replaceCopy(list, resp.Body, validators{
LastModified: resp.Header.Get(lastModifiedHeader),
ETag: resp.Header.Get(etagHeader),
})
case http.StatusNotModified:
return b.markCurrent(list)
default:
return fmt.Errorf("%w: %s", errUnexpectedStatus, resp.Status)
}
}
// 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 {
root, err := os.OpenRoot(b.dir)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
downloadName := list.Name + downloadSuffix
addresses, err := writeAndParse(root, downloadName, body, list.Format)
if err != nil {
_ = root.Remove(downloadName)
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)
if err != nil {
return 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)
// 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
}
// writeAndParse writes body to the file name in root, up to maxDownloadSize
// bytes, and parses the file as format.
func writeAndParse(
root *os.Root, name string, body io.Reader, format Format,
) (*netipx.IPSet, error) {
f, err := root.Create(name)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
written, err := io.Copy(f, io.LimitReader(body, maxDownloadSize+1))
if err != nil {
return nil, err
}
if written > maxDownloadSize {
return nil, errTooLarge
}
_, err = f.Seek(0, io.SeekStart)
if err != nil {
return nil, err
}
return parse(f, format)
}
// 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 {
root, err := os.OpenRoot(b.dir)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
now := time.Now().UTC()
err = root.Chtimes(list.Name, now, now)
if err != nil {
return err
}
b.setUpdated(list.Name, now)
b.logger.Debug("Blocklist copy is still current", "list", list.Name)
return nil
}
// 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 {
root, err := os.OpenRoot(b.dir)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
f, err := root.Open(list.Name)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return err
}
addresses, err := parse(f, list.Format)
if err != nil {
return err
}
held, err := readValidators(root, list.Name)
if err != nil {
return err
}
b.setCopy(list.Name, listCopy{
addresses: addresses, updated: info.ModTime().UTC(), validators: held,
})
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
}
+93
View File
@@ -0,0 +1,93 @@
package blocklist
import "time"
// How often each list is downloaded. No list is downloaded more often than
// its publisher updates it or allows.
const (
// DShield asks that its feeds be downloaded at most once an hour:
// https://isc.sans.edu/feeds_doc.html
dshieldInterval = time.Hour
// abuse.ch makes the list every 5 minutes and recommends a download at
// least every 15: https://feodotracker.abuse.ch/blocklist/
feodoTrackerInterval = 15 * time.Minute
// FireHOL's page for each list gives how often the list changes on
// average: about every 5 hours for abusers and every 3 hours for level
// 3. FireHOL sets no download limit.
// https://iplists.firehol.org/?ipset=firehol_abusers_30d
// https://iplists.firehol.org/?ipset=firehol_level3
fireHOLAbusersInterval = 5 * time.Hour
fireHOLLevel3Interval = 3 * time.Hour
// Spamhaus asks that DROP be downloaded at most once a day:
// https://www.spamhaus.org/blocklists/do-not-route-or-peer/
spamhausDROPInterval = 24 * time.Hour
// Tor publishes a new exit list every hour and sets no download limit:
// https://collector.torproject.org/recent/exit-lists/
torExitsInterval = time.Hour
)
// Lists returns the public blocklists 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 {
return []List{
{
Name: "dshield",
Description: "In one of the 20 networks that DShield saw scan " +
"the most targets in the last three days",
URL: "https://feeds.dshield.org/block.txt",
Interval: dshieldInterval,
Format: FormatDShield,
},
{
Name: "feodo_tracker",
Description: "A botnet command-and-control server",
URL: "https://feodotracker.abuse.ch/downloads/ipblocklist.txt",
Interval: feodoTrackerInterval,
Format: FormatLines,
},
{
Name: "firehol_abusers_30d",
Description: "Reported for comment, form or forum spam in the " +
"last 30 days",
URL: "https://iplists.firehol.org/files/firehol_abusers_30d.netset",
Interval: fireHOLAbusersInterval,
Format: FormatLines,
},
{
Name: "firehol_level3",
Description: "Reported for attacks, spyware or viruses in the " +
"last 30 days",
URL: "https://iplists.firehol.org/files/firehol_level3.netset",
Interval: fireHOLLevel3Interval,
Format: FormatLines,
},
{
Name: "spamhaus_drop",
Description: "In an IPv4 network that Spamhaus says spammers " +
"or cyber criminals have hijacked or lease",
URL: "https://www.spamhaus.org/drop/drop_v4.json",
Interval: spamhausDROPInterval,
Format: FormatSpamhausJSON,
},
{
Name: "spamhaus_dropv6",
Description: "In an IPv6 network that Spamhaus says spammers " +
"or cyber criminals have hijacked or lease",
URL: "https://www.spamhaus.org/drop/drop_v6.json",
Interval: spamhausDROPInterval,
Format: FormatSpamhausJSON,
},
{
Name: "tor_exits",
Description: "A Tor exit node, where traffic from the Tor network leaves",
URL: "https://check.torproject.org/torbulkexitlist",
Interval: torExitsInterval,
Format: FormatLines,
},
}
}
+158
View File
@@ -0,0 +1,158 @@
package blocklist
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"strings"
"go4.org/netipx"
)
const (
// commentPrefix starts a comment line in a list file.
commentPrefix = "#"
// networkSeparator separates the address and the mask length of a
// network, as in 192.0.2.0/24.
networkSeparator = "/"
// dshieldFieldSeparator separates the fields of a DShield line.
dshieldFieldSeparator = "\t"
// spamhausMetadataType is the type of the line that ends a Spamhaus
// file. That line holds the file's metadata and no network.
spamhausMetadataType = "metadata"
)
var (
errUnknownFormat = errors.New("unknown list format")
errNoNetwork = errors.New("no network on the line")
errBadRange = errors.New("first and last address make no range")
)
// parse reads a list file written in format and returns the addresses it
// holds. A line it cannot read fails the whole file, so a file that is not
// the list, such as an error page, never replaces a good copy.
func parse(r io.Reader, format Format) (*netipx.IPSet, error) {
var builder netipx.IPSetBuilder
scanner := bufio.NewScanner(r)
for lineNumber := 1; scanner.Scan(); lineNumber++ {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, commentPrefix) {
continue
}
err := addLine(&builder, line, format)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNumber, err)
}
}
err := scanner.Err()
if err != nil {
return nil, err
}
return builder.IPSet()
}
// addLine adds the address or network on one line of a list file to builder.
func addLine(builder *netipx.IPSetBuilder, line string, format Format) error {
switch format {
case FormatLines:
return addAddressOrNetwork(builder, line)
case FormatSpamhausJSON:
return addSpamhausNetwork(builder, line)
case FormatDShield:
return addDShieldNetwork(builder, line)
default:
return errUnknownFormat
}
}
// addAddressOrNetwork adds an address, such as 192.0.2.1, or a network, such
// as 192.0.2.0/24, to builder.
func addAddressOrNetwork(builder *netipx.IPSetBuilder, text string) error {
if strings.Contains(text, networkSeparator) {
network, err := netip.ParsePrefix(text)
if err != nil {
return err
}
builder.AddPrefix(network)
return nil
}
addr, err := netip.ParseAddr(text)
if err != nil {
return err
}
builder.Add(addr)
return nil
}
// addSpamhausNetwork adds the network on a line of a Spamhaus file to
// builder. The metadata line adds nothing.
func addSpamhausNetwork(builder *netipx.IPSetBuilder, line string) error {
var entry struct {
CIDR string `json:"cidr"`
Type string `json:"type"`
}
err := json.Unmarshal([]byte(line), &entry)
if err != nil {
return err
}
if entry.Type == spamhausMetadataType {
return nil
}
network, err := netip.ParsePrefix(entry.CIDR)
if err != nil {
return err
}
builder.AddPrefix(network)
return nil
}
// addDShieldNetwork adds the network on a line of a DShield file to builder.
// The line's first two fields are the network's first and last address.
func addDShieldNetwork(builder *netipx.IPSetBuilder, line string) error {
firstField, rest, found := strings.Cut(line, dshieldFieldSeparator)
if !found {
return errNoNetwork
}
lastField, _, _ := strings.Cut(rest, dshieldFieldSeparator)
first, err := netip.ParseAddr(firstField)
if err != nil {
return err
}
last, err := netip.ParseAddr(lastField)
if err != nil {
return err
}
network := netipx.IPRangeFrom(first, last)
if !network.IsValid() {
return errBadRange
}
builder.AddRange(network)
return nil
}
+37 -23
View File
@@ -945,6 +945,26 @@ func (d *Database) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
return nil
}
// These statements update a stored AS, peering or peer. Each keeps the earlier
// of the stored and the new first_seen, and the later of the stored and the new
// last_seen. Some collectors send updates days old while they catch up on a
// backlog. MIN and MAX compare the times as text, which is time order here: the
// driver writes every time in one format, and every time is UTC.
const (
updateASNSQL = `UPDATE asns
SET first_seen = MIN(first_seen, ?), last_seen = MAX(last_seen, ?)
WHERE asn = ?`
updatePeeringSQL = `UPDATE peerings
SET first_seen = MIN(first_seen, ?), last_seen = MAX(last_seen, ?)
WHERE as_a = ? AND as_b = ?`
updatePeerSQL = `UPDATE bgp_peers
SET peer_asn = ?, last_message_type = ?,
first_seen = MIN(first_seen, ?), last_seen = MAX(last_seen, ?)
WHERE peer_ip = ?`
)
// newASN returns a new ASN record first seen at seen.First and last seen at
// seen.Last, with the handle and description from the AS info lookup when it
// knows the number.
@@ -964,9 +984,8 @@ func newASN(number int, seen SeenTimes) ASN {
return asn
}
// upsertASN sets the last_seen time of ASN number to seen.Last with the given
// statements, inserting the ASN when it is new. It reports whether it inserted
// it.
// upsertASN stores the seen times of ASN number with the given statements,
// inserting the ASN when it is new. It reports whether it inserted it.
func upsertASN(
ctx context.Context, selectStmt, updateStmt, insertStmt *sql.Stmt,
number int, seen SeenTimes,
@@ -979,8 +998,8 @@ func upsertASN(
err := selectStmt.QueryRowContext(ctx, number).Scan(&asn.ASN, &handle,
&description, &asn.FirstSeen, &asn.LastSeen)
if err == nil {
// ASN exists, update last_seen
_, err = updateStmt.ExecContext(ctx, seen.Last, number)
// ASN exists, update its seen times
_, err = updateStmt.ExecContext(ctx, seen.First, seen.Last, number)
if err != nil {
return false, fmt.Errorf("failed to update ASN %d: %w", number, err)
}
@@ -1030,8 +1049,7 @@ func (d *Database) GetOrCreateASNBatch(asns map[int]SeenTimes) error {
}
defer func() { _ = selectStmt.Close() }()
updateStmt, err := tx.PrepareContext(ctx,
"UPDATE asns SET last_seen = ? WHERE asn = ?")
updateStmt, err := tx.PrepareContext(ctx, updateASNSQL)
if err != nil {
return fmt.Errorf("failed to prepare update statement: %w", err)
}
@@ -1090,17 +1108,19 @@ func (d *Database) GetOrCreateASN(number int, timestamp time.Time) (*ASN, error)
"FROM asns WHERE asn = ?", number).
Scan(&asn.ASN, &handle, &description, &asn.FirstSeen, &asn.LastSeen)
if err == nil {
// ASN exists, update last_seen
// ASN exists, update its seen times
asn.Handle = handle.String
asn.Description = description.String
_, err = tx.Exec("UPDATE asns SET last_seen = ? WHERE asn = ?",
timestamp, number)
_, err = tx.Exec(updateASNSQL, timestamp, timestamp, number)
if err != nil {
return nil, err
}
asn.LastSeen = timestamp
// Return the seen times the update stored.
seen := SeenTimes{First: asn.FirstSeen, Last: asn.LastSeen}
seen.Add(timestamp)
asn.FirstSeen, asn.LastSeen = seen.First, seen.Last
err = tx.Commit()
if err != nil {
@@ -1259,9 +1279,7 @@ func (d *Database) RecordPeering(asA, asB int, seen SeenTimes) error {
}
if exists {
_, err = tx.Exec(
"UPDATE peerings SET last_seen = ? WHERE as_a = ? AND as_b = ?",
seen.Last, asA, asB)
_, err = tx.Exec(updatePeeringSQL, seen.First, seen.Last, asA, asB)
} else {
_, err = tx.Exec(`
INSERT INTO peerings (id, as_a, as_b, first_seen, last_seen)
@@ -1316,9 +1334,7 @@ func (d *Database) UpdatePeerBatch(peers map[string]PeerUpdate) error {
}
defer func() { _ = checkStmt.Close() }()
updateStmt, err := tx.PrepareContext(ctx,
"UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, "+
"last_message_type = ? WHERE peer_ip = ?")
updateStmt, err := tx.PrepareContext(ctx, updatePeerSQL)
if err != nil {
return fmt.Errorf("failed to prepare update statement: %w", err)
}
@@ -1344,7 +1360,8 @@ func (d *Database) UpdatePeerBatch(peers map[string]PeerUpdate) error {
if exists {
_, err = updateStmt.ExecContext(ctx, update.PeerASN,
update.Seen.Last, update.MessageType, update.PeerIP)
update.MessageType, update.Seen.First, update.Seen.Last,
update.PeerIP)
} else {
_, err = insertStmt.ExecContext(ctx,
generateUUID().String(), update.PeerIP, update.PeerASN,
@@ -1393,11 +1410,8 @@ func (d *Database) UpdatePeer(
}
if exists {
_, err = tx.Exec(
"UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, "+
"last_message_type = ? WHERE peer_ip = ?",
peerASN, timestamp, messageType, peerIP,
)
_, err = tx.Exec(updatePeerSQL,
peerASN, messageType, timestamp, timestamp, peerIP)
} else {
_, err = tx.Exec(
"INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, "+
+153
View File
@@ -20,6 +20,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/mattn/go-sqlite3"
)
@@ -1960,6 +1961,158 @@ func TestUpdatePeerBatchStoresTheEarliestAndLatestTimesOfANewPeer(
}
}
// backlogAge is how much older one write is than the other in the tests of
// first_seen and last_seen, as when a collector catches up on a backlog.
const backlogAge = 24 * time.Hour
// batchSpan is how much earlier a batch's First is than its Last in the tests
// of first_seen and last_seen.
const batchSpan = time.Hour
// TestWritesKeepEarliestFirstSeenAndLatestLastSeen writes a row of each of
// asns, peerings and bgp_peers through each function that writes one, at a
// newer time and at an older one. Each row takes the older write's First as
// first_seen and the newer write's Last as last_seen. The test writes in both
// orders, each in a database of its own. Newer then older is a collector
// catching up on a backlog; older then newer is a feed that arrives in order.
// Each function writes a row of its own. A batch write's First is batchSpan
// before its Last, so the test catches the two swapped. A single-row write
// takes one time.
func TestWritesKeepEarliestFirstSeenAndLatestLastSeen(t *testing.T) {
t.Parallel()
const (
batchASN, singleASN = 64500, 64501
peerASN = 64502
asnQuery = "SELECT first_seen, last_seen FROM asns WHERE asn = ?"
peeringQuery = "SELECT first_seen, last_seen FROM peerings " +
"WHERE as_a = ? AND as_b = ?"
peerQuery = "SELECT first_seen, last_seen FROM bgp_peers " +
"WHERE peer_ip = ?"
)
for name, olderFirst := range map[string]bool{
"newer then older": false,
"older then newer": true,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
return db.GetOrCreateASNBatch(map[int]SeenTimes{batchASN: seen})
}, batchSpan, asnQuery, batchASN)
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
_, err := db.GetOrCreateASN(singleASN, seen.Last)
return err
}, 0, asnQuery, singleASN)
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
return db.RecordPeering(batchASN, singleASN, seen)
}, batchSpan, peeringQuery, batchASN, singleASN)
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
return db.UpdatePeerBatch(map[string]PeerUpdate{
testV4PeerIP: {
PeerIP: testV4PeerIP, PeerASN: peerASN,
MessageType: ristypes.MessageTypeUpdate,
Seen: seen,
},
})
}, batchSpan, peerQuery, testV4PeerIP)
checkSeenTimes(t, db, olderFirst, func(seen SeenTimes) error {
return db.UpdatePeer(testPeerIP, peerASN,
ristypes.MessageTypeUpdate, seen.Last)
}, 0, peerQuery, testPeerIP)
})
}
}
// checkSeenTimes calls write twice, each time with First span before Last: at
// newer times and at times backlogAge older. It writes the older times first
// when olderFirst is true. It checks that query, given args, reads the older
// write's First as first_seen and the newer write's Last as last_seen.
func checkSeenTimes(
t *testing.T, db *Database, olderFirst bool,
write func(seen SeenTimes) error,
span time.Duration, query string, args ...any,
) {
t.Helper()
newerLast := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
newer := SeenTimes{First: newerLast.Add(-span), Last: newerLast}
older := SeenTimes{
First: newer.First.Add(-backlogAge),
Last: newer.Last.Add(-backlogAge),
}
writes := []SeenTimes{newer, older}
if olderFirst {
writes = []SeenTimes{older, newer}
}
for _, seen := range writes {
err := write(seen)
if err != nil {
t.Fatalf("writing first %s, last %s: %v", seen.First, seen.Last, err)
}
}
var firstSeen, lastSeen time.Time
err := db.db.QueryRowContext(t.Context(), query, args...).
Scan(&firstSeen, &lastSeen)
if err != nil {
t.Fatalf("reading the row: %v", err)
}
if !firstSeen.Equal(older.First) || !lastSeen.Equal(newer.Last) {
t.Errorf("first seen %s, last seen %s; want %s, %s",
firstSeen, lastSeen, older.First, newer.Last)
}
}
// TestGetOrCreateASNReturnsStoredSeenTimes stores an AS at a newer time, then
// at an older one. The second call returns the times the row now holds: the
// older first_seen and the newer last_seen.
func TestGetOrCreateASNReturnsStoredSeenTimes(t *testing.T) {
t.Parallel()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
const asn = 64500
newer := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
older := newer.Add(-backlogAge)
_, err = db.GetOrCreateASN(asn, newer)
if err != nil {
t.Fatalf("GetOrCreateASN at %s: %v", newer, err)
}
got, err := db.GetOrCreateASN(asn, older)
if err != nil {
t.Fatalf("GetOrCreateASN at %s: %v", older, err)
}
if !got.FirstSeen.Equal(older) || !got.LastSeen.Equal(newer) {
t.Errorf("first seen %s, last seen %s; want %s, %s",
got.FirstSeen, got.LastSeen, older, newer)
}
}
// TestUpsertLiveRouteBatchWritesInKeyOrder writes new routes of two peers out
// of key order, the last one a second announcement of the first, and checks
// that the rows were inserted in key order, peer then prefix (each new row's id
+7 -8
View File
@@ -92,12 +92,11 @@ type PrefixDistribution struct {
//
//nolint:tagliatelle // field names are the existing JSON API
type IPInfo struct {
IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
Netblock string `json:"netblock"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
NumPeers int `json:"num_peers"`
IP string `json:"ip"`
Netblock string `json:"netblock"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
NumPeers int `json:"num_peers"`
// AS information
ASN int `json:"asn"`
ASName string `json:"as_name,omitempty"`
@@ -125,8 +124,8 @@ type LiveRouteDeletion struct {
// SeenTimes is the earliest and the latest time one batch of updates saw an AS,
// a peer or a peering. A row the batch adds is stored first seen at First and
// last seen at Last; a row already stored keeps its first_seen and has its
// last_seen set to Last.
// last seen at Last. A row already stored takes First as its first_seen only
// when First is earlier, and Last as its last_seen only when Last is later.
type SeenTimes struct {
First time.Time
Last time.Time
+3 -3
View File
@@ -34,7 +34,7 @@ type Tracker struct {
// Announcement/withdrawal metrics
announcementCounter metrics.Counter
withdrawalCounter metrics.Counter
churnPerMinute minuteCounter // announced and withdrawn prefixes
churnPerMinute minuteCounter // prefix announcements and withdrawals
// BGP peer tracking
bgpPeerCount atomic.Int32
@@ -198,8 +198,8 @@ func (t *Tracker) GetWithdrawalCount() uint64 {
return uint64(count)
}
// GetChurnPerMinute returns the number of prefixes announced or withdrawn in
// the last full minute of the clock.
// GetChurnPerMinute returns the number of prefix announcements and
// withdrawals, from all peers, in the last full clock minute.
func (t *Tracker) GetChurnPerMinute() int {
return t.churnPerMinute.lastFullMinute()
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"time"
)
// minuteCounter counts events in each minute of the clock and reports the
// count of the last full minute. It is safe for concurrent use.
// minuteCounter counts events in each clock minute and reports the count of
// the last full minute. It is safe for concurrent use.
type minuteCounter struct {
mu sync.Mutex
now func() time.Time
@@ -44,6 +44,12 @@ func TestChurnPerMinuteCountsLastFullMinute(t *testing.T) {
now = time.Date(2026, 10, 26, 12, 4, 0, 0, time.UTC)
expectChurn(0, "12:03 had none")
tracker.RecordWithdrawal()
now = time.Date(2026, 10, 26, 12, 6, 0, 0, time.UTC)
expectChurn(0, "12:05 had none, though 12:04 had one withdrawal")
}
// TestChurnPerMinuteCountsConcurrentRecords announces prefixes from several
+154
View File
@@ -0,0 +1,154 @@
package routetable_test
import (
"fmt"
"math/rand/v2"
"net/netip"
"runtime"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// Parameters for BenchmarkApplyMixedBatch.
const (
// benchPeers is how many peers there are, about as many as RIS has.
benchPeers = 1400
// benchPrefixes is how many IPv4 /24 prefixes the table holds, and
// benchPeersPerPrefix how many peers, picked at random, carry each.
benchPrefixes = 100000
benchPeersPerPrefix = 20
// benchBatchSize is how many updates the timed batch holds, as many as
// the prefix handler's largest batch (prefixBatchMaxSize in
// internal/routewatch).
benchBatchSize = 250000
// In the timed batch every benchWithdrawalEvery-th update is a
// withdrawal, and every benchNewOriginEvery-th announcement has an
// origin AS other than the prefix's.
benchWithdrawalEvery = 5
benchNewOriginEvery = 7
// benchOrigins is how many origin ASes the prefixes are spread over,
// from benchFirstASN on: 20 prefixes each, about as many as an origin AS
// has on average in the full table.
benchOrigins = 5000
benchFirstASN = 64500
// benchUpdatesPerSecond is how many updates share one time.
benchUpdatesPerSecond = 1000
benchMaskLength = 24
)
// benchUpdate is one update of the benchmark.
type benchUpdate struct {
prefix netip.Prefix
origin int
route routetable.Route
withdraw bool
}
// BenchmarkApplyMixedBatch times one batch of 250,000 updates of routes picked
// at random: announcements, withdrawals and announcements with a new origin AS.
// The table it applies them to holds 100,000 IPv4 prefixes from 5,000 origin
// ASes, each carried by 20 of 1,400 peers. It reports the heap bytes that table
// takes per prefix and origin AS pair. It is run by hand, not by make test, for
// example in the test image:
//
// go test -run '^$' -bench MixedBatch -benchtime 10x ./internal/routetable/
func BenchmarkApplyMixedBatch(b *testing.B) {
//nolint:gosec // The benchmark needs the same updates every run.
rng := rand.New(rand.NewPCG(1, 2))
peers := make([]string, benchPeers)
for i := range peers {
peers[i] = fmt.Sprintf("198.18.%d.%d", i/256, i%256)
}
before := heapBytes()
table := routetable.New(routetable.Params{})
updates := 0
for prefix := range benchPrefixes {
for range benchPeersPerPrefix {
peer := peers[rng.IntN(benchPeers)]
apply(table, benchAnnouncement(rng, prefix, peer, updates, false))
updates++
}
}
// The record of changes is handed over every second while routewatch
// runs, so it is not part of the table's size.
table.TakeChanges()
bytesPerPair := float64(heapBytes()-before) / benchPrefixes
batch := make([]benchUpdate, 0, benchBatchSize)
for i := range benchBatchSize {
update := benchAnnouncement(rng, rng.IntN(benchPrefixes),
peers[rng.IntN(benchPeers)], updates+i, i%benchNewOriginEvery == 0)
update.withdraw = i%benchWithdrawalEvery == 0
batch = append(batch, update)
}
for b.Loop() {
for _, update := range batch {
apply(table, update)
}
}
// After the loop: b.Loop drops the metrics reported before it starts.
b.ReportMetric(bytesPerPair, "bytes/pair")
}
// benchAnnouncement returns the announcement of the prefix numbered prefix, a
// /24 from 1.0.0.0/24 on, by peer. It is the updates-th update of the
// benchmark, and its AS path holds four ASes from the peer's AS to the origin
// AS. newOrigin gives it an origin AS other than the prefix's.
func benchAnnouncement(
rng *rand.Rand, prefix int, peer string, updates int, newOrigin bool,
) benchUpdate {
origin := benchFirstASN + prefix%benchOrigins
if newOrigin {
origin += benchOrigins
}
start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
return benchUpdate{
prefix: netip.MustParsePrefix(fmt.Sprintf("%d.%d.%d.0/%d",
1+prefix>>16, (prefix>>8)%256, prefix%256, benchMaskLength)),
origin: origin,
route: routetable.Route{
Peer: peer,
ASPath: []int{
benchFirstASN + rng.IntN(benchOrigins),
benchFirstASN + rng.IntN(benchOrigins),
benchFirstASN + rng.IntN(benchOrigins),
origin,
},
Time: start.Add(time.Duration(updates/benchUpdatesPerSecond) *
time.Second),
},
}
}
// apply applies update to table.
func apply(table *routetable.Table, update benchUpdate) {
if update.withdraw {
table.Withdraw(update.prefix, update.route.Peer)
return
}
table.Announce(update.prefix, update.origin, update.route)
}
// heapBytes returns the bytes of the live heap objects, after a collection.
func heapBytes() uint64 {
runtime.GC()
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
return stats.HeapAlloc
}
+223
View File
@@ -0,0 +1,223 @@
package routetable
import (
"net/netip"
"slices"
"time"
"github.com/bits-and-blooms/bitset"
)
// PrefixSummary is what the table answers about one prefix.
type PrefixSummary struct {
Prefix netip.Prefix
// OriginASN is the origin AS that the most peers carry the prefix with.
// When several have as many peers, it is the lowest AS number.
OriginASN int
// Peers is how many peers carry the prefix.
Peers int
// Newest is the time of the newest kept route of the prefix, or nil when
// it keeps none.
Newest *time.Time
}
// OriginatedPrefix is one prefix an AS originates.
type OriginatedPrefix struct {
Prefix netip.Prefix
// Newest is the time of the newest kept route of the prefix with that
// origin AS, or nil when it keeps none.
Newest *time.Time
}
// Counts are the table's figures for the stats.
type Counts struct {
IPv4Prefixes int
IPv6Prefixes int
// IPv4PrefixesByLength[n] and IPv6PrefixesByLength[n] are how many
// prefixes of mask length n some peer carries.
IPv4PrefixesByLength []int
IPv6PrefixesByLength []int
// IPv4Routes and IPv6Routes count peer and prefix pairs: each peer that
// carries a prefix counts once for it.
IPv4Routes int
IPv6Routes int
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
// carry but whose kept routes were all withdrawn.
OriginsWithoutKeptRoute int
// OldestKeptRoute and NewestKeptRoute are the times of the oldest and the
// newest kept routes, or nil when the table keeps none.
OldestKeptRoute *time.Time
NewestKeptRoute *time.Time
}
// Lookup returns the most specific prefix that holds addr and that some peer
// carries. It ignores prefixes shorter than /8 for IPv4 and /16 for IPv6. It
// makes one map lookup per mask length, and reports false when no prefix
// holds addr.
func (t *Table) Lookup(addr netip.Addr) (PrefixSummary, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
// An IPv4 address written in IPv6 form (::ffff:192.0.2.1) is looked up as
// IPv4.
addr = addr.Unmap()
f := t.familyOf(addr)
for bits := addr.BitLen(); bits >= f.minLookupLength; bits-- {
prefix := netip.PrefixFrom(addr, bits).Masked()
origins, found := f.byLength[bits][prefix]
if found {
return summarize(prefix, origins), true
}
}
return PrefixSummary{}, false
}
// OriginatedPrefixes returns the prefixes AS asn originates, in prefix order.
func (t *Table) OriginatedPrefixes(asn int) []OriginatedPrefix {
t.mu.RLock()
defer t.mu.RUnlock()
prefixes := make([]OriginatedPrefix, 0, len(t.byOrigin[asn]))
for prefix, o := range t.byOrigin[asn] {
prefixes = append(prefixes,
OriginatedPrefix{Prefix: prefix, Newest: o.newestTime()})
}
slices.SortFunc(prefixes, func(a, b OriginatedPrefix) int {
return a.Prefix.Compare(b.Prefix)
})
return prefixes
}
// Origins returns each origin AS of prefix, in AS number order, with its peers
// and its kept routes. It returns nil when no peer carries the prefix.
func (t *Table) Origins(prefix netip.Prefix) []Origin {
t.mu.RLock()
defer t.mu.RUnlock()
if !prefix.IsValid() {
return nil
}
var origins []Origin
for _, o := range t.origins(prefix.Masked()) {
origins = append(origins, Origin{
ASN: o.asn,
Peers: t.addresses(&o.peers),
Routes: t.exportRoutes(o.routes),
})
}
return origins
}
// SamplePrefixes returns up to limit of the prefixes of IP version ipVersion
// (4 or 6) and mask length maskLength. It reads only the prefixes it returns.
// Go starts each walk over a map at a random place, so each call returns
// another sample.
func (t *Table) SamplePrefixes(ipVersion, maskLength, limit int) []PrefixSummary {
t.mu.RLock()
defer t.mu.RUnlock()
var f *family
switch ipVersion {
case ipVersion4:
f = &t.ipv4
case ipVersion6:
f = &t.ipv6
default:
return nil
}
if maskLength < 0 || maskLength >= len(f.byLength) || limit <= 0 {
return nil
}
prefixes := f.byLength[maskLength]
sample := make([]PrefixSummary, 0, min(limit, len(prefixes)))
for prefix, origins := range prefixes {
if len(sample) == limit {
break
}
sample = append(sample, summarize(prefix, origins))
}
return sample
}
// Counts returns the table's figures for the stats. The writes keep them up to
// date, so this reads no prefix.
func (t *Table) Counts() Counts {
t.mu.RLock()
defer t.mu.RUnlock()
counts := Counts{
IPv4PrefixesByLength: t.ipv4.prefixesByLength(),
IPv6PrefixesByLength: t.ipv6.prefixesByLength(),
IPv4Routes: t.ipv4.routes,
IPv6Routes: t.ipv6.routes,
OriginsWithoutKeptRoute: t.originsWithoutKeptRoute,
}
for _, n := range counts.IPv4PrefixesByLength {
counts.IPv4Prefixes += n
}
for _, n := range counts.IPv6PrefixesByLength {
counts.IPv6Prefixes += n
}
oldest, found := t.keptTimes.Min()
if found {
counts.OldestKeptRoute = &oldest.time
}
newest, found := t.keptTimes.Max()
if found {
counts.NewestKeptRoute = &newest.time
}
return counts
}
// addresses returns the addresses of the peers in peers, in number order.
func (t *Table) addresses(peers *bitset.BitSet) []string {
addresses := make([]string, 0, peers.Count())
for number := range peers.EachSet() {
addresses = append(addresses, t.peerAddresses[number])
}
return addresses
}
// summarize returns the summary of prefix, whose origins are in AS number
// order.
func summarize(prefix netip.Prefix, origins []*origin) PrefixSummary {
summary := PrefixSummary{Prefix: prefix}
mostPeers := 0
for _, o := range origins {
peers := int(o.peers.Count())
summary.Peers += peers
if peers > mostPeers {
mostPeers = peers
summary.OriginASN = o.asn
}
newest := o.newestTime()
if newest != nil && (summary.Newest == nil || newest.After(*summary.Newest)) {
summary.Newest = newest
}
}
return summary
}
+259
View File
@@ -0,0 +1,259 @@
package routetable_test
import (
"net/netip"
"reflect"
"testing"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// announceAll has each listed peer announce prefix with origin AS asn, the
// peer at index i i*10 seconds after start.
func announceAll(
table *routetable.Table, prefix string, asn, start int, peers ...string,
) {
const secondsBetweenPeers = 10
for i, peer := range peers {
table.Announce(netip.MustParsePrefix(prefix), asn,
routeOf(peer, asn, start+i*secondsBetweenPeers))
}
}
func TestLookup(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
announceAll(table, "10.0.0.0/8", asA, 10, peerA)
// Three peers with two origins: asB has more of them.
announceAll(table, "10.1.0.0/16", asA, 10, peerA)
announceAll(table, "10.1.0.0/16", asB, 20, peerB, peerC)
// Two origins with one peer each: the lower AS number wins.
announceAll(table, prefixV4, asB, 20, peerB)
announceAll(table, prefixV4, asA, 10, peerA)
// An origin whose kept routes were withdrawn: no newest time.
announceAll(table, "10.9.0.0/16", asA, 10, peerA, peerB, peerC)
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerB)
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerC)
announceAll(table, "2001:db8::/32", asA, 10, peerA)
announceAll(table, prefixV6, asB, 20, peerB)
announceAll(table, "3000::/16", asA, 10, peerA)
// Shorter than the lookup uses.
announceAll(table, "192.0.0.0/7", asA, 10, peerA)
announceAll(table, "2000::/15", asA, 10, peerA)
tests := []struct {
addr string
// want is the summary's prefix, or empty when none is found.
want string
originASN int
peers int
newest int // in seconds after the start; 0 for none
}{
{"10.1.2.3", prefixV4, asA, 2, 20},
{"::ffff:10.1.2.3", prefixV4, asA, 2, 20},
{"10.1.3.4", "10.1.0.0/16", asB, 3, 30},
{"10.2.0.1", "10.0.0.0/8", asA, 1, 10},
{"10.9.0.1", "10.9.0.0/16", asA, 1, 0},
{"192.0.2.1", "", 0, 0, 0},
{"2001:db8:1::5", prefixV6, asB, 1, 20},
{"2001:db8:2::1", "2001:db8::/32", asA, 1, 10},
{"3000::1", "3000::/16", asA, 1, 10},
{"2001:db9::1", "", 0, 0, 0},
}
for _, tc := range tests {
t.Run(tc.addr, func(t *testing.T) {
t.Parallel()
got, found := table.Lookup(netip.MustParseAddr(tc.addr))
if tc.want == "" {
if found {
t.Errorf("Lookup(%s) = %+v, want none", tc.addr, got)
}
return
}
want := routetable.PrefixSummary{
Prefix: netip.MustParsePrefix(tc.want),
OriginASN: tc.originASN,
Peers: tc.peers,
}
if tc.newest != 0 {
want.Newest = timeAt(tc.newest)
}
if !found || !reflect.DeepEqual(got, want) {
t.Errorf("Lookup(%s) = %+v, %v; want %+v", tc.addr, got, found, want)
}
})
}
}
func TestOriginatedPrefixes(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
announceAll(table, "2001:db8::/32", asA, 30, peerA)
announceAll(table, prefixV4, asA, 10, peerA)
// asB's newer route for the same prefix does not count for asA.
announceAll(table, prefixV4, asB, 40, peerB)
announceAll(table, "10.1.0.0/16", asA, 10, peerA, peerB)
announceAll(table, "10.9.0.0/16", asA, 10, peerA, peerB, peerC)
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerB)
table.Withdraw(netip.MustParsePrefix("10.9.0.0/16"), peerC)
want := []routetable.OriginatedPrefix{
{Prefix: netip.MustParsePrefix("10.1.0.0/16"), Newest: timeAt(20)},
{Prefix: netip.MustParsePrefix(prefixV4), Newest: timeAt(10)},
{Prefix: netip.MustParsePrefix("10.9.0.0/16")},
{Prefix: netip.MustParsePrefix("2001:db8::/32"), Newest: timeAt(30)},
}
got := table.OriginatedPrefixes(asA)
if !reflect.DeepEqual(got, want) {
t.Errorf("OriginatedPrefixes(%d) = %+v, want %+v", asA, got, want)
}
got = table.OriginatedPrefixes(asC)
if len(got) != 0 {
t.Errorf("OriginatedPrefixes(%d) = %+v, want none", asC, got)
}
}
func TestSamplePrefixes(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
announced := []string{
"10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24",
"10.0.4.0/24", "10.0.0.0/16",
"2001:db8::/48", "2001:db8:1::/48", "2001:db8:2::/48",
}
for _, prefix := range announced {
announceAll(table, prefix, asA, 10, peerA)
}
tests := []struct {
name string
ipVersion, maskLength, limit int
want int
}{
{"fewer than there are", 4, 24, 3, 3},
{"more than there are", 4, 24, 10, 5},
{"IPv6", 6, 48, 10, 3},
{"a mask length with none", 4, 20, 10, 0},
{"no such IP version", 5, 24, 10, 0},
{"a mask length too long", 4, 33, 10, 0},
{"a negative mask length", 4, -1, 10, 0},
{"a limit of zero", 4, 24, 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sample := table.SamplePrefixes(tc.ipVersion, tc.maskLength, tc.limit)
if len(sample) != tc.want {
t.Fatalf("SamplePrefixes(%d, %d, %d) returned %d prefixes, want %d",
tc.ipVersion, tc.maskLength, tc.limit, len(sample), tc.want)
}
seen := make(map[netip.Prefix]bool)
for _, got := range sample {
want := routetable.PrefixSummary{
Prefix: got.Prefix, OriginASN: asA, Peers: 1, Newest: timeAt(10),
}
if seen[got.Prefix] || got.Prefix.Bits() != tc.maskLength ||
!reflect.DeepEqual(got, want) {
t.Errorf("SamplePrefixes(%d, %d, %d) returned %+v",
tc.ipVersion, tc.maskLength, tc.limit, got)
}
seen[got.Prefix] = true
}
})
}
}
func TestCounts(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
checkCounts(t, table, routetable.Counts{
IPv4PrefixesByLength: byLength(ipv4Bits, nil),
IPv6PrefixesByLength: byLength(ipv6Bits, nil),
})
announceAll(table, prefixV4, asA, 10, peerA, peerB)
announceAll(table, "10.2.0.0/16", asA, 15, peerA)
announceAll(table, "2001:db8::/32", asA, 5, peerA)
announceAll(table, "2001:db8::/32", asA, 25, peerC)
checkCounts(t, table, routetable.Counts{
IPv4Prefixes: 2,
IPv6Prefixes: 1,
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
IPv6PrefixesByLength: byLength(ipv6Bits, map[int]int{32: 1}),
IPv4Routes: 3,
IPv6Routes: 2,
OldestKeptRoute: timeAt(5),
NewestKeptRoute: timeAt(25),
})
// peerC's route drops peerA's from the kept ones, and peerA leaves the
// IPv6 prefix.
announceAll(table, prefixV4, asA, 30, peerC)
table.Withdraw(netip.MustParsePrefix("2001:db8::/32"), peerA)
checkCounts(t, table, routetable.Counts{
IPv4Prefixes: 2,
IPv6Prefixes: 1,
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
IPv6PrefixesByLength: byLength(ipv6Bits, map[int]int{32: 1}),
IPv4Routes: 4,
IPv6Routes: 1,
OldestKeptRoute: timeAt(15),
NewestKeptRoute: timeAt(30),
})
// peerC was the last peer of the IPv6 prefix.
table.PeerDown(peerC)
checkCounts(t, table, routetable.Counts{
IPv4Prefixes: 2,
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
IPv6PrefixesByLength: byLength(ipv6Bits, nil),
IPv4Routes: 3,
OldestKeptRoute: timeAt(15),
NewestKeptRoute: timeAt(20),
})
}
// The address lengths of the two IP versions.
const (
ipv4Bits = 32
ipv6Bits = 128
)
// byLength returns the prefix counts by mask length, for addresses of bits
// bits, with counts[n] prefixes of mask length n and none of any other.
func byLength(bits int, counts map[int]int) []int {
all := make([]int, bits+1)
for n, count := range counts {
all[n] = count
}
return all
}
// checkCounts checks that the table's counts are want.
func checkCounts(t *testing.T, table *routetable.Table, want routetable.Counts) {
t.Helper()
got := table.Counts()
if !reflect.DeepEqual(got, want) {
t.Errorf("Counts() = %+v, want %+v", got, want)
}
}
+578
View File
@@ -0,0 +1,578 @@
// Package routetable holds the whole routing table in memory. For each prefix
// and origin AS, it keeps the peers whose current route to the prefix has that
// origin AS. It also keeps the two newest of those routes in full. It applies
// announcements, withdrawals and peers' sessions going down. It answers the
// route reads that the pages and the API need.
package routetable
import (
"cmp"
"net/netip"
"slices"
"sync"
"time"
"github.com/bits-and-blooms/bitset"
"github.com/google/btree"
)
const (
// keptRoutesPerOrigin is how many routes of each prefix and origin AS are
// kept in full: the newest ones. Every other peer that carries the prefix
// with that origin AS keeps only its bit in the peer set.
keptRoutesPerOrigin = 2
// keptTimesDegree is the degree of the B-tree that holds the times of the
// kept routes in order: each of its nodes holds up to 63 times.
keptTimesDegree = 32
ipv4Bits = 32
ipv6Bits = 128
// The address lookup ignores prefixes shorter than these mask lengths. A
// default route or a route this short only says where a peer sends traffic
// it has no better route for; it does not say who holds the address.
minLookupMaskLengthV4 = 8
minLookupMaskLengthV6 = 16
// The IP versions SamplePrefixes takes.
ipVersion4 = 4
ipVersion6 = 6
)
// Route is one peer's route to one prefix. The next hop is the peer itself, so
// it is not stored.
type Route struct {
// Peer is the peer's address.
Peer string
ASPath []int
// Time is when the peer announced the route.
Time time.Time
}
// Origin is one origin AS of a prefix: the peers whose current route to the
// prefix has that origin AS, and the newest of those routes in full.
type Origin struct {
// ASN is the origin AS. 0 means no known origin: the path ends in an AS
// set of several members.
ASN int
// Peers are the peers' addresses, in the order of their numbers: the
// order they were first seen in.
Peers []string
// Routes are the kept routes, newest first: at most two, and none when
// both were withdrawn while other peers still carry the prefix.
Routes []Route
}
// Change is the current content of one prefix and origin AS pair that changed:
// what a database row for the pair holds.
type Change struct {
Prefix netip.Prefix
OriginASN int
// Removed says that no peer carries the prefix with this origin AS any
// more. Peers and Routes are then nil.
Removed bool
// Peers holds the number of each peer that carries the prefix with this
// origin AS.
Peers *bitset.BitSet
// Routes are the kept routes, newest first.
Routes []Route
}
// Params configures New.
type Params struct {
// PeerNumbers holds the number each peer was given before, by address. A
// peer not in it gets the next number above all of them the first time it
// announces a route.
PeerNumbers map[string]uint
}
// Table is the routing table. Its methods are safe for concurrent use.
type Table struct {
mu sync.RWMutex
ipv4 family
ipv6 family
// byOrigin holds, for each origin AS, the prefixes it originates.
byOrigin map[int]map[netip.Prefix]*origin
// peerNumbers and peerAddresses give each peer's number by its address
// and its address by its number. nextPeerNumber is the number the next
// new peer gets.
peerNumbers map[string]uint
peerAddresses map[uint]string
nextPeerNumber uint
// peerPrefixes counts the prefixes each peer carries, by its number.
peerPrefixes map[uint]int
// newPeers and changed are what TakeChanges hands over next: the peers
// numbered, and the prefix and origin AS pairs changed, since it last ran.
newPeers map[string]uint
changed map[pair]struct{}
// keptTimes counts the kept routes at each time, in time order, so its
// first and last items are the oldest and newest kept routes.
keptTimes *btree.BTreeG[keptTime]
// originsWithoutKeptRoute counts the origins that peers carry but that
// keep no route.
originsWithoutKeptRoute int
}
// New returns an empty table that numbers new peers on from
// params.PeerNumbers.
func New(params Params) *Table {
t := &Table{
ipv4: newFamily(ipv4Bits, minLookupMaskLengthV4),
ipv6: newFamily(ipv6Bits, minLookupMaskLengthV6),
byOrigin: make(map[int]map[netip.Prefix]*origin),
peerNumbers: make(map[string]uint, len(params.PeerNumbers)),
peerAddresses: make(map[uint]string, len(params.PeerNumbers)),
peerPrefixes: make(map[uint]int),
newPeers: make(map[string]uint),
changed: make(map[pair]struct{}),
keptTimes: btree.NewG(keptTimesDegree, func(a, b keptTime) bool {
return a.time.Before(b.time)
}),
}
for address, number := range params.PeerNumbers {
t.peerNumbers[address] = number
t.peerAddresses[number] = address
t.nextPeerNumber = max(t.nextPeerNumber, number+1)
}
return t
}
// Announce applies an announcement: the peer's current route to prefix is now
// r, with origin AS originASN. A peer that carried the prefix with another
// origin AS leaves that origin's peer set, and its route there is dropped. r
// is kept in full when it is one of the two newest routes of its prefix and
// origin AS; otherwise only the peer's bit is.
//
// An announcement whose path is empty or holds AS 0 is a withdrawal, and the
// caller passes it to Withdraw instead.
func (t *Table) Announce(prefix netip.Prefix, originASN int, r Route) {
t.mu.Lock()
defer t.mu.Unlock()
if !prefix.IsValid() {
return
}
prefix = prefix.Masked()
peer := t.numberPeer(r.Peer)
current := originWithPeer(t.origins(prefix), peer)
if current != nil && current.asn != originASN {
t.leave(prefix, current, peer)
}
t.join(prefix, t.findOrAddOrigin(prefix, originASN), route{
peer: peer,
path: slices.Clone(r.ASPath),
time: r.Time,
})
}
// Withdraw applies a withdrawal: the peer no longer carries prefix. It leaves
// the prefix's peer set, and its kept route, if any, is dropped.
func (t *Table) Withdraw(prefix netip.Prefix, peer string) {
t.mu.Lock()
defer t.mu.Unlock()
number, known := t.peerNumbers[peer]
if !known || !prefix.IsValid() {
return
}
prefix = prefix.Masked()
o := originWithPeer(t.origins(prefix), number)
if o != nil {
t.leave(prefix, o, number)
}
}
// PeerDown applies a peer's session going down: the peer leaves every peer
// set, and every route of it is dropped. When the peer carries any prefix, it
// visits every prefix in the table.
func (t *Table) PeerDown(peer string) {
t.mu.Lock()
defer t.mu.Unlock()
number, known := t.peerNumbers[peer]
if !known || t.peerPrefixes[number] == 0 {
return
}
for _, f := range []*family{&t.ipv4, &t.ipv6} {
for _, prefixes := range f.byLength {
for prefix, origins := range prefixes {
o := originWithPeer(origins, number)
if o != nil {
t.leave(prefix, o, number)
}
}
}
}
}
// TakeChanges returns the prefix and origin AS pairs that changed since it last
// ran, in prefix order, each with its current content. It also returns the
// peers numbered since then, by address. It then clears both records.
func (t *Table) TakeChanges() ([]Change, map[string]uint) {
t.mu.Lock()
defer t.mu.Unlock()
changes := make([]Change, 0, len(t.changed))
for key := range t.changed {
changes = append(changes, t.currentContent(key))
}
slices.SortFunc(changes, func(a, b Change) int {
return cmp.Or(a.Prefix.Compare(b.Prefix),
cmp.Compare(a.OriginASN, b.OriginASN))
})
newPeers := t.newPeers
t.newPeers = make(map[string]uint)
t.changed = make(map[pair]struct{})
return changes, newPeers
}
// numberPeer returns the peer's number, and gives it the next one the first
// time the table sees it.
func (t *Table) numberPeer(address string) uint {
number, known := t.peerNumbers[address]
if known {
return number
}
number = t.nextPeerNumber
t.nextPeerNumber++
t.peerNumbers[address] = number
t.peerAddresses[number] = address
t.newPeers[address] = number
return number
}
// familyOf returns the part of the table for addr's IP version.
func (t *Table) familyOf(addr netip.Addr) *family {
if addr.Is4() {
return &t.ipv4
}
return &t.ipv6
}
// prefixesOfLength returns the prefixes of the IP version and the mask length
// of prefix, a valid masked prefix.
func (t *Table) prefixesOfLength(prefix netip.Prefix) map[netip.Prefix][]*origin {
return t.familyOf(prefix.Addr()).byLength[prefix.Bits()]
}
// origins returns the origins of prefix, a valid masked prefix.
func (t *Table) origins(prefix netip.Prefix) []*origin {
return t.prefixesOfLength(prefix)[prefix]
}
// findOrAddOrigin returns the origin of prefix with AS asn, added with no
// peers when the prefix has none with that AS.
func (t *Table) findOrAddOrigin(prefix netip.Prefix, asn int) *origin {
prefixes := t.prefixesOfLength(prefix)
origins := prefixes[prefix]
i, found := slices.BinarySearchFunc(origins, asn,
func(o *origin, asn int) int { return cmp.Compare(o.asn, asn) })
if found {
return origins[i]
}
o := &origin{asn: asn}
prefixes[prefix] = slices.Insert(origins, i, o)
if t.byOrigin[asn] == nil {
t.byOrigin[asn] = make(map[netip.Prefix]*origin)
}
t.byOrigin[asn][prefix] = o
return o
}
// removeOrigin removes o, which no peer carries any more, from prefix, and the
// prefix from the table when it has no origin left.
func (t *Table) removeOrigin(prefix netip.Prefix, o *origin) {
prefixes := t.prefixesOfLength(prefix)
origins := slices.DeleteFunc(prefixes[prefix],
func(other *origin) bool { return other == o })
if len(origins) == 0 {
delete(prefixes, prefix)
} else {
prefixes[prefix] = origins
}
delete(t.byOrigin[o.asn], prefix)
if len(t.byOrigin[o.asn]) == 0 {
delete(t.byOrigin, o.asn)
}
}
// join puts the peer of r in the peer set of o, an origin of prefix, and keeps
// r in full when it is one of the two newest routes of o. The peer's earlier
// route, if o keeps one, is replaced: it is no longer the peer's current route.
// The pair is recorded as changed only when its peer set or kept routes change.
func (t *Table) join(prefix netip.Prefix, o *origin, r route) {
if slices.ContainsFunc(o.routes, r.equal) {
// The peer announced its kept route again.
return
}
wasWithoutKeptRoute := o.withoutKeptRoute()
joined := !o.peers.Test(r.peer)
if joined {
o.peers.Set(r.peer)
t.familyOf(prefix.Addr()).routes++
t.peerPrefixes[r.peer]++
}
dropped := t.dropKeptRoute(o, r.peer)
kept := t.keepIfNewest(o, r)
if joined || dropped || kept {
t.recordChange(prefix, o, wasWithoutKeptRoute)
}
}
// leave takes peer out of the peer set of o, an origin of prefix, and drops
// its kept route, if any.
func (t *Table) leave(prefix netip.Prefix, o *origin, peer uint) {
wasWithoutKeptRoute := o.withoutKeptRoute()
o.peers.Clear(peer)
t.dropKeptRoute(o, peer)
t.familyOf(prefix.Addr()).routes--
t.peerPrefixes[peer]--
t.recordChange(prefix, o, wasWithoutKeptRoute)
}
// keepIfNewest keeps r in o unless both kept routes of o are newer, and says
// whether it kept r. When o already keeps two routes, the older one is
// dropped, and its peer keeps only its bit.
func (t *Table) keepIfNewest(o *origin, r route) bool {
// The kept routes stay newest first: r goes before the first one that is
// not newer than it.
i := 0
for i < len(o.routes) && o.routes[i].time.After(r.time) {
i++
}
if i == keptRoutesPerOrigin {
return false
}
if len(o.routes) == keptRoutesPerOrigin {
t.dropKeptRoute(o, o.routes[keptRoutesPerOrigin-1].peer)
}
o.routes = slices.Insert(o.routes, i, r)
t.countKeptTime(r.time, 1)
return true
}
// dropKeptRoute drops the peer's route from the kept routes of o, if o keeps
// one, and says whether it did.
func (t *Table) dropKeptRoute(o *origin, peer uint) bool {
i := slices.IndexFunc(o.routes, func(r route) bool { return r.peer == peer })
if i < 0 {
return false
}
t.countKeptTime(o.routes[i].time, -1)
o.routes = slices.Delete(o.routes, i, i+1)
return true
}
// recordChange records that the pair of prefix and the AS of o changed, and
// brings the count of origins without a kept route up to date.
// wasWithoutKeptRoute says whether o was such an origin before the change. An
// origin that no peer carries any more is removed.
func (t *Table) recordChange(
prefix netip.Prefix, o *origin, wasWithoutKeptRoute bool,
) {
t.changed[pair{prefix: prefix, asn: o.asn}] = struct{}{}
if wasWithoutKeptRoute {
t.originsWithoutKeptRoute--
}
if o.withoutKeptRoute() {
t.originsWithoutKeptRoute++
}
if o.peers.None() {
t.removeOrigin(prefix, o)
}
}
// countKeptTime adds delta to the number of kept routes with time at.
func (t *Table) countKeptTime(at time.Time, delta int) {
count, _ := t.keptTimes.Get(keptTime{time: at})
count.time = at
count.routes += delta
if count.routes == 0 {
t.keptTimes.Delete(count)
return
}
t.keptTimes.ReplaceOrInsert(count)
}
// currentContent returns what the table holds for the pair now.
func (t *Table) currentContent(key pair) Change {
o := t.byOrigin[key.asn][key.prefix]
if o == nil {
return Change{Prefix: key.prefix, OriginASN: key.asn, Removed: true}
}
return Change{
Prefix: key.prefix,
OriginASN: key.asn,
Peers: o.peers.Clone(),
Routes: t.exportRoutes(o.routes),
}
}
// exportRoutes returns copies of routes that name each peer by its address, or
// nil when there are none.
func (t *Table) exportRoutes(routes []route) []Route {
if len(routes) == 0 {
return nil
}
exported := make([]Route, 0, len(routes))
for _, r := range routes {
exported = append(exported, Route{
Peer: t.peerAddresses[r.peer],
ASPath: slices.Clone(r.path),
Time: r.time,
})
}
return exported
}
// family is the part of the table for one IP version.
type family struct {
// byLength[n] holds the prefixes of mask length n that some peer carries,
// each with its origins in AS number order.
byLength []map[netip.Prefix][]*origin
// minLookupLength is the shortest mask length the address lookup uses.
minLookupLength int
// routes counts the peer and prefix pairs: each peer that carries a
// prefix counts once for it.
routes int
}
// newFamily returns an empty family for addresses of bits bits.
func newFamily(bits, minLookupLength int) family {
f := family{
byLength: make([]map[netip.Prefix][]*origin, bits+1),
minLookupLength: minLookupLength,
}
for n := range f.byLength {
f.byLength[n] = make(map[netip.Prefix][]*origin)
}
return f
}
// prefixesByLength returns how many prefixes of each mask length f holds.
func (f *family) prefixesByLength() []int {
counts := make([]int, len(f.byLength))
for n, prefixes := range f.byLength {
counts[n] = len(prefixes)
}
return counts
}
// origin is what the table holds for one prefix and origin AS.
type origin struct {
asn int
// peers holds the number of each peer whose current route to the prefix
// has this origin AS.
peers bitset.BitSet
// routes are the newest of those routes, newest first, at most
// keptRoutesPerOrigin.
routes []route
}
// withoutKeptRoute says whether peers carry o but o keeps no route.
func (o *origin) withoutKeptRoute() bool {
return o.peers.Any() && len(o.routes) == 0
}
// newestTime returns the time of the newest kept route of o, or nil when o
// keeps none.
func (o *origin) newestTime() *time.Time {
if len(o.routes) == 0 {
return nil
}
newest := o.routes[0].time
return &newest
}
// originWithPeer returns the origin whose peer set holds peer, or nil when
// none does. A peer carries a prefix with one origin AS at most.
func originWithPeer(origins []*origin, peer uint) *origin {
for _, o := range origins {
if o.peers.Test(peer) {
return o
}
}
return nil
}
// route is a kept route. peer is the peer's number.
type route struct {
peer uint
path []int
time time.Time
}
// equal says whether r and other are the same peer's route with the same path
// and time.
func (r route) equal(other route) bool {
return r.peer == other.peer && r.time.Equal(other.time) &&
slices.Equal(r.path, other.path)
}
// pair names one prefix and origin AS.
type pair struct {
prefix netip.Prefix
asn int
}
// keptTime counts the kept routes with one time.
type keptTime struct {
time time.Time
routes int
}
+543
View File
@@ -0,0 +1,543 @@
package routetable_test
import (
"net/netip"
"reflect"
"slices"
"sync"
"testing"
"time"
"github.com/bits-and-blooms/bitset"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const (
peerA = "192.0.2.1"
peerB = "192.0.2.2"
peerC = "2001:db8::3"
asA = 64500
asB = 64501
asC = 64502
// transitAS is the first AS of every test path, the peer's own.
transitAS = 64496
prefixV4 = "10.1.2.0/24"
prefixV6 = "2001:db8:1::/48"
)
// at returns the time second seconds after the start of every test.
func at(second int) time.Time {
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
Add(time.Duration(second) * time.Second)
}
// timeAt returns at(second) for a field that may be nil.
func timeAt(second int) *time.Time {
t := at(second)
return &t
}
// routeOf returns the route peer announces with origin AS asn second seconds
// after the start.
func routeOf(peer string, asn, second int) routetable.Route {
return routetable.Route{
Peer: peer, ASPath: []int{transitAS, asn}, Time: at(second),
}
}
// step is one update a test applies to a table for one prefix.
type step func(table *routetable.Table, prefix netip.Prefix)
// announce is the step of peer announcing prefix with origin AS asn second
// seconds after the start.
func announce(peer string, asn, second int) step {
return func(table *routetable.Table, prefix netip.Prefix) {
table.Announce(prefix, asn, routeOf(peer, asn, second))
}
}
// withdraw is the step of peer withdrawing prefix.
func withdraw(peer string) step {
return func(table *routetable.Table, prefix netip.Prefix) {
table.Withdraw(prefix, peer)
}
}
// peerDown is the step of peer's session going down.
func peerDown(peer string) step {
return func(table *routetable.Table, _ netip.Prefix) {
table.PeerDown(peer)
}
}
// rule is one case of the rule that decides what the table holds for a prefix:
// the steps applied to an empty table, and the prefix's origins after them.
// The peers are numbered in the order they first announce, which is the order
// Origin.Peers lists them in.
type rule struct {
name string
steps []step
want []routetable.Origin
}
// checkRules runs each case for an IPv4 and an IPv6 prefix.
func checkRules(t *testing.T, rules []rule) {
t.Helper()
for _, tc := range rules {
for _, text := range []string{prefixV4, prefixV6} {
t.Run(tc.name+" "+text, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(text)
table := routetable.New(routetable.Params{})
for _, apply := range tc.steps {
apply(table, prefix)
}
got := table.Origins(prefix)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Origins(%s) = %+v, want %+v", prefix, got, tc.want)
}
})
}
}
}
func TestAnnouncementsKeepTheTwoNewestRoutes(t *testing.T) {
t.Parallel()
checkRules(t, []rule{
{
name: "an announcement is kept",
steps: []step{announce(peerA, asA, 10)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
{
name: "a lagging peer's older route is not kept",
steps: []step{announce(peerA, asA, 20), announce(peerB, asA, 30),
announce(peerC, asA, 10)},
want: []routetable.Origin{{ASN: asA,
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
routeOf(peerB, asA, 30), routeOf(peerA, asA, 20),
}}},
},
{
name: "a third newer route drops the older kept one, whose peer " +
"keeps its bit",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 30)},
want: []routetable.Origin{{ASN: asA,
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
}}},
},
{
name: "a route as old as the older kept one replaces it",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 10)},
want: []routetable.Origin{{ASN: asA,
Peers: []string{peerA, peerB, peerC}, Routes: []routetable.Route{
routeOf(peerB, asA, 20), routeOf(peerC, asA, 10),
}}},
},
{
name: "a peer's new route replaces its kept one",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerA, asA, 30)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
Routes: []routetable.Route{
routeOf(peerA, asA, 30), routeOf(peerB, asA, 20),
}}},
},
{
name: "a peer's new route replaces its kept one even when older",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerB, asA, 5)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
Routes: []routetable.Route{
routeOf(peerA, asA, 10), routeOf(peerB, asA, 5),
}}},
},
})
}
func TestAPeerCarriesAPrefixWithOneOrigin(t *testing.T) {
t.Parallel()
checkRules(t, []rule{
{
name: "a peer moving to a new origin leaves the old one",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerA, asB, 30)},
want: []routetable.Origin{
{ASN: asA, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asA, 20)}},
{ASN: asB, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asB, 30)}},
},
},
{
name: "a peer whose route is not kept moves to a new origin",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 30), announce(peerA, asB, 40)},
want: []routetable.Origin{
{ASN: asA, Peers: []string{peerB, peerC},
Routes: []routetable.Route{
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
}},
{ASN: asB, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asB, 40)}},
},
},
{
name: "origins are listed in AS number order",
steps: []step{announce(peerA, asC, 10), announce(peerB, asA, 20),
announce(peerC, asB, 30)},
want: []routetable.Origin{
{ASN: asA, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asA, 20)}},
{ASN: asB, Peers: []string{peerC},
Routes: []routetable.Route{routeOf(peerC, asB, 30)}},
{ASN: asC, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asC, 10)}},
},
},
})
}
func TestWithdrawalsAndPeerDowns(t *testing.T) {
t.Parallel()
// threePeers is peerA, peerB and peerC announcing in turn; the routes of
// peerC and peerB are kept.
threePeers := []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
announce(peerC, asA, 30)}
checkRules(t, []rule{
{
name: "a withdrawal drops the peer's kept route",
steps: append(slices.Clone(threePeers), withdraw(peerC)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA, peerB},
Routes: []routetable.Route{routeOf(peerB, asA, 20)}}},
},
{
name: "a withdrawal of a route that is not kept leaves the kept ones",
steps: append(slices.Clone(threePeers), withdraw(peerA)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerB, peerC},
Routes: []routetable.Route{
routeOf(peerC, asA, 30), routeOf(peerB, asA, 20),
}}},
},
{
name: "an origin whose kept routes are withdrawn keeps its other " +
"peers",
steps: append(slices.Clone(threePeers), withdraw(peerB),
withdraw(peerC)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA}}},
},
{
name: "an origin without kept routes keeps the next update",
steps: append(slices.Clone(threePeers), withdraw(peerB),
withdraw(peerC), announce(peerA, asA, 5)),
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 5)}}},
},
{
name: "an origin no peer carries is removed",
steps: []step{announce(peerA, asA, 10), announce(peerB, asB, 20),
withdraw(peerA)},
want: []routetable.Origin{{ASN: asB, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asB, 20)}}},
},
{
name: "a prefix no peer carries is removed",
steps: []step{announce(peerA, asA, 10), withdraw(peerA)},
want: nil,
},
{
name: "a withdrawal from a peer that does not carry it changes nothing",
steps: []step{announce(peerA, asA, 10), withdraw(peerB)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
{
name: "a peer going down leaves the prefix",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
peerDown(peerB)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
{
name: "a peer going down after it withdrew changes nothing",
steps: []step{announce(peerA, asA, 10), announce(peerB, asA, 20),
withdraw(peerB), peerDown(peerB)},
want: []routetable.Origin{{ASN: asA, Peers: []string{peerA},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}}},
},
})
}
func TestPeerDownLeavesEveryPrefix(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
prefixes := []netip.Prefix{
netip.MustParsePrefix(prefixV4),
netip.MustParsePrefix("10.2.0.0/16"),
netip.MustParsePrefix(prefixV6),
}
// peerA carries every prefix with origin asA, peerB the first one with
// origin asB.
for _, prefix := range prefixes {
table.Announce(prefix, asA, routeOf(peerA, asA, 10))
}
table.Announce(prefixes[0], asB, routeOf(peerB, asB, 20))
table.PeerDown(peerA)
want := []routetable.Origin{{ASN: asB, Peers: []string{peerB},
Routes: []routetable.Route{routeOf(peerB, asB, 20)}}}
got := table.Origins(prefixes[0])
if !reflect.DeepEqual(got, want) {
t.Errorf("Origins(%s) = %+v, want %+v", prefixes[0], got, want)
}
for _, prefix := range prefixes[1:] {
got := table.Origins(prefix)
if got != nil {
t.Errorf("Origins(%s) = %+v after the only peer went down, want nil",
prefix, got)
}
}
counts := table.Counts()
if counts.IPv4Prefixes != 1 || counts.IPv6Prefixes != 0 ||
counts.IPv4Routes != 1 || counts.IPv6Routes != 0 {
t.Errorf("Counts() = %+v, want one IPv4 prefix and route", counts)
}
if len(table.OriginatedPrefixes(asA)) != 0 {
t.Errorf("OriginatedPrefixes(%d) = %+v, want none",
asA, table.OriginatedPrefixes(asA))
}
}
func TestCountsOriginsWithoutKeptRoute(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(prefixV4)
table := routetable.New(routetable.Params{})
steps := []struct {
apply step
want int
}{
{announce(peerA, asA, 10), 0},
{announce(peerB, asA, 20), 0},
{announce(peerC, asA, 30), 0},
{withdraw(peerC), 0},
{withdraw(peerB), 1},
// An update from the last peer is kept.
{announce(peerA, asA, 40), 0},
{withdraw(peerA), 0},
// Once more without kept routes, then without peers: the origin
// is removed.
{announce(peerA, asA, 10), 0},
{announce(peerB, asA, 20), 0},
{announce(peerC, asA, 30), 0},
{withdraw(peerB), 0},
{withdraw(peerC), 1},
{peerDown(peerA), 0},
}
for i, s := range steps {
s.apply(table, prefix)
got := table.Counts().OriginsWithoutKeptRoute
if got != s.want {
t.Errorf("after step %d, OriginsWithoutKeptRoute = %d, want %d",
i, got, s.want)
}
}
}
// change is a routetable.Change with its peer set as a list of peer numbers.
type change struct {
Prefix string
OriginASN int
Removed bool
Peers []uint
Routes []routetable.Route
}
// peerNumbers returns the numbers in set, or nil for a nil set.
func peerNumbers(set *bitset.BitSet) []uint {
if set == nil {
return nil
}
return slices.Collect(set.EachSet())
}
func TestTakeChanges(t *testing.T) {
t.Parallel()
// peerA was numbered 5 before; the next new peer is 6.
table := routetable.New(routetable.Params{
PeerNumbers: map[string]uint{peerA: 5},
})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
table.Announce(v6, asA, routeOf(peerA, asA, 10))
table.Announce(v4, asA, routeOf(peerA, asA, 10))
table.Announce(v4, asA, routeOf(peerB, asA, 20))
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{5, 6},
Routes: []routetable.Route{
routeOf(peerB, asA, 20), routeOf(peerA, asA, 10),
}},
{Prefix: prefixV6, OriginASN: asA, Peers: []uint{5},
Routes: []routetable.Route{routeOf(peerA, asA, 10)}},
}, map[string]uint{peerB: 6})
// TakeChanges cleared the record.
checkChanges(t, table, []change{}, map[string]uint{})
// peerB moves to asB; peerA withdraws, which removes the pair of v4 and
// asA.
table.Announce(v4, asB, routeOf(peerB, asB, 30))
table.Withdraw(v4, peerA)
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Removed: true},
{Prefix: prefixV4, OriginASN: asB, Peers: []uint{6},
Routes: []routetable.Route{routeOf(peerB, asB, 30)}},
}, map[string]uint{})
}
func TestTakeChangesHoldsOnlyPairsThatChanged(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
// peerA, peerB and peerC are numbered 0, 1 and 2. The routes of peerC and
// peerB to v4 are kept; peerA keeps only its bit.
announceAll(table, prefixV4, asA, 10, peerA, peerB, peerC)
table.Announce(v6, asA, routeOf(peerB, asA, 10))
table.TakeChanges()
// peerA announces again, with a time older than both kept routes.
table.Announce(v4, asA, routeOf(peerA, asA, 15))
checkChanges(t, table, []change{}, map[string]uint{})
// peerC announces its kept route again.
table.Announce(v4, asA, routeOf(peerC, asA, 30))
checkChanges(t, table, []change{}, map[string]uint{})
// peerC announces the same path with a newer time, so its kept route
// changes.
table.Announce(v4, asA, routeOf(peerC, asA, 40))
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{0, 1, 2},
Routes: []routetable.Route{
routeOf(peerC, asA, 40), routeOf(peerB, asA, 20),
}},
}, map[string]uint{})
// peerA withdraws v6, which it does not carry.
table.Withdraw(v6, peerA)
checkChanges(t, table, []change{}, map[string]uint{})
// peerA going down changes v4, the one prefix it carries.
table.PeerDown(peerA)
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Peers: []uint{1, 2},
Routes: []routetable.Route{
routeOf(peerC, asA, 40), routeOf(peerB, asA, 20),
}},
}, map[string]uint{})
// peerA, which carries nothing now, goes down again.
table.PeerDown(peerA)
checkChanges(t, table, []change{}, map[string]uint{})
}
// TestConcurrentUse has goroutines update one table while others read it. The
// race detector, which make test runs, fails it when a method uses the table
// without its lock.
func TestConcurrentUse(t *testing.T) {
t.Parallel()
const (
// rounds is how many times each goroutine calls its method.
rounds = 1000
ipVersion = 4
sampleSize = 10
)
table := routetable.New(routetable.Params{})
prefix := netip.MustParsePrefix(prefixV4)
uses := []func(round int){
func(round int) { table.Announce(prefix, asA, routeOf(peerA, asA, round)) },
func(round int) { table.Announce(prefix, asB, routeOf(peerB, asB, round)) },
func(int) { table.Withdraw(prefix, peerA) },
func(int) { table.PeerDown(peerB) },
func(int) { table.TakeChanges() },
func(int) { table.Lookup(prefix.Addr()) },
func(int) { table.Origins(prefix) },
func(int) { table.OriginatedPrefixes(asA) },
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
func(int) { table.Counts() },
}
var wg sync.WaitGroup
for _, use := range uses {
wg.Go(func() {
for round := range rounds {
use(round)
}
})
}
wg.Wait()
}
// checkChanges checks that TakeChanges returns want and wantPeers.
func checkChanges(
t *testing.T, table *routetable.Table, want []change,
wantPeers map[string]uint,
) {
t.Helper()
changes, newPeers := table.TakeChanges()
got := make([]change, 0, len(changes))
for _, c := range changes {
got = append(got, change{
Prefix: c.Prefix.String(), OriginASN: c.OriginASN, Removed: c.Removed,
Peers: peerNumbers(c.Peers), Routes: c.Routes,
})
}
if !reflect.DeepEqual(got, want) {
t.Errorf("TakeChanges() changes = %+v, want %+v", got, want)
}
if !reflect.DeepEqual(newPeers, wantPeers) {
t.Errorf("TakeChanges() new peers = %v, want %v", newPeers, wantPeers)
}
}
+29 -5
View File
@@ -8,9 +8,11 @@ package routewatch
import (
"context"
"errors"
"path/filepath"
"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/logger"
@@ -52,8 +54,13 @@ type RouteWatch struct {
peeringHandler *PeeringHandler
asnFetcher *ASNFetcher
dbMaintainer *DBMaintainer
blocklists *blocklist.Blocklists
}
// blocklistsDirName is the directory in the state directory that holds the
// blocklist copies.
const blocklistsDirName = "blocklists"
// New creates a new RouteWatch instance
func New(deps Dependencies) *RouteWatch {
rw := &RouteWatch{
@@ -122,6 +129,18 @@ 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
// are set before the handlers run.
blocklists := blocklist.New(blocklist.Params{
Dir: filepath.Join(rw.config.StateDir, blocklistsDirName),
Lists: blocklist.Lists(),
Logger: rw.logger.Logger,
})
blocklists.Start(ctx)
rw.blocklists = blocklists
rw.server.SetBlocklists(blocklists)
// Start HTTP server
err = rw.server.Start(ctx)
if err != nil {
@@ -139,11 +158,12 @@ 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 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 running.
// write what they hold, the WHOIS fetcher, the database maintainer, the
// blocklist 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
// running.
func (rw *RouteWatch) Shutdown(ctx context.Context) {
rw.mu.Lock()
if rw.shutdown {
@@ -190,6 +210,10 @@ func (rw *RouteWatch) Shutdown(ctx context.Context) {
rw.dbMaintainer.Stop(ctx)
}
if rw.blocklists != nil {
rw.blocklists.Stop(ctx)
}
err := rw.server.Stop(ctx)
if err != nil {
rw.logger.Error("Stopped the HTTP server before every request finished",
@@ -466,9 +466,9 @@ func TestRouteWatchLiveFeed(t *testing.T) {
// Create streamer
s := streamer.New(logger, metricsTracker)
// Create test config with empty state dir (no snapshot loading)
// The state directory holds the blocklist copies Run downloads.
cfg := &config.Config{
StateDir: "",
StateDir: t.TempDir(),
MaxRuntime: 5 * time.Second,
EnableBatchedDatabaseWrites: true,
}
+4 -2
View File
@@ -68,10 +68,12 @@ type ASNFetcher struct {
// NewASNFetcher creates a new ASN fetcher.
func NewASNFetcher(db database.Store, logger *slog.Logger) *ASNFetcher {
logger = logger.With("component", "asn_fetcher")
return &ASNFetcher{
db: db,
whoisClient: whois.NewClient(),
logger: logger.With("component", "asn_fetcher"),
whoisClient: whois.NewClient(logger),
logger: logger,
immediateQueue: make(chan int, immediateQueueSize),
stopCh: make(chan struct{}),
done: make(chan struct{}),
+106 -47
View File
@@ -1,7 +1,7 @@
package server
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
@@ -9,11 +9,13 @@ import (
"net/http"
"net/netip"
"runtime"
"slices"
"sort"
"strconv"
"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"
@@ -44,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 (
@@ -145,7 +156,10 @@ func (s *Server) handleIndex() http.HandlerFunc {
tmpl := templates.IndexTemplate()
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
err := tmpl.Execute(w, indexPageData{
CSPNonce: cspNonce(r.Context()),
ClientIP: extractClientIP(r),
})
if err != nil {
s.logger.Error("Failed to render index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -159,6 +173,15 @@ type pageData struct {
CSPNonce string
}
// indexPageData is what the home page is rendered with.
type indexPageData struct {
// CSPNonce is the nonce the page's script and style blocks carry.
CSPNonce string
// ClientIP is the visitor's address, which the IP information API card
// looks up: the address /ip/ would look up for the same request.
ClientIP string
}
// writeJSONError writes a standardized JSON error response with the given
// status code and error message.
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
@@ -680,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.
@@ -691,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")
@@ -699,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)
@@ -736,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
@@ -755,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
}
}
@@ -769,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.
@@ -786,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 {
@@ -804,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")
@@ -813,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
@@ -1028,24 +1097,15 @@ func splitRoutesByIPVersion(
return ipv4Routes, ipv6Routes
}
// sortRoutesByNetwork sorts routes by network address, then by mask length.
func sortRoutesByNetwork(routes []database.LiveRoute) {
sort.Slice(routes, func(i, j int) bool {
// Parse the prefixes to compare network addresses
ipI, netI, _ := net.ParseCIDR(routes[i].Prefix)
ipJ, netJ, _ := net.ParseCIDR(routes[j].Prefix)
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
// routes with the same mask length by network address.
func sortRoutesByMaskLength(routes []database.LiveRoute) {
slices.SortFunc(routes, func(a, b database.LiveRoute) int {
prefixA, _ := netip.ParsePrefix(a.Prefix)
prefixB, _ := netip.ParsePrefix(b.Prefix)
// Compare by network address first
cmp := bytes.Compare(ipI.To16(), ipJ.To16())
if cmp != 0 {
return cmp < 0
}
// If network addresses are equal, compare by mask length
onesI, _ := netI.Mask.Size()
onesJ, _ := netJ.Mask.Size()
return onesI < onesJ
return cmp.Or(cmp.Compare(prefixA.Bits(), prefixB.Bits()),
prefixA.Addr().Compare(prefixB.Addr()))
})
}
@@ -1212,9 +1272,8 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// Group prefixes by IP version
ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes)
// Sort prefixes by network address
sortRoutesByNetwork(ipv4Prefixes)
sortRoutesByNetwork(ipv6Prefixes)
sortRoutesByMaskLength(ipv4Prefixes)
sortRoutesByMaskLength(ipv6Prefixes)
// Prepare template data
data := struct {
+403 -73
View File
@@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"html"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"runtime"
"slices"
"strconv"
@@ -15,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"
@@ -458,8 +461,8 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
// TestPagesSayOriginZeroIsNoKnownOrigin stores a route with origin 0, as the
// prefix handler stores a route whose AS path ends in an AS set of several
// members, and checks that the prefix page and the prefix length page say it
// has no known origin, without a link to AS0 or the description of the
// reserved AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75).
// has no known origin, without a link to AS0
// (https://git.eeqj.de/sneak/routewatch/issues/75).
func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
t.Parallel()
@@ -479,11 +482,6 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
reserved, ok := asinfo.Get(0)
if !ok {
t.Fatal("AS0 is not in the AS info data")
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
for _, path := range []string{"/prefix/" + testPrefixV4, prefixLengthPagePath} {
@@ -505,9 +503,128 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
if strings.Contains(page, `href="/as/0"`) {
t.Errorf("%s links to AS0", path)
}
}
}
if strings.Contains(page, html.EscapeString(reserved.Description)) {
t.Errorf("%s shows AS0's description %q", path, reserved.Description)
// TestIndexShowsTheClientAddress requests the home page and checks that it has
// the IP information API card, holding the address /ip/ would look up for the
// same request: the TCP peer, or, when the peer is a trusted proxy, the client
// the proxy names in X-Forwarded-For
// (https://git.eeqj.de/sneak/routewatch/issues/156).
func TestIndexShowsTheClientAddress(t *testing.T) {
t.Parallel()
const cardHeading = "<h2>IP Information API</h2>"
// Both requests carry an X-Forwarded-For naming forwardedClient; only a
// trusted proxy's is believed.
tests := []struct {
name string
trustedProxies []netip.Prefix
peer string
want string
}{
{"direct request", nil, untrustedPeer, untrustedPeerAddr},
{
"request through a trusted proxy",
[]netip.Prefix{netip.MustParsePrefix(trustedProxyRange)},
trustedPeer, forwardedClient,
},
}
for _, tt := range tests {
// The home page reads nothing from the database, so the server has none.
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{TrustedProxies: tt.trustedProxies})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.RemoteAddr = tt.peer
req.Header.Set("X-Forwarded-For", forwardedClient)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("%s: status %d, want %d; body %s",
tt.name, rec.Code, http.StatusOK, rec.Body)
continue
}
page := rec.Body.String()
if !strings.Contains(page, cardHeading) {
t.Errorf("%s: the page has no IP information API card", tt.name)
}
want := `data-client-ip="` + tt.want + `"`
if !strings.Contains(page, want) {
t.Errorf("%s: the card does not hold %s", tt.name, want)
}
}
}
// TestPagesShowAgesInWords stores routes last updated about 5 minutes, 3 hours
// and 15 days ago, and checks that the AS page and the prefix pages show each
// age in words. Before the fix the ages read "5m0s ago", "3h0m0s ago" and
// "360h0m0s ago" (https://git.eeqj.de/sneak/routewatch/issues/163).
func TestPagesShowAgesInWords(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Each age is well past a whole number of its unit, so the time the test
// takes to run does not change the words.
ages := []struct {
prefix string
age time.Duration
want string
}{
{testPrefixV4, 5*time.Minute + 30*time.Second, "5 minutes ago"},
{"198.51.100.0/24", 3*time.Hour + 30*time.Minute, "3 hours ago"},
{"203.0.113.0/24", 15 * 24 * time.Hour, "2 weeks ago"},
}
now := time.Now().UTC()
routes := make([]*database.LiveRoute, 0, len(ages))
for _, a := range ages {
routes = append(routes, &database.LiveRoute{
Prefix: a.prefix, MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
NextHop: testPeerV4, LastUpdated: now.Add(-a.age),
})
}
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
for _, a := range ages {
cell := `<td class="age">` + a.want + `</td>`
for _, path := range []string{asPagePath, "/prefix/" + a.prefix} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d, want %d; body %s",
path, rec.Code, http.StatusOK, rec.Body)
}
if !strings.Contains(rec.Body.String(), cell) {
t.Errorf("%s does not show the age of the route for %s as %q",
path, a.prefix, a.want)
}
}
}
}
@@ -925,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
@@ -948,7 +1066,7 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
},
{
path: "/api/v1/ip/203.0.113.1",
wantCode: http.StatusNotFound,
wantCode: http.StatusOK,
},
}
@@ -962,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)
}
}
}
@@ -1050,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},
}
@@ -1074,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 {
@@ -1131,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 (
@@ -1262,7 +1569,7 @@ func TestASSearchAnswersMatches(t *testing.T) {
}
cloudflare := asinfo.Info{
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare, Inc.",
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare Inc.",
}
if !slices.Contains(data.Results, cloudflare) {
t.Errorf("results %v do not hold %v", data.Results, cloudflare)
@@ -1335,8 +1642,9 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
// The AS numbers the AS detail tests ask for. storedASN originates the route
// for testPrefixV4 and is in the database. routeOnlyASN originates the route
// for testPrefixV6, but the database has no record of the AS itself, as when
// a route is stored before its AS. builtInOnlyASN is in the AS list built into
// asinfo but not in the database. unknownASN is in neither.
// a route is stored before its AS. Both are documentation AS numbers, which
// the AS list built into asinfo does not hold. builtInOnlyASN is in that list
// but not in the database. unknownASN is in neither.
const (
storedASN = 64500
routeOnlyASN = 64501
@@ -1352,14 +1660,12 @@ const firstSeenCard = `<div class="info-label">First Seen</div>`
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
// added to the database. It fails the test when the AS list built into asinfo
// lacks routeOnlyASN or builtInOnlyASN, or holds unknownASN.
// lacks builtInOnlyASN or holds unknownASN.
func newServerWithStoredAS(t *testing.T) *Server {
t.Helper()
for _, asn := range []int{routeOnlyASN, builtInOnlyASN} {
if _, ok := asinfo.Get(asn); !ok {
t.Fatalf("AS%d is not in the AS info data", asn)
}
if _, ok := asinfo.Get(builtInOnlyASN); !ok {
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN)
}
if _, ok := asinfo.Get(unknownASN); ok {
@@ -1377,10 +1683,10 @@ func newServerWithStoredAS(t *testing.T) *Server {
}
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for each of the ASes above.
// All but unknownASN answer 200 with the handle and description from the
// built-in AS list and their prefixes, as lists even when empty. Only storedASN
// has a first_seen time. unknownASN answers 404. Before the fix builtInOnlyASN
// and routeOnlyASN answered 404 too
// All but unknownASN answer 200 with the handle and description the built-in
// AS list has for them, if any, and their prefixes, as lists even when empty.
// Only storedASN has a first_seen time. unknownASN answers 404. Before the fix
// builtInOnlyASN and routeOnlyASN answered 404 too
// (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
t.Parallel()
@@ -1434,13 +1740,13 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
continue
}
info, _ := asinfo.Get(tt.asn)
handle, description := asinfo.GetHandle(tt.asn), asinfo.GetDescription(tt.asn)
got := body.Data.ASN
if got.ASN != tt.asn || got.Handle != info.Handle ||
got.Description != info.Description {
if got.ASN != tt.asn || got.Handle != handle ||
got.Description != description {
t.Errorf("%s: AS %d %q %q, want %d %q %q", path, got.ASN,
got.Handle, got.Description, tt.asn, info.Handle, info.Description)
got.Handle, got.Description, tt.asn, handle, description)
}
if body.Data.IPv4Prefixes == nil || body.Data.IPv6Prefixes == nil {
@@ -1467,11 +1773,11 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
}
// TestASDetailPageShowsBuiltInAS asks /as/ for each of the ASes above. All but
// unknownASN answer 200 with the handle and description from the built-in AS
// list. storedASN and routeOnlyASN list their prefixes, and builtInOnlyASN says
// the AS announces none. Only storedASN shows a first-seen date, and no page
// shows a date that was never set. unknownASN answers 404. Before the fix
// builtInOnlyASN and routeOnlyASN answered 404 too
// unknownASN answer 200 with the handle and description the built-in AS list
// has for them, if any. storedASN and routeOnlyASN list their prefixes, and
// builtInOnlyASN says the AS announces none. Only storedASN shows a first-seen
// date, and no page shows a date that was never set. unknownASN answers 404.
// Before the fix builtInOnlyASN and routeOnlyASN answered 404 too
// (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
t.Parallel()
@@ -1507,12 +1813,11 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
continue
}
info, _ := asinfo.Get(tt.asn)
page := rec.Body.String()
for _, want := range []string{
html.EscapeString(info.Handle),
html.EscapeString(info.Description),
html.EscapeString(asinfo.GetHandle(tt.asn)),
html.EscapeString(asinfo.GetDescription(tt.asn)),
tt.wantText,
} {
if !strings.Contains(page, want) {
@@ -1532,19 +1837,68 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
}
}
// TestSortRoutesByMaskLength expects the AS page's prefix lists in mask length
// order, shortest first, and in address order within one mask length. Before
// the fix they were in address order, so 10.1.0.0/16 came before 11.0.0.0/8
// (https://git.eeqj.de/sneak/routewatch/issues/153).
func TestSortRoutesByMaskLength(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prefixes []string
want []string
}{
{
name: "IPv4",
prefixes: []string{
"100.64.1.0/24", "11.0.0.0/8", "10.1.0.0/16",
"20.0.1.0/24", "172.16.0.0/12", "9.0.0.0/8",
},
want: []string{
"9.0.0.0/8", "11.0.0.0/8", "172.16.0.0/12",
"10.1.0.0/16", "20.0.1.0/24", "100.64.1.0/24",
},
},
{
name: "IPv6",
prefixes: []string{
"2001:db8:1::/48", "2001:db8:8000::/33", "2001:db8:0:1::/64",
"2001:db8::/48", "2001:db8::/33",
},
want: []string{
"2001:db8::/33", "2001:db8:8000::/33", "2001:db8::/48",
"2001:db8:1::/48", "2001:db8:0:1::/64",
},
},
}
for _, tt := range tests {
routes := make([]database.LiveRoute, 0, len(tt.prefixes))
for _, prefix := range tt.prefixes {
routes = append(routes, database.LiveRoute{Prefix: prefix})
}
sortRoutesByMaskLength(routes)
got := make([]string, 0, len(routes))
for _, route := range routes {
got = append(got, route.Prefix)
}
if !slices.Equal(got, tt.want) {
t.Errorf("%s: order %v, want %v", tt.name, got, tt.want)
}
}
}
// TestNoOriginHasNoASAnswers stores a route with no known origin, which has
// origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects
// /as/0 and /api/v1/as/0 to answer 404. It expects a search for "iana" to leave
// AS 0 out of its results and its total, although the AS list built into
// asinfo holds AS 0. That search has more matches than an answer holds, so it
// still lists maxASSearchResults of them.
// /as/0 and /api/v1/as/0 to answer 404.
func TestNoOriginHasNoASAnswers(t *testing.T) {
t.Parallel()
const (
noOriginPrefix = "203.0.113.0/24"
query = "iana"
)
const noOriginPrefix = "203.0.113.0/24"
s := newServerWithTestRoutes(t)
@@ -1570,30 +1924,6 @@ func TestNoOriginHasNoASAnswers(t *testing.T) {
path, rec.Code, http.StatusNotFound, rec.Body)
}
}
first, total := asinfo.Search(query, 1)
if len(first) != 1 || first[0].ASN != ristypes.NoOrigin {
t.Fatalf("AS%d is not the first match for %q in the AS info data",
ristypes.NoOrigin, query)
}
data := searchAS(t, s, "q="+query)
for _, info := range data.Results {
if info.ASN == ristypes.NoOrigin {
t.Errorf("the search for %q lists AS%d", query, info.ASN)
}
}
if data.Total != total-1 {
t.Errorf("the search for %q has a total of %d, want %d",
query, data.Total, total-1)
}
if len(data.Results) != maxASSearchResults {
t.Errorf("the search for %q lists %d matches, want %d",
query, len(data.Results), maxASSearchResults)
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the
+5 -4
View File
@@ -11,11 +11,12 @@ import (
)
// The addresses the forwarding tests use: the client a proxy names in
// X-Forwarded-For, a peer inside the trusted range of
// TestRealIPMiddlewareTrustsOnlyConfiguredProxies, and a peer outside it,
// which lies in testPrefixV4 so that an IP lookup of it finds a route.
// X-Forwarded-For, the range of trusted proxies, a peer inside it, and a peer
// outside it, which lies in testPrefixV4 so that an IP lookup of it finds a
// route.
const (
forwardedClient = "203.0.113.9"
trustedProxyRange = "10.0.0.0/8"
trustedPeer = "10.0.0.2:1234"
untrustedPeerAddr = "192.0.2.1"
untrustedPeer = untrustedPeerAddr + ":1234"
@@ -189,7 +190,7 @@ func TestForwardedForFromUntrustedPeerIsIgnored(t *testing.T) {
func TestRealIPMiddlewareTrustsOnlyConfiguredProxies(t *testing.T) {
t.Parallel()
trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
trusted := []netip.Prefix{netip.MustParsePrefix(trustedProxyRange)}
tests := []struct {
name string
+15
View File
@@ -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
}
+28 -8
View File
@@ -126,6 +126,26 @@
margin: 0;
color: #2c3e50;
}
/* A click on a prefix list's heading folds or unfolds the list. Not
every browser shows its own marker on a flex element, so the page
hides it and draws a triangle before the heading. The triangle
points down while the list is open. */
summary.prefix-header {
cursor: pointer;
list-style: none;
}
summary.prefix-header::-webkit-details-marker {
display: none;
}
summary.prefix-header h2::before {
content: "\25B8";
display: inline-block;
margin-right: 10px;
transition: transform 0.2s;
}
details[open] > summary.prefix-header h2::before {
transform: rotate(90deg);
}
.prefix-count {
background: #e74c3c;
color: white;
@@ -242,11 +262,11 @@
</div>
{{if .IPv4Prefixes}}
<div class="prefix-section">
<div class="prefix-header">
<details class="prefix-section" open>
<summary class="prefix-header">
<h2>IPv4 Prefixes</h2>
<span class="prefix-count">{{.IPv4Count}}</span>
</div>
</summary>
<table class="prefix-table">
<thead>
<tr>
@@ -267,15 +287,15 @@
{{end}}
</tbody>
</table>
</div>
</details>
{{end}}
{{if .IPv6Prefixes}}
<div class="prefix-section">
<div class="prefix-header">
<details class="prefix-section" open>
<summary class="prefix-header">
<h2>IPv6 Prefixes</h2>
<span class="prefix-count">{{.IPv6Count}}</span>
</div>
</summary>
<table class="prefix-table">
<thead>
<tr>
@@ -296,7 +316,7 @@
{{end}}
</tbody>
</table>
</div>
</details>
{{end}}
{{if eq .TotalCount 0}}
+99 -7
View File
@@ -107,9 +107,15 @@
.stat-card.connected .stat-value {
color: #27ae60;
}
.stat-card.disconnected .stat-value {
.stat-card.disconnected .stat-value,
.stale {
color: #e74c3c;
}
/* The card has no room beside "Connected", so the spinner and "stale" go under it */
#freshness {
display: block;
font-size: 20px;
}
/* Search section */
.search-section {
@@ -233,7 +239,8 @@
cursor: pointer;
font-size: 12px;
}
.ip-result pre {
.ip-result pre,
#ip-api pre {
background: #2c3e50;
color: #ecf0f1;
padding: 16px;
@@ -245,18 +252,30 @@
max-height: 400px;
overflow-y: auto;
}
.ip-result .error {
.ip-result .error,
#ip-api .error {
background: #fee;
color: #c00;
padding: 12px;
border-radius: 6px;
font-size: 14px;
}
.ip-result .loading {
.ip-result .loading,
#ip-api .loading {
color: #7f8c8d;
font-style: italic;
}
/* IP information API card: the command, then its answer */
#ip-api p {
margin: 0;
color: #7f8c8d;
font-size: 14px;
}
#ip-api pre {
margin-top: 12px;
}
/* Footer */
.footer {
margin-top: 40px;
@@ -297,7 +316,10 @@
<main class="main-content">
<div class="stats-grid">
<div class="stat-card" id="status-card">
<div class="stat-value" id="stat-status">-</div>
<div class="stat-value">
<span id="stat-status">-</span>
<span id="freshness"></span>
</div>
<div class="stat-label">Status</div>
</div>
<div class="stat-card">
@@ -358,6 +380,13 @@
</div>
</div>
</div>
<div class="search-card" id="ip-api">
<h2>IP Information API</h2>
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
<pre id="ip-api-command" data-client-ip="{{.ClientIP}}"></pre>
<pre id="ip-api-answer" class="loading">Loading...</pre>
</div>
</main>
<footer class="footer">
@@ -369,6 +398,44 @@
</footer>
<script nonce="{{.CSPNonce}}">
// The stats are fetched every REFRESH_PERIOD_MS. Next to the status, a
// spinner turns one frame each time an answer counts more messages
// than the one before. After STALE_AFTER_REFRESHES refreshes without
// such an answer, the spinner gives way to "stale" and the time since.
const REFRESH_PERIOD_MS = 5000;
const STALE_AFTER_REFRESHES = 3;
const MS_PER_SECOND = 1000;
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
let spinnerFrame = 0;
let lastMessageCount = null;
let lastFreshAt = Date.now();
// Turn the spinner when the message count has risen since the last answer.
function noteMessageCount(count) {
if (lastMessageCount !== null && count > lastMessageCount) {
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
lastFreshAt = Date.now();
}
lastMessageCount = count;
showFreshness();
}
function showFreshness() {
const freshness = document.getElementById('freshness');
const sinceFreshMs = Date.now() - lastFreshAt;
const stale = sinceFreshMs >= STALE_AFTER_REFRESHES * REFRESH_PERIOD_MS;
freshness.classList.toggle('stale', stale);
if (stale) {
freshness.textContent = 'stale for ' + Math.floor(sinceFreshMs / MS_PER_SECOND) + 's';
freshness.style.color = '';
return;
}
freshness.textContent = SPINNER_FRAMES[spinnerFrame];
freshness.style.color = SPINNER_COLOURS[spinnerFrame % SPINNER_COLOURS.length];
}
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
@@ -390,6 +457,7 @@
const statusEl = document.getElementById('stat-status');
statusEl.textContent = data.connected ? 'Connected' : 'Disconnected';
statusCard.className = 'stat-card ' + (data.connected ? 'connected' : 'disconnected');
noteMessageCount(data.total_messages);
document.getElementById('stat-routes').textContent = formatNumber(data.live_routes);
document.getElementById('stat-asns').textContent = formatNumber(data.asns);
@@ -536,9 +604,33 @@
document.getElementById('ip-input').value = '';
});
// Initial load and refresh stats every 5 seconds
// 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
// JSON it answers, an error answer included. The answer holds outside
// data, so it is set with textContent, never innerHTML.
const ipAPICommand = document.getElementById('ip-api-command');
const ipAPIAnswer = document.getElementById('ip-api-answer');
const ipAPIURL = window.location.origin + '/ip/' + ipAPICommand.dataset.clientIp;
ipAPICommand.textContent = 'curl ' + ipAPIURL;
fetch(ipAPIURL)
.then(response => response.json())
.then(answer => {
ipAPIAnswer.className = '';
ipAPIAnswer.textContent = JSON.stringify(answer, null, 2);
})
.catch(error => {
ipAPIAnswer.className = 'error';
ipAPIAnswer.textContent = 'Error: ' + error.message;
});
// Initial load and refresh stats every REFRESH_PERIOD_MS. The freshness
// check runs every second on its own, so a request that never returns
// still turns the spinner into "stale".
updateStats();
setInterval(updateStats, 5000);
setInterval(updateStats, REFRESH_PERIOD_MS);
setInterval(showFreshness, MS_PER_SECOND);
</script>
</body>
</html>
+51 -5
View File
@@ -126,7 +126,8 @@
.connected {
color: #22c55e;
}
.disconnected {
.disconnected,
.stale {
color: #ef4444;
}
.error {
@@ -180,7 +181,10 @@
<h2>RouteWatch</h2>
<div class="metric">
<span class="metric-label">Status</span>
<span class="metric-value" id="connected">-</span>
<span>
<span class="metric-value" id="connected">-</span>
<span class="metric-value" id="freshness"></span>
</span>
</div>
<div class="metric">
<span class="metric-label">Uptime</span>
@@ -386,6 +390,44 @@
</div>
<script nonce="{{.CSPNonce}}">
// The stats are fetched every REFRESH_PERIOD_MS. Next to the status, a
// spinner turns one frame each time an answer counts more messages
// than the one before. After STALE_AFTER_REFRESHES refreshes without
// such an answer, the spinner gives way to "stale" and the time since.
const REFRESH_PERIOD_MS = 2000;
const STALE_AFTER_REFRESHES = 3;
const MS_PER_SECOND = 1000;
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
let spinnerFrame = 0;
let lastMessageCount = null;
let lastFreshAt = Date.now();
// Turn the spinner when the message count has risen since the last answer.
function noteMessageCount(count) {
if (lastMessageCount !== null && count > lastMessageCount) {
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
lastFreshAt = Date.now();
}
lastMessageCount = count;
showFreshness();
}
function showFreshness() {
const freshness = document.getElementById('freshness');
const sinceFreshMs = Date.now() - lastFreshAt;
const stale = sinceFreshMs >= STALE_AFTER_REFRESHES * REFRESH_PERIOD_MS;
freshness.classList.toggle('stale', stale);
if (stale) {
freshness.textContent = 'stale for ' + Math.floor(sinceFreshMs / MS_PER_SECOND) + 's';
freshness.style.color = '';
return;
}
freshness.textContent = SPINNER_FRAMES[spinnerFrame];
freshness.style.color = SPINNER_COLOURS[spinnerFrame % SPINNER_COLOURS.length];
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -569,7 +611,8 @@
const connectedEl = document.getElementById('connected');
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
noteMessageCount(data.total_messages);
// Update all metrics
document.getElementById('uptime').textContent = data.uptime;
document.getElementById('go_version').textContent = data.go_version;
@@ -643,9 +686,12 @@
});
}
// Update immediately and then every 2 seconds
// Update immediately and then every REFRESH_PERIOD_MS. The freshness
// check runs every second on its own, so a request that never returns
// still turns the spinner into "stale".
updateStatus();
setInterval(updateStatus, 2000);
setInterval(updateStatus, REFRESH_PERIOD_MS);
setInterval(showFreshness, MS_PER_SECOND);
</script>
</main>
+3 -44
View File
@@ -7,9 +7,9 @@ import (
"net/url"
"strings"
"sync"
"time"
"git.eeqj.de/sneak/routewatch/internal/version"
"github.com/dustin/go-humanize"
)
//go:embed index.html
@@ -48,48 +48,7 @@ var (
once sync.Once
)
const (
hoursPerDay = 24
daysPerMonth = 30
cidrPartCount = 2 // A CIDR has two parts: prefix and length
)
// timeSince returns a human-readable duration since the given time
func timeSince(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
}
if duration < time.Hour {
minutes := int(duration.Minutes())
if minutes == 1 {
return "1 minute ago"
}
return duration.Truncate(time.Minute).String() + " ago"
}
if duration < hoursPerDay*time.Hour {
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return duration.Truncate(time.Hour).String() + " ago"
}
days := int(duration.Hours() / hoursPerDay)
if days == 1 {
return "1 day ago"
}
if days < daysPerMonth {
return duration.Truncate(hoursPerDay*time.Hour).String() + " ago"
}
return t.Format("2006-01-02")
}
const cidrPartCount = 2 // A CIDR has two parts: prefix and length
// prefixURL generates a URL path for a prefix in CIDR notation.
// Takes a prefix like "192.168.1.0/24" and returns "/prefix/192.168.1.0/24"
@@ -113,7 +72,7 @@ func initTemplates() {
// Create common template functions
funcs := template.FuncMap{
"timeSince": timeSince,
"timeSince": humanize.Time,
"urlEncode": url.QueryEscape,
"prefixURL": prefixURL,
"appName": func() string { return version.Name },
+135 -8
View File
@@ -6,9 +6,25 @@ import (
"strings"
"testing"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/templates"
)
// renderPage renders a page with the data every page needs, and the visitor's
// address, which the home page also needs.
func renderPage(t *testing.T, tmpl *template.Template) string {
t.Helper()
var page bytes.Buffer
err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
if err != nil {
t.Fatalf("rendering the page: %v", err)
}
return page.String()
}
// TestFooterLicense renders the home and status pages and expects each footer
// to name the licence in LICENSE, MIT, and not WTFPL.
func TestFooterLicense(t *testing.T) {
@@ -28,14 +44,7 @@ func TestFooterLicense(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()
var page bytes.Buffer
err := tmpl.Execute(&page, struct{ CSPNonce string }{})
if err != nil {
t.Fatalf("rendering the page: %v", err)
}
_, footer, found := strings.Cut(page.String(), "<footer")
_, footer, found := strings.Cut(renderPage(t, tmpl), "<footer")
if !found {
t.Fatal("the page has no footer")
}
@@ -52,3 +61,121 @@ func TestFooterLicense(t *testing.T) {
})
}
}
// TestFreshnessIndicator renders the home and status pages and expects each to
// have, right after the status element, the element where the page's script
// shows its spinner or "stale".
func TestFreshnessIndicator(t *testing.T) {
t.Parallel()
pages := map[string]struct {
tmpl *template.Template
status string
indicator string
}{
"index": {
tmpl: templates.IndexTemplate(),
status: `<span id="stat-status">-</span>`,
indicator: `<span id="freshness"></span>`,
},
"status": {
tmpl: templates.StatusTemplate(),
status: `<span class="metric-value" id="connected">-</span>`,
indicator: `<span class="metric-value" id="freshness"></span>`,
},
}
for name, page := range pages {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, afterStatus, found := strings.Cut(renderPage(t, page.tmpl), page.status)
if !found {
t.Fatalf("the page has no status element %s", page.status)
}
if !strings.HasPrefix(strings.TrimSpace(afterStatus), page.indicator) {
t.Errorf("the status element is not followed by %s", page.indicator)
}
})
}
}
// TestASDetailPrefixListsFold renders the AS page with one IPv4 and one IPv6
// prefix. It expects each prefix list inside its own <details> element that
// starts open and whose <summary> holds the list's heading, so that a click on
// the heading folds the list.
func TestASDetailPrefixListsFold(t *testing.T) {
t.Parallel()
const (
asn = 64500
prefixV4 = "192.0.2.0/24"
prefixV6 = "2001:db8::/32"
)
ipv4 := []database.LiveRoute{{Prefix: prefixV4}}
ipv6 := []database.LiveRoute{{Prefix: prefixV6}}
// The fields the AS page handler gives the template.
data := struct {
ASN *database.ASN
IPv4Prefixes []database.LiveRoute
IPv6Prefixes []database.LiveRoute
TotalCount int
IPv4Count int
IPv6Count int
Peers []database.ASPeer
PeerCount int
CSPNonce string
}{
ASN: &database.ASN{ASN: asn},
IPv4Prefixes: ipv4,
IPv6Prefixes: ipv6,
TotalCount: len(ipv4) + len(ipv6),
IPv4Count: len(ipv4),
IPv6Count: len(ipv6),
}
var rendered bytes.Buffer
err := templates.ASDetailTemplate().Execute(&rendered, data)
if err != nil {
t.Fatalf("rendering the page: %v", err)
}
page := rendered.String()
lists := []struct{ heading, prefix string }{
{"IPv4 Prefixes", prefixV4},
{"IPv6 Prefixes", prefixV6},
}
for _, list := range lists {
before, _, found := strings.Cut(page, list.prefix)
if !found {
t.Fatalf("the page does not list %s", list.prefix)
}
// The list's section starts at the last <details before its prefix.
start := strings.LastIndex(before, "<details")
if start < 0 || strings.Contains(before[start:], "</details>") {
t.Errorf("%s: the list is not inside a <details> element", list.heading)
continue
}
tag, section, _ := strings.Cut(before[start:], ">")
if !strings.Contains(tag, " open") {
t.Errorf("%s: the <details> element does not start open: %s>",
list.heading, tag)
}
_, summary, _ := strings.Cut(section, "<summary")
summary, _, _ = strings.Cut(summary, "</summary>")
if !strings.Contains(summary, list.heading) {
t.Errorf("%s: the <summary> does not hold the heading", list.heading)
}
}
}
+224 -32
View File
@@ -7,10 +7,15 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/charmap"
)
// Timeout constants for WHOIS queries.
@@ -26,10 +31,18 @@ const maxAnswerSize = 1 << 20
// Parsing constants.
const (
keyValueParts = 2 // Expected parts when splitting "key: value"
lacnicDateFormatLen = 8 // Length of YYYYMMDD date format
)
// The classes of the two objects that describe an AS in an answer from
// RIPE, APNIC or AFRINIC, and the key of the aut-num object's line that
// gives the ID of the AS's organisation object.
const (
autNumClass = "aut-num"
organisationClass = "organisation"
orgKey = "org"
)
// WHOIS server addresses.
const (
whoisServerIANA = "whois.iana.org:43"
@@ -38,8 +51,32 @@ const (
whoisServerAPNIC = "whois.apnic.net:43"
whoisServerLACNIC = "whois.lacnic.net:43"
whoisServerAFRINIC = "whois.afrinic.net:43"
// NIC.br registers Brazil's ASes under LACNIC.
whoisServerNICbr = "whois.registro.br:43"
)
// nicbrHeader starts the copyright notice at the top of NIC.br's WHOIS
// answers. LACNIC's answer for an AS that NIC.br registers is NIC.br's, and
// carries the same notice:
//
// % Copyright (c) Nic.br - Use of this data is governed by the Use and
// % Privacy Policy at https://registro.br/upp . Distribution,
const nicbrHeader = "% Copyright (c) Nic.br"
// nicbrLimitReached is in the line that marks NIC.br's reduced answer to a
// client over its query limit. NIC.br publishes no limit. Its Use and
// Privacy Policy, https://registro.br/upp, says only that it restricts
// queries to stop the data being taken in bulk.
const nicbrLimitReached = "Query rate limit exceeded"
// NICbrSkipTime is how long the client stops asking NIC.br after NIC.br
// answers that the client is over its query limit. NIC.br publishes
// neither the limit nor how long it lasts. An hour outlasts a limit
// counted per minute or per hour. The ASes looked up meanwhile get
// NIC.br's fields when they are next refreshed.
const NICbrSkipTime = time.Hour
// RIR identifiers.
const (
RIRARIN = "ARIN"
@@ -112,21 +149,42 @@ type Client struct {
// host:port. NewClient sets a net.Dialer's; tests replace it to reach
// local servers instead.
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
// Now returns the current time. NewClient sets time.Now; tests replace
// it to move the clock.
Now func() time.Time
logger *slog.Logger
// mu guards nicbrSkipUntil.
mu sync.Mutex
// nicbrSkipUntil is when the client next asks NIC.br, after NIC.br
// answered that the client is over its query limit.
nicbrSkipUntil time.Time
}
// NewClient creates a new WHOIS client.
func NewClient() *Client {
// NewClient creates a new WHOIS client that logs to logger.
func NewClient(logger *slog.Logger) *Client {
dialer := &net.Dialer{Timeout: dialTimeout}
return &Client{DialContext: dialer.DialContext}
return &Client{
DialContext: dialer.DialContext,
Now: time.Now,
logger: logger,
}
}
// LookupASN asks IANA which RIR holds the given ASN, asks that RIR, and
// returns what the RIR's answer says. It returns ErrNoReferral when IANA
// refers the AS to no RIR, and another error when a query fails, when the
// RIR refuses it, when IANA's answer has neither a referral nor the AS's
// block, when IANA refers the AS to a server none of the five RIRs run, or
// when the RIR's answer carries none of ASNInfo's fields.
// returns what the RIR's answer says. When LACNIC's answer is NIC.br's, it
// returns what NIC.br's own server says instead, unless that query fails or
// is skipped, as askNICbr says.
//
// It returns ErrNoReferral when IANA refers the AS to no RIR, and another
// error when the query to IANA or to the RIR fails, when the RIR refuses
// it, when IANA's answer has neither a referral nor the AS's block, when
// IANA refers the AS to a server none of the five RIRs run, or when the
// answer carries none of ASNInfo's fields.
func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Query IANA first to find the authoritative RIR
query := fmt.Sprintf("AS%d", asn)
@@ -158,18 +216,77 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
return nil, fmt.Errorf("RIR query failed: %w", err)
}
// The fields come from the RIR's answer alone: IANA's describes the
// block the AS is in.
// The fields never come from IANA's answer, which describes the block
// the AS is in.
info := c.parseResponse(asn, rir, rirResp)
if rir == RIRLACNIC && strings.Contains(rirResp, nicbrHeader) {
info = c.askNICbr(ctx, query, info)
}
if info.isEmpty() {
return nil, fmt.Errorf("%s from %s: %w", query, whoisServer, errNoFields)
}
info.RawResponse = ianaResp + "\n" + rirResp
info.RawResponse = ianaResp + "\n" + info.RawResponse
return info, nil
}
// askNICbr asks NIC.br's server about an AS whose answer from LACNIC is
// NIC.br's, and returns what NIC.br's answer says. LACNIC replaces each
// accented letter in such an answer with U+FFFD. NIC.br's own server keeps
// them, in Latin-1.
//
// askNICbr returns lacnicInfo, what LACNIC's answer says, and logs a
// warning when the query fails, when NIC.br sends the reduced answer for a
// client over its limit, or when NIC.br's answer carries none of ASNInfo's
// fields. After the reduced answer, it returns lacnicInfo without asking
// NIC.br until NICbrSkipTime has passed.
func (c *Client) askNICbr(
ctx context.Context, asQuery string, lacnicInfo *ASNInfo,
) *ASNInfo {
asn := lacnicInfo.ASN
c.mu.Lock()
skipping := c.Now().Before(c.nicbrSkipUntil)
c.mu.Unlock()
if skipping {
return lacnicInfo
}
answer, err := c.query(ctx, whoisServerNICbr, asQuery)
if err != nil {
c.logger.Warn("NIC.br query failed, keeping LACNIC's fields",
"asn", asn, "error", err)
return lacnicInfo
}
if strings.Contains(answer, nicbrLimitReached) {
c.mu.Lock()
c.nicbrSkipUntil = c.Now().Add(NICbrSkipTime)
c.mu.Unlock()
c.logger.Warn("NIC.br answered that the client is over its query "+
"limit, keeping LACNIC's fields and skipping NIC.br",
"asn", asn, "skip_for", NICbrSkipTime)
return lacnicInfo
}
info := c.parseResponse(asn, RIRLACNIC, answer)
if info.isEmpty() {
c.logger.Warn("NIC.br answered with no WHOIS fields, "+
"keeping LACNIC's fields", "asn", asn)
return lacnicInfo
}
return info
}
// isEmpty reports whether info holds none of the fields a WHOIS answer
// fills, as when the answer is only comment and error lines.
func (info *ASNInfo) isEmpty() bool {
@@ -213,8 +330,8 @@ func (c *Client) queryRIR(
return answer, nil
}
// query performs a raw WHOIS query to the specified server. An answer that
// reaches maxAnswerSize is an error.
// query performs a raw WHOIS query to the specified server and returns the
// answer in UTF-8. An answer that reaches maxAnswerSize is an error.
func (c *Client) query(ctx context.Context, server, query string) (string, error) {
conn, err := c.DialContext(ctx, "tcp", server)
if err != nil {
@@ -261,7 +378,25 @@ func (c *Client) query(ctx context.Context, server, query string) (string, error
return "", fmt.Errorf("read response: %w", errAnswerTooLarge)
}
return sb.String(), nil
return toUTF8(sb.String())
}
// toUTF8 returns a WHOIS answer in UTF-8. An answer that is valid UTF-8 is
// returned as it is. Any other answer is decoded as Latin-1, which RIPE,
// APNIC and NIC.br send. LACNIC, ARIN and AFRINIC send UTF-8. RIPE's
// database working group lists the five RIRs here:
// https://www.ripe.net/ripe/mail/archives/db-wg/2024-May/008075.html
func toUTF8(answer string) (string, error) {
if utf8.ValidString(answer) {
return answer, nil
}
decoded, err := charmap.ISO8859_1.NewDecoder().String(answer)
if err != nil {
return "", fmt.Errorf("decode Latin-1 answer: %w", err)
}
return decoded, nil
}
// parseIANAReferral extracts the RIR and WHOIS server from an IANA response.
@@ -297,7 +432,9 @@ func (c *Client) parseIANAReferral(response string) (string, string, error) {
return "", "", nil
}
// parseResponse extracts ASN information from a WHOIS response.
// parseResponse extracts ASN information from a WHOIS response. Of an
// answer from RIPE, APNIC or AFRINIC it reads only the lines asObjectLines
// returns. Of an answer from ARIN or LACNIC it reads every line.
func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
info := &ASNInfo{
ASN: asn,
@@ -306,26 +443,15 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
}
lines := strings.Split(response, "\n")
if rir == RIRRIPE || rir == RIRAPNIC || rir == RIRAFRNIC {
lines = asObjectLines(lines)
}
var addressLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") ||
strings.HasPrefix(line, "#") {
continue
}
// Split on first colon
parts := strings.SplitN(line, ":", keyValueParts)
if len(parts) != keyValueParts {
continue
}
key := strings.TrimSpace(strings.ToLower(parts[0]))
value := strings.TrimSpace(parts[1])
if value == "" {
key, value, ok := splitField(line)
if !ok || value == "" {
continue
}
@@ -352,6 +478,72 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo {
return info
}
// splitField splits a "key: value" line of a WHOIS answer into its key, in
// lower case, and its value. The third result is false for a line that
// holds no field: a blank line, a comment line or a line without a colon.
func splitField(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
return "", "", false
}
key, value, ok := strings.Cut(line, ":")
return strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value), ok
}
// asObjectLines returns the lines of the aut-num object in an answer from
// RIPE, APNIC or AFRINIC, followed by the lines of the organisation object
// whose ID the aut-num object gives on its "org:" line. The answer's other
// objects are left out. RIPE and APNIC start their answer with the
// as-block object of the block the AS is in, whose dates are not the AS's.
//
// These answers are in RPSL. A blank line ends an object. An object's first
// line has the object's class as its key and the object's ID as its value,
// as in "organisation: ORG-APNI1-AP".
func asObjectLines(lines []string) []string {
var autNum []string
// The lines of each organisation object, by its ID.
organisations := make(map[string][]string)
// The class and ID of the object the line is in, "" between objects.
var class, id string
// The ID of the organisation object the aut-num object refers to.
var orgID string
for _, line := range lines {
if strings.TrimSpace(line) == "" {
class = ""
continue
}
key, value, ok := splitField(line)
if !ok {
continue
}
if class == "" {
class, id = key, value
}
switch class {
case autNumClass:
autNum = append(autNum, line)
if key == orgKey {
orgID = value
}
case organisationClass:
organisations[id] = append(organisations[id], line)
}
}
return append(autNum, organisations[orgID]...)
}
// setResponseField stores the value of one "key: value" line of a WHOIS
// response in info, keeping the first value seen for each field.
func (c *Client) setResponseField(info *ASNInfo, key, value string) {
+492 -3
View File
@@ -2,9 +2,13 @@ package whois_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
"net"
"slices"
"strings"
@@ -12,6 +16,7 @@ import (
"syscall"
"testing"
"time"
"unicode/utf8"
"git.eeqj.de/sneak/routewatch/internal/whois"
)
@@ -49,14 +54,30 @@ changed: 2002-09-01
source: IANA
`
// lacnicReferral is IANA's answer for an AS in a block it gave to LACNIC,
// shortened to the referral.
const lacnicReferral = "refer: whois.lacnic.net\n"
// ripeRecord is RIPE's answer for the AS when asked to leave out person and
// role objects: the abuse address as a comment, the AS's object and the
// role objects. It starts with the as-block object of the block the AS is
// in. Then come the abuse address as a comment, the AS's object and the
// organisation object it refers to.
const ripeRecord = `% This is the RIPE Database query service.
% The objects are in RPSL format.
% Note: --no-personal means ALL personal data has been filtered from this output.
% Information related to 'AS3209 - AS3353'
as-block: AS3209 - AS3353
descr: RIPE NCC ASN block
remarks: These AS Numbers are assigned to network operators
remarks: in the RIPE NCC service region.
mnt-by: RIPE-NCC-HM-MNT
created: 2018-11-22T15:27:19Z
last-modified: 2018-11-22T15:27:19Z
source: RIPE
% Information related to 'AS3333'
% Abuse contact for 'AS3333' is 'abuse@ripe.net'
@@ -118,6 +139,78 @@ country: ZA
address: 1 Example Road
`
// apnicServer is APNIC's WHOIS server, and apnicASN an AS that APNIC holds.
const (
apnicServer = "whois.apnic.net:43"
apnicASN = 4608
)
// The keys of the queries LookupASN sends for apnicASN: IANA's, then
// APNIC's, which asks APNIC to leave out person and role objects.
const (
apnicIANAQuery = ianaServer + " AS4608"
apnicQuery = apnicServer + " --no-personal AS4608"
)
// apnicRecord is APNIC's answer for apnicASN when asked to leave out person
// and role objects, shortened. It starts with the as-block object of the
// block the AS is in. Then come the abuse address as a comment, the AS's
// object, and the irt and organisation objects it refers to.
const apnicRecord = `% [whois.apnic.net]
% Whois data copyright terms http://www.apnic.net/db/dbcopyright.html
% Note: --no-personal means ALL personal data has been filtered from this output.
% Information related to 'AS4608 - AS4865'
as-block: AS4608 - AS4865
descr: APNIC ASN block
remarks: These AS numbers are further assigned by APNIC
remarks: to APNIC members and end-users in the APNIC region
mnt-by: APNIC-HM
mnt-lower: APNIC-HM
last-modified: 2019-05-20T03:52:26Z
source: APNIC
% Information related to 'AS4608'
% Abuse contact for 'AS4608' is 'abuse@apnic.net'
aut-num: AS4608
as-name: APNIC-SERVICES
descr: Asia Pacific Network Information Centre
country: AU
org: ORG-APNI1-AP
admin-c: AIC1-AP
abuse-c: AA1589-AP
mnt-by: APNIC-HM
mnt-irt: IRT-APNIC-IS-AP
last-modified: 2026-08-26T00:40:00Z
source: APNIC
irt: IRT-APNIC-IS-AP
address: South Brisbane, Australia
e-mail: helpdesk@apnic.net
abuse-mailbox: abuse@apnic.net
mnt-by: MAINT-APNIC-IS-AP
last-modified: 2025-11-18T00:27:08Z
source: APNIC
organisation: ORG-APNI1-AP
org-name: Asia Pacific Network Information Centre
org-type: LIR
country: AU
address: PO Box 3646
address: South Brisbane, QLD 4101,
address: Australia
e-mail: noc@apnic.net
mnt-by: APNIC-HM
last-modified: 2026-08-24T12:58:43Z
source: APNIC
% This query was served by the APNIC Whois Service version 1.88.48 (WHOIS-UK2)
`
// ianaPrivateUse is IANA's answer for a private-use AS: its block and no
// referral.
const ianaPrivateUse = `% IANA WHOIS server
@@ -187,7 +280,7 @@ func lookUp(
) (*whois.ASNInfo, error) {
t.Helper()
client := whois.NewClient()
client := whois.NewClient(slog.New(slog.DiscardHandler))
client.DialContext = fakeServers(t, answers)
return client.LookupASN(t.Context(), testASN)
@@ -197,7 +290,8 @@ func lookUp(
// answers only a query that leaves out person and role objects, and its
// answer to that still fills the AS's fields: the abuse address from the
// comment line, and the organisation's name and country from its object.
// The dates are the AS's: IANA's "changed:" line dates its block.
// The dates are the AS's. IANA's "changed:" line dates the AS's block, and
// so do the dates of the as-block object at the start of RIPE's answer.
func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
t.Parallel()
@@ -237,6 +331,46 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
}
}
// TestLookupASNReadsOnlyTheASAndItsOrganisation looks up an AS at APNIC,
// whose answer starts with the as-block object of the block the AS is in.
// The last-modified date is the aut-num object's, not the block's. The
// address is the organisation object's alone, without the irt object's.
func TestLookupASNReadsOnlyTheASAndItsOrganisation(t *testing.T) {
t.Parallel()
client := whois.NewClient(slog.New(slog.DiscardHandler))
client.DialContext = fakeServers(t, map[string]string{
apnicIANAQuery: "refer: whois.apnic.net\n",
apnicQuery: apnicRecord,
})
info, err := client.LookupASN(t.Context(), apnicASN)
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
wantLastMod := time.Date(2026, 8, 26, 0, 40, 0, 0, time.UTC)
if info.LastMod == nil || !info.LastMod.Equal(wantLastMod) {
t.Errorf("LookupASN last modified = %v, want %v",
info.LastMod, wantLastMod)
}
got := []string{
info.ASName, info.OrgID, info.OrgName, info.CountryCode,
info.Address, info.AbuseEmail,
}
want := []string{
"APNIC-SERVICES", "ORG-APNI1-AP",
"Asia Pacific Network Information Centre", "AU",
"PO Box 3646\nSouth Brisbane, QLD 4101,\nAustralia", "abuse@apnic.net",
}
if !slices.Equal(got, want) {
t.Errorf("LookupASN = AS name, org ID, org name, country, address, "+
"abuse address %q; want %q", got, want)
}
}
// TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData looks up an AS at
// APNIC and at AFRINIC. Each answers only a query that leaves out person
// and role objects, as RIPE does. That one answer still holds the
@@ -273,6 +407,361 @@ func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) {
}
}
// The name of an organisation with accented letters, in Latin-1, one byte for
// each letter, and in UTF-8.
const (
latin1OrgName = "Fundaci\xf3n Ra\xedces de Espa\xf1a"
utf8OrgName = "Fundación Raíces de España"
)
// TestLookupASNStoresAnswersInUTF8 looks up an AS at RIPE, which answers in
// Latin-1, and at LACNIC, which answers in UTF-8. Both answers give the
// organisation's name with its accented letters in UTF-8, in the parsed field
// and in the stored answer. Before the fix the Latin-1 answer was kept as
// received, which is not valid UTF-8.
func TestLookupASNStoresAnswersInUTF8(t *testing.T) {
t.Parallel()
for name, answers := range map[string]map[string]string{
"RIPE in Latin-1": {
ianaQuery: ianaReferral,
ripeQuery: "aut-num: AS3333\n" +
"org-name: " + latin1OrgName + "\n",
},
"LACNIC in UTF-8": {
ianaQuery: lacnicReferral,
"whois.lacnic.net:43 " + asQuery: "aut-num: AS3333\n" +
"owner: " + utf8OrgName + "\n",
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
info, err := lookUp(t, answers)
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
if info.OrgName != utf8OrgName {
t.Errorf("LookupASN org name = %q, want %q",
info.OrgName, utf8OrgName)
}
if !utf8.ValidString(info.RawResponse) ||
!strings.Contains(info.RawResponse, utf8OrgName) {
t.Errorf("LookupASN answer = %q, want UTF-8 holding %q",
info.RawResponse, utf8OrgName)
}
})
}
}
// lacnicServer is LACNIC's WHOIS server and nicbrServer NIC.br's, which
// registers Brazil's ASes under LACNIC. brazilASN is one of those ASes.
const (
lacnicServer = "whois.lacnic.net:43"
nicbrServer = "whois.registro.br:43"
brazilASN = 52751
)
// The keys of the queries LookupASN sends for brazilASN: IANA's, LACNIC's,
// then NIC.br's.
const (
brazilIANAQuery = ianaServer + " AS52751"
brazilLACNICQuery = lacnicServer + " AS52751"
brazilNICbrQuery = nicbrServer + " AS52751"
)
// The name of brazilASN's organisation in three forms: as LACNIC sends it,
// with each accented letter replaced by U+FFFD; as NIC.br sends it, in
// Latin-1; and in UTF-8.
const (
lacnicBrazilOrgName = "Brasrede Telecomunica\U0000FFFD\U0000FFFDes LTDA"
nicbrBrazilOrgName = "Brasrede Telecomunica\xe7\xf5es LTDA"
brazilOrgName = "Brasrede Telecomunicações LTDA"
)
// nicbrCopyright is the copyright notice at the top of NIC.br's answers.
const nicbrCopyright = "% Copyright (c) Nic.br - " +
"Use of this data is governed by the Use and\n" +
"% Privacy Policy at https://registro.br/upp . Distribution,\n" +
"% commercialization, reproduction, and use for advertising or similar\n" +
"% purposes are expressly prohibited.\n"
// lacnicBrazilRecord is LACNIC's answer for brazilASN, recorded on
// 2026-10-26. It is NIC.br's answer, which LACNIC passes on after a line
// of its own and a space. It is shortened. The client's address is a
// documentation address. The contact's name, the contact's object and the
// footer are left out.
const lacnicBrazilRecord = "% IP Client: 192.0.2.1\n" +
" " + nicbrCopyright + `% 2026-10-26T23:12:22-03:00 - 192.0.2.1
aut-num: AS52751
owner: ` + lacnicBrazilOrgName + `
owner-c: AVL217
routing-c: AVL217
abuse-c: AVL217
created: 20120829
changed: 20130306
inetnum: 177.38.152.0/21
inetnum: 2804:b34::/32
inetnum: 191.243.4.0/22
inetnum: 131.72.248.0/22
inetnum: 138.94.228.0/22
inetnum: 170.233.8.0/22
inetnum: 170.246.4.0/22
`
// nicbrRecord is NIC.br's answer for brazilASN, in Latin-1, recorded on
// 2026-10-26 and shortened as lacnicBrazilRecord is.
const nicbrRecord = nicbrCopyright + `% 2026-10-26T23:14:41-03:00 - 192.0.2.1
aut-num: AS52751
owner: ` + nicbrBrazilOrgName + `
owner-c: AVL217
routing-c: AVL217
abuse-c: AVL217
created: 20120829
changed: 20130306
inetnum: 177.38.152.0/21
inetnum: 2804:b34::/32
inetnum: 191.243.4.0/22
inetnum: 131.72.248.0/22
inetnum: 138.94.228.0/22
inetnum: 170.233.8.0/22
inetnum: 170.246.4.0/22
`
// nicbrOverLimit stands for NIC.br's answer for brazilASN to a client over
// its query limit, and is not recorded. Such an answer leaves information
// out, here the owner, and says so in its first line.
const nicbrOverLimit = `% Query rate limit exceeded. Reduced information.
aut-num: AS52751
created: 20120829
changed: 20130306
`
// newNICbrClient returns a client whose fake servers send answers and
// whose logger writes JSON lines to logs. It adds one to *nicbrAsks each
// time it connects to NIC.br's server.
func newNICbrClient(
t *testing.T, answers map[string]string, logs io.Writer, nicbrAsks *int,
) *whois.Client {
t.Helper()
dial := fakeServers(t, answers)
client := whois.NewClient(slog.New(slog.NewJSONHandler(logs, nil)))
client.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == nicbrServer {
*nicbrAsks++
}
return dial(ctx, network, address)
}
return client
}
// lookUpBrazilianAS looks brazilASN up with fake servers sending answers,
// and reports whether the client connected to NIC.br's server.
func lookUpBrazilianAS(
t *testing.T, answers map[string]string,
) (*whois.ASNInfo, bool, error) {
t.Helper()
nicbrAsks := 0
client := newNICbrClient(t, answers, io.Discard, &nicbrAsks)
info, err := client.LookupASN(t.Context(), brazilASN)
return info, nicbrAsks > 0, err
}
// TestLookupASNTakesABrazilianASFromNICbr looks up an AS that NIC.br
// registers. LACNIC's answer is NIC.br's with each accented letter replaced
// by U+FFFD, so the client also asks NIC.br's server, which answers in
// Latin-1. The organisation's name has its accented letters, in the parsed
// field and in the stored answer.
func TestLookupASNTakesABrazilianASFromNICbr(t *testing.T) {
t.Parallel()
info, _, err := lookUpBrazilianAS(t, map[string]string{
brazilIANAQuery: lacnicReferral,
brazilLACNICQuery: lacnicBrazilRecord,
brazilNICbrQuery: nicbrRecord,
})
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
if info.OrgName != brazilOrgName || info.RIR != whois.RIRLACNIC {
t.Errorf("LookupASN org name, RIR = %q, %q; want %q, %q",
info.OrgName, info.RIR, brazilOrgName, whois.RIRLACNIC)
}
if !strings.Contains(info.RawResponse, brazilOrgName) {
t.Errorf("LookupASN answer = %q, want one holding %q",
info.RawResponse, brazilOrgName)
}
wantRegDate := time.Date(2012, 8, 29, 0, 0, 0, 0, time.UTC)
if info.RegDate == nil || !info.RegDate.Equal(wantRegDate) {
t.Errorf("LookupASN registered = %v, want %v",
info.RegDate, wantRegDate)
}
}
// TestLookupASNAsksNICbrOnlyForItsASes looks up an AS at LACNIC whose
// answer does not carry NIC.br's copyright notice, as for an AS that LACNIC
// registers itself. The client does not connect to NIC.br's server, which
// would answer with a record, and keeps LACNIC's fields.
func TestLookupASNAsksNICbrOnlyForItsASes(t *testing.T) {
t.Parallel()
info, askedNICbr, err := lookUpBrazilianAS(t, map[string]string{
brazilIANAQuery: lacnicReferral,
brazilLACNICQuery: "aut-num: AS52751\n" +
"owner: " + utf8OrgName + "\n",
brazilNICbrQuery: nicbrRecord,
})
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
if askedNICbr {
t.Error("LookupASN asked NIC.br's server about an AS whose " +
"answer from LACNIC is not NIC.br's")
}
if info.OrgName != utf8OrgName {
t.Errorf("LookupASN org name = %q, want LACNIC's %q",
info.OrgName, utf8OrgName)
}
}
// TestLookupASNKeepsLACNICsAnswerWhenNICbrFails looks up an AS that NIC.br
// registers when NIC.br's server refuses the connection, sends nothing, or
// sends the reduced answer for a client over its query limit. The client
// asks NIC.br, and the lookup still succeeds with LACNIC's fields. The
// client logs one warning, which names the AS and the reason.
func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
t.Parallel()
for name, test := range map[string]struct {
nicbrAnswer map[string]string
reason string
}{
"connection refused": {
nicbrAnswer: map[string]string{},
reason: syscall.ECONNREFUSED.Error(),
},
"no answer": {
nicbrAnswer: map[string]string{brazilNICbrQuery: ""},
reason: "no WHOIS fields",
},
"over the query limit": {
nicbrAnswer: map[string]string{brazilNICbrQuery: nicbrOverLimit},
reason: "over its query limit",
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
answers := map[string]string{
brazilIANAQuery: lacnicReferral,
brazilLACNICQuery: lacnicBrazilRecord,
}
maps.Copy(answers, test.nicbrAnswer)
var logs bytes.Buffer
nicbrAsks := 0
client := newNICbrClient(t, answers, &logs, &nicbrAsks)
info, err := client.LookupASN(t.Context(), brazilASN)
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
if nicbrAsks == 0 {
t.Error("LookupASN did not ask NIC.br's server")
}
if info.OrgName != lacnicBrazilOrgName {
t.Errorf("LookupASN org name = %q, want LACNIC's %q",
info.OrgName, lacnicBrazilOrgName)
}
var logged struct {
Level string `json:"level"`
ASN int `json:"asn"`
}
err = json.Unmarshal(logs.Bytes(), &logged)
if err != nil {
t.Fatalf("want one log line, got %q: %v", logs.String(), err)
}
if logged.Level != slog.LevelWarn.String() ||
logged.ASN != brazilASN ||
!strings.Contains(logs.String(), test.reason) {
t.Errorf("logged %q, want a warning for AS%d holding %q",
logs.String(), brazilASN, test.reason)
}
})
}
}
// TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br
// registers three times with one client, while NIC.br answers that the
// client is over its query limit. The first lookup asks NIC.br. The
// second, a second before NICbrSkipTime has passed, does not. The third,
// once NICbrSkipTime has passed, asks again. Each keeps LACNIC's fields.
func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
t.Parallel()
nicbrAsks := 0
client := newNICbrClient(t, map[string]string{
brazilIANAQuery: lacnicReferral,
brazilLACNICQuery: lacnicBrazilRecord,
brazilNICbrQuery: nicbrOverLimit,
}, io.Discard, &nicbrAsks)
start := time.Date(2026, 10, 27, 0, 0, 0, 0, time.UTC)
now := start
client.Now = func() time.Time { return now }
for _, step := range []struct {
after time.Duration
wantAsks int
}{
{after: 0, wantAsks: 1},
{after: whois.NICbrSkipTime - time.Second, wantAsks: 1},
{after: whois.NICbrSkipTime, wantAsks: 2},
} {
now = start.Add(step.after)
info, err := client.LookupASN(t.Context(), brazilASN)
if err != nil {
t.Fatalf("LookupASN after %v: %v", step.after, err)
}
if nicbrAsks != step.wantAsks {
t.Errorf("after %v NIC.br's server was asked %d times, want %d",
step.after, nicbrAsks, step.wantAsks)
}
if info.OrgName != lacnicBrazilOrgName {
t.Errorf("LookupASN after %v org name = %q, want LACNIC's %q",
step.after, info.OrgName, lacnicBrazilOrgName)
}
}
}
// TestLookupASNReportsARefusal looks up an AS IANA refers to RIPE when RIPE
// refuses the query, with each of the error lines RIPE refuses with. The
// error holds RIPE's line, so the log says why the lookup failed.
Binary file not shown.
+13 -9
View File
@@ -22,7 +22,7 @@ func TestGet(t *testing.T) {
name: "AS1 Level 3",
asn: 1,
wantOK: true,
wantDesc: "Level 3 Parent, LLC",
wantDesc: "Level 3 Parent LLC",
},
{
name: "AS15169 Google",
@@ -36,10 +36,10 @@ func TestGet(t *testing.T) {
wantOK: false,
},
{
name: "AS0 IANA Reserved",
asn: 0,
name: "AS1251 with accented letters",
asn: 1251,
wantOK: true,
wantDesc: "Internet Assigned Numbers Authority",
wantDesc: "FUNDAÇÃO DE AMPARO À PESQUISA DO ESTADO SÃO PAULO",
},
}
@@ -69,7 +69,7 @@ func TestGetDescription(t *testing.T) {
asn int
want string
}{
{1, "Level 3 Parent, LLC"},
{1, "Level 3 Parent LLC"},
{999999999, ""},
}
@@ -92,8 +92,7 @@ func TestGetHandle(t *testing.T) {
asn int
want string
}{
{1, "LVLT"},
{0, "IANA-RSVD"},
{1, "LVLT-1"},
{999999999, ""},
}
@@ -142,6 +141,11 @@ func TestSearch(t *testing.T) {
query: "University",
wantMin: 10,
},
{
name: "Search for São Paulo",
query: "São Paulo",
wantMin: 1,
},
{
name: "Non-existent org",
query: "XYZABC123456",
@@ -189,13 +193,13 @@ func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
func TestSearchIgnoresCase(t *testing.T) {
t.Parallel()
const cloudflareASN = 13335 // handle CLOUDFLARENET, "Cloudflare, Inc."
const cloudflareASN = 13335 // handle CLOUDFLARENET, "Cloudflare Inc."
isCloudflare := func(info asinfo.Info) bool {
return info.ASN == cloudflareASN
}
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
for _, query := range []string{"cloudflarenet", "CLOUDFLARE INC."} {
results, _ := asinfo.Search(query, asinfo.Total())
if !slices.ContainsFunc(results, isCloudflare) {
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
+1 -1
View File
@@ -2,7 +2,7 @@
Package asinfo provides information about Autonomous Systems (AS).
The package embeds a comprehensive database of AS numbers, handles, and descriptions
sourced from https://github.com/ipverse/asn-info. The data is embedded at compile
sourced from https://github.com/ipverse/as-metadata. The data is embedded at compile
time and provides fast, offline lookups.
Basic usage:
+1 -1
View File
@@ -17,7 +17,7 @@ func ExampleGet() {
func ExampleGetDescription() {
desc := asinfo.GetDescription(13335)
fmt.Println(desc)
// Output: Cloudflare, Inc.
// Output: Cloudflare Inc.
}
func ExampleSearch() {
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# script/asupdate: rebuild the AS list built into RouteWatch from
# as.csv with cmd/asinfo-gen, which fails when it finds no AS. The new
# list replaces the old one only when the generator succeeds. Otherwise
# the old list stays as it was and the script exits non-zero.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
AS_DATA="pkg/asinfo/asdata.json.gz"
# Next to AS_DATA, so the final mv is a rename on one file system.
AS_DATA_TMP="$AS_DATA.tmp"
main() {
cd "$ROOT"
json="$(mktemp)"
trap 'rm -f "$json" "$AS_DATA_TMP"' EXIT
# A signal exits through the EXIT trap too.
trap 'exit 1' HUP INT TERM
echo "Updating AS info data..."
if ! go run ./cmd/asinfo-gen >"$json"; then
echo "asupdate: the generator failed; $AS_DATA is unchanged" >&2
exit 1
fi
# -n leaves the time out of the file, so an unchanged list gives the
# same bytes.
gzip -n -c <"$json" >"$AS_DATA_TMP"
mv "$AS_DATA_TMP" "$AS_DATA"
}
main "$@"