Remove the per-peer route tables (closes #122)
check / check (push) Waiting to run

The routing table in memory now holds every route, so the two per-peer route
tables go, with every write, delete, count and read that served them. The
prefix handler applies each update to the table, writes the table's changes
every second, and stores each new prefix with its first-seen time. The peer
delete, its retry on the next flush and the updates held for it go too. The
types and errors of the server's answers move to internal/server.

The image's memory settings fit a full table of about 1 GB: GOMEMLIMIT of
2.5 GiB, a 512 MiB page cache for the writing connection, and SQLite heap
limits of 768 MiB and 1 GiB. The README describes the new table, its
database copy and the memory budget.

Model: opus-5-5
This commit is contained in:
2026-10-27 16:10:27 +00:00
parent 506e289a63
commit 697f1ffd3e
23 changed files with 701 additions and 5273 deletions
+94 -94
View File
@@ -83,7 +83,7 @@ const (
maxASSearchResults = 50
)
// IP versions as database.LiveRoute.IPVersion records them.
// IP versions as LiveRoute.IPVersion records them.
const (
ipVersionV4 = 4
ipVersionV6 = 6
@@ -233,34 +233,34 @@ type WHOISStatsInfo struct {
//
//nolint:tagliatelle // field names are the existing JSON API
type Stats struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
// carry but whose kept routes were all withdrawn.
@@ -394,12 +394,12 @@ func (s *Server) statusStats(
// prefixDistribution lists the mask lengths that have at least one prefix, in
// ascending order, with their prefix counts. countsByLength[n] is the number
// of prefixes of mask length n.
func prefixDistribution(countsByLength []int) []database.PrefixDistribution {
var distribution []database.PrefixDistribution
func prefixDistribution(countsByLength []int) []PrefixDistribution {
var distribution []PrefixDistribution
for maskLength, count := range countsByLength {
if count > 0 {
distribution = append(distribution, database.PrefixDistribution{
distribution = append(distribution, PrefixDistribution{
MaskLength: maskLength, Count: count,
})
}
@@ -508,39 +508,39 @@ type StreamStats struct {
//
//nolint:tagliatelle // field names are the existing JSON API
type StatsResponse struct {
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
ConnectionDuration string `json:"connection_duration"`
ReconnectCount uint64 `json:"reconnect_count"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
Uptime string `json:"uptime"`
TotalMessages uint64 `json:"total_messages"`
TotalBytes uint64 `json:"total_bytes"`
TotalWireBytes uint64 `json:"total_wire_bytes"`
MessagesPerSec float64 `json:"messages_per_sec"`
MbitsPerSec float64 `json:"mbits_per_sec"`
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
Connected bool `json:"connected"`
ConnectionDuration string `json:"connection_duration"`
ReconnectCount uint64 `json:"reconnect_count"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsage string `json:"memory_usage"`
GC GCStats `json:"gc"`
Stream StreamStats `json:"stream"`
ASNs int `json:"asns"`
Prefixes int `json:"prefixes"`
IPv4Prefixes int `json:"ipv4_prefixes"`
IPv6Prefixes int `json:"ipv6_prefixes"`
Peerings int `json:"peerings"`
Peers int `json:"peers"`
DatabaseSizeBytes int64 `json:"database_size_bytes"`
LiveRoutes int `json:"live_routes"`
IPv4Routes int `json:"ipv4_routes"`
IPv6Routes int `json:"ipv6_routes"`
OldestRoute *time.Time `json:"oldest_route,omitempty"`
NewestRoute *time.Time `json:"newest_route,omitempty"`
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
IPv4PrefixDistribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
@@ -753,7 +753,7 @@ type IPLookupResponse struct {
// fields. IP hides the IPInfo field of the same name, so that an address with
// no route has it too.
type IPLookupResult struct {
*database.IPInfo
*IPInfo
IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
@@ -813,10 +813,10 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
}
switch {
case errors.Is(err, database.ErrNoRoute):
case errors.Is(err, ErrNoRoute):
// The address still gets a result, without route fields.
ipInfo = nil
case errors.Is(err, database.ErrInvalidIP):
case errors.Is(err, ErrInvalidIP):
response.Errors = append(response.Errors, ip+": "+err.Error())
continue
@@ -860,15 +860,15 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
// route. The database gives the prefix's first-seen time and the AS's WHOIS
// details. It returns ErrNoRoute when no prefix holds the address, and
// ErrInvalidIP when ip does not parse.
func (s *Server) ipInfo(ctx context.Context, ip string) (*database.IPInfo, error) {
func (s *Server) ipInfo(ctx context.Context, ip string) (*IPInfo, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return nil, fmt.Errorf("%w: %s", database.ErrInvalidIP, ip)
return nil, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
}
summary, found := s.table.Lookup(addr)
if !found {
return nil, fmt.Errorf("%w for IP %s", database.ErrNoRoute, ip)
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
@@ -881,7 +881,7 @@ func (s *Server) ipInfo(ctx context.Context, ip string) (*database.IPInfo, error
return nil, err
}
info := &database.IPInfo{
info := &IPInfo{
IP: ip,
Netblock: summary.Prefix.String(),
MaskLength: summary.Prefix.Bits(),
@@ -1165,7 +1165,7 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
}
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
if errors.Is(err, ErrNoRoute) {
s.writeJSONError(w, http.StatusNotFound, err.Error())
} else {
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
@@ -1201,9 +1201,9 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
// routing table all lack.
func (s *Server) asDetails(
ctx context.Context, asn int,
) (*database.ASN, []database.LiveRoute, error) {
) (*database.ASN, []LiveRoute, error) {
if asn == ristypes.NoOrigin {
return nil, nil, fmt.Errorf("%w: AS%d", database.ErrNoRoute, asn)
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
asInfo, known, err := s.db.GetAS(ctx, asn)
@@ -1213,13 +1213,13 @@ func (s *Server) asDetails(
originated := s.table.OriginatedPrefixes(asn)
if !known && len(originated) == 0 {
return nil, nil, fmt.Errorf("%w: AS%d", database.ErrNoRoute, asn)
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
prefixes := make([]database.LiveRoute, 0, len(originated))
prefixes := make([]LiveRoute, 0, len(originated))
for _, p := range originated {
route := database.LiveRoute{
route := LiveRoute{
Prefix: p.Prefix.String(),
MaskLength: p.Prefix.Bits(),
IPVersion: ipVersionOf(p.Prefix),
@@ -1239,9 +1239,9 @@ func (s *Server) asDetails(
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither
// group is nil, so that a JSON answer holds [] for an empty one and not null.
func splitRoutesByIPVersion(
routes []database.LiveRoute,
) ([]database.LiveRoute, []database.LiveRoute) {
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
routes []LiveRoute,
) ([]LiveRoute, []LiveRoute) {
ipv4Routes, ipv6Routes := []LiveRoute{}, []LiveRoute{}
for _, route := range routes {
if route.IPVersion == ipVersionV4 {
@@ -1256,8 +1256,8 @@ func splitRoutesByIPVersion(
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
// routes with the same mask length by network address.
func sortRoutesByMaskLength(routes []database.LiveRoute) {
slices.SortFunc(routes, func(a, b database.LiveRoute) int {
func sortRoutesByMaskLength(routes []LiveRoute) {
slices.SortFunc(routes, func(a, b LiveRoute) int {
prefixA, _ := netip.ParsePrefix(a.Prefix)
prefixB, _ := netip.ParsePrefix(b.Prefix)
@@ -1294,7 +1294,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
origins := s.table.Origins(parsed)
if len(origins) == 0 {
s.writeJSONError(w, http.StatusNotFound,
database.ErrNoRoute.Error()+": "+prefix)
ErrNoRoute.Error()+": "+prefix)
return
}
@@ -1302,7 +1302,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
routes := prefixRoutes(parsed, origins)
// Group by origin AS
originMap := make(map[int][]database.LiveRoute)
originMap := make(map[int][]LiveRoute)
for _, route := range routes {
originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
}
@@ -1392,7 +1392,7 @@ func (s *Server) handleASDetail() http.HandlerFunc {
}
if err != nil {
if errors.Is(err, database.ErrNoRoute) {
if errors.Is(err, ErrNoRoute) {
http.Error(w, "AS not found", http.StatusNotFound)
} else {
s.logger.Error("Failed to get AS details", "error", err)
@@ -1425,8 +1425,8 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// Prepare template data
data := struct {
ASN *database.ASN
IPv4Prefixes []database.LiveRoute
IPv6Prefixes []database.LiveRoute
IPv4Prefixes []LiveRoute
IPv6Prefixes []LiveRoute
TotalCount int
IPv4Count int
IPv6Count int
@@ -1585,11 +1585,11 @@ func (s *Server) originASNs(
// them, since the routing table keeps only their addresses.
func prefixRoutes(
prefix netip.Prefix, origins []routetable.Origin,
) []database.LiveRoute {
var routes []database.LiveRoute
) []LiveRoute {
var routes []LiveRoute
for _, origin := range origins {
base := database.LiveRoute{
base := LiveRoute{
Prefix: prefix.String(),
MaskLength: prefix.Bits(),
IPVersion: ipVersionOf(prefix),
@@ -1630,14 +1630,14 @@ type ASPathEntry struct {
// EnhancedRoute is a live route with the handle of each AS in its path.
type EnhancedRoute struct {
database.LiveRoute
LiveRoute
ASPathWithHandle []ASPathEntry
}
// routesWithASPathHandles looks up the handle of each AS in the path of each
// route.
func routesWithASPathHandles(routes []database.LiveRoute) []EnhancedRoute {
func routesWithASPathHandles(routes []LiveRoute) []EnhancedRoute {
enhancedRoutes := make([]EnhancedRoute, len(routes))
for i, route := range routes {
@@ -1736,7 +1736,7 @@ func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerF
// EnhancedPrefix is a prefix on the prefix length page, with the origin AS
// that most peers carry it with, that AS's description, and its age.
type EnhancedPrefix struct {
database.LiveRoute
LiveRoute
OriginASDescription string
Age string
+12 -30
View File
@@ -158,8 +158,8 @@ type tableStats struct {
OldestRoute time.Time `json:"oldest_route"`
NewestRoute time.Time `json:"newest_route"`
IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
IPv4Distribution []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6Distribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
}
// TestStatsHandlersAnswerFromMemory checks that both stats handlers answer 200
@@ -211,8 +211,8 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
OriginsWithoutKeptRoute: 1,
OldestRoute: testRouteTime(0),
NewestRoute: testRouteTime(4),
IPv4Distribution: []database.PrefixDistribution{{MaskLength: 24, Count: 2}},
IPv6Distribution: []database.PrefixDistribution{{MaskLength: 32, Count: 1}},
IPv4Distribution: []PrefixDistribution{{MaskLength: 24, Count: 2}},
IPv6Distribution: []PrefixDistribution{{MaskLength: 32, Count: 1}},
}
for name, handler := range handlers {
@@ -422,27 +422,9 @@ func TestIPLookupsReadPercentEncodedAddresses(t *testing.T) {
}
}
// errASDetailsLookupFailed is what failingASDetailsDB answers every AS details
// lookup with.
var errASDetailsLookupFailed = errors.New("AS details lookup failed")
// failingASDetailsDB wraps a real store and fails every AS details lookup.
type failingASDetailsDB struct {
database.Store
}
func (failingASDetailsDB) GetASDetailsContext(
_ context.Context, _ int,
) (*database.ASN, []database.LiveRoute, error) {
return nil, nil, errASDetailsLookupFailed
}
// TestPrefixDetailShowsOriginNames checks that the prefix page shows each
// origin AS's handle and description without looking up the AS's details,
// which also read every route the AS originates. Before the fix the page did
// that lookup once per origin, so with it failing the names were missing
// (https://git.eeqj.de/sneak/routewatch/issues/67). The AS path column shows
// handles too, so the test looks for the origin list's "(handle)" form.
// origin AS's handle and description. The AS path column shows handles too, so
// the test looks for the origin list's "(handle)" form.
func TestPrefixDetailShowsOriginNames(t *testing.T) {
t.Parallel()
@@ -465,7 +447,7 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
announce(table, testPrefixV4, asn, testPeer(i+1), testRouteTime(0))
}
s := New(failingASDetailsDB{db}, table,
s := New(db, table,
streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
@@ -1716,9 +1698,9 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
ASN database.ASN `json:"asn"`
IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"`
IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"`
ASN database.ASN `json:"asn"`
IPv4Prefixes []LiveRoute `json:"ipv4_prefixes"`
IPv6Prefixes []LiveRoute `json:"ipv6_prefixes"`
} `json:"data"`
}
@@ -1863,9 +1845,9 @@ func TestSortRoutesByMaskLength(t *testing.T) {
}
for _, tt := range tests {
routes := make([]database.LiveRoute, 0, len(tt.prefixes))
routes := make([]LiveRoute, 0, len(tt.prefixes))
for _, prefix := range tt.prefixes {
routes = append(routes, database.LiveRoute{Prefix: prefix})
routes = append(routes, LiveRoute{Prefix: prefix})
}
sortRoutesByMaskLength(routes)
@@ -11,6 +11,7 @@ import (
"time"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
)
@@ -134,6 +135,34 @@ func TestIPLookupGivesTheOriginMostPeersCarry(t *testing.T) {
}
}
// TestIPLookupAsksForNoWHOISLookupOfOriginZero checks that the IP lookup asks
// for no WHOIS lookup of AS 0, which means no known origin. It asks for one of
// an AS whose WHOIS details were never fetched or are stale, and not of one
// whose details are fresh.
func TestIPLookupAsksForNoWHOISLookupOfOriginZero(t *testing.T) {
t.Parallel()
fresh := time.Now().Add(-time.Hour)
stale := time.Now().Add(-2 * whoisStaleThreshold)
tests := []struct {
name string
asInfo database.ASN
want bool
}{
{"no known origin", database.ASN{ASN: ristypes.NoOrigin}, false},
{"details never fetched", database.ASN{ASN: mostPeersASN}, true},
{"stale details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &stale}, true},
{"fresh details", database.ASN{ASN: mostPeersASN, WHOISUpdatedAt: &fresh}, false},
}
for _, tt := range tests {
if got := needsWHOISRefresh(&tt.asInfo); got != tt.want {
t.Errorf("%s: needsWHOISRefresh = %t, want %t", tt.name, got, tt.want)
}
}
}
// prefixRoute is one route of the prefix API's answer. A field the answer
// leaves out stays nil.
//
+67
View File
@@ -0,0 +1,67 @@
package server
import (
"errors"
"time"
)
var (
// ErrInvalidIP says that an IP address is malformed. The IP lookup returns
// it.
ErrInvalidIP = errors.New("invalid IP address")
// ErrNoRoute says that the routing table has no route for an address, a
// prefix or an AS. The lookups return it.
ErrNoRoute = errors.New("no route found")
)
// LiveRoute represents a route in the live routing table. JSON leaves out an
// empty AS path, next hop or time: the routing table in memory keeps them only
// for the two newest routes of each prefix and origin AS.
//
//nolint:tagliatelle // field names are the existing JSON API
type LiveRoute struct {
Prefix string `json:"prefix"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
OriginASN int `json:"origin_asn"`
PeerIP string `json:"peer_ip"`
ASPath []int `json:"as_path,omitempty"`
NextHop string `json:"next_hop,omitempty"`
LastUpdated time.Time `json:"last_updated,omitzero"`
}
// PrefixDistribution represents the distribution of prefixes by mask length
//
//nolint:tagliatelle // field names are the existing JSON API
type PrefixDistribution struct {
MaskLength int `json:"mask_length"`
Count int `json:"count"`
}
// IPInfo represents comprehensive IP information for the /ip endpoint
//
//nolint:tagliatelle // field names are the existing JSON API
type IPInfo struct {
IP string `json:"ip"`
Netblock string `json:"netblock"`
MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"`
NumPeers int `json:"num_peers"`
// AS information
ASN int `json:"asn"`
ASName string `json:"as_name,omitempty"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"`
OrgName string `json:"org_name,omitempty"`
OrgID string `json:"org_id,omitempty"`
Address string `json:"address,omitempty"`
CountryCode string `json:"country_code,omitempty"`
AbuseEmail string `json:"abuse_email,omitempty"`
RIR string `json:"rir,omitempty"`
// Timestamps. LastSeen is zero, and left out of JSON, when the prefix
// keeps no route.
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen,omitzero"`
// Indicates if WHOIS data needs refresh (not serialized)
NeedsWHOISRefresh bool `json:"-"`
}