From 78b58a918805353443cf56ba8bcee1483eacc925 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 12:39:46 +0000 Subject: [PATCH] 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 was seen. Such a route only says where a peer sends traffic it has no better route for. The lookup now stops at /8 for IPv4 and /16 for IPv6; an address covered only by shorter routes gets "no route found". The live tables still store those routes. README states the limit. 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