Record origin changes and show them on the prefix page (closes #161)
check / check (push) Successful in 4m6s
check / check (push) Successful in 4m6s
The routing table records each prefix and origin AS pair's origin changes: when it gains its first peer and when it loses its last. Each row holds the prefix, the origin AS, announced or withdrawn, the peer count and the time. The prefix handler writes them to a new origin_changes table, and the database maintainer prunes each prefix to its newest 500 rows and 72 hours. The prefix page lists them newest first, at most 5,000. Model: deepseek-v4-pro
This commit is contained in:
+72
-19
@@ -88,6 +88,10 @@ const (
|
||||
ipVersionV6 = 6
|
||||
)
|
||||
|
||||
// maxOriginChangesOnPage is the most origin changes the prefix page shows. A
|
||||
// prefix with more shows the newest ones and says so.
|
||||
const maxOriginChangesOnPage = 5000
|
||||
|
||||
// HealthCheckResponse represents the health check response.
|
||||
type HealthCheckResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -1534,27 +1538,24 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
||||
|
||||
routes := prefixRoutes(parsed, origins)
|
||||
|
||||
// Prepare template data
|
||||
data := struct {
|
||||
Prefix string
|
||||
MaskLength int
|
||||
IPVersion int
|
||||
Routes []EnhancedRoute
|
||||
Origins []*ASNInfo
|
||||
PeerCount int
|
||||
OriginCount int
|
||||
CSPNonce string
|
||||
}{
|
||||
Prefix: parsed.String(),
|
||||
MaskLength: parsed.Bits(),
|
||||
IPVersion: ipVersionOf(parsed),
|
||||
Routes: routesWithASPathHandles(routes),
|
||||
Origins: originInfos,
|
||||
PeerCount: len(routes),
|
||||
OriginCount: len(originInfos),
|
||||
CSPNonce: cspNonce(r.Context()),
|
||||
changes, moreChanges, err := s.prefixOriginChanges(r.Context(), parsed)
|
||||
if deadlinePassed(r.Context()) {
|
||||
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get the origin changes", "error", err)
|
||||
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
data := newPrefixDetailData(parsed, routes, originInfos, changes,
|
||||
moreChanges, cspNonce(r.Context()))
|
||||
|
||||
// Check if context is still valid before writing response
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
@@ -1606,6 +1607,58 @@ func (s *Server) originASNs(
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// prefixOriginChanges returns prefix's origin changes for the page, newest
|
||||
// first, at most maxOriginChangesOnPage, and whether there are more.
|
||||
func (s *Server) prefixOriginChanges(
|
||||
ctx context.Context, prefix netip.Prefix,
|
||||
) ([]routetable.OriginChange, bool, error) {
|
||||
// One more is read to know whether there are more.
|
||||
changes, err := s.db.GetOriginChanges(ctx, prefix, maxOriginChangesOnPage+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
more := len(changes) > maxOriginChangesOnPage
|
||||
if more {
|
||||
changes = changes[:maxOriginChangesOnPage]
|
||||
}
|
||||
|
||||
return changes, more, nil
|
||||
}
|
||||
|
||||
// prefixDetailData is what the prefix detail page is rendered with.
|
||||
type prefixDetailData struct {
|
||||
Prefix string
|
||||
MaskLength int
|
||||
IPVersion int
|
||||
Routes []EnhancedRoute
|
||||
Origins []*ASNInfo
|
||||
PeerCount int
|
||||
OriginCount int
|
||||
OriginChanges []routetable.OriginChange
|
||||
MoreOriginChanges bool
|
||||
CSPNonce string
|
||||
}
|
||||
|
||||
// newPrefixDetailData builds the prefix detail page's data.
|
||||
func newPrefixDetailData(
|
||||
parsed netip.Prefix, routes []LiveRoute, originInfos []*ASNInfo,
|
||||
changes []routetable.OriginChange, moreChanges bool, nonce string,
|
||||
) prefixDetailData {
|
||||
return prefixDetailData{
|
||||
Prefix: parsed.String(),
|
||||
MaskLength: parsed.Bits(),
|
||||
IPVersion: ipVersionOf(parsed),
|
||||
Routes: routesWithASPathHandles(routes),
|
||||
Origins: originInfos,
|
||||
PeerCount: len(routes),
|
||||
OriginCount: len(originInfos),
|
||||
OriginChanges: changes,
|
||||
MoreOriginChanges: moreChanges,
|
||||
CSPNonce: nonce,
|
||||
}
|
||||
}
|
||||
|
||||
// prefixRoutes returns one route for each peer that carries prefix, by origin
|
||||
// AS in the order of origins. Each origin's kept routes come first, newest
|
||||
// first, with their AS path, next hop and time. Its other peers follow without
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
)
|
||||
|
||||
// newPrefixDetailServer returns a server whose routing table holds testPrefixV4
|
||||
// with origin AS 64500, and whose database holds the given origin changes.
|
||||
func newPrefixDetailServer(
|
||||
t *testing.T, changes []routetable.OriginChange,
|
||||
) *Server {
|
||||
t.Helper()
|
||||
|
||||
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() })
|
||||
|
||||
table := emptyTable()
|
||||
announce(table, testPrefixV4, 64500, testPeer(1), testRouteTime(0))
|
||||
|
||||
err = db.WriteOriginChanges(changes)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteOriginChanges: %v", err)
|
||||
}
|
||||
|
||||
return New(db, table, streamer.New(logger.New(), metrics.New()),
|
||||
logger.New(), &config.Config{})
|
||||
}
|
||||
|
||||
// TestPrefixDetailShowsOriginChanges checks that the prefix page shows the
|
||||
// origin changes newest first, with the announced and withdrawn rows and the
|
||||
// AS link.
|
||||
func TestPrefixDetailShowsOriginChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prefix := netip.MustParsePrefix(testPrefixV4)
|
||||
|
||||
s := newPrefixDetailServer(t, []routetable.OriginChange{
|
||||
{Prefix: prefix, OriginASN: 64500, Announced: true, PeerCount: 1,
|
||||
Time: testRouteTime(1)},
|
||||
{Prefix: prefix, OriginASN: 64500, Announced: false, PeerCount: 0,
|
||||
Time: testRouteTime(2)},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
page := rec.Body.String()
|
||||
|
||||
withdrawn := strings.Index(page, "Withdrawn")
|
||||
announced := strings.Index(page, "Announced")
|
||||
|
||||
if withdrawn < 0 || announced < 0 {
|
||||
t.Fatalf("page does not show the announced and withdrawn rows")
|
||||
}
|
||||
|
||||
// The withdrawn row is newer, so it comes first.
|
||||
if withdrawn > announced {
|
||||
t.Errorf("the withdrawn row is not shown before the announced row")
|
||||
}
|
||||
|
||||
if !strings.Contains(page, `href="/as/64500"`) {
|
||||
t.Errorf("page does not link to AS64500")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefixDetailShowsOriginZeroAsNoKnownOrigin checks that the origin
|
||||
// changes table shows AS 0 as no known origin, without a link to AS0.
|
||||
func TestPrefixDetailShowsOriginZeroAsNoKnownOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prefix := netip.MustParsePrefix(testPrefixV4)
|
||||
|
||||
s := newPrefixDetailServer(t, []routetable.OriginChange{
|
||||
{Prefix: prefix, OriginASN: ristypes.NoOrigin, Announced: true,
|
||||
PeerCount: 1, Time: testRouteTime(1)},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
page := rec.Body.String()
|
||||
|
||||
if !strings.Contains(page, "No known origin") {
|
||||
t.Errorf("page does not say the origin change has no known origin")
|
||||
}
|
||||
|
||||
if strings.Contains(page, `href="/as/0"`) {
|
||||
t.Errorf("page links to AS0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefixDetailSaysWhenMoreThan5000Changes checks that the prefix page says
|
||||
// so when a prefix has more than 5,000 origin changes.
|
||||
func TestPrefixDetailSaysWhenMoreThan5000Changes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prefix := netip.MustParsePrefix(testPrefixV4)
|
||||
|
||||
changes := make([]routetable.OriginChange, 0, 5001)
|
||||
for i := range 5001 {
|
||||
changes = append(changes, routetable.OriginChange{
|
||||
Prefix: prefix, OriginASN: 64500, Announced: true, PeerCount: 1,
|
||||
Time: testRouteTime(i),
|
||||
})
|
||||
}
|
||||
|
||||
s := newPrefixDetailServer(t, changes)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/prefix/"+testPrefixV4, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
page := rec.Body.String()
|
||||
|
||||
if !strings.Contains(page, "more than 5,000 changes") {
|
||||
t.Errorf("page does not say there are more than 5,000 changes")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user