diff --git a/internal/database/database.go b/internal/database/database.go index d66f427..a03a3b9 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -5,6 +5,7 @@ import ( "database/sql" _ "embed" "encoding/json" + "errors" "fmt" "net" "os" @@ -31,6 +32,14 @@ const ( maxIPv4 = 0xFFFFFFFF ) +// Common errors +var ( + // ErrInvalidIP is returned when an IP address is malformed + ErrInvalidIP = errors.New("invalid IP address") + // ErrNoRoute is returned when no route is found for an IP + ErrNoRoute = errors.New("no route found") +) + // Database manages the SQLite database connection and operations. type Database struct { db *sql.DB @@ -574,7 +583,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { // Parse the IP to validate it parsedIP := net.ParseIP(ip) if parsedIP == nil { - return nil, fmt.Errorf("invalid IP address: %s", ip) + return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip) } // Determine IP version @@ -590,7 +599,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { ipUint := ipToUint32(ipv4) query := ` - SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, a.handle, a.description + SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, a.handle, a.description FROM live_routes lr LEFT JOIN asns a ON a.number = lr.origin_asn WHERE lr.ip_version = ? AND lr.v4_ip_start <= ? AND lr.v4_ip_end >= ? @@ -600,28 +609,34 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { var prefix string var maskLength, originASN int + var lastUpdated time.Time var handle, description sql.NullString - err := d.db.QueryRow(query, ipVersionV4, ipUint, ipUint).Scan(&prefix, &maskLength, &originASN, &handle, &description) + err := d.db.QueryRow(query, ipVersionV4, ipUint, ipUint).Scan( + &prefix, &maskLength, &originASN, &lastUpdated, &handle, &description) if err != nil { if err == sql.ErrNoRows { - return nil, fmt.Errorf("no route found for IP %s", ip) + 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, a.handle, a.description + SELECT DISTINCT lr.prefix, lr.mask_length, lr.origin_asn, lr.last_updated, a.handle, a.description FROM live_routes lr LEFT JOIN asns a ON a.number = lr.origin_asn WHERE lr.ip_version = ? @@ -639,6 +654,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { prefix string maskLength int originASN int + lastUpdated time.Time handle sql.NullString description sql.NullString } @@ -647,9 +663,10 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { 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, &handle, &description); err != nil { + if err := rows.Scan(&prefix, &maskLength, &originASN, &lastUpdated, &handle, &description); err != nil { continue } @@ -664,6 +681,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { bestMatch.prefix = prefix bestMatch.maskLength = maskLength bestMatch.originASN = originASN + bestMatch.lastUpdated = lastUpdated bestMatch.handle = handle bestMatch.description = description bestMaskLength = maskLength @@ -671,14 +689,18 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) { } if bestMaskLength == -1 { - return nil, fmt.Errorf("no route found for IP %s", ip) + 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 } diff --git a/internal/database/models.go b/internal/database/models.go index ff04f60..a37d56e 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -70,8 +70,10 @@ type PrefixDistribution struct { // ASInfo represents AS information for an IP lookup type ASInfo struct { - ASN int `json:"asn"` - Handle string `json:"handle"` - Description string `json:"description"` - Prefix string `json:"prefix"` + 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"` } diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index dd79558..5446d17 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -190,11 +190,14 @@ func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) { // 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 } diff --git a/internal/server/server.go b/internal/server/server.go index 7641935..e73f8bf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,7 @@ package server import ( "context" "encoding/json" + "errors" "net/http" "os" "runtime" @@ -439,7 +440,13 @@ func (s *Server) handleIPLookup() http.HandlerFunc { // Look up AS information for the IP asInfo, err := s.db.GetASInfoForIP(ip) if err != nil { - writeJSONError(w, http.StatusNotFound, err.Error()) + // Check if it's an invalid IP error + if errors.Is(err, database.ErrInvalidIP) { + writeJSONError(w, http.StatusBadRequest, err.Error()) + } else { + // All other errors (including ErrNoRoute) are 404 + writeJSONError(w, http.StatusNotFound, err.Error()) + } return }