12 Commits
Author SHA1 Message Date
clawbot 9bd0e91a3b Let the lock go for as long as it was held during Overview's walk (closes #226)
check / check (push) Waiting to run
The home page's cards are worked out once a minute by Overview, which
walks every prefix of the routing table under the table's read lock.
After each batch of 10,000 prefixes, the walk let the lock go and took
it back at once. The prefix handler, waiting for the write lock, then got
in only once per batch. During a walk, its queue could fill, and the
streamer then dropped updates for it.

The walk now lets the lock go for as long as it held the lock for the
batch, as PeerDown does. A test applies another peer's 1,000
announcements while Overview walks a table with more prefixes than five
of the walk's batches.

Model: opus-5-5
2026-11-02 08:00:00 +01:00
clawbot d66c59ef07 Lengthen the WHOIS fetcher's wait after a skipped lookup (closes #222)
check / check (push) Canceled after 0s
A lookup whose RIR is paused is skipped. The skip left the fetcher's wait
as it was. Each lookup asks IANA before it finds its RIR paused. So while
most stale ASes belonged to a paused RIR, the fetcher could ask IANA every
second.

Like a failure, a skipped lookup now makes the wait 1.5 times as long, up
to one minute. The skip is still not counted as a failure, so the status
page's failure figures leave it out.

Model: opus-5-5
2026-11-02 03:59:47 +01:00
clawbot 26d939ad72 Keep taking messages while a peer's session goes down (closes #216)
check / check (push) Canceled after 0s
The table's PeerDown visits every prefix under the write lock, which
takes over a second on a full table. The prefix handler called it
inline, so its queue filled and the streamer dropped updates.

PeerDown now holds the lock for 10,000 prefixes at a time, then lets
the lock go for as long again. The handler runs PeerDown on a separate
goroutine, one peer at a time. The handler holds back the down peer's
later messages until that PeerDown returns. Then the handler applies
them in order, one at a time, and takes new messages in between. It
holds back at most 20,000 messages. Stop waits for all of this before
its last flush.

Model: opus-5-5
2026-11-02 03:27:10 +01:00
clawbot df15e2b2cd Pause a registry's WHOIS server after it refuses a query (closes #218)
check / check (push) Canceled after 0s
The fetcher kept one interval for every registry, so a registry that
refused a query was asked again within seconds. RIPE blocked the test
host's address that way.

The WHOIS client now pauses an RIR's or NIC.br's server for an hour
after the server refuses a query. A refused or reset connection is a
refusal, and so is an %ERROR:201: or %ERROR:202: line. Lookups at the
other servers go on. A lookup whose RIR is paused leaves the AS stale,
and counts as neither a success nor a failure. IANA is never paused,
because a pause of IANA would stop every lookup. NIC.br's hour-long
skip after its over-limit answer is now the same pause.

Model: opus-5-5
2026-11-02 02:59:57 +01:00
clawbot 6b004dfbc3 Keep one entry per AS in the AS handler's batch (closes #217)
check / check (push) Canceled after 0s
HandleMessage added one entry to the batch for every AS of every path,
and waited for the write in progress once the batch held 300,000. In a
burst of the feed its queue then filled, and the streamer dropped
messages.

The batch is now a map from AS number to the earliest and the latest
time of its updates. It holds one entry per AS, so it no longer grows
with the feed's rate. HandleMessage never waits for a write.
asnBatchSize now counts ASes. Stop logs the entries of the batch being
written and of the next batch under "batch_entries". An AS in both
counts twice.

Model: opus-5-5
2026-11-02 02:10:17 +01:00
clawbot 38b43549b9 Make the routewatch tests pass on a loaded host (closes #213)
check / check (push) Canceled after 0s
Five prefix handler tests gave HandleMessage two seconds to apply a full
batch of 25,000 prefixes, which under the race detector on a loaded host
takes longer. A test now waits up to stepTimeout, 15 seconds, for each step
it expects.

The slowest tests filled production limits, such as the peering handler's
500,000 paths. The peering handler now takes its path limit, and the prefix
handler its limit on one write of the table's changes, as they take their
timer interval. Those tests give small limits, and the tests of the maximum
batch size lower it to one batch. This also fixes
#215.

The test helpers bound every wait, Stop included, so a test that hangs in
one fails with its own message.

Model: opus-5-5
2026-10-28 01:44:47 +01:00
clawbot 5ea1bb17fe Keep working out the home page cards after startup (closes #211)
check / check (push) Canceled after 0s
Server.Start ran the loop that works out the home page's cards on the
context it was given. fx ends that context once start-up is done, so the
cards were worked out once and never again. The loop now runs on a
context that only Stop ends, as the other background parts do. Stop
waits for the loop to return, until its own context ends, before it
stops the HTTP server.

A new test starts a server in a synctest bubble and ends its context at
once. On the bubble's fake clock, the cards must be worked out again a
minute later, and not after Stop.

Model: opus-5-5
2026-10-27 22:55:17 +01:00
clawbot 6d4f3e3419 Say in README why RouteWatch exists (closes #204)
check / check (push) Canceled after 0s
The Rationale section now gives the owner's reason for the program: it
is an internal API that our other services ask about IP addresses. It
sees which AS routes each address and which announced prefix holds it
from its own routing table. WHOIS details of each AS, kept in its
database, add to that.

What RIS Live does and does not give stays, shortened. Planned geojs
lookups are left out, because the README describes only what is built.

Model: opus-5-5
2026-10-27 21:34:08 +01:00
clawbot 47aed8761e Give every JSON answer @meta, errors included (closes #201)
check / check (push) Canceled after 0s
A panic in a JSON route's handler answered 500 with an empty body,
because the router's recoverer sits outside the middleware that adds
`@meta`. `JSONValidationMiddleware` now wraps the handler in the same
recoverer, and answers the panic with the JSON error body.

`/status.json` and the health check answered chi's empty 405 to any
method they do not take. They now answer with the same JSON 405 as the
routes under `/api/v1` and `/ip`.

The home page's IP lookup card showed only the answer's `data`. It now
shows the whole answer, `@meta` included.

A table test checks `@meta` on every JSON route, in its successes and
its errors.

Model: opus-5-5
2026-10-27 21:20:07 +01:00
clawbot e8d04f1304 Give every API value with a unit as a number (closes #199)
check / check (push) Canceled after 0s
A value with a unit is now a number, and its field name ends in the
unit, as the vendored API_POLICIES.md requires. @meta.execution_time
becomes execution_time_ms. In the stats answers, uptime,
connection_duration, memory_usage and whois_stats.current_interval
become uptime_seconds, connection_duration_seconds, memory_usage_bytes
and whois_stats.current_interval_seconds. connection_duration_seconds
is null while the feed is not connected. The status page formats the
numbers itself. REPO_POLICIES.md is re-vendored from the same
sneak/prompts commit.

Model: opus-5-5
2026-10-27 20:19:01 +01:00
clawbot a671b8a0b0 Give every IP lookup result the same fields (closes #200)
check / check (push) Canceled after 0s
Every result of /ip/, /ip/{addr} and /api/v1/ip/{ip} now has every
field. A field without a value is null instead of left out, so an
address with no live route has all its route and AS fields set to null.
ptr is an empty list when there are no PTR names.

Two new fields, num_origins and origin_asns, give how many origin ASes
peers carry the netblock with, and which. The routing table's prefix
summary now lists them. asn still names the origin that most peers
carry.

first_seen is null, no longer the zero time, when the database has not
stored the netblock yet.

Model: opus-5-5
2026-10-27 19:54:02 +01:00
clawbot fb5e98feab Trust reverse proxies at private addresses by default (closes #198)
check / check (push) Canceled after 0s
With TRUSTED_PROXIES unset, the server now trusts reverse proxies in the
private ranges of RFC 1918. A proxy such as traefik on a Docker network is
then believed. /ip/, the home page's curl card and the request log give the
client's address from X-Forwarded-For instead of the proxy's address.

A list in TRUSTED_PROXIES replaces the default. TRUSTED_PROXIES set but
empty trusts no proxy, so the config package reads it with os.LookupEnv.
A direct client at a public address still cannot choose its address with
a forged header.

Model: opus-5-5
2026-10-27 19:18:34 +01:00
38 changed files with 2570 additions and 627 deletions
+20
View File
@@ -0,0 +1,20 @@
---
title: API Policies
last_modified: 2026-10-27
---
A value in an API response is never a string with a label or unit in it. A value
of a native type is sent as that type, and its unit goes in the field name, for
example `_ms`.
Wrong:
```json
{ "execution_time": "0 ms" }
```
Right:
```json
{ "execution_time_ms": 0 }
```
+138 -58
View File
@@ -9,19 +9,38 @@ table in real time. It works like this:
## Rationale
RIPE RIS Live streams the BGP updates that RIPE's route collectors receive, as
they happen, from the moment a client connects. It reports changes, not the
routing table they add up to, so it cannot say which AS an IP address is routed
to now or which routes a prefix has. RouteWatch applies the feed's updates to a
routing table that it keeps in memory, with a copy in its own SQLite database.
It answers IP-to-AS lookups and prefix pages from that table.
RouteWatch exists to serve our other services as an internal API for information
about IP addresses.
The feed sends no table when a client connects. So the routing table holds only
the routes announced since RouteWatch first started. An IP-to-AS lookup finds
nothing for an address whose routes have not been announced since then. The
routing table also misses updates RouteWatch drops when it falls behind, and
updates sent while it is stopped. So a lookup or a prefix page can show a route
the feed has withdrawn.
RouteWatch follows BGP routing table updates from RIPE RIS Live. RIS Live
streams the updates that RIPE's route collectors receive, as they happen, from
the moment a client connects. It reports changes, not the routing table they add
up to. RouteWatch applies those changes to a routing table that it keeps in
memory, with a copy in its own SQLite database. From that table, RouteWatch sees
directly:
- which AS each IP address is routed to
- which announced prefix holds each IP address, and how big that prefix is
RouteWatch adds details of each AS from WHOIS, the public directory of the
internet registries. The details include the organisation that holds the AS.
RouteWatch keeps them in its database and looks them up again once they are 30
days old. An IP lookup answers from those stored details and never waits for
WHOIS.
So our own services can ask RouteWatch about an IP address, instead of each
working it out for itself.
RIS Live sends no routing table when a client connects. So the routing table
holds only the routes announced since RouteWatch first started. An IP lookup
finds no route for an address until a prefix that holds it is announced. The
routing table also misses these updates:
- those sent while RouteWatch is stopped
- those that RouteWatch drops when it falls behind
A lookup or a prefix page can therefore show a route that has since been
withdrawn.
## Features
@@ -113,7 +132,13 @@ one of its peers sends an update.
figures. The route figures come from the routing table. `oldest_route` and
`newest_route` are the times of the oldest and the newest kept routes.
`origins_without_kept_route` counts, across all prefixes, the origin ASes that
have no kept route.
have no kept route. Other figures include these:
- `uptime_seconds`: the number of seconds since the RIS Live feed last
connected, or 0 when it has never connected
- `memory_usage_bytes`: the size of the Go heap in use, in bytes
- `whois_stats.current_interval_seconds`: the WHOIS fetcher's wait between
two fetches, in seconds, which can include a fraction of a second
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
database answers and the RIS Live feed is connected, otherwise 503;
`data.status` is `ok` or `error` and `data.checks` says which check failed
@@ -136,20 +161,45 @@ one of its peers sends an update.
### API v1
- `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage
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.
collector, stream and handler queue statistics. `connection_duration_seconds`
is the number of seconds the RIS Live feed has been connected, or null while
it is not connected. `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. `{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 most specific prefix that holds
the address, and its origin AS with that AS's WHOIS details. For a prefix
with several origin ASes, `asn` is the one that most peers carry.
`last_seen` is the time of the prefix's newest kept route.
- `blocklists`: the public blocklists the address is on (see Blocklists)
entry for each address. Every entry has the same fields:
- `ip`: the address
- `ptr`: the address's PTR names, or an empty list when it has none
- `blocklists`: the public blocklists the address is on (see Blocklists), or
an empty list when it is on none
Every other field is about the address's live route. Each is null when the
address has no live route. The fields about the route:
- `netblock`: the most specific prefix that holds the address
- `mask_length`: the netblock's mask length
- `ip_version`: the netblock's IP version, 4 or 6
- `num_peers`: the number of peers that carry the netblock
- `asn`: the origin AS that the most peers carry the netblock with
- `num_origins`: the number of origin ASes that peers carry the netblock
with
- `origin_asns`: those origin ASes, in AS number order
- `first_seen`: when RouteWatch first saw the netblock, or null when the
database has not stored the netblock yet
- `last_seen`: the time of the netblock's newest kept route, or null when
the netblock keeps no route
The fields about the AS in `asn` come from the database. Each is also null
when the database does not have it:
- `handle`: the AS's handle
- `description`: the AS's description
- `as_name`: the AS's name in its WHOIS details
- `org_name`: the name of the organisation that holds the AS
- `org_id`: the organisation's ID at its registry
- `address`: the organisation's postal address
- `country_code`: the organisation's country code
- `abuse_email`: the address that takes abuse reports for the AS
- `rir`: the regional internet registry of the AS
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
@@ -183,10 +233,16 @@ one of its peers sends an update.
missing, empty or only spaces answers 400.
A JSON answer is `{"status": "ok", "data": ...}`, or for an error
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
`@meta` object with the time zone, the API version and the time the request
took. The health check is the exception: its 503 answer is also
`{"status": "ok", "data": ...}`, with `error` in `data.status`.
`{"status": "error", "error": {"msg": ..., "code": ...}}`. The health check is
the exception: its 503 answer is also `{"status": "ok", "data": ...}`, with
`error` in `data.status`. Every JSON answer also carries an `@meta` object:
- `time_zone`: always `UTC`
- `api_version`: `1`
- `execution_time_ms`: the number of milliseconds the request took
Every value with a unit is a number whose field name ends in the unit, such as
`_ms`, `_seconds` or `_bytes`.
A request that runs past its time limit answers 504: the limit is 30 seconds,
and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
@@ -196,11 +252,17 @@ originates no prefix and has no record in the database or in the AS list built
into RouteWatch. AS 0 always answers 404, because it means no known origin. The
AS name search never lists it. A prefix with no live route answers 404 too. The
HTML pages give these errors as plain text. Under `/api/v1` and `/ip/`, a path
no route matches answers 404 with the message `Not found`, and `POST`, `PUT`,
`PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers 405 with
`Method not allowed`. A 500 answer says only `Internal server error`, and a host
name that does not resolve only `could not resolve host name`; the detail goes
to the log.
that matches no route answers 404 with the message `Not found`. A request with
`POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers 405
with `Method not allowed` on these routes:
- the routes under `/api/v1` and `/ip/`
- `/status.json`
- `/.well-known/healthcheck.json`
A 500 answer says only `Internal server error`. The error for a host name that
does not resolve says only `could not resolve host name`. The detail goes to the
log.
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
@@ -211,10 +273,15 @@ so each answer's policy names a random nonce made for that answer, and the
browser applies or runs only the blocks that carry it. The pages therefore use
no `style` attributes, which a nonce cannot cover.
An origin AS of 0 (`origin_asn` in a route, `asn` in an IP lookup, the key `0`
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.
An origin AS of 0 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 0 appears in
these fields:
- `origin_asn` in a route
- `asn` and `origin_asns` in an IP lookup
- the key `0` in a prefix's `origins`
The pages show such a route as having no known origin.
## Blocklists
@@ -362,10 +429,12 @@ the routing table, and everything else from the database.
- Parses BGP UPDATE messages from JSON stream
- Dispatches to registered handlers based on message type
- Each handler has its own queue with backpressure handling
- Handlers batch writes for efficiency: the AS, peer and prefix handlers
write 30,000, 10,000 and 25,000 updates at a time, or after 2, 2 and 1
seconds; the peering handler writes the peerings it has collected every 30
seconds
- Handlers batch writes for efficiency. The AS, peer and prefix handlers
write a batch when it reaches a size, or after a time:
- the AS handler at 30,000 ASes, or after 2 seconds;
- the peer handler at 10,000 updates, or after 2 seconds;
- the prefix handler at 25,000 updates, or after 1 second.
- The peering handler writes the peerings it has collected every 30 seconds
4. **Handler Details**
- **ASHandler**: Tracks all ASNs seen in AS paths
@@ -375,7 +444,9 @@ the routing table, and everything else from the database.
- An announcement gives the peer a route to the prefix.
- A withdrawal removes the peer's route. So does an announcement whose
AS path is empty or holds AS 0 anywhere.
- A peer whose BGP session goes down leaves every prefix.
- A peer whose BGP session goes down leaves every prefix. The handler
holds back the peer's later updates until the peer has left every
prefix, then applies them in order.
Every second it writes the changed prefix and origin AS pairs to the
`routes` table. A write takes at most 20,000 pairs, and the rest follow
@@ -511,8 +582,8 @@ Budget:
- Go heap: a 2.5 GiB soft limit (`GOMEMLIMIT=2560MiB`, set in the image).
- The routing table takes about 700 bytes per prefix and origin AS. A full
table has about 1.4 million of these pairs, so it takes about 1 GB.
- The rest of the heap takes up to about 0.5 GiB. Most of it is the
handlers' batches and queues, described below.
- The rest of the heap takes up to about 0.5 GiB. Most of it is what the
handlers hold, described below.
- The remaining 1 GiB is room for the garbage collector. With much less, it
would run almost all the time.
- SQLite: 656 MiB of page cache.
@@ -565,16 +636,25 @@ What happens at each limit:
`STATE` message, which it holds, in feed order, until there is room: a few
hundred bytes each, about ten a second.
- Handler batches: while the AS, peer or prefix handler writes a batch to the
database, the updates that arrive collect in its next batch. That batch stops
at ten times the batch size:
- 300,000 updates for the AS handler (`asnBatchMaxSize`, about 10 MiB);
- 100,000 for the peer handler (`peerBatchMaxSize`, about 10 MiB);
- 250,000 for the prefix handler (`prefixBatchMaxSize`, about 15 MiB).
database, the updates that arrive collect in its next batch.
- The AS handler's batch holds one entry per AS, with the earliest and the
latest time of its updates. About 80,000 ASes appear in the feed, so the
batch takes at most about 10 MiB, however fast the feed runs.
- The peer handler's batch stops at 100,000 updates (`peerBatchMaxSize`),
about 10 MiB.
- The prefix handler's batch stops at 250,000 updates
(`prefixBatchMaxSize`), about 15 MiB.
At that size the handler takes nothing more off its queue until the write
ends. The queue then fills, and the streamer drops messages as above. With
the batches being written, the three handlers' batches take at most about
0.1 GiB of the Go heap.
At that maximum the peer or prefix handler takes nothing more off its queue
until the write ends. The queue then fills, and the streamer drops messages
as above. With the batches being written, the three handlers' batches take
at most about 0.1 GiB of the Go heap.
- Held-back messages: the prefix handler holds back a peer's messages after the
peer's session goes down. It applies them once the peer has left every prefix.
It holds at most 20,000 such messages (`maxHeldBackMessages`), about 20 MiB.
At that number it takes nothing more off its queue until it has applied one of
them.
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
with the goroutine count and Go memory figures.
@@ -585,11 +665,11 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
- Container port: `8080`.
- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`.
- Environment: nothing. The server trusts a reverse proxy at a private address
without `TRUSTED_PROXIES` (see Configuration). Set it only when the proxy in
front of the app connects from a public address. Leave `XDG_DATA_HOME`,
`GOMEMLIMIT` and `MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch`
is optional and adds the `System stats` memory line to the log.
- Environment: nothing. The server trusts a reverse proxy at an RFC 1918 address
without `TRUSTED_PROXIES` (see Configuration). Set it only when the proxy
connects from any other address. Leave `XDG_DATA_HOME`, `GOMEMLIMIT` and
`MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch` is optional and
adds the `System stats` memory line to the log.
- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap
limit, so on a host with swap Docker allows the same amount of swap again.
- Health check: the image's `HEALTHCHECK` requests
+92 -22
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-18
last_modified: 2026-10-27
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -12,6 +12,7 @@ style conventions are in separate documents:
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
- [API Policies](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/API_POLICIES.md)
---
@@ -174,27 +175,32 @@ style conventions are in separate documents:
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
`script/docker` are the four, and there is no fifth — `script/check` runs the
two gate phases and `script/fmt-check`, and builds no image of its own. In
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
that install system packages and download Go's modules, or right after `FROM`
in a stage with neither, and every step that lints, tests or compiles comes
after it. A step that installs anything else as well, such as a
`script/bootstrap` that also installs node packages, comes after it too. A new
value reruns every `RUN` step after that line; `COPY` steps are compared by
content and need nothing. Docker keeps the layers above the line until the
base image, their own lines or the files they copy change. A stage without the
`ARG` line is served from cache on an unchanged tree, so a repository
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
not pass `--no-cache`: it reruns every step too, but it also builds the layers
above the line again and empties every cache mount, so Go's build cache and
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
survive between check builds, would start empty every time. A bare
`docker build .` is not evidence that anything ran: a sub-second build
reporting success is a cache hit, not a result. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host. When a check is added or changed, prove it works by
planting a defect it must catch and watching the run fail on it, then revert
the defect. A green run alone shows neither that the check ran nor that it
covers what it should.
every stage of the `Dockerfile`, every step that lints, tests or compiles
comes after `ARG CACHEBUST`. `ARG CACHEBUST` comes right after these steps:
- the steps that install system packages
- the steps that download Go's modules
- the step that runs `script/bootstrap` to install a Python `test` phase's
packages from `Pipfile.lock`
A stage with none of these steps declares `ARG CACHEBUST` right after
`FROM`. A step that installs anything else as well, such as a
`script/bootstrap` that also installs node packages, comes after it too. A
new value reruns every `RUN` step after that line; `COPY` steps are compared
by content and need nothing. Docker keeps the layers above the line until
the base image, their own lines or the files they copy change. A stage
without the `ARG` line is served from cache on an unchanged tree, so a
repository re-vendors the scripts and adds the `ARG` lines in one commit.
The scripts do not pass `--no-cache`: it reruns every step too, but it also
builds the layers above the line again and empties every cache mount, so
Go's build cache and golangci-lint's, which the canonical Go `Dockerfile`
below mounts so that they survive between check builds, would start empty
every time. A bare `docker build .` is not evidence that anything ran: a
sub-second build reporting success is a cache hit, not a result. Never
invalidate by pruning — `docker builder prune` and friends destroy a build
cache shared with every other build on the host. When a check is added or
changed, prove it works by planting a defect it must catch and watching the
run fail on it, then revert the defect. A green run alone shows neither that
the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
@@ -309,6 +315,9 @@ style conventions are in separate documents:
a package's findings only when the package's files and those of every
package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged.
- In a Python `test` phase, the layer of the step that runs
`script/bootstrap` to install the packages from `Pipfile.lock`, as
described for that phase below.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example:
@@ -363,6 +372,67 @@ style conventions are in separate documents:
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- **A Python `test` phase that installs its packages from `Pipfile.lock` does it
above `ARG CACHEBUST`, so check builds keep them.** The steps are
`COPY script/ script/`, `COPY Pipfile Pipfile.lock ./` and
`RUN script/bootstrap`, with `ARG CACHEBUST` right after them. Docker keeps
what that `RUN` installed until the base image, `script/`, `Pipfile`,
`Pipfile.lock` or a line up to and including the `RUN` changes. The tests run
after `ARG CACHEBUST`, on every check build. In every other stage, a step that
installs Python packages comes after `ARG CACHEBUST`.
- When `script/bootstrap` runs in that step, it installs nothing beyond
system packages, pipenv and its dependencies, and the packages from
`Pipfile.lock`. It installs no Python there: the tests use the base
image's Python, which the image's hash pins. If it installs anything else
there, the step goes after `ARG CACHEBUST`.
- Every external reference must be pinned by hash, so `script/bootstrap`
installs pipenv and its dependencies by hash. It does so with
`pip install --require-hashes -r` and a requirements file in `script/`
that lists each of them with `==` and its `--hash`. pip refuses to install
a dependency the file does not list.
- It then runs `pipenv install --deploy`, with `--dev` when the tests need
the development packages. `--deploy` installs only what `Pipfile.lock`
names, checks every package against the lockfile's hashes, and fails when
the lockfile is out of date with `Pipfile`.
- A kept layer holds nothing a new install would refuse: pip checked pipenv
and its dependencies, and pipenv checked the lockfile's packages, against
their hashes. pipenv creates its virtual environment with the `virtualenv`
package. The packages that `virtualenv` adds come from wheels inside that
package, so the hash pip checked for it covers them. Two things in the
layer are not fixed by a hash. A package with no ready-built wheel for the
image is built from source. pip downloads the build tools without a hash
check, so a kept layer's build of such a package may differ from a new
one. And system packages keep the versions the package manager gave when
the layer was built; a new install may get newer ones.
- As with Go's modules, the packages are kept in a layer, not a cache mount.
A cache mount is shared by every build on the host, and nothing checks its
files again.
In this example, `script/bootstrap` finds `make` and `git` already
installed, installs pipenv and its dependencies by hash, and runs
`pipenv install --dev --deploy`:
```dockerfile
# Test phase. The 90-second timeout covers both pytest runs.
# python:3.x-slim, YYYY-MM-DD
FROM python@sha256:... AS test
# make and git are base tools that script/bootstrap installs when they
# are missing. Installed here, they stay in their own layer when
# script/ changes.
RUN apt-get update \
&& apt-get install -y --no-install-recommends make git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY script/ script/
COPY Pipfile Pipfile.lock ./
RUN script/bootstrap
ARG CACHEBUST
COPY . .
RUN timeout 90 sh -c 'pipenv run pytest || { \
echo "--- Rerunning with -v for details ---"; \
pipenv run pytest -v; exit 1; }'
```
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step,
with `persist-credentials: false`: `script/cibuild` needs no token, and
+70 -2
View File
@@ -35,9 +35,77 @@ tested.
# Completed Steps
- 2026-11-02: working out the home page's cards no longer stops the prefix
handler. The walk over the routing table holds the table's lock for 10,000
prefixes at a time, then lets the lock go for as long again, as `PeerDown`
does. It used to take the lock back at once. The prefix handler then applied
about one update per 10,000 prefixes walked. During a walk, its queue could
fill, and the streamer then dropped updates for it (closes #226)
- 2026-11-02: like a failed WHOIS lookup, a lookup skipped because its RIR is
paused makes the fetcher wait longer before the next lookup. The longest wait
is one minute. The status page does not count a skip as a failure. A skip used
to leave the wait as it was. Each lookup asks IANA before it finds its RIR
paused. So while most stale ASes belonged to a paused RIR, the fetcher could
ask IANA every second (closes #222)
- 2026-11-02: a peer's session going down no longer stops the prefix handler.
The handler runs the table's `PeerDown` on a separate goroutine, one peer at a
time, and goes on taking messages. `PeerDown` holds the table's lock for
10,000 prefixes at a time, then lets the lock go for as long again. The
handler holds back the down peer's later messages until that peer's `PeerDown`
returns. Then the handler applies them in order, one at a time, and takes new
messages in between. It holds back at most 20,000 messages. At that number,
`HandleMessage` waits until one of them is applied. When `Stop` runs past its
deadline, it logs how many held-back messages were not applied (closes #216)
- 2026-11-02: RouteWatch pauses an RIR's or NIC.br's WHOIS server for an hour
after the server refuses a query. A refused or reset connection is a refusal.
So is an answer with an `%ERROR:201:` or `%ERROR:202:` line. Lookups at the
other servers go on. An AS whose RIR is paused is looked up after the pause.
IANA is never paused, because a pause of IANA would stop every lookup. A
refusal by IANA is an ordinary failure. NIC.br is also paused for an hour
after it says RouteWatch has passed NIC.br's query limit. Before, the fetcher
asked a server again within seconds of a refusal (closes #218)
- 2026-11-02: the AS handler never waits for a database write. Its batch holds
one entry per AS, with the earliest and the latest time of its updates. So the
batch no longer grows with the feed's rate. It used to hold one entry for each
AS of each path and to wait once it held 300,000. In a burst of the feed its
queue then filled, and the streamer dropped messages for it (closes #217)
- 2026-10-27: the tests of `internal/routewatch` no longer fail on a loaded
host. Five of them gave `HandleMessage` two seconds to apply a full prefix
batch, which under the race detector can take longer. A test now waits up to
15 seconds for each step it expects. The tests of the handlers' limits give
the handlers small ones, so the package no longer runs past its 90-second
limit (closes #213, #215)
- 2026-10-27: the server works out the home page's cards every minute until it
stops. It used to work them out only once, at startup: the end of the context
that `Start` is given stopped the refreshes (closes #211)
- 2026-10-27: README "Rationale" says why RouteWatch exists: to answer our other
services' questions about IP addresses, from its own routing table and the
WHOIS details it keeps. It still says what RIS Live does not give (closes
#204)
- 2026-10-27: every JSON answer carries `@meta`. A panic on a JSON route answers
500 with the JSON error body. `/status.json` and the health check answer 405
with the JSON error body to any method they do not take. The 500 and the 405
used to have an empty body. The home page's IP lookup shows the whole JSON
answer, `@meta` included. It used to show only the answer's `data`, or only
the message of an error answer. A test checks `@meta` on every JSON route, in
its successes and its errors (closes #201)
- 2026-10-27: every value in a JSON answer that has a unit is a number, with the
unit at the end of its field name. `@meta.execution_time` is now
`execution_time_ms`. The stats' `uptime`, `connection_duration` and
`memory_usage` are now `uptime_seconds`, `connection_duration_seconds` and
`memory_usage_bytes`. `whois_stats.current_interval` is now
`current_interval_seconds`. They used to be strings such as "12 ms",
"1h18m34s" or "1.1 GB". `connection_duration_seconds` is null while the feed
is not connected. `API_POLICIES.md` is vendored from `sneak/prompts`, and
`REPO_POLICIES.md` re-vendored (closes #199)
- 2026-10-27: every result of the IP lookup has the same fields. A field without
a value is null. It used to be left out. An address with no live route has
every route and AS field set to null, and `ptr` is an empty list when there
are no PTR names. New fields give the number of origin ASes of the netblock,
in `num_origins`, and which they are, in `origin_asns` (closes #200)
- 2026-10-27: with `TRUSTED_PROXIES` unset, the server trusts reverse proxies at
the private addresses of RFC 1918, so `/ip/`, the home page's `curl` card and
the request log give the client a proxy names in `X-Forwarded-For`. They used
the private addresses of RFC 1918. `/ip/`, the home page's `curl` card and the
request log then give the client's address from `X-Forwarded-For`. They used
to give the proxy's address. A list in `TRUSTED_PROXIES` replaces the default,
and `TRUSTED_PROXIES` set but empty trusts no proxy (closes #198)
- 2026-10-27: the home page has cards with figures of the routing table: the
+15 -10
View File
@@ -10,10 +10,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// walkBatch is how many prefixes the walk over the whole table reads in one
// hold of the read lock. The updates waiting for the lock get it in between.
const walkBatch = 10000
// Overview holds figures about the whole table. Working them out takes a walk
// over every prefix, so a caller asks for them now and then and keeps them.
type Overview struct {
@@ -83,8 +79,9 @@ type OriginPeers struct {
// Overview works out the table's overview. It lists at most maxListed bogon
// announcements and at most maxListed prefixes with more than one origin AS:
// the first in prefix order. It walks over every prefix, but holds the read
// lock for walkBatch prefixes at a time.
// the first in prefix order. It visits every prefix in the table, in batches
// of walkBatch. After each batch, it lets the table's lock go for as long as
// it held the lock for the batch.
func (t *Table) Overview(maxListed int) Overview {
var overview Overview
@@ -114,15 +111,18 @@ func (t *Table) Overview(maxListed int) Overview {
}
// walkPrefixes calls visit with every prefix of the table and its origins. It
// holds the read lock, but lets it go after every walkBatch prefixes, so the
// updates waiting for it get in. A prefix that is added or removed in such a
// gap may or may not be visited, as Go allows for a map that changes during a
// range over it. visit must not keep origins once it returns.
// holds the read lock for walkBatch prefixes at a time. After each batch, it
// lets the table's lock go for as long as it held the lock for the batch.
// During a walk over many batches, updates can then take the lock about half
// of the time. The walk may or may not visit a prefix that is added or removed
// while the lock is let go. Go allows this in a range over a map that changes.
// visit must not keep origins once it returns.
func (t *Table) walkPrefixes(visit func(prefix netip.Prefix, origins []*origin)) {
t.mu.RLock()
defer t.mu.RUnlock()
visited := 0
batchStart := time.Now()
for _, f := range []*family{&t.ipv4, &t.ipv6} {
for _, prefixes := range f.byLength {
@@ -131,8 +131,13 @@ func (t *Table) walkPrefixes(visit func(prefix netip.Prefix, origins []*origin))
visited++
if visited%walkBatch == 0 {
held := time.Since(batchStart)
t.mu.RUnlock()
time.Sleep(held)
t.mu.RLock()
batchStart = time.Now()
}
}
}
@@ -131,3 +131,61 @@ func TestWalkPrefixesLetsUpdatesIn(t *testing.T) {
"before its second batch")
}
}
// TestOverviewLetsUpdatesIn applies another peer's 1,000 announcements while
// Overview walks a table with more prefixes than five of the walk's batches.
// After each batch, the walk lets the lock go for as long as it held the lock.
// So all 1,000 announcements must be applied before Overview returns. A walk
// that takes the lock back at once lets in about one update per batch.
func TestOverviewLetsUpdatesIn(t *testing.T) {
t.Parallel()
const (
asn = 1000
peer = "192.0.2.1"
otherPeer = "192.0.2.2"
// prefixCount is more prefixes than five of the walk's batches.
prefixCount = 5*walkBatch + 1
announcements = 1000
)
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
table := New(Params{})
prefixes := make([]netip.Prefix, 0, prefixCount)
addr := netip.MustParseAddr("1.0.0.0")
for range prefixCount {
prefix := netip.PrefixFrom(addr, addr.BitLen())
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
prefixes = append(prefixes, prefix)
addr = addr.Next()
}
overviewDone := make(chan struct{})
go func() {
defer close(overviewDone)
table.Overview(0)
}()
// Wait until Overview holds the read lock. TryLock fails while a reader
// holds it.
for table.mu.TryLock() {
table.mu.Unlock()
runtime.Gosched()
}
for _, prefix := range prefixes[:announcements] {
table.Announce(prefix, asn, Route{Peer: otherPeer, Time: routeTime})
}
select {
case <-overviewDone:
t.Fatal("Overview returned before another peer's 1,000 announcements " +
"were applied")
default:
}
<-overviewDone
}
+6 -1
View File
@@ -14,6 +14,9 @@ type PrefixSummary struct {
// 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
// OriginASNs are all the origin ASes that peers carry the prefix with, in
// AS number order.
OriginASNs []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
@@ -211,10 +214,12 @@ func (t *Table) addresses(peers *bitset.BitSet) []string {
// 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}
summary := PrefixSummary{Prefix: prefix, OriginASNs: make([]int, 0, len(origins))}
mostPeers := 0
for _, o := range origins {
summary.OriginASNs = append(summary.OriginASNs, o.asn)
peers := int(o.peers.Count())
summary.Peers += peers
+14 -11
View File
@@ -48,19 +48,20 @@ func TestLookup(t *testing.T) {
// want is the summary's prefix, or empty when none is found.
want string
originASN int
originASNs []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},
{"10.1.2.3", prefixV4, asA, []int{asA, asB}, 2, 20},
{"::ffff:10.1.2.3", prefixV4, asA, []int{asA, asB}, 2, 20},
{"10.1.3.4", "10.1.0.0/16", asB, []int{asA, asB}, 3, 30},
{"10.2.0.1", "10.0.0.0/8", asA, []int{asA}, 1, 10},
{"10.9.0.1", "10.9.0.0/16", asA, []int{asA}, 1, 0},
{"192.0.2.1", "", 0, nil, 0, 0},
{"2001:db8:1::5", prefixV6, asB, []int{asB}, 1, 20},
{"2001:db8:2::1", "2001:db8::/32", asA, []int{asA}, 1, 10},
{"3000::1", "3000::/16", asA, []int{asA}, 1, 10},
{"2001:db9::1", "", 0, nil, 0, 0},
}
for _, tc := range tests {
@@ -79,6 +80,7 @@ func TestLookup(t *testing.T) {
want := routetable.PrefixSummary{
Prefix: netip.MustParsePrefix(tc.want),
OriginASN: tc.originASN,
OriginASNs: tc.originASNs,
Peers: tc.peers,
}
if tc.newest != 0 {
@@ -166,7 +168,8 @@ func TestSamplePrefixes(t *testing.T) {
for _, got := range sample {
want := routetable.PrefixSummary{
Prefix: got.Prefix, OriginASN: asA, Peers: 1, Newest: timeAt(10),
Prefix: got.Prefix, OriginASN: asA, OriginASNs: []int{asA},
Peers: 1, Newest: timeAt(10),
}
if seen[got.Prefix] || got.Prefix.Bits() != tc.maskLength ||
!reflect.DeepEqual(got, want) {
+28 -2
View File
@@ -27,6 +27,11 @@ const (
// kept routes in order: each of its nodes holds up to 63 times.
keptTimesDegree = 32
// walkBatch is how many prefixes a walk over the whole table visits in
// one hold of the lock. Overview and PeerDown each walk the whole table.
// Callers waiting for the lock get it between batches.
walkBatch = 10000
ipv4Bits = 32
ipv6Bits = 128
@@ -201,8 +206,13 @@ func (t *Table) Withdraw(prefix netip.Prefix, peer string) {
}
// 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.
// set, and every route of the peer is dropped. When the peer carries any
// prefix, PeerDown visits every prefix in the table, in batches of walkBatch.
// After each batch, it lets the table's lock go for as long as it held the
// lock for the batch. During a walk over many batches, reads and updates can
// then take the lock about half of the time. An announcement by the peer
// applied during the walk may be dropped too. So the caller applies the peer's
// later updates only once PeerDown has returned.
func (t *Table) PeerDown(peer string) {
t.mu.Lock()
defer t.mu.Unlock()
@@ -212,6 +222,9 @@ func (t *Table) PeerDown(peer string) {
return
}
visited := 0
batchStart := time.Now()
for _, f := range []*family{&t.ipv4, &t.ipv6} {
for _, prefixes := range f.byLength {
for prefix, origins := range prefixes {
@@ -219,6 +232,19 @@ func (t *Table) PeerDown(peer string) {
if o != nil {
t.leave(prefix, o, number)
}
// Go allows the map to change before the next step of
// the range over it.
visited++
if visited%walkBatch == 0 {
held := time.Since(batchStart)
t.mu.Unlock()
time.Sleep(held)
t.mu.Lock()
batchStart = time.Now()
}
}
}
}
@@ -0,0 +1,69 @@
package routetable
import (
"net/netip"
"runtime"
"testing"
"time"
)
// TestPeerDownLetsReadsIn reads the table's counts again and again while a peer
// that carries more than two batches of prefixes goes down. PeerDown lets the
// lock go after every walkBatch prefixes, so a read must get in while the peer
// still carries some of its prefixes but no longer all.
func TestPeerDownLetsReadsIn(t *testing.T) {
t.Parallel()
const (
asn = 1000
peer = "192.0.2.1"
// prefixCount is more prefixes than two of walkBatch.
prefixCount = 2*walkBatch + 1
)
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
table := New(Params{})
addr := netip.MustParseAddr("1.0.0.0")
for range prefixCount {
prefix := netip.PrefixFrom(addr, addr.BitLen())
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
addr = addr.Next()
}
// Hold a read lock until PeerDown waits for the lock, so that the first
// read below starts once PeerDown holds it. TryRLock fails once a writer
// waits.
table.mu.RLock()
peerDownDone := make(chan struct{})
go func() {
defer close(peerDownDone)
table.PeerDown(peer)
}()
for table.mu.TryRLock() {
table.mu.RUnlock()
runtime.Gosched()
}
table.mu.RUnlock()
for {
routes := table.Counts().IPv4Routes
if routes > 0 && routes < prefixCount {
break
}
select {
case <-peerDownDone:
t.Fatal("no read got in while PeerDown ran")
default:
}
}
<-peerDownDone
}
@@ -509,8 +509,6 @@ func announcementFeed(prefixes []string) string {
func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
t.Parallel()
const maxWait = 10 * time.Second
prefixes := []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"}
feed := announcementFeed(prefixes)
@@ -556,9 +554,9 @@ func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
for range cap(linesSeen) {
select {
case <-linesSeen:
case <-time.After(maxWait):
case <-time.After(stepTimeout):
t.Fatalf("the streamer did not read %d lines within %s",
cap(linesSeen), maxWait)
cap(linesSeen), stepTimeout)
}
}
@@ -571,8 +569,8 @@ func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
select {
case <-shutDown:
case <-time.After(maxWait):
t.Fatalf("Shutdown did not return within %s", maxWait)
case <-time.After(stepTimeout):
t.Fatalf("Shutdown did not return within %s", stepTimeout)
}
store.mu.Lock()
@@ -609,21 +607,16 @@ func freePort(t *testing.T) int {
return addr.Port
}
// TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked starts the HTTP server
// and has the AS handler start a write that the store holds until the test
// ends, past Shutdown's deadline. Shutdown must return within its deadline
// plus a small margin, with the HTTP server stopped, and must neither
// checkpoint nor close the database: the real one would wait for the write.
// TestShutdownDoesNotWaitForABlockedWrite starts the HTTP server and has the
// AS handler start a write that the store holds until the test ends, past
// Shutdown's deadline. Shutdown must return without waiting for the write, with
// the HTTP server stopped, and must neither checkpoint nor close the database:
// the real one would wait for the write.
// Before the fix Shutdown waited for the write.
func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
func TestShutdownDoesNotWaitForABlockedWrite(t *testing.T) {
t.Parallel()
const (
deadline = 100 * time.Millisecond
// margin is how long Shutdown may take after its deadline, to stop
// the HTTP server.
margin = 2 * time.Second
)
const deadline = 100 * time.Millisecond
blocking := newBlockingStore()
defer blocking.releaseWrites()
@@ -651,9 +644,8 @@ func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
}
rw.dbHandler = NewASHandler(store, log)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
startBlockedWrite(t, blocking, rw.dbHandler,
[]*ristypes.RISMessage{{Path: fill}})
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}})
ctx, cancel := context.WithTimeout(t.Context(), deadline)
defer cancel()
@@ -667,9 +659,9 @@ func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
select {
case <-shutDown:
case <-time.After(deadline + margin):
case <-time.After(deadline + stepTimeout):
t.Fatalf("Shutdown did not return within %s of its deadline while "+
"a write was blocked", margin)
"a write was blocked", stepTimeout)
}
var dialer net.Dialer
@@ -744,8 +736,6 @@ func (s *closeOrderStore) record(call string) {
func TestShutdownCheckpointsAndClosesTheDatabaseLast(t *testing.T) {
t.Parallel()
const deadline = 10 * time.Second
store := &closeOrderStore{Store: newMockStore()}
log := logger.New()
cfg := &config.Config{}
@@ -763,7 +753,7 @@ func TestShutdownCheckpointsAndClosesTheDatabaseLast(t *testing.T) {
Path: ristypes.ASPath{Sequence: []int{testASNA}},
})
ctx, cancel := context.WithTimeout(t.Context(), deadline)
ctx, cancel := context.WithTimeout(t.Context(), stepTimeout)
defer cancel()
rw.Shutdown(ctx)
+40 -85
View File
@@ -18,39 +18,34 @@ const (
// (asnBatchTimeout), so a queue smaller than asnBatchSize is fine.
asHandlerQueueSize = 20000
// asnBatchSize is the number of ASN operations to batch together
// asnBatchSize is the number of ASes in a full batch, which wakes the
// flush goroutine to write it.
asnBatchSize = 30000
// asnBatchMaxSize is the most ASN operations the batch holds while the
// flush goroutine writes the previous one: ten batches, about 10 MiB
// (README "Memory"). At this size HandleMessage waits until the flush
// goroutine takes the batch.
asnBatchMaxSize = 300000
// asnBatchTimeout is the maximum time to wait before flushing a batch
// DO NOT reduce this timeout - larger batches are more efficient
asnBatchTimeout = 2 * time.Second
)
// ASHandler processes Autonomous System Number (ASN) information extracted from
// BGP UPDATE messages. It uses batched database operations to efficiently store
// ASN data, collecting operations into batches that are flushed either when the
// batch reaches a size threshold or after a timeout period.
// ASHandler records every AS in the AS paths of BGP UPDATE messages. Its batch
// holds the earliest and the latest time of each AS's updates. The flush
// goroutine writes the batch to the database when it holds asnBatchSize ASes,
// and every asnBatchTimeout.
type ASHandler struct {
db database.Store
logger *logger.Logger
// Batching. mu guards batch and writing only: the flush goroutine takes
// the batch under mu and writes it after releasing mu, so HandleMessage
// waits for a database write only while batch is at asnBatchMaxSize.
// Batching. mu guards batch and writing only. The flush goroutine takes
// the batch under mu and writes it after releasing mu. So HandleMessage
// never waits for a database write.
mu sync.Mutex
batch []asnOp
// writing is how many operations the flush goroutine took from batch
// and has not finished writing.
// batch holds the earliest and the latest time of the updates for each AS
// since the flush goroutine last took it. It holds one entry per AS,
// however many updates arrive.
batch map[int]database.SeenTimes
// writing is how many ASes the flush goroutine took from batch and has
// not finished writing.
writing 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
// 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.
@@ -58,16 +53,10 @@ type ASHandler struct {
done chan struct{} // closed when the flush goroutine returns
}
type asnOp struct {
number int
timestamp time.Time
}
// NewASHandler creates and returns a new ASHandler instance. It initializes
// the batching system and starts a background goroutine that periodically
// flushes accumulated ASN operations to the database. The caller must call
// Stop when finished to ensure all pending operations are flushed and the
// background goroutine is terminated.
// writes the collected ASes to the database. The caller must call Stop when
// finished, to write the pending ASes and end the background goroutine.
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
return newASHandler(db, logger, asnBatchTimeout)
}
@@ -81,12 +70,11 @@ func newASHandler(
h := &ASHandler{
db: db,
logger: logger,
batch: make([]asnOp, 0, asnBatchSize),
batch: make(map[int]database.SeenTimes),
batchFullCh: make(chan struct{}, 1),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
@@ -110,48 +98,25 @@ func (h *ASHandler) QueueCapacity() int {
return asHandlerQueueSize
}
// HandleMessage processes a RIS Live BGP message by extracting all ASNs from
// the AS path and queuing them for batch insertion into the database. The
// origin ASN and all transit ASNs are recorded with their associated
// timestamps. When the batch reaches the configured size threshold, the flush
// goroutine is woken to write it.
// HandleMessage adds the time of msg to the batch entry of each AS in its AS
// path, the origin AS and the members of an AS set included. When the batch
// holds asnBatchSize ASes, HandleMessage wakes the flush goroutine to write it.
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
// Use the pre-parsed timestamp
timestamp := msg.ParsedTimestamp
originASN := msg.Path.Origin()
h.mu.Lock()
defer h.mu.Unlock()
// While the batch is at its maximum size, wait until the flush goroutine
// takes it. Meanwhile the handler takes nothing off its queue, which
// fills, and the streamer drops messages for it.
for len(h.batch) >= asnBatchMaxSize {
h.batchTaken.Wait()
}
// Queue origin ASN operation
if originASN > 0 {
h.batch = append(h.batch, asnOp{
number: originASN,
timestamp: timestamp,
})
}
// Also track all ASNs in the path, the members of an AS set included
// The origin AS is the last AS of the sequence, so the loop adds it too.
for _, asn := range slices.Concat(msg.Path.Sequence, msg.Path.Set) {
if asn > 0 {
h.batch = append(h.batch, asnOp{
number: asn,
timestamp: timestamp,
})
seen := h.batch[asn]
seen.Add(msg.ParsedTimestamp)
h.batch[asn] = seen
}
}
// A full batch wakes the flush goroutine. If it is writing, the wake-up
// waits in batchFullCh and the batch keeps growing until the write ends
// or the batch reaches asnBatchMaxSize.
// waits in batchFullCh and the batch keeps taking updates until the write
// ends.
if len(h.batch) >= asnBatchSize {
select {
case h.batchFullCh <- struct{}{}:
@@ -160,10 +125,11 @@ func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
}
}
// Stop stops the flush goroutine, which writes the operations 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, so Stop logs how many operations are not written.
// Stop stops the flush goroutine and waits for it until ctx ends. The flush
// goroutine writes the batch before it returns, unless ctx has ended. A write
// still running when the process exits is lost. Stop logs how many entries are
// not written, in the batch being written and in the next batch. An AS in both
// batches counts twice.
func (h *ASHandler) Stop(ctx context.Context) {
h.stopCh <- ctx
@@ -178,14 +144,14 @@ func (h *ASHandler) Stop(ctx context.Context) {
if unwritten > 0 {
h.logger.Error("Stop deadline passed before the AS handler wrote "+
"its updates", "updates", unwritten)
"its batches", "batch_entries", unwritten)
}
}
// 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.
func (h *ASHandler) takeBatch() []asnOp {
// takeBatch returns the pending ASes and leaves an empty batch in their place
// for HandleMessage to fill. They count in writing until flushBatch has written
// them.
func (h *ASHandler) takeBatch() map[int]database.SeenTimes {
h.mu.Lock()
defer h.mu.Unlock()
@@ -195,8 +161,7 @@ func (h *ASHandler) takeBatch() []asnOp {
batch := h.batch
h.writing = len(batch)
h.batch = make([]asnOp, 0, asnBatchSize)
h.batchTaken.Broadcast()
h.batch = make(map[int]database.SeenTimes)
return batch
}
@@ -209,21 +174,11 @@ func (h *ASHandler) flushBatch() {
return
}
// The earliest and the latest time the batch saw each AS. The batch holds
// several updates for an AS, from many peers, so a new AS is stored first
// seen at the earliest of them and last seen at the latest.
asns := make(map[int]database.SeenTimes)
for _, op := range batch {
seen := asns[op.number]
seen.Add(op.timestamp)
asns[op.number] = seen
}
// Process all ASNs in a single batch transaction
err := h.db.GetOrCreateASNBatch(asns)
err := h.db.GetOrCreateASNBatch(batch)
if err != nil {
h.logger.Error("Failed to process ASN batch",
"error", err, "count", len(asns))
"error", err, "count", len(batch))
}
h.mu.Lock()
+24 -7
View File
@@ -5,28 +5,43 @@ import (
"slices"
"testing"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
)
// pathOfASes returns an AS path of n different ASes: AS 1 to AS n. With n up to
// asnBatchSize, none of them is testASNA to testASND.
func pathOfASes(n int) ristypes.ASPath {
sequence := make([]int, n)
for i := range sequence {
sequence[i] = i + 1
}
return ristypes.ASPath{Sequence: sequence}
}
// TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS handles two updates
// whose path carries an AS the database does not hold yet, the second 5
// seconds after the first, in one batch into a real database. The AS is stored
// first seen at the time of the first update and last seen at the time of the
// second.
// whose path carries an AS the database does not hold yet, in one batch into a
// real database. The second is 5 seconds after the first, and the handler gets
// it first. The AS is stored first seen at the time of the first update and
// last seen at the time of the second.
func TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
// Built without NewASHandler's flush goroutine, so the test flushes itself.
h := &ASHandler{db: db, logger: logger.New()}
h := &ASHandler{
db: db, logger: logger.New(), batch: make(map[int]database.SeenTimes),
}
first := peerAnnouncement(testPeerIP, testV4Prefix)
second := peerAnnouncement(testPeerIP, testV4Prefix)
second.ParsedTimestamp = first.ParsedTimestamp.Add(secondUpdateAfter)
h.HandleMessage(first)
h.HandleMessage(second)
h.HandleMessage(first)
h.flushBatch()
asn, _, err := db.GetAS(t.Context(), testASNA)
@@ -54,7 +69,9 @@ func TestASHandlerRecordsNothingFromAPathWithASZero(t *testing.T) {
store := newMockStore()
// Built without NewASHandler's flush goroutine, so the test flushes itself.
h := &ASHandler{db: store, logger: logger.New()}
h := &ASHandler{
db: store, logger: logger.New(), batch: make(map[int]database.SeenTimes),
}
msg := peerAnnouncement(testPeerIP, testV4Prefix)
msg.Path = decodePath(t, testASNA, 0, testASNB)
+31 -5
View File
@@ -187,25 +187,33 @@ func (f *ASNFetcher) recordSuccess() {
f.statsMu.Unlock()
}
// recordFailure increases the interval on failed fetch using exponential backoff.
func (f *ASNFetcher) recordFailure() {
// lengthenInterval makes the interval 1.5 times as long, up to maxInterval.
// A failed lookup and a skipped one both lengthen it.
func (f *ASNFetcher) lengthenInterval() {
f.intervalMu.Lock()
f.consecutiveFails++
defer f.intervalMu.Unlock()
// Exponential backoff: multiply by 1.5, capped at max
newInterval := min(
f.currentInterval*backoffNumerator/backoffDenominator, maxInterval)
if newInterval != f.currentInterval {
f.logger.Debug("Increased fetch interval due to failure",
f.logger.Debug("Increased fetch interval",
"old_interval", f.currentInterval,
"new_interval", newInterval,
"consecutive_failures", f.consecutiveFails,
)
f.currentInterval = newInterval
}
}
// recordFailure counts a failed fetch and lengthens the interval.
func (f *ASNFetcher) recordFailure() {
f.intervalMu.Lock()
f.consecutiveFails++
f.intervalMu.Unlock()
f.lengthenInterval()
// Record error time for stats, dropping those older than statsWindow
f.statsMu.Lock()
now := f.now()
@@ -287,6 +295,16 @@ func (f *ASNFetcher) fetchNextStale(ctx context.Context) {
// so the AS stays stale. An AS IANA refers to no RIR is stored with no
// WHOIS data, so it is not looked up again until it is stale; that counts
// as neither a success nor a failure.
//
// The WHOIS client pauses an RIR's or NIC.br's server for whois.PauseTime
// after the server refuses a query. Lookups at the other servers go on. IANA
// is never paused, so a refusal by IANA is an ordinary failure. A lookup
// whose RIR is paused fails with whois.ErrPaused and is skipped. A skipped
// lookup leaves the AS stale and is not counted as a failure. Like a
// failure, a skipped lookup lengthens the interval. Each lookup asks IANA
// before it finds its RIR paused, so with a one-second interval the fetcher
// would ask IANA every second. The lookups that QueueImmediate asks for also
// run through fetchAndUpdate.
func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
@@ -307,6 +325,14 @@ func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
return false
}
if errors.Is(err, whois.ErrPaused) {
f.logger.Info("Skipped WHOIS lookup, the RIR's server is paused",
"asn", asn, "error", err)
f.lengthenInterval()
return false
}
if err != nil {
f.logger.Error("WHOIS lookup failed", "asn", asn, "error", err)
f.recordFailure()
+150 -4
View File
@@ -13,6 +13,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/whois"
)
// The WHOIS servers a lookup asks: IANA first, then the RIR IANA refers it
@@ -131,6 +132,151 @@ func TestFetchAndUpdateKeepsTheRowWhenTheLookupFails(t *testing.T) {
}
}
// TestFetchAndUpdateWaitsOutAPause looks up an AS at RIPE while RIPE refuses
// the query. The test sets the WHOIS client's clock. The next lookup comes a
// second before whois.PauseTime has passed. It sends RIPE nothing and leaves
// the AS never looked up. It counts as neither a success nor a failure. A
// lookup at whois.PauseTime asks RIPE again.
func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
t.Parallel()
f, db := newFetcherWithWHOISAnswers(t, map[string]string{
ianaWHOISServer: ianaRefersToRIPE,
ripeWHOISServer: ripeAccessDenied,
})
ripeAsks := 0
dial := f.whoisClient.DialContext
f.whoisClient.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ripeWHOISServer {
ripeAsks++
}
return dial(ctx, network, address)
}
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
now = start.Add(whois.PauseTime - time.Second)
f.fetchAndUpdate(t.Context(), testASNA)
if ripeAsks != 1 {
t.Errorf("RIPE was asked %d times during its pause, want 1", ripeAsks)
}
stats, err := db.GetWHOISStats(t.Context(), whoisStaleThreshold)
if err != nil {
t.Fatalf("GetWHOISStats: %v", err)
}
if stats.NeverFetched != 1 {
t.Errorf("never looked up = %d, want 1", stats.NeverFetched)
}
got := f.GetStats()
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 ||
got.SuccessesLastHour != 0 {
t.Errorf("%d failures in a row, %d errors, %d successes; want 1, 1, 0",
got.ConsecutiveFails, got.ErrorsLastHour, got.SuccessesLastHour)
}
now = start.Add(whois.PauseTime)
f.fetchAndUpdate(t.Context(), testASNA)
if ripeAsks != 2 {
t.Errorf("RIPE was asked %d times after its pause, want 2", ripeAsks)
}
}
// TestFetchAndUpdateBacksOffDuringAPause looks up an AS at RIPE while RIPE
// refuses the query. The refusal pauses RIPE for an hour, so every later
// lookup in the test is skipped. The test sets the WHOIS client's clock and
// looks the AS up again each time the interval has passed. Like a failure,
// each skip must make the interval 1.5 times as long, up to one minute. The
// skips must not count as failures.
func TestFetchAndUpdateBacksOffDuringAPause(t *testing.T) {
t.Parallel()
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{
ianaWHOISServer: ianaRefersToRIPE,
ripeWHOISServer: ripeAccessDenied,
})
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
for i, want := range []time.Duration{
33750 * time.Millisecond,
50625 * time.Millisecond,
time.Minute,
time.Minute,
} {
now = now.Add(f.getInterval())
f.fetchAndUpdate(t.Context(), testASNA)
if got := f.getInterval(); got != want {
t.Errorf("interval after %d skipped lookups = %v, want %v",
i+1, got, want)
}
}
got := f.GetStats()
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 {
t.Errorf("%d failures in a row and %d errors, want 1 and 1",
got.ConsecutiveFails, got.ErrorsLastHour)
}
}
// TestFetchAndUpdateKeepsAskingIANA looks up an AS while IANA's server
// refuses the connection. The test sets the WHOIS client's clock. The next
// lookup comes a second before whois.PauseTime has passed. IANA is never
// paused, so that lookup asks IANA again. Each refusal counts as a failure.
func TestFetchAndUpdateKeepsAskingIANA(t *testing.T) {
t.Parallel()
// With no answers, every server refuses the connection.
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{})
ianaAsks := 0
dial := f.whoisClient.DialContext
f.whoisClient.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ianaWHOISServer {
ianaAsks++
}
return dial(ctx, network, address)
}
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
f.whoisClient.Now = func() time.Time { return now }
f.fetchAndUpdate(t.Context(), testASNA)
now = start.Add(whois.PauseTime - time.Second)
f.fetchAndUpdate(t.Context(), testASNA)
got := f.GetStats()
if ianaAsks != 2 || got.ConsecutiveFails != 2 {
t.Errorf("IANA was asked %d times, with %d failures in a row; "+
"want 2 and 2", ianaAsks, got.ConsecutiveFails)
}
}
// TestRecordStepsTheInterval has the fetcher fail from its base interval of
// 15 seconds, then succeed. Each failure makes the interval 1.5 times as
// long, up to one minute, and each success halves it, down to one second.
@@ -224,7 +370,7 @@ func TestASNFetcherStopReturnsByItsDeadline(t *testing.T) {
select {
case <-lookupStarted:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the fetcher did not start the lookup")
}
@@ -240,22 +386,22 @@ func TestASNFetcherStopReturnsByItsDeadline(t *testing.T) {
select {
case <-f.done:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the fetcher goroutine did not return after the lookup ended")
}
}
// TestRecordKeepsOnlyTheStatsWindow records a success and a failure every
// second, by a clock the test sets, far longer than statsWindow, and never
// second, by a clock the test sets, for three times statsWindow, and never
// asks for the stats. The fetcher must keep only the times within the last
// statsWindow.
func TestRecordKeepsOnlyTheStatsWindow(t *testing.T) {
t.Parallel()
const (
records = 100_000
step = time.Second
want = int(statsWindow / step)
records = 3 * want
)
clock := time.Now()
+110 -53
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"slices"
"sync"
@@ -19,9 +20,15 @@ import (
)
const (
// handleMessageTimeout is how long a test waits for HandleMessage to
// return, or for a write to start, before it fails.
handleMessageTimeout = 2 * time.Second
// stepTimeout is how long a test waits for a step it expects, such as
// HandleMessage returning, a write starting or Shutdown returning, before
// it fails. Only a step that never comes should reach it. On a loaded
// host, under the race detector, HandleMessage takes seconds to apply a
// full prefix batch. The helpers below bound every wait with it, Stop
// included. A test that hangs in one of them waits out at most two of
// these bounds, its step's and Stop's, and then fails with its own
// message. That leaves time before go test's 90-second timeout.
stepTimeout = 15 * time.Second
// stillWaitingAfter is how long a test watches a HandleMessage that must
// wait. One that does not wait returns well within it.
@@ -171,12 +178,14 @@ type stopLog struct {
Msg string `json:"msg"`
Updates int `json:"updates"`
TableChanges int `json:"table_changes"`
HeldMessages int `json:"held_messages"`
Paths int `json:"paths"`
BatchEntries int `json:"batch_entries"`
}
// newErrorLogger returns a logger that writes only errors, as JSON lines, to
// logs.
func newErrorLogger(logs *bytes.Buffer) *logger.Logger {
func newErrorLogger(logs io.Writer) *logger.Logger {
return &logger.Logger{Logger: slog.New(slog.NewJSONHandler(logs,
&slog.HandlerOptions{Level: slog.LevelError}))}
}
@@ -198,7 +207,7 @@ func lastLogged(t *testing.T, logs *bytes.Buffer) stopLog {
return logged
}
// returnsInTime reports whether f returns within handleMessageTimeout.
// returnsInTime reports whether f returns within stepTimeout.
func returnsInTime(f func()) bool {
done := make(chan struct{})
@@ -210,11 +219,22 @@ func returnsInTime(f func()) bool {
select {
case <-done:
return true
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
return false
}
}
// stopInTime stops h, with no deadline, and fails the test if Stop does not
// return within stepTimeout. It only marks the test failed, so it can be
// deferred.
func stopInTime(t *testing.T, h interface{ Stop(ctx context.Context) }) {
t.Helper()
if !returnsInTime(func() { h.Stop(t.Context()) }) {
t.Errorf("Stop did not return within %s", stepTimeout)
}
}
// 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
@@ -240,7 +260,7 @@ func startBlockedWrite(
select {
case <-store.writeStarted:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the handler did not start to write the messages")
}
}
@@ -261,7 +281,7 @@ func handleWhileAWriteIsBlocked(
) {
t.Helper()
defer h.Stop(t.Context())
defer stopInTime(t, h)
defer store.releaseWrites()
startBlockedWrite(t, store, h, fill)
@@ -275,7 +295,9 @@ func handleWhileAWriteIsBlocked(
// while store holds the write, hands h the messages in toMax, which bring its
// next batch to its maximum size, and the message extra. HandleMessage for
// extra must wait until the test lets the write finish, and return after. Then
// it stops h, which writes what it holds.
// it stops h, which writes what it holds. The tests lower h's maximum batch
// size to one batch, so toMax is one batch: the real maximum of ten batches is
// slow to reach under the race detector.
func handleAtMaxBatchSize(
t *testing.T,
store *blockingStore,
@@ -288,14 +310,19 @@ func handleAtMaxBatchSize(
) {
t.Helper()
defer h.Stop(t.Context())
defer stopInTime(t, h)
defer store.releaseWrites()
startBlockedWrite(t, store, h, fill)
if !returnsInTime(func() {
for _, msg := range toMax {
h.HandleMessage(msg)
}
}) {
t.Fatal("HandleMessage did not return while the batch grew to its " +
"maximum size")
}
returned := make(chan struct{})
@@ -315,7 +342,7 @@ func handleAtMaxBatchSize(
select {
case <-returned:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("HandleMessage did not return after the database write finished")
}
}
@@ -345,9 +372,13 @@ func stopWhileAWriteIsBlocked(
startBlockedWrite(t, store, h, fill)
if !returnsInTime(func() {
for _, msg := range extra {
h.HandleMessage(msg)
}
}) {
t.Fatal("HandleMessage did not return while a database write was blocked")
}
ctx, cancel := context.WithTimeout(t.Context(), stopDeadline)
defer cancel()
@@ -361,7 +392,7 @@ func stopWhileAWriteIsBlocked(
select {
case <-done:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the flush goroutine did not return after the write finished")
}
}
@@ -380,7 +411,8 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
store := newBlockingStore()
h := newPrefixHandler(store, newErrorLogger(&logs),
routetable.New(routetable.Params{}), longerThanAnyTest)
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
@@ -466,20 +498,20 @@ func TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress(t *testing.T)
}
}
// TestASHandlerSkipsItsLastWriteAfterTheStopDeadline also checks what Stop
// logs: the asnBatchSize entries of the held write and the entry for testASNB,
// handled since, are not written.
func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
// Each message gives an update for its origin AS and one for each AS
// in its path: fill asnBatchSize+1, extra 2.
const wantUpdates = asnBatchSize + 1 + 2
const wantEntries = asnBatchSize + 1
var logs bytes.Buffer
store := newBlockingStore()
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}},
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}},
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
store.mu.Lock()
@@ -489,26 +521,27 @@ func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Errorf("AS%d was written after the stop deadline", testASNB)
}
if logged := lastLogged(t, &logs); logged.Updates != wantUpdates {
t.Errorf("Stop logged %d updates not written, want %d",
logged.Updates, wantUpdates)
if logged := lastLogged(t, &logs); logged.BatchEntries != wantEntries {
t.Errorf("Stop logged %d batch entries not written, want %d",
logged.BatchEntries, wantEntries)
}
}
// 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.
// waits. The full batch holds the fill's ASes again, and testASNB. 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)}
fill := &ristypes.RISMessage{Path: pathOfASes(asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{{Path: fill}},
&ristypes.RISMessage{Path: full})
[]*ristypes.RISMessage{fill},
fill,
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
store.mu.Lock()
defer store.mu.Unlock()
@@ -597,7 +630,7 @@ func TestPeeringHandlerStopCountsTheWriteInProgress(t *testing.T) {
select {
case <-h.done:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the processing goroutine did not return after the write " +
"finished")
}
@@ -618,7 +651,7 @@ func TestPeeringHandlerStartsNoWriteAfterTheStopDeadline(t *testing.T) {
const processInterval = 10 * time.Millisecond
store := newBlockingStore()
h := newPeeringHandler(store, logger.New(), processInterval)
h := newPeeringHandler(store, logger.New(), processInterval, maxTrackedPaths)
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{
{Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}}},
@@ -674,9 +707,8 @@ func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
store := newBlockingStore()
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{{Path: fill}},
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}},
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
store.mu.Lock()
@@ -714,9 +746,11 @@ func TestPrefixHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
h := newPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}),
longerThanAnyTest, maxTableChangesPerWrite)
h.batchMaxSize = prefixBatchSize
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
toMax := slices.Repeat([]string{testV4Prefix}, prefixBatchMaxSize)
toMax := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
handleAtMaxBatchSize(t, store, h,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
[]*ristypes.RISMessage{prefixAnnouncement(toMax)},
@@ -731,31 +765,48 @@ func TestPrefixHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
}
}
func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
// TestASHandlerNeverWaitsForAWrite starts a write that the store holds. Then it
// hands the AS handler twenty messages whose paths each hold one AS
// asnBatchSize times. HandleMessage must return each time, and the batch must
// hold one entry, for that AS.
func TestASHandlerNeverWaitsForAWrite(t *testing.T) {
t.Parallel()
const messages = 20
store := newBlockingStore()
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,
[]*ristypes.RISMessage{{Path: fill}},
[]*ristypes.RISMessage{{Path: toMax}},
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
store.mu.Lock()
defer store.mu.Unlock()
defer stopInTime(t, h)
defer store.releaseWrites()
if !store.asns[testASNB] {
t.Errorf("Stop did not write AS%d, handled after the batch reached its "+
"maximum size", testASNB)
startBlockedWrite(t, store, h,
[]*ristypes.RISMessage{{Path: pathOfASes(asnBatchSize)}})
oneAS := &ristypes.RISMessage{
Path: ristypes.ASPath{Sequence: slices.Repeat([]int{testASNB}, asnBatchSize)},
}
if !returnsInTime(func() {
for range messages {
h.HandleMessage(oneAS)
}
}) {
t.Fatal("HandleMessage did not return while a database write was blocked")
}
h.mu.Lock()
defer h.mu.Unlock()
if len(h.batch) != 1 {
t.Errorf("the batch holds %d entries for one AS, want 1", len(h.batch))
}
}
// 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.
// would fail.
func TestPeerHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
t.Parallel()
@@ -763,10 +814,11 @@ func TestPeerHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
store := newBlockingStore()
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
h.batchMaxSize = peerBatchSize
message := []*ristypes.RISMessage{{Peer: testPeerIP}}
handleAtMaxBatchSize(t, store, h,
slices.Repeat(message, peerBatchSize),
slices.Repeat(message, peerBatchMaxSize),
slices.Repeat(message, peerBatchSize),
&ristypes.RISMessage{Peer: otherPeerIP})
store.mu.Lock()
@@ -796,6 +848,7 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
batchSize: prefixBatchSize,
handle: func(n int) chan struct{} {
h := &PrefixHandler{
batchMaxSize: prefixBatchMaxSize,
batchFullCh: make(chan struct{}, 1),
table: routetable.New(routetable.Params{}),
}
@@ -809,11 +862,12 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
name: "AS",
batchSize: asnBatchSize,
handle: func(n int) chan struct{} {
h := &ASHandler{batchFullCh: make(chan struct{}, 1)}
// The origin AS and each AS in the path are one update each.
h.HandleMessage(&ristypes.RISMessage{
Path: ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, n-1)},
})
h := &ASHandler{
batch: make(map[int]database.SeenTimes),
batchFullCh: make(chan struct{}, 1),
}
// Each of the path's n ASes is one entry of the batch.
h.HandleMessage(&ristypes.RISMessage{Path: pathOfASes(n)})
return h.batchFullCh
},
@@ -822,7 +876,10 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
name: "peer",
batchSize: peerBatchSize,
handle: func(n int) chan struct{} {
h := &PeerHandler{batchFullCh: make(chan struct{}, 1)}
h := &PeerHandler{
batchMaxSize: peerBatchMaxSize,
batchFullCh: make(chan struct{}, 1),
}
for range n {
h.HandleMessage(&ristypes.RISMessage{Peer: testPeerIP})
}
@@ -97,7 +97,7 @@ func TestDBMaintainerStopReturnsByItsDeadline(t *testing.T) {
select {
case <-store.started:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the maintainer did not start the checkpoint")
}
@@ -113,7 +113,7 @@ func TestDBMaintainerStopReturnsByItsDeadline(t *testing.T) {
select {
case <-m.done:
case <-time.After(handleMessageTimeout):
case <-time.After(stepTimeout):
t.Fatal("the maintenance goroutine did not return after the " +
"checkpoint ended")
}
+8 -3
View File
@@ -43,10 +43,14 @@ type PeerHandler struct {
db database.Store
logger *logger.Logger
// batchMaxSize is peerBatchMaxSize. Only HandleMessage reads it, so a test
// may lower it before it hands the handler a message.
batchMaxSize int
// Batching. mu guards peerBatch and writing only: the flush goroutine
// takes the batch under mu and writes it after releasing mu, so
// HandleMessage waits for a database write only while peerBatch is at
// peerBatchMaxSize.
// batchMaxSize.
mu sync.Mutex
peerBatch []peerUpdate
// writing is how many updates the flush goroutine took from peerBatch
@@ -86,6 +90,7 @@ func newPeerHandler(
h := &PeerHandler{
db: db,
logger: logger,
batchMaxSize: peerBatchMaxSize,
peerBatch: make([]peerUpdate, 0, peerBatchSize),
batchFullCh: make(chan struct{}, 1),
stopCh: make(chan context.Context, 1),
@@ -135,7 +140,7 @@ func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
// While the batch is at its maximum size, wait until the flush goroutine
// takes it. Meanwhile the handler takes nothing off its queue, which
// fills, and the streamer drops messages for it.
for len(h.peerBatch) >= peerBatchMaxSize {
for len(h.peerBatch) >= h.batchMaxSize {
h.batchTaken.Wait()
}
@@ -149,7 +154,7 @@ func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
// A full batch wakes the flush goroutine. If it is writing, the wake-up
// waits in batchFullCh and the batch keeps growing until the write ends
// or the batch reaches peerBatchMaxSize.
// or the batch reaches batchMaxSize.
if len(h.peerBatch) >= peerBatchSize {
select {
case h.batchFullCh <- struct{}{}:
@@ -35,7 +35,7 @@ func TestPeerHandlerWritesTheEarliestAndLatestTimesOfANewPeer(t *testing.T) {
// Built without NewPeerHandler's flush goroutine, so the test flushes
// itself.
h := &PeerHandler{db: store, logger: logger.New()}
h := &PeerHandler{db: store, logger: logger.New(), batchMaxSize: peerBatchMaxSize}
first := peerAnnouncement(testPeerIP, testV4Prefix)
second := peerAnnouncement(testPeerIP, testV4Prefix)
+11 -4
View File
@@ -42,6 +42,10 @@ type PeeringHandler struct {
db database.Store
logger *logger.Logger
// maxPaths is the most paths asPaths holds: maxTrackedPaths, except in
// tests that give a smaller one.
maxPaths int
// In-memory AS path tracking: each path, JSON-encoded, with the earliest
// and the latest time an update carried it.
mu sync.Mutex
@@ -62,19 +66,22 @@ 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)
return newPeeringHandler(db, logger, peeringProcessInterval, maxTrackedPaths)
}
// 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.
// processInterval, in place of peeringProcessInterval, and the most paths it
// holds, maxPaths, in place of maxTrackedPaths. A test gives a short interval to
// have the timer start a write, and a small maxPaths to fill the map quickly.
func newPeeringHandler(
db database.Store, logger *logger.Logger, processInterval time.Duration,
maxPaths int,
) *PeeringHandler {
h := &PeeringHandler{
db: db,
logger: logger,
asPaths: make(map[string]database.SeenTimes),
maxPaths: maxPaths,
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
@@ -131,7 +138,7 @@ func (h *PeeringHandler) HandleMessage(msg *ristypes.RISMessage) {
defer h.mu.Unlock()
seen, exists := h.asPaths[key]
if !exists && len(h.asPaths) >= maxTrackedPaths {
if !exists && len(h.asPaths) >= h.maxPaths {
// Map is full; drop this new path and count it.
h.droppedPaths++
@@ -5,7 +5,6 @@ import (
"fmt"
"maps"
"slices"
"strconv"
"sync"
"testing"
"time"
@@ -20,6 +19,10 @@ const (
testASNB = 64501
testASNC = 64502
testASND = 64503
// testMaxTrackedPaths is a limit of the path map that a test fills
// quickly, in place of maxTrackedPaths.
testMaxTrackedPaths = 10
)
// recordingStore wraps mockStore to count every RecordPeering call, so a
@@ -55,6 +58,7 @@ func newTestHandler(db database.Store) *PeeringHandler {
db: db,
logger: logger.New(),
asPaths: make(map[string]database.SeenTimes),
maxPaths: maxTrackedPaths,
}
}
@@ -244,25 +248,28 @@ func TestPeeringHandlerStopWritesTheRemainingPaths(t *testing.T) {
// TestPeeringHandlerCapDropsAndCounts verifies that a full map drops new paths
// and counts them, while a path already tracked is refreshed rather than
// dropped.
// dropped. The handler's limit is small, and its timer does not fire.
func TestPeeringHandlerCapDropsAndCounts(t *testing.T) {
t.Parallel()
store := &recordingStore{mockStore: newMockStore()}
h := newTestHandler(store)
h := newPeeringHandler(store, logger.New(), longerThanAnyTest,
testMaxTrackedPaths)
defer stopInTime(t, h)
now := time.Now().UTC()
// Fill the map to exactly maxTrackedPaths, including one real path key so
// the "already tracked" branch can be exercised. The filler keys are never
// processed in this test, so their contents do not matter.
// Fill the map to exactly its limit, including one real path key so the
// "already tracked" branch can be exercised. The other paths are of one
// AS, so they carry no peering.
existing := pathKey(t, testASNA, testASNB)
h.mu.Lock()
h.asPaths[existing] = database.SeenTimes{First: now, Last: now}
for i := 0; len(h.asPaths) < maxTrackedPaths; i++ {
h.asPaths[strconv.Itoa(i)] = database.SeenTimes{First: now, Last: now}
for asn := 1; len(h.asPaths) < testMaxTrackedPaths; asn++ {
h.asPaths[pathKey(t, asn)] = database.SeenTimes{First: now, Last: now}
}
h.mu.Unlock()
@@ -270,8 +277,9 @@ func TestPeeringHandlerCapDropsAndCounts(t *testing.T) {
handle(h, now.Add(time.Second), testASNA, testASNC)
tracked, dropped := snapshot(h)
if tracked != maxTrackedPaths {
t.Fatalf("want map size %d after drop, got %d", maxTrackedPaths, tracked)
if tracked != testMaxTrackedPaths {
t.Fatalf("want map size %d after drop, got %d", testMaxTrackedPaths,
tracked)
}
if dropped != 1 {
@@ -283,8 +291,9 @@ func TestPeeringHandlerCapDropsAndCounts(t *testing.T) {
handle(h, refreshed, testASNA, testASNB)
tracked, dropped = snapshot(h)
if tracked != maxTrackedPaths {
t.Fatalf("want map size %d after refresh, got %d", maxTrackedPaths, tracked)
if tracked != testMaxTrackedPaths {
t.Fatalf("want map size %d after refresh, got %d", testMaxTrackedPaths,
tracked)
}
if dropped != 1 {
@@ -303,17 +312,21 @@ func TestPeeringHandlerCapDropsAndCounts(t *testing.T) {
// TestPeeringHandlerRunResetsTheDroppedCount fills the map, has the handler
// drop one path, and runs the processing. The count of dropped paths is the
// run's own, so it is 0 again after the run.
// run's own, so it is 0 again after the run. The handler's limit is small, and
// its timer does not fire.
func TestPeeringHandlerRunResetsTheDroppedCount(t *testing.T) {
t.Parallel()
h := newTestHandler(newMockStore())
h := newPeeringHandler(newMockStore(), logger.New(), longerThanAnyTest,
testMaxTrackedPaths)
defer stopInTime(t, h)
now := time.Now().UTC()
// Paths of one AS carry no peering, so the run records nothing.
h.mu.Lock()
for asn := 1; len(h.asPaths) < maxTrackedPaths; asn++ {
for asn := 1; len(h.asPaths) < testMaxTrackedPaths; asn++ {
h.asPaths[pathKey(t, asn)] = database.SeenTimes{First: now, Last: now}
}
h.mu.Unlock()
+179 -39
View File
@@ -44,6 +44,12 @@ const (
// 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
// maxHeldBackMessages is the most messages the handler holds back from
// peers whose PeerDown has not returned. A message takes about 1 KiB, so
// that many take about 20 MiB (README "Memory"). At this number
// HandleMessage waits until one of them is applied.
maxHeldBackMessages = 20000
)
// PrefixHandler keeps the routing table in memory current from the feed. It
@@ -52,6 +58,15 @@ const (
// does an announcement whose AS path is empty or holds AS 0 anywhere. A peer
// whose BGP session goes down leaves every prefix.
//
// The table's PeerDown applies a session going down. It visits every prefix,
// which takes seconds on a full table. So the handler runs PeerDown on
// a separate goroutine, one peer at a time, and goes on taking messages
// meanwhile. PeerDown lets the table's lock go about half of the time, so the
// handler applies other peers' messages while PeerDown runs. The handler holds
// back the down peer's later messages until PeerDown returns. Then the handler
// applies them in order, one at a time, and takes new messages in between. It
// holds back at most maxHeldBackMessages messages.
//
// Each flush writes the table's changes to the database. It also stores each
// prefix the updates name with the time it was first seen.
type PrefixHandler struct {
@@ -60,10 +75,17 @@ type PrefixHandler struct {
metrics *metrics.Tracker
table *routetable.Table
// Batching. mu guards batch, writing and writingTableChanges only: the
// batchMaxSize is prefixBatchMaxSize. Only HandleMessage reads it, so a
// test may lower it before it hands the handler a message.
batchMaxSize int
// tableChangesPerWrite is maxTableChangesPerWrite, except in tests that
// give a smaller one.
tableChangesPerWrite int
// Batching. mu guards batch, writing and writingTableChanges: 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.
// batchMaxSize.
mu sync.Mutex
batch []seenPrefix
// writing is how many updates the flush goroutine took and has not
@@ -80,6 +102,21 @@ type PrefixHandler struct {
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
done chan struct{} // closed when the flush goroutine returns
// Peers whose session went down. peerDownMu lets one PeerDown run at a
// time, so the handler's own updates wait for the table's lock behind
// at most one. heldBack, under mu, holds each such peer's later
// messages, in order, until its PeerDown has returned and they are
// applied. A peer with no such messages yet has an empty entry.
// heldCount, under mu, is how many messages heldBack holds.
// heldTaken is signalled each time a held-back message is taken out to
// be applied; its L is &mu. peerDowns counts the peerDown goroutines
// that have not returned.
peerDownMu sync.Mutex
heldBack map[string][]*ristypes.RISMessage
heldCount int
heldTaken sync.Cond
peerDowns sync.WaitGroup
}
// seenPrefix is what the batch keeps of an update: the prefix it names and its
@@ -119,26 +156,34 @@ func (u prefixUpdate) announcesRoute() bool {
func NewPrefixHandler(
db database.Store, logger *logger.Logger, table *routetable.Table,
) *PrefixHandler {
return newPrefixHandler(db, logger, table, prefixBatchTimeout)
return newPrefixHandler(db, logger, table, prefixBatchTimeout,
maxTableChangesPerWrite)
}
// 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.
// batchTimeout, in place of prefixBatchTimeout, and tableChangesPerWrite in
// place of maxTableChangesPerWrite. A test that the timer must not disturb
// gives an interval longer than the test runs. A test of writes split over
// several flushes gives a small tableChangesPerWrite, so that it exceeds it
// with few changes.
func newPrefixHandler(
db database.Store, logger *logger.Logger, table *routetable.Table,
batchTimeout time.Duration,
batchTimeout time.Duration, tableChangesPerWrite int,
) *PrefixHandler {
h := &PrefixHandler{
db: db,
logger: logger,
table: table,
batch: make([]seenPrefix, 0, prefixBatchSize),
batchMaxSize: prefixBatchMaxSize,
tableChangesPerWrite: tableChangesPerWrite,
batchFullCh: make(chan struct{}, 1),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
heldBack: make(map[string][]*ristypes.RISMessage),
}
h.batchTaken.L = &h.mu
h.heldTaken.L = &h.mu
// Start the flush timer goroutine
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
@@ -166,11 +211,91 @@ func (h *PrefixHandler) QueueCapacity() int {
return prefixHandlerQueueSize
}
// HandleMessage processes a message to track prefix information
// HandleMessage processes a message to track prefix information. A message
// from a peer whose PeerDown has not returned is held back until it has.
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
h.mu.Lock()
defer h.mu.Unlock()
// While maxHeldBackMessages messages are held back, wait until one of them
// is taken out to be applied. Meanwhile the handler takes nothing off its
// queue, as at batchMaxSize.
for h.heldCount >= maxHeldBackMessages {
h.heldTaken.Wait()
}
held, goingDown := h.heldBack[msg.Peer]
if goingDown {
h.heldBack[msg.Peer] = append(held, msg)
h.heldCount++
return
}
h.applyMessage(msg)
}
// Stop waits for every PeerDown the handler started and for the messages held
// back meanwhile to be applied. It gives up this wait once ctx ends. Then Stop
// stops the flush goroutine and waits for it until ctx ends. The flush
// goroutine makes one last flush before it returns. That flush writes at most
// tableChangesPerWrite of the routing table's changes, and the prefixes of the
// updates the handler holds. Once ctx has ended the flush goroutine skips that
// last flush, and a write still running is lost when the process exits. A
// failed write of the table's changes is not retried either. Stop logs how
// many updates and table changes are not written. It also logs how many
// held-back messages are not applied. The caller calls Stop once its last
// HandleMessage has returned.
func (h *PrefixHandler) Stop(ctx context.Context) {
peerDownsDone := make(chan struct{})
go func() {
h.peerDowns.Wait()
close(peerDownsDone)
}()
select {
case <-peerDownsDone:
case <-ctx.Done():
}
h.stopCh <- ctx
select {
case <-h.done:
case <-ctx.Done():
}
h.mu.Lock()
unwritten := h.writing + len(h.batch)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
heldMessages := h.heldCount
h.mu.Unlock()
if unwritten == 0 && tableChanges == 0 && heldMessages == 0 {
return
}
if ctx.Err() != nil {
h.logger.Error("Stop deadline passed before the prefix handler wrote "+
"its updates", "updates", unwritten, "table_changes", tableChanges,
"held_messages", heldMessages)
return
}
h.logger.Error("The prefix handler stopped before it wrote everything",
"updates", unwritten, "table_changes", tableChanges,
"held_messages", heldMessages)
}
// applyMessage applies msg. The caller holds mu. A peer's session going down
// starts the peer's PeerDown. An update is applied to the routing table, and
// its prefixes join the batch.
func (h *PrefixHandler) applyMessage(msg *ristypes.RISMessage) {
if msg.Type == ristypes.MessageTypeState {
if msg.State == ristypes.StateDown {
h.table.PeerDown(msg.Peer)
h.startPeerDown(msg.Peer)
}
return
@@ -185,13 +310,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
// the path after the rest of it.
path := slices.Concat(msg.Path.Sequence, msg.Path.Set)
h.mu.Lock()
defer h.mu.Unlock()
// While the batch is at its maximum size, wait until the flush goroutine
// takes it. Meanwhile the handler takes nothing off its queue, which
// fills, and the streamer drops messages for it.
for len(h.batch) >= prefixBatchMaxSize {
for len(h.batch) >= h.batchMaxSize {
h.batchTaken.Wait()
}
@@ -239,7 +361,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
// A full batch wakes the flush goroutine. If it is writing, the wake-up
// waits in batchFullCh and the batch keeps growing until the write ends
// or the batch reaches prefixBatchMaxSize.
// or the batch reaches batchMaxSize.
if len(h.batch) >= prefixBatchSize {
select {
case h.batchFullCh <- struct{}{}:
@@ -248,39 +370,58 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
}
}
// Stop stops the flush goroutine, which makes one last flush before it returns,
// and waits for it until ctx ends. That flush writes at most
// maxTableChangesPerWrite of the routing table's changes, and the prefixes of
// the updates the handler holds. Once ctx has ended the flush goroutine skips
// that last flush, and a write still running is lost when the process exits. A
// failed write of the table's changes is not retried either. Stop logs how many
// updates and how many of the table's changes are not written.
func (h *PrefixHandler) Stop(ctx context.Context) {
h.stopCh <- ctx
select {
case <-h.done:
case <-ctx.Done():
// startPeerDown starts peerDown for peer on a new goroutine. The caller holds
// mu. The peer's messages are held back until peerDown has applied them.
// Messages held back for an earlier PeerDown of the peer, and not yet applied,
// stay held back.
func (h *PrefixHandler) startPeerDown(peer string) {
_, goingDown := h.heldBack[peer]
if !goingDown {
h.heldBack[peer] = []*ristypes.RISMessage{}
}
h.peerDowns.Add(1)
go h.peerDown(peer)
}
// peerDown runs the table's PeerDown for peer, once no other PeerDown is
// running. Then peerDown applies the messages held back from the peer, in
// order. It takes mu for one message at a time, so HandleMessage takes new
// messages in between. The peer's new messages join the end of the held-back
// ones. A held-back message that says the peer's session went down again
// starts a new peerDown, which applies the rest.
func (h *PrefixHandler) peerDown(peer string) {
defer h.peerDowns.Done()
h.peerDownMu.Lock()
h.table.PeerDown(peer)
h.peerDownMu.Unlock()
for {
h.mu.Lock()
unwritten := h.writing + len(h.batch)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
held := h.heldBack[peer]
if len(held) == 0 {
delete(h.heldBack, peer)
h.mu.Unlock()
if unwritten == 0 && tableChanges == 0 {
return
}
if ctx.Err() != nil {
h.logger.Error("Stop deadline passed before the prefix handler wrote "+
"its updates", "updates", unwritten, "table_changes", tableChanges)
msg := held[0]
held[0] = nil // so that the message is freed once applied
h.heldBack[peer] = held[1:]
h.heldCount--
h.heldTaken.Broadcast()
h.applyMessage(msg)
h.mu.Unlock()
if msg.Type == ristypes.MessageTypeState &&
msg.State == ristypes.StateDown {
return
}
h.logger.Error("The prefix handler stopped before it wrote everything",
"updates", unwritten, "table_changes", tableChanges)
}
}
// takeBatch returns the pending updates and leaves an empty batch in their
@@ -378,14 +519,13 @@ func (h *PrefixHandler) applyToTable(update prefixUpdate) {
})
}
// writeTableChanges writes at most maxTableChangesPerWrite changes of the
// routing table in memory, and every peer the table numbered since the last
// write.
// writeTableChanges writes at most tableChangesPerWrite 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)
changes, newPeers := h.table.TakeChanges(h.tableChangesPerWrite)
h.writingTableChanges = len(changes)
h.mu.Unlock()
@@ -237,9 +237,87 @@ func TestPrefixHandlerStopLogsNothingWhenItWroteEverything(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.
// TestPrefixHandlerStopLogsTheMessagesHeldBack stops a prefix handler with a
// deadline that has passed, while a peer's PeerDown is held up and one message
// of the peer is held back. Stop must log that message as not applied.
func TestPrefixHandlerStopLogsTheMessagesHeldBack(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
h := newPrefixHandler(newMockStore(), newErrorLogger(&logs),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
h.peerDownMu.Lock()
defer h.peerDownMu.Unlock()
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.HandleMessage(sessionDown())
h.HandleMessage(peerAnnouncement(testPeerIP, testV6Prefix))
ctx, cancel := context.WithCancel(t.Context())
cancel()
h.Stop(ctx)
logged := lastLogged(t, &logs)
if logged.HeldMessages != 1 {
t.Errorf("Stop logged %d held messages not applied, want 1",
logged.HeldMessages)
}
}
// TestPrefixHandlerStopWaitsForTheMessagesHeldBack stops a prefix handler
// while a peer's PeerDown is held up and an announcement of the peer is held
// back. Stop must wait. Once the PeerDown is let go and Stop returns, the
// announcement must be in the database.
func TestPrefixHandlerStopWaitsForTheMessagesHeldBack(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
h := newPrefixHandler(db, logger.New(), routetable.New(routetable.Params{}),
longerThanAnyTest, maxTableChangesPerWrite)
h.peerDownMu.Lock()
h.HandleMessage(sessionDown())
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
stopped := make(chan struct{})
go func() {
h.Stop(t.Context())
close(stopped)
}()
select {
case <-stopped:
t.Error("Stop returned while a PeerDown was held up")
case <-time.After(stillWaitingAfter):
}
h.peerDownMu.Unlock()
select {
case <-stopped:
case <-time.After(stepTimeout):
t.Fatalf("Stop did not return within %s of the PeerDown's release",
stepTimeout)
}
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkTableOrigins(t, read, map[string]map[int][]string{
testV4Prefix: {testASNB: {testPeerIP}},
})
}
// longerThanAnyTest is a timer interval that no test lasts. A test gives it to
// newPrefixHandler, newASHandler, newPeerHandler or newPeeringHandler when the
// handler must write only when its batch is full, when the test says, or on
// Stop.
const longerThanAnyTest = time.Hour
// prefixHandlerWithoutTimer returns a prefix handler that writes to store and
@@ -250,6 +328,9 @@ func prefixHandlerWithoutTimer(store database.Store) *PrefixHandler {
db: store,
logger: logger.New(),
table: routetable.New(routetable.Params{}),
batchMaxSize: prefixBatchMaxSize,
tableChangesPerWrite: maxTableChangesPerWrite,
heldBack: make(map[string][]*ristypes.RISMessage),
}
}
@@ -7,8 +7,10 @@ import (
"log/slog"
"net/netip"
"reflect"
"runtime"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -30,11 +32,12 @@ const (
// 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
// testMaxTableChangesPerWrite is a limit of one write of the routing
// table's changes that a test exceeds quickly, in place of
// maxTableChangesPerWrite.
testMaxTableChangesPerWrite = 10
)
// 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
@@ -100,6 +103,228 @@ func TestPrefixHandlerAppliesUpdatesToTheRoutingTable(t *testing.T) {
})
}
// TestPrefixHandlerTakesMessagesWhileAPeerGoesDown holds up the PeerDown of a
// peer whose session went down, as another peer's long PeerDown would. The
// handler must go on taking messages. It applies another peer's announcement
// at once. It holds back these messages of the down peer, in this order:
// - an announcement;
// - its session going down again;
// - a second announcement.
//
// Once the PeerDowns have returned, the down peer must carry only the prefix
// of the second announcement.
func TestPrefixHandlerTakesMessagesWhileAPeerGoesDown(t *testing.T) {
t.Parallel()
h := prefixHandlerWithoutTimer(newMockStore())
h.HandleMessage(peerAnnouncement(testPeerIP, testV4Prefix))
h.peerDownMu.Lock()
handled := returnsInTime(func() {
for _, msg := range []*ristypes.RISMessage{
sessionDown(),
peerAnnouncement(secondPeerIP, otherV4Prefix),
peerAnnouncement(testPeerIP, testV6Prefix),
sessionDown(),
peerAnnouncement(testPeerIP, setV4Prefix),
} {
h.HandleMessage(msg)
}
})
if !handled {
t.Fatalf("the handler did not take the messages after the session "+
"went down within %s", stepTimeout)
}
checkTableOrigins(t, h.table, map[string]map[int][]string{
testV4Prefix: {testASNB: {testPeerIP}},
otherV4Prefix: {testASNB: {secondPeerIP}},
testV6Prefix: {},
setV4Prefix: {},
})
h.peerDownMu.Unlock()
if !returnsInTime(h.peerDowns.Wait) {
t.Fatalf("the PeerDowns did not return within %s", stepTimeout)
}
checkTableOrigins(t, h.table, map[string]map[int][]string{
testV4Prefix: {},
otherV4Prefix: {testASNB: {secondPeerIP}},
testV6Prefix: {},
setV4Prefix: {testASNB: {testPeerIP}},
})
}
// TestPrefixHandlerAppliesMessagesWhilePeerDownRuns gives a peer 50,000
// prefixes and hands the prefix handler that peer's session going down. The
// table's PeerDown then visits the prefixes in five batches. Once PeerDown has
// begun, the handler is handed 1,000 announcements of another peer. The
// handler must apply them all before PeerDown ends, while the down peer still
// carries some of its prefixes.
func TestPrefixHandlerAppliesMessagesWhilePeerDownRuns(t *testing.T) {
t.Parallel()
const (
// downPeerPrefixes is five times the 10,000 prefixes PeerDown visits
// in one hold of the table's lock.
downPeerPrefixes = 50000
// otherPeerMessages is far more messages than the handler applies
// while PeerDown runs, if PeerDown takes the lock back at once after
// each batch. The handler then applies only a few for each batch.
otherPeerMessages = 1000
// firstOtherAddr is the address of the other peer's first prefix.
firstOtherAddr = "2001:db8:2::"
)
h := prefixHandlerWithoutTimer(newMockStore())
announceManyPrefixes(h.table, downPeerPrefixes)
h.HandleMessage(sessionDown())
// Wait until PeerDown has begun: the down peer has left some of its
// prefixes. The down peer's prefixes are IPv4 and the other peer's are
// IPv6, so the counts tell them apart.
for h.table.Counts().IPv4Routes == downPeerPrefixes {
runtime.Gosched()
}
// Each announcement names the /128 of the next address.
addr := netip.MustParseAddr(firstOtherAddr)
for range otherPeerMessages {
h.HandleMessage(peerAnnouncement(secondPeerIP,
netip.PrefixFrom(addr, addr.BitLen()).String()))
addr = addr.Next()
}
counts := h.table.Counts()
if counts.IPv6Routes != otherPeerMessages {
t.Errorf("the other peer carries %d prefixes, want %d",
counts.IPv6Routes, otherPeerMessages)
}
if counts.IPv4Routes == 0 {
t.Errorf("PeerDown ended before the handler applied the other peer's "+
"%d announcements", otherPeerMessages)
}
if !returnsInTime(h.peerDowns.Wait) {
t.Fatalf("the PeerDown did not return within %s", stepTimeout)
}
}
// TestPrefixHandlerTakesMessagesWhileItAppliesHeldBackOnes holds back many
// messages of a peer whose session went down. Each names a prefix that does
// not parse, so applying it logs an error. Each log write takes a
// millisecond. While the handler applies the held-back messages, it must take
// another peer's announcement.
func TestPrefixHandlerTakesMessagesWhileItAppliesHeldBackOnes(t *testing.T) {
t.Parallel()
const (
// heldMessages take the handler at least a second to apply.
heldMessages = 1000
badPrefix = "not a prefix"
)
logs := &slowWriter{firstWrite: make(chan struct{})}
h := newPrefixHandler(newMockStore(), newErrorLogger(logs),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
defer stopInTime(t, h)
h.peerDownMu.Lock()
h.HandleMessage(sessionDown())
for range heldMessages {
h.HandleMessage(peerAnnouncement(testPeerIP, badPrefix))
}
h.peerDownMu.Unlock()
select {
case <-logs.firstWrite:
case <-time.After(stepTimeout):
t.Fatal("the handler did not start to apply the held-back messages")
}
if !returnsInTime(func() {
h.HandleMessage(peerAnnouncement(secondPeerIP, otherV4Prefix))
}) {
t.Fatalf("HandleMessage did not return within %s", stepTimeout)
}
h.mu.Lock()
left := len(h.heldBack[testPeerIP])
h.mu.Unlock()
if left == 0 {
t.Error("the handler took another peer's announcement only once it " +
"had applied every held-back message")
}
}
// TestPrefixHandlerWaitsAtTheMaximumHeldBack holds up the PeerDown of a peer
// whose session went down. It hands the handler maxHeldBackMessages messages
// of that peer. HandleMessage must then wait, even for another peer's
// message, until the PeerDown returns and a held-back message is applied.
func TestPrefixHandlerWaitsAtTheMaximumHeldBack(t *testing.T) {
t.Parallel()
h := newPrefixHandler(newMockStore(), logger.New(),
routetable.New(routetable.Params{}), longerThanAnyTest,
maxTableChangesPerWrite)
defer stopInTime(t, h)
h.peerDownMu.Lock()
h.HandleMessage(sessionDown())
held := peerAnnouncement(testPeerIP, testV6Prefix)
for range maxHeldBackMessages {
h.HandleMessage(held)
}
returned := make(chan struct{})
go func() {
h.HandleMessage(peerAnnouncement(secondPeerIP, otherV4Prefix))
close(returned)
}()
select {
case <-returned:
t.Error("HandleMessage returned while maxHeldBackMessages messages " +
"were held back")
case <-time.After(stillWaitingAfter):
}
h.peerDownMu.Unlock()
select {
case <-returned:
case <-time.After(stepTimeout):
t.Fatal("HandleMessage did not return after the PeerDown returned")
}
}
// slowWriter takes a millisecond over each write and drops what it is given.
// It closes firstWrite at the first write.
type slowWriter struct {
firstWrite chan struct{}
once sync.Once
}
func (w *slowWriter) Write(p []byte) (int, error) {
w.once.Do(func() { close(w.firstWrite) })
time.Sleep(time.Millisecond)
return len(p), nil
}
// TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable hands the
// prefix handler announcements whose AS path gives no origin. One whose path
// is empty or holds AS 0 removes the peer's route, as a withdrawal does; one
@@ -198,8 +423,9 @@ func TestPrefixHandlerKeepsThePathOfAPathEndingInASet(t *testing.T) {
}
}
// checkTableCases hands the messages of each case to a new prefix handler and
// checks its routing table right after, with no flush.
// checkTableCases hands the messages of each case to a new prefix handler. It
// checks the handler's routing table once the handler's PeerDowns have
// returned, with no flush.
func checkTableCases(t *testing.T, tests []tableCase) {
t.Helper()
@@ -212,17 +438,28 @@ func checkTableCases(t *testing.T, tests []tableCase) {
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)
}
}
h.peerDowns.Wait()
checkTableOrigins(t, h.table, tt.want)
})
}
}
// checkTableOrigins checks, for each prefix in want, the peers that carry it
// in table with each origin AS.
func checkTableOrigins(
t *testing.T, table *routetable.Table, want map[string]map[int][]string,
) {
t.Helper()
for prefix, wantOrigins := range want {
got := tableOrigins(table, prefix)
if !reflect.DeepEqual(got, wantOrigins) {
t.Errorf("peers of %s by origin AS = %v, want %v",
prefix, got, wantOrigins)
}
}
}
// tableWritesStore is a real database that hands each write of the routing
// table's changes to written once it is made.
type tableWritesStore struct {
@@ -276,9 +513,9 @@ func TestPrefixHandlerWritesTheTableChangesEachSecond(t *testing.T) {
t.Fatalf("wrote %+v, want the pair of %s and AS%d with "+
"removed %t", changes, prefix, testASNB, step.wantRemoved)
}
case <-time.After(tableWriteTimeout):
case <-time.After(stepTimeout):
t.Fatalf("the routing table's change was not written within %s",
tableWriteTimeout)
stepTimeout)
}
}
}
@@ -375,25 +612,32 @@ func (s *tableWriteSizesStore) WriteRouteChanges(
// 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.
// memory. The handler's limit is small, and its timer does not fire.
func TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes(t *testing.T) {
t.Parallel()
const (
pairs = maxTableChangesPerWrite * 3 / 2
pairs = testMaxTableChangesPerWrite * 3 / 2
flushes = 3
)
db := newTestDatabase(t)
store := &tableWriteSizesStore{Database: db}
h := prefixHandlerWithoutTimer(store)
announceManyPrefixes(h.table, pairs)
table := routetable.New(routetable.Params{})
announceManyPrefixes(table, pairs)
h := newPrefixHandler(store, logger.New(), table, longerThanAnyTest,
testMaxTableChangesPerWrite)
defer stopInTime(t, h)
for range flushes {
h.flushBatch()
}
wantChanges := []int{maxTableChangesPerWrite, pairs - maxTableChangesPerWrite}
wantChanges := []int{
testMaxTableChangesPerWrite, pairs - testMaxTableChangesPerWrite,
}
wantNewPeers := []int{1, 0}
if !slices.Equal(store.changes, wantChanges) ||
@@ -415,11 +659,11 @@ func TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes(t *testing.T) {
// takes. Its last flush writes one write's worth, so Stop must log the pairs
// that no write took, and must not say that the stop deadline passed. The
// flush timer must not fire, or its writes could leave no pairs for Stop to
// log.
// log. The handler's limit of one write is small.
func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
t.Parallel()
const pairs = maxTableChangesPerWrite + 1
const pairs = testMaxTableChangesPerWrite + 1
var logs bytes.Buffer
@@ -427,7 +671,8 @@ func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
table := routetable.New(routetable.Params{})
announceManyPrefixes(table, pairs)
h := newPrefixHandler(store, newErrorLogger(&logs), table, longerThanAnyTest)
h := newPrefixHandler(store, newErrorLogger(&logs), table, longerThanAnyTest,
testMaxTableChangesPerWrite)
h.Stop(t.Context())
written := 0
@@ -489,6 +734,7 @@ func TestARestartReadsTheRoutingTableBack(t *testing.T) {
h.HandleMessage(msg)
}
h.peerDowns.Wait()
h.flushBatch()
var logs bytes.Buffer
@@ -527,6 +773,7 @@ func TestARestartReadsTheRoutingTableBack(t *testing.T) {
restarted.HandleMessage(msg)
}
h.peerDowns.Wait()
restarted.Stop(t.Context())
read, err := db.ReadRouteTable(t.Context())
+91 -70
View File
@@ -23,7 +23,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
)
@@ -226,18 +225,23 @@ type WHOISStatsInfo struct {
NeverFetched int `json:"never_fetched"`
SuccessesLastHour int `json:"successes_last_hour"`
ErrorsLastHour int `json:"errors_last_hour"`
CurrentInterval string `json:"current_interval"`
ConsecutiveFails int `json:"consecutive_fails"`
FreshPercent float64 `json:"fresh_percent"`
// CurrentIntervalSeconds is the wait between two fetches. It can have a
// fractional part, such as 22.5.
CurrentIntervalSeconds float64 `json:"current_interval_seconds"`
}
// Stats is the /status.json answer: uptime, message counts, database stats,
// and the routing table's figures. OldestRoute and NewestRoute are the times
// of the oldest and the newest kept routes.
// and the routing table's figures. UptimeSeconds is the time since the stream
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
// newest kept routes.
//
//nolint:tagliatelle // field names are the existing JSON API
type Stats struct {
Uptime string `json:"uptime"`
UptimeSeconds int64 `json:"uptime_seconds"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
@@ -247,7 +251,7 @@ type Stats struct {
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
@@ -362,7 +366,7 @@ func (s *Server) statusStats(
runtime.ReadMemStats(&memStats)
return Stats{
Uptime: streamUptime(streamMetrics.ConnectedSince),
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes,
@@ -372,7 +376,7 @@ func (s *Server) statusStats(
Connected: streamMetrics.Connected,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
MemoryUsageBytes: memStats.Alloc,
ASNs: dbStats.ASNs,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes,
@@ -439,30 +443,31 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
NeverFetched: dbStats.NeverFetched,
SuccessesLastHour: fetcherStats.SuccessesLastHour,
ErrorsLastHour: fetcherStats.ErrorsLastHour,
CurrentInterval: fetcherStats.CurrentInterval.String(),
ConsecutiveFails: fetcherStats.ConsecutiveFails,
FreshPercent: freshPercent,
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
}
}
// streamUptime is how long the stream has been connected, to the second, or
// "0s" if it has never connected.
func streamUptime(connectedSince time.Time) string {
// uptimeSeconds is the number of whole seconds since the stream last
// connected, or 0 if it has never connected.
func uptimeSeconds(connectedSince time.Time) int64 {
if connectedSince.IsZero() {
return "0s"
return 0
}
return time.Since(connectedSince).Truncate(time.Second).String()
return int64(time.Since(connectedSince) / time.Second)
}
// connectionDuration is how long the stream has been connected, to the
// second, or "disconnected".
func connectionDuration(connected bool, connectedSince time.Time) string {
// connectionDurationSeconds is the number of whole seconds the stream has
// been connected, or nil while it is not connected.
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 {
if !connected || connectedSince.IsZero() {
return "disconnected"
return nil
}
return time.Since(connectedSince).Truncate(time.Second).String()
return new(uptimeSeconds(connectedSince))
}
// whoisStaleThreshold matches the fetcher's threshold for consistency.
@@ -512,7 +517,7 @@ type StreamStats struct {
//
//nolint:tagliatelle // field names are the existing JSON API
type StatsResponse struct {
Uptime string `json:"uptime"`
UptimeSeconds int64 `json:"uptime_seconds"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
@@ -520,11 +525,10 @@ type StatsResponse struct {
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
ConnectionDuration string `json:"connection_duration"`
ReconnectCount uint64 `json:"reconnect_count"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
@@ -546,6 +550,9 @@ type StatsResponse struct {
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
// ConnectionDurationSeconds is null while the stream is not connected.
ConnectionDurationSeconds *int64 `json:"connection_duration_seconds"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
@@ -684,7 +691,7 @@ func (s *Server) statsResponse(
runtime.ReadMemStats(&memStats)
return StatsResponse{
Uptime: streamUptime(streamMetrics.ConnectedSince),
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes,
@@ -692,12 +699,10 @@ func (s *Server) statsResponse(
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
Connected: streamMetrics.Connected,
ConnectionDuration: connectionDuration(
streamMetrics.Connected, streamMetrics.ConnectedSince),
ReconnectCount: streamMetrics.ReconnectCount,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
MemoryUsageBytes: memStats.Alloc,
GC: gcStats(&memStats),
Stream: s.streamStats(),
ASNs: dbStats.ASNs,
@@ -719,6 +724,9 @@ func (s *Server) statsResponse(
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats,
ConnectionDurationSeconds: connectionDurationSeconds(
streamMetrics.Connected, streamMetrics.ConnectedSince),
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
}
}
@@ -752,15 +760,14 @@ type IPLookupResponse struct {
}
// 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.
// and that route's AS, and the blocklists that list it. Every result has every
// field. The fields of IPInfo are null when the address has no live route.
type IPLookupResult struct {
*IPInfo
IPInfo
IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
// PTR is empty, never nil, when the address has no PTR names.
PTR []string `json:"ptr"`
// Blocklists has one entry for each blocklist the address is on.
Blocklists []blocklist.Listing `json:"blocklists"`
@@ -818,8 +825,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
switch {
case errors.Is(err, ErrNoRoute):
// The address still gets a result, without route fields.
ipInfo = nil
// The address still gets a result, with every field of
// ipInfo null.
case errors.Is(err, ErrInvalidIP):
response.Errors = append(response.Errors, ip+": "+err.Error())
@@ -846,8 +853,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
Blocklists: s.blocklistListings(ip),
})
if ipInfo != nil && ipInfo.NeedsWHOISRefresh {
refreshASNs[ipInfo.ASN] = true
if ipInfo.NeedsWHOISRefresh {
refreshASNs[*ipInfo.ASN] = true
}
}
@@ -858,60 +865,72 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
}
// ipInfo returns the IP lookup's answer for ip. The routing table gives the
// most specific prefix that holds the address, the number of peers that carry
// it, the origin AS that most of them carry, and the time of its newest kept
// route. The database gives the prefix's first-seen time and the AS's WHOIS
// details. It returns ErrNoRoute when no prefix holds the address, and
// ErrInvalidIP when ip does not parse.
func (s *Server) ipInfo(ctx context.Context, ip string) (*IPInfo, error) {
// ipInfo returns what the IP lookup knows about the route of ip. The routing
// table gives the most specific prefix that holds the address, the number of
// peers that carry it, its origin ASes and the one that most peers carry, and
// the time of its newest kept route. The database gives the prefix's
// first-seen time and the AS's WHOIS details. It returns ErrNoRoute when no
// prefix holds the address, and ErrInvalidIP when ip does not parse. With an
// error, every field of the IPInfo it returns is nil.
func (s *Server) ipInfo(ctx context.Context, ip string) (IPInfo, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
return IPInfo{}, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
}
summary, found := s.table.Lookup(addr)
if !found {
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
return IPInfo{}, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
if err != nil {
return nil, err
return IPInfo{}, err
}
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
if err != nil {
return nil, err
return IPInfo{}, err
}
info := &IPInfo{
IP: ip,
Netblock: summary.Prefix.String(),
MaskLength: summary.Prefix.Bits(),
IPVersion: ipVersionOf(summary.Prefix),
NumPeers: summary.Peers,
ASN: summary.OriginASN,
ASName: asInfo.ASName,
Handle: asInfo.Handle,
Description: asInfo.Description,
OrgName: asInfo.OrgName,
OrgID: asInfo.OrgID,
Address: asInfo.Address,
CountryCode: asInfo.CountryCode,
AbuseEmail: asInfo.AbuseEmail,
RIR: asInfo.RIR,
FirstSeen: firstSeen,
info := IPInfo{
Netblock: new(summary.Prefix.String()),
MaskLength: new(summary.Prefix.Bits()),
IPVersion: new(ipVersionOf(summary.Prefix)),
NumPeers: new(summary.Peers),
ASN: new(summary.OriginASN),
NumOrigins: new(len(summary.OriginASNs)),
OriginASNs: summary.OriginASNs,
ASName: nilIfEmpty(asInfo.ASName),
Handle: nilIfEmpty(asInfo.Handle),
Description: nilIfEmpty(asInfo.Description),
OrgName: nilIfEmpty(asInfo.OrgName),
OrgID: nilIfEmpty(asInfo.OrgID),
Address: nilIfEmpty(asInfo.Address),
CountryCode: nilIfEmpty(asInfo.CountryCode),
AbuseEmail: nilIfEmpty(asInfo.AbuseEmail),
RIR: nilIfEmpty(asInfo.RIR),
LastSeen: summary.Newest,
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
}
if summary.Newest != nil {
info.LastSeen = *summary.Newest
if !firstSeen.IsZero() {
info.FirstSeen = &firstSeen
}
return info, nil
}
// nilIfEmpty returns a pointer to s, or nil when s is empty, so that JSON
// gives a detail the database does not hold as null.
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
// needsWHOISRefresh says whether the WHOIS details of asInfo were never
// fetched or are older than whoisStaleThreshold. AS 0, no known origin, has
// none to fetch.
@@ -1117,15 +1136,17 @@ func publicAddresses(addrs []string) []string {
return public
}
// lookupPTRNames returns the PTR records of ip without their trailing dots, or
// nil when the lookup fails or runs past ptrLookupTimeout.
// lookupPTRNames returns the PTR records of ip without their trailing dots. It
// returns an empty list, never nil, when there are none, when the lookup fails
// and when it runs past ptrLookupTimeout, so that an answer's ptr is [] rather
// than null.
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout)
defer cancel()
ptrs, err := s.resolver.LookupAddr(ctx, ip)
if err != nil {
return nil
if err != nil || ptrs == nil {
return []string{}
}
// Remove trailing dots from PTR records
+291 -13
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"html"
"maps"
"net"
"net/http"
"net/http/httptest"
@@ -72,8 +73,8 @@ func emptyTable() *routetable.Table {
// Paths several tests request.
const (
asPagePath = "/as/64500"
asJSONPath = "/api/v1/as/64500"
prefixLengthPagePath = "/prefixlength/24"
statusJSONPath = "/status.json"
statsPath = "/api/v1/stats"
)
@@ -294,6 +295,167 @@ func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
}
}
// fixedIntervalASNFetcher is a WHOIS fetcher that does nothing and reports
// interval as its wait between fetches.
type fixedIntervalASNFetcher struct {
idleASNFetcher
interval time.Duration
}
func (f fixedIntervalASNFetcher) GetStats() ASNFetcherStats {
return ASNFetcherStats{CurrentInterval: f.interval}
}
// unitStats holds the figures of a stats answer that carry a unit. Each is a
// pointer, so that a figure missing from the answer is nil.
//
//nolint:tagliatelle // field names are the existing JSON API
type unitStats struct {
UptimeSeconds *int64 `json:"uptime_seconds"`
MemoryUsageBytes *uint64 `json:"memory_usage_bytes"`
WHOISStats struct {
CurrentIntervalSeconds *float64 `json:"current_interval_seconds"`
} `json:"whois_stats"`
GC struct {
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
} `json:"gc"`
}
// TestStatsAnswersGiveUnitsInFieldNames checks that the figures of both stats
// answers that carry a unit are numbers, with the unit at the end of their
// names. They used to be strings with the unit in them, such as "1h18m34s" or
// "1.1 GB" (https://git.eeqj.de/sneak/routewatch/issues/199). The stream has
// never connected, so the uptime is 0. The memory use in /api/v1/stats is the
// same number of bytes as its gc.heap_alloc_bytes.
func TestStatsAnswersGiveUnitsInFieldNames(t *testing.T) {
t.Parallel()
const fetchInterval = 18200 * time.Millisecond
s := newServerWithTestRoutes(t)
s.SetASNFetcher(fixedIntervalASNFetcher{interval: fetchInterval})
for _, path := range []string{statusJSONPath, statsPath} {
answer := get(t, s, path).Body.Bytes()
var body struct {
Data unitStats `json:"data"`
}
err := json.Unmarshal(answer, &body)
if err != nil {
t.Errorf("%s: figures are not numbers: %v; answer %s", path, err, answer)
continue
}
got := body.Data
if got.UptimeSeconds == nil || *got.UptimeSeconds != 0 {
t.Errorf("%s: uptime_seconds is not 0: %s", path, answer)
}
if got.MemoryUsageBytes == nil || *got.MemoryUsageBytes == 0 {
t.Errorf("%s: memory_usage_bytes is not a number of bytes: %s",
path, answer)
}
interval := got.WHOISStats.CurrentIntervalSeconds
if interval == nil || *interval != fetchInterval.Seconds() {
t.Errorf("%s: whois_stats.current_interval_seconds is not %v: %s",
path, fetchInterval.Seconds(), answer)
}
if path == statsPath && got.MemoryUsageBytes != nil &&
*got.MemoryUsageBytes != got.GC.HeapAllocBytes {
t.Errorf("%s: memory_usage_bytes %d, want gc.heap_alloc_bytes %d",
path, *got.MemoryUsageBytes, got.GC.HeapAllocBytes)
}
}
}
// TestStatsAnswerGivesConnectionDurationInSeconds checks that the
// connection_duration_seconds of /api/v1/stats is null while the stream is not
// connected, and a whole number of seconds once it is. It used to be the
// string "disconnected", or a Go duration such as "1h18m34s"
// (https://git.eeqj.de/sneak/routewatch/issues/199).
func TestStatsAnswerGivesConnectionDurationInSeconds(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
connectionDuration := func() json.RawMessage {
answer := get(t, s, statsPath).Body.Bytes()
var body struct {
Data map[string]json.RawMessage `json:"data"`
}
err := json.Unmarshal(answer, &body)
if err != nil {
t.Fatalf("decoding the answer: %v; answer %s", err, answer)
}
duration, ok := body.Data["connection_duration_seconds"]
if !ok {
t.Fatalf("no connection_duration_seconds: %s", answer)
}
return duration
}
disconnected := connectionDuration()
if string(disconnected) != "null" {
t.Errorf("disconnected: connection_duration_seconds = %s, want null",
disconnected)
}
s.streamer.GetMetricsTracker().SetConnected(true)
var seconds int64
connected := connectionDuration()
err := json.Unmarshal(connected, &seconds)
if err != nil || seconds != 0 {
t.Errorf("just connected: connection_duration_seconds = %s, want 0",
connected)
}
}
// TestStreamDurationsCountSeconds checks that the uptime and the connection
// duration of a stream that connected 90 seconds ago are both 90. A stream
// that has never connected has an uptime of 0, and one that is not connected
// has no connection duration.
func TestStreamDurationsCountSeconds(t *testing.T) {
t.Parallel()
const connectedFor = 90 * time.Second
connectedSince := time.Now().Add(-connectedFor)
want := int64(connectedFor / time.Second)
if got := uptimeSeconds(connectedSince); got != want {
t.Errorf("uptimeSeconds = %d, want %d", got, want)
}
if got := uptimeSeconds(time.Time{}); got != 0 {
t.Errorf("uptimeSeconds before any connection = %d, want 0", got)
}
switch got := connectionDurationSeconds(true, connectedSince); {
case got == nil:
t.Errorf("connectionDurationSeconds = nil, want %d", want)
case *got != want:
t.Errorf("connectionDurationSeconds = %d, want %d", *got, want)
}
if got := connectionDurationSeconds(false, connectedSince); got != nil {
t.Errorf("connectionDurationSeconds while not connected = %d, want nil",
*got)
}
}
// newTestTable returns a routing table that holds a route for testPrefixV4,
// with origin AS 64500, and one for testPrefixV6, with origin AS 64501.
func newTestTable() *routetable.Table {
@@ -765,7 +927,7 @@ func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: statsPath, wantJSONBody: true},
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: "/api/v1/as/64500", wantJSONBody: true},
{path: asJSONPath, wantJSONBody: true},
}
for _, tt := range tests {
@@ -946,10 +1108,10 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
path string
wantCode int
}{
{"/.well-known/healthcheck.json", http.StatusServiceUnavailable},
{healthCheckPath, http.StatusServiceUnavailable},
{statusJSONPath, http.StatusInternalServerError},
{statsPath, http.StatusInternalServerError},
{"/api/v1/as/64500", http.StatusInternalServerError},
{asJSONPath, http.StatusInternalServerError},
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
}
@@ -1219,7 +1381,8 @@ func TestIPLookupKeepsRouteWhenPTRLookupDoesNotAnswer(t *testing.T) {
t.Fatalf("decoding the answer %q: %v", rec.Body, err)
}
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock != testPrefixV4 {
if len(body.Data.Results) != 1 || body.Data.Results[0].Netblock == nil ||
*body.Data.Results[0].Netblock != testPrefixV4 {
t.Errorf("answer %s, want one result in %s", rec.Body, testPrefixV4)
}
}
@@ -1311,12 +1474,39 @@ func TestIPLookupReportsBlocklists(t *testing.T) {
}
}
// jsonNull is how JSON writes a field without a value.
const jsonNull = "null"
// jsonEmptyList is how JSON writes an empty list.
const jsonEmptyList = "[]"
// resultFields decodes an IP lookup answer and returns each of its results as
// its JSON fields.
func resultFields(t *testing.T, answer []byte) []map[string]json.RawMessage {
t.Helper()
var body struct {
Data struct {
Results []map[string]json.RawMessage `json:"results"`
} `json:"data"`
}
err := json.Unmarshal(answer, &body)
if err != nil {
t.Fatalf("decoding the answer %s: %v", answer, err)
}
return body.Data.Results
}
// TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute looks up a listed
// address that has no live route, alone and beside a routed address. Each
// answer is 200 and has a result for the address, with its blocklists and 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.
// answer is 200 and has a result for the address, with its blocklists and
// every route field set to null. Before the fix the lookup of that address
// alone answered 404, and beside a routed address it was only named in
// errors, so its blocklists were never reported. After that fix the result
// still left out the route fields
// (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
t.Parallel()
@@ -1324,6 +1514,15 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
s.resolver = &fakeResolver{}
s.SetBlocklists(fakeBlocklists{})
// routeFields are the fields of a result that come from the address's
// route and that route's origin AS.
routeFields := []string{
"netblock", "mask_length", "ip_version", "num_peers", "asn",
"num_origins", "origin_asns", "as_name", "handle", "description",
"org_name", "org_id", "address", "country_code", "abuse_email", "rir",
"first_seen", "last_seen",
}
for _, target := range []string{
listedUnroutedAddr,
addrInTestPrefixV4 + "," + listedUnroutedAddr,
@@ -1354,12 +1553,21 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
continue
}
result := body.Data.Results[i]
if result.IPInfo != nil {
t.Errorf("%s: result for %s has route fields %+v",
target, listedUnroutedAddr, result.IPInfo)
fields := resultFields(t, rec.Body.Bytes())[i]
for _, name := range routeFields {
value, present := fields[name]
switch {
case !present:
t.Errorf("%s: the result for %s has no %s",
target, listedUnroutedAddr, name)
case string(value) != jsonNull:
t.Errorf("%s: %s of %s is %s, want %s",
target, name, listedUnroutedAddr, value, jsonNull)
}
}
result := body.Data.Results[i]
if len(result.Blocklists) != 1 ||
result.Blocklists[0].Name != testListing().Name {
t.Errorf("%s: blocklists %+v, want [%+v]",
@@ -1368,6 +1576,76 @@ func TestIPLookupReportsBlocklistsOfAnAddressWithNoRoute(t *testing.T) {
}
}
// twoAddressHostName is a host name that resolves to addrInTestPrefixV4 and
// listedUnroutedAddr.
const twoAddressHostName = "two-addresses.test"
// TestIPLookupResultsHaveTheSameFields looks up a host name, addresses and
// the caller's own address, through each IP lookup route. Some of the
// addresses have a route and some do not, and none has PTR names. Every
// result has the same fields. Before the fix a result left out every field
// without a value (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupResultsHaveTheSameFields(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{hosts: map[string][]string{
twoAddressHostName: {addrInTestPrefixV4, listedUnroutedAddr},
}}
s.SetBlocklists(fakeBlocklists{})
// want is the fields of the first result.
var want []string
for _, path := range []string{
"/api/v1/ip/" + twoAddressHostName,
"/ip/" + addrInTestPrefixV6 + "," + listedUnroutedAddr,
ownAddressPath,
} {
for _, result := range resultFields(t, get(t, s, path).Body.Bytes()) {
got := slices.Sorted(maps.Keys(result))
if want == nil {
want = got
}
if !slices.Equal(got, want) {
t.Errorf("%s: a result has the fields %v, want %v", path, got, want)
}
}
}
}
// TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock looks up a routed
// address that has no PTR names, and whose netblock the database has not
// stored. Its ptr is an empty list and its first_seen is null. Before the fix
// ptr was left out, and first_seen was 0001-01-01T00:00:00Z
// (https://git.eeqj.de/sneak/routewatch/issues/200).
func TestIPLookupOfAnAddressWithoutPTRNamesOrStoredNetblock(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
path := "/api/v1/ip/" + addrInTestPrefixV4
results := resultFields(t, get(t, s, path).Body.Bytes())
if len(results) != 1 {
t.Fatalf("%s: %d results, want 1", path, len(results))
}
want := map[string]string{
"ptr": jsonEmptyList,
"first_seen": jsonNull,
}
for name, value := range want {
got := string(results[0][name])
if got != value {
t.Errorf("%s: %s is %s, want %s", path, name, got, value)
}
}
}
// 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
+33 -17
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"slices"
"strconv"
"strings"
@@ -75,9 +77,10 @@ func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
// TestIPLookupGivesTheOriginMostPeersCarry looks up an address in a prefix
// with two origin ASes, through each IP lookup route. The answer names the
// origin AS that more peers carry, counts every peer, and gives the time of
// the prefix's newest kept route. The prefix's first-seen time and the AS's
// WHOIS details come from the database.
// origin AS that more peers carry, counts every peer, lists both origin ASes,
// and gives the time of the prefix's newest kept route. The prefix's
// first-seen time and the AS's WHOIS details come from the database. A WHOIS
// detail the database does not hold is null.
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
@@ -105,6 +108,26 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Fatalf("UpdateASNWHOIS: %v", err)
}
// The details the database does not hold, such as org_name, are null.
want := IPInfo{
Netblock: new(testPrefixV4),
MaskLength: new(netip.MustParsePrefix(testPrefixV4).Bits()),
IPVersion: new(ipVersionV4),
NumPeers: new(4),
ASN: new(mostPeersASN),
NumOrigins: new(2),
OriginASNs: []int{mostPeersASN, fewerPeersASN},
ASName: &whois.ASName,
CountryCode: &whois.CountryCode,
FirstSeen: &firstSeen,
LastSeen: new(testRouteTime(5)),
}
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("encoding %+v: %v", want, err)
}
// ownAddressPath looks up the request's own address, which httptest
// gives as addrInTestPrefixV4.
for _, path := range []string{
@@ -115,22 +138,15 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
Data IPLookupResponse `json:"data"`
}
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil || len(body.Data.Results) != 1 ||
body.Data.Results[0].IPInfo == nil {
t.Fatalf("%s: answer %+v (%v), want one result with a route",
path, body.Data, err)
answer := get(t, s, path).Body.Bytes()
err := json.Unmarshal(answer, &body)
if err != nil || len(body.Data.Results) != 1 {
t.Fatalf("%s: answer %s (%v), want one result", path, answer, err)
}
got := body.Data.Results[0].IPInfo
if got.Netblock != testPrefixV4 || got.ASN != mostPeersASN ||
got.NumPeers != 4 || !got.LastSeen.Equal(testRouteTime(5)) ||
!got.FirstSeen.Equal(firstSeen) || got.ASName != whois.ASName ||
got.CountryCode != whois.CountryCode {
t.Errorf("%s: route %+v, want %s from AS%d, 4 peers, last seen %v, "+
"first seen %v, %s in %s", path, *got, testPrefixV4,
mostPeersASN, testRouteTime(5), firstSeen, whois.ASName,
whois.CountryCode)
if !reflect.DeepEqual(body.Data.Results[0].IPInfo, want) {
t.Errorf("%s: answer %s, want the fields %s", path, answer, wantJSON)
}
}
}
+61
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"testing"
"testing/synctest"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
@@ -341,6 +342,66 @@ func TestStartedServerWorksOutTheCards(t *testing.T) {
}
}
// TestStartedServerWorksOutTheCardsUntilStop starts a server with a context
// that ends as soon as Start returns. It runs in a synctest bubble, whose clock
// is fake: time.Sleep there moves the clock on and returns at once. The server
// must work the cards out again a minute later, and no more after Stop. Each
// time it works them out, it keeps a new copy. Before the fix the end of
// Start's context stopped the goroutine that works them out.
func TestStartedServerWorksOutTheCardsUntilStop(t *testing.T) {
t.Parallel()
// The server is made outside the bubble. The streamer's metrics start a
// goroutine that never returns, and a bubble waits for each of its
// goroutines to return.
s := newServerWithTable(t, newCardsTable())
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
err := s.Start(ctx)
if err != nil {
t.Fatalf("Start: %v", err)
}
cancel()
// The bubble's clock moves only while each of its goroutines waits
// on the bubble, and the goroutine that serves HTTP waits on the
// network. The test sends no request, so it closes the HTTP server.
_ = s.srv.Close()
synctest.Wait()
started := s.cards.Load()
if started == nil {
t.Fatal("the server did not work out the cards when it started")
}
time.Sleep(homeCardsRefreshInterval)
synctest.Wait()
if s.cards.Load() == started {
t.Error("the server did not work out the cards again a minute " +
"after its start context ended")
}
err = s.Stop(t.Context())
if err != nil {
t.Fatalf("Stop: %v", err)
}
stopped := s.cards.Load()
time.Sleep(homeCardsRefreshInterval)
synctest.Wait()
if s.cards.Load() != stopped {
t.Error("the server worked out the cards again after Stop")
}
})
}
// waitUntil fails the test unless done reports true within maxWait.
func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) {
t.Helper()
+19 -7
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// responseWriter wraps http.ResponseWriter to capture the response
@@ -53,6 +54,15 @@ func (rw *responseWriter) Header() http.Header {
return rw.ResponseWriter.Header()
}
// The @meta object JSONResponseMiddleware adds to a JSON answer: its key, and
// the keys of its fields.
const (
jsonKeyMeta = "@meta"
metaKeyTimeZone = "time_zone"
metaKeyAPIVersion = "api_version"
metaKeyExecutionTimeMs = "execution_time_ms"
)
// JSONResponseMiddleware is an HTTP middleware that wraps all JSON responses
// with a @meta field containing execution metadata. The metadata includes the
// time zone (always UTC), API version, and request execution time in milliseconds.
@@ -106,10 +116,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler {
}
// Add @meta field
originalResponse["@meta"] = map[string]any{
"time_zone": "UTC",
"api_version": 1,
"execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()),
originalResponse[jsonKeyMeta] = map[string]any{
metaKeyTimeZone: "UTC",
metaKeyAPIVersion: 1,
metaKeyExecutionTimeMs: execTime.Milliseconds(),
}
// Write the enhanced response
@@ -145,7 +155,8 @@ func deadlinePassed(ctx context.Context) bool {
// has passed, it answers 504 with the error message "Request timeout". A 4xx
// keeps its status, with the status's standard text as the message. For other
// errors, it returns "Internal server error", with status 500 if the original
// status was 200.
// status was 200. A panic in the handler answers 500 with "Internal server
// error" too.
//
// Non-JSON responses are passed through unchanged.
func JSONValidationMiddleware(next http.Handler) http.Handler {
@@ -157,8 +168,9 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
statusCode: http.StatusOK,
}
// Serve the request
next.ServeHTTP(rw, r)
// Serve the request. Recoverer logs a panic in the handler and turns it
// into a 500 without a body, which gets the JSON error body below.
middleware.Recoverer(next).ServeHTTP(rw, r)
// Check if it's meant to be a JSON response
contentType := rw.Header().Get("Content-Type")
+215
View File
@@ -2,6 +2,7 @@ package server
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
@@ -10,6 +11,7 @@ import (
"net/http/httptest"
"net/netip"
"os"
"regexp"
"strings"
"testing"
"time"
@@ -100,6 +102,219 @@ func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
}
// TestJSONAnswersPutNoUnitInAString sends a request to each JSON route and
// checks each answer. Its @meta.execution_time_ms is a number, and none of its
// strings is a number with a unit, such as "12 ms", "1.1 GB" or "1h18m34s"
// (https://git.eeqj.de/sneak/routewatch/issues/199). The health check answers
// 503, since the stream is not connected, and the last path matches no route.
func TestJSONAnswersPutNoUnitInAString(t *testing.T) {
t.Parallel()
numberWithUnit := regexp.MustCompile(
`^([0-9]+(\.[0-9]+)? ?(ns|µs|ms|s|m|h|B|kB|MB|GB|TB))+$`)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.SetASNFetcher(fixedIntervalASNFetcher{interval: time.Second})
paths := []string{
statusJSONPath,
statsPath,
healthCheckPath,
"/api/v1/ip/" + untrustedPeerAddr,
"/ip/" + untrustedPeerAddr,
asJSONPath,
"/api/v1/prefix/" + testPrefixV4,
"/api/v1/search/as?q=google",
"/api/v1/no-such-route",
}
for _, path := range paths {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
var answer map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Errorf("%s: decoding the answer: %v; answer %s", path, err, rec.Body)
continue
}
meta, _ := answer["@meta"].(map[string]any)
if _, ok := meta["execution_time_ms"].(float64); !ok {
t.Errorf("%s: @meta.execution_time_ms is not a number: %s",
path, rec.Body)
}
for _, value := range stringsIn(answer) {
if numberWithUnit.MatchString(value) {
t.Errorf("%s: %q is a number with a unit in a string", path, value)
}
}
}
}
// TestJSONAnswersCarryMeta sends requests to every JSON route through the
// server's router, and checks that each answer carries @meta with its three
// fields. The answers include successes, and errors that the handlers, the
// router and the middleware write. Before the fix a panic in a handler, and a
// method /status.json or the health check does not take, answered with an
// empty body (https://git.eeqj.de/sneak/routewatch/issues/201).
func TestJSONAnswersCarryMeta(t *testing.T) {
t.Parallel()
// JSON routes whose handlers write nothing or panic, so that the JSON
// check on the route writes the error answer itself.
const (
writesNothingPath = "/writes-nothing.json"
panicsPath = "/panics.json"
)
s := newServerWithTestRoutes(t)
s.resolver = &fakeResolver{}
s.streamer.GetMetricsTracker().SetConnected(true)
s.router.Get(writesNothingPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) {})).ServeHTTP)
s.router.Get(panicsPath, JSONValidationMiddleware(http.HandlerFunc(
func(http.ResponseWriter, *http.Request) { panic(errDiskIO) })).ServeHTTP)
tests := []struct {
method string
path string
wantCode int
}{
// Answers that succeed
{http.MethodGet, statusJSONPath, http.StatusOK},
{http.MethodGet, healthCheckPath, http.StatusOK},
{http.MethodGet, "/ip", http.StatusOK},
{http.MethodGet, ownAddressPath, http.StatusOK},
{http.MethodGet, "/ip/" + addrInTestPrefixV4, http.StatusOK},
{http.MethodGet, statsPath, http.StatusOK},
{http.MethodGet, "/api/v1/ip/" + addrInTestPrefixV4, http.StatusOK},
{http.MethodGet, asJSONPath, http.StatusOK},
{http.MethodGet, "/api/v1/prefix/" + testPrefixV4, http.StatusOK},
{http.MethodGet, "/api/v1/search/as?q=google", http.StatusOK},
// Errors the handlers write
{http.MethodGet, "/ip/no-such-host.example", http.StatusBadRequest},
{http.MethodGet, "/api/v1/ip/no-such-host.example", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/foo", http.StatusBadRequest},
{http.MethodGet, "/api/v1/as/0", http.StatusNotFound},
{http.MethodGet, "/api/v1/prefix/192.0.2.0/33", http.StatusBadRequest},
{http.MethodGet, "/api/v1/prefix/198.51.100.0/24", http.StatusNotFound},
{http.MethodGet, "/api/v1/search/as", http.StatusBadRequest},
// Errors the router and the middleware write
{http.MethodGet, "/api/v1/no-such-route", http.StatusNotFound},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound},
{http.MethodPost, statsPath, http.StatusMethodNotAllowed},
{http.MethodPost, ownAddressPath, http.StatusMethodNotAllowed},
{http.MethodPost, statusJSONPath, http.StatusMethodNotAllowed},
{http.MethodPut, healthCheckPath, http.StatusMethodNotAllowed},
{http.MethodGet, writesNothingPath, http.StatusInternalServerError},
{http.MethodGet, panicsPath, http.StatusInternalServerError},
}
for _, tt := range tests {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
t.Context(), tt.method, tt.path, nil), tt.wantCode)
}
// A request whose time limit has already passed answers 504. The handlers
// write the answers of the first five routes, and the JSON check on the
// route writes the last.
pastTimeLimit, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
for _, path := range []string{
statusJSONPath, statsPath, "/ip/" + addrInTestPrefixV4,
"/api/v1/ip/" + addrInTestPrefixV4, asJSONPath, writesNothingPath,
} {
checkAnswerCarriesMeta(t, s, httptest.NewRequestWithContext(
pastTimeLimit, http.MethodGet, path, nil), http.StatusGatewayTimeout)
}
// With a store whose reads fail, the health check answers 503, and the
// handlers of the other routes 500.
failing := New(failingDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
failing.resolver = &fakeResolver{}
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, healthCheckPath, nil),
http.StatusServiceUnavailable)
for _, path := range []string{
statusJSONPath, statsPath, asJSONPath,
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
} {
checkAnswerCarriesMeta(t, failing, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil), http.StatusInternalServerError)
}
}
// checkAnswerCarriesMeta serves req through the server's router, and checks
// that it answers wantCode with @meta and its three fields.
func checkAnswerCarriesMeta(
t *testing.T, s *Server, req *http.Request, wantCode int,
) {
t.Helper()
// The values README "API v1" gives the two fields that never change.
const (
wantTimeZone = "UTC"
wantAPIVersion = 1
)
name := req.Method + " " + req.URL.String()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != wantCode {
t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, wantCode, rec.Body)
}
var answer map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &answer)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
return
}
meta, _ := answer[jsonKeyMeta].(map[string]any)
_, timeIsNumber := meta[metaKeyExecutionTimeMs].(float64)
if meta[metaKeyTimeZone] != wantTimeZone ||
meta[metaKeyAPIVersion] != float64(wantAPIVersion) || !timeIsNumber {
t.Errorf("%s: @meta %v, want time_zone %q, api_version %d and a "+
"number in execution_time_ms", name, meta, wantTimeZone, wantAPIVersion)
}
}
// stringsIn returns every string in a decoded JSON value, at any depth.
func stringsIn(value any) []string {
var found []string
switch v := value.(type) {
case string:
found = append(found, v)
case []any:
for _, item := range v {
found = append(found, stringsIn(item)...)
}
case map[string]any:
for _, item := range v {
found = append(found, stringsIn(item)...)
}
}
return found
}
// TestPagesCarryTheCSPNonce checks that every script and style block of each
// page carries the nonce its Content-Security-Policy header names, so that the
// browser runs and applies them, that each answer has a nonce of its own, and
+28 -21
View File
@@ -38,30 +38,37 @@ type PrefixDistribution struct {
Count int `json:"count"`
}
// IPInfo represents comprehensive IP information for the /ip endpoint
// IPInfo is what the IP lookup knows about the route of an address and that
// route's origin AS. A field is nil, null in JSON, when its value is not known.
// Every field is nil for an address with no route.
//
//nolint:tagliatelle // field names are the existing JSON API
type IPInfo struct {
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"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"`
OrgName string `json:"org_name,omitempty"`
OrgID string `json:"org_id,omitempty"`
Address string `json:"address,omitempty"`
CountryCode string `json:"country_code,omitempty"`
AbuseEmail string `json:"abuse_email,omitempty"`
RIR string `json:"rir,omitempty"`
// Timestamps. LastSeen is zero, and left out of JSON, when the prefix
// keeps no route.
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen,omitzero"`
// Netblock is the most specific prefix that holds the address.
Netblock *string `json:"netblock"`
MaskLength *int `json:"mask_length"`
IPVersion *int `json:"ip_version"`
NumPeers *int `json:"num_peers"`
// ASN is the origin AS that the most peers carry the netblock with.
ASN *int `json:"asn"`
// NumOrigins and OriginASNs are how many origin ASes peers carry the
// netblock with, and which, in AS number order.
NumOrigins *int `json:"num_origins"`
OriginASNs []int `json:"origin_asns"`
// The details the database holds about the AS in ASN.
ASName *string `json:"as_name"`
Handle *string `json:"handle"`
Description *string `json:"description"`
OrgName *string `json:"org_name"`
OrgID *string `json:"org_id"`
Address *string `json:"address"`
CountryCode *string `json:"country_code"`
AbuseEmail *string `json:"abuse_email"`
RIR *string `json:"rir"`
// FirstSeen is when RouteWatch first saw the netblock. LastSeen is the
// time of the netblock's newest kept route.
FirstSeen *time.Time `json:"first_seen"`
LastSeen *time.Time `json:"last_seen"`
// Indicates if WHOIS data needs refresh (not serialized)
NeedsWHOISRefresh bool `json:"-"`
}
+26 -7
View File
@@ -8,8 +8,8 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
// The error messages the JSON routes under /api/v1 and /ip answer with when no
// route matches the path, and when none takes the method.
// The error messages the JSON routes answer with when no route matches the
// path, and when the route does not take the method.
const (
notFoundMessage = "Not found"
methodNotAllowedMessage = "Method not allowed"
@@ -19,6 +19,13 @@ const (
jsonRoutesAllow = "GET, HEAD"
)
// The paths of the status figures and the health check, the two JSON routes
// outside /api/v1 and /ip.
const (
statusJSONPath = "/status.json"
healthCheckPath = "/.well-known/healthcheck.json"
)
// setupRoutes configures the HTTP routes
func (s *Server) setupRoutes() {
r := chi.NewRouter()
@@ -32,6 +39,8 @@ func (s *Server) setupRoutes() {
r.Use(middleware.RequestID)
r.Use(RealIPMiddleware(s.trustedProxies))
r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging
// Recoverer answers a panic on a page. JSONValidationMiddleware recovers a
// panic on a JSON route itself, so that the answer is JSON with @meta.
r.Use(middleware.Recoverer)
r.Use(JSONResponseMiddleware)
// Timeout must come after JSONResponseMiddleware, which sends its answer
@@ -43,11 +52,6 @@ func (s *Server) setupRoutes() {
// Routes
r.Get("/", s.handleIndex())
r.Get("/status", s.handleStatusHTML())
r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get(
"/.well-known/healthcheck.json",
JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP,
)
// AS and prefix detail pages
r.Get("/as/{asn}", s.handleASDetail())
@@ -70,6 +74,21 @@ func (s *Server) setupRoutes() {
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
}
// The status figures and the health check. chi sets one 405 answer for a
// whole router. These two routes are on the pages' router, whose 405 stays
// chi's own. So each method they do not take is routed to the JSON 405
// here.
r.Get(statusJSONPath, JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP)
r.Get(healthCheckPath, JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP)
for _, method := range []string{
http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodOptions, http.MethodTrace, http.MethodConnect,
} {
r.MethodFunc(method, statusJSONPath, jsonMethodNotAllowed)
r.MethodFunc(method, healthCheckPath, jsonMethodNotAllowed)
}
// IP info JSON endpoints (replaces old /ip redirect)
r.Route("/ip", func(r chi.Router) {
r.Use(JSONValidationMiddleware)
+13 -2
View File
@@ -17,7 +17,7 @@ func TestRoutesAnswerHEAD(t *testing.T) {
s := newServerWithTestRoutes(t)
for _, path := range []string{"/", statsPath, "/ip", "/ip/"} {
for _, path := range []string{"/", statusJSONPath, statsPath, "/ip", "/ip/"} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodHead, path, nil))
@@ -31,10 +31,13 @@ func TestRoutesAnswerHEAD(t *testing.T) {
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
// routes do not take, and a path none of them matches, to each group of JSON
// routes through the server's router. Each must answer with the JSON error
// routes through the server's router. It also sends a method they do not take
// to /status.json and the health check. Each must answer with the JSON error
// body, and a 405 must name the methods the routes take in its Allow header.
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
// /status.json and the health check gave chi's empty 405
// (https://git.eeqj.de/sneak/routewatch/issues/201).
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
t.Parallel()
@@ -48,6 +51,14 @@ func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
}{
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
{
http.MethodDelete, statusJSONPath,
http.StatusMethodNotAllowed, methodNotAllowedMessage,
},
{
http.MethodOptions, healthCheckPath,
http.StatusMethodNotAllowed, methodNotAllowedMessage,
},
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
}
+27 -4
View File
@@ -82,6 +82,12 @@ type Server struct {
// cards is the copy of the home page's cards that the pages serve, nil
// until the server first works them out
cards atomic.Pointer[homeCards]
// stopCards ends the goroutine that works out the cards, and
// cardsStopped is closed once it has returned. Both are nil until Start
// starts the goroutine.
stopCards context.CancelFunc
cardsStopped chan struct{}
}
// New creates a new HTTP server that reads routes from table, the routing
@@ -111,7 +117,8 @@ func New(
// Start listens on the configured port and serves HTTP in the background.
// It returns an error if it cannot listen, for example because the port is
// already in use. ctx bounds opening the port, not serving. Once listening,
// the server works out the home page's cards every minute until ctx ends.
// the server works out the home page's cards every minute until Stop: the end
// of ctx does not stop it.
func (s *Server) Start(ctx context.Context) error {
const (
network = "tcp"
@@ -148,16 +155,22 @@ func (s *Server) Start(ctx context.Context) error {
}
}()
ctx, s.stopCards = context.WithCancel(context.WithoutCancel(ctx))
s.cardsStopped = make(chan struct{})
go func() {
defer close(s.cardsStopped)
s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval)
}()
return nil
}
// Stop stops the HTTP server. It waits, until ctx ends, for the requests in
// progress to finish, and then closes the connections of those still running
// and returns ctx's error.
// Stop stops the goroutine that works out the home page's cards, and waits
// until it has returned or ctx ends. It then stops the HTTP server. It waits,
// until ctx ends, for the requests in progress to finish, and then closes the
// connections of those still running and returns ctx's error.
func (s *Server) Stop(ctx context.Context) error {
if s.srv == nil {
return nil
@@ -165,6 +178,16 @@ func (s *Server) Stop(ctx context.Context) error {
s.logger.Info("Stopping HTTP server")
// Start returns before it starts the goroutine when it cannot listen.
if s.stopCards != nil {
s.stopCards()
select {
case <-s.cardsStopped:
case <-ctx.Done():
}
}
err := s.srv.Shutdown(ctx)
if err != nil {
return errors.Join(err, s.srv.Close())
+4 -10
View File
@@ -714,7 +714,8 @@
});
});
// IP lookup
// IP lookup: show the whole JSON answer, its @meta and an error answer
// included, as the IP information API card does.
document.getElementById('ip-form').addEventListener('submit', function(e) {
e.preventDefault();
const ip = document.getElementById('ip-input').value.trim();
@@ -730,18 +731,11 @@
fetch('/ip/' + encodeURIComponent(ip))
.then(response => response.json())
.then(response => {
contentEl.classList.remove('loading');
if (response.status === 'error') {
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + response.error.msg;
} else {
.then(answer => {
contentEl.className = '';
contentEl.textContent = JSON.stringify(response.data, null, 2);
}
contentEl.textContent = JSON.stringify(answer, null, 2);
})
.catch(error => {
contentEl.classList.remove('loading');
contentEl.className = 'error';
contentEl.textContent = 'Error: ' + error.message;
});
+19 -4
View File
@@ -401,6 +401,8 @@
const REFRESH_PERIOD_MS = 2000;
const STALE_AFTER_REFRESHES = 3;
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 3600;
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const SPINNER_COLOURS = ['#e74c3c', '#e67e22', '#f1c40f', '#27ae60', '#3498db', '#9b59b6'];
@@ -444,6 +446,17 @@
return num.toLocaleString();
}
// formatDuration writes a number of seconds in hours, minutes and
// seconds, such as "1h 18m 34s" or "18.2s", with at most one decimal.
function formatDuration(totalSeconds) {
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
const minutes = Math.floor(totalSeconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE);
const seconds = parseFloat((totalSeconds % SECONDS_PER_MINUTE).toFixed(1));
if (hours > 0) return hours + 'h ' + minutes + 'm ' + seconds + 's';
if (minutes > 0) return minutes + 'm ' + seconds + 's';
return seconds + 's';
}
function formatProcessingTime(ms) {
if (ms < 0.001) {
return (ms * 1000).toFixed(0) + ' µs';
@@ -619,11 +632,13 @@
noteMessageCount(data.total_messages);
// Update all metrics
document.getElementById('uptime').textContent = data.uptime;
document.getElementById('uptime').textContent = formatDuration(data.uptime_seconds);
document.getElementById('go_version').textContent = data.go_version;
document.getElementById('goroutines').textContent = formatNumber(data.goroutines);
document.getElementById('memory_usage').textContent = data.memory_usage;
document.getElementById('connection_duration').textContent = data.connection_duration;
document.getElementById('memory_usage').textContent = formatBytes(data.memory_usage_bytes);
// connection_duration_seconds is null while the stream is not connected.
document.getElementById('connection_duration').textContent =
data.connection_duration_seconds === null ? 'disconnected' : formatDuration(data.connection_duration_seconds);
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec));
@@ -672,7 +687,7 @@
const errorsEl = document.getElementById('whois_errors');
errorsEl.textContent = formatNumber(data.whois_stats.errors_last_hour);
errorsEl.className = 'metric-value' + (data.whois_stats.errors_last_hour > 0 ? ' disconnected' : '');
document.getElementById('whois_interval').textContent = data.whois_stats.current_interval;
document.getElementById('whois_interval').textContent = formatDuration(data.whois_stats.current_interval_seconds);
}
// Update handler stats
+82 -36
View File
@@ -12,6 +12,7 @@ import (
"regexp"
"strings"
"sync"
"syscall"
"time"
"unicode/utf8"
@@ -70,12 +71,12 @@ 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
// PauseTime is how long the client sends an RIR's or NIC.br's server no
// query after that server refuses a query. queryRegistry says what a
// refusal is. RIPE refuses an address for good when the address keeps
// passing RIPE's limit (see noPersonalFlag). An hour outlasts a limit
// counted per minute or per hour.
const PauseTime = time.Hour
// RIR identifiers.
const (
@@ -125,6 +126,10 @@ var (
errAnswerTooLarge = errors.New("WHOIS answer reached the size cap")
)
// ErrPaused is returned when a lookup needs a server that refused a query
// less than PauseTime ago. The client sends that server nothing meanwhile.
var ErrPaused = errors.New("WHOIS server paused after refusing a query")
// ASNInfo contains parsed WHOIS information for an ASN.
type ASNInfo struct {
ASN int
@@ -156,12 +161,12 @@ type Client struct {
logger *slog.Logger
// mu guards nicbrSkipUntil.
// mu guards pausedUntil.
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
// pausedUntil maps a server, given as host:port, to the time the client
// may ask it again.
pausedUntil map[string]time.Time
}
// NewClient creates a new WHOIS client that logs to logger.
@@ -172,23 +177,29 @@ func NewClient(logger *slog.Logger) *Client {
DialContext: dialer.DialContext,
Now: time.Now,
logger: logger,
pausedUntil: make(map[string]time.Time),
}
}
// 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 or
// is skipped, as askNICbr says.
// NIC.br is paused, as askNICbr says.
//
// It returns ErrNoReferral when IANA refers the AS to no RIR, and another
// error when the query to IANA or to the RIR fails, when the RIR refuses
// it, when IANA's answer has neither a referral nor the AS's block, when
// IANA refers the AS to a server none of the five RIRs run, or when the
// answer carries none of ASNInfo's fields.
// It returns ErrNoReferral when IANA refers the AS to no RIR. Its error
// wraps ErrPaused when the RIR is paused. It returns another error in each
// of these cases:
//
// - the query to IANA or to the RIR fails
// - the RIR refuses the query
// - IANA's answer has neither a referral nor the AS's block
// - IANA refers the AS to a server none of the five RIRs run
// - the answer carries none of ASNInfo's fields
func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Query IANA first to find the authoritative RIR
query := fmt.Sprintf("AS%d", asn)
// IANA is never paused, because a pause of IANA would stop every lookup.
ianaResp, err := c.query(ctx, whoisServerIANA, query)
if err != nil {
return nil, fmt.Errorf("IANA query failed: %w", err)
@@ -211,7 +222,7 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
}
// Query the authoritative RIR
rirResp, err := c.queryRIR(ctx, whoisServer, rirQuery(rir, query))
rirResp, err := c.queryRegistry(ctx, whoisServer, rirQuery(rir, query))
if err != nil {
return nil, fmt.Errorf("RIR query failed: %w", err)
}
@@ -241,22 +252,19 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// 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.
// fields. The reduced answer pauses NIC.br, as a refusal does. While NIC.br
// is paused, askNICbr returns lacnicInfo without a warning. The ASes looked
// up meanwhile get NIC.br's fields when they are next refreshed.
func (c *Client) askNICbr(
ctx context.Context, asQuery string, lacnicInfo *ASNInfo,
) *ASNInfo {
asn := lacnicInfo.ASN
c.mu.Lock()
skipping := c.Now().Before(c.nicbrSkipUntil)
c.mu.Unlock()
if skipping {
answer, err := c.queryRegistry(ctx, whoisServerNICbr, asQuery)
if errors.Is(err, ErrPaused) {
return lacnicInfo
}
answer, err := c.query(ctx, whoisServerNICbr, asQuery)
if err != nil {
c.logger.Warn("NIC.br query failed, keeping LACNIC's fields",
"asn", asn, "error", err)
@@ -265,13 +273,11 @@ func (c *Client) askNICbr(
}
if strings.Contains(answer, nicbrLimitReached) {
c.mu.Lock()
c.nicbrSkipUntil = c.Now().Add(NICbrSkipTime)
c.mu.Unlock()
c.pause(whoisServerNICbr)
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)
"limit, keeping LACNIC's fields and pausing NIC.br",
"asn", asn, "pause_for", PauseTime)
return lacnicInfo
}
@@ -309,25 +315,65 @@ func rirQuery(rir, asQuery string) string {
}
}
// queryRIR sends query to an RIR's server and returns the answer. An answer
// holding an error line with which the server refuses the query is an
// error, and the error holds that line.
func (c *Client) queryRIR(
// queryRegistry sends query to the server of an RIR or of NIC.br, and
// returns the answer. The client pauses the server for PauseTime when the
// server refuses the query. Each of these is a refusal:
//
// - a refused connection
// - a reset connection
// - an %ERROR:201: or %ERROR:202: line in the answer
//
// A refusal is returned as an error. While the server is paused,
// queryRegistry sends it nothing and returns an error that wraps ErrPaused.
func (c *Client) queryRegistry(
ctx context.Context, server, query string,
) (string, error) {
c.mu.Lock()
pausedUntil := c.pausedUntil[server]
c.mu.Unlock()
if c.Now().Before(pausedUntil) {
return "", fmt.Errorf("%w: %s until %s", ErrPaused, server,
pausedUntil.UTC().Format(time.RFC3339))
}
answer, err := c.query(ctx, server, query)
if err == nil {
err = checkRefusal(answer)
}
if errors.Is(err, errRefused) || errors.Is(err, syscall.ECONNREFUSED) ||
errors.Is(err, syscall.ECONNRESET) {
c.pause(server)
}
if err != nil {
return "", err
}
return answer, nil
}
// checkRefusal returns an error holding the first line of answer that
// starts with accessDeniedError or accessLimitError. It returns nil when
// answer has no such line.
func checkRefusal(answer string) error {
for line := range strings.SplitSeq(answer, "\n") {
if strings.HasPrefix(line, accessDeniedError) ||
strings.HasPrefix(line, accessLimitError) {
return "", fmt.Errorf("%w: %s", errRefused, line)
return fmt.Errorf("%w: %s", errRefused, line)
}
}
return answer, nil
return nil
}
// pause stops queryRegistry from querying server until PauseTime has passed.
func (c *Client) pause(server string) {
c.mu.Lock()
defer c.mu.Unlock()
c.pausedUntil[server] = c.Now().Add(PauseTime)
}
// query performs a raw WHOIS query to the specified server and returns the
+142 -5
View File
@@ -718,9 +718,10 @@ func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
// 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.
// client is over its query limit. The test sets the client's clock. The
// first lookup asks NIC.br. The next comes a second before PauseTime has
// passed, and does not ask NIC.br. The last comes at PauseTime and asks
// NIC.br again. Each keeps LACNIC's fields.
func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
t.Parallel()
@@ -740,8 +741,8 @@ func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
wantAsks int
}{
{after: 0, wantAsks: 1},
{after: whois.NICbrSkipTime - time.Second, wantAsks: 1},
{after: whois.NICbrSkipTime, wantAsks: 2},
{after: whois.PauseTime - time.Second, wantAsks: 1},
{after: whois.PauseTime, wantAsks: 2},
} {
now = start.Add(step.after)
@@ -785,6 +786,142 @@ func TestLookupASNReportsARefusal(t *testing.T) {
}
}
// resetServer starts a TCP server on the loopback address and returns the
// server's address. The server reads one query on each connection, then
// resets the connection.
func resetServer(t *testing.T) string {
t.Helper()
var listenConfig net.ListenConfig
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_, _ = bufio.NewReader(conn).ReadString('\n')
// Closing a connection whose linger time is zero resets it.
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetLinger(0)
}
_ = conn.Close()
}
}()
return listener.Addr().String()
}
// newRIPEClient returns a client whose fake servers send answers. The
// client adds one to *ripeAsks each time it connects to RIPE's server. When
// reset is true, RIPE's server resets the connection instead of answering.
func newRIPEClient(
t *testing.T, answers map[string]string, reset bool, ripeAsks *int,
) *whois.Client {
t.Helper()
dial := fakeServers(t, answers)
resetAddress := resetServer(t)
client := whois.NewClient(slog.New(slog.DiscardHandler))
client.DialContext = func(
ctx context.Context, network, address string,
) (net.Conn, error) {
if address == ripeServer {
*ripeAsks++
}
if address == ripeServer && reset {
var dialer net.Dialer
return dialer.DialContext(ctx, network, resetAddress)
}
return dial(ctx, network, address)
}
return client
}
// TestLookupASNPausesAServerThatRefuses looks up an AS at RIPE while RIPE
// refuses the query, in each of the ways a server refuses. The test sets
// the client's clock. A lookup a second before PauseTime has passed fails
// with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC succeeds
// during the pause. A lookup at PauseTime asks RIPE again.
func TestLookupASNPausesAServerThatRefuses(t *testing.T) {
t.Parallel()
for name, test := range map[string]struct {
// ripeAnswer is RIPE's answer. With none, RIPE's server refuses
// the connection, or resets it when reset is true.
ripeAnswer string
reset bool
}{
"access denied": {ripeAnswer: ripeRefusal},
"limit reached": {ripeAnswer: ripeLimitReached + "\n"},
"refused connection": {},
"reset connection": {reset: true},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
answers := map[string]string{
ianaQuery: ianaReferral,
apnicIANAQuery: "refer: whois.apnic.net\n",
apnicQuery: apnicRecord,
}
if test.ripeAnswer != "" {
answers[ripeQuery] = test.ripeAnswer
}
ripeAsks := 0
client := newRIPEClient(t, answers, test.reset, &ripeAsks)
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
now := start
client.Now = func() time.Time { return now }
_, err := client.LookupASN(t.Context(), testASN)
if err == nil || errors.Is(err, whois.ErrPaused) {
t.Fatalf("LookupASN while RIPE refuses: error %v, want a "+
"refusal", err)
}
now = start.Add(whois.PauseTime - time.Second)
_, err = client.LookupASN(t.Context(), testASN)
if !errors.Is(err, whois.ErrPaused) || ripeAsks != 1 {
t.Errorf("LookupASN during the pause: error %v after %d "+
"queries to RIPE, want ErrPaused after 1", err, ripeAsks)
}
_, err = client.LookupASN(t.Context(), apnicASN)
if err != nil {
t.Errorf("LookupASN of an AS at APNIC during RIPE's "+
"pause: %v", err)
}
now = start.Add(whois.PauseTime)
_, _ = client.LookupASN(t.Context(), testASN)
if ripeAsks != 2 {
t.Errorf("RIPE was asked %d times after the pause, want 2",
ripeAsks)
}
})
}
}
// TestLookupASNFailsWhenTheRIRGivesNoAnswer looks up an AS IANA refers to
// RIPE when RIPE's server refuses the connection, and when it answers only
// with an error. Neither is a lookup that found the AS.