Ignore routes shorter than /8 or /16 in the IP lookup (closes #57)
check / check (push) Waiting to run

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 13:29:23 +00:00
parent 00e51d284f
commit 78b58a9188
3 changed files with 92 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,
+72
View File
@@ -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