Author SHA1 Message Date
sneak b1bd1d97cf Make two tests independent of host load (closes #256)
check / check (push) Successful in 3m55s
The home-card refresh test runs keepHomeCardsFresh in a synctest bubble,
so its waits are exact on a fake clock instead of 15-second polls.

The geo fallback subtest in which geojs never answers used a 50 ms time
limit and counted requests at the fake server, which a slow host could
miss. It now uses a transport that counts each request as it is sent and
holds it until the time limit ends it.

Model: opus-5-5
2026-11-11 18:15:10 +00:00
clawbot 760ba4746b Update go-sqlite3 to v1.14.53 to drop two C compiler warnings (closes #245)
check / check (push) Successful in 1m2s
`go-sqlite3` v1.14.29 printed two `-Wdiscarded-qualifiers` warnings from its copy of SQLite when compiled with a recent `gcc`. v1.14.53 prints neither.

Model: opus-5-5
2026-11-10 18:13:50 +01:00
clawbot dcb3935643 Rewrite the README and TODO.md sentences that fail the prose rules (closes #251)
check / check (push) Successful in 1m7s
A fresh read of `README.md` and `TODO.md` against the prose rules, with every statement except measured figures and what outside services do checked against the code. Four README sentences failed the prose rules and five disagreed with the code; each is rewritten. The Status and Next Step of `TODO.md` now describe the run of `e58c663`.

Model: fable-5-1 (rewrite); opus-5-5 (commit message)
2026-11-10 18:01:51 +01:00
clawbot 6de44ac054 Create the image's app user without the system-user flag (closes #248)
check / check (push) Successful in 1m8s
`useradd -r` asks for a system user and UID 1000 is above the system range, so the image build warned. Dropping `-r` ends the warning; the user keeps UID 1000, so the owner of the data directory does not change.

Model: opus-5-5
2026-11-10 16:23:20 +01:00
6 changed files with 139 additions and 89 deletions

No files matched your search

+21 -15
View File
@@ -143,9 +143,9 @@ and origin AS can have two kept routes, one or none.
AS path, a next hop and a time. The next hop is the router the peer sends the 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. The prefix's traffic to. RouteWatch records the peer itself as the next hop. The
page also lists the prefix's origin changes, newest first. An origin change is page also lists the prefix's origin changes, newest first. An origin change is
an AS starting to announce the prefix, when its first peer carries it, or recorded when the first peer starts to carry the prefix with an origin AS, or
stopping, when its last peer no longer does. The page shows at most the newest when the last peer stops. The page shows at most the newest 5,000. Each row
5,000. Each row shows: shows:
- the time - the time
- announced or withdrawn - announced or withdrawn
- the origin AS - the origin AS
@@ -197,8 +197,9 @@ also carries an `@meta` object:
- `api_version`: `1` - `api_version`: `1`
- `execution_time_ms`: the number of milliseconds the request took - `execution_time_ms`: the number of milliseconds the request took
Every value with a unit is a number, and the unit ends the field name. Examples Every value with a unit is a number. The unit ends the field name, as in `_ms`,
are `_ms`, `_seconds`, `_bytes`, `_per_sec` and `_per_min`. `_seconds`, `_bytes`, `_per_sec` and `_per_min`. `latitude` and `longitude` are
in degrees, and their names do not say so.
The paths: The paths:
@@ -502,11 +503,13 @@ How RouteWatch keeps the answers:
there. there.
- It writes new answers every 5 seconds to `cache.db` in the state directory - It writes new answers every 5 seconds to `cache.db` in the state directory
(see Configuration), and loads them from there at startup. (see Configuration), and loads them from there at startup.
- An answer expires after the TTL of its records, the shortest one when there - Each DNS record carries a TTL, the time for which it may be kept. An answer
are several. RouteWatch keeps it for at least 60 seconds and at most 24 hours. expires after the shortest TTL of its records. RouteWatch keeps it for at
- When an address has no PTR names, that answer expires after the SOA record's least 60 seconds and at most 24 hours.
minimum field, or the record's own TTL when that is shorter. RouteWatch keeps - When an address has no PTR names, the answer's SOA record says how long that
it for at most 1 hour. answer may be kept. The answer expires after the SOA record's minimum field,
or the record's own TTL when that is shorter. RouteWatch keeps it for at most
1 hour.
- An answer with no PTR names and no SOA record is not kept. Neither is one - An answer with no PTR names and no SOA record is not kept. Neither is one
whose SOA record has a TTL or minimum field of 0. whose SOA record has a TTL or minimum field of 0.
- A failed lookup is not kept, so the next lookup of the address asks again. - A failed lookup is not kept, so the next lookup of the address asks again.
@@ -520,7 +523,8 @@ An IP lookup finds where each address is, on a best-effort basis. It uses two
sources: sources:
- [geojs](https://www.geojs.io/) is asked first. One request carries every - [geojs](https://www.geojs.io/) is asked first. One request carries every
address of the lookup, and it has 1 second to answer. address of the lookup that the cache does not hold. geojs has 1 second to
answer.
- [DB-IP City Lite](https://db-ip.com/db/download/ip-to-city-lite) answers when - [DB-IP City Lite](https://db-ip.com/db/download/ip-to-city-lite) answers when
geojs has no answer for the address. It is a database file that RouteWatch geojs has no answer for the address. It is a database file that RouteWatch
reads itself. reads itself.
@@ -702,8 +706,10 @@ What happens at each limit:
allocating more. allocating more.
- The SQLite hard heap limit: a database statement fails with an out-of-memory - The SQLite hard heap limit: a database statement fails with an out-of-memory
error, which the handler logs. The prefix handler writes the routing table's error, which the handler logs. The prefix handler writes the routing table's
changes again in a later write. Everything else in the failed write is changes and its origin changes again in a later write. Everything else in a
dropped. The process keeps running. handler's failed write is dropped. The PTR cache and the geolocation part
write their answers to `cache.db` again in a later write. The process keeps
running.
- The handler queues: the handlers are the four parts that take the feed's - The handler queues: the handlers are the four parts that take the feed's
messages (see Design). The prefix handler's queue holds at most 100,000 messages (see Design). The prefix handler's queue holds at most 100,000
messages, about 100 MiB. Each of the other three holds at most 20,000. When a messages, about 100 MiB. Each of the other three holds at most 20,000. When a
@@ -1070,8 +1076,8 @@ calls one of them. The scripts:
- `script/projectname`: print the project name, `routewatch`, which - `script/projectname`: print the project name, `routewatch`, which
`script/docker` uses as the image tag. `script/docker` uses as the image tag.
- `script/test`: build the `test` phase of the `Dockerfile` alone. It runs - `script/test`: build the `test` phase of the `Dockerfile` alone. It runs
`go test -short -count=1 -timeout 90s -race -cover ./...`, and reruns it with `go test -short -count=1 -timeout 90s -race -cover ./...`. On failure it runs
`-v` on failure. the tests again with `-v` and without `-cover`.
- `script/lint`: build the `lint` phase of the `Dockerfile` alone. It runs - `script/lint`: build the `lint` phase of the `Dockerfile` alone. It runs
`golangci-lint` v2.14.0 with `.golangci.yml`. `golangci-lint` v2.14.0 with `.golangci.yml`.
- `script/fmt`: format the Go code with `go fmt` and the markdown with - `script/fmt`: format the Go code with `go fmt` and the markdown with
+47 -28
View File
@@ -22,37 +22,54 @@ 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 The image sets memory ceilings for a 5 GiB container (README "Memory"). README
"Running under upaas" says how to run it. "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, Five runs of `next` on the live feed have ended since 2026-10-27, from three
found three problems: hours to five days long. The runs found the handlers dropping updates for five
causes, and the WHOIS fetcher asking a registry that had refused it. Each is
fixed on `next`, and its Completed Steps entry below names its issue. A 21-hour
run of `742e759`, which has every fix but one, dropped no update except after
the feed stalled. That cause, https://git.eeqj.de/sneak/routewatch/issues/229,
is fixed on `next`.
- The prefix handler dropped updates while a peer's session went down A run of `e58c663` on the live feed began at 09:19 UTC on 2026-11-10
(https://git.eeqj.de/sneak/routewatch/issues/216). (https://git.eeqj.de/sneak/routewatch/issues/232). It is to confirm that fix.
- The AS handler dropped updates when the feed burst Next Step lists what its outcome reports. The seven IP lookup additions of
(https://git.eeqj.de/sneak/routewatch/issues/217). https://git.eeqj.de/sneak/routewatch/issues/203 landed after it began, so it
- The WHOIS fetcher kept asking a registry that had refused it does not exercise them.
(https://git.eeqj.de/sneak/routewatch/issues/218).
All three are fixed on `next`. So is
https://git.eeqj.de/sneak/routewatch/issues/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.
https://git.eeqj.de/sneak/routewatch/issues/226 fixed that on `next`.
https://git.eeqj.de/sneak/routewatch/issues/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 order of the project's remaining work is kept on The order of the project's remaining work is kept on
https://git.eeqj.de/sneak/routewatch/issues/86. https://git.eeqj.de/sneak/routewatch/issues/86.
# Next Step # Next Step
Post the outcome of the 24-hour run of `9bd0e91` on Post the outcome of the run of `e58c663` on
https://git.eeqj.de/sneak/routewatch/issues/226: whether any handler still drops https://git.eeqj.de/sneak/routewatch/issues/232. The run lasts at least 24
updates, and the process's peak memory against 5 GiB. File an issue for each hours. If the feed has not stalled or reconnected by then, the run goes on until
problem the run finds. it does, up to 72 hours in all. The outcome says:
- whether the feed stalled or reconnected
- whether any handler dropped an update, and the most messages the prefix
handler's queue held at once
- the most messages the prefix handler held back at once
- the size of the `origin_changes` table on disk
- the process's peak memory against 5 GiB
File an issue for each problem the run finds.
# Completed Steps # Completed Steps
- 2026-11-11: two more tests no longer fail on a busy host. The test of the home
page's card refresh runs on a fake clock. The geo test in which geojs never
answers counts each request as it is sent, not when it reaches the server
(closes https://git.eeqj.de/sneak/routewatch/issues/256).
- 2026-11-10: `github.com/mattn/go-sqlite3` is at v1.14.53, so building on the
host no longer prints its two C compiler warnings (closes
https://git.eeqj.de/sneak/routewatch/issues/245).
- 2026-11-10: `README.md` and this file are read whole against the rules of
https://git.eeqj.de/sneak/routewatch/issues/157, and every statement except
measured figures and what outside services do is checked against the code on
`next`. Four README sentences failed the prose rules. Five disagreed with the
code. Each is rewritten. Status and Next Step describe the live-feed run in
progress (closes https://git.eeqj.de/sneak/routewatch/issues/251).
- 2026-11-10: the image creates the `routewatch` user as a regular user with UID - 2026-11-10: the image creates the `routewatch` user as a regular user with UID
1000, so the build no longer warns about the system UID range (closes 1000, so the build no longer warns about the system UID range (closes
https://git.eeqj.de/sneak/routewatch/issues/248). https://git.eeqj.de/sneak/routewatch/issues/248).
@@ -63,16 +80,18 @@ problem the run finds.
- 2026-11-10: the IP lookup checks the `icloud_private_relay` list of Apple's - 2026-11-10: the IP lookup checks the `icloud_private_relay` list of Apple's
iCloud Private Relay networks, downloaded once a day. Each result has iCloud Private Relay networks, downloaded once a day. Each result has
`flags.is_relay` (closes https://git.eeqj.de/sneak/routewatch/issues/237). `flags.is_relay` (closes https://git.eeqj.de/sneak/routewatch/issues/237).
- 2026-11-10: the IP lookup checks the `protonvpn` list of ProtonVPN's /24 - 2026-11-10: the IP lookup checks two ProtonVPN lists from a public mirror of
networks and the `protonvpn_entry` list of its servers' IPv4 and IPv6 Proton's server list. `protonvpn` holds the /24 networks of ProtonVPN's
addresses, from a public mirror of Proton's server list (closes servers, and `protonvpn_entry` holds the servers' IPv4 and IPv6 addresses
https://git.eeqj.de/sneak/routewatch/issues/236). (closes https://git.eeqj.de/sneak/routewatch/issues/236).
- 2026-11-10: the IP lookup finds PTR names over DNS-over-HTTPS and caches them - 2026-11-10: the IP lookup finds PTR names over DNS-over-HTTPS and caches them
for the record's TTL, in memory and in `cache.db` (closes in memory and in `cache.db` for the record's TTL. A TTL is the time for which
a DNS record may be kept (closes
https://git.eeqj.de/sneak/routewatch/issues/238). https://git.eeqj.de/sneak/routewatch/issues/238).
- 2026-11-10: the tests no longer fail when a busy host stalls for several - 2026-11-10: the tests no longer fail when a busy host stalls for several
seconds. Most such waits are now 15 seconds. The test of the stream's idle seconds. Most waits for something that should happen are now 15 seconds. Tests
timeout runs on a fake clock (closes #240) that depend on a short idle timeout or a request limit run on a fake clock
(closes https://git.eeqj.de/sneak/routewatch/issues/240)
- 2026-11-10: the IP lookup checks the public VPN server lists of Mullvad, - 2026-11-10: the IP lookup checks the public VPN server lists of Mullvad,
NordVPN, Private Internet Access and IVPN, and X4BNet's community list. Each NordVPN, Private Internet Access and IVPN, and X4BNet's community list. Each
result has `flags.is_vpn` and `vpn_providers` (closes result has `flags.is_vpn` and `vpn_providers` (closes
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/google/btree v1.1.3 github.com/google/btree v1.1.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mattn/go-sqlite3 v1.14.29 github.com/mattn/go-sqlite3 v1.14.53
github.com/maxmind/mmdbwriter v1.2.0 github.com/maxmind/mmdbwriter v1.2.0
github.com/miekg/dns v1.1.73 github.com/miekg/dns v1.1.73
github.com/oschwald/maxminddb-golang/v2 v2.9.0 github.com/oschwald/maxminddb-golang/v2 v2.9.0
+2 -2
View File
@@ -10,8 +10,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= github.com/mattn/go-sqlite3 v1.14.53 h1:mnTM0owzUMrKuLo+0w51NUiB0ir2Fo7kFXlPt/l3BGs=
github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.53/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/maxmind/mmdbwriter v1.2.0 h1:hyvDopImmgvle3aR8AaddxXnT0iQH2KWJX3vNfkwzYM= github.com/maxmind/mmdbwriter v1.2.0 h1:hyvDopImmgvle3aR8AaddxXnT0iQH2KWJX3vNfkwzYM=
github.com/maxmind/mmdbwriter v1.2.0/go.mod h1:EQmKHhk2y9DRVvyNxwCLKC5FrkXZLx4snc5OlLY5XLE= github.com/maxmind/mmdbwriter v1.2.0/go.mod h1:EQmKHhk2y9DRVvyNxwCLKC5FrkXZLx4snc5OlLY5XLE=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
+24 -16
View File
@@ -90,14 +90,12 @@ func parseAddresses(t *testing.T, addresses ...string) []netip.Addr {
return addrs return addrs
} }
// fakeGeoJS is a geojs server. It sends body, or status when that is set, or // fakeGeoJS is a geojs server. It sends body, or status when that is set. It
// waits for the request to end when hang is true. It keeps each request's // keeps each request's address list.
// address list.
type fakeGeoJS struct { type fakeGeoJS struct {
server *httptest.Server server *httptest.Server
body string body string
status int status int
hang bool
mu sync.Mutex mu sync.Mutex
requests []string requests []string
@@ -119,12 +117,6 @@ func (f *fakeGeoJS) serve(w http.ResponseWriter, r *http.Request) {
f.requests = append(f.requests, r.URL.Query().Get("ip")) f.requests = append(f.requests, r.URL.Query().Get("ip"))
f.mu.Unlock() f.mu.Unlock()
if f.hang {
<-r.Context().Done()
return
}
if f.status != 0 { if f.status != 0 {
w.WriteHeader(f.status) w.WriteHeader(f.status)
@@ -142,6 +134,20 @@ func (f *fakeGeoJS) requestsSeen() []string {
return append([]string{}, f.requests...) return append([]string{}, f.requests...)
} }
// hangingTransport never answers: each request waits until its context ends.
// It counts the requests as they are sent, so the count does not depend on
// whether one reaches a server before its time limit runs out.
type hangingTransport struct {
requests atomic.Int32
}
func (h *hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
h.requests.Add(1)
<-r.Context().Done()
return nil, r.Context().Err()
}
// openCacheDB opens a new cache.db in a temporary directory. // openCacheDB opens a new cache.db in a temporary directory.
func openCacheDB(t *testing.T, dir string) *sql.DB { func openCacheDB(t *testing.T, dir string) *sql.DB {
t.Helper() t.Helper()
@@ -385,13 +391,15 @@ func TestLookupFallsBackToTheDatabase(t *testing.T) {
fake := newFakeGeoJS(t, tt.body) fake := newFakeGeoJS(t, tt.body)
fake.status = tt.status fake.status = tt.status
fake.hang = tt.hang
dir := t.TempDir() dir := t.TempDir()
locator := newTestLocator(t, fake, openCacheDB(t, dir), dir) locator := newTestLocator(t, fake, openCacheDB(t, dir), dir)
locator.timeLimit = shortTimeLimit requestCount := func() int { return len(fake.requestsSeen()) }
if !tt.hang { if tt.hang {
locator.timeLimit = testTimeLimit hanging := &hangingTransport{}
locator.client = &http.Client{Transport: hanging}
locator.timeLimit = shortTimeLimit
requestCount = func() int { return int(hanging.requests.Load()) }
} }
giveDatabase(t, locator) giveDatabase(t, locator)
@@ -413,8 +421,8 @@ func TestLookupFallsBackToTheDatabase(t *testing.T) {
// asks geojs again. // asks geojs again.
lookupOne(t, locator, testAddress) lookupOne(t, locator, testAddress)
if requests := fake.requestsSeen(); len(requests) != 2 { if requests := requestCount(); requests != 2 {
t.Errorf("%d requests, want 2", len(requests)) t.Errorf("%d requests, want 2", requests)
} }
}) })
} }
+44 -27
View File
@@ -275,49 +275,66 @@ func TestPagesServeTheCopyOfTheCards(t *testing.T) {
} }
} }
// TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh with // TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh. It
// a short interval. It works the cards out at once, again after the table // works the cards out at once, again an interval after the table changes, and
// changes, and stops when its context ends. // stops when its context ends. It runs in a synctest bubble, whose clock is
// fake, so a stall on the host cannot fail it.
func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) { func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
t.Parallel() t.Parallel()
const ( const interval = time.Minute
interval = 10 * time.Millisecond
maxWait = 15 * time.Second
)
table := emptyTable() table := emptyTable()
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0)) announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
s := newServerWithTable(t, table) s := newServerWithTable(t, table)
ctx, cancel := context.WithCancel(t.Context()) // newestRouteOrigin returns the origin AS of the newest route on the
stopped := make(chan struct{}) // cards, or 0 when there is none.
newestRouteOrigin := func() int {
go func() {
s.keepHomeCardsFresh(ctx, interval)
close(stopped)
}()
newestRouteASNIs := func(asn int) bool {
cards := s.cards.Load() cards := s.cards.Load()
if cards == nil || cards.NewestRoute == nil {
return 0
}
return cards != nil && cards.NewestRoute != nil && return cards.NewestRoute.OriginASN
cards.NewestRoute.OriginASN == asn
} }
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(twoPrefixASN) }) synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
stopped := make(chan struct{})
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1)) go func() {
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(newestRouteASN) }) s.keepHomeCardsFresh(ctx, interval)
close(stopped)
}()
cancel() synctest.Wait()
select { if got := newestRouteOrigin(); got != twoPrefixASN {
case <-stopped: t.Fatalf("at the start, the newest route's AS is %d, want %d",
case <-time.After(maxWait): got, twoPrefixASN)
t.Fatal("keepHomeCardsFresh did not stop when its context ended") }
}
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2),
testRouteTime(1))
time.Sleep(interval)
synctest.Wait()
if got := newestRouteOrigin(); got != newestRouteASN {
t.Fatalf("an interval after the table changed, the newest "+
"route's AS is %d, want %d", got, newestRouteASN)
}
cancel()
synctest.Wait()
select {
case <-stopped:
default:
t.Fatal("keepHomeCardsFresh did not stop when its context ended")
}
})
} }
// TestStartedServerWorksOutTheCards starts a server and waits until its home // TestStartedServerWorksOutTheCards starts a server and waits until its home