diff --git a/README.md b/README.md index cf77465..af8ac6a 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,10 @@ routewatch/ - **Backpressure**: Probabilistic message dropping when queues exceed 50% capacity - **Graceful Shutdown**: 60-second timeout, flushes all pending batches - **Reconnection**: Exponential backoff (5s-320s) with reset after 30s of stable connection -- **IPv4 Optimization**: IP ranges stored as uint32 for O(1) lookups +- **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 ### Database Schema @@ -154,7 +157,7 @@ prefixes_v6(id, prefix, mask_length, first_seen, last_seen) -- Live routing tables (one per IP version) live_routes_v4(id, prefix, mask_length, origin_asn, peer_ip, as_path, - next_hop, last_updated, v4_ip_start, v4_ip_end) + next_hop, last_updated) live_routes_v6(id, prefix, mask_length, origin_asn, peer_ip, as_path, next_hop, last_updated) diff --git a/TODO.md b/TODO.md index b329b6e..33cafc9 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,11 @@ The other open issue is https://git.eeqj.de/sneak/routewatch/issues/30. # Completed Steps +- 2026-10-03: looking up an IP address no longer reads every IPv6 route: + both families find the most specific live route with at most 33 or 129 + lookups on the prefix index, and the IPv4 range columns are gone. Prefixes + from the feed are stored in one text form, so an IPv6 withdrawal, which the + feed sends uncompressed, now removes its route (closes #48) - 2026-10-02: a plain `docker build .` stamps the commit's tag or short commit (`git describe --tags --always`) into the page footer instead of `unknown`: `.dockerignore` sends `.git` without `.git/config`, a `VERSION` diff --git a/internal/database/counts_test.go b/internal/database/counts_test.go index 6db2859..65cfec8 100644 --- a/internal/database/counts_test.go +++ b/internal/database/counts_test.go @@ -11,15 +11,10 @@ import ( "github.com/google/uuid" ) -// mkV4Route builds an IPv4 live route with its range columns populated. +// mkV4Route builds an IPv4 live route. func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute { t.Helper() - start, end, err := CalculateIPv4Range(prefix) - if err != nil { - t.Fatalf("CalculateIPv4Range(%s): %v", prefix, err) - } - return &LiveRoute{ ID: uuid.New(), Prefix: prefix, @@ -30,8 +25,6 @@ func mkV4Route(t *testing.T, prefix string, asn int, ts time.Time) *LiveRoute { ASPath: []int{asn}, NextHop: "192.0.2.254", LastUpdated: ts, - V4IPStart: &start, - V4IPEnd: &end, } } diff --git a/internal/database/database.go b/internal/database/database.go index 4007b48..14da155 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "net" + "net/netip" "os" "path/filepath" "runtime" @@ -32,10 +33,6 @@ const ( dirPermissions = 0750 // rwxr-x--- ipVersionV4 = 4 ipVersionV6 = 6 - ipv6Length = 16 - ipv4Offset = 12 - ipv4Bits = 32 - maxIPv4 = 0xFFFFFFFF ) // SQLite memory tuning. cache_size and busy_timeout go in the DSN so every @@ -225,10 +222,10 @@ func (d *Database) beginTx() (*loggingTx, error) { // upsert did. const ( updateLiveRouteV4SQL = `UPDATE live_routes_v4 SET mask_length = ?, as_path = ?, next_hop = ?, - last_updated = ?, ip_start = ?, ip_end = ? WHERE prefix = ? AND origin_asn = ? AND peer_ip = ?` + last_updated = ? WHERE prefix = ? AND origin_asn = ? AND peer_ip = ?` insertLiveRouteV4SQL = `INSERT INTO live_routes_v4 (id, prefix, mask_length, origin_asn, peer_ip, - as_path, next_hop, last_updated, ip_start, ip_end) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + as_path, next_hop, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` updateLiveRouteV6SQL = `UPDATE live_routes_v6 SET mask_length = ?, as_path = ?, next_hop = ?, last_updated = ? WHERE prefix = ? AND origin_asn = ? AND peer_ip = ?` @@ -240,12 +237,8 @@ const ( // upsertRouteRowV4 updates an IPv4 live route, inserting it when no row matched, // and reports whether a new row was inserted. func upsertRouteRowV4(upd, ins *sql.Stmt, route *LiveRoute, pathJSON string) (inserted bool, err error) { - if route.V4IPStart == nil || route.V4IPEnd == nil { - return false, fmt.Errorf("IPv4 route %s missing range values", route.Prefix) - } - res, err := upd.Exec(route.MaskLength, pathJSON, route.NextHop, route.LastUpdated, - *route.V4IPStart, *route.V4IPEnd, route.Prefix, route.OriginASN, route.PeerIP) + route.Prefix, route.OriginASN, route.PeerIP) if err != nil { return false, err } @@ -258,7 +251,7 @@ func upsertRouteRowV4(upd, ins *sql.Stmt, route *LiveRoute, pathJSON string) (in } _, err = ins.Exec(route.ID.String(), route.Prefix, route.MaskLength, route.OriginASN, - route.PeerIP, pathJSON, route.NextHop, route.LastUpdated, *route.V4IPStart, *route.V4IPEnd) + route.PeerIP, pathJSON, route.NextHop, route.LastUpdated) if err != nil { return false, err } @@ -1322,176 +1315,6 @@ func (d *Database) GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ip return ipv4Count, ipv6Count, nil } -// GetASInfoForIP returns AS information for the given IP address -func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { - return d.GetASInfoForIPContext(context.Background(), ip) -} - -// GetASInfoForIPContext returns AS information for the given IP address with context support -func (d *Database) GetASInfoForIPContext(ctx context.Context, ip string) (*ASInfo, error) { - // Parse the IP to validate it - parsedIP := net.ParseIP(ip) - if parsedIP == nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip) - } - - // Determine IP version - ipVersion := ipVersionV4 - ipv4 := parsedIP.To4() - if ipv4 == nil { - ipVersion = ipVersionV6 - } - - // For IPv4, use optimized range query - if ipVersion == ipVersionV4 { - // Convert IP to 32-bit unsigned integer - ipUint := ipToUint32(ipv4) - - query := ` - SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, a.handle, a.description - FROM live_routes_v4 lr - LEFT JOIN asns a ON a.asn = lr.origin_asn - WHERE lr.ip_start <= ? AND lr.ip_end >= ? - ORDER BY lr.mask_length DESC - LIMIT 1 - ` - - var prefix string - var maskLength, originASN int - var lastUpdated time.Time - var handle, description sql.NullString - - err := d.db.QueryRowContext(ctx, query, ipUint, ipUint).Scan( - &prefix, &maskLength, &originASN, &lastUpdated, &handle, &description) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) - } - - return nil, fmt.Errorf("failed to query routes: %w", err) - } - - age := time.Since(lastUpdated).Round(time.Second).String() - - return &ASInfo{ - ASN: originASN, - Handle: handle.String, - Description: description.String, - Prefix: prefix, - LastUpdated: lastUpdated, - Age: age, - }, nil - } - - // For IPv6, use the original method since we don't have range optimization - query := ` - SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, a.handle, a.description - FROM live_routes_v6 lr - LEFT JOIN asns a ON a.asn = lr.origin_asn - ORDER BY lr.mask_length DESC - ` - - rows, err := d.db.QueryContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query routes: %w", err) - } - defer func() { _ = rows.Close() }() - - // Find the most specific matching prefix - var bestMatch struct { - prefix string - maskLength int - originASN int - lastUpdated time.Time - handle sql.NullString - description sql.NullString - } - bestMaskLength := -1 - - for rows.Next() { - var prefix string - var maskLength, originASN int - var lastUpdated time.Time - var handle, description sql.NullString - - if err := rows.Scan(&prefix, &maskLength, &originASN, &lastUpdated, &handle, &description); err != nil { - continue - } - - // Parse the prefix CIDR - _, ipNet, err := net.ParseCIDR(prefix) - if err != nil { - continue - } - - // Check if the IP is in this prefix - if ipNet.Contains(parsedIP) && maskLength > bestMaskLength { - bestMatch.prefix = prefix - bestMatch.maskLength = maskLength - bestMatch.originASN = originASN - bestMatch.lastUpdated = lastUpdated - bestMatch.handle = handle - bestMatch.description = description - bestMaskLength = maskLength - } - } - - if bestMaskLength == -1 { - return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) - } - - age := time.Since(bestMatch.lastUpdated).Round(time.Second).String() - - return &ASInfo{ - ASN: bestMatch.originASN, - Handle: bestMatch.handle.String, - Description: bestMatch.description.String, - Prefix: bestMatch.prefix, - LastUpdated: bestMatch.lastUpdated, - Age: age, - }, nil -} - -// ipToUint32 converts an IPv4 address to a 32-bit unsigned integer -func ipToUint32(ip net.IP) uint32 { - if len(ip) == ipv6Length { - // Convert to 4-byte representation - ip = ip[ipv4Offset:ipv6Length] - } - - return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3]) -} - -// CalculateIPv4Range calculates the start and end IP addresses for an IPv4 CIDR block -func CalculateIPv4Range(cidr string) (start, end uint32, err error) { - _, ipNet, err := net.ParseCIDR(cidr) - if err != nil { - return 0, 0, err - } - - // Get the network address (start of range) - ip := ipNet.IP.To4() - if ip == nil { - return 0, 0, fmt.Errorf("not an IPv4 address") - } - - start = ipToUint32(ip) - - // Calculate the end of the range - ones, bits := ipNet.Mask.Size() - hostBits := bits - ones - if hostBits >= ipv4Bits { - // Special case for /0 - entire IPv4 space - end = maxIPv4 - } else { - // Safe to convert since we checked hostBits < 32 - //nolint:gosec // hostBits is guaranteed to be < 32 from the check above - end = start | ((1 << uint(hostBits)) - 1) - } - - return start, end, nil -} - // GetASDetails returns detailed information about an AS including prefixes func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) { return d.GetASDetailsContext(context.Background(), asn) @@ -1913,39 +1736,37 @@ 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. func (d *Database) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) { - // Parse the IP to validate it - parsedIP := net.ParseIP(ip) - if parsedIP == nil { + addr, err := netip.ParseAddr(ip) + if err != nil { return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip) } + // An IPv4 address written in IPv6 form (::ffff:192.0.2.1) is an IPv4 lookup. + addr = addr.Unmap() - // Determine IP version - ipv4 := parsedIP.To4() - if ipv4 != nil { - return d.getIPv4Info(ctx, ip, ipv4) + info := &IPInfo{IP: ip, IPVersion: ipVersionV6} + routesTable, prefixesTable := "live_routes_v6", "prefixes_v6" + if addr.Is4() { + info.IPVersion = ipVersionV4 + routesTable, prefixesTable = "live_routes_v4", "prefixes_v4" } - return d.getIPv6Info(ctx, ip, parsedIP) -} - -// getIPv4Info returns comprehensive IP information for an IPv4 address. -func (d *Database) getIPv4Info(ctx context.Context, ip string, ipv4 net.IP) (*IPInfo, error) { - info := &IPInfo{ - IP: ip, - IPVersion: ipVersionV4, - } - - ipUint := ipToUint32(ipv4) - - // Get route info with peer count and prefix first_seen - query := ` + // Route info with peer count and prefix first_seen + //nolint:gosec // Table names are hardcoded based on IP version + query := fmt.Sprintf(` SELECT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, - (SELECT COUNT(DISTINCT peer_ip) FROM live_routes_v4 WHERE prefix = lr.prefix) as num_peers, + (SELECT COUNT(DISTINCT peer_ip) FROM %[1]s WHERE prefix = lr.prefix) as num_peers, p.first_seen, a.handle, a.description, @@ -1957,42 +1778,56 @@ func (d *Database) getIPv4Info(ctx context.Context, ip string, ipv4 net.IP) (*IP a.abuse_email, a.rir, a.whois_updated_at - FROM live_routes_v4 lr - LEFT JOIN prefixes_v4 p ON p.prefix = lr.prefix + FROM %[1]s lr + LEFT JOIN %[2]s p ON p.prefix = lr.prefix LEFT JOIN asns a ON a.asn = lr.origin_asn - WHERE lr.ip_start <= ? AND lr.ip_end >= ? - ORDER BY lr.mask_length DESC + WHERE lr.prefix = ? LIMIT 1 - ` + `, routesTable, prefixesTable) + + stmt, err := d.db.PrepareContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to prepare route query: %w", err) + } + defer func() { _ = stmt.Close() }() var handle, description, asName, orgName, orgID, address, countryCode, abuseEmail, rir sql.NullString var prefixFirstSeen sql.NullTime var whoisUpdatedAt sql.NullTime - err := d.db.QueryRowContext(ctx, query, ipUint, ipUint).Scan( - &info.Netblock, - &info.MaskLength, - &info.ASN, - &info.LastSeen, - &info.NumPeers, - &prefixFirstSeen, - &handle, - &description, - &asName, - &orgName, - &orgID, - &address, - &countryCode, - &abuseEmail, - &rir, - &whoisUpdatedAt, - ) - if err != nil { - if err == sql.ErrNoRows { - return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) + found := false + for bits := addr.BitLen(); bits >= 0; bits-- { + prefix := netip.PrefixFrom(addr, bits).Masked().String() + err := stmt.QueryRowContext(ctx, prefix).Scan( + &info.Netblock, + &info.MaskLength, + &info.ASN, + &info.LastSeen, + &info.NumPeers, + &prefixFirstSeen, + &handle, + &description, + &asName, + &orgName, + &orgID, + &address, + &countryCode, + &abuseEmail, + &rir, + &whoisUpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + continue } + if err != nil { + return nil, fmt.Errorf("failed to query routes: %w", err) + } + found = true - return nil, fmt.Errorf("failed to query routes: %w", err) + break + } + if !found { + return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) } info.Handle = handle.String @@ -2016,106 +1851,6 @@ func (d *Database) getIPv4Info(ctx context.Context, ip string, ipv4 net.IP) (*IP return info, nil } -// getIPv6Info returns comprehensive IP information for an IPv6 address. -func (d *Database) getIPv6Info(ctx context.Context, ip string, parsedIP net.IP) (*IPInfo, error) { - info := &IPInfo{ - IP: ip, - IPVersion: ipVersionV6, - } - - // For IPv6, scan all routes and find best match - query := ` - SELECT DISTINCT - lr.prefix, - lr.mask_length, - lr.origin_asn, - lr.last_updated, - a.handle, - a.description, - a.as_name, - a.org_name, - a.org_id, - a.address, - a.country_code, - a.abuse_email, - a.rir, - a.whois_updated_at - FROM live_routes_v6 lr - LEFT JOIN asns a ON a.asn = lr.origin_asn - ORDER BY lr.mask_length DESC - ` - - rows, err := d.db.QueryContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query routes: %w", err) - } - defer func() { _ = rows.Close() }() - - bestMaskLength := -1 - - for rows.Next() { - var prefix string - var maskLength, originASN int - var lastUpdated time.Time - var handle, description, asName, orgName, orgID, address, countryCode, abuseEmail, rir sql.NullString - var whoisUpdatedAt sql.NullTime - - if err := rows.Scan( - &prefix, &maskLength, &originASN, &lastUpdated, - &handle, &description, &asName, &orgName, &orgID, - &address, &countryCode, &abuseEmail, &rir, &whoisUpdatedAt, - ); err != nil { - continue - } - - _, ipNet, err := net.ParseCIDR(prefix) - if err != nil { - continue - } - - if ipNet.Contains(parsedIP) && maskLength > bestMaskLength { - info.Netblock = prefix - info.MaskLength = maskLength - info.ASN = originASN - info.LastSeen = lastUpdated - info.Handle = handle.String - info.Description = description.String - info.ASName = asName.String - info.OrgName = orgName.String - info.OrgID = orgID.String - info.Address = address.String - info.CountryCode = countryCode.String - info.AbuseEmail = abuseEmail.String - info.RIR = rir.String - bestMaskLength = maskLength - - if !whoisUpdatedAt.Valid { - info.NeedsWHOISRefresh = true - } else { - const staleThreshold = 30 * 24 * time.Hour - info.NeedsWHOISRefresh = time.Since(whoisUpdatedAt.Time) > staleThreshold - } - } - } - - if bestMaskLength == -1 { - return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip) - } - - // Get peer count and first_seen for IPv6 - countQuery := `SELECT COUNT(DISTINCT peer_ip) FROM live_routes_v6 WHERE prefix = ?` - _ = d.db.QueryRowContext(ctx, countQuery, info.Netblock).Scan(&info.NumPeers) - - firstSeenQuery := `SELECT first_seen FROM prefixes_v6 WHERE prefix = ?` - var prefixFirstSeen sql.NullTime - err = d.db.QueryRowContext(ctx, firstSeenQuery, info.Netblock).Scan(&prefixFirstSeen) - if err == nil && prefixFirstSeen.Valid { - info.FirstSeen = prefixFirstSeen.Time - } - - return info, nil -} - // Vacuum runs incremental vacuum to reclaim unused pages without blocking writes. // It frees up to the specified number of pages per call (0 = all freeable pages). func (d *Database) Vacuum(ctx context.Context) error { diff --git a/internal/database/database_test.go b/internal/database/database_test.go index fa4bb48..e15ec1a 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -3,13 +3,15 @@ package database import ( "context" "database/sql" - "net" + "errors" + "net/netip" "sync" "testing" "time" "git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/logger" + "github.com/google/uuid" ) // tempStoreMemory is the PRAGMA temp_store value meaning "hold temp B-trees in @@ -20,6 +22,9 @@ const tempStoreMemory = 2 // once so each is a distinct SQLite connection that parsed the DSN. const heldConnections = 5 +// testPeerIP is the peer every route in the IP lookup test is learned from. +const testPeerIP = "192.0.2.254" + // Parameters for the checkpoint-contention regression test. const ( // contentionIterations is how many batch writes race the checkpoint loop. @@ -32,281 +37,92 @@ const ( asnSecondBand = 100 ) -func TestIPToUint32(t *testing.T) { - tests := []struct { - name string - ip string - expected uint32 - }{ - { - name: "Simple IP", - ip: "192.168.1.1", - expected: 3232235777, // 192<<24 + 168<<16 + 1<<8 + 1 - }, - { - name: "Minimum IP", - ip: "0.0.0.0", - expected: 0, - }, - { - name: "Maximum IP", - ip: "255.255.255.255", - expected: 4294967295, - }, - { - name: "10.0.0.0", - ip: "10.0.0.0", - expected: 167772160, - }, - { - name: "172.16.0.0", - ip: "172.16.0.0", - expected: 2886729728, - }, - { - name: "8.8.8.8", - ip: "8.8.8.8", - expected: 134744072, - }, - { - name: "1.2.3.4", - ip: "1.2.3.4", - expected: 16909060, - }, +// TestGetIPInfoFindsMostSpecificLiveRoute stores nested live prefixes for both +// families and checks that a lookup returns the most specific one covering the +// address, ErrNoRoute when none covers it, and the next less specific prefix +// once the only route of the most specific one is withdrawn. +func TestGetIPInfoFindsMostSpecificLiveRoute(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() }() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ip := net.ParseIP(tt.ip) - if ip == nil { - t.Fatalf("Failed to parse IP: %s", tt.ip) - } - - result := ipToUint32(ip) - if result != tt.expected { - t.Errorf("ipToUint32(%s) = %d, want %d", tt.ip, result, tt.expected) - } - - // Test with IPv4-mapped IPv6 address - ip6 := net.ParseIP(tt.ip).To16() - if ip6 != nil { - result6 := ipToUint32(ip6) - if result6 != tt.expected { - t.Errorf("ipToUint32(%s as IPv6) = %d, want %d", tt.ip, result6, tt.expected) - } - } + // Nested live prefixes, each originated by its own AS. + origins := map[string]int{ + "10.0.0.0/8": 64500, + "10.1.0.0/16": 64501, + "10.1.2.0/24": 64502, + "2001:db8::/32": 64500, + "2001:db8:1::/48": 64501, + "2001:db8:1:2::/64": 64502, + } + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + routes := make([]*LiveRoute, 0, len(origins)) + for prefix, asn := range origins { + routes = append(routes, &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, }) } -} - -func TestCalculateIPv4Range(t *testing.T) { - tests := []struct { - name string - cidr string - wantStart uint32 - wantEnd uint32 - wantErr bool - }{ - { - name: "Single IP /32", - cidr: "192.168.1.1/32", - wantStart: 3232235777, - wantEnd: 3232235777, - }, - { - name: "Class C /24", - cidr: "192.168.1.0/24", - wantStart: 3232235776, // 192.168.1.0 - wantEnd: 3232236031, // 192.168.1.255 - }, - { - name: "Class B /16", - cidr: "192.168.0.0/16", - wantStart: 3232235520, // 192.168.0.0 - wantEnd: 3232301055, // 192.168.255.255 - }, - { - name: "Class A /8", - cidr: "10.0.0.0/8", - wantStart: 167772160, // 10.0.0.0 - wantEnd: 184549375, // 10.255.255.255 - }, - { - name: "Entire IPv4 space /0", - cidr: "0.0.0.0/0", - wantStart: 0, - wantEnd: 4294967295, - }, - { - name: "Small subnet /30", - cidr: "192.168.1.0/30", - wantStart: 3232235776, // 192.168.1.0 - wantEnd: 3232235779, // 192.168.1.3 - }, - { - name: "Medium subnet /20", - cidr: "172.16.0.0/20", - wantStart: 2886729728, // 172.16.0.0 - wantEnd: 2886733823, // 172.16.15.255 - }, - { - name: "Private range 172.16/12", - cidr: "172.16.0.0/12", - wantStart: 2886729728, // 172.16.0.0 - wantEnd: 2887778303, // 172.31.255.255 - }, - { - name: "Google DNS /29", - cidr: "8.8.8.8/29", - wantStart: 134744072, // 8.8.8.8 (network is actually 8.8.8.8 with /29) - wantEnd: 134744079, // 8.8.8.15 - }, - { - name: "Non-zero host bits", - cidr: "192.168.1.5/24", - wantStart: 3232235776, // 192.168.1.0 (network address) - wantEnd: 3232236031, // 192.168.1.255 - }, - { - name: "Invalid CIDR", - cidr: "192.168.1.1/33", - wantErr: true, - }, - { - name: "Invalid IP", - cidr: "256.256.256.256/24", - wantErr: true, - }, - { - name: "IPv6 CIDR", - cidr: "2001:db8::/32", - wantErr: true, - }, - { - name: "Empty CIDR", - cidr: "", - wantErr: true, - }, - { - name: "Missing mask", - cidr: "192.168.1.1", - wantErr: true, - }, + if err := db.UpsertLiveRouteBatch(routes); err != nil { + t.Fatalf("UpsertLiveRouteBatch: %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - start, end, err := CalculateIPv4Range(tt.cidr) + // lookup checks that ip resolves to the live prefix want, or to ErrNoRoute + // when want is empty. + lookup := func(ip, want string) { + t.Helper() - if tt.wantErr { - if err == nil { - t.Errorf("CalculateIPv4Range(%s) expected error, got nil", tt.cidr) - } - return + info, err := db.GetIPInfo(ip) + if want == "" { + if !errors.Is(err, ErrNoRoute) { + t.Errorf("GetIPInfo(%s) = %+v, %v; want ErrNoRoute", ip, info, err) } - if err != nil { - t.Errorf("CalculateIPv4Range(%s) unexpected error: %v", tt.cidr, err) - return - } + return + } + if err != nil { + t.Errorf("GetIPInfo(%s): %v", ip, err) - if start != tt.wantStart { - t.Errorf("CalculateIPv4Range(%s) start = %d, want %d", tt.cidr, start, tt.wantStart) - } - - if end != tt.wantEnd { - t.Errorf("CalculateIPv4Range(%s) end = %d, want %d", tt.cidr, end, tt.wantEnd) - } - - // Verify that start <= end - if start > end { - t.Errorf("CalculateIPv4Range(%s) start (%d) > end (%d)", tt.cidr, start, end) - } - - // Verify the range size matches the CIDR mask - if !tt.wantErr && tt.cidr != "" { - _, ipNet, _ := net.ParseCIDR(tt.cidr) - if ipNet != nil { - ones, bits := ipNet.Mask.Size() - expectedSize := uint32(1) << uint(bits-ones) - actualSize := end - start + 1 - if actualSize != expectedSize { - t.Errorf("CalculateIPv4Range(%s) range size = %d, want %d", tt.cidr, actualSize, expectedSize) - } - } - } - }) - } -} - -func TestIPv4RangeIntegration(t *testing.T) { - // Test that our functions work correctly together - tests := []struct { - name string - cidr string - testIPs []string - shouldContain []bool - }{ - { - name: "192.168.1.0/24", - cidr: "192.168.1.0/24", - testIPs: []string{ - "192.168.1.0", - "192.168.1.1", - "192.168.1.255", - "192.168.0.255", - "192.168.2.0", - }, - shouldContain: []bool{true, true, true, false, false}, - }, - { - name: "10.0.0.0/8", - cidr: "10.0.0.0/8", - testIPs: []string{ - "10.0.0.0", - "10.255.255.255", - "10.1.2.3", - "9.255.255.255", - "11.0.0.0", - }, - shouldContain: []bool{true, true, true, false, false}, - }, - { - name: "172.16.0.0/12", - cidr: "172.16.0.0/12", - testIPs: []string{ - "172.16.0.0", - "172.31.255.255", - "172.20.1.1", - "172.15.255.255", - "172.32.0.0", - }, - shouldContain: []bool{true, true, true, false, false}, - }, + return + } + if info.Netblock != want || info.MaskLength != netip.MustParsePrefix(want).Bits() || + info.ASN != origins[want] { + t.Errorf("GetIPInfo(%s) = %s (mask %d) AS%d, want %s AS%d", + ip, info.Netblock, info.MaskLength, info.ASN, want, origins[want]) + } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - start, end, err := CalculateIPv4Range(tt.cidr) - if err != nil { - t.Fatalf("Failed to calculate range for %s: %v", tt.cidr, err) - } + lookup("10.1.2.3", "10.1.2.0/24") + lookup("::ffff:10.1.2.3", "10.1.2.0/24") + lookup("10.1.3.4", "10.1.0.0/16") + lookup("10.2.0.1", "10.0.0.0/8") + lookup("192.0.2.1", "") + lookup("2001:db8:1:2::3", "2001:db8:1:2::/64") + lookup("2001:db8:1:3::4", "2001:db8:1::/48") + lookup("2001:db8:2::1", "2001:db8::/32") + lookup("2001:db9::1", "") - for i, testIP := range tt.testIPs { - ip := net.ParseIP(testIP) - if ip == nil { - t.Fatalf("Failed to parse test IP: %s", testIP) - } - - ipUint := ipToUint32(ip) - contained := ipUint >= start && ipUint <= end - - if contained != tt.shouldContain[i] { - t.Errorf("IP %s in range %s: got %v, want %v", testIP, tt.cidr, contained, tt.shouldContain[i]) - } - } - }) + err = db.DeleteLiveRouteBatch([]LiveRouteDeletion{ + {Prefix: "10.1.2.0/24", OriginASN: 64502, PeerIP: testPeerIP, IPVersion: ipVersionV4}, + {Prefix: "2001:db8:1:2::/64", OriginASN: 64502, PeerIP: testPeerIP, IPVersion: ipVersionV6}, + }) + if err != nil { + t.Fatalf("DeleteLiveRouteBatch: %v", err) } + + lookup("10.1.2.3", "10.1.0.0/16") + lookup("2001:db8:1:2::3", "2001:db8:1::/48") } // TestConnectionPoolPragmas holds several pooled connections open at once and @@ -424,21 +240,3 @@ func TestBatchWriteDuringCheckpoint(t *testing.T) { cancel() wg.Wait() } - -func BenchmarkIPToUint32(b *testing.B) { - ip := net.ParseIP("192.168.1.1") - b.ResetTimer() - - for i := 0; i < b.N; i++ { - _ = ipToUint32(ip) - } -} - -func BenchmarkCalculateIPv4Range(b *testing.B) { - cidr := "192.168.0.0/16" - b.ResetTimer() - - for i := 0; i < b.N; i++ { - _, _, _ = CalculateIPv4Range(cidr) - } -} diff --git a/internal/database/interface.go b/internal/database/interface.go index 038dfd5..89cd4f0 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -63,8 +63,6 @@ type Store interface { GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, ipv6Count int, err error) // IP lookup operations - GetASInfoForIP(ip string) (*ASInfo, error) - GetASInfoForIPContext(ctx context.Context, ip string) (*ASInfo, error) GetIPInfo(ip string) (*IPInfo, error) GetIPInfoContext(ctx context.Context, ip string) (*IPInfo, error) diff --git a/internal/database/models.go b/internal/database/models.go index fcfe8de..a8e98b0 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -77,9 +77,6 @@ type LiveRoute struct { ASPath []int `json:"as_path"` NextHop string `json:"next_hop"` LastUpdated time.Time `json:"last_updated"` - // IPv4 range fields for fast lookups (nil for IPv6) - V4IPStart *uint32 `json:"v4_ip_start,omitempty"` - V4IPEnd *uint32 `json:"v4_ip_end,omitempty"` } // PrefixDistribution represents the distribution of prefixes by mask length @@ -88,16 +85,6 @@ type PrefixDistribution struct { Count int `json:"count"` } -// ASInfo represents AS information for an IP lookup (legacy format) -type ASInfo struct { - ASN int `json:"asn"` - Handle string `json:"handle"` - Description string `json:"description"` - Prefix string `json:"prefix"` - LastUpdated time.Time `json:"last_updated"` - Age string `json:"age"` -} - // IPInfo represents comprehensive IP information for the /ip endpoint type IPInfo struct { IP string `json:"ip"` diff --git a/internal/database/schema.sql b/internal/database/schema.sql index cc2f35c..6543c5b 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -107,9 +107,6 @@ CREATE TABLE IF NOT EXISTS live_routes_v4 ( as_path TEXT NOT NULL, -- JSON array next_hop TEXT NOT NULL, last_updated DATETIME NOT NULL, - -- IPv4 range columns for fast lookups - ip_start INTEGER NOT NULL, -- Start of IPv4 range as 32-bit unsigned int - ip_end INTEGER NOT NULL, -- End of IPv4 range as 32-bit unsigned int UNIQUE(prefix, origin_asn, peer_ip) ); @@ -123,7 +120,6 @@ CREATE TABLE IF NOT EXISTS live_routes_v6 ( as_path TEXT NOT NULL, -- JSON array next_hop TEXT NOT NULL, last_updated DATETIME NOT NULL, - -- Note: IPv6 doesn't use integer range columns UNIQUE(prefix, origin_asn, peer_ip) ); @@ -132,8 +128,6 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_v4_prefix ON live_routes_v4(prefix); CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_length ON live_routes_v4(mask_length); CREATE INDEX IF NOT EXISTS idx_live_routes_v4_origin_asn ON live_routes_v4(origin_asn); CREATE INDEX IF NOT EXISTS idx_live_routes_v4_last_updated ON live_routes_v4(last_updated); --- Indexes for IPv4 range queries -CREATE INDEX IF NOT EXISTS idx_live_routes_v4_ip_range ON live_routes_v4(ip_start, ip_end); -- Index to optimize prefix distribution queries CREATE INDEX IF NOT EXISTS idx_live_routes_v4_mask_prefix ON live_routes_v4(mask_length, prefix); diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index 56efc06..d53c9fe 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -232,25 +232,6 @@ func (m *mockStore) GetLiveRouteCountsContext(ctx context.Context) (ipv4Count, i return m.GetLiveRouteCounts() } -// GetASInfoForIP mock implementation -func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) { - // Simple mock - return a test AS - now := time.Now() - return &database.ASInfo{ - ASN: 15169, - Handle: "GOOGLE", - Description: "Google LLC", - Prefix: "8.8.8.0/24", - LastUpdated: now.Add(-5 * time.Minute), - Age: "5m0s", - }, nil -} - -// GetASInfoForIPContext mock implementation with context support -func (m *mockStore) GetASInfoForIPContext(ctx context.Context, ip string) (*database.ASInfo, error) { - return m.GetASInfoForIP(ip) -} - // GetASDetails mock implementation func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) { m.mu.Lock() diff --git a/internal/routewatch/prefixhandler.go b/internal/routewatch/prefixhandler.go index 9ce5675..c8f6974 100644 --- a/internal/routewatch/prefixhandler.go +++ b/internal/routewatch/prefixhandler.go @@ -2,6 +2,7 @@ package routewatch import ( "net" + "net/netip" "strings" "sync" "time" @@ -108,7 +109,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { for _, announcement := range msg.Announcements { for _, prefix := range announcement.Prefixes { h.batch = append(h.batch, prefixUpdate{ - prefix: prefix, + prefix: canonicalPrefix(prefix), originASN: originASN, peer: msg.Peer, messageType: "announcement", @@ -125,7 +126,7 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) { // Process withdrawals for _, prefix := range msg.Withdrawals { h.batch = append(h.batch, prefixUpdate{ - prefix: prefix, + prefix: canonicalPrefix(prefix), originASN: originASN, // Use the originASN from path if available peer: msg.Peer, messageType: "withdrawal", @@ -264,6 +265,21 @@ func (h *PrefixHandler) flushBatchLocked() { h.lastFlush = time.Now() } +// canonicalPrefix returns prefix in the text form net/netip prints, the form +// the IP lookup builds when it looks a prefix up. The feed sends IPv6 +// announcements compressed ("2001:db8::/32") but IPv6 withdrawals uncompressed +// ("2001:db8:0:0:0:0:0:0/32"); stored as received, a withdrawal would not match +// the route its announcement stored. A prefix that does not parse is returned +// unchanged, and the batch flush reports it. +func canonicalPrefix(prefix string) string { + p, err := netip.ParsePrefix(prefix) + if err != nil { + return prefix + } + + return p.Masked().String() +} + // parseCIDR extracts the mask length and IP version from a prefix string func parseCIDR(prefix string) (maskLength int, ipVersion int, err error) { _, ipNet, err := net.ParseCIDR(prefix) @@ -315,20 +331,6 @@ func (h *PrefixHandler) processAnnouncement(_ *database.Prefix, update prefixUpd LastUpdated: update.timestamp, } - // For IPv4, calculate the IP range - if ipVersion == ipv4Version { - start, end, err := database.CalculateIPv4Range(update.prefix) - if err == nil { - liveRoute.V4IPStart = &start - liveRoute.V4IPEnd = &end - } else { - h.logger.Error("Failed to calculate IPv4 range", - "prefix", update.prefix, - "error", err, - ) - } - } - if err := h.db.UpsertLiveRoute(liveRoute); err != nil { h.logger.Error("Failed to upsert live route", "prefix", update.prefix, @@ -372,20 +374,6 @@ func (h *PrefixHandler) createLiveRoute(update prefixUpdate) *database.LiveRoute LastUpdated: update.timestamp, } - // For IPv4, calculate the IP range - if ipVersion == ipv4Version { - start, end, err := database.CalculateIPv4Range(update.prefix) - if err == nil { - liveRoute.V4IPStart = &start - liveRoute.V4IPEnd = &end - } else { - h.logger.Error("Failed to calculate IPv4 range", - "prefix", update.prefix, - "error", err, - ) - } - } - return liveRoute } @@ -425,20 +413,6 @@ func (h *PrefixHandler) processAnnouncementDirect(update prefixUpdate) { LastUpdated: update.timestamp, } - // For IPv4, calculate the IP range - if ipVersion == ipv4Version { - start, end, err := database.CalculateIPv4Range(update.prefix) - if err == nil { - liveRoute.V4IPStart = &start - liveRoute.V4IPEnd = &end - } else { - h.logger.Error("Failed to calculate IPv4 range", - "prefix", update.prefix, - "error", err, - ) - } - } - if err := h.db.UpsertLiveRoute(liveRoute); err != nil { h.logger.Error("Failed to upsert live route", "prefix", update.prefix, diff --git a/internal/routewatch/prefixhandler_test.go b/internal/routewatch/prefixhandler_test.go new file mode 100644 index 0000000..1968f94 --- /dev/null +++ b/internal/routewatch/prefixhandler_test.go @@ -0,0 +1,74 @@ +package routewatch + +import ( + "errors" + "testing" + "time" + + "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/ristypes" +) + +const testPeerIP = "2001:db8:ffff::1" + +// TestPrefixHandlerStoresPrefixesTheIPLookupFinds runs announcements and +// withdrawals through the prefix handler into a real database and looks the +// addresses up. The prefixes are written the way the feed sends them: IPv6 +// announcements compressed, IPv6 withdrawals uncompressed. The withdrawal must +// remove the route the announcement stored, and the IP lookup must find the +// stored prefix. +func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) { + db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) + if err != nil { + t.Fatalf("failed to create database: %v", err) + } + defer func() { _ = db.Close() }() + + // Built without NewPrefixHandler's flush timer, so the test flushes itself. + h := &PrefixHandler{db: db, logger: logger.New()} + flush := func() { + h.mu.Lock() + defer h.mu.Unlock() + h.flushBatchLocked() + } + + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + h.HandleMessage(&ristypes.RISMessage{ + Peer: testPeerIP, + Path: ristypes.ASPath{testASNA, testASNB}, + ParsedTimestamp: ts, + Announcements: []ristypes.RISAnnouncement{{ + NextHop: testPeerIP, + Prefixes: []string{"2001:db8:1::/48", "192.0.2.0/24"}, + }}, + }) + flush() + + for ip, want := range map[string]string{ + "2001:db8:1::1": "2001:db8:1::/48", + "192.0.2.1": "192.0.2.0/24", + } { + info, err := db.GetIPInfo(ip) + if err != nil { + t.Fatalf("GetIPInfo(%s) after announcement: %v", ip, err) + } + if info.Netblock != want || info.ASN != testASNB { + t.Errorf("GetIPInfo(%s) = %s AS%d, want %s AS%d", ip, info.Netblock, info.ASN, want, testASNB) + } + } + + h.HandleMessage(&ristypes.RISMessage{ + Peer: testPeerIP, + ParsedTimestamp: ts.Add(time.Minute), + Withdrawals: []string{"2001:db8:1:0:0:0:0:0/48", "192.0.2.0/24"}, + }) + flush() + + for _, ip := range []string{"2001:db8:1::1", "192.0.2.1"} { + if info, err := db.GetIPInfo(ip); !errors.Is(err, database.ErrNoRoute) { + t.Errorf("GetIPInfo(%s) after withdrawal = %+v, %v; want ErrNoRoute", ip, info, err) + } + } +}