From 3d474d21dfb7e2c570c25330a78169c8419e602f Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 2 Nov 2026 22:45:11 +0000 Subject: [PATCH] Rewrite the README sentence by sentence (closes #157) The README is rewritten from scratch against the prose rules of https://git.eeqj.de/sneak/routewatch/issues/157: every sentence under about 30 words, lists as bullets, every technical term defined where it first appears, and every statement checked against the code on next. It leads with what RouteWatch is and how to run it, then the pages, the API and the blocklists, then the settings, memory and upaas, and last the design and the development workflow. The dated entry goes into TODO.md. Model: fable-5-1 --- README.md | 1323 ++++++++++--------- TODO.md | 10 +- internal/server/middleware_internal_test.go | 2 +- 3 files changed, 734 insertions(+), 601 deletions(-) diff --git a/README.md b/README.md index 945dd13..0d81f41 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,230 @@ # RouteWatch -RouteWatch is an MIT-licensed Go daemon by @sneak that monitors the BGP routing -table in real time. It works like this: +RouteWatch is an MIT-licensed Go daemon by @sneak that follows the internet's +BGP routing table as it changes and answers questions about IP addresses over +HTTP. -- It streams BGP UPDATE messages from the RIPE RIS Live service. -- It keeps the whole routing table in memory, with a copy in SQLite. -- It serves web pages and a JSON API for looking routes up. +BGP is the protocol by which the networks of the internet tell each other which +addresses they can reach. Each network is an autonomous system, an AS, with a +number such as `AS15169`. A prefix is a block of addresses, such as +`192.0.2.0/24`. The number after the slash is the mask length. A shorter mask +length means a bigger block. A route says which AS announces a prefix and +through which ASes the announcement came. The AS that announces the prefix is +its origin AS. The ASes the announcement came through, in order, are the AS +path. + +RouteWatch does three things: + +- It streams BGP updates from the RIPE RIS Live service. This document calls + that stream the feed. +- It applies them to a routing table that it keeps in memory, with a copy in a + SQLite database. +- It serves web pages and a JSON API that answer from that table. ## Rationale RouteWatch exists to serve our other services as an internal API for information -about IP addresses. +about IP addresses. A service asks RouteWatch instead of working the answer out +for itself. -RouteWatch follows BGP routing table updates from RIPE RIS Live. RIS Live -streams the updates that RIPE's route collectors receive, as they happen, from -the moment a client connects. It reports changes, not the routing table they add -up to. RouteWatch applies those changes to a routing table that it keeps in -memory, with a copy in its own SQLite database. From that table, RouteWatch sees +RIPE runs route collectors around the world. Many routers, the RIS peers, send +the collectors their routes. The connection between a peer and a collector is a +BGP session. A peer carries a prefix when the peer has a route to that prefix. +RIS Live streams every change the collectors receive, as it happens. RouteWatch +applies those changes to its own routing table. From that table it sees directly: - which AS each IP address is routed to -- which announced prefix holds each IP address, and how big that prefix is +- which announced prefix holds the address, and how big that prefix is +- how many peers carry the prefix, and with which origin ASes RouteWatch adds details of each AS from WHOIS, the public directory of the -internet registries. The details include the organisation that holds the AS. -RouteWatch keeps them in its database and looks them up again once they are 30 -days old. An IP lookup answers from those stored details and never waits for -WHOIS. +internet registries. The details include the organisation that holds the AS and +its abuse contact. RouteWatch keeps them in its database and fetches them again +once they are 30 days old. An IP lookup answers from the stored details and +never waits for WHOIS. -So our own services can ask RouteWatch about an IP address, instead of each -working it out for itself. +An IP lookup also says which public blocklists list the address (see +Blocklists). -RIS Live sends no routing table when a client connects. So the routing table -holds only the routes announced since RouteWatch first started. An IP lookup +## Getting started + +The usual way to run RouteWatch is its Docker image. Build the image, which is +tagged `routewatch`: + +```bash +make docker +``` + +The build runs the tests and the linter first, and fails when either fails. Run +the image: + +```bash +docker run --memory=5g --memory-swap=5g -p 8080:8080 \ + -v routewatch:/var/lib/berlin.sneak.app.routewatch routewatch +``` + +The memory limit is explained in Memory. The volume holds the database and the +copies of the blocklists. + +To run the binary on the host instead, build it with `make build`. The build +needs Go and a C compiler, because the SQLite library is written in C: + +```bash +make build +./bin/routewatch +``` + +`make run` builds the binary and runs it with debug logging. It also writes the +log to `log.txt`. + +Then open `http://localhost:8080/` in a browser, or ask about an address: + +```bash +curl http://localhost:8080/ip/8.8.8.8 +``` + +The routing table starts empty. RIS Live sends no routing table when a client +connects, only the changes from then on. So the table holds the routes announced +since RouteWatch first started, and it fills as the feed runs. An IP lookup finds no route for an address until a prefix that holds it is announced. The -routing table also misses these updates: +database keeps the table across restarts. -- those sent while RouteWatch is stopped -- those that RouteWatch drops when it falls behind +## HTTP -A lookup or a prefix page can therefore show a route that has since been -withdrawn. +The server listens on `PORT`, `8080` by default. Every page and API path answers +`HEAD` as it answers `GET`, without the body. -## Features +The pages and the API read routes from the routing table in memory. For each +prefix and origin AS, the table keeps the two newest routes in full, with their +AS paths and times. This document calls them the kept routes. The table keeps +only the address of every other peer that carries the prefix with that origin +AS. When a kept route is withdrawn, no older route takes its place. So a prefix +and origin AS can have two kept routes, one or none. -- 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 -- IP lookups report the public blocklists an address is on, from copies kept in - memory and on disk -- Automatic reconnection with exponential backoff -- Batched database writes for high performance -- Backpressure handling to prevent memory exhaustion +### Web pages -## Installation +- `/`: the home page. Its top row shows these figures, refreshed every 5 + seconds: + - whether the feed is connected + - how many routes, ASes, prefixes and peers there are + - the route updates per second -```bash -go build -o routewatch ./cmd/routewatch -``` - -## Usage - -```bash -# Run the daemon (listens on port 8080 by default) -./routewatch - -# Set custom port -PORT=3000 ./routewatch - -# Enable debug logging -DEBUG=routewatch ./routewatch -``` - -## HTTP Endpoints - -Every route answers `HEAD` as it answers `GET`, without the body. - -The pages and the API read routes from the routing table that RouteWatch keeps -in memory. For each prefix and origin AS, the table keeps the two newest routes -in full, with their AS paths and times. These are the kept routes. The table -keeps only the address of every other peer that carries the prefix. When both -kept routes of an origin AS are withdrawn, that origin has no kept route until -one of its peers sends an update. - -### Web Interface - -- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP - address, and a search of AS names. A card shows the `curl` command that looks - up the visitor's own address at `/ip/{addr}`, and that command's live answer. - Other cards show figures of the routing table, with links to the AS and prefix - pages: - - the newest and the oldest kept route, with its prefix, origin AS and time - - the AS last seen in an AS path most recently, and the one last seen - longest ago + Below the figures are a lookup by AS number, a search of AS names and a + lookup by IP address. A card shows the `curl` command that looks up the + visitor's own address, and that command's live answer. Other cards show + figures of the routing table, with links to the AS and prefix pages: + - the newest and the oldest kept route, each with its prefix, origin AS and + time + - the AS most recently seen in an AS path, and the AS seen longest ago - the AS that originates the most prefixes - - how many ASes originate a single prefix, and the most recently updated of - them - - the number of bogon announcements, linked to `/bogons` + - how many ASes originate a single prefix, and the newest kept route of + those ASes + - the number of bogon announcements, linked to `/bogons`. A bogon + announcement is a route whose prefix or origin AS is reserved, as the + rules below say. - the number of prefixes with more than one origin AS, linked to `/multiple-origins` - - well-known networks, such as Apple, Google, Cogent and MIT, each with its - number of prefixes and the time of its newest kept route, newest first + - fifteen well-known networks, such as Apple, Google, Cogent and MIT, each + with its number of prefixes and the age of its newest kept route, newest + first - RouteWatch works these figures out once a minute, so they can be a minute - old. + RouteWatch works out the figures on these cards once a minute, so they can + be a minute old. Until the first figures are ready, the page says that they + are being worked out. -- `GET /bogons` - The bogon announcements (HTML): the routes whose prefix or - origin AS is reserved. A prefix is reserved when it lies inside one of these: - - a block that the IANA special-purpose address registries mark as not - globally reachable - - the multicast space, `224.0.0.0/4` and `ff00::/8`, which the IANA IPv4 and - IPv6 address space registries list as Multicast +- `/as/{asn}`: an AS, the prefixes it originates and the ASes next to it in AS + paths. The prefixes come in two lists, IPv4 and IPv6, shortest mask length + first. An AS has a handle, a short name such as `GOOGLE`, and a description, + such as `Google LLC`. When the database has no record of the AS, both come + from the AS list built into RouteWatch. The page then shows no first-seen + date. +- `/prefix/{prefix}/{length}`: a prefix, such as `/prefix/192.0.2.0/24`, with + its origin ASes and every peer that carries it. Only the kept routes show an + AS path, a next hop and a time. The next hop is the router the peer sends the + prefix's traffic to. RouteWatch records the peer itself as the next hop. +- `/prefixlength/{length}`: up to 500 IPv4 prefixes with that mask length, + picked at random. Each shows its origin AS and the age of its newest kept + route. When peers give a prefix different origin ASes, the page shows the one + that most peers give. +- `/prefixlength6/{length}`: the same for IPv6. +- `/bogons`: the bogon announcements. Each row shows the prefix, the origin AS, + the number of peers and the reason. The page lists at most 1,000 rows, in + prefix order, and says how many there are in all. The list is worked out once + a minute, with the home page's figures. +- `/multiple-origins`: the prefixes that more than one origin AS announces. Each + row shows the origin ASes and how many peers carry the prefix with each. Like + `/bogons`, the page lists at most 1,000 rows, in prefix order, and says how + many there are in all. +- `/status`: a dashboard of the figures of `/api/v1/stats`, refreshed every 2 + seconds. - A reserved AS number is one of these: - - an AS number for private use or for documentation - - `AS23456` - - the last AS number of the 16-bit or the 32-bit range +A prefix is reserved when it lies inside one of these: - Address space and AS numbers that the IANA has not handed out yet are not - counted. Each row shows the prefix, the origin AS, the number of peers and - why it is a bogon. The page shows at most 1,000 rows, in prefix order, and - says how many there are in all. +- a block that the IANA special-purpose address registries mark as not globally + reachable. The IANA is the body that hands out the internet's address space + and AS numbers. +- the multicast blocks, `224.0.0.0/4` and `ff00::/8`. A multicast address stands + for a group of receivers, not for one host. -- `GET /multiple-origins` - The prefixes announced by more than one origin AS - (HTML). Each row shows the origin ASes and how many peers carry the prefix - with each. Like `/bogons`, it shows at most 1,000 rows, in prefix order, and - says how many there are in all. -- `GET /status` - HTML status dashboard, which reads `/api/v1/stats` -- `GET /status.json` - JSON statistics: feed, memory, database and route - figures. The route figures come from the routing table. `oldest_route` and - `newest_route` are the times of the oldest and the newest kept routes. - `origins_without_kept_route` counts, across all prefixes, the origin ASes that - have no kept route. Other figures include these: - - `uptime_seconds`: the number of seconds since the RIS Live feed last - connected, or 0 when it has never connected - - `memory_usage_bytes`: the size of the Go heap in use, in bytes - - `whois_stats.current_interval_seconds`: the WHOIS fetcher's wait between - two fetches, in seconds, which can include a fraction of a second +A few blocks that may be announced lie inside reserved blocks. Of the blocks +that hold the prefix, the one with the longest mask length decides. -- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the - database answers and the RIS Live feed is connected, otherwise 503; - `data.status` is `ok` or `error` and `data.checks` says which check failed -- `GET /as/{asn}` - AS detail page (HTML): the prefixes the AS originates and - its peers. When the database has no record of the AS, its handle and - description come from the AS list built into RouteWatch. The page then shows - no first-seen date. -- `GET /prefix/{prefix}/{length}` - Prefix detail page (HTML), such as - `/prefix/192.0.2.0/24`: its origin ASes and every peer that carries it. Only - the kept routes show an AS path, a next hop and a time. -- `GET /prefixlength/{length}` - Up to 500 IPv4 prefixes with that mask length, - picked at random (HTML). Each shows the origin AS that most peers carry it - with, and the age of its newest kept route. -- `GET /prefixlength6/{length}` - The same for IPv6 -- `GET /ip/{addr}` - The same JSON answer as `/api/v1/ip/{ip}` -- `GET /ip/` - The same, for the caller's own address: the address the request - came from or, when that is a trusted proxy (`TRUSTED_PROXIES`, see - Configuration), the client address in its `X-Forwarded-For` +An AS number is reserved when it is one of these: -### API v1 +- an AS number for private use or for documentation +- `AS23456`, which stands in for a 32-bit AS number +- the last AS number of the 16-bit range or of the 32-bit range -- `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage - collector, stream and handler queue statistics. `connection_duration_seconds` - is the number of seconds the RIS Live feed has been connected, or null while - it is not connected. `stream.route_churn_per_min` is the number of prefix - announcements and withdrawals, from all peers, in the last full clock minute. -- `GET /api/v1/ip/{ip}` - Looks up each address. `{ip}` is an IP address, a host - name, or a comma-separated list of at most 10 of these. `results` has one - entry for each address. Every entry has the same fields: - - `ip`: the address - - `ptr`: the address's PTR names, or an empty list when it has none - - `blocklists`: the public blocklists the address is on (see Blocklists), or - an empty list when it is on none +Address space and AS numbers that the IANA has not handed out yet are not +counted. - Every other field is about the address's live route. Each is null when the - address has no live route. The fields about the route: - - `netblock`: the most specific prefix that holds the address - - `mask_length`: the netblock's mask length - - `ip_version`: the netblock's IP version, 4 or 6 - - `num_peers`: the number of peers that carry the netblock - - `asn`: the origin AS that the most peers carry the netblock with - - `num_origins`: the number of origin ASes that peers carry the netblock - with - - `origin_asns`: those origin ASes, in AS number order - - `first_seen`: when RouteWatch first saw the netblock, or null when the - database has not stored the netblock yet - - `last_seen`: the time of the netblock's newest kept route, or null when - the netblock keeps no route +### JSON API - The fields about the AS in `asn` come from the database. Each is also null - when the database does not have it: - - `handle`: the AS's handle - - `description`: the AS's description - - `as_name`: the AS's name in its WHOIS details - - `org_name`: the name of the organisation that holds the AS - - `org_id`: the organisation's ID at its registry - - `address`: the organisation's postal address - - `country_code`: the organisation's country code - - `abuse_email`: the address that takes abuse reports for the AS - - `rir`: the regional internet registry of the AS +A JSON answer is `{"status": "ok", "data": ...}`. An error answer is +`{"status": "error", "error": {"msg": ..., "code": ...}}`. Every JSON answer +also carries an `@meta` object: - Of the addresses a host name resolves to, only public ones are looked up. A - name with none, such as one that resolves to a private address, is reported - as `host name has no public address`, without its addresses. `errors` has - one entry for each host name that failed. The answer is 400 when: - - `{ip}` lists more than 10 - - `{ip}` comes to more than 10 addresses once its names are resolved - - nothing in `{ip}` is an IP address or a host name with a public address +- `time_zone`: always `UTC` +- `api_version`: `1` +- `execution_time_ms`: the number of milliseconds the request took - A PTR lookup gets 2 seconds. An address whose lookup takes longer is - answered without PTR names. +Every value with a unit is a number, and the unit ends the field name. Examples +are `_ms`, `_seconds`, `_bytes`, `_per_sec` and `_per_min`. -- `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6 - apart, each with the time of its newest kept route. A prefix that keeps no - route with the AS has no `last_updated`. When the database has no record of - the AS, its handle and description come from the AS list built into - RouteWatch. The answer then has no `first_seen` or `last_seen`. -- `GET /api/v1/prefix/{prefix}/{length}` - One route for each peer that carries - the prefix, and the same routes grouped by origin AS in `origins`. Only the - kept routes have `as_path`, `next_hop` and `last_updated`. -- `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description +The paths: + +- `GET /api/v1/ip/{ip}`: what RouteWatch knows about each address in `{ip}`. See + IP lookup below. +- `GET /ip/{addr}`: the same answer as `/api/v1/ip/{ip}`. +- `GET /ip/`: the same answer, for the caller's own address. That is the address + the request came from. For a request from a trusted proxy, it is the client + address in `X-Forwarded-For` (see `TRUSTED_PROXIES` in Configuration). +- `GET /api/v1/as/{asn}`: the AS and the prefixes it originates. `data` holds: + - `asn`: the AS, with its `handle`, `description`, `first_seen` and + `last_seen` + - `ipv4_prefixes` and `ipv6_prefixes`: the prefixes it originates, each with + `last_updated`, the time of its newest kept route with the AS + - `total_count`: how many prefixes the two lists hold together + + `last_updated` is left out when the kept routes of the prefix with the AS + were all withdrawn. When the database has no record of the AS, its `handle` + and `description` come from the AS list built into RouteWatch. The answer + then has no `first_seen` or `last_seen`. + +- `GET /api/v1/prefix/{prefix}/{length}`: the prefix and its routes. `data` + holds: + - `prefix`: the prefix + - `routes`: one route for each peer that carries the prefix + - `origins`: the same routes, grouped by origin AS + - `peer_count` and `origin_count`: how many routes and how many origin ASes + there are + + A route has `prefix`, `mask_length`, `ip_version`, `origin_asn` and + `peer_ip`. Only the kept routes also have `as_path`, `next_hop` and + `last_updated`. + +- `GET /api/v1/search/as?q={text}`: the ASes whose handle or description contains `{text}`, ignoring case. `data` holds: - `query`: `{text}` without its leading and trailing spaces - `total`: the number of ASes that match @@ -228,74 +233,165 @@ one of its peers sends an update. The search reads the AS list built into RouteWatch, not the database. The list comes from - [ipverse/as-metadata](https://github.com/ipverse/as-metadata). A match can - therefore be an AS that the database has no record of. A `q` that is - missing, empty or only spaces answers 400. + [ipverse/as-metadata](https://github.com/ipverse/as-metadata). So a match + can be an AS that the database has no record of. -A JSON answer is `{"status": "ok", "data": ...}`, or for an error -`{"status": "error", "error": {"msg": ..., "code": ...}}`. The health check is -the exception: its 503 answer is also `{"status": "ok", "data": ...}`, with -`error` in `data.status`. Every JSON answer also carries an `@meta` object: +- `GET /status.json`: the daemon's figures. The figures whose names do not say + what they mean: + - `uptime_seconds`: the seconds since the feed last connected, or 0 when it + has never connected + - `memory_usage_bytes`: the size of the Go heap in use + - `live_routes`, `ipv4_routes` and `ipv6_routes`: the number of peer and + prefix pairs, where each peer that carries a prefix counts once for it + - `oldest_route` and `newest_route`: the times of the oldest and the newest + kept route + - `origins_without_kept_route`: the number of prefix and origin AS pairs + that peers carry but whose kept routes were all withdrawn + - `whois_stats.current_interval_seconds`: the WHOIS fetcher's wait between + two fetches, which can include a fraction of a second +- `GET /api/v1/stats`: the `/status.json` figures and five more: + - `reconnect_count`: how many times the feed has reconnected since the start + - `connection_duration_seconds`: the seconds the feed has been connected, or + null while it is not connected + - `gc`: figures of the garbage collector, the part of the Go runtime that + frees memory no longer in use + - `stream`: figures of the feed. `stream.route_churn_per_min` is the number + of prefix announcements and withdrawals, from all peers, in the last full + clock minute. + - `handler_stats`: figures of the handlers' queues. The handlers are the + four parts that take the feed's messages (see Design). +- `GET /.well-known/healthcheck.json`: the health check. It answers 200 when the + database answers and the feed is connected, otherwise 503. `data.status` is + `ok` or `error`, and `data.checks` says which check failed. The 503 answer + keeps the `{"status": "ok", "data": ...}` form. -- `time_zone`: always `UTC` -- `api_version`: `1` -- `execution_time_ms`: the number of milliseconds the request took +### IP lookup -Every value with a unit is a number whose field name ends in the unit, such as -`_ms`, `_seconds` or `_bytes`. +`{ip}` is an IP address, a host name, or a comma-separated list of at most 10 of +these. A host name is resolved, and only its public addresses are looked up. The +answer holds `query`, the text looked up, and `results`, with one entry for each +address. Every entry has the same fields: -A request that runs past its time limit answers 504: the limit is 30 seconds, -and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`. -An AS number, prefix or mask length that does not parse answers 400, as does a -mask length out of range for its IP version. An AS answers 404 when it -originates no prefix and has no record in the database or in the AS list built -into RouteWatch. AS 0 always answers 404, because it means no known origin. The -AS name search never lists it. A prefix with no live route answers 404 too. The -HTML pages give these errors as plain text. Under `/api/v1` and `/ip/`, a path -that matches no route answers 404 with the message `Not found`. A request with -`POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers 405 -with `Method not allowed` on these routes: +- `ip`: the address +- `ptr`: the address's PTR names, the host names that DNS gives for it, or an + empty list when it has none +- `blocklists`: the public blocklists the address is on (see Blocklists), or an + empty list when it is on none -- the routes under `/api/v1` and `/ip/` -- `/status.json` -- `/.well-known/healthcheck.json` +Every other field is about the prefix in the routing table that holds the +address. Each is null when no prefix in the routing table holds the address: -A 500 answer says only `Internal server error`. The error for a host name that -does not resolve says only `could not resolve host name`. The detail goes to the -log. +- `netblock`: the most specific prefix that holds the address, the one with the + longest mask length +- `mask_length`: the netblock's mask length +- `ip_version`: the netblock's IP version, 4 or 6 +- `num_peers`: the number of peers that carry the netblock +- `asn`: the netblock's origin AS. When peers give different origin ASes, it is + the one that most peers give. +- `num_origins`: how many different origin ASes the netblock has +- `origin_asns`: those origin ASes, in AS number order +- `first_seen`: when RouteWatch first saw the netblock, or null when the + database has not stored the netblock yet +- `last_seen`: the time of the netblock's newest kept route, or null when its + kept routes were all withdrawn -Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`, -`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and -`Permissions-Policy`. The Content-Security-Policy lets a page load content only -from the server, and does not allow inline content with `'unsafe-inline'`: the -pages keep their CSS and JavaScript in inline `