When the delete of a peer's live routes after its session went down failed, the down was dropped and the routes stayed. The prefix handler now keeps such a peer, once, with the updates it sent after the down, for the next flush, which retries the delete before it writes them; each failure is logged. At most the last 25,000 of those updates are kept, outside the batch, so they do not start a flush. The delete runs in chunks of at most 10,000 routes per table, taken in prefix order through the peer's key, each chunk in its own transaction, so the database lock is released between chunks and a failure undoes at most one chunk. Model: opus-5-5
20 KiB
RouteWatch
RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing table in real time: it streams BGP UPDATE messages from the RIPE RIS Live service, maintains a live routing table in SQLite, and provides HTTP APIs for querying routing information.
Features
- Real-time streaming of BGP updates from RIPE RIS Live
- Maintains live IPv4 and IPv6 routing tables
- Tracks AS peering relationships
- HTTP API for IP-to-AS lookups, prefix details, and AS information
- Automatic reconnection with exponential backoff
- Batched database writes for high performance
- Backpressure handling to prevent memory exhaustion
Installation
go build -o routewatch ./cmd/routewatch
Usage
# Run the daemon (listens on port 8080 by default)
./routewatch
# Set custom port
PORT=3000 ./routewatch
# Enable debug logging
DEBUG=routewatch ./routewatch
HTTP Endpoints
Web Interface
GET /- Home page (HTML): headline figures, and lookups by AS number and by IP addressGET /status- HTML status dashboard, which reads/api/v1/statsGET /status.json- JSON statistics: feed, memory, database and route figuresGET /.well-known/healthcheck.json- Health check (JSON): 200 when the database answers and the RIS Live feed is connected, otherwise 503;data.statusisokorerroranddata.checkssays which check failedGET /as/{asn}- AS detail page (HTML): the prefixes the AS originates and its peersGET /prefix/{prefix}/{length}- Prefix detail page (HTML), such as/prefix/192.0.2.0/24: every live route for the prefix, one per peer, and its origin ASesGET /prefixlength/{length}- Up to 500 IPv4 prefixes with that mask length, picked at random, each with its newest route (HTML)GET /prefixlength6/{length}- The same for IPv6GET /ip/{addr}- The same JSON answer as/api/v1/ip/{ip}GET /ip/- The same, for the caller's own address, or the one a proxy names inTrue-Client-IP,X-Real-IPorX-Forwarded-For
API v1
GET /api/v1/stats- The/status.jsonfigures plus connection, garbage collector, stream and handler queue statisticsGET /api/v1/ip/{ip}- Looks up each address: its most specific live route (netblock), that route's origin AS with its WHOIS details, and the address's PTR names.{ip}is an IP address, a host name (each address it resolves to is looked up), or a comma-separated list of these.resultshas one entry per address with a live route anderrorsone per address or name that failed. The answer is 400 when nothing in{ip}is an IP address or a host name that resolves, and 404 when none of the addresses has a live route.GET /api/v1/as/{asn}- The AS and the prefixes it originates, IPv4 and IPv6 apart, each with when its newest route was updatedGET /api/v1/prefix/{prefix}/{length}- Every live route for the prefix, one per peer, and the same routes grouped by origin AS inorigins
A JSON answer is {"status": "ok", "data": ...}, or for an error
{"status": "error", "error": {"msg": ..., "code": ...}}, and carries an
@meta object with the time zone, the API version and the time the request
took. The health check is the exception: its 503 answer is also
{"status": "ok", "data": ...}, with error in data.status.
A request that runs past its time limit answers 504: the limit is 30 seconds,
and 4 seconds for the database reads behind /status.json and /api/v1/stats.
An AS number, prefix or mask length that does not parse answers 400, as does a
mask length out of range for its IP version. An AS the database does not hold,
or a prefix with no live route, answers 404. The HTML pages give these errors as
plain text.
An origin AS of 0 (origin_asn in a route, asn in an IP lookup, the key 0
in a prefix's origins) means the route has no known origin: its AS path ends
in an AS set of several members, any of which may have originated it. The pages
show such a route as having no known origin.
Code Structure
routewatch/
├── cmd/
│ ├── routewatch/ # Main daemon entry point
│ ├── asinfo-gen/ # Utility to generate AS info data
│ └── streamdumper/ # Debug utility for raw stream output
├── internal/
│ ├── routewatch/ # Core application logic
│ ├── server/ # HTTP server and handlers
│ ├── database/ # SQLite storage layer
│ ├── streamer/ # RIPE RIS Live client
│ ├── ristypes/ # BGP message data structures
│ ├── logger/ # Structured logging wrapper
│ ├── metrics/ # Performance metrics tracking
│ ├── config/ # Configuration management
│ ├── templates/ # HTML templates
│ ├── version/ # Build version information
│ └── whois/ # WHOIS lookups of AS details
└── pkg/
└── asinfo/ # AS information lookup (public API)
Architecture Overview
Component Relationships
┌─────────────────────────────────────────────────────────────────┐
│ RouteWatch │
│ (internal/routewatch/app.go - main orchestrator) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Streamer │───▶│ Handlers │───▶│ Database │ │
│ │ │ │ │ │ │ │
│ │ RIS Live │ │ - ASHandler │ │ SQLite with │ │
│ │ HTTP stream │ │ - PeerHandler│ │ WAL mode │ │
│ │ client │ │ - PrefixHdlr │ │ │ │
│ │ │ │ - PeeringHdlr│ │ Tables: │ │
│ └──────────────┘ └──────────────┘ │ - asns │ │
│ │ - prefixes │ │
│ ┌──────────────┐ ┌──────────────┐ │ - live_routes│ │
│ │ Server │───▶│ Handlers │───▶│ - peerings │ │
│ │ │ │ │ │ - bgp_peers │ │
│ │ Chi router │ │ Status, API │ └──────────────┘ │
│ │ port 8080 │ │ AS, Prefix │ │
│ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Execution Flow
-
Startup (
cmd/routewatch/main.go→internal/routewatch/cli.go)- Uber fx dependency injection initializes all components
- Signal handlers registered for graceful shutdown
-
Initialization (
internal/routewatch/app.go)- Database created with SQLite schema (WAL mode, page caches as in "Memory")
- Message handlers registered with the streamer
- HTTP server started on configured port
-
Message Processing Pipeline
RIS Live Stream → JSON Parser → Message Dispatcher → Handler Queues → Batch Writers → SQLite- Streamer connects to
ris-live.ripe.netvia HTTP - Parses BGP UPDATE messages from JSON stream
- Dispatches to registered handlers based on message type
- Each handler has its own queue with backpressure handling
- Handlers batch writes for efficiency: the AS, peer and prefix handlers write 30,000, 10,000 and 25,000 updates at a time, or after 2, 2 and 1 seconds; the peering handler writes the peerings it has collected every 30 seconds
- Streamer connects to
-
Handler Details
- ASHandler: Tracks all ASNs seen in AS paths
- PeerHandler: Records BGP peer information
- PrefixHandler: Maintains live routing table (upserts on announcement, deletes on withdrawal, deletes all of a peer's routes when its BGP session goes down). Routes are kept per peer IP, so when one peer IP has sessions on two collectors, a session going down on one also removes the routes the other still carries, until they are announced again.
- PeeringHandler: Extracts AS peering relationships from AS paths
-
HTTP Request Flow
Request → Chi Router → Middleware (timeout, logging) → Handler → Database Query → Response
Key Design Patterns
- Batched Writes: The handlers write the feed's updates to the database in batches for performance
- Backpressure: Probabilistic message dropping when queues exceed 50% capacity
- Graceful Shutdown: 60-second timeout, flushes all pending batches
- Reconnection: Any end of the stream other than shutdown, including RIS Live ending the response normally, marks the feed disconnected and reconnects with exponential backoff (5s-320s). The backoff resets after a stream that lasted more than 30s. A stream with no line for 60s is dropped and reconnected.
- IP Lookup: the most specific live route is found by looking up the
address's prefix at each mask length, longest first, in the prefix index (at
most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the text form
Go's
net/netipprints. Routes shorter than /8 for IPv4 or /16 for IPv6, such as a default route, are not used.
Database Schema
-- Core tables
asns(asn PRIMARY KEY, handle, description,
as_name, org_name, org_id, address, country_code, abuse_email,
abuse_phone, tech_email, tech_phone, rir, rir_registration_date,
rir_last_modified, whois_raw,
first_seen, last_seen, whois_updated_at)
prefixes_v4(prefix PRIMARY KEY, first_seen) WITHOUT ROWID
prefixes_v6(prefix PRIMARY KEY, first_seen) WITHOUT ROWID
-- Live routing tables (one per IP version), one route per peer and prefix
live_routes_v4(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn,
peer_ip, as_path, next_hop, last_updated,
UNIQUE(peer_ip, prefix))
live_routes_v6(id INTEGER PRIMARY KEY, prefix, mask_length, origin_asn,
peer_ip, as_path, next_hop, last_updated,
UNIQUE(peer_ip, prefix))
-- Relationship tracking
peerings(id PRIMARY KEY, as_a, as_b, first_seen, last_seen,
UNIQUE(as_a, as_b))
bgp_peers(id PRIMARY KEY, peer_ip UNIQUE, peer_asn, first_seen, last_seen,
last_message_type)
-- Nothing in the daemon writes this table
announcements(id PRIMARY KEY, prefix_id, peer_asn, origin_asn, path,
next_hop, timestamp, is_withdrawal)
The indexes are in internal/database/schema.sql.
Configuration
Configuration is handled via environment variables and OS-specific paths:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port, a whole number from 1 to 65535 |
DEBUG |
(empty) | Set to routewatch for debug logging |
XDG_DATA_HOME |
/var/lib (in the Docker image) |
Base of the state directory; must be an absolute path |
GOMEMLIMIT |
1536MiB (in the Docker image) |
Go soft memory limit; see Memory |
MALLOC_ARENA_MAX |
2 (in the Docker image) |
glibc malloc arena cap, a positive whole number; see Memory |
A variable that is set to an invalid value stops the start with an error and a non-zero exit. An empty variable counts as unset.
State directory (database location):
- macOS:
~/Library/Application Support/berlin.sneak.app.routewatch/ - Linux:
/var/lib/berlin.sneak.app.routewatch/when running as root, otherwise$XDG_DATA_HOME/berlin.sneak.app.routewatch/(withXDG_DATA_HOMEunset,~/.local/share/berlin.sneak.app.routewatch/). In the Docker image this is/var/lib/berlin.sneak.app.routewatch/.
Memory
The daemon holds a live routing table, so its memory grows with the size of the data it tracks. The image sets ceilings that keep it inside a 5 GiB container.
Budget:
- Go heap: a 1.5 GiB soft limit (
GOMEMLIMIT=1536MiB, set in the image). - SQLite: 1 GiB of page cache for the one connection every write uses, which keeps the pages the route writes touch from one batch to the next, and 16 MiB for each of the 9 reading connections, whose cache SQLite empties whenever another connection has written: about 1.15 GiB in all. A 1.5 GiB soft heap limit sits above the caches, so it does not shrink them, and a 2 GiB hard heap limit caps the C library.
- glibc allocator: the SQLite C library runs on glibc
malloc, which frees page-cache chunks back to per-arena free lists rather than to the kernel, so process RSS tracks the high-water mark of those arenas, not SQLite's live heap. glibc creates up to eight arenas per core, so on a many-core host the retained memory — and thus RSS — grows with the core count. The image setsMALLOC_ARENA_MAX=2to bound it; the two-arena cap costs nothing here because database writes are already serialized. - 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 0.2 GiB for the rest.
Run the container with a memory limit of 5 GiB and swap disabled:
docker run --memory=5g --memory-swap=5g ...
or the equivalent in your deployment tool. This leaves headroom above the ceilings for spikes and the kernel page cache.
Override the Go soft limit by setting GOMEMLIMIT in the environment (for
example -e GOMEMLIMIT=1GiB); this replaces the image default.
MALLOC_ARENA_MAX can be overridden the same way, but raising it lets RSS climb
again on a many-core host.
What happens at each limit:
- Go soft limit: as the heap approaches
GOMEMLIMIT, the runtime runs garbage collection more aggressively rather than growing further. - SQLite: at the 1.5 GiB soft heap limit it recycles its page cache instead of allocating more; at the 2 GiB hard heap limit a statement fails with an out-of-memory error, and the handler logs it and drops that batch. The process keeps running.
- 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
STATEmessage, which it holds, in feed order, until there is room: a few hundred bytes each, about ten a second. - Handler batches: while the AS, peer or prefix handler writes a batch to the
database, the updates that arrive collect in its next batch, which stops at
ten times the batch size: 300,000 updates for the AS handler
(
asnBatchMaxSize, about 10 MiB), 100,000 for the peer handler (peerBatchMaxSize, about 10 MiB) and 250,000 for the prefix handler (prefixBatchMaxSize, about 60 MiB). At that size the handler takes nothing more off its queue until the write ends, so the queue fills and the streamer drops messages as above. When the delete of a peer's live routes after its session went down fails, the prefix handler keeps the updates the peer sent after the down for the next write, which retries the delete first; on top of its 250,000 it keeps at most the last 25,000 of them (prefixBatchSize, about 6 MiB) and drops and logs the older ones, so while the delete keeps failing the other peers' updates still come off the queue. With the batches being written, the three handlers' batches take at most about 0.25 GiB of the Go heap's 1.5 GiB.
With DEBUG=routewatch the daemon logs a System stats line every 60 seconds
with the goroutine count and Go memory figures.
Running under upaas
What the upaas app needs:
- Container port:
8080. - Volume: one, at container path
/var/lib/berlin.sneak.app.routewatch. - Environment: nothing is required. Leave
XDG_DATA_HOME,GOMEMLIMITandMALLOC_ARENA_MAXat the image's values.DEBUG=routewatchis optional and adds theSystem statsmemory line to the log. - Memory Limit:
5g, the 5 GiB limit from Memory above. upaas sets no swap limit, so on a host with swap Docker allows the same amount of swap again. - Health check: the image's
HEALTHCHECKrequests/.well-known/healthcheck.jsonon the container port. upaas reads the container's health 60 seconds after a deploy and fails the deploy unless it ishealthy.
Development
# Run tests
make test
# Format code
make fmt
# Run linter
make lint
# Build
make build
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard: normalized scripts in script/ are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
script/bootstrap— install all development dependencies (git, make, go, the pinned node and yarn, prettier fromyarn.lock, Go module download)script/setup— make a fresh clone ready for development: runsscript/bootstrap, thenscript/install-precommitscript/projectname— print the project name (used for the Docker image tag)script/test— build thetestphase of theDockerfilealone, which runsgo test -short -count=1 -timeout 90s -race -cover ./...(verbose rerun on failure). The-shortflag skips the live-network integration test so the run is deterministic and offline.script/lint— build thelintphase of theDockerfilealone, which runs golangci-lint v2.14.0 with.golangci.ymlscript/fmt— format the Go code withgo fmtand the markdown with prettier (writes)script/fmt-check— check formatting (read-only)script/check— runscript/test,script/lint, andscript/fmt-checkscript/docker— build the Docker image tagged viascript/projectnamescript/cibuild— CI entrypoint: runsscript/bootstrapandscript/check, then builds the imagescript/precommit— pre-commit gate:go mod tidy(must not changego.modorgo.sum) andgo fmt(writes), thenscript/checkscript/install-precommit— install the git pre-commit hook that runsscript/precommit
Tests and the linter run only in Docker, as the test and lint phases of the
Dockerfile; the image cannot be built unless both pass. Every docker build
in script/ passes a new CACHEBUST value, so each run executes every check,
while Go's build cache and golangci-lint's cache are kept between builds.
The live-network integration test TestRouteWatchLiveFeed streams the RIPE RIS
feed for a few seconds and is skipped in short mode. To run it on demand, invoke
go test directly without -short:
go test -run TestRouteWatchLiveFeed ./internal/routewatch/.
License
MIT. See LICENSE.