From a43935308e893ec5eac7b889548ac2b8ad330683 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 15:59:15 +0200 Subject: [PATCH 01/45] Count every reconnect in reconnect_count (closes #68) `reconnect_count` in `/api/v1/stats` stayed 0 however often the stream reconnected: `SetConnected` counted a reconnect only while the stream was still marked connected, and the streamer always marks it disconnected first. It now counts one reconnect for every connection after the first, recognised by the zero `connectedSince` before the first connection. A test connects and disconnects twice and expects 2. Model: opus-5-5 --- internal/metrics/metrics.go | 25 +++++++++++++++---------- internal/metrics/metrics_test.go | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 internal/metrics/metrics_test.go diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 86f2057..25aef45 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -60,17 +60,22 @@ func New() *Tracker { } } -// SetConnected updates the connection status +// SetConnected updates the connection status. Every connection after the +// first counts one reconnect. func (t *Tracker) SetConnected(connected bool) { - wasConnected := t.isConnected.Swap(connected) - if connected { - t.mu.Lock() - t.connectedSince = time.Now() - t.mu.Unlock() - // Increment reconnect count (but not for the initial connection) - if wasConnected || t.reconnectCount.Load() > 0 { - t.reconnectCount.Add(1) - } + t.isConnected.Store(connected) + if !connected { + return + } + + t.mu.Lock() + // connectedSince is zero until the first connection. + connectedBefore := !t.connectedSince.IsZero() + t.connectedSince = time.Now() + t.mu.Unlock() + + if connectedBefore { + t.reconnectCount.Add(1) } } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..e8776fd --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,21 @@ +package metrics + +import "testing" + +// TestReconnectCount connects three times with a disconnect between each, the +// way the streamer does, and expects every connection after the first to count +// one reconnect. +func TestReconnectCount(t *testing.T) { + tracker := New() + + tracker.SetConnected(true) + tracker.SetConnected(false) + tracker.SetConnected(true) + tracker.SetConnected(false) + tracker.SetConnected(true) + + const wantReconnects = 2 + if got := tracker.GetReconnectCount(); got != wantReconnects { + t.Fatalf("reconnect count = %d, want %d", got, wantReconnects) + } +} -- 2.54.0 From 77a708d81967b652eb3c7f8f5245e7aa4575218c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 17:09:13 +0200 Subject: [PATCH 02/45] Apply each peer's last update for a prefix in a batch (closes #54) The prefix handler kept one update per prefix in each batch across all peers, chosen by a timestamp cut to whole seconds, so most peers' announcements and withdrawals were lost and the first update in a second won. A batch now keeps the last update per prefix and peer in feed order. A message's withdrawals are queued before its announcements, so a prefix both withdrawn and announced in one message keeps its route (RFC 4271 section 4.3). Tests run each case through the handler into a real database and check the in-memory counts. Model: opus-5-5 --- internal/routewatch/prefixhandler.go | 53 +++++---- internal/routewatch/prefixhandler_test.go | 139 ++++++++++++++++++++++ 2 files changed, 166 insertions(+), 26 deletions(-) diff --git a/internal/routewatch/prefixhandler.go b/internal/routewatch/prefixhandler.go index c8f6974..59f3991 100644 --- a/internal/routewatch/prefixhandler.go +++ b/internal/routewatch/prefixhandler.go @@ -105,7 +105,24 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { h.mu.Lock() defer h.mu.Unlock() - // Process announcements + // Withdrawals are queued before announcements, because the batch keeps + // each peer's last update for a prefix: a message that lists a prefix as + // both withdrawn and announced keeps the route (RFC 4271 section 4.3). + for _, prefix := range msg.Withdrawals { + h.batch = append(h.batch, prefixUpdate{ + prefix: canonicalPrefix(prefix), + originASN: originASN, // Use the originASN from path if available + peer: msg.Peer, + messageType: "withdrawal", + timestamp: timestamp, + path: msg.Path, + }) + // Record withdrawal in metrics + if h.metrics != nil { + h.metrics.RecordWithdrawal() + } + } + for _, announcement := range msg.Announcements { for _, prefix := range announcement.Prefixes { h.batch = append(h.batch, prefixUpdate{ @@ -123,22 +140,6 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { } } - // Process withdrawals - for _, prefix := range msg.Withdrawals { - h.batch = append(h.batch, prefixUpdate{ - prefix: canonicalPrefix(prefix), - originASN: originASN, // Use the originASN from path if available - peer: msg.Peer, - messageType: "withdrawal", - timestamp: timestamp, - path: msg.Path, - }) - // Record withdrawal in metrics - if h.metrics != nil { - h.metrics.RecordWithdrawal() - } - } - // Check if we need to flush if len(h.batch) >= prefixBatchSize { h.flushBatchLocked() @@ -179,14 +180,14 @@ func (h *PrefixHandler) flushBatchLocked() { startTime := time.Now() batchSize := len(h.batch) - // Group updates by prefix to deduplicate - // For each prefix, keep the latest update - prefixMap := make(map[string]prefixUpdate) + // For each prefix and peer, keep only the last update the feed delivered. + // Updates from different peers for the same prefix are all kept. Arrival + // order decides, not the timestamp: timestamps are whole seconds, so + // updates within one second carry the same one. + type prefixAndPeer struct{ prefix, peer string } + lastUpdates := make(map[prefixAndPeer]prefixUpdate) for _, update := range h.batch { - key := update.prefix - if existing, ok := prefixMap[key]; !ok || update.timestamp.After(existing.timestamp) { - prefixMap[key] = update - } + lastUpdates[prefixAndPeer{update.prefix, update.peer}] = update } // Collect routes to upsert and delete @@ -196,7 +197,7 @@ func (h *PrefixHandler) flushBatchLocked() { // Collect unique prefixes to update prefixesToUpdate := make(map[string]time.Time) - for _, update := range prefixMap { + for _, update := range lastUpdates { // Track prefix for both announcements and withdrawals if _, exists := prefixesToUpdate[update.prefix]; !exists || update.timestamp.After(prefixesToUpdate[update.prefix]) { prefixesToUpdate[update.prefix] = update.timestamp @@ -255,7 +256,7 @@ func (h *PrefixHandler) flushBatchLocked() { elapsed := time.Since(startTime) h.logger.Debug("Flushed prefix batch", "batch_size", batchSize, - "unique_prefixes", len(prefixMap), + "unique_prefixes", len(prefixesToUpdate), "success", successCount, "duration_ms", elapsed.Milliseconds(), ) diff --git a/internal/routewatch/prefixhandler_test.go b/internal/routewatch/prefixhandler_test.go index 1968f94..0d30e77 100644 --- a/internal/routewatch/prefixhandler_test.go +++ b/internal/routewatch/prefixhandler_test.go @@ -1,7 +1,9 @@ package routewatch import ( + "context" "errors" + "slices" "testing" "time" @@ -72,3 +74,140 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) { } } } + +// TestPrefixHandlerAppliesEachPeersLastUpdateInABatch runs updates for one +// prefix through the prefix handler into a real database. All updates carry the +// same timestamp, as updates within one second of the feed do. Within a batch, +// the last update each peer sent for the prefix decides whether that peer has a +// live route for it, and updates from different peers are all applied. The +// in-memory route count and prefix distribution must match the route table. +func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) { + const ( + prefix = "198.51.100.0/24" + prefixMaskLength = 24 + otherPeerIP = "2001:db8:ffff::2" + ) + + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + announce := func(peer string) *ristypes.RISMessage { + return &ristypes.RISMessage{ + Peer: peer, + Path: ristypes.ASPath{testASNA, testASNB}, + ParsedTimestamp: ts, + Announcements: []ristypes.RISAnnouncement{{NextHop: peer, Prefixes: []string{prefix}}}, + } + } + withdraw := func(peer string) *ristypes.RISMessage { + return &ristypes.RISMessage{Peer: peer, ParsedTimestamp: ts, Withdrawals: []string{prefix}} + } + announceAndWithdraw := func(peer string) *ristypes.RISMessage { + msg := announce(peer) + msg.Withdrawals = []string{prefix} + + return msg + } + + tests := []struct { + name string + // Each inner list is handled and then flushed as one batch. + batches [][]*ristypes.RISMessage + // The peers left with a live route for the prefix, by peer IP. + wantPeers []string + }{ + { + name: "two peers announce the prefix", + batches: [][]*ristypes.RISMessage{{announce(testPeerIP), announce(otherPeerIP)}}, + wantPeers: []string{testPeerIP, otherPeerIP}, + }, + { + name: "two peers withdraw the prefix", + batches: [][]*ristypes.RISMessage{ + {announce(testPeerIP)}, + {announce(otherPeerIP)}, + {withdraw(testPeerIP), withdraw(otherPeerIP)}, + }, + }, + { + name: "a peer announces and then withdraws the prefix", + batches: [][]*ristypes.RISMessage{{announce(testPeerIP), withdraw(testPeerIP)}}, + }, + { + name: "a peer withdraws and then re-announces the prefix", + batches: [][]*ristypes.RISMessage{ + {announce(testPeerIP)}, + {withdraw(testPeerIP), announce(testPeerIP)}, + }, + wantPeers: []string{testPeerIP}, + }, + { + // RFC 4271 section 4.3: the announcement counts, not the withdrawal. + name: "one message announces and withdraws the prefix", + batches: [][]*ristypes.RISMessage{ + {announce(testPeerIP)}, + {announceAndWithdraw(testPeerIP)}, + }, + wantPeers: []string{testPeerIP}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer func() { _ = db.Close() }() + + // Built without NewPrefixHandler's flush timer, so the test flushes itself. + h := &PrefixHandler{db: db, logger: logger.New()} + for _, batch := range tt.batches { + for _, msg := range batch { + h.HandleMessage(msg) + } + h.mu.Lock() + h.flushBatchLocked() + h.mu.Unlock() + } + + ctx := context.Background() + routes, err := db.GetPrefixDetailsContext(ctx, prefix) + if err != nil && !errors.Is(err, database.ErrNoRoute) { + t.Fatalf("GetPrefixDetailsContext: %v", err) + } + var gotPeers []string + for _, route := range routes { + gotPeers = append(gotPeers, route.PeerIP) + } + if !slices.Equal(gotPeers, tt.wantPeers) { + t.Errorf("peers with a live route for %s = %v, want %v", prefix, gotPeers, tt.wantPeers) + } + + stats, err := db.GetStatsContext(ctx) + if err != nil { + t.Fatalf("GetStatsContext: %v", err) + } + tableRoutesV4, _, err := db.GetLiveRouteCountsContext(ctx) + if err != nil { + t.Fatalf("GetLiveRouteCountsContext: %v", err) + } + if stats.IPv4Routes != len(tt.wantPeers) || tableRoutesV4 != len(tt.wantPeers) { + t.Errorf("IPv4 live routes: %d in memory, %d in the table, want %d", + stats.IPv4Routes, tableRoutesV4, len(tt.wantPeers)) + } + + var wantDistribution []database.PrefixDistribution + if len(tt.wantPeers) > 0 { + wantDistribution = []database.PrefixDistribution{{MaskLength: prefixMaskLength, Count: 1}} + } + queryDistributionV4, _, err := db.GetPrefixDistributionContext(ctx) + if err != nil { + t.Fatalf("GetPrefixDistributionContext: %v", err) + } + if !slices.Equal(stats.IPv4PrefixDistribution, wantDistribution) || + !slices.Equal(queryDistributionV4, wantDistribution) { + t.Errorf("IPv4 prefix distribution: %v in memory, %v from the table, want %v", + stats.IPv4PrefixDistribution, queryDistributionV4, wantDistribution) + } + }) + } +} -- 2.54.0 From 521c632087a3af5fcb38a9647af9b51669637f8e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 17:23:51 +0200 Subject: [PATCH 03/45] Ignore routes shorter than /8 or /16 in the IP lookup (closes #57) The IP lookup tried every mask length down to /0, so a default route or a very short route such as `2000::/3` was reported as the owner of any address it covered until a more specific route had been seen. The lookup now stops at /8 for IPv4 and /16 for IPv6 (named constants); an address covered only by shorter routes gets "no route found". The tables still store short routes. The test checks each limit and the length one bit shorter. Model: opus-5-5 --- README.md | 5 ++- internal/database/database.go | 23 +++++++--- internal/database/database_test.go | 72 ++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index af8ac6a..c68fdd6 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,9 @@ routewatch/ - **Reconnection**: Exponential backoff (5s-320s) with reset after 30s of stable connection - **IP Lookup**: the most specific live route is found by looking up the address's prefix at each mask length, longest first, in the prefix index - (at most 33 lookups for IPv4, 129 for IPv6); prefixes are stored in the - text form Go's `net/netip` prints + (at most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the + text form Go's `net/netip` prints. Routes shorter than /8 for IPv4 or /16 + for IPv6, such as a default route, are not used. ### Database Schema diff --git a/internal/database/database.go b/internal/database/database.go index 5393f74..ed62eb8 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -37,6 +37,14 @@ const ( ipv6Bits = 128 ) +// The IP lookup does not use routes shorter than these mask lengths. A default +// route or a route this short only says where a peer sends traffic it has no +// better route for; it does not say who holds the address. +const ( + minLookupMaskLengthV4 = 8 + minLookupMaskLengthV6 = 16 +) + // SQLite memory tuning. cache_size and busy_timeout go in the DSN so every // pooled connection gets them; the heap limits are process-wide and set once. const ( @@ -1876,11 +1884,12 @@ func (d *Database) GetIPInfo(ip string) (*IPInfo, error) { // GetIPInfoContext returns comprehensive IP information with context support. // // It finds the most specific live route for the address with point lookups on -// the prefix index: for each mask length from the longest (32 or 128) down to 0 -// it looks up the address's network prefix at that length, and the first length -// with a live route wins. That is at most 33 or 129 indexed lookups, however -// many routes are live. The prefix is looked up in the text form net/netip -// prints, which is the form the prefix handler stores. +// the prefix index: for each mask length from the longest (32 or 128) down to +// minLookupMaskLengthV4 or minLookupMaskLengthV6 it looks up the address's +// network prefix at that length, and the first length with a live route wins. +// That is at most 25 or 113 indexed lookups, however many routes are live. The +// prefix is looked up in the text form net/netip prints, which is the form the +// prefix handler stores. func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) { addr, err := netip.ParseAddr(ip) if err != nil { @@ -1891,9 +1900,11 @@ func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, er info := &IPInfo{IP: ip, IPVersion: ipVersionV6} routesTable, prefixesTable := "live_routes_v6", "prefixes_v6" + minMaskLength := minLookupMaskLengthV6 if addr.Is4() { info.IPVersion = ipVersionV4 routesTable, prefixesTable = "live_routes_v4", "prefixes_v4" + minMaskLength = minLookupMaskLengthV4 } // Route info with peer count and prefix first_seen @@ -1934,7 +1945,7 @@ func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, er var whoisUpdatedAt sql.NullTime found := false - for bits := addr.BitLen(); bits >= 0; bits-- { + for bits := addr.BitLen(); bits >= minMaskLength; bits-- { prefix := netip.PrefixFrom(addr, bits).Masked().String() err := stmt.QueryRowContext(ctx, prefix).Scan( &info.Netblock, diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 82753a9..1b52c08 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -133,6 +133,78 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) { lookup("2001:db8:1:2::3", "2001:db8:1::/48") } +// TestGetIPInfoIgnoresShortRoutes checks that the IP lookup does not report a +// default route, or a route shorter than /8 for IPv4 or /16 for IPv6, as the +// route for an address, and that a route of exactly /8 or /16, or longer, still +// is. The prefixes are written out so that a wrong limit fails the test. +func TestGetIPInfoIgnoresShortRoutes(t *testing.T) { + cfg := &config.Config{StateDir: t.TempDir()} + + db, err := New(cfg, logger.New()) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer func() { _ = db.Close() }() + + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + announce := func(prefix string, asn int) { + t.Helper() + + err := db.UpsertLiveRouteBatch([]*LiveRoute{{ + ID: uuid.New(), + Prefix: prefix, + MaskLength: netip.MustParsePrefix(prefix).Bits(), + IPVersion: detectIPVersion(prefix), + OriginASN: asn, + PeerIP: testPeerIP, + ASPath: []int{asn}, + NextHop: testPeerIP, + LastUpdated: ts, + }}) + if err != nil { + t.Fatalf("UpsertLiveRouteBatch(%s): %v", prefix, err) + } + } + + // lookup checks that ip resolves to the live prefix netblock from asn. + lookup := func(ip, netblock string, asn int) { + t.Helper() + + info, err := db.GetIPInfo(ip) + if err != nil { + t.Errorf("GetIPInfo(%s): %v", ip, err) + + return + } + if info.Netblock != netblock || info.ASN != asn { + t.Errorf("GetIPInfo(%s) = %s AS%d, want %s AS%d", ip, info.Netblock, info.ASN, netblock, asn) + } + } + + // Default and very short routes, and routes one bit shorter than each limit. + announce("2000::/3", 64500) + announce("0.0.0.0/0", 64500) + announce("2000::/15", 64500) + announce("192.0.0.0/7", 64500) + + for _, ip := range []string{"2001:db8::1", "192.0.2.1"} { + if info, err := db.GetIPInfo(ip); !errors.Is(err, ErrNoRoute) { + t.Errorf("GetIPInfo(%s) with only short routes = %+v, %v; want ErrNoRoute", ip, info, err) + } + } + + // Routes of exactly the minimum length are used. + announce("2001::/16", 64501) + announce("192.0.0.0/8", 64501) + + lookup("2001:db8::1", "2001::/16", 64501) + lookup("192.0.2.1", "192.0.0.0/8", 64501) + + announce("2001:db8::/32", 64502) + + lookup("2001:db8::1", "2001:db8::/32", 64502) +} + // TestConnectionPoolPragmas holds several pooled connections open at once and // checks each one carries the per-connection settings from the DSN, plus the // process-wide hard heap limit, and sees the new file with auto_vacuum -- 2.54.0 From 616f13071f824ac2d56ca9f4b9c0095bb6a07166 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 17:24:02 +0200 Subject: [PATCH 04/45] Parse the prefix on the prefix pages before looking it up (closes #71) `/prefix/{prefix}/{len}` and `/api/v1/prefix/{prefix}/{len}` passed the URL text to the lookup as typed: a prefix that does not parse answered 500, and a valid prefix written in another form (`2001:DB8:0:0::/32`, `192.0.2.1/24`) answered 404. Both now parse the prefix with `net/netip`, mask it and look up the form the tables store; one that does not parse answers 400. The answer shows the stored form of the prefix. The test drives both pages through the router against a real database. Model: opus-5-5 --- internal/server/handlers.go | 23 +++++++++++--- internal/server/handlers_test.go | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 195cb33..eed2271 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -7,6 +7,7 @@ import ( "errors" "net" "net/http" + "net/netip" "runtime" "sort" "strconv" @@ -748,8 +749,15 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { return } - // Combine prefix and length into CIDR notation - prefix := prefixParam + "/" + lenParam + // Look the prefix up in the text form net/netip prints, the form the + // tables store, so that any valid way of writing it finds the route. + parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + + return + } + prefix := parsed.Masked().String() routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) if err != nil { @@ -912,8 +920,15 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { return } - // Combine prefix and length into CIDR notation - prefix := prefixParam + "/" + lenParam + // Look the prefix up in the text form net/netip prints, the form the + // tables store, so that any valid way of writing it finds the route. + parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam) + if err != nil { + http.Error(w, "Invalid prefix", http.StatusBadRequest) + + return + } + prefix := parsed.Masked().String() routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) if err != nil { diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 5bff6d4..927938c 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -148,6 +148,58 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) { } } +// TestPrefixDetailHandlersParsePrefix checks that both prefix pages find a +// stored route whatever valid text form the URL writes its prefix in, and +// answer 400 for a prefix that does not parse. The tables store the form +// net/netip prints. Before the fix the URL text was looked up as typed, so +// "2001:DB8:0:0::/32" was not found although "2001:db8::/32" was stored, and +// "foo/24" answered 500 (https://git.eeqj.de/sneak/routewatch/issues/71). +func TestPrefixDetailHandlersParsePrefix(t *testing.T) { + db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) + if err != nil { + t.Fatalf("database.New: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{ + { + ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4, + OriginASN: 64500, PeerIP: "198.51.100.1", ASPath: []int{64500}, NextHop: "198.51.100.1", + LastUpdated: ts, + }, + { + ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6, + OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, NextHop: "2001:db8::1", + LastUpdated: ts, + }, + }); err != nil { + t.Fatalf("UpsertLiveRouteBatch: %v", err) + } + + s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) + + tests := []struct { + path string + want int + }{ + {"/api/v1/prefix/foo/24", http.StatusBadRequest}, + {"/prefix/foo/24", http.StatusBadRequest}, + {"/api/v1/prefix/2001:DB8:0:0::/32", http.StatusOK}, + {"/prefix/2001:DB8:0:0::/32", http.StatusOK}, + {"/api/v1/prefix/192.0.2.1/24", http.StatusOK}, + {"/prefix/192.0.2.1/24", http.StatusOK}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + if rec.Code != tt.want { + t.Errorf("%s: status %d, want %d; body %s", tt.path, rec.Code, tt.want, rec.Body) + } + } +} + // settledGoroutineCount lets transient goroutines finish, then reports the // current count. func settledGoroutineCount() int { -- 2.54.0 From b4dee4840130ae3f393923dea1ddcbaa034d056f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 18:26:04 +0200 Subject: [PATCH 05/45] Exit with status 1 when the web server cannot listen (closes #73) `Server.Start` opened its port inside a goroutine and returned nil at once, so a port already in use was only logged and the daemon kept running with no web server. It now opens the listener before returning and returns that error; serving runs in the background on the open listener. `CLIEntry` turns an error from `Run` into an fx shutdown with exit status 1, after the normal stop sequence, so a container restart policy can act. A serving error after a successful listen is still only logged. Model: opus-5-5 --- internal/routewatch/cli.go | 5 +++++ internal/server/server.go | 18 ++++++++++++++---- internal/server/server_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 internal/server/server_test.go diff --git a/internal/routewatch/cli.go b/internal/routewatch/cli.go index d857de7..6c2bd6c 100644 --- a/internal/routewatch/cli.go +++ b/internal/routewatch/cli.go @@ -20,6 +20,8 @@ const ( debugInterval = 60 * time.Second // bytesPerMB is bytes per megabyte bytesPerMB = 1024 * 1024 + // failureExitCode is the exit status when RouteWatch stops on an error + failureExitCode = 1 ) // logDebugStats logs goroutine count and memory usage @@ -83,6 +85,9 @@ func CLIEntry() { go func() { if err := rw.Run(ctx); err != nil { logger.Error("RouteWatch error", "error", err) + if err := shutdowner.Shutdown(fx.ExitCode(failureExitCode)); err != nil { + logger.Error("Failed to shutdown gracefully", "error", err) + } } }() diff --git a/internal/server/server.go b/internal/server/server.go index 10f8649..16ba2b3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,8 @@ package server import ( "context" + "fmt" + "net" "net/http" "strconv" "time" @@ -53,9 +55,12 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger, return s } -// Start starts the HTTP server +// Start listens on the configured port and serves HTTP in the background. +// It returns an error if it cannot listen, for example because the port is +// already in use. func (s *Server) Start() error { const ( + network = "tcp" readHeaderTimeout = 40 * time.Second readTimeout = 60 * time.Second writeTimeout = 60 * time.Second @@ -73,10 +78,15 @@ func (s *Server) Start() error { s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr) - // Start in goroutine but log when actually listening + listener, err := net.Listen(network, s.srv.Addr) + if err != nil { + return fmt.Errorf("starting HTTP server: %w", err) + } + + s.logger.Info("HTTP server listening", "addr", listener.Addr().String()) + go func() { - s.logger.Info("HTTP server listening", "addr", s.srv.Addr) - if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := s.srv.Serve(listener); err != nil && err != http.ErrServerClosed { s.logger.Error("HTTP server error", "error", err) } }() diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..ff7c7d2 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "context" + "net" + "testing" + + "git.eeqj.de/sneak/routewatch/internal/config" + "git.eeqj.de/sneak/routewatch/internal/logger" + "git.eeqj.de/sneak/routewatch/internal/metrics" + "git.eeqj.de/sneak/routewatch/internal/streamer" +) + +// TestStartFailsWhenPortIsHeld holds a port with a listener of its own and +// checks that Start on that port returns an error. Before the fix Start +// listened in a goroutine, only logged the failure and returned nil, so the +// daemon kept running without its web server. +func TestStartFailsWhenPortIsHeld(t *testing.T) { + held, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatalf("failed to open the test listener: %v", err) + } + defer func() { _ = held.Close() }() + + port := held.Addr().(*net.TCPAddr).Port + log := logger.New() + s := New(nil, streamer.New(log, metrics.New()), log, &config.Config{Port: port}) + defer func() { _ = s.Stop(context.Background()) }() + + if err := s.Start(); err == nil { + t.Fatalf("Start returned nil while port %d was held by another listener", port) + } +} -- 2.54.0 From ad4c3d91f107df16b92390e67b2502ba55307c91 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 19:08:58 +0200 Subject: [PATCH 06/45] Show the prefix page's origin names from the prefix query (closes #67) The prefix page looked up each origin AS's details only to show its handle and description, and that lookup reads every live route the AS originates. `GetPrefixDetailsContext` already selected the handle and description and dropped them; it now returns them keyed by AS number, and the page uses them. The origin list now also shows the description after the handle, which it never rendered. The JSON prefix answer is unchanged. The test serves the page from a store whose AS lookup always fails. Model: opus-5-5 --- internal/database/database.go | 21 ++++--- internal/database/interface.go | 4 +- internal/routewatch/app_integration_test.go | 8 ++- internal/routewatch/prefixhandler_test.go | 2 +- internal/server/handlers.go | 16 ++--- internal/server/handlers_test.go | 70 +++++++++++++++++++++ internal/templates/prefix_detail.html | 1 + 7 files changed, 97 insertions(+), 25 deletions(-) diff --git a/internal/database/database.go b/internal/database/database.go index ed62eb8..468bd2b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1632,16 +1632,17 @@ func (d *Database) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, er } // GetPrefixDetails returns detailed information about a prefix -func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) { +func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) { return d.GetPrefixDetailsContext(context.Background(), prefix) } -// GetPrefixDetailsContext returns detailed information about a prefix with context support -func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error) { +// GetPrefixDetailsContext returns the live routes for a prefix and, keyed by +// AS number, the handle and description of each AS that originates it. +func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error) { // Determine if it's IPv4 or IPv6 by parsing the prefix _, ipnet, err := net.ParseCIDR(prefix) if err != nil { - return nil, fmt.Errorf("invalid prefix format: %w", err) + return nil, nil, fmt.Errorf("invalid prefix format: %w", err) } tableName := "live_routes_v4" @@ -1663,11 +1664,12 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ( rows, err := d.db.QueryContext(ctx, query, prefix) if err != nil { - return nil, fmt.Errorf("failed to query prefix details: %w", err) + return nil, nil, fmt.Errorf("failed to query prefix details: %w", err) } defer func() { _ = rows.Close() }() var routes []LiveRoute + origins := make(map[int]ASN) for rows.Next() { var route LiveRoute var pathJSON string @@ -1690,13 +1692,18 @@ func (d *Database) GetPrefixDetailsContext(ctx context.Context, prefix string) ( route.IPVersion = ipVersion route.MaskLength, _ = ipnet.Mask.Size() routes = append(routes, route) + origins[route.OriginASN] = ASN{ + ASN: route.OriginASN, + Handle: handle.String, + Description: description.String, + } } if len(routes) == 0 { - return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix) + return nil, nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix) } - return routes, nil + return routes, origins, nil } // GetRandomPrefixesByLength returns a random sample of prefixes with the specified mask length diff --git a/internal/database/interface.go b/internal/database/interface.go index 89cd4f0..504c252 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -76,8 +76,8 @@ type Store interface { GetASDetailsContext(ctx context.Context, asn int) (*ASN, []LiveRoute, error) GetASPeers(asn int) ([]ASPeer, error) GetASPeersContext(ctx context.Context, asn int) ([]ASPeer, error) - GetPrefixDetails(prefix string) ([]LiveRoute, error) - GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, error) + GetPrefixDetails(prefix string) ([]LiveRoute, map[int]ASN, error) + GetPrefixDetailsContext(ctx context.Context, prefix string) ([]LiveRoute, map[int]ASN, error) GetRandomPrefixesByLength(maskLength, ipVersion, limit int) ([]LiveRoute, error) GetRandomPrefixesByLengthContext(ctx context.Context, maskLength, ipVersion, limit int) ([]LiveRoute, error) diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index d53c9fe..a359d03 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -252,13 +252,15 @@ func (m *mockStore) GetASDetailsContext(ctx context.Context, asn int) (*database } // GetPrefixDetails mock implementation -func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) { +func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, map[int]database.ASN, error) { // Return empty routes for now - return []database.LiveRoute{}, nil + return []database.LiveRoute{}, map[int]database.ASN{}, nil } // GetPrefixDetailsContext mock implementation with context support -func (m *mockStore) GetPrefixDetailsContext(ctx context.Context, prefix string) ([]database.LiveRoute, error) { +func (m *mockStore) GetPrefixDetailsContext( + ctx context.Context, prefix string, +) ([]database.LiveRoute, map[int]database.ASN, error) { return m.GetPrefixDetails(prefix) } diff --git a/internal/routewatch/prefixhandler_test.go b/internal/routewatch/prefixhandler_test.go index 0d30e77..fcfa9a3 100644 --- a/internal/routewatch/prefixhandler_test.go +++ b/internal/routewatch/prefixhandler_test.go @@ -170,7 +170,7 @@ func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) { } ctx := context.Background() - routes, err := db.GetPrefixDetailsContext(ctx, prefix) + routes, _, err := db.GetPrefixDetailsContext(ctx, prefix) if err != nil && !errors.Is(err, database.ErrNoRoute) { t.Fatalf("GetPrefixDetailsContext: %v", err) } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index eed2271..0856bd2 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -759,7 +759,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { } prefix := parsed.Masked().String() - routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) + routes, _, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) if err != nil { if errors.Is(err, database.ErrNoRoute) { writeJSONError(w, http.StatusNotFound, err.Error()) @@ -930,7 +930,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { } prefix := parsed.Masked().String() - routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) + routes, originASNs, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) if err != nil { if errors.Is(err, database.ErrNoRoute) { http.Error(w, "Prefix not found", http.StatusNotFound) @@ -952,18 +952,10 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { originMap := make(map[int]*ASNInfo) for _, route := range routes { if _, exists := originMap[route.OriginASN]; !exists { - // Get AS info from database - asInfo, _, _ := s.db.GetASDetailsContext(r.Context(), route.OriginASN) - handle := "" - description := "" - if asInfo != nil { - handle = asInfo.Handle - description = asInfo.Description - } originMap[route.OriginASN] = &ASNInfo{ ASN: route.OriginASN, - Handle: handle, - Description: description, + Handle: originASNs[route.OriginASN].Handle, + Description: originASNs[route.OriginASN].Description, PeerCount: 0, } } diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index 927938c..3821654 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -3,10 +3,13 @@ package server import ( "context" "encoding/json" + "errors" + "html" "net/http" "net/http/httptest" "runtime" "slices" + "strings" "testing" "time" @@ -15,6 +18,7 @@ import ( "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/metrics" "git.eeqj.de/sneak/routewatch/internal/streamer" + "git.eeqj.de/sneak/routewatch/pkg/asinfo" "github.com/google/uuid" ) @@ -200,6 +204,72 @@ func TestPrefixDetailHandlersParsePrefix(t *testing.T) { } } +// failingASDetailsDB wraps a real store and fails every AS details lookup. +type failingASDetailsDB struct { + database.Store +} + +func (failingASDetailsDB) GetASDetailsContext(_ context.Context, _ int) (*database.ASN, []database.LiveRoute, error) { + return nil, nil, errors.New("AS details lookup failed") +} + +// TestPrefixDetailShowsOriginNames checks that the prefix page shows each +// origin AS's handle and description without looking up the AS's details, +// which also read every route the AS originates. Before the fix the page did +// that lookup once per origin, so with it failing the names were missing +// (https://git.eeqj.de/sneak/routewatch/issues/67). The AS path column shows +// handles too, so the test looks for the origin list's "(handle)" form. +func TestPrefixDetailShowsOriginNames(t *testing.T) { + db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) + if err != nil { + t.Fatalf("database.New: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + origins := []int{15169, 13335} + for _, asn := range origins { + if _, err := db.GetOrCreateASN(asn, ts); err != nil { + t.Fatalf("GetOrCreateASN(%d): %v", asn, err) + } + } + if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{ + { + ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4, + OriginASN: origins[0], PeerIP: "198.51.100.1", ASPath: []int{origins[0]}, NextHop: "198.51.100.1", + LastUpdated: ts, + }, + { + ID: uuid.New(), Prefix: "192.0.2.0/24", MaskLength: 24, IPVersion: 4, + OriginASN: origins[1], PeerIP: "198.51.100.2", ASPath: []int{origins[1]}, NextHop: "198.51.100.2", + LastUpdated: ts, + }, + }); err != nil { + t.Fatalf("UpsertLiveRouteBatch: %v", err) + } + + s := New(failingASDetailsDB{db}, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) + rec := httptest.NewRecorder() + s.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/prefix/192.0.2.0/24", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body) + } + + page := rec.Body.String() + for _, asn := range origins { + info, ok := asinfo.Get(asn) + if !ok { + t.Fatalf("AS%d is not in the AS info data", asn) + } + if want := "(" + html.EscapeString(info.Handle) + ")"; !strings.Contains(page, want) { + t.Errorf("page does not show AS%d's handle %q", asn, want) + } + if want := html.EscapeString(info.Description); !strings.Contains(page, want) { + t.Errorf("page does not show AS%d's description %q", asn, want) + } + } +} + // settledGoroutineCount lets transient goroutines finish, then reports the // current count. func settledGoroutineCount() int { diff --git a/internal/templates/prefix_detail.html b/internal/templates/prefix_detail.html index 5ab9bf3..cb12cb8 100644 --- a/internal/templates/prefix_detail.html +++ b/internal/templates/prefix_detail.html @@ -283,6 +283,7 @@