13 Commits
Author SHA1 Message Date
sneak 1c137c75a7 Merge branch 'prod' into main
check / check (push) Waiting to run
2026-10-27 18:16:02 +01:00
sneak 4c0ef132e7 Merge pull request 'next into main' (#166) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #166
2026-10-27 18:15:11 +01:00
sneak e68be8254a Merge branch 'main' into next
check / check (push) Canceled after 0s
2026-10-27 18:14:58 +01:00
clawbot 1c65656b58 Remove the per-peer route tables (closes #122)
check / check (push) Canceled after 0s
The routing table in memory now holds every route, so the two per-peer route
tables go, with every write, delete, count and read that served them. The
prefix handler applies each update to the table, writes the table's changes
every second, and stores each new prefix with its first-seen time. The peer
delete, its retry on the next flush and the updates held for it go too. The
types and errors of the server's answers move to internal/server.

The image's memory settings fit a full table of about 1 GB: GOMEMLIMIT of
2.5 GiB, a 512 MiB page cache for the writing connection, and SQLite heap
limits of 768 MiB and 1 GiB. The README describes the new table, its
database copy and the memory budget.

Model: opus-5-5
2026-10-27 17:22:32 +01:00
clawbot 506e289a63 Read every route from the routing table in memory (part of #122)
check / check (push) Canceled after 0s
The IP lookups, the AS and prefix pages and their API answers, the prefix-length pages and the stats read routes only from the routing table the prefix handler keeps. fx reads the table at startup and gives it to both. AS names, WHOIS details and first-seen times still come from the database.

The IP lookup names the origin AS most peers carry, with the time of the newest kept route. The prefix answers list peers whose routes are not kept without a path or time. The stats gain origins_without_kept_route. They no longer read route times from the per-peer tables, so the two helpers that did are gone.

Model: opus-5-5
2026-10-27 16:21:59 +01:00
clawbot 2c304a0022 Make a timed batch write on every tick (closes #192)
check / check (push) Canceled after 0s
The prefix, AS and peer handlers skipped a timed write unless one
interval had passed since the last write ended. The next tick came a
little less than one interval after that, so every other tick was
skipped. Each tick now writes. A full batch's write restarts the timer,
so the next timed write comes one interval after that write began.
Comparing clock readings would not do: two ticks one interval apart can
read a hair under it. flushBatch no longer reports whether it wrote a
batch, since nothing reads that. A new test runs the loop on a fake
clock and fails on the old code.

Model: opus-5-5
2026-10-27 15:23:52 +01:00
clawbot 05b39eee7a Start no AS, peer or peering write after the stop deadline (closes #189)
check / check (push) Canceled after 0s
A write can run past the stop deadline while a full batch's wake-up or a
tick waits beside Stop's context. The select picked one at random, so about
half the time the AS, peer or peering handler started another write after
the deadline. Each loop now takes a waiting stop first, as the prefix
handler's does. The prefix, AS and peer handlers' flush loops were
near-copies, so they are now one function, flushLoop. A new test for each
handler fails about half its runs on the old code.

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-27 08:54:57 +01:00
clawbot 7a34a46c4d Add internal/routetable, the routing table in memory (part of #122)
check / check (push) Canceled after 0s
A new package holds every prefix some peer carries. For each prefix and
origin AS it keeps the set of peers that carry it, one bit per peer,
and the two newest of their routes in full. It applies announcements,
withdrawals and peers' sessions going down by the rule confirmed on the
issue, and records which pairs changed for the database writes to come.
It answers the IP lookup, an AS's prefixes, a prefix's origins, a sample
of one mask length, and the counts the stats show. Nothing uses it yet.

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

Model: opus-5-5
2026-10-27 07:51:55 +01:00
sneak 2152395f36 Merge pull request 'deploy' (#149) from main into prod
check / check (push) Canceled after 0s
Reviewed-on: #149
2026-10-22 18:52:21 +02:00
sneak 20a29e389d Merge pull request 'next into main' (#87) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #87
2026-10-22 18:25:14 +02:00
39 changed files with 4142 additions and 5919 deletions
+12 -6
View File
@@ -121,10 +121,11 @@ RUN chown -R routewatch:routewatch /app
ENV XDG_DATA_HOME=/var/lib
# Cap the Go heap at 1.5 GiB so the runtime collects harder before the
# container's memory limit is reached. setpriv in the entrypoint preserves this
# the way it does XDG_DATA_HOME above.
ENV GOMEMLIMIT=1536MiB
# Cap the Go heap at 2.5 GiB so the runtime collects harder before the
# container's memory limit is reached. A full routing table takes about 1 GB
# of it (README "Memory"). setpriv in the entrypoint preserves this the way it
# does XDG_DATA_HOME above.
ENV GOMEMLIMIT=2560MiB
# Cap glibc's malloc arenas. The SQLite C library allocates and frees millions
# of small page-cache chunks from many threads; glibc otherwise creates up to
@@ -139,8 +140,13 @@ EXPOSE 8080
COPY ./entrypoint.sh /entrypoint.sh
# Health check using the health endpoint, on the port PORT names
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# Health check using the health endpoint, on the port PORT names. During the
# start period, Docker 25 and later checks every 5 seconds, and the first pass
# makes the container healthy. After it, Docker checks every 30 seconds. The
# start period covers the 60 seconds after which upaas reads the health
# (README "Running under upaas"). Startup reads the routing table first,
# which takes about 25 seconds when the table is full.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -sf "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]
+182 -128
View File
@@ -1,25 +1,27 @@
# RouteWatch
RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing
table in real time: it streams BGP UPDATE messages from the RIPE RIS Live
service, maintains a live routing table in SQLite, and provides HTTP APIs for
querying routing information.
table in real time. It works like this:
- It streams BGP UPDATE messages from the RIPE RIS Live service.
- It keeps the whole routing table in memory, with a copy in SQLite.
- It serves web pages and a JSON API for looking routes up.
## 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
live routing tables in its own SQLite database and answers IP-to-AS lookups and
prefix pages from them.
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.
The feed sends no table when a client connects, so these tables hold only the
routes the feed has announced since RouteWatch first started: an IP-to-AS lookup
finds nothing for an address whose routes have not been announced since then.
The tables also miss the updates RouteWatch drops when it falls behind under
load and those sent while it is stopped, so a lookup or a prefix page can show a
route the feed has withdrawn.
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.
## Features
@@ -56,13 +58,24 @@ DEBUG=routewatch ./routewatch
Every route answers `HEAD` as it answers `GET`, without the body.
The pages and the API read routes from the routing table that RouteWatch keeps
in memory. For each prefix and origin AS, the table keeps the two newest routes
in full, with their AS paths and times. These are the kept routes. The table
keeps only the address of every other peer that carries the prefix. When both
kept routes of an origin AS are withdrawn, that origin has no kept route until
one of its peers sends an update.
### Web Interface
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
address, and a search of AS names. A card shows the `curl` command that looks
up the visitor's own address at `/ip/{addr}`, and that command's live answer.
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
- `GET /status.json` - JSON statistics: feed, memory, database and route figures
- `GET /status.json` - JSON statistics: feed, memory, database and route
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.
- `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
@@ -71,10 +84,11 @@ Every route answers `HEAD` as it answers `GET`, without the body.
description come from the AS list built into RouteWatch. The page then shows
no first-seen date.
- `GET /prefix/{prefix}/{length}` - Prefix detail page (HTML), such as
`/prefix/192.0.2.0/24`: every live route for the prefix, one per peer, and its
origin ASes
`/prefix/192.0.2.0/24`: its origin ASes and every peer that carries it. Only
the kept routes show an AS path, a next hop and a time.
- `GET /prefixlength/{length}` - Up to 500 IPv4 prefixes with that mask length,
picked at random, each with its newest route (HTML)
picked at random (HTML). Each shows the origin AS that most peers carry it
with, and the age of its newest kept route.
- `GET /prefixlength6/{length}` - The same for IPv6
- `GET /ip/{addr}` - The same JSON answer as `/api/v1/ip/{ip}`
- `GET /ip/` - The same, for the caller's own address: the address the request
@@ -91,8 +105,10 @@ Every route answers `HEAD` as it answers `GET`, without the body.
name, or a comma-separated list of at most 10 of these. `results` has one
entry for each address, with:
- `ip` and `ptr`: the address and its PTR names
- `netblock` and the fields beside it: the address's most specific live
route, and that route's origin AS with its WHOIS details
- `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)
An address with no live route has a result without the route and AS fields.
@@ -108,11 +124,13 @@ Every route answers `HEAD` as it answers `GET`, without the body.
answered without PTR names.
- `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6
apart, each with when its newest route was updated. When the database has no
record of the AS, its handle and description come from the AS list built into
apart, each with the time of its newest kept route. A prefix that keeps no
route with the AS has no `last_updated`. When the database has no record of
the AS, its handle and description come from the AS list built into
RouteWatch. The answer then has no `first_seen` or `last_seen`.
- `GET /api/v1/prefix/{prefix}/{length}` - Every live route for the prefix, one
per peer, and the same routes grouped by origin AS in `origins`
- `GET /api/v1/prefix/{prefix}/{length}` - One route for each peer that carries
the prefix, and the same routes grouped by origin AS in `origins`. Only the
kept routes have `as_path`, `next_hop` and `last_updated`.
- `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description
contains `{text}`, ignoring case. `data` holds:
- `query`: `{text}` without its leading and trailing spaces
@@ -135,16 +153,16 @@ took. The health check is the exception: its 503 answer is also
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`.
An AS number, prefix or mask length that does not parse answers 400, as does a
mask length out of range for its IP version. An AS answers 404 when it is not in
the AS list built into RouteWatch and the database has neither a record of it
nor a live route it originates. 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.
mask length out of range for its IP version. An AS answers 404 when it
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.
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
@@ -250,31 +268,36 @@ routewatch/
### Component Relationships
`internal/routewatch/app.go` builds and starts these parts:
```
┌─────────────────────────────────────────────────────────────────┐
│ RouteWatch │
│ (internal/routewatch/app.go - main orchestrator) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Streamer │───▶│ Handlers │───▶│ Database │ │
│ │ │ │ │ │ │ │
│ │ RIS Live │ │ - ASHandler │ │ SQLite with │ │
│ │ HTTP stream │ │ - PeerHandler│ │ WAL mode │ │
│ │ client │ │ - PrefixHdlr │ │ │ │
│ │ │ │ - PeeringHdlr│ │ Tables: │ │
│ └──────────────┘ └──────────────┘ │ - asns │ │
│ │ - prefixes │ │
│ ┌──────────────┐ ┌──────────────┐ │ - live_routes│ │
│ │ Server │───▶│ Handlers │───▶│ - peerings │ │
│ │ │ │ │ │ - bgp_peers │ │
│ │ Chi router │ │ Status, API │ └──────────────┘ │
│ │ port 8080 │ │ AS, Prefix │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Streamer │───▶│ Handlers │───▶│ Database │
│ │ │ │ │ │
│ RIS Live │ │ - ASHandler │ │ SQLite with │
│ HTTP stream │ │ - PeerHandler│ │ WAL mode │
│ client │ │ - PrefixHdlr │ │ │
│ │ │ - PeeringHdlr│ │ Tables: │
└──────────────┘ └──────┬───────┘ │ - asns │
│ │ - prefixes │
▼ │ - routes │
┌──────────────┐ │ - route_peers│
│ Routing table│───▶│ - peerings │
│ in memory │ │ - bgp_peers │
└──────────────┘ └──────────────┘
▲ ▲
┌──────────────┐ ┌──────┴───────┐ │
│ Server │───▶│ Handlers │───────────┘
│ │ │ │
│ Chi router │ │ Status, API │
│ port 8080 │ │ AS, Prefix │
└──────────────┘ └──────────────┘
```
The prefix handler keeps the routing table in memory current. It writes the
table's changes to the database every second. The web handlers read routes from
the routing table, and everything else from the database.
### Execution Flow
1. **Startup** (`cmd/routewatch/main.go` → `internal/routewatch/cli.go`)
@@ -283,13 +306,17 @@ routewatch/
2. **Initialization** (`internal/routewatch/app.go`)
- Database created with SQLite schema (WAL mode, page caches as in "Memory")
- The routing table read from the `routes` table into memory, before the
streamer connects. A full table takes about 25 seconds to read.
- Message handlers registered with the streamer
- HTTP server started on configured port
3. **Message Processing Pipeline**
```
RIS Live Stream → JSON Parser → Message Dispatcher → Handler Queues → Batch Writers → SQLite
RIS Live Stream → JSON Parser → Message Dispatcher → Handler Queues
├→ Batch Writers → SQLite
└→ PrefixHandler → Routing Table in Memory → SQLite, every second
```
- Streamer connects to `ris-live.ripe.net` via HTTP
@@ -304,21 +331,41 @@ routewatch/
4. **Handler Details**
- **ASHandler**: Tracks all ASNs seen in AS paths
- **PeerHandler**: Records BGP peer information
- **PrefixHandler**: Maintains live routing table (upserts on announcement,
deletes on withdrawal or on an announcement whose AS path is empty or
holds AS 0 anywhere, deletes all of a peer's routes when its BGP session
goes down). Routes are kept per peer IP, so when one peer IP has sessions
on two collectors, a session going down on one also removes the routes the
other still carries, until they are announced again.
- **PrefixHandler**: Keeps the routing table in memory current. It applies
each update to the table as it handles it:
- 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.
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
in the next writes. It also stores each new prefix with the time it was
first seen. Peers are known by their IP address. A peer IP can have
sessions on two collectors. A session going down on one then also
removes the routes the other still carries, until they are announced
again.
- **PeeringHandler**: Extracts AS peering relationships from AS paths
5. **HTTP Request Flow**
```
Request → Chi Router → Middleware (timeout, logging) → Handler → Database Query → Response
Request → Chi Router → Middleware (timeout, logging) → Handler → Routing Table or Database → Response
```
### Key Design Patterns
- **Routing Table in Memory**: `internal/routetable` holds the whole routing
table. For each prefix and origin AS it keeps:
- every peer whose current route to the prefix has that origin AS, one bit
per peer;
- the newest two of those routes in full, with AS path and time.
Of the other routes, only the peer is kept. The prefix handler writes the
changed pairs to the `routes` table every second, one row per prefix and
origin AS. Startup reads that table back into memory before the streamer
connects. A crash loses only the changes not yet written.
- **Batched Writes**: The handlers write the feed's updates to the database in
batches for performance
- **Backpressure**: Probabilistic message dropping when queues exceed 50%
@@ -333,11 +380,10 @@ routewatch/
with exponential backoff (5s-320s). The backoff resets after a stream that
lasted more than 30s. A stream with no line for 60s is dropped and
reconnected.
- **IP Lookup**: the most specific live route is found by looking up the
address's prefix at each mask length, longest first, in the prefix index (at
most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the text form
Go's `net/netip` prints. Routes shorter than /8 for IPv4 or /16 for IPv6, such
as a default route, are not used.
- **IP Lookup**: the routing table in memory finds the most specific prefix that
holds the address. It looks up the address's prefix at each mask length,
longest first: at most 25 lookups for IPv4 and 113 for IPv6. Prefixes shorter
than /8 for IPv4 or /16 for IPv6, such as a default route, are not used.
### Database Schema
@@ -351,13 +397,14 @@ asns(asn PRIMARY KEY, handle, description,
prefixes_v4(prefix PRIMARY KEY, first_seen) WITHOUT ROWID
prefixes_v6(prefix PRIMARY KEY, first_seen) WITHOUT ROWID
-- Live routing tables (one per IP version), one route per peer and prefix
live_routes_v4(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn,
peer_ip, as_path, next_hop, last_updated,
UNIQUE(peer_ip, prefix))
live_routes_v6(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn,
peer_ip, as_path, next_hop, last_updated,
UNIQUE(peer_ip, prefix))
-- The routing table, one row per prefix and origin AS that some peer
-- carries: the peer set, and the newest two routes in full
routes(prefix, origin_asn, peers,
newest_peer, newest_as_path, newest_time,
older_peer, older_as_path, older_time,
PRIMARY KEY (prefix, origin_asn)) WITHOUT ROWID
-- The peers of the routes table, each with its number in the peer sets
route_peers(number PRIMARY KEY, address UNIQUE)
-- Relationship tracking
peerings(id PRIMARY KEY, as_a, as_b, first_seen, last_seen,
@@ -381,7 +428,7 @@ Configuration is handled via environment variables and OS-specific paths:
| `PORT` | `8080` | HTTP server port, a whole number from 1 to 65535 |
| `DEBUG` | (empty) | Set to `routewatch` for debug logging |
| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path |
| `GOMEMLIMIT` | `1536MiB` (in the Docker image) | Go soft memory limit; see Memory |
| `GOMEMLIMIT` | `2560MiB` (in the Docker image) | Go soft memory limit; see Memory |
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory |
| `TRUSTED_PROXIES` | (empty) | Addresses and CIDR ranges of trusted proxies; see below |
@@ -407,28 +454,37 @@ State directory (the database and the blocklist copies):
## Memory
The daemon holds a live routing table, so its memory grows with the size of the
data it tracks. The image sets ceilings that keep it inside a 5 GiB container.
The daemon keeps the whole routing table in memory. The image sets ceilings that
keep it inside a 5 GiB container.
Budget:
- Go heap: a 1.5 GiB soft limit (`GOMEMLIMIT=1536MiB`, set in the image).
- SQLite: 1 GiB of page cache for the one connection every write uses, which
keeps the pages the route writes touch from one batch to the next, and 16 MiB
for each of the 9 reading connections, whose cache SQLite empties whenever
another connection has written: about 1.15 GiB in all. A 1.5 GiB soft heap
limit sits above the caches, so it does not shrink them, and a 2 GiB hard heap
limit caps the C library.
- glibc allocator: the SQLite C library runs on glibc `malloc`, which frees
page-cache chunks back to per-arena free lists rather than to the kernel, so
process RSS tracks the high-water mark of those arenas, not SQLite's live
heap. glibc creates up to eight arenas per core, so on a many-core host the
retained memory — and thus RSS — grows with the core count. The image sets
`MALLOC_ARENA_MAX=2` to bound it; the two-arena cap costs nothing here because
database writes are already serialized.
- 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 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.
- The connection that does every write has 512 MiB. It keeps the pages the
writes touch from one write to the next. With a full routing table, the
`routes` table takes about 450 MB, so its pages fit.
- Each of the 9 reading connections has 16 MiB. SQLite empties a reading
connection's cache whenever another connection has written.
- A 768 MiB soft heap limit sits above the caches, so it does not shrink
them. A 1 GiB hard heap limit caps the C library.
- glibc allocator: the SQLite C library allocates memory with glibc `malloc`.
- glibc keeps freed page-cache chunks in per-arena free lists instead of
returning them to the kernel. So the process's RSS follows the high-water
mark of those arenas, not SQLite's live heap.
- glibc creates up to eight arenas per core, so on a many-core host RSS
grows with the core count.
- The image sets `MALLOC_ARENA_MAX=2` to bound it. Two arenas cost nothing
here, because database writes already run one at a time.
- About 0.2 GiB for everything else in the runtime.
The ceilings add up to 3.7 GiB: the Go heap's 1.5 GiB, SQLite's 2 GiB and the
The ceilings add up to 3.7 GiB: the Go heap's 2.5 GiB, SQLite's 1 GiB and the
0.2 GiB for the rest.
Run the container with a memory limit of 5 GiB and swap disabled:
@@ -440,38 +496,36 @@ docker run --memory=5g --memory-swap=5g ...
or the equivalent in your deployment tool. This leaves headroom above the
ceilings for spikes and the kernel page cache.
Override the Go soft limit by setting `GOMEMLIMIT` in the environment (for
example `-e GOMEMLIMIT=1GiB`); this replaces the image default.
`MALLOC_ARENA_MAX` can be overridden the same way, but raising it lets RSS climb
again on a many-core host.
To replace the image's Go soft limit, set `GOMEMLIMIT` in the environment, for
example `-e GOMEMLIMIT=3GiB`. A limit close to the routing table's size makes
the garbage collector run almost all the time. `MALLOC_ARENA_MAX` can be
overridden the same way, but raising it lets RSS climb again on a many-core
host.
What happens at each limit:
- Go soft limit: as the heap approaches `GOMEMLIMIT`, the runtime runs garbage
collection more aggressively rather than growing further.
- SQLite: at the 1.5 GiB soft heap limit it recycles its page cache instead of
allocating more; at the 2 GiB hard heap limit a statement fails with an
out-of-memory error, and the handler logs it and drops that batch. The process
keeps running.
- SQLite: at the 768 MiB soft heap limit it recycles its page cache instead of
allocating more. At the 1 GiB hard heap limit a statement fails with an
out-of-memory error, and the handler logs it. The prefix handler writes the
routing table's changes again on a later write. The other writes drop their
batch. The process keeps running.
- Handler queues: each of the four handler queues holds at most 20,000 messages.
When a queue fills, the streamer drops messages instead of blocking, except a
`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, which 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) and 250,000 for the prefix handler
(`prefixBatchMaxSize`, about 60 MiB). At that size the handler takes nothing
more off its queue until the write ends, so the queue fills and the streamer
drops messages as above. When the delete of a peer's live routes after its
session went down fails, the prefix handler keeps the updates the peer sent
after the down for the next write, which retries the delete first; on top of
its 250,000 it keeps at most the last 25,000 of them (`prefixBatchSize`, about
6 MiB) and drops and logs the older ones, so while the delete keeps failing
the other peers' updates still come off the queue. With the batches being
written, the three handlers' batches take at most about 0.25 GiB of the Go
heap's 1.5 GiB.
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).
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.
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
with the goroutine count and Go memory figures.
@@ -493,25 +547,25 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
- Health check: the image's `HEALTHCHECK` requests
`/.well-known/healthcheck.json` on the container port. upaas reads the
container's health 60 seconds after a deploy and fails the deploy unless it is
`healthy`.
`healthy`. For the first 60 seconds, Docker 25 and later runs the check every
5 seconds, and the first pass makes the container healthy. Startup first reads
the routing table, which takes about 25 seconds when the table is full.
- Stop grace: the container needs 9 seconds between SIGTERM and SIGKILL. upaas
gives 10 (`stopTimeoutSeconds` in its `internal/docker/client.go`), as
`docker stop` does by default. The daemon allows 8 seconds for the handlers to
write what they hold and for the requests in progress to finish, then stops
write what they hold and for the requests in progress to finish. Then it stops
the HTTP server, closes the database and exits with status 0. A longer grace
changes nothing. What is not written after 8 seconds is lost, and the streamer
and each handler log how much. When the prefix handler has fallen behind the
feed, a stop loses far more: its batch is full at 250,000 updates and the
streamer's worker for it waits for room, so the streamer's stop takes the
whole 8 seconds and every handler then skips its last write. Lost then are the
messages left on the four handlers' queues, what the AS, peer and peering
handlers hold, the prefix handler's batch, and the write it has in progress,
up to another 250,000 updates, which take about 20 seconds and are cut off by
the exit; withdrawals and peer downs are among them. A route withdrawn in them
stays in the live route table until its peer announces or withdraws the prefix
again or its session goes down. When the 8 seconds run out the database is not
closed, and SQLite rolls back a write the exit cut off when the database next
opens.
and each handler log how much.
- The prefix handler's last write takes at most 20,000 of the routing
table's changed pairs. A full-table peer's session going down changes
about a million pairs, which take about a minute to write. A stop in that
minute leaves the rest unwritten.
- A lost withdrawal or peer down leaves its routes in the `routes` table,
and the next start reads them back. They stay until the peer sends another
update for the prefix, or its session goes down.
- When the 8 seconds run out the database is not closed. When the database
next opens, SQLite rolls back any write the exit cut off.
## Development
+42 -6
View File
@@ -25,14 +25,52 @@ are measured by a benchmark, not yet on the live feed.
# Next Step
Run `next` on the live feed, as README "Running under upaas" describes, for the
final check https://git.eeqj.de/sneak/routewatch/issues/96 names: whether the
prefix handler still drops updates after the first hour. If it does, a new issue
Run `next` on the live feed, as README "Running under upaas" describes, until
the routing table stops growing. Post its size and memory figures on
https://git.eeqj.de/sneak/routewatch/issues/122. The same run makes the final
check https://git.eeqj.de/sneak/routewatch/issues/96 names: whether the prefix
handler still drops updates after the first hour. If it does, a new issue
follows with the numbers. Then an audit of the code at the commit that run
tested.
# Completed Steps
- 2026-10-27: the per-peer route tables are gone, with the code that wrote,
counted and read them. The prefix handler applies each update to the routing
table in memory and writes the table's changes every second. It also stores
each new prefix with its first-seen time. The image's memory limits are set
for a full routing table of about 1 GB: `GOMEMLIMIT` is 2.5 GiB, the writing
connection's page cache 512 MiB, and SQLite's heap limits 768 MiB and 1 GiB
(closes #122)
- 2026-10-27: the pages, the API and the stats read every route from the routing
table in memory. For a prefix with several origin ASes, the IP lookup gives
the one most peers carry. Its `last_seen` is the newest kept route's time. The
prefix page and API list every peer that carries the prefix, with an AS path,
next hop and time only for the kept routes. The stats count the origins whose
kept routes were all withdrawn, in `origins_without_kept_route` (part of #122)
- 2026-10-27: the prefix, AS and peer handlers make a timed write on every tick
of their timers, every 1, 2 and 2 seconds. They used to skip every other tick.
A full batch's write restarts the timer, so the next timed write comes one
interval after that write began (closes #192)
- 2026-10-27: once the stop deadline has passed, the AS, peer and peering
handlers start no write either. A full batch or a tick of the timer that
waited while a write ran past the deadline could still start one. The flush
goroutines of the prefix, AS and peer handlers now run the same code (closes
#189)
- 2026-10-27: once the stop deadline has passed, the prefix handler starts no
flush. A batch that filled while a write ran past the deadline could still be
written after it (closes #188)
- 2026-10-27: the prefix handler applies each update to the routing table in
memory, and writes the table's changes to the database every second. A write
takes at most 20,000 changed pairs, and the rest follow in the next writes. A
failed write is written again later. Startup reads the table before the
streamer connects. The pages still read the per-peer route tables (part of
#122)
- 2026-10-27: the database keeps a copy of the whole routing table in a new
`routes` table, one row per prefix and origin AS. The peers' numbers are in a
new `route_peers` table. One function writes the pairs that changed in memory,
and another reads the whole table back into memory at startup. Nothing uses
them yet (part of #122)
- 2026-10-27: a new package, `internal/routetable`, holds the whole routing
table in memory. For each prefix and origin AS it keeps the peers that carry
it, one bit per peer, and the two newest of their routes. Nothing uses it yet
@@ -248,6 +286,4 @@ tested.
# Future Steps
- the rest of https://git.eeqj.de/sneak/routewatch/issues/122, one PR each: the
routing table in the database, the prefix handler writing to it, the pages and
the API reading from it, then removing the per-peer route tables
None.
+9 -100
View File
@@ -6,11 +6,10 @@ import (
"sync"
)
// liveCounts holds the running row counts and the prefix distribution that the
// stats endpoints report. They are seeded once at startup from the tables and
// then adjusted on every write, so a stats read serves them from memory instead
// of running a query over the tables. The COUNT(*) scans (issue 27) and then
// the prefix distribution query (issue 30) each grew with the database until
// liveCounts holds the running row counts that the stats endpoints report.
// They are seeded once at startup from the tables and then adjusted on every
// write, so a stats read serves them from memory instead of running a query
// over the tables. The COUNT(*) scans (issue 27) grew with the database until
// they took the whole request timeout and made /api/v1/stats return 500.
//
// A single mutex guards all fields so the stats reader takes a consistent
@@ -23,20 +22,11 @@ type liveCounts struct {
prefixesV6 int
peerings int
peers int
routesV4 int
routesV6 int
// The prefix distribution: for each mask length, the number of distinct
// prefixes that have at least one live route.
distributionV4 [ipv4Bits + 1]int
distributionV6 [ipv6Bits + 1]int
}
// seed sets every count to the value read from the tables at startup. It runs
// before any writer, so it needs no coordination with the adjust methods.
func (c *liveCounts) seed(
asns, prefixesV4, prefixesV6, peerings, peers, routesV4, routesV6 int,
distributionV4, distributionV6 []PrefixDistribution,
) {
func (c *liveCounts) seed(asns, prefixesV4, prefixesV6, peerings, peers int) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -45,16 +35,6 @@ func (c *liveCounts) seed(
c.prefixesV6 = prefixesV6
c.peerings = peerings
c.peers = peers
c.routesV4 = routesV4
c.routesV6 = routesV6
for _, entry := range distributionV4 {
addAtMaskLength(c.distributionV4[:], entry.MaskLength, entry.Count)
}
for _, entry := range distributionV6 {
addAtMaskLength(c.distributionV6[:], entry.MaskLength, entry.Count)
}
}
// addASNs adds n to the ASN count.
@@ -86,41 +66,7 @@ func (c *liveCounts) addPeers(n int) {
c.mu.Unlock()
}
// addRoutes adds to the IPv4 and IPv6 live-route counts. Deletions pass
// negative values.
func (c *liveCounts) addRoutes(v4, v6 int) {
c.mu.Lock()
c.routesV4 += v4
c.routesV6 += v6
c.mu.Unlock()
}
// addToDistribution adds n to the IPv4 and IPv6 prefix distributions once for
// each listed mask length. A write lists the mask lengths of the prefixes it
// gave their first live route with n = 1, and of the prefixes it left with no
// live route with n = -1.
func (c *liveCounts) addToDistribution(maskLengthsV4, maskLengthsV6 []int, n int) {
c.mu.Lock()
defer c.mu.Unlock()
for _, maskLength := range maskLengthsV4 {
addAtMaskLength(c.distributionV4[:], maskLength, n)
}
for _, maskLength := range maskLengthsV6 {
addAtMaskLength(c.distributionV6[:], maskLength, n)
}
}
// addAtMaskLength adds n to counts[maskLength]. A mask length the array has no
// entry for is ignored, so a malformed route cannot crash the daemon.
func addAtMaskLength(counts []int, maskLength, n int) {
if maskLength >= 0 && maskLength < len(counts) {
counts[maskLength] += n
}
}
// fill copies the counts into a Stats, including the derived totals, under a
// fill copies the counts into a Stats, including the derived total, under a
// single read lock so the reader sees one consistent snapshot.
func (c *liveCounts) fill(s *Stats) {
c.mu.RLock()
@@ -132,26 +78,6 @@ func (c *liveCounts) fill(s *Stats) {
s.Prefixes = c.prefixesV4 + c.prefixesV6
s.Peerings = c.peerings
s.Peers = c.peers
s.IPv4Routes = c.routesV4
s.IPv6Routes = c.routesV6
s.LiveRoutes = c.routesV4 + c.routesV6
s.IPv4PrefixDistribution = distributionList(c.distributionV4[:])
s.IPv6PrefixDistribution = distributionList(c.distributionV6[:])
}
// distributionList lists the mask lengths that have at least one prefix, in
// ascending order, the way the distribution query returns them.
func distributionList(counts []int) []PrefixDistribution {
var list []PrefixDistribution
for maskLength, count := range counts {
if count > 0 {
list = append(list,
PrefixDistribution{MaskLength: maskLength, Count: count})
}
}
return list
}
// countRows returns the number of rows in the named table. It is used only at
@@ -167,9 +93,8 @@ func (d *Database) countRows(ctx context.Context, table string) (int, error) {
return n, nil
}
// seedCounts reads the current row counts and prefix distribution from the
// tables into the in-memory counters. It runs once at startup, before the
// streamer begins writing.
// seedCounts reads the current row counts from the tables into the in-memory
// counters. It runs once at startup, before the streamer begins writing.
func (d *Database) seedCounts(ctx context.Context) error {
asns, err := d.countRows(ctx, "asns")
if err != nil {
@@ -196,23 +121,7 @@ func (d *Database) seedCounts(ctx context.Context) error {
return err
}
routesV4, err := d.countRows(ctx, tableLiveRoutesV4)
if err != nil {
return err
}
routesV6, err := d.countRows(ctx, tableLiveRoutesV6)
if err != nil {
return err
}
distributionV4, distributionV6, err := d.GetPrefixDistributionContext(ctx)
if err != nil {
return err
}
d.counts.seed(asns, prefixesV4, prefixesV6, peerings, peers, routesV4, routesV6,
distributionV4, distributionV6)
d.counts.seed(asns, prefixesV4, prefixesV6, peerings, peers)
return nil
}
+25 -631
View File
@@ -3,62 +3,23 @@ package database
import (
"context"
"fmt"
"slices"
"sync"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"github.com/google/uuid"
)
// testV4PeerIP is the peer the IPv4 routes in these tests are learned from.
// testV4PeerIP is the peer the IPv4 rows in these tests are learned from.
const testV4PeerIP = "192.0.2.1"
// testV6Prefix is the prefix of the IPv6 routes in these tests.
// testV6Prefix is the IPv6 prefix of these tests.
const testV6Prefix = "2001:db8::/32"
// mkV4Route builds an IPv4 live route with its mask length taken from the
// prefix.
func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute {
t.Helper()
maskLength, err := prefixMaskLength(prefix)
if err != nil {
t.Fatalf("prefixMaskLength(%s): %v", prefix, err)
}
return &LiveRoute{
Prefix: prefix,
MaskLength: maskLength,
IPVersion: ipVersionV4,
OriginASN: asn,
PeerIP: testV4PeerIP,
ASPath: []int{asn},
NextHop: "192.0.2.254",
LastUpdated: ts,
}
}
// mkV6Route builds an IPv6 live route.
func mkV6Route(prefix string, asn int, ts time.Time) *LiveRoute {
return &LiveRoute{
Prefix: prefix,
MaskLength: 32,
IPVersion: ipVersionV6,
OriginASN: asn,
PeerIP: "2001:db8::1",
ASPath: []int{asn},
NextHop: "2001:db8::ffff",
LastUpdated: ts,
}
}
// TestLiveCountsTrackWritesInRealtime checks that the stats counts start at
// zero, reflect each write the moment it commits (no recompute, no timer), do
// not move when a route is merely re-announced, and drop when a route is
// deleted. These counts are what /api/v1/stats reports; before this change the
// zero and reflect each write the moment it commits (no recompute, no timer).
// These counts are what /api/v1/stats reports; before this change the
// endpoint recomputed them with a COUNT(*) over each table on every request.
func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
t.Parallel()
@@ -79,7 +40,7 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
}
if empty.ASNs != 0 || empty.Prefixes != 0 || empty.Peerings != 0 ||
empty.Peers != 0 || empty.LiveRoutes != 0 {
empty.Peers != 0 {
t.Fatalf("empty database counts nonzero: %+v", empty)
}
@@ -87,17 +48,6 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
writeASNsPrefixesAndPeers(t, db, ts)
routes := []*LiveRoute{
mkV4Route(t, "198.51.100.0/24", 64500, ts),
mkV4Route(t, "203.0.113.0/24", 64501, ts.Add(time.Minute)),
mkV6Route(testV6Prefix, 64502, ts.Add(2*time.Minute)),
}
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
@@ -105,46 +55,11 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
assertCounts(t, "after inserts", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1,
ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3,
})
// Re-announcing the same routes is an update, not an insert: counts hold.
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch (re-announce): %v", err)
}
stats, err = db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
assertCounts(t, "after re-announce", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1,
ipv4Routes: 2, ipv6Routes: 1, liveRoutes: 3,
})
// A withdrawal removes one route.
err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{
{Prefix: "203.0.113.0/24", PeerIP: testV4PeerIP, IPVersion: ipVersionV4},
})
if err != nil {
t.Fatalf("DeleteLiveRouteBatch: %v", err)
}
stats, err = db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
assertCounts(t, "after delete", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1,
ipv4Routes: 1, ipv6Routes: 1, liveRoutes: 2,
})
}
// writeASNsPrefixesAndPeers writes two ASNs, two prefixes, one peer and one
// peering: the rows besides live routes that the stats count.
// peering: the rows the stats count.
func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) {
t.Helper()
@@ -180,8 +95,8 @@ func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) {
}
// TestLiveCountsSeededFromDatabaseAtStartup writes rows, reopens the same
// database file, and checks the counts and the prefix distribution come back
// from the seed scan rather than starting at zero.
// database file, and checks the counts come back from the seed scan rather
// than starting at zero.
func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
t.Parallel()
@@ -192,23 +107,7 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
t.Fatalf("failed to create database: %v", err)
}
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
seen := SeenTimes{First: ts, Last: ts}
err = db.GetOrCreateASNBatch(
map[int]SeenTimes{64500: seen, 64501: seen, 64502: seen})
if err != nil {
t.Fatalf("GetOrCreateASNBatch: %v", err)
}
err = db.UpsertLiveRouteBatch([]*LiveRoute{
mkV4Route(t, "198.51.100.0/24", 64500, ts),
mkV6Route(testV6Prefix, 64502, ts),
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
writeASNsPrefixesAndPeers(t, db, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
err = db.Close()
if err != nil {
@@ -226,489 +125,9 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
t.Fatalf("GetStatsContext after reopen: %v", err)
}
if stats.ASNs != 3 {
t.Errorf("seeded ASNs = %d, want 3", stats.ASNs)
}
if stats.IPv4Routes != 1 || stats.IPv6Routes != 1 || stats.LiveRoutes != 2 {
t.Errorf("seeded routes = (v4 %d, v6 %d, total %d), want (1, 1, 2)",
stats.IPv4Routes, stats.IPv6Routes, stats.LiveRoutes)
}
assertDistribution(t, "seeded IPv4 distribution", stats.IPv4PrefixDistribution,
[]PrefixDistribution{{MaskLength: 24, Count: 1}})
assertDistribution(t, "seeded IPv6 distribution", stats.IPv6PrefixDistribution,
[]PrefixDistribution{{MaskLength: 32, Count: 1}})
}
// TestPrefixDistributionTracksWrites checks that the prefix distribution the
// stats read reports stays exact across each kind of live-route write, and that
// after every step it equals what the distribution query reads from the route
// tables. The steps run once through the batch methods the prefix handler uses
// and once through the single-route methods.
func TestPrefixDistributionTracksWrites(t *testing.T) {
t.Parallel()
steps := prefixDistributionSteps(t)
for _, batch := range []bool{true, false} {
name := "single-route writes"
if batch {
name = "batch writes"
}
t.Run(name, func(t *testing.T) {
t.Parallel()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
ctx := context.Background()
for _, step := range steps {
err := announceRoutes(db, batch, step.announce)
if err != nil {
t.Fatalf("%s: announce: %v", step.name, err)
}
err = withdrawRoutes(db, batch, step.withdraw)
if err != nil {
t.Fatalf("%s: withdraw: %v", step.name, err)
}
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("%s: GetStatsContext: %v", step.name, err)
}
queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx)
if err != nil {
t.Fatalf("%s: GetPrefixDistributionContext: %v", step.name, err)
}
assertDistribution(t, step.name+": IPv4 distribution",
stats.IPv4PrefixDistribution, step.wantV4)
assertDistribution(t, step.name+": IPv6 distribution",
stats.IPv6PrefixDistribution, step.wantV6)
assertDistribution(t, step.name+": IPv4 distribution query",
queryV4, step.wantV4)
assertDistribution(t, step.name+": IPv6 distribution query",
queryV6, step.wantV6)
}
})
}
}
// prefixDistributionStep is one step of TestPrefixDistributionTracksWrites:
// the routes it announces, then the routes it withdraws, then the
// distributions both the stats and the distribution query must report.
type prefixDistributionStep struct {
name string
announce []*LiveRoute
withdraw []*LiveRoute
wantV4 []PrefixDistribution
wantV6 []PrefixDistribution
}
// prefixDistributionSteps returns the steps of
// TestPrefixDistributionTracksWrites, in the order they run.
func prefixDistributionSteps(t *testing.T) []prefixDistributionStep {
t.Helper()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
shared := mkV4Route(t, "198.51.100.0/24", 64500, ts)
sharedSecondPeer := mkV4Route(t, "198.51.100.0/24", 64500, ts)
sharedSecondPeer.PeerIP = "192.0.2.2"
other := mkV4Route(t, "203.0.113.0/24", 64501, ts)
wide := mkV4Route(t, "172.16.0.0/16", 64502, ts)
v6 := mkV6Route(testV6Prefix, 64503, ts)
v6SecondPeer := mkV6Route(testV6Prefix, 64503, ts)
v6SecondPeer.PeerIP = "2001:db8::2"
all := []PrefixDistribution{{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 2}}
v6Only := []PrefixDistribution{{MaskLength: 32, Count: 1}}
return []prefixDistributionStep{
{
name: "new routes",
announce: []*LiveRoute{shared, other, wide, v6},
wantV4: all, wantV6: v6Only,
},
{
name: "re-announcement",
announce: []*LiveRoute{shared, other, wide, v6},
wantV4: all, wantV6: v6Only,
},
{
name: "second peer announces a prefix that has a live route",
announce: []*LiveRoute{sharedSecondPeer, v6SecondPeer},
wantV4: all, wantV6: v6Only,
},
{
name: "withdrawal of a route that is not the last for its prefix",
withdraw: []*LiveRoute{shared, v6},
wantV4: all, wantV6: v6Only,
},
{
name: "withdrawal of the last route for a prefix",
withdraw: []*LiveRoute{sharedSecondPeer, v6SecondPeer},
wantV4: []PrefixDistribution{
{MaskLength: 16, Count: 1}, {MaskLength: 24, Count: 1},
},
},
{
name: "withdrawal of every remaining route",
withdraw: []*LiveRoute{other, wide},
},
{
name: "two peers announce a new prefix together",
announce: []*LiveRoute{shared, sharedSecondPeer, v6, v6SecondPeer},
wantV4: []PrefixDistribution{{MaskLength: 24, Count: 1}},
wantV6: v6Only,
},
{
name: "both routes for a prefix withdrawn together",
withdraw: []*LiveRoute{shared, sharedSecondPeer, v6, v6SecondPeer},
},
// The feed often withdraws a route that is not live. That must not take
// the prefix out of the distribution. A count wrongly taken below zero is
// left out of the answer, so the next step shows it: its announcement
// at the same mask length would then not be counted.
{
name: "withdrawal of routes that are not live",
withdraw: []*LiveRoute{other, v6},
},
{
name: "announcement after a withdrawal of routes that are not live",
announce: []*LiveRoute{other, v6},
wantV4: []PrefixDistribution{{MaskLength: 24, Count: 1}},
wantV6: v6Only,
},
}
}
// announceRoutes writes routes in one UpsertLiveRouteBatch, or with one
// UpsertLiveRoute each.
func announceRoutes(db *Database, batch bool, routes []*LiveRoute) error {
if batch {
return db.UpsertLiveRouteBatch(routes)
}
for _, route := range routes {
err := db.UpsertLiveRoute(route)
if err != nil {
return err
}
}
return nil
}
// withdrawRoutes removes routes in one DeleteLiveRouteBatch, or with one
// DeleteLiveRoute each.
func withdrawRoutes(db *Database, batch bool, routes []*LiveRoute) error {
if !batch {
for _, route := range routes {
err := db.DeleteLiveRoute(route.Prefix, route.PeerIP)
if err != nil {
return err
}
}
return nil
}
deletions := make([]LiveRouteDeletion, 0, len(routes))
for _, route := range routes {
deletions = append(deletions, LiveRouteDeletion{
Prefix: route.Prefix, PeerIP: route.PeerIP, IPVersion: route.IPVersion,
})
}
return db.DeleteLiveRouteBatch(deletions)
}
// TestDeleteLiveRoutesOfPeer gives two peers IPv4 and IPv6 live routes, some
// for the same prefixes, and deletes all of one peer's routes. The other peer's
// routes stay, and the route counts and prefix distribution held in memory
// equal what is read from the route tables.
func TestDeleteLiveRoutesOfPeer(t *testing.T) {
t.Parallel()
const (
peerIP = "192.0.2.1"
otherPeerIP = "192.0.2.2"
sharedV4 = "198.51.100.0/24"
sharedV6 = "2001:db8::/32"
)
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
ofPeer := func(peer string, route *LiveRoute) *LiveRoute {
route.PeerIP = peer
return route
}
onlyV6 := mkV6Route("2001:db8:1::/48", 64503, ts)
onlyV6.MaskLength = 48
err = db.UpsertLiveRouteBatch([]*LiveRoute{
ofPeer(peerIP, mkV4Route(t, sharedV4, 64500, ts)),
ofPeer(otherPeerIP, mkV4Route(t, sharedV4, 64500, ts)),
ofPeer(peerIP, mkV4Route(t, "203.0.113.0/24", 64501, ts)),
ofPeer(peerIP, mkV6Route(sharedV6, 64502, ts)),
ofPeer(otherPeerIP, mkV6Route(sharedV6, 64502, ts)),
ofPeer(peerIP, onlyV6),
assertCounts(t, "after reopen", stats, wantCounts{
asns: 2, prefixes: 2, peerings: 1, peers: 1,
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
// peerIP has two IPv4 and two IPv6 routes; a second delete finds none.
for _, want := range []int{4, 0} {
deleted, err := db.DeleteLiveRoutesOfPeer(peerIP)
if err != nil {
t.Fatalf("DeleteLiveRoutesOfPeer: %v", err)
}
if deleted != want {
t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d",
deleted, want)
}
}
for _, prefix := range []string{sharedV4, sharedV6} {
routes, _, err := db.GetPrefixDetailsContext(context.Background(), prefix)
if err != nil {
t.Fatalf("GetPrefixDetailsContext(%s): %v", prefix, err)
}
if len(routes) != 1 || routes[0].PeerIP != otherPeerIP {
t.Errorf("live routes for %s = %+v, want only the one from %s",
prefix, routes, otherPeerIP)
}
}
checkOneLiveRoutePerIPVersion(t, db)
}
// TestDeleteLiveRoutesOfPeerDeletesInChunks gives a peer one IPv4 route more
// than a chunk holds, two of them for prefixes another peer also has. One chunk
// deletes peerRouteChunkSize of the peer's routes and commits, leaving the last
// one. The routes are written again, and DeleteLiveRoutesOfPeer deletes all of
// them, a chunk and then the last one. After each step the route count and
// prefix distribution held in memory and those read from the route table are
// the ones wanted.
func TestDeleteLiveRoutesOfPeerDeletesInChunks(t *testing.T) {
t.Parallel()
const (
peerIP = "192.0.2.1"
otherPeerIP = "192.0.2.2"
// How many of the peer's prefixes the other peer also has, the first
// ones in prefix order.
sharedPrefixes = 2
)
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
peerRoutes := make([]*LiveRoute, 0, peerRouteChunkSize+1)
otherPeerRoutes := make([]*LiveRoute, 0, sharedPrefixes)
for i := range peerRouteChunkSize + 1 {
prefix := fmt.Sprintf("10.%d.%d.0/24", i/256, i%256)
route := mkV4Route(t, prefix, 64500, ts)
route.PeerIP = peerIP
peerRoutes = append(peerRoutes, route)
if i < sharedPrefixes {
route := mkV4Route(t, prefix, 64500, ts)
route.PeerIP = otherPeerIP
otherPeerRoutes = append(otherPeerRoutes, route)
}
}
err = db.UpsertLiveRouteBatch(slices.Concat(peerRoutes, otherPeerRoutes))
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
deletedV4, deletedV6, err := db.deletePeerRouteChunk(peerIP)
if err != nil {
t.Fatalf("deletePeerRouteChunk: %v", err)
}
if deletedV4 != peerRouteChunkSize || deletedV6 != 0 {
t.Errorf("deletePeerRouteChunk deleted (%d, %d) routes, want (%d, 0)",
deletedV4, deletedV6, peerRouteChunkSize)
}
// The shared prefixes went with the chunk, so each route left has a
// prefix of its own.
checkV4RoutesOfOwnPrefixes(t, db, "after one chunk", 1+sharedPrefixes)
err = db.UpsertLiveRouteBatch(peerRoutes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
deleted, err := db.DeleteLiveRoutesOfPeer(peerIP)
if err != nil {
t.Fatalf("DeleteLiveRoutesOfPeer: %v", err)
}
if deleted != len(peerRoutes) {
t.Errorf("DeleteLiveRoutesOfPeer deleted %d routes, want %d",
deleted, len(peerRoutes))
}
checkV4RoutesOfOwnPrefixes(t, db, "after DeleteLiveRoutesOfPeer",
sharedPrefixes)
}
// checkV4RoutesOfOwnPrefixes checks that the database has wantRoutes IPv4 live
// routes, each for a /24 no other live route has, both as counted in memory and
// as read from the route table. when says which step of the test checks.
func checkV4RoutesOfOwnPrefixes(
t *testing.T, db *Database, when string, wantRoutes int,
) {
t.Helper()
ctx := context.Background()
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
tableRoutes, _, err := db.GetLiveRouteCountsContext(ctx)
if err != nil {
t.Fatalf("GetLiveRouteCountsContext: %v", err)
}
if stats.IPv4Routes != wantRoutes || tableRoutes != wantRoutes {
t.Errorf("%s: IPv4 live routes: %d in memory, %d in the table, want %d",
when, stats.IPv4Routes, tableRoutes, wantRoutes)
}
queryDistribution, _, err := db.GetPrefixDistributionContext(ctx)
if err != nil {
t.Fatalf("GetPrefixDistributionContext: %v", err)
}
want := []PrefixDistribution{{MaskLength: 24, Count: wantRoutes}}
assertDistribution(t, when+": IPv4 distribution",
stats.IPv4PrefixDistribution, want)
assertDistribution(t, when+": IPv4 distribution query",
queryDistribution, want)
}
// checkOneLiveRoutePerIPVersion checks that the database has one IPv4 live
// route, for a /24, and one IPv6 live route, for a /32, both as counted in
// memory and as read from the route tables.
func checkOneLiveRoutePerIPVersion(t *testing.T, db *Database) {
t.Helper()
ctx := context.Background()
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
tableV4, tableV6, err := db.GetLiveRouteCountsContext(ctx)
if err != nil {
t.Fatalf("GetLiveRouteCountsContext: %v", err)
}
if stats.IPv4Routes != 1 || tableV4 != 1 || stats.IPv6Routes != 1 ||
tableV6 != 1 {
t.Errorf("live routes (IPv4, IPv6): (%d, %d) in memory, (%d, %d) in "+
"the tables, want (1, 1)",
stats.IPv4Routes, stats.IPv6Routes, tableV4, tableV6)
}
queryV4, queryV6, err := db.GetPrefixDistributionContext(ctx)
if err != nil {
t.Fatalf("GetPrefixDistributionContext: %v", err)
}
wantV4 := []PrefixDistribution{{MaskLength: 24, Count: 1}}
wantV6 := []PrefixDistribution{{MaskLength: 32, Count: 1}}
assertDistribution(t, "IPv4 distribution", stats.IPv4PrefixDistribution, wantV4)
assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution, wantV6)
assertDistribution(t, "IPv4 distribution query", queryV4, wantV4)
assertDistribution(t, "IPv6 distribution query", queryV6, wantV6)
}
// TestStatsRouteTimestamps checks the oldest/newest route timestamps are read
// from the right rows across both tables and parse into time.Time. The old
// MIN/MAX union query read its result into *time.Time, which the driver could
// not parse, so it logged a warning every call and left both timestamps nil.
func TestStatsRouteTimestamps(t *testing.T) {
t.Parallel()
cfg := &config.Config{StateDir: t.TempDir()}
db, err := New(cfg, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
ctx := context.Background()
empty, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext on empty database: %v", err)
}
if empty.OldestRoute != nil || empty.NewestRoute != nil {
t.Fatalf("empty database timestamps = (%v, %v), want (nil, nil)",
empty.OldestRoute, empty.NewestRoute)
}
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
oldest := base
newest := base.Add(2 * time.Minute)
err = db.UpsertLiveRouteBatch([]*LiveRoute{
mkV4Route(t, "198.51.100.0/24", 64500, base.Add(time.Minute)),
mkV4Route(t, "203.0.113.0/24", 64501, oldest),
mkV6Route(testV6Prefix, 64502, newest),
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
stats, err := db.GetStatsContext(ctx)
if err != nil {
t.Fatalf("GetStatsContext: %v", err)
}
if stats.OldestRoute == nil || !stats.OldestRoute.Equal(oldest) {
t.Errorf("OldestRoute = %v, want %v", stats.OldestRoute, oldest)
}
if stats.NewestRoute == nil || !stats.NewestRoute.Equal(newest) {
t.Errorf("NewestRoute = %v, want %v", stats.NewestRoute, newest)
}
}
// TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so
@@ -726,7 +145,10 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
const writers = 4
const (
writers = 4
prefixesPerWrite = 25
)
var wg sync.WaitGroup
wg.Add(writers)
@@ -735,14 +157,12 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
go func(base int) {
defer wg.Done()
for i := range 25 {
asn := 65000 + base*100 + i
route := mkV6Route(testV6Prefix, asn, ts)
route.PeerIP = "2001:db8::" + uuid.NewString()
for i := range prefixesPerWrite {
prefix := fmt.Sprintf("10.%d.%d.0/24", base, i)
err := db.UpsertLiveRoute(route)
err := db.UpdatePrefixesBatch(map[string]time.Time{prefix: ts})
if err != nil {
t.Errorf("UpsertLiveRoute: %v", err)
t.Errorf("UpdatePrefixesBatch: %v", err)
return
}
@@ -782,22 +202,16 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
t.Fatalf("GetStatsContext: %v", err)
}
if want := writers * 25; stats.IPv6Routes != want {
t.Errorf("IPv6Routes = %d, want %d", stats.IPv6Routes, want)
if want := writers * prefixesPerWrite; stats.IPv4Prefixes != want {
t.Errorf("IPv4Prefixes = %d, want %d", stats.IPv4Prefixes, want)
}
// Every writer announced the same prefix, so it counts once.
assertDistribution(t, "IPv6 distribution", stats.IPv6PrefixDistribution,
[]PrefixDistribution{{MaskLength: 32, Count: 1}})
}
type wantCounts struct {
asns int
prefixes int
peerings int
peers int
ipv4Routes int
ipv6Routes int
liveRoutes int
asns int
prefixes int
peerings int
peers int
}
func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
@@ -818,24 +232,4 @@ func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
if got.Peers != want.peers {
t.Errorf("%s: Peers = %d, want %d", when, got.Peers, want.peers)
}
if got.IPv4Routes != want.ipv4Routes {
t.Errorf("%s: IPv4Routes = %d, want %d", when, got.IPv4Routes, want.ipv4Routes)
}
if got.IPv6Routes != want.ipv6Routes {
t.Errorf("%s: IPv6Routes = %d, want %d", when, got.IPv6Routes, want.ipv6Routes)
}
if got.LiveRoutes != want.liveRoutes {
t.Errorf("%s: LiveRoutes = %d, want %d", when, got.LiveRoutes, want.liveRoutes)
}
}
func assertDistribution(t *testing.T, what string, got, want []PrefixDistribution) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("%s = %v, want %v", what, got, want)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -46
View File
@@ -1,34 +1,31 @@
// Package database provides SQLite storage for BGP routing data including ASNs,
// prefixes, announcements, peerings, and live route tables.
// Package database provides SQLite storage for BGP routing data: ASNs,
// prefixes, announcements, peerings and BGP peers. Its routes table holds a
// copy of the whole routing table that internal/routetable keeps in memory.
package database
import (
"context"
"net/netip"
"time"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// Stats contains database statistics including counts for ASNs, prefixes,
// peerings, peers, and live routes, as well as file size and prefix distribution data.
// Stats contains database statistics: counts of ASNs, prefixes, peerings and
// peers, and the file size.
type Stats struct {
ASNs int
Prefixes int
IPv4Prefixes int
IPv6Prefixes int
Peerings int
Peers int
FileSizeBytes int64
LiveRoutes int
IPv4Routes int
IPv6Routes int
OldestRoute *time.Time
NewestRoute *time.Time
IPv4PrefixDistribution []PrefixDistribution
IPv6PrefixDistribution []PrefixDistribution
ASNs int
Prefixes int
IPv4Prefixes int
IPv6Prefixes int
Peerings int
Peers int
FileSizeBytes int64
}
// Store defines the interface for database operations. It provides methods for
// managing ASNs, prefixes, announcements, peerings, BGP peers, and live routes.
// Implementations must be safe for concurrent use.
// managing ASNs, prefixes, announcements, peerings, BGP peers, and the copy of
// the routing table. Implementations must be safe for concurrent use.
//
//nolint:interfacebloat // the one seam handlers and tests depend on
type Store interface {
@@ -54,22 +51,9 @@ type Store interface {
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
UpdatePeerBatch(peers map[string]PeerUpdate) error
// Live route operations
UpsertLiveRoute(route *LiveRoute) error
UpsertLiveRouteBatch(routes []*LiveRoute) error
DeleteLiveRoute(prefix, peerIP string) error
DeleteLiveRouteBatch(deletions []LiveRouteDeletion) error
DeleteLiveRoutesOfPeer(peerIP string) (deleted int, err error)
GetPrefixDistribution() (
ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetPrefixDistributionContext(ctx context.Context) (
ipv4 []PrefixDistribution, ipv6 []PrefixDistribution, err error)
GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error)
GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error)
// IP lookup operations
GetIPInfo(ip string) (*IPInfo, error)
GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error)
// Routing table operations: the database's copy of the table in memory
WriteRouteChanges(changes []routetable.Change, newPeers map[string]uint) error
ReadRouteTable(ctx context.Context) (*routetable.Table, error)
// ASN WHOIS operations
GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error)
@@ -77,18 +61,11 @@ type Store interface {
GetWHOISStats(ctx context.Context, staleThreshold time.Duration) (*WHOISStats, error)
// AS and prefix detail operations
GetASDetails(asn int) (*ASN, []LiveRoute, error)
GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error)
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
GetASPeers(asn int) ([]ASPeer, error)
GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error)
GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error)
GetPrefixDetailsContext(
ctx context.Context, prefix string,
) ([]LiveRoute, map[int]ASN, error)
GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error)
GetRandomPrefixesByLengthContext(
ctx context.Context, maskLength, ipVersion, limit int,
) ([]LiveRoute, error)
// Lifecycle
Close() error
-56
View File
@@ -66,62 +66,6 @@ type ASNPeering struct {
LastSeen time.Time
}
// LiveRoute represents a route in the live routing table
//
//nolint:tagliatelle // field names are the existing JSON API
type LiveRoute struct {
Prefix string `json:"prefix"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
OriginASN int `json:"origin_asn"`
PeerIP string `json:"peer_ip"`
ASPath []int `json:"as_path"`
NextHop string `json:"next_hop"`
LastUpdated time.Time `json:"last_updated"`
}
// PrefixDistribution represents the distribution of prefixes by mask length
//
//nolint:tagliatelle // field names are the existing JSON API
type PrefixDistribution struct {
MaskLength int `json:"mask_length"`
Count int `json:"count"`
}
// IPInfo represents comprehensive IP information for the /ip endpoint
//
//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
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
// Indicates if WHOIS data needs refresh (not serialized)
NeedsWHOISRefresh bool `json:"-"`
}
// LiveRouteDeletion represents parameters for deleting a live route
type LiveRouteDeletion struct {
Prefix string
PeerIP string
IPVersion int
}
// SeenTimes is the earliest and the latest time one batch of updates saw an AS,
// a peer or a peering. A row the batch adds is stored first seen at First and
// last seen at Last. A row already stored takes First as its first_seen only
+339
View File
@@ -0,0 +1,339 @@
package database
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/netip"
"time"
"github.com/bits-and-blooms/bitset"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// keptRoutesPerRow is how many kept routes a row of the routes table has
// columns for: the newest and the older one.
const keptRoutesPerRow = 2
// errUnknownRoutePeer is returned for a peer that route_peers lacks: on a
// write, the peer of a kept route; on a read, a peer number in a row.
var errUnknownRoutePeer = errors.New("peer not in route_peers")
// The statements of the routes and route_peers tables (schema.sql).
const (
insertRoutePeerSQL = `INSERT INTO route_peers (number, address)
VALUES (?, ?)`
routePeerNumberSQL = `SELECT number FROM route_peers WHERE address = ?`
routePeersSQL = `SELECT number, address FROM route_peers`
upsertRouteSQL = `INSERT INTO routes (prefix, origin_asn, peers,
newest_peer, newest_as_path, newest_time,
older_peer, older_as_path, older_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (prefix, origin_asn) DO UPDATE SET peers = excluded.peers,
newest_peer = excluded.newest_peer,
newest_as_path = excluded.newest_as_path,
newest_time = excluded.newest_time,
older_peer = excluded.older_peer,
older_as_path = excluded.older_as_path,
older_time = excluded.older_time`
deleteRouteSQL = `DELETE FROM routes WHERE prefix = ? AND origin_asn = ?`
routesSQL = `SELECT prefix, origin_asn, peers,
newest_peer, newest_as_path, newest_time,
older_peer, older_as_path, older_time FROM routes`
)
// WriteRouteChanges writes, in one transaction, what one call of the routing
// table's TakeChanges returned. It stores each new peer with its number, writes
// the row of each changed prefix and origin AS pair, and deletes the row of
// each removed one. Route times are stored to the second, as the feed gives
// them.
//
// When it fails, it writes nothing. The caller then writes these changes and
// peers again, because later changes can name the peers it did not store.
func (d *Database) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
if len(changes) == 0 && len(newPeers) == 0 {
return nil
}
d.lock("WriteRouteChanges")
defer d.unlock()
tx, err := d.beginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer d.rollback(tx)
ctx := context.Background()
// The new peers first, so that the rows can look up their numbers.
for address, number := range newPeers {
_, err := tx.ExecContext(ctx, insertRoutePeerSQL, number, address)
if err != nil {
return fmt.Errorf("failed to store peer %s: %w", address, err)
}
}
err = writeRouteRows(ctx, tx, changes)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// writeRouteRows upserts the row of each change in tx, or deletes it for a
// removed pair.
func writeRouteRows(
ctx context.Context, tx *loggingTx, changes []routetable.Change,
) error {
upsert, err := tx.PrepareContext(ctx, upsertRouteSQL)
if err != nil {
return fmt.Errorf("failed to prepare route upsert: %w", err)
}
defer func() { _ = upsert.Close() }()
del, err := tx.PrepareContext(ctx, deleteRouteSQL)
if err != nil {
return fmt.Errorf("failed to prepare route delete: %w", err)
}
defer func() { _ = del.Close() }()
peerNumber, err := tx.PrepareContext(ctx, routePeerNumberSQL)
if err != nil {
return fmt.Errorf("failed to prepare peer number lookup: %w", err)
}
defer func() { _ = peerNumber.Close() }()
for _, change := range changes {
prefix := change.Prefix.String()
if change.Removed {
_, err := del.ExecContext(ctx, prefix, change.OriginASN)
if err != nil {
return fmt.Errorf("failed to delete route %s: %w", prefix, err)
}
continue
}
row, err := routeRow(ctx, peerNumber, change)
if err != nil {
return fmt.Errorf("failed to write route %s: %w", prefix, err)
}
_, err = upsert.ExecContext(ctx, row...)
if err != nil {
return fmt.Errorf("failed to write route %s: %w", prefix, err)
}
}
return nil
}
// routeRow returns the values of the routes row of change, in the order of
// upsertRouteSQL. peerNumber looks up a peer's number by its address. The
// table keeps at most keptRoutesPerRow routes of a pair.
func routeRow(
ctx context.Context, peerNumber *sql.Stmt, change routetable.Change,
) ([]any, error) {
peers, err := change.Peers.MarshalBinary()
if err != nil {
return nil, err
}
row := []any{change.Prefix.String(), change.OriginASN, peers}
for i := range keptRoutesPerRow {
if i >= len(change.Routes) {
row = append(row, nil, nil, nil)
continue
}
r := change.Routes[i]
var number uint
err := peerNumber.QueryRowContext(ctx, r.Peer).Scan(&number)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", errUnknownRoutePeer, r.Peer)
}
if err != nil {
return nil, err
}
path, err := json.Marshal(r.ASPath)
if err != nil {
return nil, err
}
row = append(row, number, string(path), r.Time.Unix())
}
return row, nil
}
// ReadRouteTable returns the routing table that the routes and route_peers
// tables hold, as startup reads it. It reads the peers first, so that the
// table keeps their numbers, then loads each row into the table as it reads
// it. The two reads are separate, so nothing may write the tables meanwhile.
// It fails on a row that names a peer number route_peers lacks.
func (d *Database) ReadRouteTable(ctx context.Context) (*routetable.Table, error) {
peerNumbers, err := d.readRoutePeers(ctx)
if err != nil {
return nil, err
}
addresses := make(map[uint]string, len(peerNumbers))
numbers := new(bitset.BitSet)
for address, number := range peerNumbers {
addresses[number] = address
numbers.Set(number)
}
table := routetable.New(routetable.Params{PeerNumbers: peerNumbers})
rows, err := d.db.QueryContext(ctx, routesSQL)
if err != nil {
return nil, fmt.Errorf("failed to read routes: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
change, err := scanRouteRow(rows, addresses, numbers)
if err != nil {
return nil, err
}
table.Load(change)
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("failed to read routes: %w", err)
}
return table, nil
}
// readRoutePeers returns the number of each peer in route_peers, by address.
func (d *Database) readRoutePeers(ctx context.Context) (map[string]uint, error) {
rows, err := d.db.QueryContext(ctx, routePeersSQL)
if err != nil {
return nil, fmt.Errorf("failed to read route peers: %w", err)
}
defer func() { _ = rows.Close() }()
peerNumbers := make(map[string]uint)
for rows.Next() {
var (
number uint
address string
)
err := rows.Scan(&number, &address)
if err != nil {
return nil, fmt.Errorf("failed to read a route peer: %w", err)
}
peerNumbers[address] = number
}
err = rows.Err()
if err != nil {
return nil, fmt.Errorf("failed to read route peers: %w", err)
}
return peerNumbers, nil
}
// keptRouteColumns are the three columns of one kept route in a routes row.
type keptRouteColumns struct {
peer sql.Null[uint]
asPath sql.NullString
time sql.NullInt64
}
// scanRouteRow returns the content of the routes row rows is at, with each kept
// route's peer named by its address in addresses. It fails when the row names a
// peer number that addresses lacks. numbers holds the numbers of addresses as a
// set, against which a row's whole peer set is checked at once.
func scanRouteRow(
rows *sql.Rows, addresses map[uint]string, numbers *bitset.BitSet,
) (routetable.Change, error) {
var (
change routetable.Change
prefix string
peers []byte
kept [keptRoutesPerRow]keptRouteColumns
)
err := rows.Scan(&prefix, &change.OriginASN, &peers,
&kept[0].peer, &kept[0].asPath, &kept[0].time,
&kept[1].peer, &kept[1].asPath, &kept[1].time)
if err != nil {
return change, fmt.Errorf("failed to read a route: %w", err)
}
change.Prefix, err = netip.ParsePrefix(prefix)
if err != nil {
return change, fmt.Errorf("failed to read route %s: %w", prefix, err)
}
change.Peers = new(bitset.BitSet)
err = change.Peers.UnmarshalBinary(peers)
if err != nil {
return change, fmt.Errorf("failed to read the peers of route %s: %w",
prefix, err)
}
if !numbers.IsSuperSet(change.Peers) {
unknown, _ := change.Peers.Difference(numbers).NextSet(0)
return change, fmt.Errorf("%w: number %d in the peers of route %s",
errUnknownRoutePeer, unknown, prefix)
}
for _, columns := range kept {
if !columns.peer.Valid {
continue
}
address, known := addresses[columns.peer.V]
if !known {
return change, fmt.Errorf("%w: number %d in route %s",
errUnknownRoutePeer, columns.peer.V, prefix)
}
r := routetable.Route{
Peer: address,
Time: time.Unix(columns.time.Int64, 0).UTC(),
}
err := json.Unmarshal([]byte(columns.asPath.String), &r.ASPath)
if err != nil {
return change, fmt.Errorf("failed to read an AS path of route %s: %w",
prefix, err)
}
change.Routes = append(change.Routes, r)
}
return change, nil
}
+527
View File
@@ -0,0 +1,527 @@
package database
import (
"errors"
"fmt"
"math"
"math/rand/v2"
"net/netip"
"os"
"reflect"
"slices"
"testing"
"time"
"github.com/bits-and-blooms/bitset"
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// Peers, origin ASes and prefixes of the routes table tests.
const (
peerA = "192.0.2.1"
peerB = "192.0.2.2"
peerC = "2001:db8::3"
peerD = "2001:db8::4"
peerE = "2001:db8::5"
asA = 64500
asB = 64501
// transitAS is the first AS of every test path, the peer's own.
transitAS = 64496
tablePrefixV4 = "10.1.2.0/24"
tablePrefixShortV4 = "10.9.0.0/16"
tablePrefixV6 = "2001:db8:1::/48"
)
// tableTime returns the time second seconds after the start of every routes
// table test.
func tableTime(second int) time.Time {
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
Add(time.Duration(second) * time.Second)
}
// announceTo has peer announce prefix with origin AS asn second seconds after
// the start.
func announceTo(
table *routetable.Table, prefix string, asn int, peer string, second int,
) {
table.Announce(netip.MustParsePrefix(prefix), asn, routetable.Route{
Peer: peer, ASPath: []int{transitAS, asn}, Time: tableTime(second),
})
}
// newRoutesTestDatabase returns a new database in a temporary directory.
func newRoutesTestDatabase(t *testing.T) *Database {
t.Helper()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// writeTableChanges writes what table changed since the last call.
func writeTableChanges(t *testing.T, db *Database, table *routetable.Table) {
t.Helper()
changes, newPeers := table.TakeChanges(math.MaxInt)
err := db.WriteRouteChanges(changes, newPeers)
if err != nil {
t.Fatalf("WriteRouteChanges: %v", err)
}
}
func TestReadRouteTableGivesBackWhatWasWritten(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
source := routetable.New(routetable.Params{})
// Two origins of one prefix: the routes of peerC and peerB are kept for
// asA, peerA keeps only its bit.
announceTo(source, tablePrefixV4, asA, peerA, 10)
announceTo(source, tablePrefixV4, asA, peerB, 20)
announceTo(source, tablePrefixV4, asA, peerC, 30)
announceTo(source, tablePrefixV4, asB, peerD, 40)
// Two routes with the same time.
announceTo(source, tablePrefixV6, asA, peerA, 50)
announceTo(source, tablePrefixV6, asA, peerC, 50)
// An origin whose kept routes were withdrawn while peerA carries it.
announceTo(source, tablePrefixShortV4, asB, peerA, 10)
announceTo(source, tablePrefixShortV4, asB, peerB, 20)
announceTo(source, tablePrefixShortV4, asB, peerC, 30)
source.Withdraw(netip.MustParsePrefix(tablePrefixShortV4), peerB)
source.Withdraw(netip.MustParsePrefix(tablePrefixShortV4), peerC)
writeTableChanges(t, db, source)
checkReadTable(t, db, source)
// A peer moves to another origin, one goes down and a new one comes.
announceTo(source, tablePrefixV4, asB, peerA, 60)
source.PeerDown(peerC)
announceTo(source, tablePrefixV6, asB, peerE, 70)
writeTableChanges(t, db, source)
checkReadTable(t, db, source)
}
func TestReadRouteTableGivesATableThatTakesLaterUpdates(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
source := routetable.New(routetable.Params{})
// peerA and peerB carry tablePrefixV4, peerB and peerC tablePrefixV6.
announceTo(source, tablePrefixV4, asA, peerA, 10)
announceTo(source, tablePrefixV4, asA, peerB, 20)
announceTo(source, tablePrefixV6, asA, peerB, 30)
announceTo(source, tablePrefixV6, asA, peerC, 40)
writeTableChanges(t, db, source)
read := checkReadTable(t, db, source)
// The same updates to both tables: a loaded peer withdraws a prefix,
// another one goes down, and a new peer comes.
for _, table := range []*routetable.Table{source, read} {
table.Withdraw(netip.MustParsePrefix(tablePrefixV4), peerA)
table.PeerDown(peerB)
announceTo(table, tablePrefixShortV4, asB, peerD, 50)
}
checkSameReads(t, read, source)
}
func TestReadRouteTableFailsOnAPeerWithoutANumber(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
// Only the routes of peerB and peerC are kept, so only the peer set names
// peerA.
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV4, asA, peerB, 20)
announceTo(table, tablePrefixV4, asA, peerC, 30)
writeTableChanges(t, db, table)
_, err := db.db.ExecContext(t.Context(),
"DELETE FROM route_peers WHERE address = ?", peerA)
if err != nil {
t.Fatalf("deleting a route peer: %v", err)
}
_, err = db.ReadRouteTable(t.Context())
if !errors.Is(err, errUnknownRoutePeer) {
t.Fatalf("ReadRouteTable = %v, want %v", err, errUnknownRoutePeer)
}
}
// checkReadTable checks that the table ReadRouteTable reads answers every read
// as want does, and that reading it recorded no change. It returns that table.
func checkReadTable(
t *testing.T, db *Database, want *routetable.Table,
) *routetable.Table {
t.Helper()
got, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkSameReads(t, got, want)
changes, newPeers := got.TakeChanges(math.MaxInt)
if len(changes) != 0 || len(newPeers) != 0 {
t.Errorf("the read recorded changes %+v and new peers %v, want none",
changes, newPeers)
}
return got
}
// checkSameReads checks that got answers every read as want does, for the
// prefixes and the origin ASes the tests use.
func checkSameReads(t *testing.T, got, want *routetable.Table) {
t.Helper()
if !reflect.DeepEqual(got.Counts(), want.Counts()) {
t.Errorf("Counts() = %+v, want %+v", got.Counts(), want.Counts())
}
for _, text := range []string{
tablePrefixV4, tablePrefixShortV4, tablePrefixV6,
} {
prefix := netip.MustParsePrefix(text)
if !reflect.DeepEqual(got.Origins(prefix), want.Origins(prefix)) {
t.Errorf("Origins(%s) = %+v, want %+v",
prefix, got.Origins(prefix), want.Origins(prefix))
}
gotSummary, gotFound := got.Lookup(prefix.Addr())
wantSummary, wantFound := want.Lookup(prefix.Addr())
if gotFound != wantFound || !reflect.DeepEqual(gotSummary, wantSummary) {
t.Errorf("Lookup(%s) = %+v, %v; want %+v, %v", prefix.Addr(),
gotSummary, gotFound, wantSummary, wantFound)
}
ipVersion := ipVersionV6
if prefix.Addr().Is4() {
ipVersion = ipVersionV4
}
gotSample := sortedSample(got, ipVersion, prefix.Bits())
wantSample := sortedSample(want, ipVersion, prefix.Bits())
if !reflect.DeepEqual(gotSample, wantSample) {
t.Errorf("SamplePrefixes(%d, %d) = %+v, want %+v",
ipVersion, prefix.Bits(), gotSample, wantSample)
}
}
for _, asn := range []int{asA, asB} {
if !reflect.DeepEqual(got.OriginatedPrefixes(asn),
want.OriginatedPrefixes(asn)) {
t.Errorf("OriginatedPrefixes(%d) = %+v, want %+v", asn,
got.OriginatedPrefixes(asn), want.OriginatedPrefixes(asn))
}
}
}
// sortedSample returns every prefix of the IP version and mask length in
// table, in prefix order.
func sortedSample(
table *routetable.Table, ipVersion, maskLength int,
) []routetable.PrefixSummary {
// More than the tests have of any mask length.
const all = 100
sample := table.SamplePrefixes(ipVersion, maskLength, all)
slices.SortFunc(sample, func(a, b routetable.PrefixSummary) int {
return a.Prefix.Compare(b.Prefix)
})
return sample
}
func TestWriteRouteChangesDeletesTheRowOfARemovedPair(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV4, asB, peerB, 20)
writeTableChanges(t, db, table)
// The prefix's only peer with asA withdraws it.
table.Withdraw(netip.MustParsePrefix(tablePrefixV4), peerA)
writeTableChanges(t, db, table)
var origins []int
rows, err := db.db.QueryContext(t.Context(),
"SELECT origin_asn FROM routes WHERE prefix = ?", tablePrefixV4)
if err != nil {
t.Fatalf("reading the routes: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var origin int
err := rows.Scan(&origin)
if err != nil {
t.Fatalf("reading a route: %v", err)
}
origins = append(origins, origin)
}
err = rows.Err()
if err != nil {
t.Fatalf("reading the routes: %v", err)
}
if !slices.Equal(origins, []int{asB}) {
t.Errorf("rows of %s have origins %v, want only %d",
tablePrefixV4, origins, asB)
}
}
// routePeer is one row of route_peers.
type routePeer struct {
number uint
address string
}
// readRoutePeerRows returns the rows of route_peers in number order.
func readRoutePeerRows(t *testing.T, db *Database) []routePeer {
t.Helper()
rows, err := db.db.QueryContext(t.Context(),
"SELECT number, address FROM route_peers ORDER BY number")
if err != nil {
t.Fatalf("reading the route peers: %v", err)
}
defer func() { _ = rows.Close() }()
var peers []routePeer
for rows.Next() {
var peer routePeer
err := rows.Scan(&peer.number, &peer.address)
if err != nil {
t.Fatalf("reading a route peer: %v", err)
}
peers = append(peers, peer)
}
err = rows.Err()
if err != nil {
t.Fatalf("reading the route peers: %v", err)
}
return peers
}
func TestWriteRouteChangesStoresNewPeers(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV6, asA, peerC, 20)
writeTableChanges(t, db, table)
// peerA is stored already; peerB is new.
announceTo(table, tablePrefixV4, asA, peerA, 30)
announceTo(table, tablePrefixV4, asA, peerB, 40)
writeTableChanges(t, db, table)
want := []routePeer{{0, peerA}, {1, peerC}, {2, peerB}}
got := readRoutePeerRows(t, db)
if !slices.Equal(got, want) {
t.Errorf("route_peers = %+v, want %+v", got, want)
}
}
func TestWriteRouteChangesWritesNothingWhenItFails(t *testing.T) {
t.Parallel()
db := newRoutesTestDatabase(t)
table := routetable.New(routetable.Params{})
announceTo(table, tablePrefixV4, asA, peerA, 10)
announceTo(table, tablePrefixV6, asA, peerB, 20)
// peerB's number is missing, so its kept route cannot be written.
changes, newPeers := table.TakeChanges(math.MaxInt)
delete(newPeers, peerB)
err := db.WriteRouteChanges(changes, newPeers)
if !errors.Is(err, errUnknownRoutePeer) {
t.Fatalf("WriteRouteChanges = %v, want %v", err, errUnknownRoutePeer)
}
peers := readRoutePeerRows(t, db)
if len(peers) != 0 {
t.Errorf("route_peers = %+v after the failed write, want none", peers)
}
var routes int
err = db.db.QueryRowContext(t.Context(),
"SELECT COUNT(*) FROM routes").Scan(&routes)
if err != nil {
t.Fatalf("counting the routes: %v", err)
}
if routes != 0 {
t.Errorf("routes holds %d rows after the failed write, want none", routes)
}
}
// Parameters for BenchmarkReadRouteTable.
const (
// benchTablePairs is how many prefix and origin AS pairs the table holds:
// one origin AS for each prefix. Every benchTableV6Every-th prefix is an
// IPv6 /48, the others IPv4 /24s.
benchTablePairs = 200000
benchTableV6Every = 5
// benchTablePeers is how many peers there are, about as many as RIS has.
// benchTablePeersPerPair is how many of them, picked at random, carry each
// pair: RIS's 460 million routes over 1.4 million pairs.
benchTablePeers = 1400
benchTablePeersPerPair = 330
// benchTableBatch is how many changes each write holds.
benchTableBatch = 10000
)
// BenchmarkReadRouteTable times the read of a routing table of 200,000 prefix
// and origin AS pairs, each carried by 330 of 1,400 peers and keeping two
// routes. It writes that table first. It reports the bytes of the database
// file per pair. It is run by hand, not by make test, for example in the test
// image:
//
// go test -run '^$' -bench ReadRouteTable -benchtime 5x ./internal/database/
func BenchmarkReadRouteTable(b *testing.B) {
db, err := New(&config.Config{StateDir: b.TempDir()}, logger.New())
if err != nil {
b.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
//nolint:gosec // The benchmark needs the same table every run.
rng := rand.New(rand.NewPCG(1, 2))
newPeers := make(map[string]uint, benchTablePeers)
for number := range uint(benchTablePeers) {
newPeers[benchPeerAddress(number)] = number
}
for first := 0; first < benchTablePairs; first += benchTableBatch {
changes := make([]routetable.Change, 0, benchTableBatch)
for pair := first; pair < first+benchTableBatch; pair++ {
changes = append(changes, benchTableChange(rng, pair))
}
err := db.WriteRouteChanges(changes, newPeers)
if err != nil {
b.Fatalf("WriteRouteChanges: %v", err)
}
newPeers = nil
}
// The WAL is copied into the database file, whose size is reported.
err = db.Checkpoint(b.Context())
if err != nil {
b.Fatalf("Checkpoint: %v", err)
}
file, err := os.Stat(db.path)
if err != nil {
b.Fatalf("reading the database file's size: %v", err)
}
for b.Loop() {
table, err := db.ReadRouteTable(b.Context())
if err != nil {
b.Fatalf("ReadRouteTable: %v", err)
}
counts := table.Counts()
if counts.IPv4Prefixes+counts.IPv6Prefixes != benchTablePairs {
b.Fatalf("read %d prefixes, want %d",
counts.IPv4Prefixes+counts.IPv6Prefixes, benchTablePairs)
}
}
// After the loop: b.Loop drops the metrics reported before it starts.
b.ReportMetric(float64(file.Size())/benchTablePairs, "file-bytes/pair")
}
// benchPeerAddress returns the address of the peer numbered number.
func benchPeerAddress(number uint) string {
const addressesPerBlock = 256
return fmt.Sprintf("198.18.%d.%d",
number/addressesPerBlock, number%addressesPerBlock)
}
// benchTableChange returns the content of the pair numbered pair: its prefix,
// its origin AS, benchTablePeersPerPair peers picked at random, and the routes
// of two of them, each with an AS path of four ASes.
func benchTableChange(rng *rand.Rand, pair int) routetable.Change {
const (
firstOrigin = 64500
transitASes = 50000
olderSeconds = 10
)
text := fmt.Sprintf("%d.%d.%d.0/24", 1+pair>>16, (pair>>8)%256, pair%256)
if pair%benchTableV6Every == 0 {
text = fmt.Sprintf("2001:%x:%x::/48", pair>>16, pair%65536)
}
prefix := netip.MustParsePrefix(text)
origin := firstOrigin + pair%transitASes
peers := bitset.New(benchTablePeers)
for peers.Count() < benchTablePeersPerPair {
peers.Set(rng.UintN(benchTablePeers))
}
change := routetable.Change{Prefix: prefix, OriginASN: origin, Peers: peers}
// The routes of the first two peers, newest first.
for i, number := range slices.Collect(peers.EachSet())[:keptRoutesPerRow] {
change.Routes = append(change.Routes, routetable.Route{
Peer: benchPeerAddress(number),
ASPath: []int{
firstOrigin + rng.IntN(transitASes),
firstOrigin + rng.IntN(transitASes),
firstOrigin + rng.IntN(transitASes),
origin,
},
Time: tableTime(pair - i*olderSeconds),
})
}
return change
}
+26 -56
View File
@@ -89,61 +89,31 @@ CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
CREATE INDEX IF NOT EXISTS idx_bgp_peers_last_seen ON bgp_peers(last_seen);
CREATE INDEX IF NOT EXISTS idx_bgp_peers_ip ON bgp_peers(peer_ip);
-- IPv4 routing table maintained by PrefixHandler. A route write changes the
-- table and its entry in every index on a column the write sets, so the table
-- has only the indexes that the queries named with them use.
CREATE TABLE IF NOT EXISTS live_routes_v4 (
-- The row id, which the prefix-length pages' sample draws through the
-- mask_length index. Declared as a column, it stays the same for as long
-- as the route is live, also through a VACUUM.
id INTEGER PRIMARY KEY,
prefix TEXT NOT NULL,
mask_length INTEGER NOT NULL, -- CIDR mask length (0-32)
-- The whole routing table, as internal/routetable keeps it in memory: one row
-- for each prefix and origin AS that some peer carries. Startup reads every
-- row, and the rows of the pairs that change are written back.
CREATE TABLE IF NOT EXISTS routes (
prefix TEXT NOT NULL, -- in the text form Go's net/netip prints
origin_asn INTEGER NOT NULL,
peer_ip TEXT NOT NULL,
as_path TEXT NOT NULL, -- JSON array
next_hop TEXT NOT NULL,
last_updated DATETIME NOT NULL,
-- A peer has at most one route for a prefix. This key finds the route a
-- write updates or deletes and, peer_ip coming first, all of a peer's
-- routes when its BGP session goes down.
UNIQUE(peer_ip, prefix)
);
-- The peers whose current route to the prefix has this origin AS: a set
-- of their numbers in route_peers, as the bitset package's MarshalBinary
-- writes it.
peers BLOB NOT NULL,
-- The newest of those routes and the one before it, the two kept in full.
-- Each has its peer's number in route_peers, its AS path as a JSON array,
-- and its time in seconds since 1970, UTC. A route that is not kept has
-- NULL in its three columns.
newest_peer INTEGER,
newest_as_path TEXT,
newest_time INTEGER,
older_peer INTEGER,
older_as_path TEXT,
older_time INTEGER,
PRIMARY KEY (prefix, origin_asn)
) WITHOUT ROWID;
-- IPv6 routing table maintained by PrefixHandler, as live_routes_v4.
CREATE TABLE IF NOT EXISTS live_routes_v6 (
id INTEGER PRIMARY KEY,
prefix TEXT NOT NULL,
mask_length INTEGER NOT NULL, -- CIDR mask length (0-128)
origin_asn INTEGER NOT NULL,
peer_ip TEXT NOT NULL,
as_path TEXT NOT NULL, -- JSON array
next_hop TEXT NOT NULL,
last_updated DATETIME NOT NULL,
UNIQUE(peer_ip, prefix)
);
-- Indexes for both live route tables, and the queries that use each.
--
-- prefix, last_updated: a prefix's routes, for the prefix page and its API
-- answer, the IP lookup and its peer count, and the check whether a prefix
-- still has a live route; and a prefix's newest route for the prefix-length
-- pages and the AS page, in one search whatever the number of peers.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_prefix_last_updated ON live_routes_v4(prefix, last_updated);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_prefix_last_updated ON live_routes_v6(prefix, last_updated);
-- mask_length: the prefix-length pages' random sample, the row id range of a
-- mask length and the first route of it at or after a row id.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_length ON live_routes_v4(mask_length);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_mask_length ON live_routes_v6(mask_length);
-- mask_length, prefix: the prefix-length pages' list of a mask length's
-- prefixes in text order, and the prefix distribution read at startup.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_prefix ON live_routes_v4(mask_length, prefix);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_mask_prefix ON live_routes_v6(mask_length, prefix);
-- origin_asn, prefix: the prefixes an AS originates, each in one search
-- whatever the number of peers, for the AS page and its API answer.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_origin_prefix ON live_routes_v4(origin_asn, prefix);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_origin_prefix ON live_routes_v6(origin_asn, prefix);
-- last_updated: the oldest and newest route times in /api/v1/stats and
-- /status.json.
CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(last_updated);
CREATE INDEX IF NOT EXISTS idx_live_routes_v6_last_updated ON live_routes_v6(last_updated);
-- The peers of the routes table, each with the number it has there.
CREATE TABLE IF NOT EXISTS route_peers (
number INTEGER PRIMARY KEY,
address TEXT NOT NULL UNIQUE
);
-14
View File
@@ -1,8 +1,6 @@
package database
import (
"fmt"
"net"
"strings"
"github.com/google/uuid"
@@ -20,15 +18,3 @@ func detectIPVersion(prefix string) int {
return ipVersionV4
}
// prefixMaskLength returns the mask length of a prefix such as 192.0.2.0/24.
func prefixMaskLength(prefix string) (int, error) {
_, network, err := net.ParseCIDR(prefix)
if err != nil {
return 0, fmt.Errorf("invalid prefix %s: %w", prefix, err)
}
maskLength, _ := network.Mask.Size()
return maskLength, nil
}
+2 -1
View File
@@ -2,6 +2,7 @@ package routetable_test
import (
"fmt"
"math"
"math/rand/v2"
"net/netip"
"runtime"
@@ -77,7 +78,7 @@ func BenchmarkApplyMixedBatch(b *testing.B) {
// The record of changes is handed over every second while routewatch
// runs, so it is not part of the table's size.
table.TakeChanges()
table.TakeChanges(math.MaxInt)
bytesPerPair := float64(heapBytes()-before) / benchPrefixes
+10
View File
@@ -31,6 +31,11 @@ type OriginatedPrefix struct {
// Counts are the table's figures for the stats.
type Counts struct {
// Pairs counts the prefix and origin AS pairs that some peer carries.
Pairs int
// Peers counts the peers that have a number: those given in
// Params.PeerNumbers and those numbered since.
Peers int
IPv4Prefixes int
IPv6Prefixes int
// IPv4PrefixesByLength[n] and IPv6PrefixesByLength[n] are how many
@@ -160,6 +165,7 @@ func (t *Table) Counts() Counts {
defer t.mu.RUnlock()
counts := Counts{
Peers: len(t.peerNumbers),
IPv4PrefixesByLength: t.ipv4.prefixesByLength(),
IPv6PrefixesByLength: t.ipv6.prefixesByLength(),
IPv4Routes: t.ipv4.routes,
@@ -175,6 +181,10 @@ func (t *Table) Counts() Counts {
counts.IPv6Prefixes += n
}
for _, prefixes := range t.byOrigin {
counts.Pairs += len(prefixes)
}
oldest, found := t.keptTimes.Min()
if found {
counts.OldestKeptRoute = &oldest.time
+7 -1
View File
@@ -194,6 +194,8 @@ func TestCounts(t *testing.T) {
announceAll(table, "2001:db8::/32", asA, 5, peerA)
announceAll(table, "2001:db8::/32", asA, 25, peerC)
checkCounts(t, table, routetable.Counts{
Pairs: 3,
Peers: 3,
IPv4Prefixes: 2,
IPv6Prefixes: 1,
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
@@ -209,6 +211,8 @@ func TestCounts(t *testing.T) {
announceAll(table, prefixV4, asA, 30, peerC)
table.Withdraw(netip.MustParsePrefix("2001:db8::/32"), peerA)
checkCounts(t, table, routetable.Counts{
Pairs: 3,
Peers: 3,
IPv4Prefixes: 2,
IPv6Prefixes: 1,
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
@@ -219,9 +223,11 @@ func TestCounts(t *testing.T) {
NewestKeptRoute: timeAt(30),
})
// peerC was the last peer of the IPv6 prefix.
// peerC was the last peer of the IPv6 prefix. It keeps its number.
table.PeerDown(peerC)
checkCounts(t, table, routetable.Counts{
Pairs: 2,
Peers: 3,
IPv4Prefixes: 2,
IPv4PrefixesByLength: byLength(ipv4Bits, map[int]int{16: 1, 24: 1}),
IPv6PrefixesByLength: byLength(ipv6Bits, nil),
+114 -15
View File
@@ -7,6 +7,7 @@ package routetable
import (
"cmp"
"maps"
"net/netip"
"slices"
"sync"
@@ -102,11 +103,15 @@ type Table struct {
peerNumbers map[string]uint
peerAddresses map[uint]string
nextPeerNumber uint
// peerPrefixes counts the prefixes each peer carries, by its number.
peerPrefixes map[uint]int
// peerPrefixes[n] counts the prefixes the peer numbered n carries, for
// each n below nextPeerNumber. It is a slice, not a map, because Load
// counts once for every peer of every pair it loads: with a map, reading
// a full table at startup took several times as long.
peerPrefixes []int
// newPeers and changed are what TakeChanges hands over next: the peers
// numbered, and the prefix and origin AS pairs changed, since it last ran.
// newPeers and changed are what TakeChanges hands over: the peers
// numbered since it last ran, and the prefix and origin AS pairs changed
// since it last took them.
newPeers map[string]uint
changed map[pair]struct{}
@@ -127,7 +132,6 @@ func New(params Params) *Table {
byOrigin: make(map[int]map[netip.Prefix]*origin),
peerNumbers: make(map[string]uint, len(params.PeerNumbers)),
peerAddresses: make(map[uint]string, len(params.PeerNumbers)),
peerPrefixes: make(map[uint]int),
newPeers: make(map[string]uint),
changed: make(map[pair]struct{}),
keptTimes: btree.NewG(keptTimesDegree, func(a, b keptTime) bool {
@@ -141,6 +145,8 @@ func New(params Params) *Table {
t.nextPeerNumber = max(t.nextPeerNumber, number+1)
}
t.peerPrefixes = make([]int, t.nextPeerNumber)
return t
}
@@ -218,16 +224,77 @@ func (t *Table) PeerDown(peer string) {
}
}
// TakeChanges returns the prefix and origin AS pairs that changed since it last
// ran, in prefix order, each with its current content. It also returns the
// peers numbered since then, by address. It then clears both records.
func (t *Table) TakeChanges() ([]Change, map[string]uint) {
// Load puts one prefix and origin AS pair that the database holds into the
// table, as startup does with each row it reads. The change's peers join the
// pair's peer set, and its routes are kept as an announcement's would be.
// Unlike an update, Load records no change, so TakeChanges does not hand the
// pair back to be written again. Loading a pair twice changes nothing.
//
// The caller makes sure that every peer change names, in its peer set or in
// its routes, has a number in Params.PeerNumbers.
func (t *Table) Load(change Change) {
t.mu.Lock()
defer t.mu.Unlock()
changes := make([]Change, 0, len(t.changed))
// A removed pair has no peers, and a pair without peers is not in the
// table.
if change.Peers == nil || change.Peers.None() || !change.Prefix.IsValid() {
return
}
prefix := change.Prefix.Masked()
o := t.findOrAddOrigin(prefix, change.OriginASN)
wasWithoutKeptRoute := o.withoutKeptRoute()
// The peers not yet in the peer set join it in one union, which takes
// less time than setting their bits one at a time.
joining := change.Peers.Difference(&o.peers)
o.peers.InPlaceUnion(joining)
t.familyOf(prefix.Addr()).routes += int(joining.Count())
for peer := range joining.EachSet() {
t.peerPrefixes[peer]++
}
// Oldest first, as they were announced, so that two routes with the same
// time are kept in the order they had.
for _, r := range slices.Backward(change.Routes) {
peer := t.peerNumbers[r.Peer]
t.dropKeptRoute(o, peer)
t.keepIfNewest(o, route{
peer: peer,
path: slices.Clone(r.ASPath),
time: r.Time,
})
}
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
}
// TakeChanges returns at most maxChanges of the prefix and origin AS pairs that
// changed since they were last taken, in prefix order, each with its current
// content. The other changed pairs wait for a later call. It also returns every
// peer numbered since it last ran, by address, so a pair taken later names only
// peers already returned. It then forgets what it returned.
func (t *Table) TakeChanges(maxChanges int) ([]Change, map[string]uint) {
t.mu.Lock()
defer t.mu.Unlock()
changes := make([]Change, 0, min(maxChanges, len(t.changed)))
for key := range t.changed {
if len(changes) == maxChanges {
break
}
changes = append(changes, t.currentContent(key))
delete(t.changed, key)
}
// A map keeps the memory of its largest size, so an emptied one is
// replaced.
if len(t.changed) == 0 {
t.changed = make(map[pair]struct{})
}
slices.SortFunc(changes, func(a, b Change) int {
@@ -237,11 +304,35 @@ func (t *Table) TakeChanges() ([]Change, map[string]uint) {
newPeers := t.newPeers
t.newPeers = make(map[string]uint)
t.changed = make(map[pair]struct{})
return changes, newPeers
}
// PutBackChanges records again what one TakeChanges call returned, for a
// caller that could not write it. The next TakeChanges returns those peers
// again, and those pairs wait to be taken again like any changed pair. Each is
// then taken with its content at that time, so a later change of a pair
// replaces the earlier.
func (t *Table) PutBackChanges(changes []Change, newPeers map[string]uint) {
t.mu.Lock()
defer t.mu.Unlock()
for _, change := range changes {
t.changed[pair{prefix: change.Prefix, asn: change.OriginASN}] = struct{}{}
}
maps.Copy(t.newPeers, newPeers)
}
// ChangeCount returns how many changed prefix and origin AS pairs wait for
// TakeChanges.
func (t *Table) ChangeCount() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.changed)
}
// numberPeer returns the peer's number, and gives it the next one the first
// time the table sees it.
func (t *Table) numberPeer(address string) uint {
@@ -254,6 +345,7 @@ func (t *Table) numberPeer(address string) uint {
t.nextPeerNumber++
t.peerNumbers[address] = number
t.peerAddresses[number] = address
t.peerPrefixes = append(t.peerPrefixes, 0)
t.newPeers[address] = number
return number
@@ -413,6 +505,17 @@ func (t *Table) recordChange(
) {
t.changed[pair{prefix: prefix, asn: o.asn}] = struct{}{}
t.countOriginWithoutKeptRoute(o, wasWithoutKeptRoute)
if o.peers.None() {
t.removeOrigin(prefix, o)
}
}
// countOriginWithoutKeptRoute brings the count of origins without a kept route
// up to date after a change to o. wasWithoutKeptRoute says whether o was such
// an origin before the change.
func (t *Table) countOriginWithoutKeptRoute(o *origin, wasWithoutKeptRoute bool) {
if wasWithoutKeptRoute {
t.originsWithoutKeptRoute--
}
@@ -420,10 +523,6 @@ func (t *Table) recordChange(
if o.withoutKeptRoute() {
t.originsWithoutKeptRoute++
}
if o.peers.None() {
t.removeOrigin(prefix, o)
}
}
// countKeptTime adds delta to the number of kept routes with time at.
+128 -4
View File
@@ -1,6 +1,7 @@
package routetable_test
import (
"math"
"net/netip"
"reflect"
"slices"
@@ -17,6 +18,7 @@ const (
peerA = "192.0.2.1"
peerB = "192.0.2.2"
peerC = "2001:db8::3"
peerD = "2001:db8::4"
asA = 64500
asB = 64501
@@ -437,7 +439,7 @@ func TestTakeChangesHoldsOnlyPairsThatChanged(t *testing.T) {
// peerB to v4 are kept; peerA keeps only its bit.
announceAll(table, prefixV4, asA, 10, peerA, peerB, peerC)
table.Announce(v6, asA, routeOf(peerB, asA, 10))
table.TakeChanges()
table.TakeChanges(math.MaxInt)
// peerA announces again, with a time older than both kept routes.
table.Announce(v4, asA, routeOf(peerA, asA, 15))
@@ -475,6 +477,126 @@ func TestTakeChangesHoldsOnlyPairsThatChanged(t *testing.T) {
checkChanges(t, table, []change{}, map[string]uint{})
}
// TestTakeChangesTakesAtMostMaxChanges changes three prefix and origin AS
// pairs, then takes the changes two at a time. The first call must return two
// pairs and both new peers, and the second the third pair and no peer.
func TestTakeChangesTakesAtMostMaxChanges(t *testing.T) {
t.Parallel()
const maxChanges = 2
table := routetable.New(routetable.Params{})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
table.Announce(v4, asA, routeOf(peerA, asA, 10))
table.Announce(v4, asB, routeOf(peerB, asB, 10))
table.Announce(v6, asA, routeOf(peerA, asA, 10))
first, firstPeers := table.TakeChanges(maxChanges)
second, secondPeers := table.TakeChanges(maxChanges)
if len(first) != maxChanges || len(second) != 1 {
t.Fatalf("TakeChanges(%d) returned %d and then %d pairs, want %d and 1",
maxChanges, len(first), len(second), maxChanges)
}
wantPeers := map[string]uint{peerA: 0, peerB: 1}
if !reflect.DeepEqual(firstPeers, wantPeers) || len(secondPeers) != 0 {
t.Errorf("TakeChanges(%d) returned the new peers %v and then %v, "+
"want %v and none", maxChanges, firstPeers, secondPeers, wantPeers)
}
for _, c := range first {
if c.Prefix == second[0].Prefix && c.OriginASN == second[0].OriginASN {
t.Errorf("both calls returned %s with AS%d", c.Prefix, c.OriginASN)
}
}
}
// TestPutBackChanges takes a table's changes and puts them back, as a caller
// does whose write failed. The next TakeChanges must return them again, with
// each pair's content then, and with the changes since.
func TestPutBackChanges(t *testing.T) {
t.Parallel()
table := routetable.New(routetable.Params{})
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
table.Announce(v4, asA, routeOf(peerA, asA, 10))
table.Announce(v6, asA, routeOf(peerA, asA, 10))
table.PutBackChanges(table.TakeChanges(math.MaxInt))
if got := table.ChangeCount(); got != 2 {
t.Errorf("ChangeCount() = %d after the changes were put back, want 2",
got)
}
// peerA withdraws v4, so v4's earlier content must not come back; peerB
// announces v6, and is a new peer as well.
table.Withdraw(v4, peerA)
table.Announce(v6, asA, routeOf(peerB, asA, 20))
checkChanges(t, table, []change{
{Prefix: prefixV4, OriginASN: asA, Removed: true},
{Prefix: prefixV6, OriginASN: asA, Peers: []uint{0, 1},
Routes: []routetable.Route{
routeOf(peerB, asA, 20), routeOf(peerA, asA, 10),
}},
}, map[string]uint{peerA: 0, peerB: 1})
if got := table.ChangeCount(); got != 0 {
t.Errorf("ChangeCount() = %d after TakeChanges, want 0", got)
}
}
func TestLoad(t *testing.T) {
t.Parallel()
const otherV4 = "10.9.0.0/16"
v4 := netip.MustParsePrefix(prefixV4)
v6 := netip.MustParsePrefix(prefixV6)
other := netip.MustParsePrefix(otherV4)
// The routes of peerC and peerB to v4 are kept; peerA keeps only its bit.
// The two routes to v6 have the same time. The routes of other were
// withdrawn while peerA still carries it.
source := routetable.New(routetable.Params{})
announceAll(source, prefixV4, asA, 10, peerA, peerB, peerC)
announceAll(source, prefixV4, asB, 40, peerD)
source.Announce(v6, asA, routeOf(peerA, asA, 50))
source.Announce(v6, asA, routeOf(peerC, asA, 50))
announceAll(source, otherV4, asB, 10, peerA, peerB, peerC)
source.Withdraw(other, peerB)
source.Withdraw(other, peerC)
changes, peers := source.TakeChanges(math.MaxInt)
loaded := routetable.New(routetable.Params{PeerNumbers: peers})
// Loading a pair twice changes nothing.
for range 2 {
for _, change := range changes {
loaded.Load(change)
}
}
for _, prefix := range []netip.Prefix{v4, v6, other} {
got, want := loaded.Origins(prefix), source.Origins(prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("Origins(%s) = %+v after the load, want %+v", prefix, got, want)
}
}
if !reflect.DeepEqual(loaded.Counts(), source.Counts()) {
t.Errorf("Counts() = %+v after the load, want %+v",
loaded.Counts(), source.Counts())
}
checkChanges(t, loaded, []change{}, map[string]uint{})
}
// TestConcurrentUse has goroutines update one table while others read it. The
// race detector, which make test runs, fails it when a method uses the table
// without its lock.
@@ -496,7 +618,8 @@ func TestConcurrentUse(t *testing.T) {
func(round int) { table.Announce(prefix, asB, routeOf(peerB, asB, round)) },
func(int) { table.Withdraw(prefix, peerA) },
func(int) { table.PeerDown(peerB) },
func(int) { table.TakeChanges() },
func(int) { table.PutBackChanges(table.TakeChanges(math.MaxInt)) },
func(int) { table.ChangeCount() },
func(int) { table.Lookup(prefix.Addr()) },
func(int) { table.Origins(prefix) },
func(int) { table.OriginatedPrefixes(asA) },
@@ -516,14 +639,15 @@ func TestConcurrentUse(t *testing.T) {
wg.Wait()
}
// checkChanges checks that TakeChanges returns want and wantPeers.
// checkChanges checks that TakeChanges, taking every change, returns want and
// wantPeers.
func checkChanges(
t *testing.T, table *routetable.Table, want []change,
wantPeers map[string]uint,
) {
t.Helper()
changes, newPeers := table.TakeChanges()
changes, newPeers := table.TakeChanges(math.MaxInt)
got := make([]change, 0, len(changes))
for _, c := range changes {
+38 -6
View File
@@ -2,12 +2,15 @@
// running instance of the application and contains pointers to its core
// dependencies, and is responsible for initialization. It also contains the
// ASN WHOIS fetcher for background updates and the database maintainer for
// background maintenance tasks.
// background maintenance tasks. The package also holds the handlers that
// write the feed's updates to the database in batches, and the flush loop
// that the AS, peer and prefix handlers share.
package routewatch
import (
"context"
"errors"
"fmt"
"path/filepath"
"sync"
"time"
@@ -17,6 +20,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
@@ -27,11 +31,13 @@ import (
// are disabled, since only the batched handlers exist.
var errNonBatchedNotImplemented = errors.New("non-batched handlers not implemented")
// Dependencies contains all dependencies for RouteWatch
// Dependencies contains all dependencies for RouteWatch. Table is the routing
// table read from the database at startup, which the server reads too.
type Dependencies struct {
fx.In
DB database.Store
Table *routetable.Table
Streamer *streamer.Streamer
Server *server.Server
Logger *logger.Logger
@@ -41,6 +47,7 @@ type Dependencies struct {
// RouteWatch represents the main application instance
type RouteWatch struct {
db database.Store
table *routetable.Table
streamer *streamer.Streamer
server *server.Server
logger *logger.Logger
@@ -65,6 +72,7 @@ const blocklistsDirName = "blocklists"
func New(deps Dependencies) *RouteWatch {
rw := &RouteWatch{
db: deps.DB,
table: deps.Table,
streamer: deps.Streamer,
server: deps.Server,
logger: deps.Logger,
@@ -92,6 +100,7 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
// Register database handler to process BGP UPDATE messages
if rw.config.EnableBatchedDatabaseWrites {
rw.logger.Info("Using batched database handlers for improved performance")
// ASHandler maintains the asns table
rw.dbHandler = NewASHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.dbHandler)
@@ -100,8 +109,10 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
rw.streamer.RegisterHandler(rw.peerHandler)
// PrefixHandler maintains the prefixes and live_routes tables
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger)
// PrefixHandler keeps the routing table in memory current, and writes
// its changes to the routes table and new prefixes to the prefix
// tables
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger, rw.table)
rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker())
rw.streamer.RegisterHandler(rw.prefixHandler)
@@ -115,8 +126,6 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
return errNonBatchedNotImplemented
}
// No longer need routing table handler - PrefixHandler maintains live_routes table
// Start streaming
err := rw.streamer.Start(ctx)
if err != nil {
@@ -254,6 +263,28 @@ func (rw *RouteWatch) closeDatabase(ctx context.Context) {
}
}
// readRouteTable reads the routing table from the database and logs how many
// prefix and origin AS pairs and peers it holds and how long the read took.
// fx calls it once, to build the server and RouteWatch, before the streamer
// connects, so the prefix handler applies the feed's updates to all of it. No
// start timeout limits the read: fx builds them before it runs a start hook.
func readRouteTable(
db database.Store, logger *logger.Logger,
) (*routetable.Table, error) {
start := time.Now()
table, err := db.ReadRouteTable(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to read the routing table: %w", err)
}
counts := table.Counts()
logger.Info("Read the routing table", "pairs", counts.Pairs,
"peers", counts.Peers, "duration_ms", time.Since(start).Milliseconds())
return table, nil
}
// getModule provides all fx dependencies
func getModule() []fx.Option {
return []fx.Option{
@@ -265,6 +296,7 @@ func getModule() []fx.Option {
database.New,
fx.As(new(database.Store)),
),
readRouteTable,
streamer.New,
server.New,
New,
@@ -7,6 +7,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"strconv"
"strings"
@@ -19,6 +20,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/streamer"
)
@@ -31,14 +33,15 @@ type mockStore struct {
ASNCount int
PrefixCount int
PeeringCount int
RouteCount int
WithdrawalCount int
// Track unique items
ASNs map[int]*database.ASN
Prefixes map[string]*database.Prefix
Peerings map[string]bool // key is "from_to"
Routes map[string]bool // key is "prefix_origin_peer"
// TablePrefixes holds the prefixes that the written changes of the
// routing table leave some peer carrying.
TablePrefixes map[string]bool
// Track IP versions
IPv4Prefixes int
@@ -48,10 +51,10 @@ type mockStore struct {
// newMockStore creates a new mock store
func newMockStore() *mockStore {
return &mockStore{
ASNs: make(map[int]*database.ASN),
Prefixes: make(map[string]*database.Prefix),
Peerings: make(map[string]bool),
Routes: make(map[string]bool),
ASNs: make(map[int]*database.ASN),
Prefixes: make(map[string]*database.Prefix),
Peerings: make(map[string]bool),
TablePrefixes: make(map[string]bool),
}
}
@@ -101,6 +104,13 @@ func (m *mockStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
IPVersion: ipVersion,
FirstSeen: timestamp,
}
m.PrefixCount++
if ipVersion == ipVersionV4 {
m.IPv4Prefixes++
} else {
m.IPv6Prefixes++
}
}
}
@@ -201,92 +211,30 @@ func (m *mockStore) GetStatsContext(_ context.Context) (database.Stats, error) {
return m.GetStats()
}
// UpsertLiveRoute mock implementation
func (m *mockStore) UpsertLiveRoute(_ *database.LiveRoute) error {
// Simple mock - just return nil
return nil
}
// DeleteLiveRoute mock implementation
func (m *mockStore) DeleteLiveRoute(_, _ string) error {
// Simple mock - just return nil
return nil
}
// GetPrefixDistribution mock implementation
func (m *mockStore) GetPrefixDistribution() (
[]database.PrefixDistribution, []database.PrefixDistribution, error,
) {
// Return empty distributions for now
return nil, nil, nil
}
// GetPrefixDistributionContext mock implementation with context support
func (m *mockStore) GetPrefixDistributionContext(_ context.Context) (
[]database.PrefixDistribution, []database.PrefixDistribution, error,
) {
return m.GetPrefixDistribution()
}
// GetLiveRouteCounts mock implementation
func (m *mockStore) GetLiveRouteCounts() (int, int, error) {
// Return mock counts
return m.RouteCount / 2, m.RouteCount / 2, nil
}
// GetLiveRouteCountsContext mock implementation with context support
func (m *mockStore) GetLiveRouteCountsContext(_ context.Context) (int, int, error) {
return m.GetLiveRouteCounts()
}
// GetASDetails mock implementation
func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) {
// GetAS returns the stored AS, or only its number when none is stored.
func (m *mockStore) GetAS(_ context.Context, asn int) (*database.ASN, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if ASN exists
if asnInfo, exists := m.ASNs[asn]; exists {
// Return empty prefixes for now
return asnInfo, []database.LiveRoute{}, nil
return asnInfo, true, nil
}
return nil, nil, database.ErrNoRoute
return &database.ASN{ASN: asn}, false, nil
}
// GetASDetailsContext mock implementation with context support
func (m *mockStore) GetASDetailsContext(
_ context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
return m.GetASDetails(asn)
// GetASWHOIS returns the stored AS, or only its number when none is stored.
func (m *mockStore) GetASWHOIS(ctx context.Context, asn int) (*database.ASN, error) {
asnInfo, _, err := m.GetAS(ctx, asn)
return asnInfo, err
}
// GetPrefixDetails mock implementation
func (m *mockStore) GetPrefixDetails(
_ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
// Return empty routes for now
return []database.LiveRoute{}, map[int]database.ASN{}, nil
}
// GetPrefixDetailsContext mock implementation with context support
func (m *mockStore) GetPrefixDetailsContext(
_ context.Context, prefix string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return m.GetPrefixDetails(prefix)
}
func (m *mockStore) GetRandomPrefixesByLength(
_, _, _ int,
) ([]database.LiveRoute, error) {
// Return empty routes for now
return []database.LiveRoute{}, nil
}
// GetRandomPrefixesByLengthContext mock implementation with context support
func (m *mockStore) GetRandomPrefixesByLengthContext(
_ context.Context, maskLength, ipVersion, limit int,
) ([]database.LiveRoute, error) {
return m.GetRandomPrefixesByLength(maskLength, ipVersion, limit)
// GetPrefixFirstSeen returns the zero time: the mock keeps no prefix times.
func (m *mockStore) GetPrefixFirstSeen(
_ context.Context, _ netip.Prefix,
) (time.Time, error) {
return time.Time{}, nil
}
// GetASPeers mock implementation
@@ -302,32 +250,6 @@ func (m *mockStore) GetASPeersContext(
return m.GetASPeers(asn)
}
// GetIPInfo mock implementation
func (m *mockStore) GetIPInfo(ip string) (*database.IPInfo, error) {
return m.GetIPInfoContext(context.Background(), ip)
}
// GetIPInfoContext mock implementation with context support
func (m *mockStore) GetIPInfoContext(
_ context.Context, ip string,
) (*database.IPInfo, error) {
now := time.Now()
return &database.IPInfo{
IP: ip,
Netblock: "8.8.8.0/24",
MaskLength: 24,
IPVersion: 4,
NumPeers: 3,
ASN: 15169,
Handle: "GOOGLE",
Description: "Google LLC",
CountryCode: "US",
FirstSeen: now.Add(-24 * time.Hour),
LastSeen: now,
}, nil
}
// GetNextStaleASN mock implementation
func (m *mockStore) GetNextStaleASN(_ context.Context, _ time.Duration) (int, error) {
return 0, database.ErrNoStaleASN
@@ -355,44 +277,28 @@ func (m *mockStore) GetWHOISStats(
}, nil
}
// UpsertLiveRouteBatch mock implementation
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
// WriteRouteChanges records the prefixes the changes leave some peer carrying,
// whatever their origin AS.
func (m *mockStore) WriteRouteChanges(
changes []routetable.Change, _ map[string]uint,
) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, route := range routes {
// Track prefix
if _, exists := m.Prefixes[route.Prefix]; !exists {
m.Prefixes[route.Prefix] = &database.Prefix{
Prefix: route.Prefix,
IPVersion: route.IPVersion,
FirstSeen: route.LastUpdated,
}
m.PrefixCount++
if route.IPVersion == 4 {
m.IPv4Prefixes++
} else {
m.IPv6Prefixes++
}
for _, change := range changes {
if change.Removed {
delete(m.TablePrefixes, change.Prefix.String())
} else {
m.TablePrefixes[change.Prefix.String()] = true
}
m.RouteCount++
}
return nil
}
// DeleteLiveRouteBatch mock implementation
func (m *mockStore) DeleteLiveRouteBatch(_ []database.LiveRouteDeletion) error {
// Simple mock - just return nil
return nil
}
// DeleteLiveRoutesOfPeer mock implementation
func (m *mockStore) DeleteLiveRoutesOfPeer(_ string) (int, error) {
// Simple mock - deletes nothing
return 0, nil
// ReadRouteTable returns an empty routing table.
func (m *mockStore) ReadRouteTable(_ context.Context) (*routetable.Table, error) {
return routetable.New(routetable.Params{}), nil
}
// GetOrCreateASNBatch mock implementation
@@ -473,12 +379,15 @@ func TestRouteWatchLiveFeed(t *testing.T) {
EnableBatchedDatabaseWrites: true,
}
table := routetable.New(routetable.Params{})
// Create server
srv := server.New(mockDB, s, logger, cfg)
srv := server.New(mockDB, table, s, logger, cfg)
// Create RouteWatch with 5 second limit
deps := Dependencies{
DB: mockDB,
Table: table,
Streamer: s,
Server: srv,
Logger: logger,
@@ -566,7 +475,8 @@ func announcementFeed(prefixes []string) string {
// handler's worker waits on the first one until the server sees the streamer
// close the connection, which only Shutdown does, so all three are still in
// the streamer when Shutdown starts. Each prefix must be in the database when
// Shutdown returns.
// Shutdown returns, both in the prefix tables and in the routing table's
// writes.
func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
t.Parallel()
@@ -596,11 +506,11 @@ func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
rw := New(Dependencies{
DB: store,
Streamer: s,
Server: server.New(store, s, log, cfg),
Server: server.New(store, nil, s, log, cfg),
Logger: log,
Config: cfg,
})
rw.prefixHandler = NewPrefixHandler(store, log)
rw.prefixHandler = NewPrefixHandler(store, log, routetable.New(routetable.Params{}))
s.RegisterHandler(gatedPrefixHandler{
PrefixHandler: rw.prefixHandler, release: release,
})
@@ -640,9 +550,11 @@ func TestShutdownWritesTheUpdatesQueuedInTheStreamer(t *testing.T) {
defer store.mu.Unlock()
for _, prefix := range prefixes {
if _, ok := store.Prefixes[prefix]; !ok {
t.Errorf("%s was queued in the streamer when Shutdown started "+
"but is not in the database", prefix)
_, inPrefixTables := store.Prefixes[prefix]
if !inPrefixTables || !store.TablePrefixes[prefix] {
t.Errorf("%s was queued in the streamer when Shutdown started; "+
"written to the prefix tables %t, in the routing table's "+
"writes %t", prefix, inPrefixTables, store.TablePrefixes[prefix])
}
}
}
@@ -696,7 +608,7 @@ func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
rw := New(Dependencies{
DB: store,
Streamer: s,
Server: server.New(store, s, log, cfg),
Server: server.New(store, nil, s, log, cfg),
Logger: log,
Config: cfg,
})
@@ -810,7 +722,7 @@ func TestShutdownCheckpointsAndClosesTheDatabaseLast(t *testing.T) {
rw := New(Dependencies{
DB: store,
Streamer: s,
Server: server.New(store, s, log, cfg),
Server: server.New(store, nil, s, log, cfg),
Logger: log,
Config: cfg,
})
+10 -32
View File
@@ -52,7 +52,6 @@ type ASHandler struct {
// &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
@@ -70,19 +69,27 @@ type asnOp struct {
// Stop when finished to ensure all pending operations are flushed and the
// background goroutine is terminated.
func NewASHandler(db database.Store, logger *logger.Logger) *ASHandler {
return newASHandler(db, logger, asnBatchTimeout)
}
// newASHandler is NewASHandler with the flush timer's interval, batchTimeout,
// in place of asnBatchTimeout. A test that the timer must not disturb gives an
// interval longer than the test runs.
func newASHandler(
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
) *ASHandler {
h := &ASHandler{
db: db,
logger: logger,
batch: make([]asnOp, 0, asnBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -175,33 +182,6 @@ func (h *ASHandler) Stop(ctx context.Context) {
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *ASHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(asnBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= asnBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending operations and leaves an empty batch in their
// place for HandleMessage to fill. They count in writing until flushBatch has
// written them.
@@ -249,6 +229,4 @@ func (h *ASHandler) flushBatch() {
h.mu.Lock()
h.writing = 0
h.mu.Unlock()
h.lastFlush = time.Now()
}
@@ -29,9 +29,9 @@ func TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS(t *testing.T) {
h.HandleMessage(second)
h.flushBatch()
asn, _, err := db.GetASDetails(testASNA)
asn, _, err := db.GetAS(t.Context(), testASNA)
if err != nil {
t.Fatalf("GetASDetails(%d): %v", testASNA, err)
t.Fatalf("GetAS(%d): %v", testASNA, err)
}
if !asn.FirstSeen.Equal(first.ParsedTimestamp) {
+62
View File
@@ -0,0 +1,62 @@
package routewatch
import (
"context"
"time"
)
// flushLoop is the flush goroutine of the prefix, AS and peer handlers. It is
// the only goroutine that writes a handler's batch. flushBatch takes the batch
// and writes it.
//
// flushLoop calls flushBatch on each tick of a timer, every batchTimeout. It
// also calls it at once when batchFullCh wakes it. A full batch's write
// restarts the timer, so the next tick comes batchTimeout after that write
// began. When stopCh hands it Stop's context, it calls flushBatch once more,
// unless the stop deadline has passed. Then it closes done and returns.
func flushLoop(
batchTimeout time.Duration,
batchFullCh <-chan struct{},
stopCh <-chan context.Context,
done chan<- struct{},
flushBatch func(),
) {
defer close(done)
ticker := time.NewTicker(batchTimeout)
defer ticker.Stop()
for {
// Stop goes first. A write can run past the stop deadline while a full
// batch's wake-up or a tick waits beside Stop's context. The select
// below picks one of them at random, but past the deadline no flush
// may start.
select {
case ctx := <-stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
flushBatch()
}
return
default:
}
select {
case <-ticker.C:
flushBatch()
case <-batchFullCh:
// Reset also drops a tick that waits in ticker.C, so no timed
// write follows this one at once.
ticker.Reset(batchTimeout)
flushBatch()
case ctx := <-stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
flushBatch()
}
return
}
}
}
+245 -39
View File
@@ -4,15 +4,18 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"sync"
"testing"
"testing/synctest"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const (
@@ -25,10 +28,62 @@ const (
stillWaitingAfter = 100 * time.Millisecond
)
// TestFlushLoopWritesOnEveryTick runs flushLoop in a synctest bubble, whose
// clock is fake: time.Sleep there moves the clock on and returns at once. Each
// write takes a tenth of the interval. Two timed writes must start one interval
// apart. A full batch's write, which starts as the second timed write ends,
// must be followed by a timed write one interval after it began.
func TestFlushLoopWritesOnEveryTick(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
const (
interval = time.Second
writeTime = 100 * time.Millisecond
)
writeStarts := make(chan time.Time)
batchFullCh := make(chan struct{}, 1)
stopCh := make(chan context.Context, 1)
done := make(chan struct{})
go func() {
flushLoop(interval, batchFullCh, stopCh, done, func() {
writeStarts <- time.Now()
time.Sleep(writeTime)
})
}()
first := <-writeStarts
second := <-writeStarts
if gap := second.Sub(first); gap != interval {
t.Errorf("the second timed write started %s after the first, "+
"want %s", gap, interval)
}
batchFullCh <- struct{}{}
full := <-writeStarts
timed := <-writeStarts
if gap := timed.Sub(full); gap != interval {
t.Errorf("the timed write after a full batch's started %s after "+
"it, want %s", gap, interval)
}
stopCh <- t.Context()
<-writeStarts // Stop's last write
<-done
})
}
// blockingStore is a mockStore whose batch writes for the AS, peer and prefix
// handlers, delete of a peer's routes and peering write wait until the test
// calls releaseWrites. writeStarted is closed when the first of them starts. It
// records what each batch write was given.
// handlers and peering write wait until the test calls releaseWrites.
// writeStarted is closed when the first of them starts. It records what each
// batch write was given.
type blockingStore struct {
*mockStore
@@ -54,13 +109,13 @@ func newBlockingStore() *blockingStore {
}
}
func (s *blockingStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) error {
func (s *blockingStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
s.waitForRelease()
s.mu.Lock()
defer s.mu.Unlock()
for _, route := range routes {
s.prefixes[route.Prefix] = true
for prefix := range prefixes {
s.prefixes[prefix] = true
}
return nil
@@ -90,12 +145,6 @@ func (s *blockingStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) er
return nil
}
func (s *blockingStore) DeleteLiveRoutesOfPeer(peerIP string) (int, error) {
s.waitForRelease()
return s.mockStore.DeleteLiveRoutesOfPeer(peerIP)
}
func (s *blockingStore) RecordPeering(
asA, asB int, seen database.SeenTimes,
) error {
@@ -119,10 +168,10 @@ func (s *blockingStore) releaseWrites() {
//
//nolint:tagliatelle // field names are the keys the handlers log
type stopLog struct {
Msg string `json:"msg"`
Updates int `json:"updates"`
PeersDown int `json:"peers_down"`
Paths int `json:"paths"`
Msg string `json:"msg"`
Updates int `json:"updates"`
TableChanges int `json:"table_changes"`
Paths int `json:"paths"`
}
// newErrorLogger returns a logger that writes only errors, as JSON lines, to
@@ -166,9 +215,10 @@ func returnsInTime(f func()) bool {
}
}
// startBlockedWrite hands h the messages in fill, which fill its batch so that
// the handler writes it to store, and waits until that write starts. store
// holds it until the test calls releaseWrites.
// startBlockedWrite hands h the messages in fill and waits until h starts to
// write them to store. The prefix, AS and peer handlers start when fill fills
// their batch, and the peering handler on its timer. store holds the write
// until the test calls releaseWrites.
func startBlockedWrite(
t *testing.T,
store *blockingStore,
@@ -191,7 +241,7 @@ func startBlockedWrite(
select {
case <-store.writeStarted:
case <-time.After(handleMessageTimeout):
t.Fatal("the handler did not write its full batch")
t.Fatal("the handler did not start to write the messages")
}
}
@@ -271,7 +321,7 @@ func handleAtMaxBatchSize(
}
// stopWhileAWriteIsBlocked starts a blocked write with the messages in fill,
// hands h the message extra, and stops h with a deadline that passes while
// hands h the messages in extra, and stops h with a deadline that passes while
// store holds the write. Stop must return all the same. Then it lets the write
// finish and waits until done, closed when h's flush goroutine returns. Past
// the deadline that goroutine skips its last write, so extra is not written,
@@ -285,7 +335,7 @@ func stopWhileAWriteIsBlocked(
},
done <-chan struct{},
fill []*ristypes.RISMessage,
extra *ristypes.RISMessage,
extra ...*ristypes.RISMessage,
) {
t.Helper()
@@ -294,7 +344,10 @@ func stopWhileAWriteIsBlocked(
defer store.releaseWrites()
startBlockedWrite(t, store, h, fill)
h.HandleMessage(extra)
for _, msg := range extra {
h.HandleMessage(msg)
}
ctx, cancel := context.WithTimeout(t.Context(), stopDeadline)
defer cancel()
@@ -313,6 +366,11 @@ func stopWhileAWriteIsBlocked(
}
}
// TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline also checks the
// routing table's changes: the flush whose prefix write is held wrote those of
// the fill, so Stop must log the one of the extra prefix as not written. The
// flush timer must not fire. A flush it started before the fill would be the
// one held, and it would not have written the fill's change.
func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
@@ -321,12 +379,11 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
var logs bytes.Buffer
store := newBlockingStore()
h := NewPrefixHandler(store, newErrorLogger(&logs))
h := newPrefixHandler(store, newErrorLogger(&logs),
routetable.New(routetable.Params{}), longerThanAnyTest)
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
// The peer's session goes down first, so the write held is the delete of
// its routes, and Stop must log that peer down too.
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{sessionDown(), prefixAnnouncement(fill)},
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
prefixAnnouncement([]string{extraPrefix}))
store.mu.Lock()
@@ -337,9 +394,75 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
}
logged := lastLogged(t, &logs)
if logged.Updates != prefixBatchSize+1 || logged.PeersDown != 1 {
t.Errorf("Stop logged %d updates and %d peer downs not written, "+
"want %d and 1", logged.Updates, logged.PeersDown, prefixBatchSize+1)
if logged.Updates != prefixBatchSize+1 || logged.TableChanges != 1 {
t.Errorf("Stop logged %d updates and %d table changes not written, "+
"want %d and 1", logged.Updates, logged.TableChanges,
prefixBatchSize+1)
}
}
// TestPrefixHandlerSkipsAFullBatchAfterTheStopDeadline hands the prefix handler
// a full batch while a write is held, so the batch's wake-up of the flush
// goroutine waits. The stop deadline passes meanwhile. When the write ends, the
// flush goroutine must stop instead of writing the batch.
func TestPrefixHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
full := slices.Repeat([]string{extraPrefix}, prefixBatchSize)
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
prefixAnnouncement(full))
store.mu.Lock()
defer store.mu.Unlock()
if store.prefixes[extraPrefix] {
t.Errorf("%s was written after the stop deadline", extraPrefix)
}
}
// tableWriteBlockingStore is a blockingStore whose writes of the routing
// table's changes also wait until the test calls releaseWrites.
type tableWriteBlockingStore struct {
*blockingStore
}
func (s tableWriteBlockingStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
s.waitForRelease()
return s.mockStore.WriteRouteChanges(changes, newPeers)
}
// TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress holds the write
// of the routing table's changes that a full batch starts, then hands the
// prefix handler one more prefix and stops it with a deadline that passes
// meanwhile. Stop must log as not written both the change the held write took
// and the one since.
func TestPrefixHandlerStopCountsTheTableChangesOfAWriteInProgress(t *testing.T) {
t.Parallel()
const extraPrefix = "198.51.100.0/24"
var logs bytes.Buffer
blocking := newBlockingStore()
h := NewPrefixHandler(tableWriteBlockingStore{blockingStore: blocking},
newErrorLogger(&logs), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
stopWhileAWriteIsBlocked(t, blocking, h, h.done,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
prefixAnnouncement([]string{extraPrefix}))
if logged := lastLogged(t, &logs); logged.TableChanges != 2 {
t.Errorf("Stop logged %d table changes not written, want 2",
logged.TableChanges)
}
}
@@ -353,7 +476,7 @@ func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
var logs bytes.Buffer
store := newBlockingStore()
h := NewASHandler(store, newErrorLogger(&logs))
h := newASHandler(store, newErrorLogger(&logs), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{{Path: fill}},
@@ -372,6 +495,29 @@ func TestASHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
}
}
// TestASHandlerSkipsAFullBatchAfterTheStopDeadline hands the AS handler a full
// batch while a write is held, so the batch's wake-up of the flush goroutine
// waits. The stop deadline passes meanwhile. When the write ends, the flush
// goroutine must stop instead of writing the batch.
func TestASHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
full := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNB}, asnBatchSize)}
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{{Path: fill}},
&ristypes.RISMessage{Path: full})
store.mu.Lock()
defer store.mu.Unlock()
if store.asns[testASNB] {
t.Errorf("AS%d was written after the stop deadline", testASNB)
}
}
func TestPeerHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
@@ -380,7 +526,7 @@ func TestPeerHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
var logs bytes.Buffer
store := newBlockingStore()
h := NewPeerHandler(store, newErrorLogger(&logs))
h := newPeerHandler(store, newErrorLogger(&logs), longerThanAnyTest)
stopWhileAWriteIsBlocked(t, store, h, h.done,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
&ristypes.RISMessage{Peer: otherPeerIP})
@@ -398,6 +544,30 @@ func TestPeerHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
}
}
// TestPeerHandlerSkipsAFullBatchAfterTheStopDeadline hands the peer handler a
// full batch while a write is held, so the batch's wake-up of the flush
// goroutine waits. The stop deadline passes meanwhile. When the write ends, the
// flush goroutine must stop instead of writing the batch.
func TestPeerHandlerSkipsAFullBatchAfterTheStopDeadline(t *testing.T) {
t.Parallel()
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
stopWhileAWriteIsBlocked(t, store, h, h.done,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
slices.Repeat([]*ristypes.RISMessage{{Peer: otherPeerIP}},
peerBatchSize)...)
store.mu.Lock()
defer store.mu.Unlock()
if store.peers[otherPeerIP] {
t.Errorf("peer %s was written after the stop deadline", otherPeerIP)
}
}
// TestPeeringHandlerStopCountsTheWriteInProgress hands a running peering
// handler one AS path and stops it with a deadline that passes while store
// holds Stop's last write, of that path's peering. Stop must log that path as
@@ -437,6 +607,35 @@ func TestPeeringHandlerStopCountsTheWriteInProgress(t *testing.T) {
}
}
// TestPeeringHandlerStartsNoWriteAfterTheStopDeadline has the peering handler's
// timer start a write that store holds, and hands the handler one more AS path
// meanwhile. The stop deadline passes, and a tick of the timer waits. When the
// write ends, the processing goroutine must stop instead of writing the path.
func TestPeeringHandlerStartsNoWriteAfterTheStopDeadline(t *testing.T) {
t.Parallel()
// The timer fires many times while Stop waits for its deadline.
const processInterval = 10 * time.Millisecond
store := newBlockingStore()
h := newPeeringHandler(store, logger.New(), processInterval)
stopWhileAWriteIsBlocked(t, store, h, h.done,
[]*ristypes.RISMessage{
{Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}}},
},
&ristypes.RISMessage{
Path: ristypes.ASPath{Sequence: []int{testASNC, testASND}},
})
store.mockStore.mu.Lock()
defer store.mockStore.mu.Unlock()
if store.Peerings[fmt.Sprintf("%d_%d", testASNC, testASND)] {
t.Errorf("the peering of AS%d and AS%d was written after the stop "+
"deadline", testASNC, testASND)
}
}
// prefixAnnouncement returns a message in which testPeerIP announces prefixes
// with origin AS testASNA.
func prefixAnnouncement(prefixes []string) *ristypes.RISMessage {
@@ -455,7 +654,7 @@ func TestPrefixHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New())
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
@@ -474,7 +673,7 @@ func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := NewASHandler(store, logger.New())
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
handleWhileAWriteIsBlocked(t, store, h,
[]*ristypes.RISMessage{{Path: fill}},
@@ -495,7 +694,7 @@ func TestPeerHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := NewPeerHandler(store, logger.New())
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
handleWhileAWriteIsBlocked(t, store, h,
slices.Repeat([]*ristypes.RISMessage{{Peer: testPeerIP}}, peerBatchSize),
&ristypes.RISMessage{Peer: otherPeerIP})
@@ -515,7 +714,7 @@ func TestPrefixHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
const extraPrefix = "198.51.100.0/24"
store := newBlockingStore()
h := NewPrefixHandler(store, logger.New())
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
toMax := slices.Repeat([]string{testV4Prefix}, prefixBatchMaxSize)
handleAtMaxBatchSize(t, store, h,
@@ -536,7 +735,7 @@ func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
t.Parallel()
store := newBlockingStore()
h := NewASHandler(store, logger.New())
h := newASHandler(store, logger.New(), longerThanAnyTest)
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
toMax := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchMaxSize)}
handleAtMaxBatchSize(t, store, h,
@@ -553,13 +752,17 @@ func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
}
}
// TestPeerHandlerWaitsAtTheMaximumBatchSize must not have its flush timer fire.
// A timer flush during the fill would take only part of it. The batch would
// then reach its maximum size before the test's last message, and the test
// would hang.
func TestPeerHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
t.Parallel()
const otherPeerIP = "2001:db8:ffff::2"
store := newBlockingStore()
h := NewPeerHandler(store, logger.New())
h := newPeerHandler(store, logger.New(), longerThanAnyTest)
message := []*ristypes.RISMessage{{Peer: testPeerIP}}
handleAtMaxBatchSize(t, store, h,
slices.Repeat(message, peerBatchSize),
@@ -592,7 +795,10 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
name: "prefix",
batchSize: prefixBatchSize,
handle: func(n int) chan struct{} {
h := &PrefixHandler{batchFullCh: make(chan struct{}, 1)}
h := &PrefixHandler{
batchFullCh: make(chan struct{}, 1),
table: routetable.New(routetable.Params{}),
}
h.HandleMessage(prefixAnnouncement(
slices.Repeat([]string{testV4Prefix}, n)))
+10 -32
View File
@@ -56,7 +56,6 @@ type PeerHandler struct {
// is &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when peerBatch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
@@ -75,19 +74,27 @@ type peerUpdate struct {
// periodically flushes accumulated peer updates to the database. The handler
// should be stopped by calling Stop when it is no longer needed.
func NewPeerHandler(db database.Store, logger *logger.Logger) *PeerHandler {
return newPeerHandler(db, logger, peerBatchTimeout)
}
// newPeerHandler is NewPeerHandler with the flush timer's interval,
// batchTimeout, in place of peerBatchTimeout. A test that the timer must not
// disturb gives an interval longer than the test runs.
func newPeerHandler(
db database.Store, logger *logger.Logger, batchTimeout time.Duration,
) *PeerHandler {
h := &PeerHandler{
db: db,
logger: logger,
peerBatch: make([]peerUpdate, 0, peerBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -173,33 +180,6 @@ func (h *PeerHandler) Stop(ctx context.Context) {
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PeerHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(peerBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= peerBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending updates and leaves an empty batch in their
// place for HandleMessage to fill. They count in writing until flushBatch has
// written them.
@@ -254,6 +234,4 @@ func (h *PeerHandler) flushBatch() {
h.mu.Lock()
h.writing = 0
h.mu.Unlock()
h.lastFlush = time.Now()
}
+27 -4
View File
@@ -62,6 +62,15 @@ type PeeringHandler struct {
// periodically processes accumulated AS paths into peering records. The
// handler begins processing immediately upon creation.
func NewPeeringHandler(db database.Store, logger *logger.Logger) *PeeringHandler {
return newPeeringHandler(db, logger, peeringProcessInterval)
}
// newPeeringHandler is NewPeeringHandler with the processing timer's interval,
// processInterval, in place of peeringProcessInterval. A test gives a short
// one to have the timer start a write.
func newPeeringHandler(
db database.Store, logger *logger.Logger, processInterval time.Duration,
) *PeeringHandler {
h := &PeeringHandler{
db: db,
logger: logger,
@@ -71,7 +80,7 @@ func NewPeeringHandler(db database.Store, logger *logger.Logger) *PeeringHandler
}
// Start the periodic processing goroutine
go h.processLoop()
go h.processLoop(processInterval)
return h
}
@@ -163,15 +172,29 @@ func (h *PeeringHandler) Stop(ctx context.Context) {
}
}
// processLoop runs periodically to process AS paths into peerings, and once
// processLoop processes AS paths into peerings every processInterval, and once
// more on Stop.
func (h *PeeringHandler) processLoop() {
func (h *PeeringHandler) processLoop(processInterval time.Duration) {
defer close(h.done)
ticker := time.NewTicker(peeringProcessInterval)
ticker := time.NewTicker(processInterval)
defer ticker.Stop()
for {
// Stop goes first. A write can run past the stop deadline while a
// tick waits beside Stop's context. The select below picks one of
// them at random, but past the deadline no write may start.
select {
case ctx := <-h.stopCh:
// Final processing, unless the stop deadline has passed
if ctx.Err() == nil {
h.processPeerings()
}
return
default:
}
select {
case <-ticker.C:
h.processPeerings()
+168 -368
View File
@@ -2,10 +2,8 @@ package routewatch
import (
"context"
"net"
"net/netip"
"slices"
"strings"
"sync"
"time"
@@ -13,6 +11,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
const (
@@ -27,64 +26,71 @@ const (
prefixBatchSize = 25000
// prefixBatchMaxSize is the most prefix updates the batch holds while the
// flush goroutine writes the previous one: ten batches, about 60 MiB
// flush goroutine writes the previous one: ten batches, about 15 MiB
// (README "Memory"). At this size HandleMessage waits until the flush
// goroutine takes the batch. On top of these, the handler holds at most
// prefixBatchSize updates, about 6 MiB, that a flush kept for the peers
// whose route delete failed (held).
// goroutine takes the batch.
prefixBatchMaxSize = 250000
// prefixBatchTimeout is the maximum time to wait before flushing a batch
// DO NOT reduce this timeout - larger batches are more efficient
prefixBatchTimeout = 1 * time.Second
// IP version constants
ipv4Version = 4
ipv6Version = 6
// maxTableChangesPerWrite is the most changed prefix and origin AS pairs
// of the routing table that one flush writes; the rest wait for the next
// flushes. The change of one pair takes about 430 bytes of memory and 15
// microseconds to write. So a write holds about 9 MB and takes about a
// third of the one-second flush interval, which leaves the rest to the
// batch. The feed peaks at about 5,000 updates a second, each changing at
// most one pair. So the million pairs that a full-table peer's session
// going down changes are written in about a minute.
maxTableChangesPerWrite = 20000
)
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the
// database. Routes are added on announcement and deleted on withdrawal or on an
// announcement whose AS path is empty or holds AS 0 anywhere, and all routes of
// a peer are deleted when its BGP session goes down.
// PrefixHandler keeps the routing table in memory current from the feed. It
// applies each update to the table as it handles it. An announcement gives
// the peer a route to the prefix. A withdrawal removes the peer's route, as
// does an announcement whose AS path is empty or holds AS 0 anywhere. A peer
// whose BGP session goes down leaves every prefix.
//
// 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 {
db database.Store
logger *logger.Logger
metrics *metrics.Tracker
table *routetable.Table
// Batching. mu guards batch, held, peersDown, writing and deleting only:
// the flush goroutine takes them under mu and writes them after releasing
// Batching. mu guards batch, writing and writingTableChanges only: the
// flush goroutine takes them under mu and writes them after releasing
// mu, so HandleMessage waits for a database write only while batch is at
// prefixBatchMaxSize.
mu sync.Mutex
batch []prefixUpdate
// held holds the updates that the peers whose route delete failed in the
// last flush sent after their session went down, for the next flush to
// write after it retries the delete. They are not in batch, so they do
// not count toward a full batch: a delete that keeps failing is retried
// at the pace of the flush timer.
held []prefixUpdate
// peersDown holds, each once, the peers whose BGP session went down
// since the batch was taken, and those whose route delete failed in the
// last flush.
peersDown []string
batch []seenPrefix
// writing is how many updates the flush goroutine took and has not
// finished writing.
writing int
// deleting is how many peers from peersDown the flush goroutine took and
// has not yet deleted the routes of.
deleting int
// writingTableChanges is how many changes the flush goroutine took from
// the routing table and has not finished writing.
writingTableChanges int
// batchTaken is signalled when the flush goroutine takes batch; its L is
// &mu.
batchTaken sync.Cond
batchFullCh chan struct{} // wakes the flush goroutine when batch is full
lastFlush time.Time // used only by the flush goroutine
// stopCh hands Stop's context to the flush goroutine. It holds one, so
// Stop does not wait for a write in progress to hand it over.
stopCh chan context.Context
done chan struct{} // closed when the flush goroutine returns
}
// seenPrefix is what the batch keeps of an update: the prefix it names and its
// time. A flush needs only these to store each new prefix's first-seen time.
type seenPrefix struct {
prefix string
timestamp time.Time
}
// prefixUpdate is one prefix of an update message, as applyToTable applies it
// to the routing table.
type prefixUpdate struct {
prefix string
originASN int
@@ -97,21 +103,45 @@ type prefixUpdate struct {
endsInSet bool
}
// NewPrefixHandler creates a new batched prefix tracking handler
func NewPrefixHandler(db database.Store, logger *logger.Logger) *PrefixHandler {
// announcesRoute says whether u gives the peer a route to the prefix: an
// announcement whose path gives an origin, or ends in an AS set of several
// members, which gives origin 0 (none). Any other update removes the peer's
// route for the prefix: a withdrawal, or an announcement whose path is empty
// or holds AS 0 anywhere (the decoder empties such a path). Such an
// announcement replaces the peer's route, as any announcement does, with none
// (RFC 7607 treats AS 0 in a path as a withdrawal).
func (u prefixUpdate) announcesRoute() bool {
return u.messageType == "announcement" && (u.originASN > 0 || u.endsInSet)
}
// NewPrefixHandler creates a new batched prefix tracking handler, which applies
// the updates to table, the routing table read from db at startup.
func NewPrefixHandler(
db database.Store, logger *logger.Logger, table *routetable.Table,
) *PrefixHandler {
return newPrefixHandler(db, logger, table, prefixBatchTimeout)
}
// newPrefixHandler is NewPrefixHandler with the flush timer's interval,
// batchTimeout, in place of prefixBatchTimeout. A test that the timer must not
// disturb gives an interval longer than the test runs.
func newPrefixHandler(
db database.Store, logger *logger.Logger, table *routetable.Table,
batchTimeout time.Duration,
) *PrefixHandler {
h := &PrefixHandler{
db: db,
logger: logger,
batch: make([]prefixUpdate, 0, prefixBatchSize),
table: table,
batch: make([]seenPrefix, 0, prefixBatchSize),
batchFullCh: make(chan struct{}, 1),
lastFlush: time.Now(),
stopCh: make(chan context.Context, 1),
done: make(chan struct{}),
}
h.batchTaken.L = &h.mu
// Start the flush timer goroutine
go h.flushLoop()
go flushLoop(batchTimeout, h.batchFullCh, h.stopCh, h.done, h.flushBatch)
return h
}
@@ -140,7 +170,7 @@ func (h *PrefixHandler) QueueCapacity() int {
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
if msg.Type == ristypes.MessageTypeState {
if msg.State == ristypes.StateDown {
h.peerSessionDown(msg.Peer)
h.table.PeerDown(msg.Peer)
}
return
@@ -165,18 +195,21 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
h.batchTaken.Wait()
}
// Withdrawals are queued before announcements, because the batch keeps
// each peer's last update for a prefix: a message that lists a prefix as
// both withdrawn and announced keeps the route (RFC 4271 section 4.3).
// A withdrawal names only the prefix and peer: the message's AS path, and
// so its origin AS, belongs to the announced prefixes.
// Withdrawals are applied to the routing table before announcements, so
// a message that lists a prefix as both withdrawn and announced keeps the
// route (RFC 4271 section 4.3). A withdrawal names only the prefix and
// peer: the message's AS path, and so its origin AS, belongs to the
// announced prefixes.
for _, prefix := range msg.Withdrawals {
h.batch = append(h.batch, prefixUpdate{
update := prefixUpdate{
prefix: canonicalPrefix(prefix),
peer: msg.Peer,
messageType: "withdrawal",
timestamp: timestamp,
})
}
h.batch = append(h.batch, seenPrefix{update.prefix, update.timestamp})
h.applyToTable(update)
// Record withdrawal in metrics
if h.metrics != nil {
h.metrics.RecordWithdrawal()
@@ -185,7 +218,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
for _, announcement := range msg.Announcements {
for _, prefix := range announcement.Prefixes {
h.batch = append(h.batch, prefixUpdate{
update := prefixUpdate{
prefix: canonicalPrefix(prefix),
originASN: originASN,
peer: msg.Peer,
@@ -193,7 +226,10 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
timestamp: timestamp,
path: path,
endsInSet: len(msg.Path.Set) > 0,
})
}
h.batch = append(h.batch, seenPrefix{update.prefix, update.timestamp})
h.applyToTable(update)
// Record announcement in metrics
if h.metrics != nil {
h.metrics.RecordAnnouncement()
@@ -212,12 +248,13 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
}
}
// Stop stops the flush goroutine, which writes the updates and peer downs the
// handler holds before it returns, and waits for it until ctx ends. Once ctx
// has ended the flush goroutine skips that last write, and a write still
// running is lost when the process exits. A peer whose route delete fails in
// the last write is not retried either. Stop logs how many updates are not
// written and how many peer downs have not had their routes deleted.
// Stop stops the flush goroutine, which makes one last flush before it returns,
// and waits for it until ctx ends. That flush writes 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
@@ -227,341 +264,156 @@ func (h *PrefixHandler) Stop(ctx context.Context) {
}
h.mu.Lock()
unwritten := h.writing + len(h.batch) + len(h.held)
peersDown := h.deleting + len(h.peersDown)
unwritten := h.writing + len(h.batch)
tableChanges := h.writingTableChanges + h.table.ChangeCount()
h.mu.Unlock()
if unwritten == 0 && peersDown == 0 {
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, "peers_down", peersDown)
"its updates", "updates", unwritten, "table_changes", tableChanges)
return
}
h.logger.Error("The prefix handler stopped without deleting the routes "+
"of peers whose session went down", "updates", unwritten,
"peers_down", peersDown)
h.logger.Error("The prefix handler stopped before it wrote everything",
"updates", unwritten, "table_changes", tableChanges)
}
// peerSessionDown notes that the peer's BGP session went down, so that the next
// flush deletes all of the peer's live routes before it writes the batch. The
// peer's updates still in the batch or held came before the down, which
// replaces them, so they are dropped; updates the peer sends later stay in the
// batch and are written after the delete. A peer already in peersDown is not
// added again: one delete removes all of its routes.
func (h *PrefixHandler) peerSessionDown(peer string) {
h.mu.Lock()
defer h.mu.Unlock()
isPeers := func(update prefixUpdate) bool { return update.peer == peer }
h.batch = slices.DeleteFunc(h.batch, isPeers)
h.held = slices.DeleteFunc(h.held, isPeers)
if !slices.Contains(h.peersDown, peer) {
h.peersDown = append(h.peersDown, peer)
}
}
// flushLoop runs in a goroutine, the only one that writes the batch: it
// flushes when the batch is full, periodically, and once more on Stop.
func (h *PrefixHandler) flushLoop() {
defer close(h.done)
ticker := time.NewTicker(prefixBatchTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if time.Since(h.lastFlush) >= prefixBatchTimeout {
h.flushBatch()
}
case <-h.batchFullCh:
h.flushBatch()
case ctx := <-h.stopCh:
// Final flush, unless the stop deadline has passed
if ctx.Err() == nil {
h.flushBatch()
}
return
}
}
}
// takeBatch returns the pending updates, the held ones first, and the peers in
// peersDown, and leaves an empty batch, no held updates and no peers in their
// takeBatch returns the pending updates and leaves an empty batch in their
// place for HandleMessage to fill. The updates count in writing until
// flushBatch has written them, and the peers in deleting until their routes
// are deleted.
func (h *PrefixHandler) takeBatch() ([]prefixUpdate, []string) {
// flushBatch has written them.
func (h *PrefixHandler) takeBatch() []seenPrefix {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.held) == 0 && len(h.batch) == 0 && len(h.peersDown) == 0 {
return nil, nil
}
batch, peersDown := h.batch, h.peersDown
if len(h.held) > 0 {
batch = slices.Concat(h.held, h.batch)
if len(h.batch) == 0 {
return nil
}
batch := h.batch
h.writing = len(batch)
h.deleting = len(peersDown)
h.held = nil
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
h.peersDown = nil
h.batch = make([]seenPrefix, 0, prefixBatchSize)
h.batchTaken.Broadcast()
return batch, peersDown
return batch
}
// flushBatch takes the pending batch and writes it to the database, without
// holding the mutex during the write.
// flushBatch writes the routing table's changes, then takes the pending batch
// and stores the prefixes it names, without holding the mutex during the
// writes.
func (h *PrefixHandler) flushBatch() {
batch, peersDown := h.takeBatch()
if len(batch) == 0 && len(peersDown) == 0 {
h.writeTableChanges()
batch := h.takeBatch()
if len(batch) == 0 {
return
}
startTime := time.Now()
batchSize := len(batch)
// The routes of a peer whose session went down are deleted first: the
// batch holds only the updates the peer sent after the down. A peer whose
// delete failed is kept, with those updates, for the next flush to retry.
failedPeers := h.deleteLiveRoutesOfPeers(peersDown)
if len(failedPeers) > 0 {
batch = h.keepForNextFlush(batch, failedPeers)
}
// For each prefix and peer, keep only the last update the feed delivered.
// Updates from different peers for the same prefix are all kept. Arrival
// order decides, not the timestamp: timestamps are whole seconds, so
// updates within one second carry the same one.
type prefixAndPeer struct{ prefix, peer string }
lastUpdates := make(map[prefixAndPeer]prefixUpdate)
// Each prefix the batch names, announced or withdrawn, with the earliest
// timestamp among all of its updates, not only each peer's last one: the
// prefix tables store only first_seen, written when the prefix is new and
// never changed after.
// timestamp among its updates: the prefix tables store only first_seen,
// written when the prefix is new and never changed after.
prefixesToUpdate := make(map[string]time.Time)
for _, update := range batch {
lastUpdates[prefixAndPeer{update.prefix, update.peer}] = update
seen, exists := prefixesToUpdate[update.prefix]
if !exists || update.timestamp.Before(seen) {
prefixesToUpdate[update.prefix] = update.timestamp
}
}
// Collect routes to upsert and delete
var (
routesToUpsert []*database.LiveRoute
routesToDelete []database.LiveRouteDeletion
)
for _, update := range lastUpdates {
// An announcement is stored when its path gives an origin, or when it
// ends in an AS set of several members: that route is stored with
// origin 0 (none). Any other update deletes the peer's route for the
// prefix: a withdrawal, or an announcement whose path is empty or
// holds AS 0 anywhere (the decoder empties such a path). Such an
// announcement replaces the peer's route, as any announcement does,
// with none (RFC 7607 treats AS 0 in a path as a withdrawal).
if update.messageType == "announcement" &&
(update.originASN > 0 || update.endsInSet) {
// Create live route for batch upsert
route := h.createLiveRoute(update)
if route != nil {
routesToUpsert = append(routesToUpsert, route)
}
} else {
// Parse CIDR to get IP version
_, ipVersion, err := parseCIDR(update.prefix)
if err != nil {
h.logger.Error("Failed to parse CIDR for route deletion",
"prefix", update.prefix, "error", err)
continue
}
// Create deletion record for batch delete
routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
Prefix: update.prefix,
PeerIP: update.peer,
IPVersion: ipVersion,
})
}
err := h.db.UpdatePrefixesBatch(prefixesToUpdate)
if err != nil {
h.logger.Error("Failed to update prefix batch",
"error", err, "count", len(prefixesToUpdate))
}
successCount := h.writeBatch(routesToUpsert, routesToDelete, prefixesToUpdate)
h.mu.Lock()
h.writing = 0
h.mu.Unlock()
elapsed := time.Since(startTime)
h.logger.Debug("Flushed prefix batch",
"batch_size", batchSize,
"peers_down", len(peersDown),
"batch_size", len(batch),
"unique_prefixes", len(prefixesToUpdate),
"success", successCount,
"duration_ms", elapsed.Milliseconds(),
"duration_ms", time.Since(startTime).Milliseconds(),
)
h.lastFlush = time.Now()
}
// deleteLiveRoutesOfPeers deletes all live routes of each of peers, the peers
// whose BGP session went down, and returns those whose delete failed. Most
// downs come from peers that have no routes, so only a delete that removed
// routes is logged. A peer whose delete succeeded no longer counts in deleting;
// a failed one counts until keepForNextFlush puts it back in peersDown.
func (h *PrefixHandler) deleteLiveRoutesOfPeers(peers []string) []string {
var failed []string
// applyToTable applies update to the routing table in memory: as an
// announcement when it gives the peer a route, otherwise as a withdrawal. A
// prefix that does not parse is logged and left out. Each announcement counts
// in the IPv4 or IPv6 route update rate.
func (h *PrefixHandler) applyToTable(update prefixUpdate) {
prefix, err := netip.ParsePrefix(update.prefix)
if err != nil {
h.logger.Error("Failed to parse prefix",
"prefix", update.prefix, "error", err)
for _, peer := range peers {
deleted, err := h.db.DeleteLiveRoutesOfPeer(peer)
if err != nil {
h.logger.Error(
"Failed to delete the live routes of a peer whose session "+
"went down; the next flush retries",
"peer", peer, "routes_deleted", deleted, "error", err)
failed = append(failed, peer)
continue
}
h.mu.Lock()
h.deleting--
h.mu.Unlock()
if deleted > 0 {
h.logger.Info(
"Deleted the live routes of a peer whose session went down",
"peer", peer, "routes", deleted)
}
return
}
return failed
}
if !update.announcesRoute() {
h.table.Withdraw(prefix, update.peer)
// keepForNextFlush puts the peers whose route delete failed back in peersDown,
// and their updates from batch in held, so that the next flush retries each
// delete before it writes the peer's updates; written now, they would be
// deleted by the retry. The updates of a peer whose session went down again
// since the batch was taken came before that down, so they are dropped, as
// peerSessionDown drops them, and the peer is already in peersDown. It returns
// the updates of the other peers, for this flush to write.
//
// While a delete keeps failing, the held updates would grow with every flush,
// so only the last prefixBatchSize of them are kept and the older ones are
// dropped. This never leaves in place a route the peer withdrew: a prefix with
// a kept update keeps its last one, and the retry deletes all of the peer's
// routes, so a prefix whose updates were all dropped has no route from the peer
// until the peer announces it again.
func (h *PrefixHandler) keepForNextFlush(
batch []prefixUpdate, failedPeers []string,
) []prefixUpdate {
var kept, toWrite []prefixUpdate
return
}
for _, update := range batch {
if slices.Contains(failedPeers, update.peer) {
kept = append(kept, update)
if h.metrics != nil {
if prefix.Addr().Is4() {
h.metrics.RecordIPv4Update()
} else {
toWrite = append(toWrite, update)
h.metrics.RecordIPv6Update()
}
}
h.table.Announce(prefix, update.originASN, routetable.Route{
Peer: update.peer,
ASPath: update.path,
Time: update.timestamp,
})
}
// writeTableChanges writes at most maxTableChangesPerWrite changes of the
// routing table in memory, and every peer the table numbered since the last
// write.
// When the write fails, they go back to the table, so that a later write writes
// them again, each pair with its content then.
func (h *PrefixHandler) writeTableChanges() {
h.mu.Lock()
changes, newPeers := h.table.TakeChanges(maxTableChangesPerWrite)
h.writingTableChanges = len(changes)
h.mu.Unlock()
if len(changes) == 0 && len(newPeers) == 0 {
return
}
err := h.db.WriteRouteChanges(changes, newPeers)
h.mu.Lock()
defer h.mu.Unlock()
kept = slices.DeleteFunc(kept, func(update prefixUpdate) bool {
return slices.Contains(h.peersDown, update.peer)
})
h.writingTableChanges = 0
if len(kept) > prefixBatchSize {
h.logger.Error(
"Dropped the oldest updates of peers whose route delete failed",
"peers", failedPeers, "dropped", len(kept)-prefixBatchSize)
if err != nil {
h.logger.Error("Failed to write the routing table's changes; the next "+
"flush retries", "changes", len(changes), "new_peers", len(newPeers),
"error", err)
// A copy, so that the dropped updates are freed.
kept = slices.Clone(kept[len(kept)-prefixBatchSize:])
h.table.PutBackChanges(changes, newPeers)
}
// takeBatch left held empty, and only this flush fills it. The kept
// updates now count in held, not in writing, and the failed peers in
// peersDown, not in deleting.
h.held = kept
h.writing = len(toWrite)
h.deleting = 0
for _, peer := range failedPeers {
if !slices.Contains(h.peersDown, peer) {
h.peersDown = append(h.peersDown, peer)
}
}
return toWrite
}
// writeBatch writes the collected route upserts, route deletions and prefix
// updates to the database and returns how many routes it wrote.
func (h *PrefixHandler) writeBatch(
routesToUpsert []*database.LiveRoute,
routesToDelete []database.LiveRouteDeletion,
prefixesToUpdate map[string]time.Time,
) int {
successCount := 0
if len(routesToUpsert) > 0 {
err := h.db.UpsertLiveRouteBatch(routesToUpsert)
if err != nil {
h.logger.Error("Failed to upsert route batch",
"error", err, "count", len(routesToUpsert))
} else {
successCount += len(routesToUpsert)
}
}
if len(routesToDelete) > 0 {
err := h.db.DeleteLiveRouteBatch(routesToDelete)
if err != nil {
h.logger.Error("Failed to delete route batch",
"error", err, "count", len(routesToDelete))
} else {
successCount += len(routesToDelete)
}
}
// Update prefix tables
if len(prefixesToUpdate) > 0 {
err := h.db.UpdatePrefixesBatch(prefixesToUpdate)
if err != nil {
h.logger.Error("Failed to update prefix batch",
"error", err, "count", len(prefixesToUpdate))
}
}
return successCount
}
// canonicalPrefix returns prefix in the text form net/netip prints, the form
// the IP lookup builds when it looks a prefix up. The feed sends IPv6
// the prefix tables store and the IP lookup reads them in. The feed sends IPv6
// announcements compressed ("2001:db8::/32") but IPv6 withdrawals uncompressed
// ("2001:db8:0:0:0:0:0:0/32"); stored as received, a withdrawal would not match
// the route its announcement stored. A prefix that does not parse is returned
// unchanged, and the batch flush reports it.
// ("2001:db8:0:0:0:0:0:0/32"). A prefix that does not parse is returned
// unchanged, and applyToTable logs it.
func canonicalPrefix(prefix string) string {
p, err := netip.ParsePrefix(prefix)
if err != nil {
@@ -570,55 +422,3 @@ func canonicalPrefix(prefix string) string {
return p.Masked().String()
}
// parseCIDR returns the mask length and the IP version of a prefix string
func parseCIDR(prefix string) (int, int, error) {
_, ipNet, err := net.ParseCIDR(prefix)
if err != nil {
return 0, 0, err
}
ones, _ := ipNet.Mask.Size()
if strings.Contains(prefix, ":") {
return ones, ipv6Version, nil
}
return ones, ipv4Version, nil
}
// createLiveRoute creates a LiveRoute from a prefix update
func (h *PrefixHandler) createLiveRoute(update prefixUpdate) *database.LiveRoute {
// Parse CIDR to get mask length
maskLength, ipVersion, err := parseCIDR(update.prefix)
if err != nil {
h.logger.Error("Failed to parse CIDR",
"prefix", update.prefix,
"error", err,
)
return nil
}
// Track route update metrics
if h.metrics != nil {
if ipVersion == ipv4Version {
h.metrics.RecordIPv4Update()
} else {
h.metrics.RecordIPv6Update()
}
}
// Create live route record
liveRoute := &database.LiveRoute{
Prefix: update.prefix,
MaskLength: maskLength,
IPVersion: ipVersion,
OriginASN: update.originASN,
PeerIP: update.peer,
ASPath: update.path,
NextHop: update.peer, // Using peer as next hop
LastUpdated: update.timestamp,
}
return liveRoute
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,607 @@
package routewatch
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"net/netip"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// More peers and prefixes for the tests of the routing table in memory.
const (
secondPeerIP = "2001:db8:ffff::2"
thirdPeerIP = "2001:db8:ffff::3"
fourthPeerIP = "192.0.2.4"
fifthPeerIP = "192.0.2.5"
otherV4Prefix = "198.51.100.0/24"
setV4Prefix = "203.0.113.0/24"
// firstManyPrefix is the first prefix announceManyPrefixes announces.
firstManyPrefix = "10.0.0.0/32"
)
// tableWriteTimeout is how long a test waits for the flush timer to write the
// routing table's changes: a few ticks of it.
const tableWriteTimeout = 5 * prefixBatchTimeout
// tableCase is one test of the routing table in memory: the messages handed to
// the prefix handler, and for each prefix the peers that carry it with each
// origin AS after them.
type tableCase struct {
name string
messages []*ristypes.RISMessage
want map[string]map[int][]string
}
// TestPrefixHandlerAppliesUpdatesToTheRoutingTable hands the prefix handler
// announcements, withdrawals and a peer's session going down.
func TestPrefixHandlerAppliesUpdatesToTheRoutingTable(t *testing.T) {
t.Parallel()
announceAndWithdraw := peerAnnouncement(testPeerIP, testV4Prefix)
announceAndWithdraw.Withdrawals = []string{testV4Prefix}
checkTableCases(t, []tableCase{
{
name: "an announcement",
messages: []*ristypes.RISMessage{peerAnnouncement(testPeerIP, testV4Prefix)},
want: map[string]map[int][]string{testV4Prefix: {testASNB: {testPeerIP}}},
},
{
name: "a withdrawal",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
peerWithdrawal(testPeerIP, testV4Prefix),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
// RFC 4271 section 4.3: the announcement counts, not the withdrawal.
name: "a message that announces and withdraws the prefix",
messages: []*ristypes.RISMessage{announceAndWithdraw},
want: map[string]map[int][]string{testV4Prefix: {testASNB: {testPeerIP}}},
},
{
name: "a peer's session going down",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix, testV6Prefix),
peerAnnouncement(secondPeerIP, testV4Prefix),
sessionDown(),
},
want: map[string]map[int][]string{
testV4Prefix: {testASNB: {secondPeerIP}},
testV6Prefix: {},
},
},
{
name: "an announcement after the peer's session went down",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
sessionDown(),
peerAnnouncement(testPeerIP, otherV4Prefix),
},
want: map[string]map[int][]string{
testV4Prefix: {},
otherV4Prefix: {testASNB: {testPeerIP}},
},
},
})
}
// 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
// whose path ends in an AS set of several members is kept with origin 0.
func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) {
t.Parallel()
announceWithPath := func(path ristypes.ASPath) *ristypes.RISMessage {
msg := peerAnnouncement(testPeerIP, testV4Prefix)
msg.Path = path
return msg
}
checkTableCases(t, []tableCase{
{
name: "a path whose last AS is 0",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
announceWithPath(ristypes.ASPath{Sequence: []int{testASNA, 0}}),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
name: "an empty path",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
announceWithPath(ristypes.ASPath{}),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
name: "a path with AS 0 before its end",
messages: []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix),
announceWithPath(decodePath(t, testASNA, 0, testASNB)),
},
want: map[string]map[int][]string{testV4Prefix: {}},
},
{
name: "a path that ends in an AS set",
messages: []*ristypes.RISMessage{
announceWithPath(decodePath(t, testASNA, []int{testASNC, testASND})),
},
want: map[string]map[int][]string{testV4Prefix: {0: {testPeerIP}}},
},
})
}
// TestPrefixHandlerKeepsThePathOfAPathEndingInASet announces a prefix with AS
// paths that end in an AS set. Any member of a set of several may be the
// origin, so the routing table keeps the route with origin 0 (none); a set of
// one member counts as that AS. The kept AS path lists the set's members after
// the rest of the path.
func TestPrefixHandlerKeepsThePathOfAPathEndingInASet(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path []any
wantOrigin int
wantPath []int
}{
{
name: "a set of several members",
path: []any{testASNA, testASNB, []int{testASNC, testASND}},
wantPath: []int{testASNA, testASNB, testASNC, testASND},
},
{
name: "a set of one member",
path: []any{testASNA, testASNB, []int{testASNC}},
wantOrigin: testASNC,
wantPath: []int{testASNA, testASNB, testASNC},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
msg := peerAnnouncement(testPeerIP, testV4Prefix)
msg.Path = decodePath(t, tt.path...)
h := prefixHandlerWithoutTimer(newMockStore())
h.HandleMessage(msg)
origins := h.table.Origins(netip.MustParsePrefix(testV4Prefix))
if len(origins) != 1 || origins[0].ASN != tt.wantOrigin ||
len(origins[0].Routes) != 1 ||
!slices.Equal(origins[0].Routes[0].ASPath, tt.wantPath) {
t.Errorf("Origins(%s) = %+v, want one route with origin AS%d "+
"and AS path %v", testV4Prefix, origins, tt.wantOrigin,
tt.wantPath)
}
})
}
}
// checkTableCases hands the messages of each case to a new prefix handler and
// checks its routing table right after, with no flush.
func checkTableCases(t *testing.T, tests []tableCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := prefixHandlerWithoutTimer(newMockStore())
for _, msg := range tt.messages {
h.HandleMessage(msg)
}
for prefix, want := range tt.want {
got := tableOrigins(h.table, prefix)
if !reflect.DeepEqual(got, want) {
t.Errorf("peers of %s by origin AS = %v, want %v",
prefix, got, want)
}
}
})
}
}
// tableWritesStore is a real database that hands each write of the routing
// table's changes to written once it is made.
type tableWritesStore struct {
*database.Database
written chan []routetable.Change
}
func (s tableWritesStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
err := s.Database.WriteRouteChanges(changes, newPeers)
s.written <- changes
return err
}
// TestPrefixHandlerWritesTheTableChangesEachSecond hands a running prefix
// handler an announcement and then its withdrawal, each far short of a full
// batch. The flush timer must write the change of each to the database.
func TestPrefixHandlerWritesTheTableChangesEachSecond(t *testing.T) {
t.Parallel()
// The two writes the test waits for, and room for one more.
const writesBuffered = 3
store := tableWritesStore{
Database: newTestDatabase(t),
written: make(chan []routetable.Change, writesBuffered),
}
h := NewPrefixHandler(store, logger.New(), routetable.New(routetable.Params{}))
defer h.Stop(t.Context())
prefix := netip.MustParsePrefix(testV4Prefix)
for _, step := range []struct {
msg *ristypes.RISMessage
wantRemoved bool
}{
{msg: peerAnnouncement(testPeerIP, testV4Prefix)},
{msg: peerWithdrawal(testPeerIP, testV4Prefix), wantRemoved: true},
} {
h.HandleMessage(step.msg)
select {
case changes := <-store.written:
if len(changes) != 1 || changes[0].Prefix != prefix ||
changes[0].OriginASN != testASNB ||
changes[0].Removed != step.wantRemoved {
t.Fatalf("wrote %+v, want the pair of %s and AS%d with "+
"removed %t", changes, prefix, testASNB, step.wantRemoved)
}
case <-time.After(tableWriteTimeout):
t.Fatalf("the routing table's change was not written within %s",
tableWriteTimeout)
}
}
}
// errTestTableWrite is the error a test store's failing write of the routing
// table's changes returns.
var errTestTableWrite = errors.New("test: the write of the table's changes failed")
// firstTableWriteFailsStore is a real database whose first write of the
// routing table's changes fails, writing nothing. writes counts the writes
// asked for.
type firstTableWriteFailsStore struct {
*database.Database
writes int
}
func (s *firstTableWriteFailsStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
s.writes++
if s.writes == 1 {
return errTestTableWrite
}
return s.Database.WriteRouteChanges(changes, newPeers)
}
// TestPrefixHandlerWritesAFailedTableWriteAgain runs two flushes into a
// database whose first write of the routing table's changes fails. Before the
// first, two new peers announce a prefix each; before the second, one of them
// announces its prefix again with another path to the same origin AS. The
// second write must hold the changes and new peers of the first, with the later
// path, so the database holds the table that is in memory.
func TestPrefixHandlerWritesAFailedTableWriteAgain(t *testing.T) {
t.Parallel()
db := newTestDatabase(t)
store := &firstTableWriteFailsStore{Database: db}
h := prefixHandlerWithoutTimer(store)
first := peerAnnouncement(testPeerIP, testV4Prefix)
h.HandleMessage(first)
h.HandleMessage(peerAnnouncement(secondPeerIP, otherV4Prefix))
h.flushBatch()
laterPath := []int{testASNC, testASNB}
later := peerAnnouncement(testPeerIP, testV4Prefix)
later.Path = ristypes.ASPath{Sequence: laterPath}
later.ParsedTimestamp = first.ParsedTimestamp.Add(secondUpdateAfter)
h.HandleMessage(later)
h.flushBatch()
if store.writes != 2 {
t.Fatalf("the store was asked for %d writes of the table's changes, "+
"want 2", store.writes)
}
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkSameTable(t, read, h.table, testV4Prefix, otherV4Prefix)
origins := read.Origins(netip.MustParsePrefix(testV4Prefix))
if len(origins) != 1 || len(origins[0].Routes) != 1 ||
!slices.Equal(origins[0].Routes[0].ASPath, laterPath) {
t.Errorf("Origins(%s) = %+v, want one route with the path %v",
testV4Prefix, origins, laterPath)
}
}
// tableWriteSizesStore is a real database that records how many changes and
// how many new peers each write of the routing table's changes holds.
type tableWriteSizesStore struct {
*database.Database
changes []int
newPeers []int
}
func (s *tableWriteSizesStore) WriteRouteChanges(
changes []routetable.Change, newPeers map[string]uint,
) error {
s.changes = append(s.changes, len(changes))
s.newPeers = append(s.newPeers, len(newPeers))
return s.Database.WriteRouteChanges(changes, newPeers)
}
// TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes has one new peer
// change one and a half times as many prefix and origin AS pairs as one write
// takes, then flushes three times. The first write must hold as many pairs as
// one write takes, and the peer. The second must hold the other pairs, and the
// third flush must write nothing. The database must then hold the table in
// memory.
func TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes(t *testing.T) {
t.Parallel()
const (
pairs = maxTableChangesPerWrite * 3 / 2
flushes = 3
)
db := newTestDatabase(t)
store := &tableWriteSizesStore{Database: db}
h := prefixHandlerWithoutTimer(store)
announceManyPrefixes(h.table, pairs)
for range flushes {
h.flushBatch()
}
wantChanges := []int{maxTableChangesPerWrite, pairs - maxTableChangesPerWrite}
wantNewPeers := []int{1, 0}
if !slices.Equal(store.changes, wantChanges) ||
!slices.Equal(store.newPeers, wantNewPeers) {
t.Errorf("the writes held %v pairs and %v new peers, want %v and %v",
store.changes, store.newPeers, wantChanges, wantNewPeers)
}
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable: %v", err)
}
checkSameTable(t, read, h.table, firstManyPrefix)
}
// TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten stops a prefix handler,
// with no deadline, whose routing table holds more changed pairs than one write
// takes. Its last flush writes one write's worth, so Stop must log the pairs
// that no write took, 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.
func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
t.Parallel()
const pairs = maxTableChangesPerWrite + 1
var logs bytes.Buffer
store := &tableWriteSizesStore{Database: newTestDatabase(t)}
table := routetable.New(routetable.Params{})
announceManyPrefixes(table, pairs)
h := newPrefixHandler(store, newErrorLogger(&logs), table, longerThanAnyTest)
h.Stop(t.Context())
written := 0
for _, n := range store.changes {
written += n
}
logged := lastLogged(t, &logs)
if strings.Contains(logged.Msg, "deadline") {
t.Errorf("Stop logged %q although its deadline had not passed",
logged.Msg)
}
if logged.TableChanges != pairs-written {
t.Errorf("Stop logged %d table changes not written, want %d",
logged.TableChanges, pairs-written)
}
}
// readLog holds what readRouteTable logs.
//
//nolint:tagliatelle // field names are the keys readRouteTable logs
type readLog struct {
Msg string `json:"msg"`
Pairs int `json:"pairs"`
Peers int `json:"peers"`
DurationMs *int64 `json:"duration_ms"`
}
// TestARestartReadsTheRoutingTableBack runs announcements, a withdrawal and a
// peer's session going down through a prefix handler into a database, then
// reads the table back as startup does. The table read must answer as the one
// in memory, and the log must give its pairs and peers. A new handler on the
// table read then takes later updates and writes them as it stops: the
// database must then hold what the first table holds after the same updates.
func TestARestartReadsTheRoutingTableBack(t *testing.T) {
t.Parallel()
prefixes := []string{testV4Prefix, testV6Prefix, otherV4Prefix, setV4Prefix}
db := newTestDatabase(t)
h := prefixHandlerWithoutTimer(db)
viaOtherOrigin := peerAnnouncement(thirdPeerIP, testV4Prefix)
viaOtherOrigin.Path = ristypes.ASPath{Sequence: []int{testASNA, testASNC}}
endsInSet := peerAnnouncement(secondPeerIP, setV4Prefix)
endsInSet.Path = decodePath(t, testASNA, []int{testASNC, testASND})
for _, msg := range []*ristypes.RISMessage{
peerAnnouncement(testPeerIP, testV4Prefix, testV6Prefix),
peerAnnouncement(secondPeerIP, testV4Prefix),
viaOtherOrigin,
endsInSet,
peerAnnouncement(thirdPeerIP, otherV4Prefix),
peerWithdrawal(thirdPeerIP, otherV4Prefix),
peerAnnouncement(fourthPeerIP, otherV4Prefix),
sessionDownOf(fourthPeerIP),
} {
h.HandleMessage(msg)
}
h.flushBatch()
var logs bytes.Buffer
table, err := readRouteTable(db,
&logger.Logger{Logger: slog.New(slog.NewJSONHandler(&logs, nil))})
if err != nil {
t.Fatalf("readRouteTable: %v", err)
}
checkSameTable(t, table, h.table, prefixes...)
// testV4Prefix with two origin ASes, testV6Prefix and setV4Prefix; four
// peers, the last of which carries nothing.
const wantPairs, wantPeers = 4, 4
var logged readLog
err = json.Unmarshal(bytes.TrimSpace(logs.Bytes()), &logged)
if err != nil || logged.Pairs != wantPairs || logged.Peers != wantPeers ||
logged.DurationMs == nil {
t.Errorf("readRouteTable logged %q, want %d pairs, %d peers and the "+
"duration", logs.String(), wantPairs, wantPeers)
}
restarted := NewPrefixHandler(db, logger.New(), table)
for _, msg := range []*ristypes.RISMessage{
peerWithdrawal(testPeerIP, testV6Prefix),
sessionDownOf(secondPeerIP),
peerAnnouncement(fourthPeerIP, otherV4Prefix),
peerAnnouncement(fifthPeerIP, setV4Prefix),
} {
// The first handler only applies the updates to its table.
h.HandleMessage(msg)
restarted.HandleMessage(msg)
}
restarted.Stop(t.Context())
read, err := db.ReadRouteTable(t.Context())
if err != nil {
t.Fatalf("ReadRouteTable after the restart: %v", err)
}
checkSameTable(t, read, h.table, prefixes...)
}
// peerWithdrawal returns an UPDATE message in which peer withdraws prefixes.
func peerWithdrawal(peer string, prefixes ...string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Type: ristypes.MessageTypeUpdate,
Peer: peer,
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
Withdrawals: prefixes,
}
}
// announceManyPrefixes has testPeerIP announce n prefixes in table, with the
// path of peerAnnouncement: the /32 of each address from firstManyPrefix on.
func announceManyPrefixes(table *routetable.Table, n int) {
addr := netip.MustParsePrefix(firstManyPrefix).Addr()
route := routetable.Route{
Peer: testPeerIP,
ASPath: []int{testASNA, testASNB},
Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
for range n {
table.Announce(netip.PrefixFrom(addr, addr.BitLen()), testASNB, route)
addr = addr.Next()
}
}
// sessionDownOf returns the STATE message that says the BGP session of peer
// went down.
func sessionDownOf(peer string) *ristypes.RISMessage {
return &ristypes.RISMessage{
Type: ristypes.MessageTypeState,
Peer: peer,
State: ristypes.StateDown,
}
}
// tableOrigins returns, for each origin AS of prefix in table, the peers that
// carry the prefix with it.
func tableOrigins(table *routetable.Table, prefix string) map[int][]string {
origins := make(map[int][]string)
for _, o := range table.Origins(netip.MustParsePrefix(prefix)) {
origins[o.ASN] = o.Peers
}
return origins
}
// checkSameTable checks that got answers as want does for each of prefixes, and
// that the two tables have the same counts.
func checkSameTable(
t *testing.T, got, want *routetable.Table, prefixes ...string,
) {
t.Helper()
for _, text := range prefixes {
prefix := netip.MustParsePrefix(text)
gotOrigins, wantOrigins := got.Origins(prefix), want.Origins(prefix)
if !reflect.DeepEqual(gotOrigins, wantOrigins) {
t.Errorf("Origins(%s) = %+v, want %+v", text, gotOrigins, wantOrigins)
}
}
gotCounts, wantCounts := got.Counts(), want.Counts()
if !reflect.DeepEqual(gotCounts, wantCounts) {
t.Errorf("Counts() = %+v, want %+v", gotCounts, wantCounts)
}
}
+400 -248
View File
@@ -5,12 +5,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -19,6 +19,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
@@ -38,7 +39,7 @@ const (
const (
// maxLookupTargets is the most addresses and host names an IP lookup may
// list, and the most addresses it may have once the names are resolved:
// each address costs up to 113 database reads and a PTR lookup.
// each address costs two database reads and a PTR lookup.
maxLookupTargets = 10
// ptrLookupTimeout is each PTR lookup's own time limit, so that ten
@@ -82,7 +83,7 @@ const (
maxASSearchResults = 50
)
// IP versions as database.LiveRoute.IPVersion records them.
// IP versions as LiveRoute.IPVersion records them.
const (
ipVersionV4 = 4
ipVersionV6 = 6
@@ -227,38 +228,43 @@ type WHOISStatsInfo struct {
}
// Stats is the /status.json answer: uptime, message counts, database stats,
// and route information.
// and the routing table's figures. 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"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
// carry but whose kept routes were all withdrawn.
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
// databaseStats gets the database stats, giving up when ctx is done; it then
@@ -317,15 +323,6 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
return
}
const bitsPerMegabit = 1000000.0
// Get route update metrics
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
// Get memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
// Get WHOIS stats if fetcher is available
var whoisStats *WHOISStatsInfo
if s.asnFetcher != nil {
@@ -337,44 +334,80 @@ func (s *Server) handleStatusJSON() http.HandlerFunc {
}
}
stats := Stats{
Uptime: streamUptime(metrics.ConnectedSince),
TotalMessages: metrics.TotalMessages,
TotalBytes: metrics.TotalBytes,
TotalWireBytes: metrics.TotalWireBytes,
MessagesPerSec: metrics.MessagesPerSec,
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
WireMbitsPerSec: metrics.WireBitsPerSec / bitsPerMegabit,
Connected: metrics.Connected,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
IPv6Prefixes: dbStats.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: dbStats.IPv4Routes,
IPv6Routes: dbStats.IPv6Routes,
OldestRoute: dbStats.OldestRoute,
NewestRoute: dbStats.NewestRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
WHOISStats: whoisStats,
}
err = writeJSONSuccess(w, stats)
err = writeJSONSuccess(w, s.statusStats(metrics, dbStats, whoisStats))
if err != nil {
s.logger.Error("Failed to encode stats", "error", err)
}
}
}
// statusStats builds the /status.json answer from the stream metrics, the
// database stats, the routing table's figures and the WHOIS stats, with the
// route update rates and memory use as they are now.
func (s *Server) statusStats(
streamMetrics metrics.StreamMetrics,
dbStats database.Stats,
whoisStats *WHOISStatsInfo,
) Stats {
const bitsPerMegabit = 1000000.0
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
counts := s.table.Counts()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
return Stats{
Uptime: streamUptime(streamMetrics.ConnectedSince),
TotalMessages: streamMetrics.TotalMessages,
TotalBytes: streamMetrics.TotalBytes,
TotalWireBytes: streamMetrics.TotalWireBytes,
MessagesPerSec: streamMetrics.MessagesPerSec,
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
Connected: streamMetrics.Connected,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsage: humanize.Bytes(memStats.Alloc),
ASNs: dbStats.ASNs,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes,
IPv6Prefixes: counts.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: counts.IPv4Routes + counts.IPv6Routes,
IPv4Routes: counts.IPv4Routes,
IPv6Routes: counts.IPv6Routes,
OldestRoute: counts.OldestKeptRoute,
NewestRoute: counts.NewestKeptRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
IPv4PrefixDistribution: prefixDistribution(counts.IPv4PrefixesByLength),
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats,
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
}
}
// prefixDistribution lists the mask lengths that have at least one prefix, in
// ascending order, with their prefix counts. countsByLength[n] is the number
// of prefixes of mask length n.
func prefixDistribution(countsByLength []int) []PrefixDistribution {
var distribution []PrefixDistribution
for maskLength, count := range countsByLength {
if count > 0 {
distribution = append(distribution, PrefixDistribution{
MaskLength: maskLength, Count: count,
})
}
}
return distribution
}
// getWHOISStats builds WHOIS statistics from database and fetcher.
func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
// Get database WHOIS stats
@@ -470,43 +503,46 @@ type StreamStats struct {
BGPPeerCount int `json:"bgp_peer_count"`
}
// StatsResponse represents the API statistics response
// StatsResponse represents the API statistics response. The fields it shares
// with Stats mean what they mean there.
//
//nolint:tagliatelle // field names are the existing JSON API
type StatsResponse struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
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"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
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"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
// handlerStatsInfo converts the streamer's handler statistics for the API
@@ -626,8 +662,8 @@ func (s *Server) handleStats() http.HandlerFunc {
}
// statsResponse builds the /api/v1/stats answer from the stream metrics, the
// database stats and the WHOIS stats, with the route update rates, memory use
// and handler queues as they are now.
// database stats, the routing table's figures and the WHOIS stats, with the
// route update rates, memory use and handler queues as they are now.
func (s *Server) statsResponse(
streamMetrics metrics.StreamMetrics,
dbStats database.Stats,
@@ -637,6 +673,7 @@ func (s *Server) statsResponse(
// Get route update metrics
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
counts := s.table.Counts()
// Get memory stats
var memStats runtime.MemStats
@@ -660,23 +697,25 @@ func (s *Server) statsResponse(
GC: gcStats(&memStats),
Stream: s.streamStats(),
ASNs: dbStats.ASNs,
Prefixes: dbStats.Prefixes,
IPv4Prefixes: dbStats.IPv4Prefixes,
IPv6Prefixes: dbStats.IPv6Prefixes,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes,
IPv6Prefixes: counts.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: dbStats.LiveRoutes,
IPv4Routes: dbStats.IPv4Routes,
IPv6Routes: dbStats.IPv6Routes,
OldestRoute: dbStats.OldestRoute,
NewestRoute: dbStats.NewestRoute,
LiveRoutes: counts.IPv4Routes + counts.IPv6Routes,
IPv4Routes: counts.IPv4Routes,
IPv6Routes: counts.IPv6Routes,
OldestRoute: counts.OldestKeptRoute,
NewestRoute: counts.NewestKeptRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
HandlerStats: handlerStatsInfo(s.streamer.GetHandlerStats()),
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
IPv4PrefixDistribution: prefixDistribution(counts.IPv4PrefixesByLength),
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats,
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
}
}
@@ -714,7 +753,7 @@ type IPLookupResponse struct {
// fields. IP hides the IPInfo field of the same name, so that an address with
// no route has it too.
type IPLookupResult struct {
*database.IPInfo
*IPInfo
IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
@@ -766,7 +805,7 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
// Look up each IP
for _, ip := range ipsToLookup {
ipInfo, err := s.db.GetIPInfoContext(ctx, ip)
ipInfo, err := s.ipInfo(ctx, ip)
if deadlinePassed(ctx) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
@@ -774,10 +813,10 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
switch {
case errors.Is(err, database.ErrNoRoute):
case errors.Is(err, ErrNoRoute):
// The address still gets a result, without route fields.
ipInfo = nil
case errors.Is(err, database.ErrInvalidIP):
case errors.Is(err, ErrInvalidIP):
response.Errors = append(response.Errors, ip+": "+err.Error())
continue
@@ -815,6 +854,81 @@ 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) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
}
summary, found := s.table.Lookup(addr)
if !found {
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
if err != nil {
return nil, err
}
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
if err != nil {
return nil, 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,
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
}
if summary.Newest != nil {
info.LastSeen = *summary.Newest
}
return info, nil
}
// 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.
func needsWHOISRefresh(asInfo *database.ASN) bool {
if asInfo.ASN == ristypes.NoOrigin {
return false
}
return asInfo.WHOISUpdatedAt == nil ||
time.Since(*asInfo.WHOISUpdatedAt) > whoisStaleThreshold
}
// ipVersionOf returns 4 or 6, the IP version of prefix.
func ipVersionOf(prefix netip.Prefix) int {
if prefix.Addr().Is4() {
return ipVersionV4
}
return ipVersionV6
}
// blocklistListings returns the blocklists that list ip. It returns an empty
// list, never nil, so that an answer's blocklists is [] rather than null.
func (s *Server) blocklistListings(ip string) []blocklist.Listing {
@@ -1043,7 +1157,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
return
}
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
asInfo, prefixes, err := s.asDetails(r.Context(), asn)
if deadlinePassed(r.Context()) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
@@ -1051,7 +1165,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
}
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
if errors.Is(err, ErrNoRoute) {
s.writeJSONError(w, http.StatusNotFound, err.Error())
} else {
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
@@ -1079,12 +1193,55 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
}
}
// asDetails returns the AS's handle, description and seen times, from the
// database, and the prefixes it originates, from the routing table. Each
// prefix has the time of its newest kept route, if it keeps one. It returns
// ErrNoRoute for AS 0, which means no known origin. It also returns ErrNoRoute
// for an AS that the database, the AS list built into RouteWatch and the
// routing table all lack.
func (s *Server) asDetails(
ctx context.Context, asn int,
) (*database.ASN, []LiveRoute, error) {
if asn == ristypes.NoOrigin {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
asInfo, known, err := s.db.GetAS(ctx, asn)
if err != nil {
return nil, nil, err
}
originated := s.table.OriginatedPrefixes(asn)
if !known && len(originated) == 0 {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
prefixes := make([]LiveRoute, 0, len(originated))
for _, p := range originated {
route := LiveRoute{
Prefix: p.Prefix.String(),
MaskLength: p.Prefix.Bits(),
IPVersion: ipVersionOf(p.Prefix),
OriginASN: asn,
}
if p.Newest != nil {
route.LastUpdated = *p.Newest
}
prefixes = append(prefixes, route)
}
return asInfo, prefixes, nil
}
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither
// group is nil, so that a JSON answer holds [] for an empty one and not null.
func splitRoutesByIPVersion(
routes []database.LiveRoute,
) ([]database.LiveRoute, []database.LiveRoute) {
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
routes []LiveRoute,
) ([]LiveRoute, []LiveRoute) {
ipv4Routes, ipv6Routes := []LiveRoute{}, []LiveRoute{}
for _, route := range routes {
if route.IPVersion == ipVersionV4 {
@@ -1099,8 +1256,8 @@ func splitRoutesByIPVersion(
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
// routes with the same mask length by network address.
func sortRoutesByMaskLength(routes []database.LiveRoute) {
slices.SortFunc(routes, func(a, b database.LiveRoute) int {
func sortRoutesByMaskLength(routes []LiveRoute) {
slices.SortFunc(routes, func(a, b LiveRoute) int {
prefixA, _ := netip.ParsePrefix(a.Prefix)
prefixB, _ := netip.ParsePrefix(b.Prefix)
@@ -1123,8 +1280,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return
}
// Look the prefix up in the text form net/netip prints, the form the
// tables store, so that any valid way of writing it finds the route.
// Masking the prefix lets any valid way of writing it find its routes.
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
s.writeJSONError(w, http.StatusBadRequest, invalidPrefixMessage)
@@ -1132,30 +1288,21 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return
}
prefix := parsed.Masked().String()
parsed = parsed.Masked()
prefix := parsed.String()
routes, _, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if deadlinePassed(r.Context()) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
origins := s.table.Origins(parsed)
if len(origins) == 0 {
s.writeJSONError(w, http.StatusNotFound,
ErrNoRoute.Error()+": "+prefix)
return
}
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
s.writeJSONError(w, http.StatusNotFound, err.Error())
} else {
s.logger.Error("Failed to get prefix details",
"prefix", prefix, "error", err)
s.writeJSONError(w, http.StatusInternalServerError,
internalErrorMessage)
}
return
}
routes := prefixRoutes(parsed, origins)
// Group by origin AS
originMap := make(map[int][]database.LiveRoute)
originMap := make(map[int][]LiveRoute)
for _, route := range routes {
originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
}
@@ -1237,7 +1384,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
return
}
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
asInfo, prefixes, err := s.asDetails(r.Context(), asn)
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
@@ -1245,7 +1392,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
}
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
if errors.Is(err, ErrNoRoute) {
http.Error(w, "AS not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get AS details", "error", err)
@@ -1278,8 +1425,8 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// Prepare template data
data := struct {
ASN *database.ASN
IPv4Prefixes []database.LiveRoute
IPv6Prefixes []database.LiveRoute
IPv4Prefixes []LiveRoute
IPv6Prefixes []LiveRoute
TotalCount int
IPv4Count int
IPv6Count int
@@ -1327,8 +1474,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
return
}
// Look the prefix up in the text form net/netip prints, the form the
// tables store, so that any valid way of writing it finds the route.
// Masking the prefix lets any valid way of writing it find its routes.
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
http.Error(w, invalidPrefixMessage, http.StatusBadRequest)
@@ -1336,9 +1482,16 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
return
}
prefix := parsed.Masked().String()
parsed = parsed.Masked()
routes, originNames, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
origins := s.table.Origins(parsed)
if len(origins) == 0 {
http.Error(w, "Prefix not found", http.StatusNotFound)
return
}
originInfos, err := s.originASNs(r.Context(), origins)
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
@@ -1346,27 +1499,13 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
}
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "Prefix not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get prefix details", "error", err)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
}
s.logger.Error("Failed to get the origin ASes' names", "error", err)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
return
}
// Group by origin AS and collect unique AS info
origins := originASNs(routes, originNames)
// Get the first route to extract some common info
var maskLength, ipVersion int
if len(routes) > 0 {
maskLength, ipVersion = prefixMaskLengthAndIPVersion(prefix)
}
// Create enhanced routes with AS path handles
enhancedRoutes := routesWithASPathHandles(routes)
routes := prefixRoutes(parsed, origins)
// Prepare template data
data := struct {
@@ -1379,13 +1518,13 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
OriginCount int
CSPNonce string
}{
Prefix: prefix,
MaskLength: maskLength,
IPVersion: ipVersion,
Routes: enhancedRoutes,
Origins: origins,
Prefix: parsed.String(),
MaskLength: parsed.Bits(),
IPVersion: ipVersionOf(parsed),
Routes: routesWithASPathHandles(routes),
Origins: originInfos,
PeerCount: len(routes),
OriginCount: len(origins),
OriginCount: len(originInfos),
CSPNonce: cspNonce(r.Context()),
}
@@ -1415,34 +1554,72 @@ type ASNInfo struct {
PeerCount int
}
// originASNs groups routes by origin AS, with each AS's handle and description
// from names, as the prefix query returned them, and the number of routes it
// originates. The origin ASes come in no particular order.
func originASNs(
routes []database.LiveRoute,
names map[int]database.ASN,
) []*ASNInfo {
originMap := make(map[int]*ASNInfo)
// originASNs returns each of origins, a prefix's origin ASes, with its handle
// and description from the database and the number of peers that carry the
// prefix with it.
func (s *Server) originASNs(
ctx context.Context, origins []routetable.Origin,
) ([]*ASNInfo, error) {
infos := make([]*ASNInfo, 0, len(origins))
for _, route := range routes {
if _, exists := originMap[route.OriginASN]; !exists {
originMap[route.OriginASN] = &ASNInfo{
ASN: route.OriginASN,
Handle: names[route.OriginASN].Handle,
Description: names[route.OriginASN].Description,
PeerCount: 0,
}
for _, origin := range origins {
asInfo, _, err := s.db.GetAS(ctx, origin.ASN)
if err != nil {
return nil, err
}
originMap[route.OriginASN].PeerCount++
infos = append(infos, &ASNInfo{
ASN: origin.ASN,
Handle: asInfo.Handle,
Description: asInfo.Description,
PeerCount: len(origin.Peers),
})
}
origins := make([]*ASNInfo, 0, len(originMap))
for _, origin := range originMap {
origins = append(origins, origin)
return infos, nil
}
// prefixRoutes returns one route for each peer that carries prefix, by origin
// AS in the order of origins. Each origin's kept routes come first, newest
// first, with their AS path, next hop and time. Its other peers follow without
// them, since the routing table keeps only their addresses.
func prefixRoutes(
prefix netip.Prefix, origins []routetable.Origin,
) []LiveRoute {
var routes []LiveRoute
for _, origin := range origins {
base := LiveRoute{
Prefix: prefix.String(),
MaskLength: prefix.Bits(),
IPVersion: ipVersionOf(prefix),
OriginASN: origin.ASN,
}
kept := make(map[string]bool, len(origin.Routes))
for _, r := range origin.Routes {
kept[r.Peer] = true
route := base
route.PeerIP = r.Peer
route.ASPath = r.ASPath
// The next hop is the peer, as the prefix handler records it.
route.NextHop = r.Peer
route.LastUpdated = r.Time
routes = append(routes, route)
}
for _, peer := range origin.Peers {
if !kept[peer] {
route := base
route.PeerIP = peer
routes = append(routes, route)
}
}
}
return origins
return routes
}
// ASPathEntry is one AS of a route's AS path, with its handle.
@@ -1453,14 +1630,14 @@ type ASPathEntry struct {
// EnhancedRoute is a live route with the handle of each AS in its path.
type EnhancedRoute struct {
database.LiveRoute
LiveRoute
ASPathWithHandle []ASPathEntry
}
// routesWithASPathHandles looks up the handle of each AS in the path of each
// route.
func routesWithASPathHandles(routes []database.LiveRoute) []EnhancedRoute {
func routesWithASPathHandles(routes []LiveRoute) []EnhancedRoute {
enhancedRoutes := make([]EnhancedRoute, len(routes))
for i, route := range routes {
@@ -1484,23 +1661,6 @@ func routesWithASPathHandles(routes []database.LiveRoute) []EnhancedRoute {
return enhancedRoutes
}
// prefixMaskLengthAndIPVersion returns the mask length and IP version of a
// prefix in CIDR notation, or zeros if it does not parse.
func prefixMaskLengthAndIPVersion(prefix string) (int, int) {
_, ipNet, err := net.ParseCIDR(prefix)
if err != nil {
return 0, 0
}
maskLength, _ := ipNet.Mask.Size()
if ipNet.IP.To4() != nil {
return maskLength, ipVersionV4
}
return maskLength, ipVersionV6
}
// handlePrefixLength shows a random sample of IPv4 prefixes with the
// specified mask length
func (s *Server) handlePrefixLength() http.HandlerFunc {
@@ -1539,29 +1699,11 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
// Get random sample of prefixes
const maxPrefixes = 500
prefixes, err := s.db.GetRandomPrefixesByLengthContext(
r.Context(), maskLength, ipVersion, maxPrefixes)
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
return
}
if err != nil {
s.logger.Error("Failed to get prefixes by length", "error", err)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
return
}
prefixes := s.table.SamplePrefixes(ipVersion, maskLength, maxPrefixes)
// Sort prefixes for display
sort.Slice(prefixes, func(i, j int) bool {
// First compare by IP version
if prefixes[i].IPVersion != prefixes[j].IPVersion {
return prefixes[i].IPVersion < prefixes[j].IPVersion
}
// Then by prefix
return prefixes[i].Prefix < prefixes[j].Prefix
slices.SortFunc(prefixes, func(a, b routetable.PrefixSummary) int {
return a.Prefix.Compare(b.Prefix)
})
// Render template
@@ -1591,23 +1733,33 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
}
}
// EnhancedPrefix is a live route on the prefix length page, with the
// description of its origin AS and its age.
// EnhancedPrefix is a prefix on the prefix length page, with the origin AS
// that most peers carry it with, that AS's description, and its age.
type EnhancedPrefix struct {
database.LiveRoute
LiveRoute
OriginASDescription string
Age string
}
// prefixesWithASDescriptions looks up the description of the origin AS of
// each prefix and gives each one's age.
func prefixesWithASDescriptions(prefixes []database.LiveRoute) []EnhancedPrefix {
// each prefix. It gives each prefix the age of its newest kept route, or no
// age when it keeps none.
func prefixesWithASDescriptions(
prefixes []routetable.PrefixSummary,
) []EnhancedPrefix {
enhancedPrefixes := make([]EnhancedPrefix, len(prefixes))
for i, prefix := range prefixes {
enhancedPrefixes[i] = EnhancedPrefix{
LiveRoute: prefix,
Age: formatAge(prefix.LastUpdated),
Prefix: prefix.Prefix.String(),
MaskLength: prefix.Prefix.Bits(),
IPVersion: ipVersionOf(prefix.Prefix),
OriginASN: prefix.OriginASN,
}
if prefix.Newest != nil {
enhancedPrefixes[i].LastUpdated = *prefix.Newest
enhancedPrefixes[i].Age = formatAge(*prefix.Newest)
}
// Get AS description
+237 -271
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"reflect"
"runtime"
"slices"
"strconv"
@@ -23,6 +24,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
)
@@ -35,6 +37,38 @@ const (
testPeerV6 = "2001:db8::1"
)
// testPeer returns the address of the nth of the IPv4 peers that tests
// announce routes from: 198.51.100.n.
func testPeer(n int) string {
return "198.51.100." + strconv.Itoa(n)
}
// testRouteTime is the time the tests' routes are announced at, or a number of
// minutes after it.
func testRouteTime(minutes int) time.Time {
return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).
Add(time.Duration(minutes) * time.Minute)
}
// announce has peer announce prefix in table at time at, with an AS path
// that holds only the origin AS asn.
func announce(
table *routetable.Table, prefix string, asn int, peer string, at time.Time,
) {
table.Announce(netip.MustParsePrefix(prefix), asn,
routetable.Route{Peer: peer, ASPath: []int{asn}, Time: at})
}
// withdraw has peer withdraw prefix in table.
func withdraw(table *routetable.Table, prefix, peer string) {
table.Withdraw(netip.MustParsePrefix(prefix), peer)
}
// emptyTable returns a routing table that holds no route.
func emptyTable() *routetable.Table {
return routetable.New(routetable.Params{})
}
// Paths several tests request.
const (
asPagePath = "/as/64500"
@@ -76,7 +110,8 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err
func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
release := make(chan struct{})
db := blockingStatsDB{release: release}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := New(db, emptyTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
handlers := map[string]http.HandlerFunc{
statusJSONHandlerName: s.handleStatusJSON(),
@@ -108,51 +143,78 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
}
}
// tableStats holds the figures of a stats answer that come from the routing
// table.
//
//nolint:tagliatelle // field names are the existing JSON API
type tableStats struct {
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
OldestRoute time.Time `json:"oldest_route"`
NewestRoute time.Time `json:"newest_route"`
IPv4Distribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
}
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
// with the live route counts and the prefix distribution while the database is
// closed, so that any query would fail: the request path reads them from
// memory. The prefix distribution query it used to run read every live route
// and, on a large database, took the whole 4-second deadline, so
// with the routing table's figures while the database is closed, so that any
// query would fail. The prefix distribution query they used to run read every
// live route and, on a large database, took the whole 4-second deadline, so
// /api/v1/stats answered 500 (https://git.eeqj.de/sneak/routewatch/issues/30).
// The oldest and newest route times still come from one-row lookups at the ends
// of an index; with the database closed they are left out of the answer.
//
// The table holds two IPv4 prefixes and an IPv6 one. Of the three peers that
// carried 203.0.113.0/24, the two with kept routes withdrew it, so its origin
// keeps no route. The oldest and newest routes are those of the other two
// prefixes.
func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Parallel()
const withdrawnPrefix = "203.0.113.0/24"
table := emptyTable()
announce(table, testPrefixV4, 64502, testPeer(1), testRouteTime(0))
for n := 1; n <= 3; n++ {
announce(table, withdrawnPrefix, 64500, testPeer(n), testRouteTime(n))
}
withdraw(table, withdrawnPrefix, testPeer(2))
withdraw(table, withdrawnPrefix, testPeer(3))
announce(table, testPrefixV6, 64501, testPeerV6, testRouteTime(4))
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
Prefix: "198.51.100.0/24", MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500},
NextHop: "192.0.2.1", LastUpdated: ts,
},
{
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
err = db.Close()
if err != nil {
t.Fatalf("Close: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
handlers := map[string]http.HandlerFunc{
statusJSONHandlerName: s.handleStatusJSON(),
statsHandlerName: s.handleStats(),
}
want := tableStats{
Prefixes: 3, IPv4Prefixes: 2, IPv6Prefixes: 1,
LiveRoutes: 3, IPv4Routes: 2, IPv6Routes: 1,
OriginsWithoutKeptRoute: 1,
OldestRoute: testRouteTime(0),
NewestRoute: testRouteTime(4),
IPv4Distribution: []PrefixDistribution{{MaskLength: 24, Count: 2}},
IPv6Distribution: []PrefixDistribution{{MaskLength: 32, Count: 1}},
}
for name, handler := range handlers {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil))
@@ -164,14 +226,8 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
continue
}
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
} `json:"data"`
Data tableStats `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
@@ -179,21 +235,16 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
t.Fatalf("%s: decoding the answer: %v", name, err)
}
if body.Data.IPv4Routes != 1 || body.Data.IPv6Routes != 1 {
t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)",
name, body.Data.IPv4Routes, body.Data.IPv6Routes)
got := body.Data
if !got.OldestRoute.Equal(want.OldestRoute) ||
!got.NewestRoute.Equal(want.NewestRoute) {
t.Errorf("%s: routes from %v to %v, want from %v to %v", name,
got.OldestRoute, got.NewestRoute, want.OldestRoute, want.NewestRoute)
}
wantV4 := []database.PrefixDistribution{{MaskLength: 24, Count: 1}}
if !slices.Equal(body.Data.IPv4Distribution, wantV4) {
t.Errorf("%s: IPv4 distribution = %v, want %v",
name, body.Data.IPv4Distribution, wantV4)
}
wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}}
if !slices.Equal(body.Data.IPv6Distribution, wantV6) {
t.Errorf("%s: IPv6 distribution = %v, want %v",
name, body.Data.IPv6Distribution, wantV6)
got.OldestRoute, got.NewestRoute = want.OldestRoute, want.NewestRoute
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: figures %+v, want %+v", name, got, want)
}
}
}
@@ -243,11 +294,29 @@ func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
}
}
// newServerWithTestRoutes returns a server on a new database that holds a
// route for testPrefixV4 and one for testPrefixV6.
// 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 {
table := emptyTable()
announce(table, testPrefixV4, 64500, testPeerV4, testRouteTime(0))
announce(table, testPrefixV6, 64501, testPeerV6, testRouteTime(0))
return table
}
// newServerWithTestRoutes returns a server on a new database that reads the
// routes of newTestTable.
func newServerWithTestRoutes(t *testing.T) *Server {
t.Helper()
return newServerWithTable(t, newTestTable())
}
// newServerWithTable returns a server on a new database that reads routes from
// table.
func newServerWithTable(t *testing.T, table *routetable.Table) *Server {
t.Helper()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
@@ -255,25 +324,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
NextHop: testPeerV4, LastUpdated: ts,
},
{
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
return New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
return New(db, table, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
}
@@ -371,27 +422,9 @@ func TestIPLookupsReadPercentEncodedAddresses(t *testing.T) {
}
}
// errASDetailsLookupFailed is what failingASDetailsDB answers every AS details
// lookup with.
var errASDetailsLookupFailed = errors.New("AS details lookup failed")
// failingASDetailsDB wraps a real store and fails every AS details lookup.
type failingASDetailsDB struct {
database.Store
}
func (failingASDetailsDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errASDetailsLookupFailed
}
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
// origin AS's handle and description without looking up the AS's details,
// which also read every route the AS originates. Before the fix the page did
// that lookup once per origin, so with it failing the names were missing
// (https://git.eeqj.de/sneak/routewatch/issues/67). The AS path column shows
// handles too, so the test looks for the origin list's "(handle)" form.
// origin AS's handle and description. The AS path column shows handles too, so
// the test looks for the origin list's "(handle)" form.
func TestPrefixDetailShowsOriginNames(t *testing.T) {
t.Parallel()
@@ -402,34 +435,20 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
t.Cleanup(func() { _ = db.Close() })
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
origins := []int{15169, 13335}
table := emptyTable()
for _, asn := range origins {
_, err := db.GetOrCreateASN(asn, ts)
for i, asn := range origins {
_, err := db.GetOrCreateASN(asn, testRouteTime(0))
if err != nil {
t.Fatalf("GetOrCreateASN(%d): %v", asn, err)
}
announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0))
}
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{
{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: origins[0], PeerIP: testPeerV4,
ASPath: []int{origins[0]}, NextHop: testPeerV4, LastUpdated: ts,
},
{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: origins[1], PeerIP: "198.51.100.2",
ASPath: []int{origins[1]}, NextHop: "198.51.100.2", LastUpdated: ts,
},
})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s := New(db, table,
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
@@ -466,23 +485,14 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
table := emptyTable()
table.Announce(netip.MustParsePrefix(testPrefixV4), ristypes.NoOrigin,
routetable.Route{
Peer: testPeerV4, ASPath: []int{64500, 64501, 64502},
Time: testRouteTime(0),
})
t.Cleanup(func() { _ = db.Close() })
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{{
Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
OriginASN: 0, PeerIP: testPeerV4, ASPath: []int{64500, 64501, 64502},
NextHop: testPeerV4, LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := newServerWithTable(t, table)
for _, path := range []string{"/prefix/" + testPrefixV4, prefixLengthPagePath} {
rec := httptest.NewRecorder()
@@ -533,8 +543,9 @@ func TestIndexShowsTheClientAddress(t *testing.T) {
}
for _, tt := range tests {
// The home page reads nothing from the database, so the server has none.
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(),
// The home page reads no route and nothing from the database, so the
// server has neither a routing table nor a database.
s := New(nil, nil, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{TrustedProxies: tt.trustedProxies})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
@@ -571,13 +582,6 @@ func TestIndexShowsTheClientAddress(t *testing.T) {
func TestPagesShowAgesInWords(t *testing.T) {
t.Parallel()
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("database.New: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// Each age is well past a whole number of its unit, so the time the test
// takes to run does not change the words.
ages := []struct {
@@ -591,22 +595,13 @@ func TestPagesShowAgesInWords(t *testing.T) {
}
now := time.Now().UTC()
routes := make([]*database.LiveRoute, 0, len(ages))
table := emptyTable()
for _, a := range ages {
routes = append(routes, &database.LiveRoute{
Prefix: a.prefix, MaskLength: 24, IPVersion: 4,
OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500},
NextHop: testPeerV4, LastUpdated: now.Add(-a.age),
})
announce(table, a.prefix, 64500, testPeerV4, now.Add(-a.age))
}
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s := newServerWithTable(t, table)
for _, a := range ages {
cell := `<td class="age">` + a.want + `</td>`
@@ -643,7 +638,7 @@ const shortRequestDeadline = 50 * time.Millisecond
func TestRequestPastTimeLimitAnswers504(t *testing.T) {
t.Parallel()
s := New(nil, streamer.New(logger.New(), metrics.New()), logger.New(),
s := New(nil, nil, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
waitForTimeLimit := func(_ http.ResponseWriter, r *http.Request) {
@@ -693,8 +688,8 @@ func TestStatsHandlersAnswer504WhenDatabaseReadRunsOut(t *testing.T) {
defer close(release)
db := blockingStatsDB{release: release}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(db, emptyTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
handlers := map[string]http.HandlerFunc{
statusJSONHandlerName: s.handleStatusJSON(),
@@ -720,10 +715,9 @@ func TestStatsHandlersAnswer504WhenDatabaseReadRunsOut(t *testing.T) {
// slowDB embeds database.Store (left nil) and overrides the reads the routes
// in TestRoutesAnswer504WhenDatabaseReadIsCutOff make. Each read waits until
// its context ends and then answers as the real store does when the time
// limit cuts it off: the IP read with the context's error, the prefix read
// that there is no route, and the others with what they had read, here
// nothing, and no error. Every other Store method is unused and would panic
// if called.
// limit cuts it off: the stats read with what it had read, here nothing, and
// no error, and the others with the context's error. Every other Store method
// is unused and would panic if called.
type slowDB struct {
database.Store
}
@@ -734,66 +728,44 @@ func (slowDB) GetStatsContext(ctx context.Context) (database.Stats, error) {
return database.Stats{}, nil
}
func (slowDB) GetIPInfoContext(
ctx context.Context, _ string,
) (*database.IPInfo, error) {
func (slowDB) GetAS(ctx context.Context, _ int) (*database.ASN, bool, error) {
<-ctx.Done()
return nil, ctx.Err()
return nil, false, ctx.Err()
}
func (slowDB) GetASDetailsContext(
ctx context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
func (slowDB) GetPrefixFirstSeen(
ctx context.Context, _ netip.Prefix,
) (time.Time, error) {
<-ctx.Done()
return &database.ASN{ASN: asn}, nil, nil
}
func (slowDB) GetPrefixDetailsContext(
ctx context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
<-ctx.Done()
return nil, nil, database.ErrNoRoute
}
func (slowDB) GetRandomPrefixesByLengthContext(
ctx context.Context, _, _, _ int,
) ([]database.LiveRoute, error) {
<-ctx.Done()
return nil, nil
return time.Time{}, ctx.Err()
}
// TestRoutesAnswer504WhenDatabaseReadIsCutOff sends a request through the
// server's router for each route that reads the database, with a store whose
// reads are cut off when the request's time limit passes. Each route answers
// 504 with the time limit message. Without its check after the read, a route
// answers with what the cut-off read returned: 500, 404, 200, or, on a page
// that stops before rendering once the request has ended, 504 with an empty
// body. The health check is left out: it answers 503 when its own 2-second
// ping runs out.
// answers with what the cut-off read returned: 500 or 200. The health check is
// left out: it answers 503 when its own 2-second ping runs out. The prefix
// length pages and the prefix API read only the routing table.
func TestRoutesAnswer504WhenDatabaseReadIsCutOff(t *testing.T) {
t.Parallel()
s := New(slowDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(slowDB{}, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
tests := []struct {
path string
wantJSONBody bool
}{
{path: asPagePath},
{path: "/prefix/192.0.2.0/24"},
{path: prefixLengthPagePath},
{path: "/prefixlength6/48"},
{path: "/prefix/" + testPrefixV4},
{path: statusJSONPath, wantJSONBody: true},
{path: "/ip/192.0.2.1", wantJSONBody: true},
{path: "/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: statsPath, wantJSONBody: true},
{path: "/api/v1/ip/192.0.2.1", wantJSONBody: true},
{path: "/api/v1/ip/" + addrInTestPrefixV4, wantJSONBody: true},
{path: "/api/v1/as/64500", wantJSONBody: true},
{path: "/api/v1/prefix/192.0.2.0/24", wantJSONBody: true},
}
for _, tt := range tests {
@@ -810,10 +782,8 @@ type slowSecondReadDB struct {
database.Store
}
func (slowSecondReadDB) GetASDetailsContext(
_ context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
return &database.ASN{ASN: asn}, nil, nil
func (slowSecondReadDB) GetAS(_ context.Context, asn int) (*database.ASN, bool, error) {
return &database.ASN{ASN: asn}, true, nil
}
func (slowSecondReadDB) GetASPeersContext(
@@ -855,8 +825,8 @@ func (idleASNFetcher) GetStats() ASNFetcherStats {
func TestRoutesAnswer504WhenSecondDatabaseReadIsCutOff(t *testing.T) {
t.Parallel()
s := New(slowSecondReadDB{}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s := New(slowSecondReadDB{}, emptyTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s.SetASNFetcher(idleASNFetcher{})
tests := []struct {
@@ -950,34 +920,27 @@ func (failingDB) GetStatsContext(_ context.Context) (database.Stats, error) {
return database.Stats{}, errDiskIO
}
func (failingDB) GetIPInfoContext(
_ context.Context, _ string,
) (*database.IPInfo, error) {
return nil, errDiskIO
func (failingDB) GetAS(_ context.Context, _ int) (*database.ASN, bool, error) {
return nil, false, errDiskIO
}
func (failingDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errDiskIO
func (failingDB) GetPrefixFirstSeen(
_ context.Context, _ netip.Prefix,
) (time.Time, error) {
return time.Time{}, errDiskIO
}
func (failingDB) GetPrefixDetailsContext(
_ context.Context, _ string,
) ([]database.LiveRoute, map[int]database.ASN, error) {
return nil, nil, errDiskIO
}
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route and
// the health check with a store whose reads fail with the driver's error text,
// and a prefix that does not parse, and checks that no answer carries the
// driver's or the prefix parser's text. Before the fix the answers sent it to
// the client (https://git.eeqj.de/sneak/routewatch/issues/108).
// TestJSONRoutesDoNotSendErrorDetails sends a request to each JSON route that
// reads the database, and to the health check, with a store whose reads fail
// with the driver's error text. It also sends a prefix that does not parse.
// No answer may carry the driver's or the prefix parser's text. Before the fix
// the answers sent it to the client
// (https://git.eeqj.de/sneak/routewatch/issues/108).
func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
t.Parallel()
s := New(failingDB{}, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(failingDB{}, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
tests := []struct {
path string
@@ -987,8 +950,7 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
{statusJSONPath, http.StatusInternalServerError},
{statsPath, http.StatusInternalServerError},
{"/api/v1/as/64500", http.StatusInternalServerError},
{"/api/v1/prefix/192.0.2.0/24", http.StatusInternalServerError},
{"/api/v1/ip/192.0.2.1", http.StatusInternalServerError},
{"/api/v1/ip/" + addrInTestPrefixV4, http.StatusInternalServerError},
{"/api/v1/prefix/foo/24", http.StatusBadRequest},
}
@@ -1011,45 +973,47 @@ func TestJSONRoutesDoNotSendErrorDetails(t *testing.T) {
}
}
// The addresses failingIPLookupDB fails the read of and finds a route for.
// The address whose read failingIPLookupDB fails, in testPrefixV4, and one it
// reads, in testPrefixV6.
const (
failingLookupIP = "192.0.2.1"
routedLookupIP = "192.0.2.2"
failingLookupIP = addrInTestPrefixV4
routedLookupIP = addrInTestPrefixV6
)
// failingIPLookupDB embeds database.Store (left nil) and answers the IP lookup
// with errDiskIO for failingLookupIP, a route for routedLookupIP, and no route
// for any other address. Every other Store method is unused and would panic if
// called.
// failingIPLookupDB embeds database.Store (left nil). It fails the read of
// testPrefixV4's first-seen time with errDiskIO, and answers the IP lookup's
// other reads with nothing stored. Every other Store method is unused and
// would panic if called.
type failingIPLookupDB struct {
database.Store
}
func (failingIPLookupDB) GetIPInfoContext(
_ context.Context, ip string,
) (*database.IPInfo, error) {
switch ip {
case failingLookupIP:
return nil, errDiskIO
case routedLookupIP:
return &database.IPInfo{IP: ip}, nil
default:
return nil, database.ErrNoRoute
func (failingIPLookupDB) GetPrefixFirstSeen(
_ context.Context, prefix netip.Prefix,
) (time.Time, error) {
if prefix == netip.MustParsePrefix(testPrefixV4) {
return time.Time{}, errDiskIO
}
return time.Time{}, nil
}
func (failingIPLookupDB) GetASWHOIS(_ context.Context, asn int) (*database.ASN, error) {
return &database.ASN{ASN: asn}, nil
}
// TestIPLookupAnswers500WhenDatabaseReadFails sends IP lookups through the
// server's router with a store whose read of one address fails with the
// server's router with a store whose read for one address fails with the
// driver's error. A lookup that asks for that address answers 500 with the
// JSON error body, as the AS and prefix JSON routes do; an address with no
// route answers 200. Before the fix the failed read was reported as if the
// address had no route: 404 for that address alone, and 200 beside a routed
// address (https://git.eeqj.de/sneak/routewatch/issues/109).
// JSON error body, as the AS JSON route does; an address with no route answers
// 200. Before the fix the failed read was reported as if the address had no
// route: 404 for that address alone, and 200 beside a routed address
// (https://git.eeqj.de/sneak/routewatch/issues/109).
func TestIPLookupAnswers500WhenDatabaseReadFails(t *testing.T) {
t.Parallel()
s := New(failingIPLookupDB{}, streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
s := New(failingIPLookupDB{}, newTestTable(),
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
s.resolver = &fakeResolver{}
tests := []struct {
@@ -1122,30 +1086,37 @@ func (r *fakeResolver) LookupAddr(ctx context.Context, _ string) ([]string, erro
return nil, errNoSuchHost
}
// countingIPLookupDB embeds database.Store (left nil) and counts its IP
// lookups, answering each that the address has no route. Every other Store
// method is unused and would panic if called.
// countingIPLookupDB embeds database.Store (left nil) and answers the IP
// lookup's reads with nothing stored. It counts the lookups, as the reads of a
// prefix's first-seen time. Every other Store method is unused and would panic
// if called.
type countingIPLookupDB struct {
database.Store
lookups atomic.Int64
}
func (d *countingIPLookupDB) GetIPInfoContext(
_ context.Context, _ string,
) (*database.IPInfo, error) {
func (d *countingIPLookupDB) GetPrefixFirstSeen(
_ context.Context, _ netip.Prefix,
) (time.Time, error) {
d.lookups.Add(1)
return nil, database.ErrNoRoute
return time.Time{}, nil
}
func (d *countingIPLookupDB) GetASWHOIS(
_ context.Context, asn int,
) (*database.ASN, error) {
return &database.ASN{ASN: asn}, nil
}
// TestIPLookupRefusesTooManyAddresses sends IP lookups of maxLookupTargets
// addresses and of one more, listed or as a host name that resolves to them,
// and a lookup listing maxLookupTargets+1 host names. The longer lookups answer
// 400 without reading the database, and the list of host names without
// resolving any of them; the others read the database once per address. Before
// the fix every address was read and every name resolved, however many there
// were (https://git.eeqj.de/sneak/routewatch/issues/110).
// addresses in testPrefixV4 and of one more, listed or as a host name that
// resolves to them, and a lookup listing maxLookupTargets+1 host names. The
// longer lookups answer 400 without reading the database, and the list of host
// names without resolving any of them; the others read the database for each
// address. Before the fix every address was read and every name resolved,
// however many there were (https://git.eeqj.de/sneak/routewatch/issues/110).
func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
t.Parallel()
@@ -1180,8 +1151,8 @@ func TestIPLookupRefusesTooManyAddresses(t *testing.T) {
for _, tt := range tests {
db := &countingIPLookupDB{}
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(),
&config.Config{})
s := New(db, newTestTable(), streamer.New(logger.New(), metrics.New()),
logger.New(), &config.Config{})
resolver := &fakeResolver{hosts: hosts}
s.resolver = resolver
@@ -1727,9 +1698,9 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
ASN database.ASN `json:"asn"`
IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"`
IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"`
ASN database.ASN `json:"asn"`
IPv4Prefixes []LiveRoute `json:"ipv4_prefixes"`
IPv6Prefixes []LiveRoute `json:"ipv6_prefixes"`
} `json:"data"`
}
@@ -1874,9 +1845,9 @@ func TestSortRoutesByMaskLength(t *testing.T) {
}
for _, tt := range tests {
routes := make([]database.LiveRoute, 0, len(tt.prefixes))
routes := make([]LiveRoute, 0, len(tt.prefixes))
for _, prefix := range tt.prefixes {
routes = append(routes, database.LiveRoute{Prefix: prefix})
routes = append(routes, LiveRoute{Prefix: prefix})
}
sortRoutesByMaskLength(routes)
@@ -1901,16 +1872,11 @@ func TestNoOriginHasNoASAnswers(t *testing.T) {
const noOriginPrefix = "203.0.113.0/24"
s := newServerWithTestRoutes(t)
err := s.db.UpsertLiveRouteBatch([]*database.LiveRoute{{
Prefix: noOriginPrefix, MaskLength: 24, IPVersion: 4,
OriginASN: ristypes.NoOrigin, PeerIP: testPeerV4,
ASPath: []int{64500, 64501, 64502}, NextHop: testPeerV4,
LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
s.table.Announce(netip.MustParsePrefix(noOriginPrefix), ristypes.NoOrigin,
routetable.Route{
Peer: testPeerV4, ASPath: []int{64500, 64501, 64502},
Time: testRouteTime(0),
})
asn := strconv.Itoa(ristypes.NoOrigin)
@@ -0,0 +1,397 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
// The origin ASes in the routing table that newTwoOriginTable fills.
const (
mostPeersASN = 64500
fewerPeersASN = 64501
v6OriginASN = 64502
)
// noKeptRoutePrefix is the prefix of newTwoOriginTable whose origin keeps no
// route.
const noKeptRoutePrefix = "198.51.100.0/24"
// routeTimeFormat is how the pages write a route's time.
const routeTimeFormat = "2006-01-02 15:04:05"
// newTwoOriginTable returns a routing table that holds:
// - testPrefixV4 with two origin ASes. Peers 1, 2 and 3 carry it with
// mostPeersASN, which keeps the routes of peers 3 and 2. Peer 4 carries
// it with fewerPeersASN, in the prefix's newest route.
// - noKeptRoutePrefix with mostPeersASN. Only peer 7 carries it, and its
// route is not kept: peers 5 and 6, whose routes were, withdrew it.
// - testPrefixV6 with v6OriginASN.
func newTwoOriginTable() *routetable.Table {
table := emptyTable()
for n := 1; n <= 3; n++ {
announce(table, testPrefixV4, mostPeersASN, testPeer(n), testRouteTime(n))
}
announce(table, testPrefixV4, fewerPeersASN, testPeer(4), testRouteTime(5))
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(5), testRouteTime(4))
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(6), testRouteTime(6))
announce(table, noKeptRoutePrefix, mostPeersASN, testPeer(7), testRouteTime(0))
withdraw(table, noKeptRoutePrefix, testPeer(5))
withdraw(table, noKeptRoutePrefix, testPeer(6))
announce(table, testPrefixV6, v6OriginASN, testPeerV6, testRouteTime(0))
return table
}
// get sends a GET for path through s's router and fails the test unless it
// answers 200.
func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d, want %d; body %s",
path, rec.Code, http.StatusOK, rec.Body)
}
return rec
}
// 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.
func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
s.resolver = &fakeResolver{}
firstSeen := testRouteTime(-1)
err := s.db.UpdatePrefixesBatch(map[string]time.Time{testPrefixV4: firstSeen})
if err != nil {
t.Fatalf("UpdatePrefixesBatch: %v", err)
}
_, err = s.db.GetOrCreateASN(mostPeersASN, firstSeen)
if err != nil {
t.Fatalf("GetOrCreateASN: %v", err)
}
whois := &database.ASNWHOISUpdate{
ASN: mostPeersASN, ASName: "TEST-AS", CountryCode: "DE",
}
err = s.db.UpdateASNWHOIS(t.Context(), whois)
if err != nil {
t.Fatalf("UpdateASNWHOIS: %v", err)
}
// ownAddressPath looks up the request's own address, which httptest
// gives as addrInTestPrefixV4.
for _, path := range []string{
"/api/v1/ip/" + addrInTestPrefixV4, "/ip/" + addrInTestPrefixV4,
ownAddressPath,
} {
var body struct {
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)
}
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)
}
}
}
// TestIPLookupAsksForNoWHOISLookupOfOriginZero checks that the IP lookup asks
// for no WHOIS lookup of AS 0, which means no known origin. It asks for one of
// an AS whose WHOIS details were never fetched or are stale, and not of one
// whose details are fresh.
func TestIPLookupAsksForNoWHOISLookupOfOriginZero(t *testing.T) {
t.Parallel()
fresh := time.Now().Add(-time.Hour)
stale := time.Now().Add(-2 * whoisStaleThreshold)
tests := []struct {
name string
asInfo database.ASN
want bool
}{
{"no known origin", database.ASN{ASN: ristypes.NoOrigin}, false},
{"details never fetched", database.ASN{ASN: mostPeersASN}, true},
{"stale details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &stale}, true},
{"fresh details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &fresh}, false},
}
for _, tt := range tests {
if got := needsWHOISRefresh(&tt.asInfo); got != tt.want {
t.Errorf("%s: needsWHOISRefresh = %t, want %t", tt.name, got, tt.want)
}
}
}
// prefixRoute is one route of the prefix API's answer. A field the answer
// leaves out stays nil.
//
//nolint:tagliatelle // field names are the existing JSON API
type prefixRoute struct {
OriginASN int `json:"origin_asn"`
PeerIP string `json:"peer_ip"`
ASPath []int `json:"as_path"`
NextHop *string `json:"next_hop"`
LastUpdated *time.Time `json:"last_updated"`
}
// TestPrefixAnswersListEveryPeer asks the prefix API and page for a prefix
// with two origin ASes, and for one whose origin keeps no route. Each answer
// lists every peer that carries the prefix, by origin AS. Only the kept routes
// have an AS path, a next hop and a time, newest first. The other peers follow
// without them.
func TestPrefixAnswersListEveryPeer(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
// keptAt[peer] is the time of the peer's kept route; a peer not in it is
// listed without a route.
keptAt := map[string]time.Time{
testPeer(3): testRouteTime(3),
testPeer(2): testRouteTime(2),
testPeer(4): testRouteTime(5),
}
tests := []struct {
prefix string
wantPeers []string
wantOrigins []int
}{
{
testPrefixV4,
[]string{testPeer(3), testPeer(2), testPeer(1), testPeer(4)},
[]int{mostPeersASN, mostPeersASN, mostPeersASN, fewerPeersASN},
},
{noKeptRoutePrefix, []string{testPeer(7)}, []int{mostPeersASN}},
}
for _, tt := range tests {
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
Routes []prefixRoute `json:"routes"`
PeerCount int `json:"peer_count"`
OriginCount int `json:"origin_count"`
} `json:"data"`
}
path := "/api/v1/prefix/" + tt.prefix
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil {
t.Fatalf("%s: decoding the answer: %v", path, err)
}
wantOriginCount := len(slices.Compact(slices.Clone(tt.wantOrigins)))
if body.Data.PeerCount != len(tt.wantPeers) ||
body.Data.OriginCount != wantOriginCount {
t.Errorf("%s: %d peers and %d origins, want %d and %d", path,
body.Data.PeerCount, body.Data.OriginCount, len(tt.wantPeers),
wantOriginCount)
}
checkPrefixRoutes(t, path, body.Data.Routes, tt.wantPeers, tt.wantOrigins,
keptAt)
checkPrefixPage(t, s, tt.prefix, tt.wantPeers, keptAt)
}
}
// checkPrefixRoutes checks that routes come from wantPeers, in that order,
// with wantOrigins as their origin ASes. The route of a peer in keptAt must
// have an AS path, the peer as next hop, and the time keptAt gives. Any other
// route must have none of the three.
func checkPrefixRoutes(
t *testing.T, path string, routes []prefixRoute, wantPeers []string,
wantOrigins []int, keptAt map[string]time.Time,
) {
t.Helper()
if len(routes) != len(wantPeers) {
t.Fatalf("%s: routes %+v, want one each from %v", path, routes, wantPeers)
}
for i, route := range routes {
if route.PeerIP != wantPeers[i] || route.OriginASN != wantOrigins[i] {
t.Errorf("%s: route %d from %s with AS%d, want from %s with AS%d",
path, i, route.PeerIP, route.OriginASN, wantPeers[i], wantOrigins[i])
}
at, kept := keptAt[route.PeerIP]
if !kept {
if route.ASPath != nil || route.NextHop != nil || route.LastUpdated != nil {
t.Errorf("%s: route from %s is not kept but has %+v",
path, route.PeerIP, route)
}
continue
}
if route.ASPath == nil || route.NextHop == nil ||
*route.NextHop != route.PeerIP || route.LastUpdated == nil ||
!route.LastUpdated.Equal(at) {
t.Errorf("%s: kept route from %s is %+v, want its AS path, the peer "+
"as next hop and the time %v", path, route.PeerIP, route, at)
}
}
}
// checkPrefixPage checks that the page of prefix shows each of wantPeers, the
// time of each kept route among them, and no time that was never set.
func checkPrefixPage(
t *testing.T, s *Server, prefix string, wantPeers []string,
keptAt map[string]time.Time,
) {
t.Helper()
path := "/prefix/" + prefix
page := get(t, s, path).Body.String()
for _, peer := range wantPeers {
want := `<td class="peer-ip">` + peer + `</td>`
if !strings.Contains(page, want) {
t.Errorf("%s does not list the peer %s", path, peer)
}
at, kept := keptAt[peer]
if kept && !strings.Contains(page, at.Format(routeTimeFormat)) {
t.Errorf("%s does not show the time %v of the route from %s",
path, at, peer)
}
}
if strings.Contains(page, zeroDate) {
t.Errorf("%s shows the date %s", path, zeroDate)
}
}
// TestASAnswersGiveEachPrefixItsNewestKeptRoute asks the AS API and page for
// mostPeersASN. Its prefixes are testPrefixV4, whose newest kept route with
// that origin AS is peer 3's, and noKeptRoutePrefix, which keeps no route. The
// first has that route's time. The second is listed without a time.
func TestASAnswersGiveEachPrefixItsNewestKeptRoute(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
path := "/api/v1/as/" + strconv.Itoa(mostPeersASN)
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
IPv4Prefixes []struct {
Prefix string `json:"prefix"`
LastUpdated *time.Time `json:"last_updated"`
} `json:"ipv4_prefixes"`
} `json:"data"`
}
err := json.Unmarshal(get(t, s, path).Body.Bytes(), &body)
if err != nil {
t.Fatalf("%s: decoding the answer: %v", path, err)
}
prefixes := body.Data.IPv4Prefixes
if len(prefixes) != 2 || prefixes[0].Prefix != testPrefixV4 ||
prefixes[0].LastUpdated == nil ||
!prefixes[0].LastUpdated.Equal(testRouteTime(3)) ||
prefixes[1].Prefix != noKeptRoutePrefix || prefixes[1].LastUpdated != nil {
t.Errorf("%s: prefixes %+v, want %s updated %v and %s without a time",
path, prefixes, testPrefixV4, testRouteTime(3), noKeptRoutePrefix)
}
path = "/as/" + strconv.Itoa(mostPeersASN)
page := get(t, s, path).Body.String()
for _, want := range []string{
testPrefixV4, noKeptRoutePrefix, testRouteTime(3).Format(routeTimeFormat),
} {
if !strings.Contains(page, want) {
t.Errorf("%s does not show %s", path, want)
}
}
if strings.Contains(page, zeroDate) {
t.Errorf("%s shows the date %s", path, zeroDate)
}
}
// TestPrefixLengthPagesShowTheOriginMostPeersCarry asks for the IPv4 /24
// page and the IPv6 /32 page. The first lists both /24 prefixes with
// mostPeersASN, the origin AS more peers carry testPrefixV4 with, and gives
// noKeptRoutePrefix no age. The second lists testPrefixV6.
func TestPrefixLengthPagesShowTheOriginMostPeersCarry(t *testing.T) {
t.Parallel()
s := newServerWithTable(t, newTwoOriginTable())
page := get(t, s, prefixLengthPagePath).Body.String()
for _, want := range []string{
">" + testPrefixV4 + "<", ">" + noKeptRoutePrefix + "<",
`href="/as/` + strconv.Itoa(mostPeersASN) + `"`,
} {
if !strings.Contains(page, want) {
t.Errorf("%s does not show %s", prefixLengthPagePath, want)
}
}
if strings.Contains(page, `href="/as/`+strconv.Itoa(fewerPeersASN)+`"`) {
t.Errorf("%s links to AS%d, which fewer peers carry",
prefixLengthPagePath, fewerPeersASN)
}
const noAge = `<td class="age"></td>`
if strings.Count(page, noAge) != 1 {
t.Errorf("%s gives %d prefixes no age, want 1 (%s)",
prefixLengthPagePath, strings.Count(page, noAge), noKeptRoutePrefix)
}
const v6PagePath = "/prefixlength6/32"
page = get(t, s, v6PagePath).Body.String()
if !strings.Contains(page, ">"+testPrefixV6+"<") ||
!strings.Contains(page, `href="/as/`+strconv.Itoa(v6OriginASN)+`"`) {
t.Errorf("%s does not show %s with AS%d", v6PagePath, testPrefixV6,
v6OriginASN)
}
}
+67
View File
@@ -0,0 +1,67 @@
package server
import (
"errors"
"time"
)
var (
// ErrInvalidIP says that an IP address is malformed. The IP lookup returns
// it.
ErrInvalidIP = errors.New("invalid IP address")
// ErrNoRoute says that the routing table has no route for an address, a
// prefix or an AS. The lookups return it.
ErrNoRoute = errors.New("no route found")
)
// LiveRoute represents a route in the live routing table. JSON leaves out an
// empty AS path, next hop or time: the routing table in memory keeps them only
// for the two newest routes of each prefix and origin AS.
//
//nolint:tagliatelle // field names are the existing JSON API
type LiveRoute struct {
Prefix string `json:"prefix"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
OriginASN int `json:"origin_asn"`
PeerIP string `json:"peer_ip"`
ASPath []int `json:"as_path,omitempty"`
NextHop string `json:"next_hop,omitempty"`
LastUpdated time.Time `json:"last_updated,omitzero"`
}
// PrefixDistribution represents the distribution of prefixes by mask length
//
//nolint:tagliatelle // field names are the existing JSON API
type PrefixDistribution struct {
MaskLength int `json:"mask_length"`
Count int `json:"count"`
}
// IPInfo represents comprehensive IP information for the /ip endpoint
//
//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"`
// Indicates if WHOIS data needs refresh (not serialized)
NeedsWHOISRefresh bool `json:"-"`
}
+10 -1
View File
@@ -3,6 +3,10 @@
// - the AS, prefix and prefix length pages, and the JSON answers behind them
// - the IP lookups
// - the AS name search
//
// Every route it shows comes from the routing table in memory. AS names,
// WHOIS details and the times a prefix and an AS were first seen come from
// the database.
package server
import (
@@ -19,6 +23,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"github.com/go-chi/chi/v5"
)
@@ -55,6 +60,7 @@ type dnsResolver interface {
type Server struct {
router *chi.Mux
db database.Store
table *routetable.Table
streamer *streamer.Streamer
logger *logger.Logger
port int
@@ -71,15 +77,18 @@ type Server struct {
resolver dnsResolver
}
// New creates a new HTTP server
// New creates a new HTTP server that reads routes from table, the routing
// table that the prefix handler keeps current.
func New(
db database.Store,
table *routetable.Table,
streamer *streamer.Streamer,
logger *logger.Logger,
cfg *config.Config,
) *Server {
s := &Server{
db: db,
table: table,
streamer: streamer,
logger: logger,
port: cfg.Port,
+1 -1
View File
@@ -34,7 +34,7 @@ func TestStartFailsWhenPortIsHeld(t *testing.T) {
port := addr.Port
log := logger.New()
s := server.New(nil, streamer.New(log, metrics.New()), log,
s := server.New(nil, nil, streamer.New(log, metrics.New()), log,
&config.Config{Port: port})
defer func() { _ = s.Stop(context.Background()) }()
+4 -4
View File
@@ -281,8 +281,8 @@
<tr>
<td><a href="{{.Prefix | prefixURL}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td>
<td>{{if not .LastUpdated.IsZero}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{end}}</td>
<td class="age">{{if not .LastUpdated.IsZero}}{{.LastUpdated | timeSince}}{{end}}</td>
</tr>
{{end}}
</tbody>
@@ -310,8 +310,8 @@
<tr>
<td><a href="{{.Prefix | prefixURL}}" class="prefix-link">{{.Prefix}}</a></td>
<td>/{{.MaskLength}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td>
<td>{{if not .LastUpdated.IsZero}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{end}}</td>
<td class="age">{{if not .LastUpdated.IsZero}}{{.LastUpdated | timeSince}}{{end}}</td>
</tr>
{{end}}
</tbody>
+2 -2
View File
@@ -329,8 +329,8 @@
<td class="peer-ip">{{.PeerIP}}</td>
<td class="as-path">{{range $i, $as := .ASPathWithHandle}}{{if $i}} → {{end}}<a href="/as/{{$as.ASN}}" class="as-link">{{if $as.Handle}}{{$as.Handle}}{{else}}AS{{$as.ASN}}{{end}}</a>{{end}}</td>
<td class="peer-ip">{{.NextHop}}</td>
<td>{{.LastUpdated.Format "2006-01-02 15:04:05"}}</td>
<td class="age">{{.LastUpdated | timeSince}}</td>
<td>{{if not .LastUpdated.IsZero}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{end}}</td>
<td class="age">{{if not .LastUpdated.IsZero}}{{.LastUpdated | timeSince}}{{end}}</td>
</tr>
{{end}}
</tbody>
+6
View File
@@ -318,6 +318,10 @@
<span class="metric-label">IPv6 Routes</span>
<span class="metric-value" id="ipv6_routes">-</span>
</div>
<div class="metric">
<span class="metric-label">Origins Without a Kept Route</span>
<span class="metric-value" id="origins_without_kept_route">-</span>
</div>
<div class="metric">
<span class="metric-label">IPv4 Updates/sec</span>
<span class="metric-value" id="ipv4_updates_per_sec">-</span>
@@ -571,6 +575,7 @@
document.getElementById('live_routes').textContent = '-';
document.getElementById('ipv4_routes').textContent = '-';
document.getElementById('ipv6_routes').textContent = '-';
document.getElementById('origins_without_kept_route').textContent = '-';
document.getElementById('ipv4_updates_per_sec').textContent = '-';
document.getElementById('ipv6_updates_per_sec').textContent = '-';
document.getElementById('oldest_route').textContent = '-';
@@ -634,6 +639,7 @@
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes);
document.getElementById('origins_without_kept_route').textContent = formatNumber(data.origins_without_kept_route);
document.getElementById('ipv4_updates_per_sec').textContent = formatNumber(Math.round(data.ipv4_updates_per_sec));
document.getElementById('ipv6_updates_per_sec').textContent = formatNumber(Math.round(data.ipv6_updates_per_sec));
document.getElementById('oldest_route').textContent = formatRelativeTime(data.oldest_route);
+5 -4
View File
@@ -7,6 +7,7 @@ import (
"testing"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/server"
"git.eeqj.de/sneak/routewatch/internal/templates"
)
@@ -114,14 +115,14 @@ func TestASDetailPrefixListsFold(t *testing.T) {
prefixV6 = "2001:db8::/32"
)
ipv4 := []database.LiveRoute{{Prefix: prefixV4}}
ipv6 := []database.LiveRoute{{Prefix: prefixV6}}
ipv4 := []server.LiveRoute{{Prefix: prefixV4}}
ipv6 := []server.LiveRoute{{Prefix: prefixV6}}
// The fields the AS page handler gives the template.
data := struct {
ASN *database.ASN
IPv4Prefixes []database.LiveRoute
IPv6Prefixes []database.LiveRoute
IPv4Prefixes []server.LiveRoute
IPv6Prefixes []server.LiveRoute
TotalCount int
IPv4Count int
IPv6Count int