Add route age information to IP lookup API

- Add last_updated timestamp and age fields to ASInfo
- Include route's last_updated time from live_routes table
- Calculate and display age as human-readable duration
- Update both IPv4 and IPv6 queries to fetch timestamp
- Fix error handling to return 400 for invalid IPs
This commit is contained in:
Jeffrey Paul 2025-07-28 03:44:19 +02:00
parent 691710bc7c
commit 2fc24bb937
4 changed files with 46 additions and 12 deletions

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
"os" "os"
@ -31,6 +32,14 @@ const (
maxIPv4 = 0xFFFFFFFF 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. // Database manages the SQLite database connection and operations.
type Database struct { type Database struct {
db *sql.DB db *sql.DB
@ -574,7 +583,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
// Parse the IP to validate it // Parse the IP to validate it
parsedIP := net.ParseIP(ip) parsedIP := net.ParseIP(ip)
if parsedIP == nil { if parsedIP == nil {
return nil, fmt.Errorf("invalid IP address: %s", ip) return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
} }
// Determine IP version // Determine IP version
@ -590,7 +599,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
ipUint := ipToUint32(ipv4) ipUint := ipToUint32(ipv4)
query := ` 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 FROM live_routes lr
LEFT JOIN asns a ON a.number = lr.origin_asn LEFT JOIN asns a ON a.number = lr.origin_asn
WHERE lr.ip_version = ? AND lr.v4_ip_start <= ? AND lr.v4_ip_end >= ? 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 prefix string
var maskLength, originASN int var maskLength, originASN int
var lastUpdated time.Time
var handle, description sql.NullString 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 != nil {
if err == sql.ErrNoRows { 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) return nil, fmt.Errorf("failed to query routes: %w", err)
} }
age := time.Since(lastUpdated).Round(time.Second).String()
return &ASInfo{ return &ASInfo{
ASN: originASN, ASN: originASN,
Handle: handle.String, Handle: handle.String,
Description: description.String, Description: description.String,
Prefix: prefix, Prefix: prefix,
LastUpdated: lastUpdated,
Age: age,
}, nil }, nil
} }
// For IPv6, use the original method since we don't have range optimization // For IPv6, use the original method since we don't have range optimization
query := ` 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 FROM live_routes lr
LEFT JOIN asns a ON a.number = lr.origin_asn LEFT JOIN asns a ON a.number = lr.origin_asn
WHERE lr.ip_version = ? WHERE lr.ip_version = ?
@ -639,6 +654,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
prefix string prefix string
maskLength int maskLength int
originASN int originASN int
lastUpdated time.Time
handle sql.NullString handle sql.NullString
description sql.NullString description sql.NullString
} }
@ -647,9 +663,10 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
for rows.Next() { for rows.Next() {
var prefix string var prefix string
var maskLength, originASN int var maskLength, originASN int
var lastUpdated time.Time
var handle, description sql.NullString 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 continue
} }
@ -664,6 +681,7 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
bestMatch.prefix = prefix bestMatch.prefix = prefix
bestMatch.maskLength = maskLength bestMatch.maskLength = maskLength
bestMatch.originASN = originASN bestMatch.originASN = originASN
bestMatch.lastUpdated = lastUpdated
bestMatch.handle = handle bestMatch.handle = handle
bestMatch.description = description bestMatch.description = description
bestMaskLength = maskLength bestMaskLength = maskLength
@ -671,14 +689,18 @@ func (d *Database) GetASInfoForIP(ip string) (*ASInfo, error) {
} }
if bestMaskLength == -1 { 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{ return &ASInfo{
ASN: bestMatch.originASN, ASN: bestMatch.originASN,
Handle: bestMatch.handle.String, Handle: bestMatch.handle.String,
Description: bestMatch.description.String, Description: bestMatch.description.String,
Prefix: bestMatch.prefix, Prefix: bestMatch.prefix,
LastUpdated: bestMatch.lastUpdated,
Age: age,
}, nil }, nil
} }

View File

@ -70,8 +70,10 @@ type PrefixDistribution struct {
// ASInfo represents AS information for an IP lookup // ASInfo represents AS information for an IP lookup
type ASInfo struct { type ASInfo struct {
ASN int `json:"asn"` ASN int `json:"asn"`
Handle string `json:"handle"` Handle string `json:"handle"`
Description string `json:"description"` Description string `json:"description"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
LastUpdated time.Time `json:"last_updated"`
Age string `json:"age"`
} }

View File

@ -190,11 +190,14 @@ func (m *mockStore) GetLiveRouteCounts() (ipv4Count, ipv6Count int, err error) {
// GetASInfoForIP mock implementation // GetASInfoForIP mock implementation
func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) { func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) {
// Simple mock - return a test AS // Simple mock - return a test AS
now := time.Now()
return &database.ASInfo{ return &database.ASInfo{
ASN: 15169, ASN: 15169,
Handle: "GOOGLE", Handle: "GOOGLE",
Description: "Google LLC", Description: "Google LLC",
Prefix: "8.8.8.0/24", Prefix: "8.8.8.0/24",
LastUpdated: now.Add(-5 * time.Minute),
Age: "5m0s",
}, nil }, nil
} }

View File

@ -4,6 +4,7 @@ package server
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
@ -439,7 +440,13 @@ func (s *Server) handleIPLookup() http.HandlerFunc {
// Look up AS information for the IP // Look up AS information for the IP
asInfo, err := s.db.GetASInfoForIP(ip) asInfo, err := s.db.GetASInfoForIP(ip)
if err != nil { 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 return
} }