Files
routewatch/TODO.md
T
clawbot fb5e98feab
check / check (push) Canceled after 0s
Trust reverse proxies at private addresses by default (closes #198)
With TRUSTED_PROXIES unset, the server now trusts reverse proxies in the
private ranges of RFC 1918. A proxy such as traefik on a Docker network is
then believed. /ip/, the home page's curl card and the request log give the
client's address from X-Forwarded-For instead of the proxy's address.

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

Model: opus-5-5
2026-10-27 19:18:34 +01:00

303 lines
19 KiB
Markdown

# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# Status
pre-1.0. No git tags. `main` last took `next` on 2026-10-06, through
https://git.eeqj.de/sneak/routewatch/pulls/6. The linter and the tests are
phases of the Docker build, and the Gitea workflow runs `script/cibuild` (the
format check of the Go code and the markdown, both phases, then the image) on
every push. The image sets memory ceilings for a 5 GiB container (README
"Memory") and the README says how to run it under upaas (README "Running under
upaas"). A 25-hour run of `a9b6860` on the live feed peaked at about 0.6 GiB
(https://git.eeqj.de/sneak/routewatch/issues/3). A run of `825afa2` on the live
feed found the prefix handler dropping about a quarter of its updates because
its route writes were slower than the feed
(https://git.eeqj.de/sneak/routewatch/issues/96); the cheaper writes since then
are measured by a benchmark, not yet on the live feed.
# Next Step
Run `next` on the live feed, as README "Running under upaas" describes, until
the routing table stops growing. Post its size and memory figures on
https://git.eeqj.de/sneak/routewatch/issues/122. The same run makes the final
check https://git.eeqj.de/sneak/routewatch/issues/96 names: whether the prefix
handler still drops updates after the first hour. If it does, a new issue
follows with the numbers. Then an audit of the code at the commit that run
tested.
# Completed Steps
- 2026-10-27: with `TRUSTED_PROXIES` unset, the server trusts reverse proxies at
the private addresses of RFC 1918. `/ip/`, the home page's `curl` card and the
request log then give the client's address from `X-Forwarded-For`. They used
to give the proxy's address. A list in `TRUSTED_PROXIES` replaces the default,
and `TRUSTED_PROXIES` set but empty trusts no proxy (closes #198)
- 2026-10-27: the home page has cards with figures of the routing table: the
newest and oldest kept route, the newest and oldest updated AS, the AS with
the most prefixes, the ASes with a single prefix, and fifteen well-known
networks. Two cards count the bogon announcements and the prefixes with more
than one origin AS, and link to the new pages `/bogons` and
`/multiple-origins` that list them. A new package, `internal/bogon`, tells
which prefixes and AS numbers are reserved. The server works the figures out
once a minute (closes #154)
- 2026-10-27: the per-peer route tables are gone, with the code that wrote,
counted and read them. The prefix handler applies each update to the routing
table in memory and writes the table's changes every second. It also stores
each new prefix with its first-seen time. The image's memory limits are set
for a full routing table of about 1 GB: `GOMEMLIMIT` is 2.5 GiB, the writing
connection's page cache 512 MiB, and SQLite's heap limits 768 MiB and 1 GiB
(closes #122)
- 2026-10-27: the pages, the API and the stats read every route from the routing
table in memory. For a prefix with several origin ASes, the IP lookup gives
the one most peers carry. Its `last_seen` is the newest kept route's time. The
prefix page and API list every peer that carries the prefix, with an AS path,
next hop and time only for the kept routes. The stats count the origins whose
kept routes were all withdrawn, in `origins_without_kept_route` (part of #122)
- 2026-10-27: the prefix, AS and peer handlers make a timed write on every tick
of their timers, every 1, 2 and 2 seconds. They used to skip every other tick.
A full batch's write restarts the timer, so the next timed write comes one
interval after that write began (closes #192)
- 2026-10-27: once the stop deadline has passed, the AS, peer and peering
handlers start no write either. A full batch or a tick of the timer that
waited while a write ran past the deadline could still start one. The flush
goroutines of the prefix, AS and peer handlers now run the same code (closes
#189)
- 2026-10-27: once the stop deadline has passed, the prefix handler starts no
flush. A batch that filled while a write ran past the deadline could still be
written after it (closes #188)
- 2026-10-27: the prefix handler applies each update to the routing table in
memory, and writes the table's changes to the database every second. A write
takes at most 20,000 changed pairs, and the rest follow in the next writes. A
failed write is written again later. Startup reads the table before the
streamer connects. The pages still read the per-peer route tables (part of
#122)
- 2026-10-27: the database keeps a copy of the whole routing table in a new
`routes` table, one row per prefix and origin AS. The peers' numbers are in a
new `route_peers` table. One function writes the pairs that changed in memory,
and another reads the whole table back into memory at startup. Nothing uses
them yet (part of #122)
- 2026-10-27: a new package, `internal/routetable`, holds the whole routing
table in memory. For each prefix and origin AS it keeps the peers that carry
it, one bit per peer, and the two newest of their routes. Nothing uses it yet
(part of #122)
- 2026-10-27: each result of the IP lookup lists the public blocklists the
address is on, in `blocklists`. The lists are DShield, Feodo Tracker, two
FireHOL lists, Spamhaus DROP and the Tor exit list. RouteWatch downloads each
list on its own schedule and answers from copies in memory. The copies are
also kept in the state directory's `blocklists` directory. IP lookup answers
carry `Cache-Control`. An address with no live route now has a result too,
without route fields, so the IP lookup no longer answers 404 (closes #158)
- 2026-10-27: the home page has an IP information API card. It shows the `curl`
command that looks up the visitor's own address at `/ip/{addr}`, and that
command's live answer (closes #156)
- 2026-10-27: on the AS page, a click on the heading of the IPv4 or the IPv6
prefix list folds or unfolds the list. Both lists start open (closes #155)
- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
shortest first, and prefixes of one mask length by address. They used to be
listed by address (closes #153)
- 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame
each time the stats show more messages than the time before. A page that has
not seen more messages for three refreshes shows "stale" and the time since
then instead, in red (closes #152)
- 2026-10-27: the WHOIS details of a Brazilian AS keep their accented letters.
LACNIC's answer for such an AS comes from NIC.br, Brazil's registry, with each
accented letter replaced. The details now come from NIC.br's own server,
`whois.registro.br`. When that query fails, the details from LACNIC's answer
are kept and a warning is logged. After NIC.br answers that RouteWatch is over
its query limit, RouteWatch stops asking NIC.br for an hour (closes #174)
- 2026-10-27: `make asupdate` stops with an error when the download of `as.csv`
breaks off. The generator used to retry the failed read forever. A record that
does not parse is still logged and skipped (closes #177)
- 2026-10-27: `make asupdate` runs the new `script/asupdate`. It replaces the AS
list built into RouteWatch only when the generator succeeds and finds at least
one AS. A failed download used to replace the list with an empty file. The
generator reads `as.csv` from `ipverse/as-metadata`, the source's new name
(closes #173)
- 2026-10-27: the WHOIS details of an AS at RIPE, APNIC or AFRINIC come only
from its `aut-num` object and the organisation object it names. RIPE and APNIC
start their answer with the `as-block` object of the AS's block. Its dates
were stored as the AS's (closes #168)
- 2026-10-27: AS names keep their accented letters. The AS list built into
RouteWatch is rebuilt from today's `as.csv`, which added a country code
column. That file no longer lists AS 0 or the documentation AS numbers. A
WHOIS answer that is not valid UTF-8 is decoded as Latin-1, which RIPE and
APNIC send, before it is parsed and stored (closes #165)
- 2026-10-27: the Age column on the AS and prefix pages reads in words, such as
"5 minutes ago" or "2 weeks ago", instead of Go durations such as "360h0m0s
ago". An age under a minute reads in seconds, and one over a month in months
or years instead of a date (closes #163)
- 2026-10-26: a write to a stored AS, peering or peer keeps the earlier of the
two first-seen times and the later of the two last-seen times. An old update
from a collector catching up on a backlog no longer moves last seen back
(closes #162)
- 2026-10-26: the status page shows "Route Churn/min", the number of prefix
announcements and withdrawals, from all peers, in the last full clock minute.
`/api/v1/stats` reports it as `route_churn_per_min` in `stream`, which
replaces the moving average `route_churn_per_sec`. The messages and updates
per second on `/` and `/status` are whole numbers (closes #160)
- 2026-10-26: the WHOIS fetcher backs off more slowly. A failure makes its wait
1.5 times as long, up to one minute instead of five. Its queries to RIPE,
APNIC and AFRINIC leave out person and role objects, which RIPE counts against
a daily limit per address. A refused query is logged with the RIR's error line
(closes #159)
- 2026-10-26: the home page's AS name search lists the ASes whose handle or
description holds the text, as links to their pages, from the new
`/api/v1/search/as`; the search ignores case. When the database has no record
of an AS, its page takes the handle and description from the AS list built
into RouteWatch and still lists the AS's prefixes (closes #151)
- 2026-10-19: README "HTTP Endpoints" lists every route with what it answers,
"Database Schema" matches `schema.sql`, and the rest of the README matches the
code; this file brought up to date (closes #72)
- 2026-10-19: route writes are cheaper: the live route and prefix tables lose
their random UUID keys and the prefix tables their duplicate index, a route
update sets only the columns that change, and every write goes through one
connection with a page cache of 1 GiB (README "Memory"). A benchmark writes
the prefix handler's largest batch faster than the feed sends it (closes #96)
- 2026-10-19: the WAL checkpoint, the incremental vacuum and ANALYZE take the
database lock every batch write holds, so a vacuum no longer fails with
"database is locked" and an ANALYZE no longer makes a batch write lose its
updates; ANALYZE runs as `PRAGMA optimize` with an analysis limit (closes #94)
- 2026-10-19: shutdown stops the streamer first, and each handler writes
everything left on its queue before it stops, so the updates waiting there are
no longer lost (closes #69)
- 2026-10-19: a path that ends in an AS set of several members has origin AS 0,
no known origin, instead of the set's last member, and the set adds no
peerings; the pages show such a route as having no known origin (closes #75)
- 2026-10-19: `bgp_peer_count` drops to 0 when the last peer goes down (closes
#92)
- 2026-10-19: `bgp_peer_count` counts every peer the feed has heard from, not
only those whose session opened since startup, and drops a peer whose session
goes down (closes #74)
- 2026-10-19: the shared files are re-vendored from `sneak/prompts` at
`0653579`: golangci-lint v2.14.0 and its configuration, lint and test as
`Dockerfile` phases that keep Go's and golangci-lint's caches, markdown
formatted with prettier, agent guidance in `AGENTS.md`; the code is fixed for
what the new linter configuration finds (closes #52)
- 2026-10-19: the streamer reads feed lines up to 1 MiB, so a line over 64 KiB
no longer ends the connection (closes #76)
- 2026-10-19: a peer's live routes are removed when its BGP session goes down;
`STATE` messages are never dropped for backpressure, and one that finds its
queue full waits for room ahead of the handler's other messages (closes #56)
- 2026-10-19: the prefix-length pages pick their sample with a bounded number of
index lookups instead of reading every route of that length, and list each
prefix once (closes #66)
- 2026-10-19: a request past its time limit answers 504 instead of 408, and
`/api/v1/stats` answers 504 instead of 500 when its read runs out of time
(closes #70)
- 2026-10-19: the AS, peer and prefix handlers write each batch outside the lock
their message handling needs, so messages keep coming off their queues during
a write; meanwhile the next batch grows to at most ten batch sizes (closes
#65)
- 2026-10-19: a live route is identified by prefix and peer, so an announcement
with a new origin AS replaces the peer's route and a withdrawal removes it
whatever its origin AS (closes #55)
- 2026-10-14: the streamer reconnects when RIS Live ends the stream or sends
nothing for 60 seconds, and the health check reports the feed disconnected
meanwhile (closes #53)
- 2026-10-14: the prefix page takes the names of the origin ASes from the prefix
query instead of reading every route of each origin AS (closes #67)
- 2026-10-14: the daemon exits with status 1 when its web server cannot listen
on its port, instead of running on without it (closes #73)
- 2026-10-14: the prefix pages parse the prefix before looking it up: one that
does not parse answers 400, and any valid way of writing a prefix finds its
routes (closes #71)
- 2026-10-14: the IP lookup ignores routes shorter than /8 for IPv4 and /16 for
IPv6, so a default route is no longer named as an address's owner (closes #57)
- 2026-10-14: a prefix batch keeps each peer's last update for a prefix, instead
of one update per prefix across all peers (closes #54)
- 2026-10-14: `reconnect_count` in `/api/v1/stats` counts every reconnect
(closes #68)
- 2026-10-03: `/api/v1/stats` serves the prefix distribution from memory, seeded
at startup and adjusted on every live-route write, so a request no longer
reads every live route (closes #30)
- 2026-10-03: a new database is created with `auto_vacuum` set to incremental,
through the connection string so it is set before the file is first written,
and each periodic incremental vacuum now returns up to 1000 free pages; the
late `PRAGMA auto_vacuum` in `Initialize`, which SQLite ignored, is gone
(closes #43)
- 2026-10-03: looking up an IP address no longer reads every IPv6 route: both
families find the most specific live route with at most 33 or 129 lookups on
the prefix index, and the IPv4 range columns are gone. Prefixes from the feed
are stored in one text form, so an IPv6 withdrawal, which the feed sends
uncompressed, now removes its route (closes #48)
- 2026-10-02: a plain `docker build .` stamps the commit's tag or short commit
(`git describe --tags --always`) into the page footer instead of `unknown`:
`.dockerignore` sends `.git` without `.git/config`, a `VERSION` build argument
takes precedence, and `make build` stamps the same value (closes #46)
- 2026-09-29: the entrypoint creates the data directory if it is missing and
stops the start if a step fails; README "Running under upaas" no longer asks
for the host directory to be created first (closes #42)
- 2026-09-29: `.dockerignore` keeps `.git`, local build output, local databases
and `.env` out of the Docker build context, and so out of the source archive
in the image (closes #39)
- 2026-09-29: README first line names the MIT license and the author; the
License section now says MIT and links `LICENSE`, and an Author section was
added; this file brought up to date (closes #38)
- 2026-09-29: MIT `LICENSE` (closes #1)
- 2026-09-28: stopping the daemon while the feed is flowing no longer panics
with "send on closed channel": the read loop checks for a stop just before
handing a message to the handler queues, and a second `Stop` no longer closes
the queues again (closes #34)
- 2026-09-28: `docker stop` no longer kills the daemon 2 seconds after the stop
signal: the entrypoint switches to the `routewatch` user with `setpriv`
instead of `runuser`, so the daemon receives the signal itself and gets the
whole wait `docker stop` allows, up to its own 60-second limit (closes #33)
- 2026-09-28: ready to run under upaas: a set but invalid `PORT`,
`XDG_DATA_HOME` or `MALLOC_ARENA_MAX` stops the start, the health check
follows `PORT`, README "Running under upaas" section (closes #31)
- 2026-09-22: realtime in-memory database statistics: counts seeded at startup
and adjusted on every write, oldest/newest route timestamps via index-end
lookups; `/api/v1/stats` no longer scans the tables (closes #27)
- 2026-09-21: batch writes take the write lock when their transaction begins
(`_txlock=immediate`), so they wait out a WAL checkpoint instead of failing
with "database is locked" (closes #25)
- 2026-09-21: `MALLOC_ARENA_MAX=2` in the image caps glibc malloc arenas, so
memory outside the Go runtime no longer grows with the core count (closes #23)
- 2026-09-21: `GOMEMLIMIT=1536MiB` in the image; README Memory section with the
memory budget and the 5 GiB container limit (closes #13)
- 2026-09-21: the four handler queues hold at most 20,000 messages each, down
from 100,000 (closes #11)
- 2026-09-21: two goroutine leaks fixed: the stats handlers after a timeout and
the streamer's tickers on every reconnect (closes #12)
- 2026-09-21: parsed RIS messages no longer keep the unused `Community` and
`Raw` fields (closes #9)
- 2026-09-21: `.editorconfig`, and a Gitea workflow that runs `script/cibuild`
on every push (closes #14)
- 2026-09-21: the peering handler's AS-path map holds at most 500,000 paths and
is swapped for an empty one every 30 seconds instead of copied (closes #10)
- 2026-09-21: SQLite memory bounded across the whole connection pool: a 64 MiB
page cache on each connection, 1 GiB soft and 1.5 GiB hard heap limits (closes
#8)
- 2026-09-21: the Docker build runs the format check, the linter and the tests,
linting in a separate stage on a golangci-lint image pinned by digest (closes
#5)
- 2026-09-21: `make test` skips the live-network feed test (`-short`), so
`make check` no longer depends on the network (closes #2)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-22: repo policy compliance: required policy files, .gitignore update,
Makefile fmt-check/check/docker/hooks targets, gofmt pass
(repo-policies-compliance, unmerged)
- 2026-01-01: WAL checkpointing: periodic (5s), on startup with logging,
TRUNCATE mode; fixed slow queries
- 2025-12-31: status page: navbar, home page with search, oldest/newest route
timestamps, NULL handling in WHOIS stats; container runs as routewatch user
with proper state dir
- 2025-12-30: structured HTTP request logging, increased timeouts, CIDR prefix
URL routing fixes, status page metrics and footer
- 2025-12-29: Dockerfile multi-stage build with source archive; SQLite
incremental vacuum for non-blocking space reclamation
- 2025-07-27: initial RouteWatch BGP stream monitor
# Future Steps
None.