Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 35d225f6e9 Identify a live route by prefix and peer (closes #55)
check / check (push) Canceled after 0s
A peer has at most one route for a prefix, but the live route tables were
keyed by prefix, origin AS and peer. An announcement with a new origin AS
added a second row instead of replacing the first, and a withdrawal sent in
the same message as announcements was given their origin AS and so missed
the withdrawn route.

The tables are now unique on prefix and peer. An announcement updates the
peer's row for the prefix, origin AS included, and a withdrawal deletes it
without looking at an origin AS. The origin-only delete statements, the
deletion's origin field and DeleteLiveRoute's origin parameter are removed.

Model: opus-5-5
2026-10-14 15:16:19 +00:00
5 changed files with 12 additions and 163 deletions

No files matched your search

+2 -3
View File
@@ -144,9 +144,8 @@ routewatch/
- **Reconnection**: Exponential backoff (5s-320s) with reset after 30s of stable connection - **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 - **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 address's prefix at each mask length, longest first, in the prefix index
(at most 25 lookups for IPv4, 113 for IPv6); prefixes are stored in the (at most 33 lookups for IPv4, 129 for IPv6); prefixes are stored in the
text form Go's `net/netip` prints. Routes shorter than /8 for IPv4 or /16 text form Go's `net/netip` prints
for IPv6, such as a default route, are not used.
### Database Schema ### Database Schema
+6 -17
View File
@@ -37,14 +37,6 @@ const (
ipv6Bits = 128 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 // 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. // pooled connection gets them; the heap limits are process-wide and set once.
const ( const (
@@ -1840,12 +1832,11 @@ func (d *Database) GetIPInfo(ip string) (*IPInfo, error) {
// GetIPInfoContext returns comprehensive IP information with context support. // GetIPInfoContext returns comprehensive IP information with context support.
// //
// It finds the most specific live route for the address with point lookups on // 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 // the prefix index: for each mask length from the longest (32 or 128) down to 0
// minLookupMaskLengthV4 or minLookupMaskLengthV6 it looks up the address's // it looks up the address's network prefix at that length, and the first length
// network prefix at that length, and the first length with a live route wins. // with a live route wins. That is at most 33 or 129 indexed lookups, however
// That is at most 25 or 113 indexed lookups, however many routes are live. The // many routes are live. The prefix is looked up in the text form net/netip
// prefix is looked up in the text form net/netip prints, which is the form the // prints, which is the form the prefix handler stores.
// prefix handler stores.
func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) { func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) {
addr, err := netip.ParseAddr(ip) addr, err := netip.ParseAddr(ip)
if err != nil { if err != nil {
@@ -1856,11 +1847,9 @@ func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, er
info := &IPInfo{IP: ip, IPVersion: ipVersionV6} info := &IPInfo{IP: ip, IPVersion: ipVersionV6}
routesTable, prefixesTable := "live_routes_v6", "prefixes_v6" routesTable, prefixesTable := "live_routes_v6", "prefixes_v6"
minMaskLength := minLookupMaskLengthV6
if addr.Is4() { if addr.Is4() {
info.IPVersion = ipVersionV4 info.IPVersion = ipVersionV4
routesTable, prefixesTable = "live_routes_v4", "prefixes_v4" routesTable, prefixesTable = "live_routes_v4", "prefixes_v4"
minMaskLength = minLookupMaskLengthV4
} }
// Route info with peer count and prefix first_seen // Route info with peer count and prefix first_seen
@@ -1901,7 +1890,7 @@ func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, er
var whoisUpdatedAt sql.NullTime var whoisUpdatedAt sql.NullTime
found := false found := false
for bits := addr.BitLen(); bits >= minMaskLength; bits-- { for bits := addr.BitLen(); bits >= 0; bits-- {
prefix := netip.PrefixFrom(addr, bits).Masked().String() prefix := netip.PrefixFrom(addr, bits).Masked().String()
err := stmt.QueryRowContext(ctx, prefix).Scan( err := stmt.QueryRowContext(ctx, prefix).Scan(
&info.Netblock, &info.Netblock,
-72
View File
@@ -133,78 +133,6 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
lookup("2001:db8:1:2::3", "2001:db8:1::/48") 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 // TestConnectionPoolPragmas holds several pooled connections open at once and
// checks each one carries the per-connection settings from the DSN, plus the // 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 // process-wide hard heap limit, and sees the new file with auto_vacuum
+4 -19
View File
@@ -7,7 +7,6 @@ import (
"errors" "errors"
"net" "net"
"net/http" "net/http"
"net/netip"
"runtime" "runtime"
"sort" "sort"
"strconv" "strconv"
@@ -749,15 +748,8 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
return return
} }
// Look the prefix up in the text form net/netip prints, the form the // Combine prefix and length into CIDR notation
// tables store, so that any valid way of writing it finds the route. prefix := prefixParam + "/" + lenParam
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) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
@@ -920,15 +912,8 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
return return
} }
// Look the prefix up in the text form net/netip prints, the form the // Combine prefix and length into CIDR notation
// tables store, so that any valid way of writing it finds the route. prefix := prefixParam + "/" + lenParam
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) routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
if err != nil { if err != nil {
-52
View File
@@ -148,58 +148,6 @@ 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 // settledGoroutineCount lets transient goroutines finish, then reports the
// current count. // current count.
func settledGoroutineCount() int { func settledGoroutineCount() int {