Ignore routes shorter than /8 or /16 in the IP lookup (closes #57)
check / check (push) Canceled after 0s

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
This commit is contained in:
2026-10-14 12:39:46 +00:00
parent 00e51d284f
commit 4c9802b46c
3 changed files with 72 additions and 8 deletions
+3 -2
View File
@@ -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
+17 -6
View File
@@ -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,
+52
View File
@@ -133,6 +133,58 @@ func TestGetIPInfoFindsMostSpecificLiveRoute(t *testing.T) {
lookup("2001:db8:1:2::3", "2001:db8:1::/48")
}
// TestGetIPInfoIgnoresShortRoutes checks that a default route or a route
// shorter than the lookup's minimum mask length is not reported as the route
// for an address, and that a longer route covering the address still is.
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)
}
}
announce("2000::/3", 64500)
announce("0.0.0.0/0", 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)
}
}
announce("2001:db8::/32", 64501)
info, err := db.GetIPInfo("2001:db8::1")
if err != nil {
t.Fatalf("GetIPInfo(2001:db8::1) after a /32 is added: %v", err)
}
if info.Netblock != "2001:db8::/32" || info.ASN != 64501 {
t.Errorf("GetIPInfo(2001:db8::1) = %s AS%d, want 2001:db8::/32 AS64501", info.Netblock, info.ASN)
}
}
// 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