Compare commits
1
Commits
prod
...
697f1ffd3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697f1ffd3e |
+5
-4
@@ -121,10 +121,11 @@ RUN chown -R routewatch:routewatch /app
|
|||||||
|
|
||||||
ENV XDG_DATA_HOME=/var/lib
|
ENV XDG_DATA_HOME=/var/lib
|
||||||
|
|
||||||
# Cap the Go heap at 1.5 GiB so the runtime collects harder before the
|
# Cap the Go heap at 2.5 GiB so the runtime collects harder before the
|
||||||
# container's memory limit is reached. setpriv in the entrypoint preserves this
|
# container's memory limit is reached. A full routing table takes about 1 GB
|
||||||
# the way it does XDG_DATA_HOME above.
|
# of it (README "Memory"). setpriv in the entrypoint preserves this the way it
|
||||||
ENV GOMEMLIMIT=1536MiB
|
# does XDG_DATA_HOME above.
|
||||||
|
ENV GOMEMLIMIT=2560MiB
|
||||||
|
|
||||||
# Cap glibc's malloc arenas. The SQLite C library allocates and frees millions
|
# 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
|
# of small page-cache chunks from many threads; glibc otherwise creates up to
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing
|
|||||||
table in real time. It works like this:
|
table in real time. It works like this:
|
||||||
|
|
||||||
- It streams BGP UPDATE messages from the RIPE RIS Live service.
|
- It streams BGP UPDATE messages from the RIPE RIS Live service.
|
||||||
- It keeps a live routing table in SQLite.
|
- 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.
|
- It serves web pages and a JSON API for looking routes up.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
@@ -268,31 +268,36 @@ routewatch/
|
|||||||
|
|
||||||
### Component Relationships
|
### Component Relationships
|
||||||
|
|
||||||
|
`internal/routewatch/app.go` builds and starts these parts:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
│ RouteWatch │
|
│ Streamer │───▶│ Handlers │───▶│ Database │
|
||||||
│ (internal/routewatch/app.go - main orchestrator) │
|
│ │ │ │ │ │
|
||||||
├─────────────────────────────────────────────────────────────────┤
|
│ RIS Live │ │ - ASHandler │ │ SQLite with │
|
||||||
│ │
|
│ HTTP stream │ │ - PeerHandler│ │ WAL mode │
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
│ client │ │ - PrefixHdlr │ │ │
|
||||||
│ │ Streamer │───▶│ Handlers │───▶│ Database │ │
|
│ │ │ - PeeringHdlr│ │ Tables: │
|
||||||
│ │ │ │ │ │ │ │
|
└──────────────┘ └──────┬───────┘ │ - asns │
|
||||||
│ │ RIS Live │ │ - ASHandler │ │ SQLite with │ │
|
│ │ - prefixes │
|
||||||
│ │ HTTP stream │ │ - PeerHandler│ │ WAL mode │ │
|
▼ │ - routes │
|
||||||
│ │ client │ │ - PrefixHdlr │ │ │ │
|
┌──────────────┐ │ - route_peers│
|
||||||
│ │ │ │ - PeeringHdlr│ │ Tables: │ │
|
│ Routing table│───▶│ - peerings │
|
||||||
│ └──────────────┘ └──────────────┘ │ - asns │ │
|
│ in memory │ │ - bgp_peers │
|
||||||
│ │ - prefixes │ │
|
└──────────────┘ └──────────────┘
|
||||||
│ ┌──────────────┐ ┌──────────────┐ │ - live_routes│ │
|
▲ ▲
|
||||||
│ │ Server │───▶│ Handlers │───▶│ - peerings │ │
|
┌──────────────┐ ┌──────┴───────┐ │
|
||||||
│ │ │ │ │ │ - bgp_peers │ │
|
│ Server │───▶│ Handlers │───────────┘
|
||||||
│ │ Chi router │ │ Status, API │ └──────────────┘ │
|
│ │ │ │
|
||||||
│ │ port 8080 │ │ AS, Prefix │ │
|
│ 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
|
### Execution Flow
|
||||||
|
|
||||||
1. **Startup** (`cmd/routewatch/main.go` → `internal/routewatch/cli.go`)
|
1. **Startup** (`cmd/routewatch/main.go` → `internal/routewatch/cli.go`)
|
||||||
@@ -301,15 +306,17 @@ routewatch/
|
|||||||
|
|
||||||
2. **Initialization** (`internal/routewatch/app.go`)
|
2. **Initialization** (`internal/routewatch/app.go`)
|
||||||
- Database created with SQLite schema (WAL mode, page caches as in "Memory")
|
- Database created with SQLite schema (WAL mode, page caches as in "Memory")
|
||||||
- The routing table read from the database into memory, before the streamer
|
- The routing table read from the `routes` table into memory, before the
|
||||||
connects
|
streamer connects. A full table takes about 25 seconds to read.
|
||||||
- Message handlers registered with the streamer
|
- Message handlers registered with the streamer
|
||||||
- HTTP server started on configured port
|
- HTTP server started on configured port
|
||||||
|
|
||||||
3. **Message Processing Pipeline**
|
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
|
- Streamer connects to `ris-live.ripe.net` via HTTP
|
||||||
@@ -324,24 +331,41 @@ routewatch/
|
|||||||
4. **Handler Details**
|
4. **Handler Details**
|
||||||
- **ASHandler**: Tracks all ASNs seen in AS paths
|
- **ASHandler**: Tracks all ASNs seen in AS paths
|
||||||
- **PeerHandler**: Records BGP peer information
|
- **PeerHandler**: Records BGP peer information
|
||||||
- **PrefixHandler**: Maintains live routing table (upserts on announcement,
|
- **PrefixHandler**: Keeps the routing table in memory current. It applies
|
||||||
deletes on withdrawal or on an announcement whose AS path is empty or
|
each update to the table as it handles it:
|
||||||
holds AS 0 anywhere, deletes all of a peer's routes when its BGP session
|
- An announcement gives the peer a route to the prefix.
|
||||||
goes down). Routes are kept per peer IP, so when one peer IP has sessions
|
- A withdrawal removes the peer's route. So does an announcement whose
|
||||||
on two collectors, a session going down on one also removes the routes the
|
AS path is empty or holds AS 0 anywhere.
|
||||||
other still carries, until they are announced again. It also applies each
|
- A peer whose BGP session goes down leaves every prefix.
|
||||||
update to the routing table in memory, and writes the table's changes to
|
|
||||||
the database every second. A write takes at most 20,000 changed prefix and
|
Every second it writes the changed prefix and origin AS pairs to the
|
||||||
origin AS pairs, and the rest follow in the next writes.
|
`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
|
- **PeeringHandler**: Extracts AS peering relationships from AS paths
|
||||||
|
|
||||||
5. **HTTP Request Flow**
|
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
|
### 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
|
- **Batched Writes**: The handlers write the feed's updates to the database in
|
||||||
batches for performance
|
batches for performance
|
||||||
- **Backpressure**: Probabilistic message dropping when queues exceed 50%
|
- **Backpressure**: Probabilistic message dropping when queues exceed 50%
|
||||||
@@ -373,13 +397,14 @@ asns(asn PRIMARY KEY, handle, description,
|
|||||||
prefixes_v4(prefix PRIMARY KEY, first_seen) WITHOUT ROWID
|
prefixes_v4(prefix PRIMARY KEY, first_seen) WITHOUT ROWID
|
||||||
prefixes_v6(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
|
-- The routing table, one row per prefix and origin AS that some peer
|
||||||
live_routes_v4(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn,
|
-- carries: the peer set, and the newest two routes in full
|
||||||
peer_ip, as_path, next_hop, last_updated,
|
routes(prefix, origin_asn, peers,
|
||||||
UNIQUE(peer_ip, prefix))
|
newest_peer, newest_as_path, newest_time,
|
||||||
live_routes_v6(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn,
|
older_peer, older_as_path, older_time,
|
||||||
peer_ip, as_path, next_hop, last_updated,
|
PRIMARY KEY (prefix, origin_asn)) WITHOUT ROWID
|
||||||
UNIQUE(peer_ip, prefix))
|
-- The peers of the routes table, each with its number in the peer sets
|
||||||
|
route_peers(number PRIMARY KEY, address UNIQUE)
|
||||||
|
|
||||||
-- Relationship tracking
|
-- Relationship tracking
|
||||||
peerings(id PRIMARY KEY, as_a, as_b, first_seen, last_seen,
|
peerings(id PRIMARY KEY, as_a, as_b, first_seen, last_seen,
|
||||||
@@ -403,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 |
|
| `PORT` | `8080` | HTTP server port, a whole number from 1 to 65535 |
|
||||||
| `DEBUG` | (empty) | Set to `routewatch` for debug logging |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `TRUSTED_PROXIES` | (empty) | Addresses and CIDR ranges of trusted proxies; see below |
|
||||||
|
|
||||||
@@ -429,28 +454,37 @@ State directory (the database and the blocklist copies):
|
|||||||
|
|
||||||
## Memory
|
## Memory
|
||||||
|
|
||||||
The daemon holds a live routing table, so its memory grows with the size of the
|
The daemon keeps the whole routing table in memory. The image sets ceilings that
|
||||||
data it tracks. The image sets ceilings that keep it inside a 5 GiB container.
|
keep it inside a 5 GiB container.
|
||||||
|
|
||||||
Budget:
|
Budget:
|
||||||
|
|
||||||
- Go heap: a 1.5 GiB soft limit (`GOMEMLIMIT=1536MiB`, set in the image).
|
- Go heap: a 2.5 GiB soft limit (`GOMEMLIMIT=2560MiB`, set in the image).
|
||||||
- SQLite: 1 GiB of page cache for the one connection every write uses, which
|
- The routing table takes about 700 bytes per prefix and origin AS. A full
|
||||||
keeps the pages the route writes touch from one batch to the next, and 16 MiB
|
table has about 1.4 million of these pairs, so it takes about 1 GB.
|
||||||
for each of the 9 reading connections, whose cache SQLite empties whenever
|
- The rest of the heap takes up to about 0.5 GiB. Most of it is the
|
||||||
another connection has written: about 1.15 GiB in all. A 1.5 GiB soft heap
|
handlers' batches and queues, described below.
|
||||||
limit sits above the caches, so it does not shrink them, and a 2 GiB hard heap
|
- The remaining 1 GiB is room for the garbage collector. With much less, it
|
||||||
limit caps the C library.
|
would run almost all the time.
|
||||||
- glibc allocator: the SQLite C library runs on glibc `malloc`, which frees
|
- SQLite: 656 MiB of page cache.
|
||||||
page-cache chunks back to per-arena free lists rather than to the kernel, so
|
- The connection that does every write has 512 MiB. It keeps the pages the
|
||||||
process RSS tracks the high-water mark of those arenas, not SQLite's live
|
writes touch from one write to the next. With a full routing table, the
|
||||||
heap. glibc creates up to eight arenas per core, so on a many-core host the
|
`routes` table takes about 450 MB, so its pages fit.
|
||||||
retained memory — and thus RSS — grows with the core count. The image sets
|
- Each of the 9 reading connections has 16 MiB. SQLite empties a reading
|
||||||
`MALLOC_ARENA_MAX=2` to bound it; the two-arena cap costs nothing here because
|
connection's cache whenever another connection has written.
|
||||||
database writes are already serialized.
|
- 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.
|
- 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.
|
0.2 GiB for the rest.
|
||||||
|
|
||||||
Run the container with a memory limit of 5 GiB and swap disabled:
|
Run the container with a memory limit of 5 GiB and swap disabled:
|
||||||
@@ -462,38 +496,36 @@ docker run --memory=5g --memory-swap=5g ...
|
|||||||
or the equivalent in your deployment tool. This leaves headroom above the
|
or the equivalent in your deployment tool. This leaves headroom above the
|
||||||
ceilings for spikes and the kernel page cache.
|
ceilings for spikes and the kernel page cache.
|
||||||
|
|
||||||
Override the Go soft limit by setting `GOMEMLIMIT` in the environment (for
|
To replace the image's Go soft limit, set `GOMEMLIMIT` in the environment, for
|
||||||
example `-e GOMEMLIMIT=1GiB`); this replaces the image default.
|
example `-e GOMEMLIMIT=3GiB`. A limit close to the routing table's size makes
|
||||||
`MALLOC_ARENA_MAX` can be overridden the same way, but raising it lets RSS climb
|
the garbage collector run almost all the time. `MALLOC_ARENA_MAX` can be
|
||||||
again on a many-core host.
|
overridden the same way, but raising it lets RSS climb again on a many-core
|
||||||
|
host.
|
||||||
|
|
||||||
What happens at each limit:
|
What happens at each limit:
|
||||||
|
|
||||||
- Go soft limit: as the heap approaches `GOMEMLIMIT`, the runtime runs garbage
|
- Go soft limit: as the heap approaches `GOMEMLIMIT`, the runtime runs garbage
|
||||||
collection more aggressively rather than growing further.
|
collection more aggressively rather than growing further.
|
||||||
- SQLite: at the 1.5 GiB soft heap limit it recycles its page cache instead of
|
- SQLite: at the 768 MiB soft heap limit it recycles its page cache instead of
|
||||||
allocating more; at the 2 GiB hard heap limit a statement fails with an
|
allocating more. At the 1 GiB hard heap limit a statement fails with an
|
||||||
out-of-memory error, and the handler logs it and drops that batch. The process
|
out-of-memory error, and the handler logs it. The prefix handler writes the
|
||||||
keeps running.
|
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.
|
- 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
|
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
|
`STATE` message, which it holds, in feed order, until there is room: a few
|
||||||
hundred bytes each, about ten a second.
|
hundred bytes each, about ten a second.
|
||||||
- Handler batches: while the AS, peer or prefix handler writes a batch to the
|
- 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
|
database, the updates that arrive collect in its next batch. That batch stops
|
||||||
ten times the batch size: 300,000 updates for the AS handler
|
at ten times the batch size:
|
||||||
(`asnBatchMaxSize`, about 10 MiB), 100,000 for the peer handler
|
- 300,000 updates for the AS handler (`asnBatchMaxSize`, about 10 MiB);
|
||||||
(`peerBatchMaxSize`, about 10 MiB) and 250,000 for the prefix handler
|
- 100,000 for the peer handler (`peerBatchMaxSize`, about 10 MiB);
|
||||||
(`prefixBatchMaxSize`, about 60 MiB). At that size the handler takes nothing
|
- 250,000 for the prefix handler (`prefixBatchMaxSize`, about 15 MiB).
|
||||||
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
|
At that size the handler takes nothing more off its queue until the write
|
||||||
session went down fails, the prefix handler keeps the updates the peer sent
|
ends. The queue then fills, and the streamer drops messages as above. With
|
||||||
after the down for the next write, which retries the delete first; on top of
|
the batches being written, the three handlers' batches take at most about
|
||||||
its 250,000 it keeps at most the last 25,000 of them (`prefixBatchSize`, about
|
0.1 GiB of the Go heap.
|
||||||
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.
|
|
||||||
|
|
||||||
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
|
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
|
||||||
with the goroutine count and Go memory figures.
|
with the goroutine count and Go memory figures.
|
||||||
@@ -521,21 +553,19 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
|
|||||||
- Stop grace: the container needs 9 seconds between SIGTERM and SIGKILL. upaas
|
- Stop grace: the container needs 9 seconds between SIGTERM and SIGKILL. upaas
|
||||||
gives 10 (`stopTimeoutSeconds` in its `internal/docker/client.go`), as
|
gives 10 (`stopTimeoutSeconds` in its `internal/docker/client.go`), as
|
||||||
`docker stop` does by default. The daemon allows 8 seconds for the handlers to
|
`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
|
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
|
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
|
and each handler log how much.
|
||||||
feed, a stop loses far more: its batch is full at 250,000 updates and the
|
- The prefix handler's last write takes at most 20,000 of the routing
|
||||||
streamer's worker for it waits for room, so the streamer's stop takes the
|
table's changed pairs. A full-table peer's session going down changes
|
||||||
whole 8 seconds and every handler then skips its last write. Lost then are the
|
about a million pairs, which take about a minute to write. A stop in that
|
||||||
messages left on the four handlers' queues, what the AS, peer and peering
|
minute leaves the rest unwritten.
|
||||||
handlers hold, the prefix handler's batch, and the write it has in progress,
|
- A lost withdrawal or peer down leaves its routes in the `routes` table,
|
||||||
up to another 250,000 updates, which take about 20 seconds and are cut off by
|
and the next start reads them back. They stay until the peer sends another
|
||||||
the exit; withdrawals and peer downs are among them. A route withdrawn in them
|
update for the prefix, or its session goes down.
|
||||||
stays in the live route table until its peer announces or withdraws the prefix
|
- When the 8 seconds run out the database is not closed. When the database
|
||||||
again or its session goes down. When the 8 seconds run out the database is not
|
next opens, SQLite rolls back any write the exit cut off.
|
||||||
closed, and SQLite rolls back a write the exit cut off when the database next
|
|
||||||
opens.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,23 @@ are measured by a benchmark, not yet on the live feed.
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Run `next` on the live feed, as README "Running under upaas" describes, for the
|
Run `next` on the live feed, as README "Running under upaas" describes, until
|
||||||
final check https://git.eeqj.de/sneak/routewatch/issues/96 names: whether the
|
the routing table stops growing. Post its size and memory figures on
|
||||||
prefix handler still drops updates after the first hour. If it does, a new issue
|
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
|
follows with the numbers. Then an audit of the code at the commit that run
|
||||||
tested.
|
tested.
|
||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
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
|
the one most peers carry. Its `last_seen` is the newest kept route's time. The
|
||||||
@@ -277,5 +286,4 @@ tested.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- the rest of https://git.eeqj.de/sneak/routewatch/issues/122: removing the
|
None.
|
||||||
per-peer route tables
|
|
||||||
|
|||||||
+9
-100
@@ -6,11 +6,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// liveCounts holds the running row counts and the prefix distribution that the
|
// liveCounts holds the running row counts that the stats endpoints report.
|
||||||
// stats endpoints report. They are seeded once at startup from the tables and
|
// They are seeded once at startup from the tables and then adjusted on every
|
||||||
// then adjusted on every write, so a stats read serves them from memory instead
|
// write, so a stats read serves them from memory instead of running a query
|
||||||
// of running a query over the tables. The COUNT(*) scans (issue 27) and then
|
// over the tables. The COUNT(*) scans (issue 27) grew with the database until
|
||||||
// the prefix distribution query (issue 30) each grew with the database until
|
|
||||||
// they took the whole request timeout and made /api/v1/stats return 500.
|
// 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
|
// A single mutex guards all fields so the stats reader takes a consistent
|
||||||
@@ -23,20 +22,11 @@ type liveCounts struct {
|
|||||||
prefixesV6 int
|
prefixesV6 int
|
||||||
peerings int
|
peerings int
|
||||||
peers 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
|
// 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.
|
// before any writer, so it needs no coordination with the adjust methods.
|
||||||
func (c *liveCounts) seed(
|
func (c *liveCounts) seed(asns, prefixesV4, prefixesV6, peerings, peers int) {
|
||||||
asns, prefixesV4, prefixesV6, peerings, peers, routesV4, routesV6 int,
|
|
||||||
distributionV4, distributionV6 []PrefixDistribution,
|
|
||||||
) {
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
@@ -45,16 +35,6 @@ func (c *liveCounts) seed(
|
|||||||
c.prefixesV6 = prefixesV6
|
c.prefixesV6 = prefixesV6
|
||||||
c.peerings = peerings
|
c.peerings = peerings
|
||||||
c.peers = peers
|
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.
|
// addASNs adds n to the ASN count.
|
||||||
@@ -86,41 +66,7 @@ func (c *liveCounts) addPeers(n int) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// addRoutes adds to the IPv4 and IPv6 live-route counts. Deletions pass
|
// fill copies the counts into a Stats, including the derived total, under a
|
||||||
// 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
|
|
||||||
// single read lock so the reader sees one consistent snapshot.
|
// single read lock so the reader sees one consistent snapshot.
|
||||||
func (c *liveCounts) fill(s *Stats) {
|
func (c *liveCounts) fill(s *Stats) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
@@ -132,26 +78,6 @@ func (c *liveCounts) fill(s *Stats) {
|
|||||||
s.Prefixes = c.prefixesV4 + c.prefixesV6
|
s.Prefixes = c.prefixesV4 + c.prefixesV6
|
||||||
s.Peerings = c.peerings
|
s.Peerings = c.peerings
|
||||||
s.Peers = c.peers
|
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
|
// 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
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedCounts reads the current row counts and prefix distribution from the
|
// seedCounts reads the current row counts from the tables into the in-memory
|
||||||
// tables into the in-memory counters. It runs once at startup, before the
|
// counters. It runs once at startup, before the streamer begins writing.
|
||||||
// streamer begins writing.
|
|
||||||
func (d *Database) seedCounts(ctx context.Context) error {
|
func (d *Database) seedCounts(ctx context.Context) error {
|
||||||
asns, err := d.countRows(ctx, "asns")
|
asns, err := d.countRows(ctx, "asns")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,23 +121,7 @@ func (d *Database) seedCounts(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
routesV4, err := d.countRows(ctx, tableLiveRoutesV4)
|
d.counts.seed(asns, prefixesV4, prefixesV6, peerings, peers)
|
||||||
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)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,62 +3,23 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
"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"
|
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"
|
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
|
// TestLiveCountsTrackWritesInRealtime checks that the stats counts start at
|
||||||
// zero, reflect each write the moment it commits (no recompute, no timer), do
|
// zero and reflect each write the moment it commits (no recompute, no timer).
|
||||||
// not move when a route is merely re-announced, and drop when a route is
|
// These counts are what /api/v1/stats reports; before this change the
|
||||||
// deleted. These counts are what /api/v1/stats reports; before this change the
|
|
||||||
// endpoint recomputed them with a COUNT(*) over each table on every request.
|
// endpoint recomputed them with a COUNT(*) over each table on every request.
|
||||||
func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
|
func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -79,7 +40,7 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if empty.ASNs != 0 || empty.Prefixes != 0 || empty.Peerings != 0 ||
|
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)
|
t.Fatalf("empty database counts nonzero: %+v", empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,17 +48,6 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
|
|||||||
|
|
||||||
writeASNsPrefixesAndPeers(t, db, ts)
|
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)
|
stats, err := db.GetStatsContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetStatsContext: %v", err)
|
t.Fatalf("GetStatsContext: %v", err)
|
||||||
@@ -105,46 +55,11 @@ func TestLiveCountsTrackWritesInRealtime(t *testing.T) {
|
|||||||
|
|
||||||
assertCounts(t, "after inserts", stats, wantCounts{
|
assertCounts(t, "after inserts", stats, wantCounts{
|
||||||
asns: 2, prefixes: 2, peerings: 1, peers: 1,
|
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
|
// 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) {
|
func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -180,8 +95,8 @@ func writeASNsPrefixesAndPeers(t *testing.T, db *Database, ts time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestLiveCountsSeededFromDatabaseAtStartup writes rows, reopens the same
|
// TestLiveCountsSeededFromDatabaseAtStartup writes rows, reopens the same
|
||||||
// database file, and checks the counts and the prefix distribution come back
|
// database file, and checks the counts come back from the seed scan rather
|
||||||
// from the seed scan rather than starting at zero.
|
// than starting at zero.
|
||||||
func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
|
func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -192,23 +107,7 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
|
|||||||
t.Fatalf("failed to create database: %v", err)
|
t.Fatalf("failed to create database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
writeASNsPrefixesAndPeers(t, db, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Close()
|
err = db.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,436 +125,10 @@ func TestLiveCountsSeededFromDatabaseAtStartup(t *testing.T) {
|
|||||||
t.Fatalf("GetStatsContext after reopen: %v", err)
|
t.Fatalf("GetStatsContext after reopen: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.ASNs != 3 {
|
assertCounts(t, "after reopen", stats, wantCounts{
|
||||||
t.Errorf("seeded ASNs = %d, want 3", stats.ASNs)
|
asns: 2, prefixes: 2, peerings: 1, peers: 1,
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so
|
// TestLiveCountsConcurrentReadWrite runs writers and stats readers at once so
|
||||||
// the race detector proves the counters are safe under concurrent use.
|
// the race detector proves the counters are safe under concurrent use.
|
||||||
@@ -672,7 +145,10 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
|
|||||||
|
|
||||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
|
||||||
const writers = 4
|
const (
|
||||||
|
writers = 4
|
||||||
|
prefixesPerWrite = 25
|
||||||
|
)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(writers)
|
wg.Add(writers)
|
||||||
@@ -681,14 +157,12 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
|
|||||||
go func(base int) {
|
go func(base int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
for i := range 25 {
|
for i := range prefixesPerWrite {
|
||||||
asn := 65000 + base*100 + i
|
prefix := fmt.Sprintf("10.%d.%d.0/24", base, i)
|
||||||
route := mkV6Route(testV6Prefix, asn, ts)
|
|
||||||
route.PeerIP = "2001:db8::" + uuid.NewString()
|
|
||||||
|
|
||||||
err := db.UpsertLiveRoute(route)
|
err := db.UpdatePrefixesBatch(map[string]time.Time{prefix: ts})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("UpsertLiveRoute: %v", err)
|
t.Errorf("UpdatePrefixesBatch: %v", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -728,12 +202,9 @@ func TestLiveCountsConcurrentReadWrite(t *testing.T) {
|
|||||||
t.Fatalf("GetStatsContext: %v", err)
|
t.Fatalf("GetStatsContext: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if want := writers * 25; stats.IPv6Routes != want {
|
if want := writers * prefixesPerWrite; stats.IPv4Prefixes != want {
|
||||||
t.Errorf("IPv6Routes = %d, want %d", stats.IPv6Routes, 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 {
|
type wantCounts struct {
|
||||||
@@ -741,9 +212,6 @@ type wantCounts struct {
|
|||||||
prefixes int
|
prefixes int
|
||||||
peerings int
|
peerings int
|
||||||
peers int
|
peers int
|
||||||
ipv4Routes int
|
|
||||||
ipv6Routes int
|
|
||||||
liveRoutes int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
|
func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
|
||||||
@@ -764,24 +232,4 @@ func assertCounts(t *testing.T, when string, got Stats, want wantCounts) {
|
|||||||
if got.Peers != want.peers {
|
if got.Peers != want.peers {
|
||||||
t.Errorf("%s: Peers = %d, want %d", when, 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1454
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
// Package database provides SQLite storage for BGP routing data including ASNs,
|
// Package database provides SQLite storage for BGP routing data: ASNs,
|
||||||
// prefixes, announcements, peerings, and live route tables. Its routes table
|
// prefixes, announcements, peerings and BGP peers. Its routes table holds a
|
||||||
// holds a copy of the whole routing table that internal/routetable keeps in
|
// copy of the whole routing table that internal/routetable keeps in memory.
|
||||||
// memory.
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +11,8 @@ import (
|
|||||||
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stats contains database statistics including counts for ASNs, prefixes,
|
// Stats contains database statistics: counts of ASNs, prefixes, peerings and
|
||||||
// peerings, peers, and live routes, as well as file size and prefix distribution data.
|
// peers, and the file size.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
ASNs int
|
ASNs int
|
||||||
Prefixes int
|
Prefixes int
|
||||||
@@ -22,16 +21,11 @@ type Stats struct {
|
|||||||
Peerings int
|
Peerings int
|
||||||
Peers int
|
Peers int
|
||||||
FileSizeBytes int64
|
FileSizeBytes int64
|
||||||
LiveRoutes int
|
|
||||||
IPv4Routes int
|
|
||||||
IPv6Routes int
|
|
||||||
IPv4PrefixDistribution []PrefixDistribution
|
|
||||||
IPv6PrefixDistribution []PrefixDistribution
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store defines the interface for database operations. It provides methods for
|
// Store defines the interface for database operations. It provides methods for
|
||||||
// managing ASNs, prefixes, announcements, peerings, BGP peers, and live routes.
|
// managing ASNs, prefixes, announcements, peerings, BGP peers, and the copy of
|
||||||
// Implementations must be safe for concurrent use.
|
// the routing table. Implementations must be safe for concurrent use.
|
||||||
//
|
//
|
||||||
//nolint:interfacebloat // the one seam handlers and tests depend on
|
//nolint:interfacebloat // the one seam handlers and tests depend on
|
||||||
type Store interface {
|
type Store interface {
|
||||||
@@ -57,27 +51,10 @@ type Store interface {
|
|||||||
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
|
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
|
||||||
UpdatePeerBatch(peers map[string]PeerUpdate) 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)
|
|
||||||
|
|
||||||
// Routing table operations: the database's copy of the table in memory
|
// Routing table operations: the database's copy of the table in memory
|
||||||
WriteRouteChanges(changes []routetable.Change, newPeers map[string]uint) error
|
WriteRouteChanges(changes []routetable.Change, newPeers map[string]uint) error
|
||||||
ReadRouteTable(ctx context.Context) (*routetable.Table, error)
|
ReadRouteTable(ctx context.Context) (*routetable.Table, error)
|
||||||
|
|
||||||
// IP lookup operations
|
|
||||||
GetIPInfo(ip string) (*IPInfo, error)
|
|
||||||
GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error)
|
|
||||||
|
|
||||||
// ASN WHOIS operations
|
// ASN WHOIS operations
|
||||||
GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error)
|
GetNextStaleASN(ctx context.Context, staleThreshold time.Duration) (int, error)
|
||||||
UpdateASNWHOIS(ctx context.Context, update *ASNWHOISUpdate) error
|
UpdateASNWHOIS(ctx context.Context, update *ASNWHOISUpdate) error
|
||||||
@@ -87,18 +64,8 @@ type Store interface {
|
|||||||
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
|
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
|
||||||
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
|
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
|
||||||
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
|
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
|
||||||
GetASDetails(asn int) (*ASN, []LiveRoute, error)
|
|
||||||
GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error)
|
|
||||||
GetASPeers(asn int) ([]ASPeer, error)
|
GetASPeers(asn int) ([]ASPeer, error)
|
||||||
GetASPeersContext(ctx context.Context, 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
|
// Lifecycle
|
||||||
Close() error
|
Close() error
|
||||||
|
|||||||
@@ -66,65 +66,6 @@ type ASNPeering struct {
|
|||||||
LastSeen time.Time
|
LastSeen time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
// 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
|
// 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
|
// last seen at Last. A row already stored takes First as its first_seen only
|
||||||
|
|||||||
@@ -89,65 +89,6 @@ 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_last_seen ON bgp_peers(last_seen);
|
||||||
CREATE INDEX IF NOT EXISTS idx_bgp_peers_ip ON bgp_peers(peer_ip);
|
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)
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 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 whole routing table, as internal/routetable keeps it in memory: one row
|
-- 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
|
-- 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.
|
-- row, and the rows of the pairs that change are written back.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -20,15 +18,3 @@ func detectIPVersion(prefix string) int {
|
|||||||
|
|
||||||
return ipVersionV4
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
|||||||
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
|
rw.peerHandler = NewPeerHandler(rw.db, rw.logger)
|
||||||
rw.streamer.RegisterHandler(rw.peerHandler)
|
rw.streamer.RegisterHandler(rw.peerHandler)
|
||||||
|
|
||||||
// PrefixHandler maintains the prefixes and live_routes tables, and
|
// PrefixHandler keeps the routing table in memory current, and writes
|
||||||
// the routing table in memory and its copy in the routes table
|
// its changes to the routes table and new prefixes to the prefix
|
||||||
|
// tables
|
||||||
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger, rw.table)
|
rw.prefixHandler = NewPrefixHandler(rw.db, rw.logger, rw.table)
|
||||||
rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker())
|
rw.prefixHandler.SetMetricsTracker(rw.streamer.GetMetricsTracker())
|
||||||
rw.streamer.RegisterHandler(rw.prefixHandler)
|
rw.streamer.RegisterHandler(rw.prefixHandler)
|
||||||
@@ -125,8 +126,6 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
|||||||
return errNonBatchedNotImplemented
|
return errNonBatchedNotImplemented
|
||||||
}
|
}
|
||||||
|
|
||||||
// No longer need routing table handler - PrefixHandler maintains live_routes table
|
|
||||||
|
|
||||||
// Start streaming
|
// Start streaming
|
||||||
err := rw.streamer.Start(ctx)
|
err := rw.streamer.Start(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -33,14 +33,12 @@ type mockStore struct {
|
|||||||
ASNCount int
|
ASNCount int
|
||||||
PrefixCount int
|
PrefixCount int
|
||||||
PeeringCount int
|
PeeringCount int
|
||||||
RouteCount int
|
|
||||||
WithdrawalCount int
|
WithdrawalCount int
|
||||||
|
|
||||||
// Track unique items
|
// Track unique items
|
||||||
ASNs map[int]*database.ASN
|
ASNs map[int]*database.ASN
|
||||||
Prefixes map[string]*database.Prefix
|
Prefixes map[string]*database.Prefix
|
||||||
Peerings map[string]bool // key is "from_to"
|
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
|
// TablePrefixes holds the prefixes that the written changes of the
|
||||||
// routing table leave some peer carrying.
|
// routing table leave some peer carrying.
|
||||||
TablePrefixes map[string]bool
|
TablePrefixes map[string]bool
|
||||||
@@ -56,7 +54,6 @@ func newMockStore() *mockStore {
|
|||||||
ASNs: make(map[int]*database.ASN),
|
ASNs: make(map[int]*database.ASN),
|
||||||
Prefixes: make(map[string]*database.Prefix),
|
Prefixes: make(map[string]*database.Prefix),
|
||||||
Peerings: make(map[string]bool),
|
Peerings: make(map[string]bool),
|
||||||
Routes: make(map[string]bool),
|
|
||||||
TablePrefixes: make(map[string]bool),
|
TablePrefixes: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +104,13 @@ func (m *mockStore) UpdatePrefixesBatch(prefixes map[string]time.Time) error {
|
|||||||
IPVersion: ipVersion,
|
IPVersion: ipVersion,
|
||||||
FirstSeen: timestamp,
|
FirstSeen: timestamp,
|
||||||
}
|
}
|
||||||
|
m.PrefixCount++
|
||||||
|
|
||||||
|
if ipVersion == ipVersionV4 {
|
||||||
|
m.IPv4Prefixes++
|
||||||
|
} else {
|
||||||
|
m.IPv6Prefixes++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,65 +211,6 @@ func (m *mockStore) GetStatsContext(_ context.Context) (database.Stats, error) {
|
|||||||
return m.GetStats()
|
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) {
|
|
||||||
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 nil, nil, database.ErrNoRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetASDetailsContext mock implementation with context support
|
|
||||||
func (m *mockStore) GetASDetailsContext(
|
|
||||||
_ context.Context, asn int,
|
|
||||||
) (*database.ASN, []database.LiveRoute, error) {
|
|
||||||
return m.GetASDetails(asn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAS returns the stored AS, or only its number when none is stored.
|
// 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) {
|
func (m *mockStore) GetAS(_ context.Context, asn int) (*database.ASN, bool, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -292,35 +237,6 @@ func (m *mockStore) GetPrefixFirstSeen(
|
|||||||
return time.Time{}, nil
|
return time.Time{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetASPeers mock implementation
|
// GetASPeers mock implementation
|
||||||
func (m *mockStore) GetASPeers(_ int) ([]database.ASPeer, error) {
|
func (m *mockStore) GetASPeers(_ int) ([]database.ASPeer, error) {
|
||||||
// Return empty peers for now
|
// Return empty peers for now
|
||||||
@@ -334,32 +250,6 @@ func (m *mockStore) GetASPeersContext(
|
|||||||
return m.GetASPeers(asn)
|
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
|
// GetNextStaleASN mock implementation
|
||||||
func (m *mockStore) GetNextStaleASN(_ context.Context, _ time.Duration) (int, error) {
|
func (m *mockStore) GetNextStaleASN(_ context.Context, _ time.Duration) (int, error) {
|
||||||
return 0, database.ErrNoStaleASN
|
return 0, database.ErrNoStaleASN
|
||||||
@@ -387,46 +277,6 @@ func (m *mockStore) GetWHOISStats(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertLiveRouteBatch mock implementation
|
|
||||||
func (m *mockStore) UpsertLiveRouteBatch(routes []*database.LiveRoute) 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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteRouteChanges records the prefixes the changes leave some peer carrying,
|
// WriteRouteChanges records the prefixes the changes leave some peer carrying,
|
||||||
// whatever their origin AS.
|
// whatever their origin AS.
|
||||||
func (m *mockStore) WriteRouteChanges(
|
func (m *mockStore) WriteRouteChanges(
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ func TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS(t *testing.T) {
|
|||||||
h.HandleMessage(second)
|
h.HandleMessage(second)
|
||||||
h.flushBatch()
|
h.flushBatch()
|
||||||
|
|
||||||
asn, _, err := db.GetASDetails(testASNA)
|
asn, _, err := db.GetAS(t.Context(), testASNA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetASDetails(%d): %v", testASNA, err)
|
t.Fatalf("GetAS(%d): %v", testASNA, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !asn.FirstSeen.Equal(first.ParsedTimestamp) {
|
if !asn.FirstSeen.Equal(first.ParsedTimestamp) {
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ func TestFlushLoopWritesOnEveryTick(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// blockingStore is a mockStore whose batch writes for the AS, peer and prefix
|
// 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
|
// handlers and peering write wait until the test calls releaseWrites.
|
||||||
// calls releaseWrites. writeStarted is closed when the first of them starts. It
|
// writeStarted is closed when the first of them starts. It records what each
|
||||||
// records what each batch write was given.
|
// batch write was given.
|
||||||
type blockingStore struct {
|
type blockingStore struct {
|
||||||
*mockStore
|
*mockStore
|
||||||
|
|
||||||
@@ -109,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.waitForRelease()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
for _, route := range routes {
|
for prefix := range prefixes {
|
||||||
s.prefixes[route.Prefix] = true
|
s.prefixes[prefix] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -145,12 +145,6 @@ func (s *blockingStore) UpdatePeerBatch(peers map[string]database.PeerUpdate) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *blockingStore) DeleteLiveRoutesOfPeer(peerIP string) (int, error) {
|
|
||||||
s.waitForRelease()
|
|
||||||
|
|
||||||
return s.mockStore.DeleteLiveRoutesOfPeer(peerIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *blockingStore) RecordPeering(
|
func (s *blockingStore) RecordPeering(
|
||||||
asA, asB int, seen database.SeenTimes,
|
asA, asB int, seen database.SeenTimes,
|
||||||
) error {
|
) error {
|
||||||
@@ -176,7 +170,6 @@ func (s *blockingStore) releaseWrites() {
|
|||||||
type stopLog struct {
|
type stopLog struct {
|
||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
Updates int `json:"updates"`
|
Updates int `json:"updates"`
|
||||||
PeersDown int `json:"peers_down"`
|
|
||||||
TableChanges int `json:"table_changes"`
|
TableChanges int `json:"table_changes"`
|
||||||
Paths int `json:"paths"`
|
Paths int `json:"paths"`
|
||||||
}
|
}
|
||||||
@@ -374,10 +367,10 @@ func stopWhileAWriteIsBlocked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline also checks the
|
// TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline also checks the
|
||||||
// routing table's changes: the flush whose delete is held wrote those of the
|
// routing table's changes: the flush whose prefix write is held wrote those of
|
||||||
// fill, so Stop must log the one of the extra prefix as not written. The flush
|
// the fill, so Stop must log the one of the extra prefix as not written. The
|
||||||
// timer must not fire. A flush it started before the fill would be the one
|
// flush timer must not fire. A flush it started before the fill would be the
|
||||||
// held, and it would not have written the fill's change.
|
// one held, and it would not have written the fill's change.
|
||||||
func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
|
func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -389,10 +382,8 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
|
|||||||
h := newPrefixHandler(store, newErrorLogger(&logs),
|
h := newPrefixHandler(store, newErrorLogger(&logs),
|
||||||
routetable.New(routetable.Params{}), longerThanAnyTest)
|
routetable.New(routetable.Params{}), longerThanAnyTest)
|
||||||
fill := slices.Repeat([]string{testV4Prefix}, prefixBatchSize)
|
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,
|
stopWhileAWriteIsBlocked(t, store, h, h.done,
|
||||||
[]*ristypes.RISMessage{sessionDown(), prefixAnnouncement(fill)},
|
[]*ristypes.RISMessage{prefixAnnouncement(fill)},
|
||||||
prefixAnnouncement([]string{extraPrefix}))
|
prefixAnnouncement([]string{extraPrefix}))
|
||||||
|
|
||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
@@ -403,11 +394,10 @@ func TestPrefixHandlerSkipsItsLastWriteAfterTheStopDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logged := lastLogged(t, &logs)
|
logged := lastLogged(t, &logs)
|
||||||
if logged.Updates != prefixBatchSize+1 || logged.PeersDown != 1 ||
|
if logged.Updates != prefixBatchSize+1 || logged.TableChanges != 1 {
|
||||||
logged.TableChanges != 1 {
|
t.Errorf("Stop logged %d updates and %d table changes not written, "+
|
||||||
t.Errorf("Stop logged %d updates, %d peer downs and %d table changes "+
|
"want %d and 1", logged.Updates, logged.TableChanges,
|
||||||
"not written, want %d, 1 and 1", logged.Updates, logged.PeersDown,
|
prefixBatchSize+1)
|
||||||
logged.TableChanges, prefixBatchSize+1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package routewatch
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,11 +26,9 @@ const (
|
|||||||
prefixBatchSize = 25000
|
prefixBatchSize = 25000
|
||||||
|
|
||||||
// prefixBatchMaxSize is the most prefix updates the batch holds while the
|
// 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
|
// (README "Memory"). At this size HandleMessage waits until the flush
|
||||||
// goroutine takes the batch. On top of these, the handler holds at most
|
// goroutine takes the batch.
|
||||||
// prefixBatchSize updates, about 6 MiB, that a flush kept for the peers
|
|
||||||
// whose route delete failed (held).
|
|
||||||
prefixBatchMaxSize = 250000
|
prefixBatchMaxSize = 250000
|
||||||
|
|
||||||
// prefixBatchTimeout is the maximum time to wait before flushing a batch
|
// prefixBatchTimeout is the maximum time to wait before flushing a batch
|
||||||
@@ -48,47 +44,31 @@ const (
|
|||||||
// most one pair. So the million pairs that a full-table peer's session
|
// most one pair. So the million pairs that a full-table peer's session
|
||||||
// going down changes are written in about a minute.
|
// going down changes are written in about a minute.
|
||||||
maxTableChangesPerWrite = 20000
|
maxTableChangesPerWrite = 20000
|
||||||
|
|
||||||
// IP version constants
|
|
||||||
ipv4Version = 4
|
|
||||||
ipv6Version = 6
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the
|
// PrefixHandler keeps the routing table in memory current from the feed. It
|
||||||
// database. Routes are added on announcement and deleted on withdrawal or on an
|
// applies each update to the table as it handles it. An announcement gives
|
||||||
// announcement whose AS path is empty or holds AS 0 anywhere, and all routes of
|
// the peer a route to the prefix. A withdrawal removes the peer's route, as
|
||||||
// a peer are deleted when its BGP session goes down.
|
// does an announcement whose AS path is empty or holds AS 0 anywhere. A peer
|
||||||
|
// whose BGP session goes down leaves every prefix.
|
||||||
//
|
//
|
||||||
// It applies the same updates to the routing table in memory as it handles
|
// Each flush writes the table's changes to the database. It also stores each
|
||||||
// them, and writes the table's changes to the database with each flush.
|
// prefix the updates name with the time it was first seen.
|
||||||
type PrefixHandler struct {
|
type PrefixHandler struct {
|
||||||
db database.Store
|
db database.Store
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
metrics *metrics.Tracker
|
metrics *metrics.Tracker
|
||||||
table *routetable.Table
|
table *routetable.Table
|
||||||
|
|
||||||
// Batching. mu guards batch, held, peersDown, writing, deleting and
|
// Batching. mu guards batch, writing and writingTableChanges only: the
|
||||||
// writingTableChanges only: the flush goroutine takes them under mu and
|
// flush goroutine takes them under mu and writes them after releasing
|
||||||
// writes them after releasing mu, so HandleMessage waits for a database
|
// mu, so HandleMessage waits for a database write only while batch is at
|
||||||
// write only while batch is at prefixBatchMaxSize.
|
// prefixBatchMaxSize.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
batch []prefixUpdate
|
batch []seenPrefix
|
||||||
// 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
|
|
||||||
// writing is how many updates the flush goroutine took and has not
|
// writing is how many updates the flush goroutine took and has not
|
||||||
// finished writing.
|
// finished writing.
|
||||||
writing int
|
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
|
// writingTableChanges is how many changes the flush goroutine took from
|
||||||
// the routing table and has not finished writing.
|
// the routing table and has not finished writing.
|
||||||
writingTableChanges int
|
writingTableChanges int
|
||||||
@@ -102,6 +82,15 @@ type PrefixHandler struct {
|
|||||||
done chan struct{} // closed when the flush goroutine returns
|
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 {
|
type prefixUpdate struct {
|
||||||
prefix string
|
prefix string
|
||||||
originASN int
|
originASN int
|
||||||
@@ -144,7 +133,7 @@ func newPrefixHandler(
|
|||||||
db: db,
|
db: db,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
table: table,
|
table: table,
|
||||||
batch: make([]prefixUpdate, 0, prefixBatchSize),
|
batch: make([]seenPrefix, 0, prefixBatchSize),
|
||||||
batchFullCh: make(chan struct{}, 1),
|
batchFullCh: make(chan struct{}, 1),
|
||||||
stopCh: make(chan context.Context, 1),
|
stopCh: make(chan context.Context, 1),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
@@ -181,7 +170,7 @@ func (h *PrefixHandler) QueueCapacity() int {
|
|||||||
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||||
if msg.Type == ristypes.MessageTypeState {
|
if msg.Type == ristypes.MessageTypeState {
|
||||||
if msg.State == ristypes.StateDown {
|
if msg.State == ristypes.StateDown {
|
||||||
h.peerSessionDown(msg.Peer)
|
h.table.PeerDown(msg.Peer)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -206,12 +195,11 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
h.batchTaken.Wait()
|
h.batchTaken.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Withdrawals are queued, and applied to the routing table, before
|
// Withdrawals are applied to the routing table before announcements, so
|
||||||
// announcements, because the batch keeps each peer's last update for a
|
// a message that lists a prefix as both withdrawn and announced keeps the
|
||||||
// prefix: a message that lists a prefix as both withdrawn and announced
|
// route (RFC 4271 section 4.3). A withdrawal names only the prefix and
|
||||||
// keeps the route (RFC 4271 section 4.3). A withdrawal names only the
|
// peer: the message's AS path, and so its origin AS, belongs to the
|
||||||
// prefix and peer: the message's AS path, and so its origin AS, belongs
|
// announced prefixes.
|
||||||
// to the announced prefixes.
|
|
||||||
for _, prefix := range msg.Withdrawals {
|
for _, prefix := range msg.Withdrawals {
|
||||||
update := prefixUpdate{
|
update := prefixUpdate{
|
||||||
prefix: canonicalPrefix(prefix),
|
prefix: canonicalPrefix(prefix),
|
||||||
@@ -219,7 +207,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
messageType: "withdrawal",
|
messageType: "withdrawal",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
}
|
}
|
||||||
h.batch = append(h.batch, update)
|
h.batch = append(h.batch, seenPrefix{update.prefix, update.timestamp})
|
||||||
h.applyToTable(update)
|
h.applyToTable(update)
|
||||||
|
|
||||||
// Record withdrawal in metrics
|
// Record withdrawal in metrics
|
||||||
@@ -239,7 +227,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
path: path,
|
path: path,
|
||||||
endsInSet: len(msg.Path.Set) > 0,
|
endsInSet: len(msg.Path.Set) > 0,
|
||||||
}
|
}
|
||||||
h.batch = append(h.batch, update)
|
h.batch = append(h.batch, seenPrefix{update.prefix, update.timestamp})
|
||||||
h.applyToTable(update)
|
h.applyToTable(update)
|
||||||
|
|
||||||
// Record announcement in metrics
|
// Record announcement in metrics
|
||||||
@@ -261,14 +249,12 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the flush goroutine, which makes one last flush before it returns,
|
// Stop stops the flush goroutine, which makes one last flush before it returns,
|
||||||
// and waits for it until ctx ends. That flush writes the updates and peer downs
|
// and waits for it until ctx ends. That flush writes at most
|
||||||
// the handler holds, and at most maxTableChangesPerWrite of the routing table's
|
// maxTableChangesPerWrite of the routing table's changes, and the prefixes of
|
||||||
// changes. Once ctx has ended the flush goroutine skips that last flush, and a
|
// the updates the handler holds. Once ctx has ended the flush goroutine skips
|
||||||
// write still running is lost when the process exits. A peer whose route delete
|
// that last flush, and a write still running is lost when the process exits. A
|
||||||
// fails in the last flush is not retried either, nor is a failed write of the
|
// failed write of the table's changes is not retried either. Stop logs how many
|
||||||
// table's changes. Stop logs how many updates are not written, how many peer
|
// updates and how many of the table's changes are not written.
|
||||||
// downs have not had their routes deleted, and how many of the table's changes
|
|
||||||
// are not written.
|
|
||||||
func (h *PrefixHandler) Stop(ctx context.Context) {
|
func (h *PrefixHandler) Stop(ctx context.Context) {
|
||||||
h.stopCh <- ctx
|
h.stopCh <- ctx
|
||||||
|
|
||||||
@@ -278,176 +264,96 @@ func (h *PrefixHandler) Stop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
unwritten := h.writing + len(h.batch) + len(h.held)
|
unwritten := h.writing + len(h.batch)
|
||||||
peersDown := h.deleting + len(h.peersDown)
|
|
||||||
tableChanges := h.writingTableChanges + h.table.ChangeCount()
|
tableChanges := h.writingTableChanges + h.table.ChangeCount()
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if unwritten == 0 && peersDown == 0 && tableChanges == 0 {
|
if unwritten == 0 && tableChanges == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
h.logger.Error("Stop deadline passed before the prefix handler wrote "+
|
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)
|
||||||
"table_changes", tableChanges)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Error("The prefix handler stopped before it wrote everything",
|
h.logger.Error("The prefix handler stopped before it wrote everything",
|
||||||
"updates", unwritten, "peers_down", peersDown,
|
"updates", unwritten, "table_changes", tableChanges)
|
||||||
"table_changes", tableChanges)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// peerSessionDown notes that the peer's BGP session went down, so that the next
|
// takeBatch returns the pending updates and leaves an empty batch in their
|
||||||
// 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. In the routing table in
|
|
||||||
// memory the down takes effect at once.
|
|
||||||
func (h *PrefixHandler) peerSessionDown(peer string) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
h.table.PeerDown(peer)
|
|
||||||
|
|
||||||
isPeers := func(update prefixUpdate) bool { return update.peer == peer }
|
|
||||||
h.batch = slices.DeleteFunc(h.batch, isPeers)
|
|
||||||
h.held = slices.DeleteFunc(h.held, isPeers)
|
|
||||||
|
|
||||||
if !slices.Contains(h.peersDown, peer) {
|
|
||||||
h.peersDown = append(h.peersDown, peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeBatch returns the pending updates, the held ones first, and the peers in
|
|
||||||
// peersDown, and leaves an empty batch, no held updates and no peers in their
|
|
||||||
// place for HandleMessage to fill. The updates count in writing until
|
// place for HandleMessage to fill. The updates count in writing until
|
||||||
// flushBatch has written them, and the peers in deleting until their routes
|
// flushBatch has written them.
|
||||||
// are deleted.
|
func (h *PrefixHandler) takeBatch() []seenPrefix {
|
||||||
func (h *PrefixHandler) takeBatch() ([]prefixUpdate, []string) {
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
if len(h.held) == 0 && len(h.batch) == 0 && len(h.peersDown) == 0 {
|
if len(h.batch) == 0 {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
batch, peersDown := h.batch, h.peersDown
|
|
||||||
if len(h.held) > 0 {
|
|
||||||
batch = slices.Concat(h.held, h.batch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batch := h.batch
|
||||||
h.writing = len(batch)
|
h.writing = len(batch)
|
||||||
h.deleting = len(peersDown)
|
h.batch = make([]seenPrefix, 0, prefixBatchSize)
|
||||||
h.held = nil
|
|
||||||
h.batch = make([]prefixUpdate, 0, prefixBatchSize)
|
|
||||||
h.peersDown = nil
|
|
||||||
h.batchTaken.Broadcast()
|
h.batchTaken.Broadcast()
|
||||||
|
|
||||||
return batch, peersDown
|
return batch
|
||||||
}
|
}
|
||||||
|
|
||||||
// flushBatch writes the routing table's changes, then takes the pending batch
|
// flushBatch writes the routing table's changes, then takes the pending batch
|
||||||
// and writes it to the database, without holding the mutex during the writes.
|
// and stores the prefixes it names, without holding the mutex during the
|
||||||
|
// writes.
|
||||||
func (h *PrefixHandler) flushBatch() {
|
func (h *PrefixHandler) flushBatch() {
|
||||||
h.writeTableChanges()
|
h.writeTableChanges()
|
||||||
|
|
||||||
batch, peersDown := h.takeBatch()
|
batch := h.takeBatch()
|
||||||
if len(batch) == 0 && len(peersDown) == 0 {
|
if len(batch) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
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
|
// 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
|
// timestamp among its updates: the prefix tables store only first_seen,
|
||||||
// prefix tables store only first_seen, written when the prefix is new and
|
// written when the prefix is new and never changed after.
|
||||||
// never changed after.
|
|
||||||
prefixesToUpdate := make(map[string]time.Time)
|
prefixesToUpdate := make(map[string]time.Time)
|
||||||
|
|
||||||
for _, update := range batch {
|
for _, update := range batch {
|
||||||
lastUpdates[prefixAndPeer{update.prefix, update.peer}] = update
|
|
||||||
|
|
||||||
seen, exists := prefixesToUpdate[update.prefix]
|
seen, exists := prefixesToUpdate[update.prefix]
|
||||||
if !exists || update.timestamp.Before(seen) {
|
if !exists || update.timestamp.Before(seen) {
|
||||||
prefixesToUpdate[update.prefix] = update.timestamp
|
prefixesToUpdate[update.prefix] = update.timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect routes to upsert and delete
|
err := h.db.UpdatePrefixesBatch(prefixesToUpdate)
|
||||||
var (
|
|
||||||
routesToUpsert []*database.LiveRoute
|
|
||||||
routesToDelete []database.LiveRouteDeletion
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, update := range lastUpdates {
|
|
||||||
if update.announcesRoute() {
|
|
||||||
// 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 {
|
if err != nil {
|
||||||
h.logger.Error("Failed to parse CIDR for route deletion",
|
h.logger.Error("Failed to update prefix batch",
|
||||||
"prefix", update.prefix, "error", err)
|
"error", err, "count", len(prefixesToUpdate))
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create deletion record for batch delete
|
|
||||||
routesToDelete = append(routesToDelete, database.LiveRouteDeletion{
|
|
||||||
Prefix: update.prefix,
|
|
||||||
PeerIP: update.peer,
|
|
||||||
IPVersion: ipVersion,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
successCount := h.writeBatch(routesToUpsert, routesToDelete, prefixesToUpdate)
|
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.writing = 0
|
h.writing = 0
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
elapsed := time.Since(startTime)
|
|
||||||
h.logger.Debug("Flushed prefix batch",
|
h.logger.Debug("Flushed prefix batch",
|
||||||
"batch_size", batchSize,
|
"batch_size", len(batch),
|
||||||
"peers_down", len(peersDown),
|
|
||||||
"unique_prefixes", len(prefixesToUpdate),
|
"unique_prefixes", len(prefixesToUpdate),
|
||||||
"success", successCount,
|
"duration_ms", time.Since(startTime).Milliseconds(),
|
||||||
"duration_ms", elapsed.Milliseconds(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyToTable applies update to the routing table in memory: as an
|
// applyToTable applies update to the routing table in memory: as an
|
||||||
// announcement when it gives the peer a route, otherwise as a withdrawal. A
|
// announcement when it gives the peer a route, otherwise as a withdrawal. A
|
||||||
// prefix that does not parse is left out; flushBatch logs it.
|
// 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) {
|
func (h *PrefixHandler) applyToTable(update prefixUpdate) {
|
||||||
prefix, err := netip.ParsePrefix(update.prefix)
|
prefix, err := netip.ParsePrefix(update.prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to parse prefix",
|
||||||
|
"prefix", update.prefix, "error", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +363,14 @@ func (h *PrefixHandler) applyToTable(update prefixUpdate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.metrics != nil {
|
||||||
|
if prefix.Addr().Is4() {
|
||||||
|
h.metrics.RecordIPv4Update()
|
||||||
|
} else {
|
||||||
|
h.metrics.RecordIPv6Update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h.table.Announce(prefix, update.originASN, routetable.Route{
|
h.table.Announce(prefix, update.originASN, routetable.Route{
|
||||||
Peer: update.peer,
|
Peer: update.peer,
|
||||||
ASPath: update.path,
|
ASPath: update.path,
|
||||||
@@ -495,147 +409,11 @@ func (h *PrefixHandler) writeTableChanges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
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 failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
for _, update := range batch {
|
|
||||||
if slices.Contains(failedPeers, update.peer) {
|
|
||||||
kept = append(kept, update)
|
|
||||||
} else {
|
|
||||||
toWrite = append(toWrite, update)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
kept = slices.DeleteFunc(kept, func(update prefixUpdate) bool {
|
|
||||||
return slices.Contains(h.peersDown, update.peer)
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(kept) > prefixBatchSize {
|
|
||||||
h.logger.Error(
|
|
||||||
"Dropped the oldest updates of peers whose route delete failed",
|
|
||||||
"peers", failedPeers, "dropped", len(kept)-prefixBatchSize)
|
|
||||||
|
|
||||||
// A copy, so that the dropped updates are freed.
|
|
||||||
kept = slices.Clone(kept[len(kept)-prefixBatchSize:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// 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
|
// 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
|
// ("2001:db8:0:0:0:0:0:0/32"). A prefix that does not parse is returned
|
||||||
// the route its announcement stored. A prefix that does not parse is returned
|
// unchanged, and applyToTable logs it.
|
||||||
// unchanged, and the batch flush reports it.
|
|
||||||
func canonicalPrefix(prefix string) string {
|
func canonicalPrefix(prefix string) string {
|
||||||
p, err := netip.ParsePrefix(prefix)
|
p, err := netip.ParsePrefix(prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -644,55 +422,3 @@ func canonicalPrefix(prefix string) string {
|
|||||||
|
|
||||||
return p.Masked().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
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -84,13 +85,25 @@ func TestPrefixHandlerAppliesUpdatesToTheRoutingTable(t *testing.T) {
|
|||||||
testV6Prefix: {},
|
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
|
// TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable hands the
|
||||||
// prefix handler announcements whose AS path gives no origin. One whose path
|
// prefix handler announcements whose AS path gives no origin. One whose path
|
||||||
// holds AS 0 removes the peer's route, as a withdrawal does; one whose path ends
|
// is empty or holds AS 0 removes the peer's route, as a withdrawal does; one
|
||||||
// in an AS set of several members is kept with origin 0.
|
// whose path ends in an AS set of several members is kept with origin 0.
|
||||||
func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) {
|
func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -110,6 +123,14 @@ func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T)
|
|||||||
},
|
},
|
||||||
want: map[string]map[int][]string{testV4Prefix: {}},
|
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",
|
name: "a path with AS 0 before its end",
|
||||||
messages: []*ristypes.RISMessage{
|
messages: []*ristypes.RISMessage{
|
||||||
@@ -128,6 +149,55 @@ func TestPrefixHandlerAppliesAPathWithoutAnOriginToTheRoutingTable(t *testing.T)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// checkTableCases hands the messages of each case to a new prefix handler and
|
||||||
// checks its routing table right after, with no flush.
|
// checks its routing table right after, with no flush.
|
||||||
func checkTableCases(t *testing.T, tests []tableCase) {
|
func checkTableCases(t *testing.T, tests []tableCase) {
|
||||||
@@ -343,8 +413,9 @@ func TestPrefixHandlerWritesManyTableChangesOverSeveralFlushes(t *testing.T) {
|
|||||||
// TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten stops a prefix handler,
|
// TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten stops a prefix handler,
|
||||||
// with no deadline, whose routing table holds more changed pairs than one write
|
// 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
|
// takes. Its last flush writes one write's worth, so Stop must log the pairs
|
||||||
// that no write took. The flush timer must not fire, or its writes could leave
|
// that no write took, and must not say that the stop deadline passed. The
|
||||||
// no pairs for Stop to log.
|
// flush timer must not fire, or its writes could leave no pairs for Stop to
|
||||||
|
// log.
|
||||||
func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
|
func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -364,7 +435,13 @@ func TestPrefixHandlerStopLogsTheTableChangesLeftUnwritten(t *testing.T) {
|
|||||||
written += n
|
written += n
|
||||||
}
|
}
|
||||||
|
|
||||||
if logged := lastLogged(t, &logs); logged.TableChanges != pairs-written {
|
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",
|
t.Errorf("Stop logged %d table changes not written, want %d",
|
||||||
logged.TableChanges, pairs-written)
|
logged.TableChanges, pairs-written)
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-37
@@ -83,7 +83,7 @@ const (
|
|||||||
maxASSearchResults = 50
|
maxASSearchResults = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
// IP versions as database.LiveRoute.IPVersion records them.
|
// IP versions as LiveRoute.IPVersion records them.
|
||||||
const (
|
const (
|
||||||
ipVersionV4 = 4
|
ipVersionV4 = 4
|
||||||
ipVersionV6 = 6
|
ipVersionV6 = 6
|
||||||
@@ -258,8 +258,8 @@ type Stats struct {
|
|||||||
NewestRoute *time.Time `json:"newest_route,omitempty"`
|
NewestRoute *time.Time `json:"newest_route,omitempty"`
|
||||||
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
||||||
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
||||||
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
|
IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
|
||||||
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
|
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
||||||
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
||||||
|
|
||||||
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
|
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
|
||||||
@@ -394,12 +394,12 @@ func (s *Server) statusStats(
|
|||||||
// prefixDistribution lists the mask lengths that have at least one prefix, in
|
// prefixDistribution lists the mask lengths that have at least one prefix, in
|
||||||
// ascending order, with their prefix counts. countsByLength[n] is the number
|
// ascending order, with their prefix counts. countsByLength[n] is the number
|
||||||
// of prefixes of mask length n.
|
// of prefixes of mask length n.
|
||||||
func prefixDistribution(countsByLength []int) []database.PrefixDistribution {
|
func prefixDistribution(countsByLength []int) []PrefixDistribution {
|
||||||
var distribution []database.PrefixDistribution
|
var distribution []PrefixDistribution
|
||||||
|
|
||||||
for maskLength, count := range countsByLength {
|
for maskLength, count := range countsByLength {
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
distribution = append(distribution, database.PrefixDistribution{
|
distribution = append(distribution, PrefixDistribution{
|
||||||
MaskLength: maskLength, Count: count,
|
MaskLength: maskLength, Count: count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -538,8 +538,8 @@ type StatsResponse struct {
|
|||||||
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
||||||
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
||||||
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
|
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
|
||||||
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
|
IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
|
||||||
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
|
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
||||||
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
||||||
|
|
||||||
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
||||||
@@ -753,7 +753,7 @@ type IPLookupResponse struct {
|
|||||||
// fields. IP hides the IPInfo field of the same name, so that an address with
|
// fields. IP hides the IPInfo field of the same name, so that an address with
|
||||||
// no route has it too.
|
// no route has it too.
|
||||||
type IPLookupResult struct {
|
type IPLookupResult struct {
|
||||||
*database.IPInfo
|
*IPInfo
|
||||||
|
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
PTR []string `json:"ptr,omitempty"`
|
PTR []string `json:"ptr,omitempty"`
|
||||||
@@ -813,10 +813,10 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, database.ErrNoRoute):
|
case errors.Is(err, ErrNoRoute):
|
||||||
// The address still gets a result, without route fields.
|
// The address still gets a result, without route fields.
|
||||||
ipInfo = nil
|
ipInfo = nil
|
||||||
case errors.Is(err, database.ErrInvalidIP):
|
case errors.Is(err, ErrInvalidIP):
|
||||||
response.Errors = append(response.Errors, ip+": "+err.Error())
|
response.Errors = append(response.Errors, ip+": "+err.Error())
|
||||||
|
|
||||||
continue
|
continue
|
||||||
@@ -860,15 +860,15 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
|
|||||||
// route. The database gives the prefix's first-seen time and the AS's WHOIS
|
// 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
|
// details. It returns ErrNoRoute when no prefix holds the address, and
|
||||||
// ErrInvalidIP when ip does not parse.
|
// ErrInvalidIP when ip does not parse.
|
||||||
func (s *Server) ipInfo(ctx context.Context, ip string) (*database.IPInfo, error) {
|
func (s *Server) ipInfo(ctx context.Context, ip string) (*IPInfo, error) {
|
||||||
addr, err := netip.ParseAddr(ip)
|
addr, err := netip.ParseAddr(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %s", database.ErrInvalidIP, ip)
|
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, found := s.table.Lookup(addr)
|
summary, found := s.table.Lookup(addr)
|
||||||
if !found {
|
if !found {
|
||||||
return nil, fmt.Errorf("%w for IP %s", database.ErrNoRoute, ip)
|
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
|
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
|
||||||
@@ -881,7 +881,7 @@ func (s *Server) ipInfo(ctx context.Context, ip string) (*database.IPInfo, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
info := &database.IPInfo{
|
info := &IPInfo{
|
||||||
IP: ip,
|
IP: ip,
|
||||||
Netblock: summary.Prefix.String(),
|
Netblock: summary.Prefix.String(),
|
||||||
MaskLength: summary.Prefix.Bits(),
|
MaskLength: summary.Prefix.Bits(),
|
||||||
@@ -1165,7 +1165,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, database.ErrNoRoute) {
|
if errors.Is(err, ErrNoRoute) {
|
||||||
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
||||||
} else {
|
} else {
|
||||||
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
|
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
|
||||||
@@ -1201,9 +1201,9 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
|||||||
// routing table all lack.
|
// routing table all lack.
|
||||||
func (s *Server) asDetails(
|
func (s *Server) asDetails(
|
||||||
ctx context.Context, asn int,
|
ctx context.Context, asn int,
|
||||||
) (*database.ASN, []database.LiveRoute, error) {
|
) (*database.ASN, []LiveRoute, error) {
|
||||||
if asn == ristypes.NoOrigin {
|
if asn == ristypes.NoOrigin {
|
||||||
return nil, nil, fmt.Errorf("%w: AS%d", database.ErrNoRoute, asn)
|
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||||
}
|
}
|
||||||
|
|
||||||
asInfo, known, err := s.db.GetAS(ctx, asn)
|
asInfo, known, err := s.db.GetAS(ctx, asn)
|
||||||
@@ -1213,13 +1213,13 @@ func (s *Server) asDetails(
|
|||||||
|
|
||||||
originated := s.table.OriginatedPrefixes(asn)
|
originated := s.table.OriginatedPrefixes(asn)
|
||||||
if !known && len(originated) == 0 {
|
if !known && len(originated) == 0 {
|
||||||
return nil, nil, fmt.Errorf("%w: AS%d", database.ErrNoRoute, asn)
|
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||||
}
|
}
|
||||||
|
|
||||||
prefixes := make([]database.LiveRoute, 0, len(originated))
|
prefixes := make([]LiveRoute, 0, len(originated))
|
||||||
|
|
||||||
for _, p := range originated {
|
for _, p := range originated {
|
||||||
route := database.LiveRoute{
|
route := LiveRoute{
|
||||||
Prefix: p.Prefix.String(),
|
Prefix: p.Prefix.String(),
|
||||||
MaskLength: p.Prefix.Bits(),
|
MaskLength: p.Prefix.Bits(),
|
||||||
IPVersion: ipVersionOf(p.Prefix),
|
IPVersion: ipVersionOf(p.Prefix),
|
||||||
@@ -1239,9 +1239,9 @@ func (s *Server) asDetails(
|
|||||||
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither
|
// 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.
|
// group is nil, so that a JSON answer holds [] for an empty one and not null.
|
||||||
func splitRoutesByIPVersion(
|
func splitRoutesByIPVersion(
|
||||||
routes []database.LiveRoute,
|
routes []LiveRoute,
|
||||||
) ([]database.LiveRoute, []database.LiveRoute) {
|
) ([]LiveRoute, []LiveRoute) {
|
||||||
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
|
ipv4Routes, ipv6Routes := []LiveRoute{}, []LiveRoute{}
|
||||||
|
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
if route.IPVersion == ipVersionV4 {
|
if route.IPVersion == ipVersionV4 {
|
||||||
@@ -1256,8 +1256,8 @@ func splitRoutesByIPVersion(
|
|||||||
|
|
||||||
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
|
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
|
||||||
// routes with the same mask length by network address.
|
// routes with the same mask length by network address.
|
||||||
func sortRoutesByMaskLength(routes []database.LiveRoute) {
|
func sortRoutesByMaskLength(routes []LiveRoute) {
|
||||||
slices.SortFunc(routes, func(a, b database.LiveRoute) int {
|
slices.SortFunc(routes, func(a, b LiveRoute) int {
|
||||||
prefixA, _ := netip.ParsePrefix(a.Prefix)
|
prefixA, _ := netip.ParsePrefix(a.Prefix)
|
||||||
prefixB, _ := netip.ParsePrefix(b.Prefix)
|
prefixB, _ := netip.ParsePrefix(b.Prefix)
|
||||||
|
|
||||||
@@ -1294,7 +1294,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
|||||||
origins := s.table.Origins(parsed)
|
origins := s.table.Origins(parsed)
|
||||||
if len(origins) == 0 {
|
if len(origins) == 0 {
|
||||||
s.writeJSONError(w, http.StatusNotFound,
|
s.writeJSONError(w, http.StatusNotFound,
|
||||||
database.ErrNoRoute.Error()+": "+prefix)
|
ErrNoRoute.Error()+": "+prefix)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1302,7 +1302,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
|||||||
routes := prefixRoutes(parsed, origins)
|
routes := prefixRoutes(parsed, origins)
|
||||||
|
|
||||||
// Group by origin AS
|
// Group by origin AS
|
||||||
originMap := make(map[int][]database.LiveRoute)
|
originMap := make(map[int][]LiveRoute)
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
|
originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
|
||||||
}
|
}
|
||||||
@@ -1392,7 +1392,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, database.ErrNoRoute) {
|
if errors.Is(err, ErrNoRoute) {
|
||||||
http.Error(w, "AS not found", http.StatusNotFound)
|
http.Error(w, "AS not found", http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
s.logger.Error("Failed to get AS details", "error", err)
|
s.logger.Error("Failed to get AS details", "error", err)
|
||||||
@@ -1425,8 +1425,8 @@ func (s *Server) handleASDetail() http.HandlerFunc {
|
|||||||
// Prepare template data
|
// Prepare template data
|
||||||
data := struct {
|
data := struct {
|
||||||
ASN *database.ASN
|
ASN *database.ASN
|
||||||
IPv4Prefixes []database.LiveRoute
|
IPv4Prefixes []LiveRoute
|
||||||
IPv6Prefixes []database.LiveRoute
|
IPv6Prefixes []LiveRoute
|
||||||
TotalCount int
|
TotalCount int
|
||||||
IPv4Count int
|
IPv4Count int
|
||||||
IPv6Count int
|
IPv6Count int
|
||||||
@@ -1585,11 +1585,11 @@ func (s *Server) originASNs(
|
|||||||
// them, since the routing table keeps only their addresses.
|
// them, since the routing table keeps only their addresses.
|
||||||
func prefixRoutes(
|
func prefixRoutes(
|
||||||
prefix netip.Prefix, origins []routetable.Origin,
|
prefix netip.Prefix, origins []routetable.Origin,
|
||||||
) []database.LiveRoute {
|
) []LiveRoute {
|
||||||
var routes []database.LiveRoute
|
var routes []LiveRoute
|
||||||
|
|
||||||
for _, origin := range origins {
|
for _, origin := range origins {
|
||||||
base := database.LiveRoute{
|
base := LiveRoute{
|
||||||
Prefix: prefix.String(),
|
Prefix: prefix.String(),
|
||||||
MaskLength: prefix.Bits(),
|
MaskLength: prefix.Bits(),
|
||||||
IPVersion: ipVersionOf(prefix),
|
IPVersion: ipVersionOf(prefix),
|
||||||
@@ -1630,14 +1630,14 @@ type ASPathEntry struct {
|
|||||||
|
|
||||||
// EnhancedRoute is a live route with the handle of each AS in its path.
|
// EnhancedRoute is a live route with the handle of each AS in its path.
|
||||||
type EnhancedRoute struct {
|
type EnhancedRoute struct {
|
||||||
database.LiveRoute
|
LiveRoute
|
||||||
|
|
||||||
ASPathWithHandle []ASPathEntry
|
ASPathWithHandle []ASPathEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// routesWithASPathHandles looks up the handle of each AS in the path of each
|
// routesWithASPathHandles looks up the handle of each AS in the path of each
|
||||||
// route.
|
// route.
|
||||||
func routesWithASPathHandles(routes []database.LiveRoute) []EnhancedRoute {
|
func routesWithASPathHandles(routes []LiveRoute) []EnhancedRoute {
|
||||||
enhancedRoutes := make([]EnhancedRoute, len(routes))
|
enhancedRoutes := make([]EnhancedRoute, len(routes))
|
||||||
|
|
||||||
for i, route := range routes {
|
for i, route := range routes {
|
||||||
@@ -1736,7 +1736,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
|
|||||||
// EnhancedPrefix is a prefix on the prefix length page, with the origin AS
|
// 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.
|
// that most peers carry it with, that AS's description, and its age.
|
||||||
type EnhancedPrefix struct {
|
type EnhancedPrefix struct {
|
||||||
database.LiveRoute
|
LiveRoute
|
||||||
|
|
||||||
OriginASDescription string
|
OriginASDescription string
|
||||||
Age string
|
Age string
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ type tableStats struct {
|
|||||||
OldestRoute time.Time `json:"oldest_route"`
|
OldestRoute time.Time `json:"oldest_route"`
|
||||||
NewestRoute time.Time `json:"newest_route"`
|
NewestRoute time.Time `json:"newest_route"`
|
||||||
|
|
||||||
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
|
IPv4Distribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
|
||||||
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
|
IPv6Distribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
|
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
|
||||||
@@ -211,8 +211,8 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
|
|||||||
OriginsWithoutKeptRoute: 1,
|
OriginsWithoutKeptRoute: 1,
|
||||||
OldestRoute: testRouteTime(0),
|
OldestRoute: testRouteTime(0),
|
||||||
NewestRoute: testRouteTime(4),
|
NewestRoute: testRouteTime(4),
|
||||||
IPv4Distribution: []database.PrefixDistribution{{MaskLength: 24, Count: 2}},
|
IPv4Distribution: []PrefixDistribution{{MaskLength: 24, Count: 2}},
|
||||||
IPv6Distribution: []database.PrefixDistribution{{MaskLength: 32, Count: 1}},
|
IPv6Distribution: []PrefixDistribution{{MaskLength: 32, Count: 1}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, handler := range handlers {
|
for name, handler := range handlers {
|
||||||
@@ -422,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
|
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
|
||||||
// origin AS's handle and description without looking up the AS's details,
|
// origin AS's handle and description. The AS path column shows handles too, so
|
||||||
// which also read every route the AS originates. Before the fix the page did
|
// the test looks for the origin list's "(handle)" form.
|
||||||
// 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.
|
|
||||||
func TestPrefixDetailShowsOriginNames(t *testing.T) {
|
func TestPrefixDetailShowsOriginNames(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -465,7 +447,7 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
|
|||||||
announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0))
|
announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
s := New(failingASDetailsDB{db}, table,
|
s := New(db, table,
|
||||||
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
@@ -1717,8 +1699,8 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
ASN database.ASN `json:"asn"`
|
ASN database.ASN `json:"asn"`
|
||||||
IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"`
|
IPv4Prefixes []LiveRoute `json:"ipv4_prefixes"`
|
||||||
IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"`
|
IPv6Prefixes []LiveRoute `json:"ipv6_prefixes"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1863,9 +1845,9 @@ func TestSortRoutesByMaskLength(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
routes := make([]database.LiveRoute, 0, len(tt.prefixes))
|
routes := make([]LiveRoute, 0, len(tt.prefixes))
|
||||||
for _, prefix := range tt.prefixes {
|
for _, prefix := range tt.prefixes {
|
||||||
routes = append(routes, database.LiveRoute{Prefix: prefix})
|
routes = append(routes, LiveRoute{Prefix: prefix})
|
||||||
}
|
}
|
||||||
|
|
||||||
sortRoutesByMaskLength(routes)
|
sortRoutesByMaskLength(routes)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +135,34 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// prefixRoute is one route of the prefix API's answer. A field the answer
|
||||||
// leaves out stays nil.
|
// leaves out stays nil.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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:"-"`
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/server"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,14 +115,14 @@ func TestASDetailPrefixListsFold(t *testing.T) {
|
|||||||
prefixV6 = "2001:db8::/32"
|
prefixV6 = "2001:db8::/32"
|
||||||
)
|
)
|
||||||
|
|
||||||
ipv4 := []database.LiveRoute{{Prefix: prefixV4}}
|
ipv4 := []server.LiveRoute{{Prefix: prefixV4}}
|
||||||
ipv6 := []database.LiveRoute{{Prefix: prefixV6}}
|
ipv6 := []server.LiveRoute{{Prefix: prefixV6}}
|
||||||
|
|
||||||
// The fields the AS page handler gives the template.
|
// The fields the AS page handler gives the template.
|
||||||
data := struct {
|
data := struct {
|
||||||
ASN *database.ASN
|
ASN *database.ASN
|
||||||
IPv4Prefixes []database.LiveRoute
|
IPv4Prefixes []server.LiveRoute
|
||||||
IPv6Prefixes []database.LiveRoute
|
IPv6Prefixes []server.LiveRoute
|
||||||
TotalCount int
|
TotalCount int
|
||||||
IPv4Count int
|
IPv4Count int
|
||||||
IPv6Count int
|
IPv6Count int
|
||||||
|
|||||||
Reference in New Issue
Block a user