11 Commits
Author SHA1 Message Date
clawbot 05b39eee7a Start no AS, peer or peering write after the stop deadline (closes #189)
check / check (push) Waiting to run
A write can run past the stop deadline while a full batch's wake-up or a
tick waits beside Stop's context. The select picked one at random, so about
half the time the AS, peer or peering handler started another write after
the deadline. Each loop now takes a waiting stop first, as the prefix
handler's does. The prefix, AS and peer handlers' flush loops were
near-copies, so they are now one function, flushLoop. A new test for each
handler fails about half its runs on the old code.

The AS and peer handler tests give the flush timer an interval longer than
the test. A timer flush during the fill can no longer hang
TestPeerHandlerWaitsAtTheMaximumBatchSize.

Model: opus-5-5
2026-10-27 14:56:11 +01:00
clawbot ce19b14275 Start no prefix flush after the stop deadline (closes #188)
check / check (push) Canceled after 0s
When a write ran past the stop deadline, a full batch's wake-up or a tick
could wait beside Stop's context. The flush goroutine's select picked one of
them at random, so about half the time it wrote that batch after the
deadline. That was the intermittent failure of
TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline. The flush goroutine
now takes a waiting stop first. A new test hands over a full batch while a
write is held; it fails about half its runs on the old code.

TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline and
TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten also failed when the
flush timer fired between two of their steps. Both now give the handler a
timer interval longer than the test.

Model: opus-5-5
2026-10-27 13:13:03 +01:00
clawbot bac531c76d Write the routing table every second and read it at startup (part of #122)
check / check (push) Canceled after 0s
The prefix handler applies each update to the routing table in memory as
it handles it, sorted into announcements and withdrawals as for the live
route tables. Each flush first writes at most 20,000 of the table's
changed pairs, with every new peer; the rest follow in the next flushes.
A failed write puts its changes and new peers back in the table. Stop
makes one last flush, and logs the changed pairs left.

Startup reads the table before the streamer connects, and logs its pairs,
peers and time. The per-peer counts are a slice, so the read takes about
a third less time. The health check's start period is 60 seconds, so a
full table's read fits before upaas looks.

Model: opus-5-5
2026-10-27 10:27:11 +01:00
clawbot eda37da369 Store the routing table in the database (part of #122)
check / check (push) Canceled after 0s
A new routes table holds one row per prefix and origin AS: the peer set
as the bitset's bytes, and the two kept routes, each with its peer's
number, AS path and time in seconds. A new route_peers table holds each
peer's number and address.

WriteRouteChanges writes one TakeChanges result in a transaction.
ReadRouteTable reads both tables into a new routing table, through the
new Table.Load, which records no change. It fails on a row that names a
peer number missing from route_peers. Nothing uses them yet.

Model: opus-5-5
2026-10-27 08:54:57 +01:00
clawbot 7a34a46c4d Add internal/routetable, the routing table in memory (part of #122)
check / check (push) Canceled after 0s
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
41 changed files with 6477 additions and 361 deletions
+7 -2
View File
@@ -139,8 +139,13 @@ EXPOSE 8080
COPY ./entrypoint.sh /entrypoint.sh
# Health check using the health endpoint, on the port PORT names
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# Health check using the health endpoint, on the port PORT names. During the
# start period, Docker 25 and later checks every 5 seconds, and the first pass
# makes the container healthy. After it, Docker checks every 30 seconds. The
# start period covers the 60 seconds after which upaas reads the health
# (README "Running under upaas"). Startup reads the routing table first,
# which takes about 25 seconds when the table is full.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -sf "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]
+101 -19
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
@@ -84,18 +89,26 @@ Every route answers `HEAD` as it answers `GET`, without the body.
collector, stream and handler queue statistics. `stream.route_churn_per_min`
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: 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.
- `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
@@ -149,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
```
@@ -158,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
@@ -210,6 +285,8 @@ routewatch/
2. **Initialization** (`internal/routewatch/app.go`)
- Database created with SQLite schema (WAL mode, page caches as in "Memory")
- The routing table read from the database into memory, before the streamer
connects
- Message handlers registered with the streamer
- HTTP server started on configured port
@@ -236,7 +313,10 @@ routewatch/
holds AS 0 anywhere, deletes all of a peer's routes when its BGP session
goes down). Routes are kept per peer IP, so when one peer IP has sessions
on two collectors, a session going down on one also removes the routes the
other still carries, until they are announced again.
other still carries, until they are announced again. It also applies each
update to the routing table in memory, and writes the table's changes to
the database every second. A write takes at most 20,000 changed prefix and
origin AS pairs, and the rest follow in the next writes.
- **PeeringHandler**: Extracts AS peering relationships from AS paths
5. **HTTP Request Flow**
@@ -324,7 +404,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
@@ -420,7 +500,9 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
- Health check: the image's `HEALTHCHECK` requests
`/.well-known/healthcheck.json` on the container port. upaas reads the
container's health 60 seconds after a deploy and fails the deploy unless it is
`healthy`.
`healthy`. For the first 60 seconds, Docker 25 and later runs the check every
5 seconds, and the first pass makes the container healthy. Startup first reads
the routing table, which takes about 25 seconds when the table is full.
- Stop grace: the container needs 9 seconds between SIGTERM and SIGKILL. upaas
gives 10 (`stopTimeoutSeconds` in its `internal/docker/client.go`), as
`docker stop` does by default. The daemon allows 8 seconds for the handlers to
+47 -2
View File
@@ -33,11 +33,54 @@ tested.
# Completed Steps
- 2026-10-27: once the stop deadline has passed, the AS, peer and peering
handlers start no write either. A full batch or a tick of the timer that
waited while a write ran past the deadline could still start one. The flush
goroutines of the prefix, AS and peer handlers now run the same code (closes
#189)
- 2026-10-27: once the stop deadline has passed, the prefix handler starts no
flush. A batch that filled while a write ran past the deadline could still be
written after it (closes #188)
- 2026-10-27: the prefix handler applies each update to the routing table in
memory, and writes the table's changes to the database every second. A write
takes at most 20,000 changed pairs, and the rest follow in the next writes. A
failed write is written again later. Startup reads the table before the
streamer connects. The pages still read the per-peer route tables (part of
#122)
- 2026-10-27: the database keeps a copy of the whole routing table in a new
`routes` table, one row per prefix and origin AS. The peers' numbers are in a
new `route_peers` table. One function writes the pairs that changed in memory,
and another reads the whole table back into memory at startup. Nothing uses
them yet (part of #122)
- 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 (closes #174)
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)
@@ -224,4 +267,6 @@ tested.
# Future Steps
None open.
- the rest of https://git.eeqj.de/sneak/routewatch/issues/122, one PR each: the
pages and the API reading from the routing table, then removing the per-peer
route tables
+3
View File
@@ -3,12 +3,15 @@ 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
)
+6
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,6 +28,8 @@ 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=
+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
}
+9 -1
View File
@@ -1,10 +1,14 @@
// Package database provides SQLite storage for BGP routing data including ASNs,
// prefixes, announcements, peerings, and live route tables.
// prefixes, announcements, peerings, and live route tables. Its routes table
// holds a copy of the whole routing table that internal/routetable keeps in
// memory.
package database
import (
"context"
"time"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// Stats contains database statistics including counts for ASNs, prefixes,
@@ -67,6 +71,10 @@ type Store interface {
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error)
// Routing table operations: the database's copy of the table in memory
WriteRouteChanges(changes []routetable.Change, newPeers map[string]uint) error
ReadRouteTable(ctx context.Context) (*routetable.Table, error)
// IP lookup operations
GetIPInfo(ip string) (*IPInfo, error)
GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error)
+5 -6
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"`
+339
View File
@@ -0,0 +1,339 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/netip"
"time"
"github.com/bits-and-blooms/bitset"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// keptRoutesPerRow is how many kept routes a row of the routes table has
// columns for: the newest and the older one.
const keptRoutesPerRow = 2
// errUnknownRoutePeer is returned for a peer that route_peers lacks: on a
// write, the peer of a kept route; on a read, a peer number in a row.
var errUnknownRoutePeer = errors.New("peer not in route_peers")
// The statements of the routes and route_peers tables (schema.sql).
const (
insertRoutePeerSQL = `INSERT INTO route_peers (number, address)
VALUES (?, ?)`
routePeerNumberSQL = `SELECT number FROM route_peers WHERE address = ?`
routePeersSQL = `SELECT number, address FROM route_peers`
upsertRouteSQL = `INSERT INTO routes (prefix, origin_asn, peers,
newest_peer, newest_as_path, newest_time,
older_peer, older_as_path, older_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (prefix, origin_asn) DO UPDATE SET peers = excluded.peers,
newest_peer = excluded.newest_peer,
newest_as_path = excluded.newest_as_path,
newest_time = excluded.newest_time,
older_peer = excluded.older_peer,
older_as_path = excluded.older_as_path,
older_time = excluded.older_time`
deleteRouteSQL = `DELETE FROM routes WHERE prefix = ? AND origin_asn = ?`
routesSQL = `SELECT prefix, origin_asn, peers,
newest_peer, newest_as_path, newest_time,
older_peer, older_as_path, older_time FROM routes`
)
// WriteRouteChanges writes, in one transaction, what one call of the routing
// table's TakeChanges returned. It stores each new peer with its number, writes
// the row of each changed prefix and origin AS pair, and deletes the row of
// each removed one. Route times are stored to the second, as the feed gives
// them.
//
// When it fails, it writes nothing. The caller then writes these changes and
// peers again, because later changes can name the peers it did not store.
func (d *Database) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
if len(changes) == 0 && len(newPeers) == 0 {
return nil
}
d.lock("WriteRouteChanges")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer d.rollback(tx)
ctx := context.Background()
// The new peers first, so that the rows can look up their numbers.
for address, number := range newPeers {
_, err := tx.ExecContext(ctx, insertRoutePeerSQL, number, address)
if err != nil {
return fmt.Errorf("failed to store peer %s: %w", address, err)
}
}
err = writeRouteRows(ctx, tx, changes)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// writeRouteRows upserts the row of each change in tx, or deletes it for a
// removed pair.
func writeRouteRows(
ctx context.Context, tx *loggingTx, changes []routetable.Change,
) error {
upsert, err := tx.PrepareContext(ctx, upsertRouteSQL)
if err != nil {
return fmt.Errorf("failed to prepare route upsert: %w", err)
}
defer func() { _ = upsert.Close() }()
del, err := tx.PrepareContext(ctx, deleteRouteSQL)
if err != nil {
return fmt.Errorf("failed to prepare route delete: %w", err)
}
defer func() { _ = del.Close() }()
peerNumber, err := tx.PrepareContext(ctx, routePeerNumberSQL)
if err != nil {
return fmt.Errorf("failed to prepare peer number lookup: %w", err)
}
defer func() { _ = peerNumber.Close() }()
for _, change := range changes {
prefix := change.Prefix.String()
if change.Removed {
_, err := del.ExecContext(ctx, prefix, change.OriginASN)
if err != nil {
return fmt.Errorf("failed to delete route %s: %w", prefix, err)
}
continue
}
row, err := routeRow(ctx, peerNumber, change)
if err != nil {
return fmt.Errorf("failed to write route %s: %w", prefix, err)
}
_, err = upsert.ExecContext(ctx, row...)
if err != nil {
return fmt.Errorf("failed to write route %s: %w", prefix, err)
}
}
return nil
}
// routeRow returns the values of the routes row of change, in the order of
// upsertRouteSQL. peerNumber looks up a peer's number by its address. The
// table keeps at most keptRoutesPerRow routes of a pair.
func routeRow(
ctx context.Context, peerNumber *sql.Stmt, change routetable.Change,
) ([]any, error) {
peers, err := change.Peers.MarshalBinary()
if err != nil {
return nil, err
}
row := []any{change.Prefix.String(), change.OriginASN, peers}
for i := range keptRoutesPerRow {
if i >= len(change.Routes) {
row = append(row, nil, nil, nil)
continue
}
r := change.Routes[i]
var number uint
err := peerNumber.QueryRowContext(ctx, r.Peer).Scan(&number)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", errUnknownRoutePeer, r.Peer)
}
if err != nil {
return nil, err
}
path, err := json.Marshal(r.ASPath)
if err != nil {
return nil, err
}
row = append(row, number, string(path), r.Time.Unix())
}
return row, nil
}
// ReadRouteTable returns the routing table that the routes and route_peers
// tables hold, as startup reads it. It reads the peers first, so that the
// table keeps their numbers, then loads each row into the table as it reads
// it. The two reads are separate, so nothing may write the tables meanwhile.
// It fails on a row that names a peer number route_peers lacks.
func (d *Database) ReadRouteTable(ctx context.Context) (*routetable.Table, error) {
peerNumbers, err := d.readRoutePeers(ctx)
if err != nil {
return nil, err
}
addresses := make(map[uint]string, len(peerNumbers))
numbers := new(bitset.BitSet)
for address, number := range peerNumbers {
addresses[number] = address
numbers.Set(number)
}
table := routetable.New(routetable.Params{PeerNumbers: peerNumbers})
rows, err := d.db.QueryContext(ctx, routesSQL)
if err != nil {
return nil, fmt.Errorf("failed to read routes: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
change, err := scanRouteRow(rows, addresses, numbers)
if err != nil {
return nil, err
}
table.Load(change)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("failed to read routes: %w", err)
}
return table, nil
}
// readRoutePeers returns the number of each peer in route_peers, by address.
func (d *Database) readRoutePeers(ctx context.Context) (map[string]uint, error) {
rows, err := d.db.QueryContext(ctx, routePeersSQL)
if err != nil {
return nil, fmt.Errorf("failed to read route peers: %w", err)
}
defer func() { _ = rows.Close() }()
peerNumbers := make(map[string]uint)
for rows.Next() {
var (
number uint
address string
)
err := rows.Scan(&number, &address)
if err != nil {
return nil, fmt.Errorf("failed to read a route peer: %w", err)
}
peerNumbers[address] = number
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("failed to read route peers: %w", err)
}
return peerNumbers, nil
}
// keptRouteColumns are the three columns of one kept route in a routes row.
type keptRouteColumns struct {
peer sql.Null[uint]
asPath sql.NullString
time sql.NullInt64
}
// scanRouteRow returns the content of the routes row rows is at, with each kept
// route's peer named by its address in addresses. It fails when the row names a
// peer number that addresses lacks. numbers holds the numbers of addresses as a
// set, against which a row's whole peer set is checked at once.
func scanRouteRow(
rows *sql.Rows, addresses map[uint]string, numbers *bitset.BitSet,
) (routetable.Change, error) {
var (
change routetable.Change
prefix string
peers []byte
kept [keptRoutesPerRow]keptRouteColumns
)
err := rows.Scan(&prefix, &change.OriginASN, &peers,
&kept[0].peer, &kept[0].asPath, &kept[0].time,
&kept[1].peer, &kept[1].asPath, &kept[1].time)
if err != nil {
return change, fmt.Errorf("failed to read a route: %w", err)
}
change.Prefix, err = netip.ParsePrefix(prefix)
if err != nil {
return change, fmt.Errorf("failed to read route %s: %w", prefix, err)
}
change.Peers = new(bitset.BitSet)
err = change.Peers.UnmarshalBinary(peers)
if err != nil {
return change, fmt.Errorf("failed to read the peers of route %s: %w",
prefix, err)
}
if !numbers.IsSuperSet(change.Peers) {
unknown, _ := change.Peers.Difference(numbers).NextSet(0)
return change, fmt.Errorf("%w: number %d in the peers of route %s",
errUnknownRoutePeer, unknown, prefix)
}
for _, columns := range kept {
if !columns.peer.Valid {
continue
}
address, known := addresses[columns.peer.V]
if !known {
return change, fmt.Errorf("%w: number %d in route %s",
errUnknownRoutePeer, columns.peer.V, prefix)
}
r := routetable.Route{
Peer: address,
Time: time.Unix(columns.time.Int64, 0).UTC(),
}
err := json.Unmarshal([]byte(columns.asPath.String), &r.ASPath)
if err != nil {
return change, fmt.Errorf("failed to read an AS path of route %s: %w",
prefix, err)
}
change.Routes = append(change.Routes, r)
}
return change, nil
}
+527
View File
@@ -0,0 +1,527 @@
package database
import (
"errors"
"fmt"
"math"
"math/rand/v2"
"net/netip"
"os"
"reflect"
"slices"
"testing"
"time"
"github.com/bits-and-blooms/bitset"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// Peers, origin ASes and prefixes of the routes table tests.
const (
peerA = "192.0.2.1"
peerB = "192.0.2.2"
peerC = "2001:db8::3"
peerD = "2001:db8::4"
peerE = "2001:db8::5"
asA = 64500
asB = 64501
// transitAS is the first AS of every test path, the peer's own.
transitAS = 64496
tablePrefixV4 = "10.1.2.0/24"
tablePrefixShortV4 = "10.9.0.0/16"
tablePrefixV6 = "2001:db8:1::/48"
)
// tableTime returns the time second seconds after the start of every routes
// table test.
func tableTime(second int) time.Time {
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
Add(time.Duration(second) * time.Second)
}
// announceTo has peer announce prefix with origin AS asn second seconds after
// the start.
func announceTo(
table *routetable.Table, prefix string, asn int, peer string, second int,
) {
table.Announce(netip.MustParsePrefix(prefix), asn, routetable.Route{
Peer: peer, ASPath: []int{transitAS, asn}, Time: tableTime(second),
})
}
// newRoutesTestDatabase returns a new database in a temporary directory.
func newRoutesTestDatabase(t *testing.T) *Database {
t.Helper()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// writeTableChanges writes what table changed since the last call.
func writeTableChanges(t *testing.T, db *Database, table *routetable.Table) {
t.Helper()
changes, newPeers := table.TakeChanges(math.MaxInt)
err := db.WriteRouteChanges(changes, newPeers)
if err != nil {
t.Fatalf("WriteRouteChanges: %v", err)
}
}
func TestReadRouteTableGivesBackWhatWasWritten(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
source := routetable.New(routetable.Params{})
// Two origins of one prefix: the routes of peerC and peerB are kept for
// asA, peerA keeps only its bit.
announceTo(source, tablePrefixV4, asA, peerA, 10)
announceTo(source, tablePrefixV4, asA, peerB, 20)
announceTo(source, tablePrefixV4, asA, peerC, 30)
announceTo(source, tablePrefixV4, asB, peerD, 40)
// Two routes with the same time.
announceTo(source, tablePrefixV6, asA, peerA, 50)
announceTo(source, tablePrefixV6, asA, peerC, 50)
// An origin whose kept routes were withdrawn while peerA carries it.
announceTo(source, tablePrefixShortV4, asB, peerA, 10)
announceTo(source, tablePrefixShortV4, asB, peerB, 20)
announceTo(source, tablePrefixShortV4, asB, peerC, 30)
source.Withdraw(netip.MustParsePrefix(tablePrefixShortV4), peerB)
source.Withdraw(netip.MustParsePrefix(tablePrefixShortV4), peerC)
writeTableChanges(t, db, source)
checkReadTable(t, db, source)
// A peer moves to another origin, one goes down and a new one comes.
announceTo(source, tablePrefixV4, asB, peerA, 60)
source.PeerDown(peerC)
announceTo(source, tablePrefixV6, asB, peerE, 70)
writeTableChanges(t, db, source)
checkReadTable(t, db, source)
}
func TestReadRouteTableGivesATableThatTakesLaterUpdates(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
source := routetable.New(routetable.Params{})
// peerA and peerB carry tablePrefixV4, peerB and peerC tablePrefixV6.
announceTo(source, tablePrefixV4, asA, peerA, 10)
announceTo(source, tablePrefixV4, asA, peerB, 20)
announceTo(source, tablePrefixV6, asA, peerB, 30)
announceTo(source, tablePrefixV6, asA, peerC, 40)
writeTableChanges(t, db, source)
read := checkReadTable(t, db, source)
// The same updates to both tables: a loaded peer withdraws a prefix,
// another one goes down, and a new peer comes.
for _, table := range []*routetable.Table{source, read} {
table.Withdraw(netip.MustParsePrefix(tablePrefixV4), peerA)
table.PeerDown(peerB)
announceTo(table, tablePrefixShortV4, asB, peerD, 50)
}
checkSameReads(t, read, source)
}
func TestReadRouteTableFailsOnAPeerWithoutANumber(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
// Only the routes of peerB and peerC are kept, so only the peer set names
// peerA.
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV4, asA, peerB, 20)
announceTo(table, tablePrefixV4, asA, peerC, 30)
writeTableChanges(t, db, table)
_, err := db.db.ExecContext(t.Context(),
"DELETE FROM route_peers WHERE address = ?", peerA)
if err != nil {
t.Fatalf("deleting a route peer: %v", err)
}
_, err = db.ReadRouteTable(t.Context())
if !errors.Is(err, errUnknownRoutePeer) {
t.Fatalf("ReadRouteTable = %v, want %v", err, errUnknownRoutePeer)
}
}
// checkReadTable checks that the table ReadRouteTable reads answers every read
// as want does, and that reading it recorded no change. It returns that table.
func checkReadTable(
t *testing.T, db *Database, want *routetable.Table,
) *routetable.Table {
t.Helper()
got, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkSameReads(t, got, want)
changes, newPeers := got.TakeChanges(math.MaxInt)
if len(changes) != 0 || len(newPeers) != 0 {
t.Errorf("the read recorded changes %+v and new peers %v, want none",
changes, newPeers)
}
return got
}
// checkSameReads checks that got answers every read as want does, for the
// prefixes and the origin ASes the tests use.
func checkSameReads(t *testing.T, got, want *routetable.Table) {
t.Helper()
if !reflect.DeepEqual(got.Counts(), want.Counts()) {
t.Errorf("Counts() = %+v, want %+v", got.Counts(), want.Counts())
}
for _, text := range []string{
tablePrefixV4, tablePrefixShortV4, tablePrefixV6,
} {
prefix := netip.MustParsePrefix(text)
if !reflect.DeepEqual(got.Origins(prefix), want.Origins(prefix)) {
t.Errorf("Origins(%s) = %+v, want %+v",
prefix, got.Origins(prefix), want.Origins(prefix))
}
gotSummary, gotFound := got.Lookup(prefix.Addr())
wantSummary, wantFound := want.Lookup(prefix.Addr())
if gotFound != wantFound || !reflect.DeepEqual(gotSummary, wantSummary) {
t.Errorf("Lookup(%s) = %+v, %v; want %+v, %v", prefix.Addr(),
gotSummary, gotFound, wantSummary, wantFound)
}
ipVersion := ipVersionV6
if prefix.Addr().Is4() {
ipVersion = ipVersionV4
}
gotSample := sortedSample(got, ipVersion, prefix.Bits())
wantSample := sortedSample(want, ipVersion, prefix.Bits())
if !reflect.DeepEqual(gotSample, wantSample) {
t.Errorf("SamplePrefixes(%d, %d) = %+v, want %+v",
ipVersion, prefix.Bits(), gotSample, wantSample)
}
}
for _, asn := range []int{asA, asB} {
if !reflect.DeepEqual(got.OriginatedPrefixes(asn),
want.OriginatedPrefixes(asn)) {
t.Errorf("OriginatedPrefixes(%d) = %+v, want %+v", asn,
got.OriginatedPrefixes(asn), want.OriginatedPrefixes(asn))
}
}
}
// sortedSample returns every prefix of the IP version and mask length in
// table, in prefix order.
func sortedSample(
table *routetable.Table, ipVersion, maskLength int,
) []routetable.PrefixSummary {
// More than the tests have of any mask length.
const all = 100
sample := table.SamplePrefixes(ipVersion, maskLength, all)
slices.SortFunc(sample, func(a, b routetable.PrefixSummary) int {
return a.Prefix.Compare(b.Prefix)
})
return sample
}
func TestWriteRouteChangesDeletesTheRowOfARemovedPair(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV4, asB, peerB, 20)
writeTableChanges(t, db, table)
// The prefix's only peer with asA withdraws it.
table.Withdraw(netip.MustParsePrefix(tablePrefixV4), peerA)
writeTableChanges(t, db, table)
var origins []int
rows, err := db.db.QueryContext(t.Context(),
"SELECT origin_asn FROM routes WHERE prefix = ?", tablePrefixV4)
if err != nil {
t.Fatalf("reading the routes: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var origin int
err := rows.Scan(&origin)
if err != nil {
t.Fatalf("reading a route: %v", err)
}
origins = append(origins, origin)
}
err = rows.Err()
if err != nil {
t.Fatalf("reading the routes: %v", err)
}
if !slices.Equal(origins, []int{asB}) {
t.Errorf("rows of %s have origins %v, want only %d",
tablePrefixV4, origins, asB)
}
}
// routePeer is one row of route_peers.
type routePeer struct {
number uint
address string
}
// readRoutePeerRows returns the rows of route_peers in number order.
func readRoutePeerRows(t *testing.T, db *Database) []routePeer {
t.Helper()
rows, err := db.db.QueryContext(t.Context(),
"SELECT number, address FROM route_peers ORDER BY number")
if err != nil {
t.Fatalf("reading the route peers: %v", err)
}
defer func() { _ = rows.Close() }()
var peers []routePeer
for rows.Next() {
var peer routePeer
err := rows.Scan(&peer.number, &peer.address)
if err != nil {
t.Fatalf("reading a route peer: %v", err)
}
peers = append(peers, peer)
}
err = rows.Err()
if err != nil {
t.Fatalf("reading the route peers: %v", err)
}
return peers
}
func TestWriteRouteChangesStoresNewPeers(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV6, asA, peerC, 20)
writeTableChanges(t, db, table)
// peerA is stored already; peerB is new.
announceTo(table, tablePrefixV4, asA, peerA, 30)
announceTo(table, tablePrefixV4, asA, peerB, 40)
writeTableChanges(t, db, table)
want := []routePeer{{0, peerA}, {1, peerC}, {2, peerB}}
got := readRoutePeerRows(t, db)
if !slices.Equal(got, want) {
t.Errorf("route_peers = %+v, want %+v", got, want)
}
}
func TestWriteRouteChangesWritesNothingWhenItFails(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV6, asA, peerB, 20)
// peerB's number is missing, so its kept route cannot be written.
changes, newPeers := table.TakeChanges(math.MaxInt)
delete(newPeers, peerB)
err := db.WriteRouteChanges(changes, newPeers)
if !errors.Is(err, errUnknownRoutePeer) {
t.Fatalf("WriteRouteChanges = %v, want %v", err, errUnknownRoutePeer)
}
peers := readRoutePeerRows(t, db)
if len(peers) != 0 {
t.Errorf("route_peers = %+v after the failed write, want none", peers)
}
var routes int
err = db.db.QueryRowContext(t.Context(),
"SELECT COUNT(*) FROM routes").Scan(&routes)
if err != nil {
t.Fatalf("counting the routes: %v", err)
}
if routes != 0 {
t.Errorf("routes holds %d rows after the failed write, want none", routes)
}
}
// Parameters for BenchmarkReadRouteTable.
const (
// benchTablePairs is how many prefix and origin AS pairs the table holds:
// one origin AS for each prefix. Every benchTableV6Every-th prefix is an
// IPv6 /48, the others IPv4 /24s.
benchTablePairs = 200000
benchTableV6Every = 5
// benchTablePeers is how many peers there are, about as many as RIS has.
// benchTablePeersPerPair is how many of them, picked at random, carry each
// pair: RIS's 460 million routes over 1.4 million pairs.
benchTablePeers = 1400
benchTablePeersPerPair = 330
// benchTableBatch is how many changes each write holds.
benchTableBatch = 10000
)
// BenchmarkReadRouteTable times the read of a routing table of 200,000 prefix
// and origin AS pairs, each carried by 330 of 1,400 peers and keeping two
// routes. It writes that table first. It reports the bytes of the database
// file per pair. It is run by hand, not by make test, for example in the test
// image:
//
// go test -run '^$' -bench ReadRouteTable -benchtime 5x ./internal/database/
func BenchmarkReadRouteTable(b *testing.B) {
db, err := New(&config.Config{StateDir: b.TempDir()}, logger.New())
if err != nil {
b.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
//nolint:gosec // The benchmark needs the same table every run.
rng := rand.New(rand.NewPCG(1, 2))
newPeers := make(map[string]uint, benchTablePeers)
for number := range uint(benchTablePeers) {
newPeers[benchPeerAddress(number)] = number
}
for first := 0; first < benchTablePairs; first += benchTableBatch {
changes := make([]routetable.Change, 0, benchTableBatch)
for pair := first; pair < first+benchTableBatch; pair++ {
changes = append(changes, benchTableChange(rng, pair))
}
err := db.WriteRouteChanges(changes, newPeers)
if err != nil {
b.Fatalf("WriteRouteChanges: %v", err)
}
newPeers = nil
}
// The WAL is copied into the database file, whose size is reported.
err = db.Checkpoint(b.Context())
if err != nil {
b.Fatalf("Checkpoint: %v", err)
}
file, err := os.Stat(db.path)
if err != nil {
b.Fatalf("reading the database file's size: %v", err)
}
for b.Loop() {
table, err := db.ReadRouteTable(b.Context())
if err != nil {
b.Fatalf("ReadRouteTable: %v", err)
}
counts := table.Counts()
if counts.IPv4Prefixes+counts.IPv6Prefixes != benchTablePairs {
b.Fatalf("read %d prefixes, want %d",
counts.IPv4Prefixes+counts.IPv6Prefixes, benchTablePairs)
}
}
// After the loop: b.Loop drops the metrics reported before it starts.
b.ReportMetric(float64(file.Size())/benchTablePairs, "file-bytes/pair")
}
// benchPeerAddress returns the address of the peer numbered number.
func benchPeerAddress(number uint) string {
const addressesPerBlock = 256
return fmt.Sprintf("198.18.%d.%d",
number/addressesPerBlock, number%addressesPerBlock)
}
// benchTableChange returns the content of the pair numbered pair: its prefix,
// its origin AS, benchTablePeersPerPair peers picked at random, and the routes
// of two of them, each with an AS path of four ASes.
func benchTableChange(rng *rand.Rand, pair int) routetable.Change {
const (
firstOrigin = 64500
transitASes = 50000
olderSeconds = 10
)
text := fmt.Sprintf("%d.%d.%d.0/24", 1+pair>>16, (pair>>8)%256, pair%256)
if pair%benchTableV6Every == 0 {
text = fmt.Sprintf("2001:%x:%x::/48", pair>>16, pair%65536)
}
prefix := netip.MustParsePrefix(text)
origin := firstOrigin + pair%transitASes
peers := bitset.New(benchTablePeers)
for peers.Count() < benchTablePeersPerPair {
peers.Set(rng.UintN(benchTablePeers))
}
change := routetable.Change{Prefix: prefix, OriginASN: origin, Peers: peers}
// The routes of the first two peers, newest first.
for i, number := range slices.Collect(peers.EachSet())[:keptRoutesPerRow] {
change.Routes = append(change.Routes, routetable.Route{
Peer: benchPeerAddress(number),
ASPath: []int{
firstOrigin + rng.IntN(transitASes),
firstOrigin + rng.IntN(transitASes),
firstOrigin + rng.IntN(transitASes),
origin,
},
Time: tableTime(pair - i*olderSeconds),
})
}
return change
}
+30 -1
View File
@@ -146,4 +146,33 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_v6_origin_prefix ON live_routes_v6(or
-- last_updated: the oldest and newest route times in /api/v1/stats and
-- /status.json.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(last_updated);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
-- The whole routing table, as internal/routetable keeps it in memory: one row
-- for each prefix and origin AS that some peer carries. Startup reads every
-- row, and the rows of the pairs that change are written back.
CREATE TABLE IF NOT EXISTS routes (
prefix TEXT NOT NULL, -- in the text form Go's net/netip prints
origin_asn INTEGER NOT NULL,
-- The peers whose current route to the prefix has this origin AS: a set
-- of their numbers in route_peers, as the bitset package's MarshalBinary
-- writes it.
peers BLOB NOT NULL,
-- The newest of those routes and the one before it, the two kept in full.
-- Each has its peer's number in route_peers, its AS path as a JSON array,
-- and its time in seconds since 1970, UTC. A route that is not kept has
-- NULL in its three columns.
newest_peer INTEGER,
newest_as_path TEXT,
newest_time INTEGER,
older_peer INTEGER,
older_as_path TEXT,
older_time INTEGER,
PRIMARY KEY (prefix, origin_asn)
) WITHOUT ROWID;
-- The peers of the routes table, each with the number it has there.
CREATE TABLE IF NOT EXISTS route_peers (
number INTEGER PRIMARY KEY,
address TEXT NOT NULL UNIQUE
);
+155
View File
@@ -0,0 +1,155 @@
package routetable_test
import (
"fmt"
"math"
"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(math.MaxInt)
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
}
+233
View File
@@ -0,0 +1,233 @@
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 {
// Pairs counts the prefix and origin AS pairs that some peer carries.
Pairs int
// Peers counts the peers that have a number: those given in
// Params.PeerNumbers and those numbered since.
Peers int
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{
Peers: len(t.peerNumbers),
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
}
for _, prefixes := range t.byOrigin {
counts.Pairs += len(prefixes)
}
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
}
+265
View File
@@ -0,0 +1,265 @@
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{
Pairs: 3,
Peers: 3,
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{
Pairs: 3,
Peers: 3,
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. It keeps its number.
table.PeerDown(peerC)
checkCounts(t, table, routetable.Counts{
Pairs: 2,
Peers: 3,
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)
}
}
+677
View File
@@ -0,0 +1,677 @@
// 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"
"maps"
"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[n] counts the prefixes the peer numbered n carries, for
// each n below nextPeerNumber. It is a slice, not a map, because Load
// counts once for every peer of every pair it loads: with a map, reading
// a full table at startup took several times as long.
peerPrefixes []int
// newPeers and changed are what TakeChanges hands over: the peers
// numbered since it last ran, and the prefix and origin AS pairs changed
// since it last took them.
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)),
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)
}
t.peerPrefixes = make([]int, t.nextPeerNumber)
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)
}
}
}
}
}
// Load puts one prefix and origin AS pair that the database holds into the
// table, as startup does with each row it reads. The change's peers join the
// pair's peer set, and its routes are kept as an announcement's would be.
// Unlike an update, Load records no change, so TakeChanges does not hand the
// pair back to be written again. Loading a pair twice changes nothing.
//
// The caller makes sure that every peer change names, in its peer set or in
// its routes, has a number in Params.PeerNumbers.
func (t *Table) Load(change Change) {
t.mu.Lock()
defer t.mu.Unlock()
// A removed pair has no peers, and a pair without peers is not in the
// table.
if change.Peers == nil || change.Peers.None() || !change.Prefix.IsValid() {
return
}
prefix := change.Prefix.Masked()
o := t.findOrAddOrigin(prefix, change.OriginASN)
wasWithoutKeptRoute := o.withoutKeptRoute()
// The peers not yet in the peer set join it in one union, which takes
// less time than setting their bits one at a time.
joining := change.Peers.Difference(&o.peers)
o.peers.InPlaceUnion(joining)
t.familyOf(prefix.Addr()).routes += int(joining.Count())
for peer := range joining.EachSet() {
t.peerPrefixes[peer]++
}
// Oldest first, as they were announced, so that two routes with the same
// time are kept in the order they had.
for _, r := range slices.Backward(change.Routes) {
peer := t.peerNumbers[r.Peer]
t.dropKeptRoute(o, peer)
t.keepIfNewest(o, route{
peer: peer,
path: slices.Clone(r.ASPath),
time: r.Time,
})
}
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
}
// TakeChanges returns at most maxChanges of the prefix and origin AS pairs that
// changed since they were last taken, in prefix order, each with its current
// content. The other changed pairs wait for a later call. It also returns every
// peer numbered since it last ran, by address, so a pair taken later names only
// peers already returned. It then forgets what it returned.
func (t *Table) TakeChanges(maxChanges int) ([]Change, map[string]uint) {
t.mu.Lock()
defer t.mu.Unlock()
changes := make([]Change, 0, min(maxChanges, len(t.changed)))
for key := range t.changed {
if len(changes) == maxChanges {
break
}
changes = append(changes, t.currentContent(key))
delete(t.changed, key)
}
// A map keeps the memory of its largest size, so an emptied one is
// replaced.
if len(t.changed) == 0 {
t.changed = make(map[pair]struct{})
}
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)
return changes, newPeers
}
// PutBackChanges records again what one TakeChanges call returned, for a
// caller that could not write it. The next TakeChanges returns those peers
// again, and those pairs wait to be taken again like any changed pair. Each is
// then taken with its content at that time, so a later change of a pair
// replaces the earlier.
func (t *Table) PutBackChanges(changes []Change, newPeers map[string]uint) {
t.mu.Lock()
defer t.mu.Unlock()
for _, change := range changes {
t.changed[pair{prefix: change.Prefix, asn: change.OriginASN}] = struct{}{}
}
maps.Copy(t.newPeers, newPeers)
}
// ChangeCount returns how many changed prefix and origin AS pairs wait for
// TakeChanges.
func (t *Table) ChangeCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.changed)
}
// 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.peerPrefixes = append(t.peerPrefixes, 0)
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{}{}
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
if o.peers.None() {
t.removeOrigin(prefix, o)
}
}
// countOriginWithoutKeptRoute brings the count of origins without a kept route
// up to date after a change to o. wasWithoutKeptRoute says whether o was such
// an origin before the change.
func (t *Table) countOriginWithoutKeptRoute(o *origin, wasWithoutKeptRoute bool) {
if wasWithoutKeptRoute {
t.originsWithoutKeptRoute--
}
if o.withoutKeptRoute() {
t.originsWithoutKeptRoute++
}
}
// 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
}
+667
View File
@@ -0,0 +1,667 @@
package routetable_test
import (
"math"
"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"
peerD = "2001:db8::4"
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(math.MaxInt)
// 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{})
}
// TestTakeChangesTakesAtMostMaxChanges changes three prefix and origin AS
// pairs, then takes the changes two at a time. The first call must return two
// pairs and both new peers, and the second the third pair and no peer.
func TestTakeChangesTakesAtMostMaxChanges(t *testing.T) {
t.Parallel()
const maxChanges = 2
table := routetable.New(routetable.Params{})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
table.Announce(v4, asA, routeOf(peerA, asA, 10))
table.Announce(v4, asB, routeOf(peerB, asB, 10))
table.Announce(v6, asA, routeOf(peerA, asA, 10))
first, firstPeers := table.TakeChanges(maxChanges)
second, secondPeers := table.TakeChanges(maxChanges)
if len(first) != maxChanges || len(second) != 1 {
t.Fatalf("TakeChanges(%d) returned %d and then %d pairs, want %d and 1",
maxChanges, len(first), len(second), maxChanges)
}
wantPeers := map[string]uint{peerA: 0, peerB: 1}
if !reflect.DeepEqual(firstPeers, wantPeers) || len(secondPeers) != 0 {
t.Errorf("TakeChanges(%d) returned the new peers %v and then %v, "+
"want %v and none", maxChanges, firstPeers, secondPeers, wantPeers)
}
for _, c := range first {
if c.Prefix == second[0].Prefix && c.OriginASN == second[0].OriginASN {
t.Errorf("both calls returned %s with AS%d", c.Prefix, c.OriginASN)
}
}
}
// TestPutBackChanges takes a table's changes and puts them back, as a caller
// does whose write failed. The next TakeChanges must return them again, with
// each pair's content then, and with the changes since.
func TestPutBackChanges(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
table.Announce(v4, asA, routeOf(peerA, asA, 10))
table.Announce(v6, asA, routeOf(peerA, asA, 10))
table.PutBackChanges(table.TakeChanges(math.MaxInt))
if got := table.ChangeCount(); got != 2 {
t.Errorf("ChangeCount() = %d after the changes were put back, want 2",
got)
}
// peerA withdraws v4, so v4's earlier content must not come back; peerB
// announces v6, and is a new peer as well.
table.Withdraw(v4, peerA)
table.Announce(v6, asA, routeOf(peerB, asA, 20))
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Removed: true},
{Prefix: prefixV6, OriginASN: asA, Peers: []uint{0, 1},
Routes: []routetable.Route{
routeOf(peerB, asA, 20), routeOf(peerA, asA, 10),
}},
}, map[string]uint{peerA: 0, peerB: 1})
if got := table.ChangeCount(); got != 0 {
t.Errorf("ChangeCount() = %d after TakeChanges, want 0", got)
}
}
func TestLoad(t *testing.T) {
t.Parallel()
const otherV4 = "10.9.0.0/16"
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
other := netip.MustParsePrefix(otherV4)
// The routes of peerC and peerB to v4 are kept; peerA keeps only its bit.
// The two routes to v6 have the same time. The routes of other were
// withdrawn while peerA still carries it.
source := routetable.New(routetable.Params{})
announceAll(source, prefixV4, asA, 10, peerA, peerB, peerC)
announceAll(source, prefixV4, asB, 40, peerD)
source.Announce(v6, asA, routeOf(peerA, asA, 50))
source.Announce(v6, asA, routeOf(peerC, asA, 50))
announceAll(source, otherV4, asB, 10, peerA, peerB, peerC)
source.Withdraw(other, peerB)
source.Withdraw(other, peerC)
changes, peers := source.TakeChanges(math.MaxInt)
loaded := routetable.New(routetable.Params{PeerNumbers: peers})
// Loading a pair twice changes nothing.
for range 2 {
for _, change := range changes {
loaded.Load(change)
}
}
for _, prefix := range []netip.Prefix{v4, v6, other} {
got, want := loaded.Origins(prefix), source.Origins(prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("Origins(%s) = %+v after the load, want %+v", prefix, got, want)
}
}
if !reflect.DeepEqual(loaded.Counts(), source.Counts()) {
t.Errorf("Counts() = %+v after the load, want %+v",
loaded.Counts(), source.Counts())
}
checkChanges(t, loaded, []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.PutBackChanges(table.TakeChanges(math.MaxInt)) },
func(int) { table.ChangeCount() },
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, taking every change, returns want and
// wantPeers.
func checkChanges(
t *testing.T, table *routetable.Table, want []change,
wantPeers map[string]uint,
) {
t.Helper()
changes, newPeers := table.TakeChanges(math.MaxInt)
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)
}
}
+66 -8
View File
@@ -2,19 +2,25 @@
// running instance of the application and contains pointers to its core
// dependencies, and is responsible for initialization. It also contains the
// ASN WHOIS fetcher for background updates and the database maintainer for
// background maintenance tasks.
// background maintenance tasks. The package also holds the handlers that
// write the feed's updates to the database in batches, and the flush loop
// that the AS, peer and prefix handlers share.
package routewatch
import (
"context"
"errors"
"fmt"
"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"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
@@ -52,8 +58,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{
@@ -85,6 +96,14 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
// Register database handler to process BGP UPDATE messages
if rw.config.EnableBatchedDatabaseWrites {
rw.logger.Info("Using batched database handlers for improved performance")
// The routing table is read before the streamer connects, so that
// the prefix handler applies the feed's updates to all of it.
table, err := rw.readRouteTable(ctx)
if err != nil {
return err
}
// ASHandler maintains the asns table
rw.dbHandler = NewASHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.dbHandler)
@@ -93,8 +112,9 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.peerHandler)
// PrefixHandler maintains the prefixes and live_routes tables
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger)
// PrefixHandler maintains the prefixes and live_routes tables, and
// the routing table in memory and its copy in the routes table
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger, table)
rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker())
rw.streamer.RegisterHandler(rw.prefixHandler)
@@ -122,6 +142,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 +171,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 +223,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",
@@ -230,6 +267,27 @@ func (rw *RouteWatch) closeDatabase(ctx context.Context) {
}
}
// readRouteTable reads the routing table from the database and logs how many
// prefix and origin AS pairs and peers it holds and how long the read took.
// ctx is fx's start context, which ends 15 seconds after the start. Reading a
// full table can take longer, so the read does not stop with ctx.
func (rw *RouteWatch) readRouteTable(
ctx context.Context,
) (*routetable.Table, error) {
start := time.Now()
table, err := rw.db.ReadRouteTable(context.WithoutCancel(ctx))
if err != nil {
return nil, fmt.Errorf("failed to read the routing table: %w", err)
}
counts := table.Counts()
rw.logger.Info("Read the routing table", "pairs", counts.Pairs,
"peers", counts.Peers, "duration_ms", time.Since(start).Milliseconds())
return table, nil
}
// getModule provides all fx dependencies
func getModule() []fx.Option {
return []fx.Option{
@@ -19,6 +19,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
)
@@ -39,6 +40,9 @@ type mockStore struct {
Prefixes map[string]*database.Prefix
Peerings map[string]bool // key is "from_to"
Routes map[string]bool // key is "prefix_origin_peer"
// TablePrefixes holds the prefixes that the written changes of the
// routing table leave some peer carrying.
TablePrefixes map[string]bool
// Track IP versions
IPv4Prefixes int
@@ -48,10 +52,11 @@ type mockStore struct {
// newMockStore creates a new mock store
func newMockStore() *mockStore {
return &mockStore{
ASNs: make(map[int]*database.ASN),
Prefixes: make(map[string]*database.Prefix),
Peerings: make(map[string]bool),
Routes: make(map[string]bool),
ASNs: make(map[int]*database.ASN),
Prefixes: make(map[string]*database.Prefix),
Peerings: make(map[string]bool),
Routes: make(map[string]bool),
TablePrefixes: make(map[string]bool),
}
}
@@ -395,6 +400,30 @@ func (m *mockStore) DeleteLiveRoutesOfPeer(_ string) (int, error) {
return 0, nil
}
// WriteRouteChanges records the prefixes the changes leave some peer carrying,
// whatever their origin AS.
func (m *mockStore) WriteRouteChanges(
changes []routetable.Change, _ map[string]uint,
) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, change := range changes {
if change.Removed {
delete(m.TablePrefixes, change.Prefix.String())
} else {
m.TablePrefixes[change.Prefix.String()] = true
}
}
return nil
}
// ReadRouteTable returns an empty routing table.
func (m *mockStore) ReadRouteTable(_ context.Context) (*routetable.Table, error) {
return routetable.New(routetable.Params{}), nil
}
// GetOrCreateASNBatch mock implementation
func (m *mockStore) GetOrCreateASNBatch(asns map[int]database.SeenTimes) error {
m.mu.Lock()
@@ -466,9 +495,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,
}
@@ -566,7 +595,8 @@ func announcementFeed(prefixes []string) string {
// handler's worker waits on the first one until the server sees the streamer
// close the connection, which only Shutdown does, so all three are still in
// the streamer when Shutdown starts. Each prefix must be in the database when
// Shutdown returns.
// Shutdown returns, both in the prefix tables and in the routing table's
// writes.
func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
t.Parallel()
@@ -600,7 +630,7 @@ func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
Logger: log,
Config: cfg,
})
rw.prefixHandler = NewPrefixHandler(store, log)
rw.prefixHandler = NewPrefixHandler(store, log, routetable.New(routetable.Params{}))
s.RegisterHandler(gatedPrefixHandler{
PrefixHandler: rw.prefixHandler, release: release,
})
@@ -640,9 +670,11 @@ func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
defer store.mu.Unlock()
for _, prefix := range prefixes {
if _, ok := store.Prefixes[prefix]; !ok {
t.Errorf("%s was queued in the streamer when Shutdown started "+
"but is not in the database", prefix)
_, inPrefixTables := store.Prefixes[prefix]
if !inPrefixTables || !store.TablePrefixes[prefix] {
t.Errorf("%s was queued in the streamer when Shutdown started; "+
"written to the prefix tables %t, in the routing table's "+
"writes %t", prefix, inPrefixTables, store.TablePrefixes[prefix])
}
}
}
+14 -34
View File
@@ -52,7 +52,6 @@ type ASHandler struct {
// &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
@@ -70,19 +69,27 @@ type asnOp struct {
// Stop when finished to ensure all pending operations are flushed and the
// background goroutine is terminated.
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
return newASHandler(db, logger, asnBatchTimeout)
}
// newASHandler is NewASHandler with the flush timer's interval, batchTimeout,
// in place of asnBatchTimeout. A test that the timer must not disturb gives an
// interval longer than the test runs.
func newASHandler(
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
) *ASHandler {
h := &ASHandler{
db: db,
logger: logger,
batch: make([]asnOp, 0, asnBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -175,33 +182,6 @@ func (h *ASHandler) Stop(ctx context.Context) {
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *ASHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(asnBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= asnBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending operations and leaves an empty batch in their
// place for HandleMessage to fill. They count in writing until flushBatch has
// written them.
@@ -222,11 +202,11 @@ func (h *ASHandler) takeBatch() []asnOp {
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *ASHandler) flushBatch() {
// holding the mutex during the write. It reports whether there was a batch.
func (h *ASHandler) flushBatch() bool {
batch := h.takeBatch()
if len(batch) == 0 {
return
return false
}
// The earliest and the latest time the batch saw each AS. The batch holds
@@ -250,5 +230,5 @@ func (h *ASHandler) flushBatch() {
h.writing = 0
h.mu.Unlock()
h.lastFlush = time.Now()
return 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{}),
+64
View File
@@ -0,0 +1,64 @@
package routewatch
import (
"context"
"time"
)
// flushLoop is the flush goroutine of the prefix, AS and peer handlers. It is
// the only goroutine that writes a handler's batch. flushBatch takes the batch,
// writes it, and reports whether there was one.
//
// flushLoop calls flushBatch when batchFullCh wakes it. It also calls it every
// batchTimeout, unless it wrote a batch less than batchTimeout ago. When stopCh
// hands it Stop's context, it calls flushBatch once more, unless the stop
// deadline has passed. Then it closes done and returns.
func flushLoop(
batchTimeout time.Duration,
batchFullCh <-chan struct{},
stopCh <-chan context.Context,
done chan<- struct{},
flushBatch func() bool,
) {
defer close(done)
ticker := time.NewTicker(batchTimeout)
defer ticker.Stop()
lastFlush := time.Now()
for {
// Stop goes first. A write can run past the stop deadline while a full
// batch's wake-up or a tick waits beside Stop's context. The select
// below picks one of them at random, but past the deadline no flush
// may start.
select {
case ctx := <-stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
flushBatch()
}
return
default:
}
select {
case <-ticker.C:
if time.Since(lastFlush) >= batchTimeout && flushBatch() {
lastFlush = time.Now()
}
case <-batchFullCh:
if flushBatch() {
lastFlush = time.Now()
}
case ctx := <-stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
flushBatch()
}
return
}
}
}
+187 -24
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"sync"
@@ -13,6 +14,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const (
@@ -119,10 +121,11 @@ func (s *blockingStore) releaseWrites() {
//
//nolint:tagliatelle // field names are the keys the handlers log
type stopLog struct {
Msg string `json:"msg"`
Updates int `json:"updates"`
PeersDown int `json:"peers_down"`
Paths int `json:"paths"`
Msg string `json:"msg"`
Updates int `json:"updates"`
PeersDown int `json:"peers_down"`
TableChanges int `json:"table_changes"`
Paths int `json:"paths"`
}
// newErrorLogger returns a logger that writes only errors, as JSON lines, to
@@ -166,9 +169,10 @@ func returnsInTime(f func()) bool {
}
}
// startBlockedWrite hands h the messages in fill, which fill its batch so that
// the handler writes it to store, and waits until that write starts. store
// holds it until the test calls releaseWrites.
// startBlockedWrite hands h the messages in fill and waits until h starts to
// write them to store. The prefix, AS and peer handlers start when fill fills
// their batch, and the peering handler on its timer. store holds the write
// until the test calls releaseWrites.
func startBlockedWrite(
t *testing.T,
store *blockingStore,
@@ -191,7 +195,7 @@ func startBlockedWrite(
select {
case <-store.writeStarted:
case <-time.After(handleMessageTimeout):
t.Fatal("the handler did not write its full batch")
t.Fatal("the handler did not start to write the messages")
}
}
@@ -271,7 +275,7 @@ func handleAtMaxBatchSize(
}
// stopWhileAWriteIsBlocked starts a blocked write with the messages in fill,
// hands h the message extra, and stops h with a deadline that passes while
// hands h the messages in extra, and stops h with a deadline that passes while
// store holds the write. Stop must return all the same. Then it lets the write
// finish and waits until done, closed when h's flush goroutine returns. Past
// the deadline that goroutine skips its last write, so extra is not written,
@@ -285,7 +289,7 @@ func stopWhileAWriteIsBlocked(
},
done <-chan struct{},
fill []*ristypes.RISMessage,
extra *ristypes.RISMessage,
extra ...*ristypes.RISMessage,
) {
t.Helper()
@@ -294,7 +298,10 @@ func stopWhileAWriteIsBlocked(
defer store.releaseWrites()
startBlockedWrite(t, store, h, fill)
h.HandleMessage(extra)
for _, msg := range extra {
h.HandleMessage(msg)
}
ctx, cancel := context.WithTimeout(t.Context(), stopDeadline)
defer cancel()
@@ -313,6 +320,11 @@ func stopWhileAWriteIsBlocked(
}
}
// TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline also checks the
// routing table's changes: the flush whose delete is held wrote those of the
// fill, so Stop must log the one of the extra prefix as not written. The flush
// timer must not fire. A flush it started before the fill would be the one
// held, and it would not have written the fill's change.
func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
@@ -321,7 +333,8 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
var logs bytes.Buffer
store := newBlockingStore()
h := NewPrefixHandler(store, newErrorLogger(&logs))
h := newPrefixHandler(store, newErrorLogger(&logs),
routetable.New(routetable.Params{}), longerThanAnyTest)
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
// The peer's session goes down first, so the write held is the delete of
// its routes, and Stop must log that peer down too.
@@ -337,9 +350,76 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
}
logged := lastLogged(t, &logs)
if logged.Updates != prefixBatchSize+1 || logged.PeersDown != 1 {
t.Errorf("Stop logged %d updates and %d peer downs not written, "+
"want %d and 1", logged.Updates, logged.PeersDown, prefixBatchSize+1)
if logged.Updates != prefixBatchSize+1 || logged.PeersDown != 1 ||
logged.TableChanges != 1 {
t.Errorf("Stop logged %d updates, %d peer downs and %d table changes "+
"not written, want %d, 1 and 1", logged.Updates, logged.PeersDown,
logged.TableChanges, prefixBatchSize+1)
}
}
// TestPrefixHandlerSkipsAFullBatchAfterTheStopDeadline hands the prefix handler
// a full batch while a write is held, so the batch's wake-up of the flush
// goroutine waits. The stop deadline passes meanwhile. When the write ends, the
// flush goroutine must stop instead of writing the batch.
func TestPrefixHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
full := slices.Repeat([]string{extraPrefix}, prefixBatchSize)
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
prefixAnnouncement(full))
store.mu.Lock()
defer store.mu.Unlock()
if store.prefixes[extraPrefix] {
t.Errorf("%s was written after the stop deadline", extraPrefix)
}
}
// tableWriteBlockingStore is a blockingStore whose writes of the routing
// table's changes also wait until the test calls releaseWrites.
type tableWriteBlockingStore struct {
*blockingStore
}
func (s tableWriteBlockingStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
s.waitForRelease()
return s.mockStore.WriteRouteChanges(changes, newPeers)
}
// TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress holds the write
// of the routing table's changes that a full batch starts, then hands the
// prefix handler one more prefix and stops it with a deadline that passes
// meanwhile. Stop must log as not written both the change the held write took
// and the one since.
func TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress(t *testing.T) {
t.Parallel()
const extraPrefix = "198.51.100.0/24"
var logs bytes.Buffer
blocking := newBlockingStore()
h := NewPrefixHandler(tableWriteBlockingStore{blockingStore: blocking},
newErrorLogger(&logs), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
stopWhileAWriteIsBlocked(t, blocking, h, h.done,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
prefixAnnouncement([]string{extraPrefix}))
if logged := lastLogged(t, &logs); logged.TableChanges != 2 {
t.Errorf("Stop logged %d table changes not written, want 2",
logged.TableChanges)
}
}
@@ -353,7 +433,7 @@ func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
var logs bytes.Buffer
store := newBlockingStore()
h := NewASHandler(store, newErrorLogger(&logs))
h := newASHandler(store, newErrorLogger(&logs), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{{Path: fill}},
@@ -372,6 +452,29 @@ func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
}
}
// TestASHandlerSkipsAFullBatchAfterTheStopDeadline hands the AS handler a full
// batch while a write is held, so the batch's wake-up of the flush goroutine
// waits. The stop deadline passes meanwhile. When the write ends, the flush
// goroutine must stop instead of writing the batch.
func TestASHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
full := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNB}, asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{{Path: fill}},
&ristypes.RISMessage{Path: full})
store.mu.Lock()
defer store.mu.Unlock()
if store.asns[testASNB] {
t.Errorf("AS%d was written after the stop deadline", testASNB)
}
}
func TestPeerHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
@@ -380,7 +483,7 @@ func TestPeerHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
var logs bytes.Buffer
store := newBlockingStore()
h := NewPeerHandler(store, newErrorLogger(&logs))
h := newPeerHandler(store, newErrorLogger(&logs), longerThanAnyTest)
stopWhileAWriteIsBlocked(t, store, h, h.done,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
&ristypes.RISMessage{Peer: otherPeerIP})
@@ -398,6 +501,30 @@ func TestPeerHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
}
}
// TestPeerHandlerSkipsAFullBatchAfterTheStopDeadline hands the peer handler a
// full batch while a write is held, so the batch's wake-up of the flush
// goroutine waits. The stop deadline passes meanwhile. When the write ends, the
// flush goroutine must stop instead of writing the batch.
func TestPeerHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
stopWhileAWriteIsBlocked(t, store, h, h.done,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
slices.Repeat([]*ristypes.RISMessage{{Peer: otherPeerIP}},
peerBatchSize)...)
store.mu.Lock()
defer store.mu.Unlock()
if store.peers[otherPeerIP] {
t.Errorf("peer %s was written after the stop deadline", otherPeerIP)
}
}
// TestPeeringHandlerStopCountsTheWriteInProgress hands a running peering
// handler one AS path and stops it with a deadline that passes while store
// holds Stop's last write, of that path's peering. Stop must log that path as
@@ -437,6 +564,35 @@ func TestPeeringHandlerStopCountsTheWriteInProgress(t *testing.T) {
}
}
// TestPeeringHandlerStartsNoWriteAfterTheStopDeadline has the peering handler's
// timer start a write that store holds, and hands the handler one more AS path
// meanwhile. The stop deadline passes, and a tick of the timer waits. When the
// write ends, the processing goroutine must stop instead of writing the path.
func TestPeeringHandlerStartsNoWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
// The timer fires many times while Stop waits for its deadline.
const processInterval = 10 * time.Millisecond
store := newBlockingStore()
h := newPeeringHandler(store, logger.New(), processInterval)
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{
{Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}}},
},
&ristypes.RISMessage{
Path: ristypes.ASPath{Sequence: []int{testASNC, testASND}},
})
store.mockStore.mu.Lock()
defer store.mockStore.mu.Unlock()
if store.Peerings[fmt.Sprintf("%d_%d", testASNC, testASND)] {
t.Errorf("the peering of AS%d and AS%d was written after the stop "+
"deadline", testASNC, testASND)
}
}
// prefixAnnouncement returns a message in which testPeerIP announces prefixes
// with origin AS testASNA.
func prefixAnnouncement(prefixes []string) *ristypes.RISMessage {
@@ -455,7 +611,7 @@ func TestPrefixHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New())
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
@@ -474,7 +630,7 @@ func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := NewASHandler(store, logger.New())
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{{Path: fill}},
@@ -495,7 +651,7 @@ func TestPeerHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := NewPeerHandler(store, logger.New())
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
handleWhileAWriteIsBlocked(t, store, h,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
&ristypes.RISMessage{Peer: otherPeerIP})
@@ -515,7 +671,7 @@ func TestPrefixHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New())
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
toMax := slices.Repeat([]string{testV4Prefix}, prefixBatchMaxSize)
handleAtMaxBatchSize(t, store, h,
@@ -536,7 +692,7 @@ func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := NewASHandler(store, logger.New())
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
toMax := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchMaxSize)}
handleAtMaxBatchSize(t, store, h,
@@ -553,13 +709,17 @@ func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
}
}
// TestPeerHandlerWaitsAtTheMaximumBatchSize must not have its flush timer fire.
// A timer flush during the fill would take only part of it. The batch would
// then reach its maximum size before the test's last message, and the test
// would hang.
func TestPeerHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
t.Parallel()
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := NewPeerHandler(store, logger.New())
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
message := []*ristypes.RISMessage{{Peer: testPeerIP}}
handleAtMaxBatchSize(t, store, h,
slices.Repeat(message, peerBatchSize),
@@ -592,7 +752,10 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
name: "prefix",
batchSize: prefixBatchSize,
handle: func(n int) chan struct{} {
h := &PrefixHandler{batchFullCh: make(chan struct{}, 1)}
h := &PrefixHandler{
batchFullCh: make(chan struct{}, 1),
table: routetable.New(routetable.Params{}),
}
h.HandleMessage(prefixAnnouncement(
slices.Repeat([]string{testV4Prefix}, n)))
+14 -34
View File
@@ -56,7 +56,6 @@ type PeerHandler struct {
// is &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when peerBatch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
@@ -75,19 +74,27 @@ type peerUpdate struct {
// periodically flushes accumulated peer updates to the database. The handler
// should be stopped by calling Stop when it is no longer needed.
func NewPeerHandler(db database.Store, logger *logger.Logger) *PeerHandler {
return newPeerHandler(db, logger, peerBatchTimeout)
}
// newPeerHandler is NewPeerHandler with the flush timer's interval,
// batchTimeout, in place of peerBatchTimeout. A test that the timer must not
// disturb gives an interval longer than the test runs.
func newPeerHandler(
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
) *PeerHandler {
h := &PeerHandler{
db: db,
logger: logger,
peerBatch: make([]peerUpdate, 0, peerBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -173,33 +180,6 @@ func (h *PeerHandler) Stop(ctx context.Context) {
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PeerHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(peerBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= peerBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending updates and leaves an empty batch in their
// place for HandleMessage to fill. They count in writing until flushBatch has
// written them.
@@ -220,11 +200,11 @@ func (h *PeerHandler) takeBatch() []peerUpdate {
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *PeerHandler) flushBatch() {
// holding the mutex during the write. It reports whether there was a batch.
func (h *PeerHandler) flushBatch() bool {
batch := h.takeBatch()
if len(batch) == 0 {
return
return false
}
// The earliest and the latest time the batch saw each peer, with the peer
@@ -255,5 +235,5 @@ func (h *PeerHandler) flushBatch() {
h.writing = 0
h.mu.Unlock()
h.lastFlush = time.Now()
return true
}
+27 -4
View File
@@ -62,6 +62,15 @@ type PeeringHandler struct {
// periodically processes accumulated AS paths into peering records. The
// handler begins processing immediately upon creation.
func NewPeeringHandler(db database.Store, logger *logger.Logger) *PeeringHandler {
return newPeeringHandler(db, logger, peeringProcessInterval)
}
// newPeeringHandler is NewPeeringHandler with the processing timer's interval,
// processInterval, in place of peeringProcessInterval. A test gives a short
// one to have the timer start a write.
func newPeeringHandler(
db database.Store, logger *logger.Logger, processInterval time.Duration,
) *PeeringHandler {
h := &PeeringHandler{
db: db,
logger: logger,
@@ -71,7 +80,7 @@ func NewPeeringHandler(db database.Store, logger *logger.Logger) *PeeringHandler
}
// Start the periodic processing goroutine
go h.processLoop()
go h.processLoop(processInterval)
return h
}
@@ -163,15 +172,29 @@ func (h *PeeringHandler) Stop(ctx context.Context) {
}
}
// processLoop runs periodically to process AS paths into peerings, and once
// processLoop processes AS paths into peerings every processInterval, and once
// more on Stop.
func (h *PeeringHandler) processLoop() {
func (h *PeeringHandler) processLoop(processInterval time.Duration) {
defer close(h.done)
ticker := time.NewTicker(peeringProcessInterval)
ticker := time.NewTicker(processInterval)
defer ticker.Stop()
for {
// Stop goes first. A write can run past the stop deadline while a
// tick waits beside Stop's context. The select below picks one of
// them at random, but past the deadline no write may start.
select {
case ctx := <-h.stopCh:
// Final processing, unless the stop deadline has passed
if ctx.Err() == nil {
h.processPeerings()
}
return
default:
}
select {
case <-ticker.C:
h.processPeerings()
+148 -71
View File
@@ -13,6 +13,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const (
@@ -38,6 +39,16 @@ const (
// DO NOT reduce this timeout - larger batches are more efficient
prefixBatchTimeout = 1 * time.Second
// maxTableChangesPerWrite is the most changed prefix and origin AS pairs
// of the routing table that one flush writes; the rest wait for the next
// flushes. The change of one pair takes about 430 bytes of memory and 15
// microseconds to write. So a write holds about 9 MB and takes about a
// third of the one-second flush interval, which leaves the rest to the
// batch. The feed peaks at about 5,000 updates a second, each changing at
// most one pair. So the million pairs that a full-table peer's session
// going down changes are written in about a minute.
maxTableChangesPerWrite = 20000
// IP version constants
ipv4Version = 4
ipv6Version = 6
@@ -47,15 +58,19 @@ const (
// database. Routes are added on announcement and deleted on withdrawal or on an
// announcement whose AS path is empty or holds AS 0 anywhere, and all routes of
// a peer are deleted when its BGP session goes down.
//
// It applies the same updates to the routing table in memory as it handles
// them, and writes the table's changes to the database with each flush.
type PrefixHandler struct {
db database.Store
logger *logger.Logger
metrics *metrics.Tracker
table *routetable.Table
// Batching. mu guards batch, held, peersDown, writing and deleting only:
// the flush goroutine takes them under mu and writes them after releasing
// mu, so HandleMessage waits for a database write only while batch is at
// prefixBatchMaxSize.
// Batching. mu guards batch, held, peersDown, writing, deleting and
// writingTableChanges only: the flush goroutine takes them under mu and
// writes them after releasing mu, so HandleMessage waits for a database
// write only while batch is at prefixBatchMaxSize.
mu sync.Mutex
batch []prefixUpdate
// held holds the updates that the peers whose route delete failed in the
@@ -74,11 +89,13 @@ type PrefixHandler struct {
// deleting is how many peers from peersDown the flush goroutine took and
// has not yet deleted the routes of.
deleting int
// writingTableChanges is how many changes the flush goroutine took from
// the routing table and has not finished writing.
writingTableChanges int
// batchTaken is signalled when the flush goroutine takes batch; its L is
// &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
@@ -97,21 +114,45 @@ type prefixUpdate struct {
endsInSet bool
}
// NewPrefixHandler creates a new batched prefix tracking handler
func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
// announcesRoute says whether u gives the peer a route to the prefix: an
// announcement whose path gives an origin, or ends in an AS set of several
// members, which gives origin 0 (none). Any other update removes the peer's
// route for the prefix: a withdrawal, or an announcement whose path is empty
// or holds AS 0 anywhere (the decoder empties such a path). Such an
// announcement replaces the peer's route, as any announcement does, with none
// (RFC 7607 treats AS 0 in a path as a withdrawal).
func (u prefixUpdate) announcesRoute() bool {
return u.messageType == "announcement" && (u.originASN > 0 || u.endsInSet)
}
// NewPrefixHandler creates a new batched prefix tracking handler, which applies
// the updates to table, the routing table read from db at startup.
func NewPrefixHandler(
db database.Store, logger *logger.Logger, table *routetable.Table,
) *PrefixHandler {
return newPrefixHandler(db, logger, table, prefixBatchTimeout)
}
// newPrefixHandler is NewPrefixHandler with the flush timer's interval,
// batchTimeout, in place of prefixBatchTimeout. A test that the timer must not
// disturb gives an interval longer than the test runs.
func newPrefixHandler(
db database.Store, logger *logger.Logger, table *routetable.Table,
batchTimeout time.Duration,
) *PrefixHandler {
h := &PrefixHandler{
db: db,
logger: logger,
table: table,
batch: make([]prefixUpdate, 0, prefixBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -165,18 +206,22 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
h.batchTaken.Wait()
}
// Withdrawals are queued before announcements, because the batch keeps
// each peer's last update for a prefix: a message that lists a prefix as
// both withdrawn and announced keeps the route (RFC 4271 section 4.3).
// A withdrawal names only the prefix and peer: the message's AS path, and
// so its origin AS, belongs to the announced prefixes.
// Withdrawals are queued, and applied to the routing table, before
// announcements, because the batch keeps each peer's last update for a
// prefix: a message that lists a prefix as both withdrawn and announced
// keeps the route (RFC 4271 section 4.3). A withdrawal names only the
// prefix and peer: the message's AS path, and so its origin AS, belongs
// to the announced prefixes.
for _, prefix := range msg.Withdrawals {
h.batch = append(h.batch, prefixUpdate{
update := prefixUpdate{
prefix: canonicalPrefix(prefix),
peer: msg.Peer,
messageType: "withdrawal",
timestamp: timestamp,
})
}
h.batch = append(h.batch, update)
h.applyToTable(update)
// Record withdrawal in metrics
if h.metrics != nil {
h.metrics.RecordWithdrawal()
@@ -185,7 +230,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
for _, announcement := range msg.Announcements {
for _, prefix := range announcement.Prefixes {
h.batch = append(h.batch, prefixUpdate{
update := prefixUpdate{
prefix: canonicalPrefix(prefix),
originASN: originASN,
peer: msg.Peer,
@@ -193,7 +238,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
timestamp: timestamp,
path: path,
endsInSet: len(msg.Path.Set) > 0,
})
}
h.batch = append(h.batch, update)
h.applyToTable(update)
// Record announcement in metrics
if h.metrics != nil {
h.metrics.RecordAnnouncement()
@@ -212,12 +260,15 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
}
}
// Stop stops the flush goroutine, which writes the updates and peer downs the
// handler holds before it returns, and waits for it until ctx ends. Once ctx
// has ended the flush goroutine skips that last write, and a write still
// running is lost when the process exits. A peer whose route delete fails in
// the last write is not retried either. Stop logs how many updates are not
// written and how many peer downs have not had their routes deleted.
// Stop stops the flush goroutine, which makes one last flush before it returns,
// and waits for it until ctx ends. That flush writes the updates and peer downs
// the handler holds, and at most maxTableChangesPerWrite of the routing table's
// changes. Once ctx has ended the flush goroutine skips that last flush, and a
// write still running is lost when the process exits. A peer whose route delete
// fails in the last flush is not retried either, nor is a failed write of the
// table's changes. Stop logs how many updates are not written, how many peer
// downs have not had their routes deleted, and how many of the table's changes
// are not written.
func (h *PrefixHandler) Stop(ctx context.Context) {
h.stopCh <- ctx
@@ -229,22 +280,24 @@ func (h *PrefixHandler) Stop(ctx context.Context) {
h.mu.Lock()
unwritten := h.writing + len(h.batch) + len(h.held)
peersDown := h.deleting + len(h.peersDown)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
h.mu.Unlock()
if unwritten == 0 && peersDown == 0 {
if unwritten == 0 && peersDown == 0 && tableChanges == 0 {
return
}
if ctx.Err() != nil {
h.logger.Error("Stop deadline passed before the prefix handler wrote "+
"its updates", "updates", unwritten, "peers_down", peersDown)
"its updates", "updates", unwritten, "peers_down", peersDown,
"table_changes", tableChanges)
return
}
h.logger.Error("The prefix handler stopped without deleting the routes "+
"of peers whose session went down", "updates", unwritten,
"peers_down", peersDown)
h.logger.Error("The prefix handler stopped before it wrote everything",
"updates", unwritten, "peers_down", peersDown,
"table_changes", tableChanges)
}
// peerSessionDown notes that the peer's BGP session went down, so that the next
@@ -252,11 +305,14 @@ func (h *PrefixHandler) Stop(ctx context.Context) {
// peer's updates still in the batch or held came before the down, which
// replaces them, so they are dropped; updates the peer sends later stay in the
// batch and are written after the delete. A peer already in peersDown is not
// added again: one delete removes all of its routes.
// added again: one delete removes all of its routes. In the routing table in
// memory the down takes effect at once.
func (h *PrefixHandler) peerSessionDown(peer string) {
h.mu.Lock()
defer h.mu.Unlock()
h.table.PeerDown(peer)
isPeers := func(update prefixUpdate) bool { return update.peer == peer }
h.batch = slices.DeleteFunc(h.batch, isPeers)
h.held = slices.DeleteFunc(h.held, isPeers)
@@ -266,33 +322,6 @@ func (h *PrefixHandler) peerSessionDown(peer string) {
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PrefixHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(prefixBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= prefixBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending updates, the held ones first, and the peers in
// peersDown, and leaves an empty batch, no held updates and no peers in their
// place for HandleMessage to fill. The updates count in writing until
@@ -321,12 +350,15 @@ func (h *PrefixHandler) takeBatch() ([]prefixUpdate, []string) {
return batch, peersDown
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
func (h *PrefixHandler) flushBatch() {
// flushBatch writes the routing table's changes, then takes the pending batch
// and writes it to the database, without holding the mutex during the writes.
// It reports whether there was a batch, or a peer down to delete the routes of.
func (h *PrefixHandler) flushBatch() bool {
h.writeTableChanges()
batch, peersDown := h.takeBatch()
if len(batch) == 0 && len(peersDown) == 0 {
return
return false
}
startTime := time.Now()
@@ -370,15 +402,7 @@ func (h *PrefixHandler) flushBatch() {
)
for _, update := range lastUpdates {
// An announcement is stored when its path gives an origin, or when it
// ends in an AS set of several members: that route is stored with
// origin 0 (none). Any other update deletes the peer's route for the
// prefix: a withdrawal, or an announcement whose path is empty or
// holds AS 0 anywhere (the decoder empties such a path). Such an
// announcement replaces the peer's route, as any announcement does,
// with none (RFC 7607 treats AS 0 in a path as a withdrawal).
if update.messageType == "announcement" &&
(update.originASN > 0 || update.endsInSet) {
if update.announcesRoute() {
// Create live route for batch upsert
route := h.createLiveRoute(update)
if route != nil {
@@ -418,7 +442,60 @@ func (h *PrefixHandler) flushBatch() {
"duration_ms", elapsed.Milliseconds(),
)
h.lastFlush = time.Now()
return true
}
// applyToTable applies update to the routing table in memory: as an
// announcement when it gives the peer a route, otherwise as a withdrawal. A
// prefix that does not parse is left out; flushBatch logs it.
func (h *PrefixHandler) applyToTable(update prefixUpdate) {
prefix, err := netip.ParsePrefix(update.prefix)
if err != nil {
return
}
if !update.announcesRoute() {
h.table.Withdraw(prefix, update.peer)
return
}
h.table.Announce(prefix, update.originASN, routetable.Route{
Peer: update.peer,
ASPath: update.path,
Time: update.timestamp,
})
}
// writeTableChanges writes at most maxTableChangesPerWrite changes of the
// routing table in memory, and every peer the table numbered since the last
// write.
// When the write fails, they go back to the table, so that a later write writes
// them again, each pair with its content then.
func (h *PrefixHandler) writeTableChanges() {
h.mu.Lock()
changes, newPeers := h.table.TakeChanges(maxTableChangesPerWrite)
h.writingTableChanges = len(changes)
h.mu.Unlock()
if len(changes) == 0 && len(newPeers) == 0 {
return
}
err := h.db.WriteRouteChanges(changes, newPeers)
h.mu.Lock()
defer h.mu.Unlock()
h.writingTableChanges = 0
if err != nil {
h.logger.Error("Failed to write the routing table's changes; the next "+
"flush retries", "changes", len(changes), "new_peers", len(newPeers),
"error", err)
h.table.PutBackChanges(changes, newPeers)
}
}
// deleteLiveRoutesOfPeers deletes all live routes of each of peers, the peers
@@ -16,6 +16,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const testPeerIP = "2001:db8:ffff::1"
@@ -40,9 +41,7 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: db, logger: logger.New()}
h := prefixHandlerWithoutTimer(db)
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
h.HandleMessage(&ristypes.RISMessage{
@@ -462,8 +461,7 @@ func TestPrefixHandlerDoesNotStoreAPrefixThatDoesNotParse(t *testing.T) {
t.Fatalf("failed to create database: %v", err)
}
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: db, logger: logger.New()}
h := prefixHandlerWithoutTimer(db)
withdrawal := &ristypes.RISMessage{
Type: ristypes.MessageTypeUpdate,
@@ -694,8 +692,7 @@ func TestPrefixHandlerHandlesADownWhileAFlushWrites(t *testing.T) {
release: make(chan struct{}),
}
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: store, logger: logger.New()}
h := prefixHandlerWithoutTimer(store)
h.HandleMessage(peerAnnouncement(testPeerIP, beforeDown))
flushed := make(chan struct{})
@@ -832,10 +829,7 @@ func TestPrefixHandlerRetriesAFailedDeleteOfAPeersRoutes(t *testing.T) {
db := newTestDatabase(t)
store := &firstPeerDeleteFailsStore{Database: db}
// Built without NewPrefixHandler's flush timer, so the test
// flushes itself.
h := &PrefixHandler{db: store, logger: logger.New()}
h := prefixHandlerWithoutTimer(store)
store.whileFailing = func() {
for _, msg := range tt.whileFailing {
h.HandleMessage(msg)
@@ -897,11 +891,7 @@ func TestPrefixHandlerKeepsAtMostABatchOfUpdatesWhileADeleteKeepsFailing(
otherPeersPrefixes := []string{testV4Prefix, testV6Prefix, "2001:db8:2::/48"}
db := newTestDatabase(t)
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{
db: peerDeleteAlwaysFailsStore{Database: db}, logger: logger.New(),
}
h := prefixHandlerWithoutTimer(peerDeleteAlwaysFailsStore{Database: db})
h.HandleMessage(sessionDown())
// The peer announces a /32 for each address from 10.0.0.0 on, each
@@ -953,7 +943,7 @@ func TestPrefixHandlerStopLogsAFailedDeleteWithoutTheDeadline(t *testing.T) {
var logs bytes.Buffer
h := NewPrefixHandler(peerDeleteAlwaysFailsStore{Database: newTestDatabase(t)},
newErrorLogger(&logs))
newErrorLogger(&logs), routetable.New(routetable.Params{}))
h.HandleMessage(sessionDown())
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.Stop(t.Context())
@@ -979,7 +969,8 @@ func TestPrefixHandlerStopLogsNothingAfterADeleteThatSucceeds(t *testing.T) {
var logs bytes.Buffer
h := NewPrefixHandler(newTestDatabase(t), newErrorLogger(&logs))
h := NewPrefixHandler(newTestDatabase(t), newErrorLogger(&logs),
routetable.New(routetable.Params{}))
h.HandleMessage(sessionDown())
h.Stop(t.Context())
@@ -989,6 +980,22 @@ func TestPrefixHandlerStopLogsNothingAfterADeleteThatSucceeds(t *testing.T) {
}
}
// longerThanAnyTest is a flush timer interval that no test lasts. A test whose
// prefix, AS or peer handler must flush only when its batch is full and on Stop
// gives it to newPrefixHandler, newASHandler or newPeerHandler.
const longerThanAnyTest = time.Hour
// prefixHandlerWithoutTimer returns a prefix handler that writes to store and
// applies the updates to an empty routing table. It is built without
// NewPrefixHandler's flush goroutine, so the test flushes itself.
func prefixHandlerWithoutTimer(store database.Store) *PrefixHandler {
return &PrefixHandler{
db: store,
logger: logger.New(),
table: routetable.New(routetable.Params{}),
}
}
// newTestDatabase opens a database in a new temporary directory. It is closed
// when the test ends.
func newTestDatabase(t *testing.T) *database.Database {
@@ -1012,9 +1019,7 @@ func handleBatches(
t.Helper()
db := newTestDatabase(t)
// Built without NewPrefixHandler's flush timer, so the test flushes itself.
h := &PrefixHandler{db: db, logger: logger.New()}
h := prefixHandlerWithoutTimer(db)
for _, batch := range batches {
for _, msg := range batch {
@@ -0,0 +1,534 @@
package routewatch
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/netip"
"reflect"
"slices"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// More peers and prefixes for the tests of the routing table in memory.
const (
secondPeerIP = "2001:db8:ffff::2"
thirdPeerIP = "2001:db8:ffff::3"
fourthPeerIP = "192.0.2.4"
fifthPeerIP = "192.0.2.5"
otherV4Prefix = "198.51.100.0/24"
setV4Prefix = "203.0.113.0/24"
// firstManyPrefix is the first prefix announceManyPrefixes announces.
firstManyPrefix = "10.0.0.0/32"
)
// tableWriteTimeout is how long a test waits for the flush timer to write the
// routing table's changes: a few ticks of it.
const tableWriteTimeout = 5 * prefixBatchTimeout
// tableCase is one test of the routing table in memory: the messages handed to
// the prefix handler, and for each prefix the peers that carry it with each
// origin AS after them.
type tableCase struct {
name string
messages []*ristypes.RISMessage
want map[string]map[int][]string
}
// TestPrefixHandlerAppliesUpdatesToTheRoutingTable hands the prefix handler
// announcements, withdrawals and a peer's session going down.
func TestPrefixHandlerAppliesUpdatesToTheRoutingTable(t *testing.T) {
t.Parallel()
announceAndWithdraw := peerAnnouncement(testPeerIP, testV4Prefix)
announceAndWithdraw.Withdrawals = []string{testV4Prefix}
checkTableCases(t, []tableCase{
{
name: "an announcement",
messages: []*ristypes.RISMessage{peerAnnouncement(testPeerIP, testV4Prefix)},
want: map[string]map[int][]string{testV4Prefix: {testASNB: {testPeerIP}}},
},
{
name: "a withdrawal",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
peerWithdrawal(testPeerIP, testV4Prefix),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
// RFC 4271 section 4.3: the announcement counts, not the withdrawal.
name: "a message that announces and withdraws the prefix",
messages: []*ristypes.RISMessage{announceAndWithdraw},
want: map[string]map[int][]string{testV4Prefix: {testASNB: {testPeerIP}}},
},
{
name: "a peer's session going down",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix, testV6Prefix),
peerAnnouncement(secondPeerIP, testV4Prefix),
sessionDown(),
},
want: map[string]map[int][]string{
testV4Prefix: {testASNB: {secondPeerIP}},
testV6Prefix: {},
},
},
})
}
// TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable hands the
// prefix handler announcements whose AS path gives no origin. One whose path
// holds AS 0 removes the peer's route, as a withdrawal does; one whose path ends
// in an AS set of several members is kept with origin 0.
func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) {
t.Parallel()
announceWithPath := func(path ristypes.ASPath) *ristypes.RISMessage {
msg := peerAnnouncement(testPeerIP, testV4Prefix)
msg.Path = path
return msg
}
checkTableCases(t, []tableCase{
{
name: "a path whose last AS is 0",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
announceWithPath(ristypes.ASPath{Sequence: []int{testASNA, 0}}),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
name: "a path with AS 0 before its end",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
announceWithPath(decodePath(t, testASNA, 0, testASNB)),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
name: "a path that ends in an AS set",
messages: []*ristypes.RISMessage{
announceWithPath(decodePath(t, testASNA, []int{testASNC, testASND})),
},
want: map[string]map[int][]string{testV4Prefix: {0: {testPeerIP}}},
},
})
}
// checkTableCases hands the messages of each case to a new prefix handler and
// checks its routing table right after, with no flush.
func checkTableCases(t *testing.T, tests []tableCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := prefixHandlerWithoutTimer(newMockStore())
for _, msg := range tt.messages {
h.HandleMessage(msg)
}
for prefix, want := range tt.want {
got := tableOrigins(h.table, prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("peers of %s by origin AS = %v, want %v",
prefix, got, want)
}
}
})
}
}
// tableWritesStore is a real database that hands each write of the routing
// table's changes to written once it is made.
type tableWritesStore struct {
*database.Database
written chan []routetable.Change
}
func (s tableWritesStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
err := s.Database.WriteRouteChanges(changes, newPeers)
s.written <- changes
return err
}
// TestPrefixHandlerWritesTheTableChangesEachSecond hands a running prefix
// handler an announcement and then its withdrawal, each far short of a full
// batch. The flush timer must write the change of each to the database.
func TestPrefixHandlerWritesTheTableChangesEachSecond(t *testing.T) {
t.Parallel()
// The two writes the test waits for, and room for one more.
const writesBuffered = 3
store := tableWritesStore{
Database: newTestDatabase(t),
written: make(chan []routetable.Change, writesBuffered),
}
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
defer h.Stop(t.Context())
prefix := netip.MustParsePrefix(testV4Prefix)
for _, step := range []struct {
msg *ristypes.RISMessage
wantRemoved bool
}{
{msg: peerAnnouncement(testPeerIP, testV4Prefix)},
{msg: peerWithdrawal(testPeerIP, testV4Prefix), wantRemoved: true},
} {
h.HandleMessage(step.msg)
select {
case changes := <-store.written:
if len(changes) != 1 || changes[0].Prefix != prefix ||
changes[0].OriginASN != testASNB ||
changes[0].Removed != step.wantRemoved {
t.Fatalf("wrote %+v, want the pair of %s and AS%d with "+
"removed %t", changes, prefix, testASNB, step.wantRemoved)
}
case <-time.After(tableWriteTimeout):
t.Fatalf("the routing table's change was not written within %s",
tableWriteTimeout)
}
}
}
// errTestTableWrite is the error a test store's failing write of the routing
// table's changes returns.
var errTestTableWrite = errors.New("test: the write of the table's changes failed")
// firstTableWriteFailsStore is a real database whose first write of the
// routing table's changes fails, writing nothing. writes counts the writes
// asked for.
type firstTableWriteFailsStore struct {
*database.Database
writes int
}
func (s *firstTableWriteFailsStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
s.writes++
if s.writes == 1 {
return errTestTableWrite
}
return s.Database.WriteRouteChanges(changes, newPeers)
}
// TestPrefixHandlerWritesAFailedTableWriteAgain runs two flushes into a
// database whose first write of the routing table's changes fails. Before the
// first, two new peers announce a prefix each; before the second, one of them
// announces its prefix again with another path to the same origin AS. The
// second write must hold the changes and new peers of the first, with the later
// path, so the database holds the table that is in memory.
func TestPrefixHandlerWritesAFailedTableWriteAgain(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
store := &firstTableWriteFailsStore{Database: db}
h := prefixHandlerWithoutTimer(store)
first := peerAnnouncement(testPeerIP, testV4Prefix)
h.HandleMessage(first)
h.HandleMessage(peerAnnouncement(secondPeerIP, otherV4Prefix))
h.flushBatch()
laterPath := []int{testASNC, testASNB}
later := peerAnnouncement(testPeerIP, testV4Prefix)
later.Path = ristypes.ASPath{Sequence: laterPath}
later.ParsedTimestamp = first.ParsedTimestamp.Add(secondUpdateAfter)
h.HandleMessage(later)
h.flushBatch()
if store.writes != 2 {
t.Fatalf("the store was asked for %d writes of the table's changes, "+
"want 2", store.writes)
}
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkSameTable(t, read, h.table, testV4Prefix, otherV4Prefix)
origins := read.Origins(netip.MustParsePrefix(testV4Prefix))
if len(origins) != 1 || len(origins[0].Routes) != 1 ||
!slices.Equal(origins[0].Routes[0].ASPath, laterPath) {
t.Errorf("Origins(%s) = %+v, want one route with the path %v",
testV4Prefix, origins, laterPath)
}
}
// tableWriteSizesStore is a real database that records how many changes and
// how many new peers each write of the routing table's changes holds.
type tableWriteSizesStore struct {
*database.Database
changes []int
newPeers []int
}
func (s *tableWriteSizesStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
s.changes = append(s.changes, len(changes))
s.newPeers = append(s.newPeers, len(newPeers))
return s.Database.WriteRouteChanges(changes, newPeers)
}
// TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes has one new peer
// change one and a half times as many prefix and origin AS pairs as one write
// takes, then flushes three times. The first write must hold as many pairs as
// one write takes, and the peer. The second must hold the other pairs, and the
// third flush must write nothing. The database must then hold the table in
// memory.
func TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes(t *testing.T) {
t.Parallel()
const (
pairs = maxTableChangesPerWrite * 3 / 2
flushes = 3
)
db := newTestDatabase(t)
store := &tableWriteSizesStore{Database: db}
h := prefixHandlerWithoutTimer(store)
announceManyPrefixes(h.table, pairs)
for range flushes {
h.flushBatch()
}
wantChanges := []int{maxTableChangesPerWrite, pairs - maxTableChangesPerWrite}
wantNewPeers := []int{1, 0}
if !slices.Equal(store.changes, wantChanges) ||
!slices.Equal(store.newPeers, wantNewPeers) {
t.Errorf("the writes held %v pairs and %v new peers, want %v and %v",
store.changes, store.newPeers, wantChanges, wantNewPeers)
}
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkSameTable(t, read, h.table, firstManyPrefix)
}
// TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten stops a prefix handler,
// with no deadline, whose routing table holds more changed pairs than one write
// takes. Its last flush writes one write's worth, so Stop must log the pairs
// that no write took. The flush timer must not fire, or its writes could leave
// no pairs for Stop to log.
func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
t.Parallel()
const pairs = maxTableChangesPerWrite + 1
var logs bytes.Buffer
store := &tableWriteSizesStore{Database: newTestDatabase(t)}
table := routetable.New(routetable.Params{})
announceManyPrefixes(table, pairs)
h := newPrefixHandler(store, newErrorLogger(&logs), table, longerThanAnyTest)
h.Stop(t.Context())
written := 0
for _, n := range store.changes {
written += n
}
if logged := lastLogged(t, &logs); logged.TableChanges != pairs-written {
t.Errorf("Stop logged %d table changes not written, want %d",
logged.TableChanges, pairs-written)
}
}
// readLog holds what readRouteTable logs.
//
//nolint:tagliatelle // field names are the keys readRouteTable logs
type readLog struct {
Msg string `json:"msg"`
Pairs int `json:"pairs"`
Peers int `json:"peers"`
DurationMs *int64 `json:"duration_ms"`
}
// TestARestartReadsTheRoutingTableBack runs announcements, a withdrawal and a
// peer's session going down through a prefix handler into a database, then
// reads the table back as startup does. The table read must answer as the one
// in memory, and the log must give its pairs and peers. A new handler on the
// table read then takes later updates and writes them as it stops: the
// database must then hold what the first table holds after the same updates.
func TestARestartReadsTheRoutingTableBack(t *testing.T) {
t.Parallel()
prefixes := []string{testV4Prefix, testV6Prefix, otherV4Prefix, setV4Prefix}
db := newTestDatabase(t)
h := prefixHandlerWithoutTimer(db)
viaOtherOrigin := peerAnnouncement(thirdPeerIP, testV4Prefix)
viaOtherOrigin.Path = ristypes.ASPath{Sequence: []int{testASNA, testASNC}}
endsInSet := peerAnnouncement(secondPeerIP, setV4Prefix)
endsInSet.Path = decodePath(t, testASNA, []int{testASNC, testASND})
for _, msg := range []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix, testV6Prefix),
peerAnnouncement(secondPeerIP, testV4Prefix),
viaOtherOrigin,
endsInSet,
peerAnnouncement(thirdPeerIP, otherV4Prefix),
peerWithdrawal(thirdPeerIP, otherV4Prefix),
peerAnnouncement(fourthPeerIP, otherV4Prefix),
sessionDownOf(fourthPeerIP),
} {
h.HandleMessage(msg)
}
h.flushBatch()
var logs bytes.Buffer
rw := &RouteWatch{
db: db,
logger: &logger.Logger{Logger: slog.New(slog.NewJSONHandler(&logs, nil))},
}
table, err := rw.readRouteTable(t.Context())
if err != nil {
t.Fatalf("readRouteTable: %v", err)
}
checkSameTable(t, table, h.table, prefixes...)
// testV4Prefix with two origin ASes, testV6Prefix and setV4Prefix; four
// peers, the last of which carries nothing.
const wantPairs, wantPeers = 4, 4
var logged readLog
err = json.Unmarshal(bytes.TrimSpace(logs.Bytes()), &logged)
if err != nil || logged.Pairs != wantPairs || logged.Peers != wantPeers ||
logged.DurationMs == nil {
t.Errorf("readRouteTable logged %q, want %d pairs, %d peers and the "+
"duration", logs.String(), wantPairs, wantPeers)
}
restarted := NewPrefixHandler(db, logger.New(), table)
for _, msg := range []*ristypes.RISMessage{
peerWithdrawal(testPeerIP, testV6Prefix),
sessionDownOf(secondPeerIP),
peerAnnouncement(fourthPeerIP, otherV4Prefix),
peerAnnouncement(fifthPeerIP, setV4Prefix),
} {
// The first handler only applies the updates to its table.
h.HandleMessage(msg)
restarted.HandleMessage(msg)
}
restarted.Stop(t.Context())
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable after the restart: %v", err)
}
checkSameTable(t, read, h.table, prefixes...)
}
// peerWithdrawal returns an UPDATE message in which peer withdraws prefixes.
func peerWithdrawal(peer string, prefixes ...string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Type: ristypes.MessageTypeUpdate,
Peer: peer,
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
Withdrawals: prefixes,
}
}
// announceManyPrefixes has testPeerIP announce n prefixes in table, with the
// path of peerAnnouncement: the /32 of each address from firstManyPrefix on.
func announceManyPrefixes(table *routetable.Table, n int) {
addr := netip.MustParsePrefix(firstManyPrefix).Addr()
route := routetable.Route{
Peer: testPeerIP,
ASPath: []int{testASNA, testASNB},
Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
for range n {
table.Announce(netip.PrefixFrom(addr, addr.BitLen()), testASNB, route)
addr = addr.Next()
}
}
// sessionDownOf returns the STATE message that says the BGP session of peer
// went down.
func sessionDownOf(peer string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Type: ristypes.MessageTypeState,
Peer: peer,
State: ristypes.StateDown,
}
}
// tableOrigins returns, for each origin AS of prefix in table, the peers that
// carry the prefix with it.
func tableOrigins(table *routetable.Table, prefix string) map[int][]string {
origins := make(map[int][]string)
for _, o := range table.Origins(netip.MustParsePrefix(prefix)) {
origins[o.ASN] = o.Peers
}
return origins
}
// checkSameTable checks that got answers as want does for each of prefixes, and
// that the two tables have the same counts.
func checkSameTable(
t *testing.T, got, want *routetable.Table, prefixes ...string,
) {
t.Helper()
for _, text := range prefixes {
prefix := netip.MustParsePrefix(text)
gotOrigins, wantOrigins := got.Origins(prefix), want.Origins(prefix)
if !reflect.DeepEqual(gotOrigins, wantOrigins) {
t.Errorf("Origins(%s) = %+v, want %+v", text, gotOrigins, wantOrigins)
}
}
gotCounts, wantCounts := got.Counts(), want.Counts()
if !reflect.DeepEqual(gotCounts, wantCounts) {
t.Errorf("Counts() = %+v, want %+v", gotCounts, wantCounts)
}
}
+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 {
+314 -8
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"
@@ -503,6 +506,64 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
}
}
// 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
@@ -981,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
@@ -1004,7 +1066,7 @@ func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
},
{
path: "/api/v1/ip/203.0.113.1",
wantCode: http.StatusNotFound,
wantCode: http.StatusOK,
},
}
@@ -1018,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)
}
}
}
@@ -1106,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},
}
@@ -1130,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 {
@@ -1187,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 (
@@ -1586,6 +1837,61 @@ 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.
+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>
+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)
}
}
}
+69 -10
View File
@@ -7,9 +7,11 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net"
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -68,6 +70,13 @@ const nicbrHeader = "% Copyright (c) Nic.br"
// 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"
@@ -140,18 +149,36 @@ 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. When LACNIC's answer is NIC.br's, it
// returns what NIC.br's own server says instead, unless that query fails.
// 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
@@ -209,19 +236,51 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// 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,
// 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.
// 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 {
answer, err := c.query(ctx, whoisServerNICbr, asQuery)
if err != nil || strings.Contains(answer, nicbrLimitReached) {
asn := lacnicInfo.ASN
c.mu.Lock()
skipping := c.Now().Before(c.nicbrSkipUntil)
c.mu.Unlock()
if skipping {
return lacnicInfo
}
info := c.parseResponse(lacnicInfo.ASN, RIRLACNIC, answer)
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
}
+149 -24
View File
@@ -2,9 +2,12 @@ package whois_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"maps"
"net"
"slices"
@@ -277,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)
@@ -335,7 +338,7 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
func TestLookupASNReadsOnlyTheASAndItsOrganisation(t *testing.T) {
t.Parallel()
client := whois.NewClient()
client := whois.NewClient(slog.New(slog.DiscardHandler))
client.DialContext = fakeServers(t, map[string]string{
apnicIANAQuery: "refer: whois.apnic.net\n",
apnicQuery: apnicRecord,
@@ -539,6 +542,30 @@ 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(
@@ -546,23 +573,12 @@ func lookUpBrazilianAS(
) (*whois.ASNInfo, bool, error) {
t.Helper()
dial := fakeServers(t, answers)
askedNICbr := false
client := whois.NewClient()
client.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == nicbrServer {
askedNICbr = true
}
return dial(ctx, network, address)
}
nicbrAsks := 0
client := newNICbrClient(t, answers, io.Discard, &nicbrAsks)
info, err := client.LookupASN(t.Context(), brazilASN)
return info, askedNICbr, err
return info, nicbrAsks > 0, err
}
// TestLookupASNTakesABrazilianASFromNICbr looks up an AS that NIC.br
@@ -599,17 +615,58 @@ func TestLookupASNTakesABrazilianASFromNICbr(t *testing.T) {
}
}
// 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.
// 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, nicbrAnswer := range map[string]map[string]string{
"connection refused": {},
"no answer": {brazilNICbrQuery: ""},
"over the query limit": {brazilNICbrQuery: nicbrOverLimit},
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()
@@ -618,14 +675,19 @@ func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
brazilIANAQuery: lacnicReferral,
brazilLACNICQuery: lacnicBrazilRecord,
}
maps.Copy(answers, nicbrAnswer)
maps.Copy(answers, test.nicbrAnswer)
info, askedNICbr, err := lookUpBrazilianAS(t, answers)
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 !askedNICbr {
if nicbrAsks == 0 {
t.Error("LookupASN did not ask NIC.br's server")
}
@@ -633,10 +695,73 @@ func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
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.