Every sentence of TODO.md is rewritten from scratch against the rules of #157: about 30 words at most, one idea each, subject and verb early, identifiers in backticks. Status and Next Step describe the repo as the tracker shows it now. Every Completed Steps entry keeps its date, its facts and its issue reference, checked against the commit it names. The Workflow section keeps its meaning. The entry for this rewrite is added. Model: fable-5-1
27 KiB
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.mdchanges in the same commit as the work) - merge to
mainif the branch is not protected, otherwise open a PR - push
Status
RouteWatch is pre-1.0, with no git tags. main last took next on 2026-10-27,
through #166.
#202 carries the commits since then
into main. It is labelled merge-ready and waits for sneak.
The linter and the tests are phases of the Docker build. The Gitea workflow runs
script/cibuild on every push. That script runs the two phases and checks the
formatting of the Go code and the markdown. Then it builds the image.
The image sets memory ceilings for a 5 GiB container (README "Memory"). README "Running under upaas" says how to run it.
A five-day run of 1c65656 on the live feed, from 2026-10-27 to 2026-11-01,
found three problems:
- The prefix handler dropped updates while a peer's session went down (#216).
- The AS handler dropped updates when the feed burst (#217).
- The WHOIS fetcher kept asking a registry that had refused it (#218).
All three are fixed on next. So is
#222, which the third fix brought up.
A run of 26d939a on the live feed showed the prefix handler still dropping
updates while the home page's cards were worked out.
#226 fixed that on next.
#225, also on next, takes the peers
whose sessions go down together out of the table in one walk. A 24-hour run of
9bd0e91 on the live feed is checking whether any handler still drops updates.
The README rewrite, #210, is being reworked. It is part of #157.
Two questions wait for sneak: which history the prefix page keeps (#161), and which fields the IP lookup answer gains (#203).
The order of the project's remaining work is kept on #86.
Next Step
Post the outcome of the 24-hour run of 9bd0e91 on
#226: whether any handler still drops
updates, and the process's peak memory against 5 GiB. File an issue for each
problem the run finds.
Completed Steps
- 2026-11-02:
TODO.mdis rewritten from scratch, sentence by sentence, against the rules of #157. Every Completed Steps entry keeps its date, its facts and its issue. Status and Next Step describe the repo as the tracker shows it (part of #157) - 2026-11-02: when the sessions of several peers go down, the prefix handler
takes the peers out of the routing table together. The table's
PeersDowntakes several peers out of every prefix in one walk. One walk runs at a time. The next one takes every peer whose session went down since the previous one began. Each peer used to have a walk of its own. A peer's messages stayed held back while the walks queued ahead of its own ran. The handler holds back at most 150,000 messages, about 150 MiB. It used to hold back at most 20,000. The status page shows how many messages the handler holds back (closes #225) - 2026-11-02: working out the home page's cards no longer stops the prefix
handler. The walk over the routing table holds the table's lock for 10,000
prefixes at a time, then lets the lock go for as long again, as
PeerDowndoes. It used to take the lock back at once. The prefix handler then applied about one update per 10,000 prefixes walked. During a walk, its queue could fill, and the streamer then dropped updates for it (closes #226) - 2026-11-02: A skipped WHOIS lookup now makes the fetcher wait longer before the next one, as a failed lookup does. A lookup is skipped when its RIR is paused. An RIR is one of the five regional Internet registries. The longest wait is one minute. The status page does not count a skip as a failure. A skip used to leave the wait as it was. Each lookup asks IANA which RIR holds the AS before it finds that RIR paused. So while most stale ASes belonged to a paused RIR, the fetcher could ask IANA every second (closes #222)
- 2026-11-02: A peer's session going down no longer stops the prefix handler.
The handler runs the routing table's
PeerDownon a goroutine of its own, one peer at a time. Meanwhile it goes on taking messages.PeerDownholds the table's lock for 10,000 prefixes at a time, then lets the lock go for as long again. The handler holds back the down peer's later messages until that peer'sPeerDownreturns. Then the handler applies them in order, one at a time, and takes new messages in between. It holds back at most 20,000 messages. At that number,HandleMessagewaits until one of them is applied. WhenStopruns past its deadline, it logs how many held-back messages were not applied (closes #216) - 2026-11-02: RouteWatch pauses an RIR's or NIC.br's WHOIS server for an hour
after the server refuses a query. NIC.br is Brazil's registry. A refused or
reset connection is a refusal. So is an answer with an
%ERROR:201:or%ERROR:202:line. Lookups at the other servers go on. An AS whose RIR is paused is looked up after the pause. While NIC.br is paused, a Brazilian AS gets the details from LACNIC's answer. IANA is never paused, because a pause of IANA would stop every lookup. A refusal by IANA is an ordinary failure. NIC.br is also paused for an hour after it says RouteWatch has passed its query limit. Before, the fetcher asked a server again within seconds of a refusal (closes #218) - 2026-11-02: The AS handler never waits for a database write. Its batch holds one entry per AS, with the earliest and the latest time of its updates. So the batch no longer grows with the feed's rate. It used to hold one entry for each AS of each path. At 300,000 entries it waited for the write in progress. In a burst of the feed its queue then filled, and the streamer dropped messages for it (closes #217)
- 2026-10-27: The tests of
internal/routewatchno longer fail on a loaded host. Five of them gaveHandleMessagetwo seconds to apply a full prefix batch. Under the race detector that can take longer. A test now waits up to 15 seconds for each step it expects. The tests of the handlers' limits now give the handlers small limits. So the package no longer runs past its 90-second limit (closes #213, #215) - 2026-10-27: The server works out the home page's cards every minute until it
stops. It used to work them out only once, at startup. The end of the context
given to
Startstopped the refreshes (closes #211) - 2026-10-27: README "Rationale" says why RouteWatch exists. RouteWatch answers our other services' questions about IP addresses, from its own routing table and the WHOIS details it keeps. The section still says what RIS Live does not give (closes #204)
- 2026-10-27: Every JSON answer carries
@meta. A panic on a JSON route answers 500 with the JSON error body./status.jsonand the health check answer 405 with the JSON error body to any method they do not take. The 500 and the 405 used to have an empty body. The home page's IP lookup shows the whole JSON answer,@metaincluded. It used to show only the answer'sdata, or only the message of an error answer. A test checks@metaon every JSON route, in its successes and its errors (closes #201) - 2026-10-27: Every value with a unit in a JSON answer is a number. The unit is
at the end of the field name.
@meta.execution_timeis nowexecution_time_ms. In the stats,uptimeis nowuptime_seconds,connection_durationis nowconnection_duration_seconds, andmemory_usageis nowmemory_usage_bytes.whois_stats.current_intervalis nowwhois_stats.current_interval_seconds. They used to be strings such as "12 ms", "1h18m34s" or "1.1 GB".connection_duration_secondsis null while the feed is not connected.API_POLICIES.mdis vendored fromsneak/prompts, andREPO_POLICIES.mdis re-vendored (closes #199) - 2026-10-27: Every result of the IP lookup has the same fields. A field without
a value is null. It used to be left out. An address with no route in the
routing table has every route and AS field set to null.
ptris an empty list when there are no PTR names. The new fieldnum_originsgives the number of origin ASes of the netblock, andorigin_asnsgives which they are (closes #200) - 2026-10-27: With
TRUSTED_PROXIESunset, the server trusts reverse proxies at the private addresses of RFC 1918./ip/, the home page'scurlcard and the request log then give the client's address fromX-Forwarded-For. They used to give the proxy's address. A list inTRUSTED_PROXIESreplaces the default.TRUSTED_PROXIESset but empty trusts no proxy (closes #198) - 2026-10-27: The home page has cards with figures of the routing table. One
card shows the newest and the oldest kept route. One shows the newest and the
oldest updated AS. One shows the AS with the most prefixes. One shows the ASes
with a single prefix. One shows fifteen well-known networks. Two cards count
the bogon announcements and the prefixes with more than one origin AS. They
link to the new pages
/bogonsand/multiple-origins, which 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. It writes the table's changes every second. It also stores
each new prefix with its first-seen time. The image's memory limits fit a full
routing table of about 1 GB.
GOMEMLIMITis 2.5 GiB. The writing connection's page cache is 512 MiB. SQLite's soft and hard heap limits are 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_seenis the newest kept route's time. The prefix page and API list every peer that carries the prefix. Only the kept routes show an AS path, a next hop and a time. The stats count the origins whose kept routes were all withdrawn, inorigins_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. The prefix handler's timer ticks every second. The other two tick every two 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. Before, a full batch or a timer tick could wait while a write ran past the deadline. Either could then start one more write. 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. Before, a batch that filled during a late write could still be written after the deadline (closes #188)
- 2026-10-27: The prefix handler applies each update to the routing table in memory. It writes the table's changes to the database every second. A write takes at most 20,000 changed pairs of prefix and origin AS. 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
routestable, one row per prefix and origin AS. The peers' numbers are in a newroute_peerstable. One function writes the pairs that changed in memory. 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. It also keeps 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, two Spamhaus DROP lists 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'sblocklistsdirectory. IP lookup answers carryCache-Control. An address with no 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
curlcommand that looks up the visitor's own address at/ip/{addr}. It also shows 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. Prefixes of one mask length are listed by address. They used to be sorted by address first, then by mask length (closes #153)
- 2026-10-27: The connection status on
/and/statusgets a spinner. It turns one frame each time the stats show more messages than the time before. After three refreshes with no new messages, the page shows "stale" in red instead. Next to it is the time since the last fresh answer (closes #152) - 2026-10-27: The WHOIS details of a Brazilian AS keep their accented letters.
LACNIC's answer for such an AS is NIC.br's answer with each accented letter
replaced. NIC.br is Brazil's registry. 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. RouteWatch stops asking NIC.br for an hour after NIC.br says RouteWatch is over its query limit (closes #174) - 2026-10-27:
make asupdatestops with an error when the download ofas.csvbreaks 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 asupdateruns the newscript/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 readsas.csvfromipverse/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-numobject and the organisation object it names. RIPE and APNIC start their answer with theas-blockobject 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 before it is parsed and stored. RIPE and APNIC send Latin-1 (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". It used to print Go durations such as "360h0m0s ago". An age under a minute reads "now" or in seconds. An age over a month reads 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. It keeps 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". That is the number of
prefix announcements and withdrawals from all peers in the last full clock
minute.
/api/v1/statsreports it asroute_churn_per_mininstream. It replaces the moving averageroute_churn_per_sec. The messages and updates per second on/and/statusare 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. RIPE counts those objects 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 works. It lists the ASes whose
handle or description holds the text, as links to their pages. The new
/api/v1/search/asanswers it. 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. It still lists the AS's prefixes (closes #151) - 2026-10-19: README "HTTP Endpoints" lists every route with what it answers.
README "Database Schema" matches
schema.sql. The rest of the README matches the code. This file is brought up to date (closes #72) - 2026-10-19: Route writes are cheaper. The live route and prefix tables lose their random UUID keys. The prefix tables lose their duplicate index. A route update sets only the columns that change. 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 checkpoint of the WAL (SQLite's write-ahead log), the
incremental vacuum and
ANALYZEtake the database lock that every batch write holds. So a vacuum no longer fails with "database is locked". AnANALYZEno longer makes a batch write lose its updates.ANALYZEruns asPRAGMA optimizewith an analysis limit (closes #94) - 2026-10-19: Shutdown stops the streamer first. Each handler then 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, which means no known origin. It used to have the set's last member as its origin. The set adds no peerings. The pages show such a route as having no known origin (closes #75)
- 2026-10-19:
bgp_peer_countdrops to 0 when the last peer goes down (closes #92) - 2026-10-19:
bgp_peer_countcounts every peer the feed has heard from. It used to count only the peers whose session opened since startup. It drops a peer whose session goes down (closes #74) - 2026-10-19: The shared files are re-vendored from
sneak/promptsat0653579. The linter isgolangci-lintv2.14.0 with its configuration. Lint and test areDockerfilephases that keep Go's andgolangci-lint's caches.make fmtalso formats the markdown withprettier. The agent guidance moves toAGENTS.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. A line over 64 KiB used to end the connection (closes #76)
- 2026-10-19: A peer's live routes are removed when its BGP session goes down.
STATEmessages are never dropped when a queue is full. 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. They used to read every route of that length. They list each prefix once (closes #66)
- 2026-10-19: A request past its time limit answers 504 instead of 408.
/api/v1/statsanswers 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. 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. 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. It used to read 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. It used to run on without a web server (closes #73)
- 2026-10-14: The prefix pages parse the prefix before looking it up. One that does not parse answers 400. 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. It used to keep one update per prefix across all peers (closes #54)
- 2026-10-14:
reconnect_countin/api/v1/statscounts every reconnect (closes #68) - 2026-10-03:
/api/v1/statsserves the prefix distribution from memory. The distribution is 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_vacuumset to incremental. The setting goes in the connection string, so it is set before the file is first written. Each periodic incremental vacuum now returns up to 1000 free pages. The latePRAGMA auto_vacuuminInitialize, 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. 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 into the page footer. It used to stampunknown. The value comes fromgit describe --tags --always..dockerignoresends.gitwithout.git/config. AVERSIONbuild argument takes precedence.make buildstamps the same value (closes #46) - 2026-09-29: The entrypoint creates the data directory if it is missing. It 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:
.dockerignorekeeps.git, local build output, local databases and.envout of the Docker build context. So they stay out of the source archive in the image (closes #39) - 2026-09-29: The README's first line names the MIT licence and the author. The
License section says MIT and links
LICENSE. An Author section is added. This file is brought up to date (closes #38) - 2026-09-29:
LICENSEis added, with the MIT licence (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. A second
Stopno longer closes the queues again (closes #34) - 2026-09-28:
docker stopno longer kills the daemon 2 seconds after the stop signal. The entrypoint switches to theroutewatchuser withsetprivinstead ofrunuser. So the daemon receives the signal itself. It gets the whole waitdocker stopallows, up to its own 60-second limit (closes #33) - 2026-09-28: RouteWatch is ready to run under upaas. A set but invalid
PORT,XDG_DATA_HOMEorMALLOC_ARENA_MAXstops the start. The health check followsPORT. README gains a "Running under upaas" section (closes #31) - 2026-09-22: The database statistics are kept in memory. The counts are seeded
at startup and adjusted on every write. The oldest and newest route times come
from a lookup at each end of an index.
/api/v1/statsno 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=2in the image caps the number of glibc malloc arenas. So memory outside the Go runtime no longer grows with the core count (closes #23) - 2026-09-21: The image sets
GOMEMLIMIT=1536MiB. README "Memory" gives 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 are fixed. The stats handlers leaked after a timeout. The streamer's tickers leaked on every reconnect (closes #12)
- 2026-09-21: Parsed RIS messages no longer keep the unused
CommunityandRawfields (closes #9) - 2026-09-21:
.editorconfigis added. A Gitea workflow runsscript/cibuildon every push (closes #14) - 2026-09-21: The peering handler's AS-path map holds at most 500,000 paths. Every 30 seconds it is swapped for an empty one instead of copied (closes #10)
- 2026-09-21: SQLite's memory is bounded across the whole connection pool. Each connection has a 64 MiB page cache. The heap limits are 1 GiB soft and 1.5 GiB hard (closes #8)
- 2026-09-21: The Docker build runs the format check, the linter and the tests.
Linting runs in a separate stage on a
golangci-lintimage pinned by digest (closes #5) - 2026-09-21:
make testskips the live feed test, with-short. Somake checkno longer depends on the network (closes #2) - 2026-07-07: The scripts-to-rule-them-all layout is adopted.
script/holds the entrypoints. TheMakefiletargetscheck,test,lint,fmtandfmt-checkcall them. So dobootstrap,setup,dockerandhooks. README gains an "Entrypoints" section - 2026-02-22: The repo is brought in line with the repo policies. The required
policy files are added.
.gitignoreis updated. TheMakefilegainsfmt-check,check,dockerandhookstargets. The Go code is formatted withgofmt. The work was on the branchrepo-policies-compliance, unmerged when this entry was written - 2026-01-01: The WAL is checkpointed every 5 seconds, and at startup with a log
line. Checkpoints run in
TRUNCATEmode. This fixed slow queries - 2025-12-31: The pages get a navigation bar and a home page with a search. The
status page shows the oldest and newest route times. The WHOIS stats query
handles
NULL. The container runs as theroutewatchuser with the right state directory - 2025-12-30: HTTP requests are logged as structured log lines. The HTTP timeouts are longer. Prefix URLs with a slash in them route correctly. The status page gets more metrics and a footer
- 2025-12-29: The
Dockerfilebuilds in stages and puts a source archive in the image. SQLite reclaims space with incremental vacuum, which does not block - 2025-07-27: The first commit: RouteWatch, a BGP stream monitor