check / check (push) Canceled after 0s
A value with a unit is now a number, and its field name ends in the unit, as the vendored API_POLICIES.md requires. @meta.execution_time becomes execution_time_ms. In the stats answers, uptime, connection_duration, memory_usage and whois_stats.current_interval become uptime_seconds, connection_duration_seconds, memory_usage_bytes and whois_stats.current_interval_seconds. connection_duration_seconds is null while the feed is not connected. The status page formats the numbers itself. REPO_POLICIES.md is re-vendored from the same sneak/prompts commit. Model: opus-5-5
1830 lines
56 KiB
Go
1830 lines
56 KiB
Go
package server
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
|
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
// statsContextTimeout is the timeout for stats API operations.
|
|
statsContextTimeout = 4 * time.Second
|
|
|
|
// healthCheckTimeout is the timeout for health check operations.
|
|
healthCheckTimeout = 2 * time.Second
|
|
)
|
|
|
|
// Limits of one IP lookup.
|
|
const (
|
|
// maxLookupTargets is the most addresses and host names an IP lookup may
|
|
// list, and the most addresses it may have once the names are resolved:
|
|
// each address costs two database reads and a PTR lookup.
|
|
maxLookupTargets = 10
|
|
|
|
// ptrLookupTimeout is each PTR lookup's own time limit, so that ten
|
|
// addresses whose PTR lookups never answer fit in the 30-second limit.
|
|
ptrLookupTimeout = 2 * time.Second
|
|
)
|
|
|
|
// The Cache-Control header of an IP lookup answer, such as
|
|
// "public, max-age=900".
|
|
const (
|
|
cacheControlHeader = "Cache-Control"
|
|
cacheControlPublic = "public"
|
|
cacheControlPrivate = "private"
|
|
cacheControlMaxAge = ", max-age="
|
|
)
|
|
|
|
// Keys of the JSON envelope the handlers and middleware write, and the status
|
|
// value of an error answer.
|
|
const (
|
|
jsonKeyStatus = "status"
|
|
jsonKeyError = "error"
|
|
jsonKeyMsg = "msg"
|
|
jsonKeyCode = "code"
|
|
jsonStatusError = "error"
|
|
)
|
|
|
|
// Error messages an answer sends, in place of the error behind it where there
|
|
// is one.
|
|
const (
|
|
invalidPrefixMessage = "Invalid prefix"
|
|
hostNotResolvedMessage = "could not resolve host name"
|
|
hostNotPublicMessage = "host name has no public address"
|
|
tooManyTargetsMessage = "Too many addresses or host names"
|
|
searchQueryMissingMessage = "Missing search query"
|
|
)
|
|
|
|
// The AS name search reads the text it looks for from the URL query parameter
|
|
// asSearchQueryParam, and answers with at most maxASSearchResults matches.
|
|
const (
|
|
asSearchQueryParam = "q"
|
|
maxASSearchResults = 50
|
|
)
|
|
|
|
// IP versions as LiveRoute.IPVersion records them.
|
|
const (
|
|
ipVersionV4 = 4
|
|
ipVersionV6 = 6
|
|
)
|
|
|
|
// HealthCheckResponse represents the health check response.
|
|
type HealthCheckResponse struct {
|
|
Status string `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
Checks map[string]string `json:"checks"`
|
|
}
|
|
|
|
// handleHealthCheck returns a handler that performs health checks.
|
|
// Returns 200 if healthy, 503 if any check fails.
|
|
// Uses lightweight checks to avoid timeout issues under load.
|
|
func (s *Server) handleHealthCheck() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout)
|
|
defer cancel()
|
|
|
|
checks := make(map[string]string)
|
|
healthy := true
|
|
|
|
// Check database connectivity with lightweight ping
|
|
err := s.db.Ping(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Health check database ping failed", "error", err)
|
|
|
|
checks["database"] = jsonStatusError
|
|
healthy = false
|
|
} else {
|
|
checks["database"] = "ok"
|
|
}
|
|
|
|
// Check streamer connection
|
|
metrics := s.streamer.GetMetrics()
|
|
if metrics.Connected {
|
|
checks["ris_live"] = "ok"
|
|
} else {
|
|
checks["ris_live"] = "disconnected"
|
|
healthy = false
|
|
}
|
|
|
|
// Build response
|
|
status := "ok"
|
|
if !healthy {
|
|
status = jsonStatusError
|
|
}
|
|
|
|
response := HealthCheckResponse{
|
|
Status: status,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Checks: checks,
|
|
}
|
|
|
|
if !healthy {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
|
|
err = writeJSONSuccess(w, response)
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode health check response", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleIndex returns a handler that serves the home page.
|
|
func (s *Server) handleIndex() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
tmpl := templates.IndexTemplate()
|
|
|
|
err := tmpl.Execute(w, indexPageData{
|
|
CSPNonce: cspNonce(r.Context()),
|
|
ClientIP: extractClientIP(r),
|
|
Cards: s.cards.Load(),
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("Failed to render index template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// pageData is what the pages that show no data are rendered with: the nonce
|
|
// their script and style blocks carry.
|
|
type pageData struct {
|
|
CSPNonce string
|
|
}
|
|
|
|
// indexPageData is what the home page is rendered with.
|
|
type indexPageData struct {
|
|
// CSPNonce is the nonce the page's script and style blocks carry.
|
|
CSPNonce string
|
|
// ClientIP is the visitor's address, which the IP information API card
|
|
// looks up: the address /ip/ would look up for the same request.
|
|
ClientIP string
|
|
// Cards is the copy of the cards that show figures of the routing table,
|
|
// or nil before the server has first worked them out.
|
|
Cards *homeCards
|
|
}
|
|
|
|
// writeJSONError writes a standardized JSON error response with the given
|
|
// status code and error message.
|
|
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
|
|
err := json.NewEncoder(w).Encode(map[string]any{
|
|
jsonKeyStatus: jsonStatusError,
|
|
jsonKeyError: map[string]any{
|
|
jsonKeyMsg: message,
|
|
jsonKeyCode: statusCode,
|
|
},
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode error response", "error", err)
|
|
}
|
|
}
|
|
|
|
// writeJSONSuccess writes a standardized JSON success response containing
|
|
// the provided data wrapped in a status envelope.
|
|
func writeJSONSuccess(w http.ResponseWriter, data any) error {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
return json.NewEncoder(w).Encode(map[string]any{
|
|
jsonKeyStatus: "ok",
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
// WHOISStatsInfo contains WHOIS fetcher statistics for the status page.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type WHOISStatsInfo struct {
|
|
TotalASNs int `json:"total_asns"`
|
|
FreshASNs int `json:"fresh_asns"`
|
|
StaleASNs int `json:"stale_asns"`
|
|
NeverFetched int `json:"never_fetched"`
|
|
SuccessesLastHour int `json:"successes_last_hour"`
|
|
ErrorsLastHour int `json:"errors_last_hour"`
|
|
ConsecutiveFails int `json:"consecutive_fails"`
|
|
FreshPercent float64 `json:"fresh_percent"`
|
|
|
|
// CurrentIntervalSeconds is the wait between two fetches. It can have a
|
|
// fractional part, such as 22.5.
|
|
CurrentIntervalSeconds float64 `json:"current_interval_seconds"`
|
|
}
|
|
|
|
// Stats is the /status.json answer: uptime, message counts, database stats,
|
|
// and the routing table's figures. UptimeSeconds is the time since the stream
|
|
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go
|
|
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
|
|
// newest kept routes.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type Stats struct {
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
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"`
|
|
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
|
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.
|
|
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
|
}
|
|
|
|
// databaseStats gets the database stats, giving up when ctx is done; it then
|
|
// returns ctx's error. The channels are buffered so the goroutine's send never
|
|
// blocks if the timeout wins and nothing here receives; otherwise it would
|
|
// block forever and leak.
|
|
func (s *Server) databaseStats(ctx context.Context) (database.Stats, error) {
|
|
statsChan := make(chan database.Stats, 1)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
dbStats, err := s.db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
s.logger.Debug("Database stats query failed", "error", err)
|
|
|
|
errChan <- err
|
|
|
|
return
|
|
}
|
|
|
|
statsChan <- dbStats
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return database.Stats{}, ctx.Err()
|
|
case err := <-errChan:
|
|
return database.Stats{}, err
|
|
case dbStats := <-statsChan:
|
|
return dbStats, nil
|
|
}
|
|
}
|
|
|
|
// handleStatusJSON returns a handler that serves JSON statistics including
|
|
// uptime, message counts, database stats, and route information.
|
|
func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Create a 4 second timeout context for this request
|
|
ctx, cancel := context.WithTimeout(r.Context(), statsContextTimeout)
|
|
defer cancel()
|
|
|
|
metrics := s.streamer.GetMetrics()
|
|
|
|
dbStats, err := s.databaseStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.logger.Error("Database stats timeout in status.json")
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get database stats", "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
// Get WHOIS stats if fetcher is available
|
|
var whoisStats *WHOISStatsInfo
|
|
if s.asnFetcher != nil {
|
|
whoisStats = s.getWHOISStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
err = writeJSONSuccess(w, s.statusStats(metrics, dbStats, whoisStats))
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode stats", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// statusStats builds the /status.json answer from the stream metrics, the
|
|
// database stats, the routing table's figures and the WHOIS stats, with the
|
|
// route update rates and memory use as they are now.
|
|
func (s *Server) statusStats(
|
|
streamMetrics metrics.StreamMetrics,
|
|
dbStats database.Stats,
|
|
whoisStats *WHOISStatsInfo,
|
|
) Stats {
|
|
const bitsPerMegabit = 1000000.0
|
|
|
|
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
|
|
counts := s.table.Counts()
|
|
|
|
var memStats runtime.MemStats
|
|
runtime.ReadMemStats(&memStats)
|
|
|
|
return Stats{
|
|
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
|
TotalMessages: streamMetrics.TotalMessages,
|
|
TotalBytes: streamMetrics.TotalBytes,
|
|
TotalWireBytes: streamMetrics.TotalWireBytes,
|
|
MessagesPerSec: streamMetrics.MessagesPerSec,
|
|
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
|
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
|
Connected: streamMetrics.Connected,
|
|
GoVersion: runtime.Version(),
|
|
Goroutines: runtime.NumGoroutine(),
|
|
MemoryUsageBytes: memStats.Alloc,
|
|
ASNs: dbStats.ASNs,
|
|
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
|
|
IPv4Prefixes: counts.IPv4Prefixes,
|
|
IPv6Prefixes: counts.IPv6Prefixes,
|
|
Peerings: dbStats.Peerings,
|
|
Peers: dbStats.Peers,
|
|
DatabaseSizeBytes: dbStats.FileSizeBytes,
|
|
LiveRoutes: counts.IPv4Routes + counts.IPv6Routes,
|
|
IPv4Routes: counts.IPv4Routes,
|
|
IPv6Routes: counts.IPv6Routes,
|
|
OldestRoute: counts.OldestKeptRoute,
|
|
NewestRoute: counts.NewestKeptRoute,
|
|
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
|
|
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
|
|
IPv4PrefixDistribution: prefixDistribution(counts.IPv4PrefixesByLength),
|
|
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
|
|
WHOISStats: whoisStats,
|
|
|
|
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
|
|
}
|
|
}
|
|
|
|
// 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) []PrefixDistribution {
|
|
var distribution []PrefixDistribution
|
|
|
|
for maskLength, count := range countsByLength {
|
|
if count > 0 {
|
|
distribution = append(distribution, PrefixDistribution{
|
|
MaskLength: maskLength, Count: count,
|
|
})
|
|
}
|
|
}
|
|
|
|
return distribution
|
|
}
|
|
|
|
// getWHOISStats builds WHOIS statistics from database and fetcher.
|
|
func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
|
|
// Get database WHOIS stats
|
|
dbStats, err := s.db.GetWHOISStats(ctx, whoisStaleThreshold)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to get WHOIS stats", "error", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get fetcher stats
|
|
fetcherStats := s.asnFetcher.GetStats()
|
|
|
|
// Calculate fresh percentage
|
|
var freshPercent float64
|
|
if dbStats.TotalASNs > 0 {
|
|
freshPercent = float64(dbStats.FreshASNs) / float64(dbStats.TotalASNs) *
|
|
percentMultiplier
|
|
}
|
|
|
|
return &WHOISStatsInfo{
|
|
TotalASNs: dbStats.TotalASNs,
|
|
FreshASNs: dbStats.FreshASNs,
|
|
StaleASNs: dbStats.StaleASNs,
|
|
NeverFetched: dbStats.NeverFetched,
|
|
SuccessesLastHour: fetcherStats.SuccessesLastHour,
|
|
ErrorsLastHour: fetcherStats.ErrorsLastHour,
|
|
ConsecutiveFails: fetcherStats.ConsecutiveFails,
|
|
FreshPercent: freshPercent,
|
|
|
|
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
|
|
}
|
|
}
|
|
|
|
// uptimeSeconds is the number of whole seconds since the stream last
|
|
// connected, or 0 if it has never connected.
|
|
func uptimeSeconds(connectedSince time.Time) int64 {
|
|
if connectedSince.IsZero() {
|
|
return 0
|
|
}
|
|
|
|
return int64(time.Since(connectedSince) / time.Second)
|
|
}
|
|
|
|
// connectionDurationSeconds is the number of whole seconds the stream has
|
|
// been connected, or nil while it is not connected.
|
|
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 {
|
|
if !connected || connectedSince.IsZero() {
|
|
return nil
|
|
}
|
|
|
|
return new(uptimeSeconds(connectedSince))
|
|
}
|
|
|
|
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
|
const whoisStaleThreshold = 30 * 24 * time.Hour
|
|
|
|
// percentMultiplier converts a ratio to a percentage.
|
|
const percentMultiplier = 100
|
|
|
|
// HandlerStatsInfo represents handler statistics in the API response
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type HandlerStatsInfo struct {
|
|
Name string `json:"name"`
|
|
QueueLength int `json:"queue_length"`
|
|
QueueCapacity int `json:"queue_capacity"`
|
|
QueueHighWaterMark int `json:"queue_high_water_mark"`
|
|
ProcessedCount uint64 `json:"processed_count"`
|
|
DroppedCount uint64 `json:"dropped_count"`
|
|
AvgProcessTimeMs float64 `json:"avg_process_time_ms"`
|
|
MinProcessTimeMs float64 `json:"min_process_time_ms"`
|
|
MaxProcessTimeMs float64 `json:"max_process_time_ms"`
|
|
}
|
|
|
|
// GCStats represents garbage collection statistics
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type GCStats struct {
|
|
NumGC uint32 `json:"num_gc"`
|
|
TotalPauseMs uint64 `json:"total_pause_ms"`
|
|
LastPauseMs float64 `json:"last_pause_ms"`
|
|
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
|
|
HeapSysBytes uint64 `json:"heap_sys_bytes"`
|
|
}
|
|
|
|
// StreamStats represents stream statistics including announcements/withdrawals
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type StreamStats struct {
|
|
Announcements uint64 `json:"announcements"`
|
|
Withdrawals uint64 `json:"withdrawals"`
|
|
RouteChurnPerMin int `json:"route_churn_per_min"`
|
|
BGPPeerCount int `json:"bgp_peer_count"`
|
|
}
|
|
|
|
// StatsResponse represents the API statistics response. The fields it shares
|
|
// with Stats mean what they mean there.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type StatsResponse struct {
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
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"`
|
|
ReconnectCount uint64 `json:"reconnect_count"`
|
|
GoVersion string `json:"go_version"`
|
|
Goroutines int `json:"goroutines"`
|
|
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
|
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"`
|
|
|
|
// ConnectionDurationSeconds is null while the stream is not connected.
|
|
ConnectionDurationSeconds *int64 `json:"connection_duration_seconds"`
|
|
|
|
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
|
|
}
|
|
|
|
// handlerStatsInfo converts the streamer's handler statistics for the API
|
|
// response.
|
|
func handlerStatsInfo(handlerStats []streamer.HandlerStats) []HandlerStatsInfo {
|
|
const microsecondsPerMillisecond = 1000.0
|
|
|
|
handlerStatsInfo := make([]HandlerStatsInfo, 0, len(handlerStats))
|
|
for _, hs := range handlerStats {
|
|
handlerStatsInfo = append(handlerStatsInfo, HandlerStatsInfo{
|
|
Name: hs.Name,
|
|
QueueLength: hs.QueueLength,
|
|
QueueCapacity: hs.QueueCapacity,
|
|
QueueHighWaterMark: hs.QueueHighWaterMark,
|
|
ProcessedCount: hs.ProcessedCount,
|
|
DroppedCount: hs.DroppedCount,
|
|
AvgProcessTimeMs: float64(hs.AvgProcessTime.Microseconds()) /
|
|
microsecondsPerMillisecond,
|
|
MinProcessTimeMs: float64(hs.MinProcessTime.Microseconds()) /
|
|
microsecondsPerMillisecond,
|
|
MaxProcessTimeMs: float64(hs.MaxProcessTime.Microseconds()) /
|
|
microsecondsPerMillisecond,
|
|
})
|
|
}
|
|
|
|
return handlerStatsInfo
|
|
}
|
|
|
|
// gcStats reports the garbage collector's figures from memStats.
|
|
func gcStats(memStats *runtime.MemStats) GCStats {
|
|
const (
|
|
nanosecondsPerMillisecond = 1e6
|
|
gcPauseHistorySize = 256 // Size of runtime.MemStats.PauseNs circular buffer
|
|
)
|
|
|
|
// Calculate last GC pause
|
|
var lastPauseMs float64
|
|
|
|
if memStats.NumGC > 0 {
|
|
// PauseNs is a circular buffer, get the most recent pause
|
|
lastPauseIdx := (memStats.NumGC + gcPauseHistorySize - 1) % gcPauseHistorySize
|
|
lastPauseMs = float64(memStats.PauseNs[lastPauseIdx]) / nanosecondsPerMillisecond
|
|
}
|
|
|
|
return GCStats{
|
|
NumGC: memStats.NumGC,
|
|
TotalPauseMs: memStats.PauseTotalNs / uint64(nanosecondsPerMillisecond),
|
|
LastPauseMs: lastPauseMs,
|
|
HeapAllocBytes: memStats.HeapAlloc,
|
|
HeapSysBytes: memStats.HeapSys,
|
|
}
|
|
}
|
|
|
|
// streamStats reports the announcement, withdrawal and BGP peer figures of the
|
|
// streamer's metrics tracker.
|
|
func (s *Server) streamStats() StreamStats {
|
|
metricsTracker := s.streamer.GetMetricsTracker()
|
|
|
|
return StreamStats{
|
|
Announcements: metricsTracker.GetAnnouncementCount(),
|
|
Withdrawals: metricsTracker.GetWithdrawalCount(),
|
|
RouteChurnPerMin: metricsTracker.GetChurnPerMinute(),
|
|
BGPPeerCount: metricsTracker.GetBGPPeerCount(),
|
|
}
|
|
}
|
|
|
|
// handleStats returns a handler that serves API v1 statistics including
|
|
// detailed handler queue statistics and performance metrics.
|
|
func (s *Server) handleStats() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Create a 4 second timeout context for this request
|
|
ctx, cancel := context.WithTimeout(r.Context(), statsContextTimeout)
|
|
defer cancel()
|
|
|
|
// Check if context is already cancelled
|
|
select {
|
|
case <-ctx.Done():
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
default:
|
|
}
|
|
|
|
metrics := s.streamer.GetMetrics()
|
|
|
|
dbStats, err := s.databaseStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.logger.Error("Database stats timeout")
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get database stats", "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
// Get WHOIS stats if fetcher is available
|
|
var whoisStats *WHOISStatsInfo
|
|
if s.asnFetcher != nil {
|
|
whoisStats = s.getWHOISStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
err = writeJSONSuccess(w, s.statsResponse(metrics, dbStats, whoisStats))
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode stats", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// statsResponse builds the /api/v1/stats answer from the stream metrics, the
|
|
// database stats, the routing table's figures and the WHOIS stats, with the
|
|
// route update rates, memory use and handler queues as they are now.
|
|
func (s *Server) statsResponse(
|
|
streamMetrics metrics.StreamMetrics,
|
|
dbStats database.Stats,
|
|
whoisStats *WHOISStatsInfo,
|
|
) StatsResponse {
|
|
const bitsPerMegabit = 1000000.0
|
|
|
|
// Get route update metrics
|
|
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
|
|
counts := s.table.Counts()
|
|
|
|
// Get memory stats
|
|
var memStats runtime.MemStats
|
|
runtime.ReadMemStats(&memStats)
|
|
|
|
return StatsResponse{
|
|
UptimeSeconds: uptimeSeconds(streamMetrics.ConnectedSince),
|
|
TotalMessages: streamMetrics.TotalMessages,
|
|
TotalBytes: streamMetrics.TotalBytes,
|
|
TotalWireBytes: streamMetrics.TotalWireBytes,
|
|
MessagesPerSec: streamMetrics.MessagesPerSec,
|
|
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
|
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
|
Connected: streamMetrics.Connected,
|
|
ReconnectCount: streamMetrics.ReconnectCount,
|
|
GoVersion: runtime.Version(),
|
|
Goroutines: runtime.NumGoroutine(),
|
|
MemoryUsageBytes: memStats.Alloc,
|
|
GC: gcStats(&memStats),
|
|
Stream: s.streamStats(),
|
|
ASNs: dbStats.ASNs,
|
|
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
|
|
IPv4Prefixes: counts.IPv4Prefixes,
|
|
IPv6Prefixes: counts.IPv6Prefixes,
|
|
Peerings: dbStats.Peerings,
|
|
Peers: dbStats.Peers,
|
|
DatabaseSizeBytes: dbStats.FileSizeBytes,
|
|
LiveRoutes: counts.IPv4Routes + counts.IPv6Routes,
|
|
IPv4Routes: counts.IPv4Routes,
|
|
IPv6Routes: counts.IPv6Routes,
|
|
OldestRoute: counts.OldestKeptRoute,
|
|
NewestRoute: counts.NewestKeptRoute,
|
|
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
|
|
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
|
|
HandlerStats: handlerStatsInfo(s.streamer.GetHandlerStats()),
|
|
IPv4PrefixDistribution: prefixDistribution(counts.IPv4PrefixesByLength),
|
|
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
|
|
WHOISStats: whoisStats,
|
|
|
|
ConnectionDurationSeconds: connectionDurationSeconds(
|
|
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
|
|
|
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
|
|
}
|
|
}
|
|
|
|
// handleStatusHTML returns a handler that serves the HTML status page,
|
|
// which displays real-time statistics fetched via JavaScript.
|
|
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
tmpl := templates.StatusTemplate()
|
|
|
|
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
|
|
if err != nil {
|
|
s.logger.Error("Failed to render template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleIPLookup returns a handler that looks up AS information for an IP address
|
|
func (s *Server) handleIPLookup() http.HandlerFunc {
|
|
return s.handleIPInfo()
|
|
}
|
|
|
|
// IPLookupResponse is the standard response for IP/hostname lookups.
|
|
type IPLookupResponse struct {
|
|
Query string `json:"query"`
|
|
Results []IPLookupResult `json:"results"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// IPLookupResult is the answer for one address: its PTR names, its live route
|
|
// and that route's AS, and the blocklists that list it. Every result has every
|
|
// field. The fields of IPInfo are null when the address has no live route.
|
|
type IPLookupResult struct {
|
|
IPInfo
|
|
|
|
IP string `json:"ip"`
|
|
// PTR is empty, never nil, when the address has no PTR names.
|
|
PTR []string `json:"ptr"`
|
|
|
|
// Blocklists has one entry for each blocklist the address is on.
|
|
Blocklists []blocklist.Listing `json:"blocklists"`
|
|
}
|
|
|
|
// handleIPInfo returns a handler that provides comprehensive IP information.
|
|
// Used for /ip, /ip/{addr}, and /api/v1/ip/{ip} endpoints.
|
|
// Accepts IP addresses (single or comma-separated) and hostnames.
|
|
// Always returns the same response structure with PTR records for each IP.
|
|
func (s *Server) handleIPInfo() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
target, ownAddress := lookupTarget(r)
|
|
if target == "" {
|
|
s.writeJSONError(w, http.StatusBadRequest, "Could not determine IP address")
|
|
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
response := IPLookupResponse{Query: target, Results: make([]IPLookupResult, 0)}
|
|
|
|
// Collect all IPs to look up
|
|
ipsToLookup, resolveErrors, err := s.resolveLookupTargets(ctx, target)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.writeJSONError(w, http.StatusBadRequest, tooManyTargetsMessage)
|
|
|
|
return
|
|
}
|
|
|
|
response.Errors = append(response.Errors, resolveErrors...)
|
|
|
|
if len(ipsToLookup) == 0 {
|
|
s.writeJSONError(w, http.StatusBadRequest, noTargetsMessage(resolveErrors))
|
|
|
|
return
|
|
}
|
|
|
|
// Track ASNs that need WHOIS refresh
|
|
refreshASNs := make(map[int]bool)
|
|
|
|
// Look up each IP
|
|
for _, ip := range ipsToLookup {
|
|
ipInfo, err := s.ipInfo(ctx, ip)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, ErrNoRoute):
|
|
// The address still gets a result, with every field of
|
|
// ipInfo null.
|
|
case errors.Is(err, ErrInvalidIP):
|
|
response.Errors = append(response.Errors, ip+": "+err.Error())
|
|
|
|
continue
|
|
case err != nil:
|
|
s.logger.Error("Failed to look up IP", "ip", ip, "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
// Do PTR lookup for this IP
|
|
ptrs := s.lookupPTRNames(ctx, ip)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
response.Results = append(response.Results, IPLookupResult{
|
|
IP: ip,
|
|
PTR: ptrs,
|
|
IPInfo: ipInfo,
|
|
Blocklists: s.blocklistListings(ip),
|
|
})
|
|
|
|
if ipInfo.NeedsWHOISRefresh {
|
|
refreshASNs[*ipInfo.ASN] = true
|
|
}
|
|
}
|
|
|
|
// Queue WHOIS refresh for stale ASNs (non-blocking)
|
|
s.queueWHOISRefresh(refreshASNs)
|
|
|
|
s.writeIPLookupAnswer(w, response, ownAddress)
|
|
}
|
|
}
|
|
|
|
// ipInfo returns what the IP lookup knows about the route of ip. The routing
|
|
// table gives the most specific prefix that holds the address, the number of
|
|
// peers that carry it, its origin ASes and the one that most peers carry, and
|
|
// the time of its newest kept 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. With an
|
|
// error, every field of the IPInfo it returns is nil.
|
|
func (s *Server) ipInfo(ctx context.Context, ip string) (IPInfo, error) {
|
|
addr, err := netip.ParseAddr(ip)
|
|
if err != nil {
|
|
return IPInfo{}, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
|
|
}
|
|
|
|
summary, found := s.table.Lookup(addr)
|
|
if !found {
|
|
return IPInfo{}, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
|
|
}
|
|
|
|
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
|
|
if err != nil {
|
|
return IPInfo{}, err
|
|
}
|
|
|
|
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
|
|
if err != nil {
|
|
return IPInfo{}, err
|
|
}
|
|
|
|
info := IPInfo{
|
|
Netblock: new(summary.Prefix.String()),
|
|
MaskLength: new(summary.Prefix.Bits()),
|
|
IPVersion: new(ipVersionOf(summary.Prefix)),
|
|
NumPeers: new(summary.Peers),
|
|
ASN: new(summary.OriginASN),
|
|
NumOrigins: new(len(summary.OriginASNs)),
|
|
OriginASNs: summary.OriginASNs,
|
|
ASName: nilIfEmpty(asInfo.ASName),
|
|
Handle: nilIfEmpty(asInfo.Handle),
|
|
Description: nilIfEmpty(asInfo.Description),
|
|
OrgName: nilIfEmpty(asInfo.OrgName),
|
|
OrgID: nilIfEmpty(asInfo.OrgID),
|
|
Address: nilIfEmpty(asInfo.Address),
|
|
CountryCode: nilIfEmpty(asInfo.CountryCode),
|
|
AbuseEmail: nilIfEmpty(asInfo.AbuseEmail),
|
|
RIR: nilIfEmpty(asInfo.RIR),
|
|
LastSeen: summary.Newest,
|
|
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
|
|
}
|
|
|
|
if !firstSeen.IsZero() {
|
|
info.FirstSeen = &firstSeen
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// nilIfEmpty returns a pointer to s, or nil when s is empty, so that JSON
|
|
// gives a detail the database does not hold as null.
|
|
func nilIfEmpty(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
|
|
return &s
|
|
}
|
|
|
|
// needsWHOISRefresh says whether the WHOIS details of asInfo were never
|
|
// fetched or are older than whoisStaleThreshold. AS 0, no known origin, has
|
|
// none to fetch.
|
|
func needsWHOISRefresh(asInfo *database.ASN) bool {
|
|
if asInfo.ASN == ristypes.NoOrigin {
|
|
return false
|
|
}
|
|
|
|
return asInfo.WHOISUpdatedAt == nil ||
|
|
time.Since(*asInfo.WHOISUpdatedAt) > whoisStaleThreshold
|
|
}
|
|
|
|
// ipVersionOf returns 4 or 6, the IP version of prefix.
|
|
func ipVersionOf(prefix netip.Prefix) int {
|
|
if prefix.Addr().Is4() {
|
|
return ipVersionV4
|
|
}
|
|
|
|
return ipVersionV6
|
|
}
|
|
|
|
// blocklistListings returns the blocklists that list ip. It returns an empty
|
|
// list, never nil, so that an answer's blocklists is [] rather than null.
|
|
func (s *Server) blocklistListings(ip string) []blocklist.Listing {
|
|
addr, err := netip.ParseAddr(ip)
|
|
if s.blocklists == nil || err != nil {
|
|
return []blocklist.Listing{}
|
|
}
|
|
|
|
return s.blocklists.Lookup(addr)
|
|
}
|
|
|
|
// setIPLookupCacheControl lets caches keep an IP lookup answer for the
|
|
// shortest blocklist interval, the soonest a blocklist copy can change. The
|
|
// answer about the caller's own address is private, so that a shared cache
|
|
// does not give it to another caller. Without blocklists it sets nothing.
|
|
func (s *Server) setIPLookupCacheControl(w http.ResponseWriter, ownAddress bool) {
|
|
if s.blocklists == nil {
|
|
return
|
|
}
|
|
|
|
visibility := cacheControlPublic
|
|
if ownAddress {
|
|
visibility = cacheControlPrivate
|
|
}
|
|
|
|
maxAgeSeconds := int(s.blocklists.ShortestInterval().Seconds())
|
|
w.Header().Set(cacheControlHeader,
|
|
visibility+cacheControlMaxAge+strconv.Itoa(maxAgeSeconds))
|
|
}
|
|
|
|
// noTargetsMessage returns the message of the 400 an IP lookup answers when
|
|
// no address is left to look up, followed by the first of resolveErrors, which
|
|
// says why, when there is one.
|
|
func noTargetsMessage(resolveErrors []string) string {
|
|
const message = "No valid IPs or hostnames provided"
|
|
|
|
if len(resolveErrors) == 0 {
|
|
return message
|
|
}
|
|
|
|
return message + ": " + resolveErrors[0]
|
|
}
|
|
|
|
// writeIPLookupAnswer sends response with its Cache-Control header.
|
|
// ownAddress says the request asked about the caller's own address.
|
|
func (s *Server) writeIPLookupAnswer(
|
|
w http.ResponseWriter, response IPLookupResponse, ownAddress bool,
|
|
) {
|
|
s.setIPLookupCacheControl(w, ownAddress)
|
|
|
|
err := writeJSONSuccess(w, response)
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode IP lookup response", "error", err)
|
|
}
|
|
}
|
|
|
|
// lookupTarget returns what an IP lookup request asks for: the ip or addr URL
|
|
// parameter, or else the client's own address. The bool is true for the
|
|
// client's own address.
|
|
func lookupTarget(r *http.Request) (string, bool) {
|
|
target := chi.URLParam(r, "ip")
|
|
if target == "" {
|
|
target = chi.URLParam(r, "addr")
|
|
}
|
|
|
|
if target == "" {
|
|
// Use client IP (RealIP middleware has already processed this)
|
|
return extractClientIP(r), true
|
|
}
|
|
|
|
return target, false
|
|
}
|
|
|
|
// queueWHOISRefresh asks the WHOIS fetcher, when there is one, to refresh
|
|
// each of asns now.
|
|
func (s *Server) queueWHOISRefresh(asns map[int]bool) {
|
|
if s.asnFetcher == nil {
|
|
return
|
|
}
|
|
|
|
for asn := range asns {
|
|
s.asnFetcher.QueueImmediate(asn)
|
|
}
|
|
}
|
|
|
|
// errTooManyTargets is what resolveLookupTargets returns for a list of more
|
|
// than maxLookupTargets addresses and host names, or of names that resolve to
|
|
// more than maxLookupTargets addresses.
|
|
var errTooManyTargets = errors.New("too many addresses or host names")
|
|
|
|
// splitLookupTargets returns the addresses and host names in a comma-separated
|
|
// list, leaving out blank entries, or errTooManyTargets when there are more
|
|
// than maxLookupTargets.
|
|
func splitLookupTargets(target string) ([]string, error) {
|
|
var targets []string
|
|
|
|
for t := range strings.SplitSeq(target, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
|
|
if len(targets) == maxLookupTargets {
|
|
return nil, errTooManyTargets
|
|
}
|
|
|
|
targets = append(targets, t)
|
|
}
|
|
|
|
return targets, nil
|
|
}
|
|
|
|
// resolveLookupTargets splits a comma-separated list of IP addresses and
|
|
// hostnames and returns the IP addresses to look up, resolving each hostname.
|
|
// A hostname that does not resolve is reported in the returned errors instead,
|
|
// with the resolver's own error only in the log, since it names the DNS server.
|
|
// Only the public addresses a hostname resolves to are kept, so the answer
|
|
// does not show the deployment's private addresses; a hostname with none is
|
|
// reported in the errors. More than maxLookupTargets entries, or addresses once
|
|
// resolved, return errTooManyTargets. It stops once ctx's deadline has passed;
|
|
// the caller then answers 504.
|
|
func (s *Server) resolveLookupTargets(
|
|
ctx context.Context, target string,
|
|
) ([]string, []string, error) {
|
|
targets, err := splitLookupTargets(target)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var ipsToLookup, resolveErrors []string
|
|
|
|
for _, t := range targets {
|
|
// Check if this target is an IP address
|
|
if net.ParseIP(t) != nil {
|
|
ipsToLookup = append(ipsToLookup, t)
|
|
|
|
continue
|
|
}
|
|
|
|
// It's a hostname - resolve it
|
|
resolved, err := s.resolver.LookupHost(ctx, t)
|
|
if deadlinePassed(ctx) {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Warn("Host name lookup failed", "host", t, "error", err)
|
|
resolveErrors = append(resolveErrors, t+": "+hostNotResolvedMessage)
|
|
|
|
continue
|
|
}
|
|
|
|
public := publicAddresses(resolved)
|
|
if len(public) == 0 {
|
|
resolveErrors = append(resolveErrors, t+": "+hostNotPublicMessage)
|
|
|
|
continue
|
|
}
|
|
|
|
ipsToLookup = append(ipsToLookup, public...)
|
|
}
|
|
|
|
if len(ipsToLookup) > maxLookupTargets {
|
|
return nil, nil, errTooManyTargets
|
|
}
|
|
|
|
return ipsToLookup, resolveErrors, nil
|
|
}
|
|
|
|
// publicAddresses returns the addresses in addrs that are global unicast and
|
|
// not private; Go counts the private ranges as global unicast.
|
|
func publicAddresses(addrs []string) []string {
|
|
var public []string
|
|
|
|
for _, a := range addrs {
|
|
ip, err := netip.ParseAddr(a)
|
|
if err == nil && ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
|
public = append(public, a)
|
|
}
|
|
}
|
|
|
|
return public
|
|
}
|
|
|
|
// lookupPTRNames returns the PTR records of ip without their trailing dots. It
|
|
// returns an empty list, never nil, when there are none, when the lookup fails
|
|
// and when it runs past ptrLookupTimeout, so that an answer's ptr is [] rather
|
|
// than null.
|
|
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
|
|
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout)
|
|
defer cancel()
|
|
|
|
ptrs, err := s.resolver.LookupAddr(ctx, ip)
|
|
if err != nil || ptrs == nil {
|
|
return []string{}
|
|
}
|
|
|
|
// Remove trailing dots from PTR records
|
|
for i, ptr := range ptrs {
|
|
ptrs[i] = strings.TrimSuffix(ptr, ".")
|
|
}
|
|
|
|
return ptrs
|
|
}
|
|
|
|
// extractClientIP extracts the client IP from the request.
|
|
// Works with RealIPMiddleware, which sets RemoteAddr.
|
|
func extractClientIP(r *http.Request) string {
|
|
// RemoteAddr is in the form "IP:port" or just "IP" for unix sockets
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
// Might be just an IP without port
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
// handleASDetailJSON returns AS details as JSON
|
|
func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
asnStr := chi.URLParam(r, "asn")
|
|
|
|
asn, err := strconv.Atoi(asnStr)
|
|
if err != nil {
|
|
s.writeJSONError(w, http.StatusBadRequest, "Invalid ASN")
|
|
|
|
return
|
|
}
|
|
|
|
asInfo, prefixes, err := s.asDetails(r.Context(), asn)
|
|
if deadlinePassed(r.Context()) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
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)
|
|
s.writeJSONError(w, http.StatusInternalServerError,
|
|
internalErrorMessage)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Group prefixes by IP version
|
|
ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes)
|
|
|
|
response := map[string]any{
|
|
"asn": asInfo,
|
|
"ipv4_prefixes": ipv4Prefixes,
|
|
"ipv6_prefixes": ipv6Prefixes,
|
|
"total_count": len(prefixes),
|
|
}
|
|
|
|
err = writeJSONSuccess(w, response)
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode AS details", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// asDetails returns the AS's handle, description and seen times, from the
|
|
// database, and the prefixes it originates, from the routing table. Each
|
|
// prefix has the time of its newest kept route, if it keeps one. It returns
|
|
// ErrNoRoute for AS 0, which means no known origin. It also returns ErrNoRoute
|
|
// for an AS that the database, the AS list built into RouteWatch and the
|
|
// routing table all lack.
|
|
func (s *Server) asDetails(
|
|
ctx context.Context, asn int,
|
|
) (*database.ASN, []LiveRoute, error) {
|
|
if asn == ristypes.NoOrigin {
|
|
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
|
}
|
|
|
|
asInfo, known, err := s.db.GetAS(ctx, asn)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
originated := s.table.OriginatedPrefixes(asn)
|
|
if !known && len(originated) == 0 {
|
|
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
|
}
|
|
|
|
prefixes := make([]LiveRoute, 0, len(originated))
|
|
|
|
for _, p := range originated {
|
|
route := LiveRoute{
|
|
Prefix: p.Prefix.String(),
|
|
MaskLength: p.Prefix.Bits(),
|
|
IPVersion: ipVersionOf(p.Prefix),
|
|
OriginASN: asn,
|
|
}
|
|
|
|
if p.Newest != nil {
|
|
route.LastUpdated = *p.Newest
|
|
}
|
|
|
|
prefixes = append(prefixes, route)
|
|
}
|
|
|
|
return asInfo, prefixes, nil
|
|
}
|
|
|
|
// 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 []LiveRoute,
|
|
) ([]LiveRoute, []LiveRoute) {
|
|
ipv4Routes, ipv6Routes := []LiveRoute{}, []LiveRoute{}
|
|
|
|
for _, route := range routes {
|
|
if route.IPVersion == ipVersionV4 {
|
|
ipv4Routes = append(ipv4Routes, route)
|
|
} else {
|
|
ipv6Routes = append(ipv6Routes, route)
|
|
}
|
|
}
|
|
|
|
return ipv4Routes, ipv6Routes
|
|
}
|
|
|
|
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
|
|
// routes with the same mask length by network address.
|
|
func sortRoutesByMaskLength(routes []LiveRoute) {
|
|
slices.SortFunc(routes, func(a, b LiveRoute) int {
|
|
prefixA, _ := netip.ParsePrefix(a.Prefix)
|
|
prefixB, _ := netip.ParsePrefix(b.Prefix)
|
|
|
|
return cmp.Or(cmp.Compare(prefixA.Bits(), prefixB.Bits()),
|
|
prefixA.Addr().Compare(prefixB.Addr()))
|
|
})
|
|
}
|
|
|
|
// handlePrefixDetailJSON returns prefix details as JSON
|
|
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get prefix and length from URL params
|
|
prefixParam := chi.URLParam(r, "prefix")
|
|
lenParam := chi.URLParam(r, "len")
|
|
|
|
if prefixParam == "" || lenParam == "" {
|
|
s.writeJSONError(w, http.StatusBadRequest,
|
|
"Prefix and length parameters are required")
|
|
|
|
return
|
|
}
|
|
|
|
// Masking the prefix lets any valid way of writing it find its routes.
|
|
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
|
if err != nil {
|
|
s.writeJSONError(w, http.StatusBadRequest, invalidPrefixMessage)
|
|
|
|
return
|
|
}
|
|
|
|
parsed = parsed.Masked()
|
|
prefix := parsed.String()
|
|
|
|
origins := s.table.Origins(parsed)
|
|
if len(origins) == 0 {
|
|
s.writeJSONError(w, http.StatusNotFound,
|
|
ErrNoRoute.Error()+": "+prefix)
|
|
|
|
return
|
|
}
|
|
|
|
routes := prefixRoutes(parsed, origins)
|
|
|
|
// Group by origin AS
|
|
originMap := make(map[int][]LiveRoute)
|
|
for _, route := range routes {
|
|
originMap[route.OriginASN] = append(originMap[route.OriginASN], route)
|
|
}
|
|
|
|
response := map[string]any{
|
|
"prefix": prefix,
|
|
"routes": routes,
|
|
"origins": originMap,
|
|
"peer_count": len(routes),
|
|
"origin_count": len(originMap),
|
|
}
|
|
|
|
err = writeJSONSuccess(w, response)
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode prefix details", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ASSearchResponse is the answer to an AS name search: the text searched for,
|
|
// the number of ASes that match it, and the first maxASSearchResults of them in
|
|
// AS number order.
|
|
type ASSearchResponse struct {
|
|
Query string `json:"query"`
|
|
Total int `json:"total"`
|
|
Results []asinfo.Info `json:"results"`
|
|
}
|
|
|
|
// handleASSearch returns a handler that searches the AS list built into
|
|
// asinfo for the text in the q parameter, without its leading and trailing
|
|
// spaces. It answers 400 when no text is left.
|
|
func (s *Server) handleASSearch() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
query := strings.TrimSpace(r.URL.Query().Get(asSearchQueryParam))
|
|
if query == "" {
|
|
s.writeJSONError(w, http.StatusBadRequest, searchQueryMissingMessage)
|
|
|
|
return
|
|
}
|
|
|
|
// AS 0 means no known origin and has no AS page, so the answer leaves
|
|
// it out. The matches come in AS number order, so AS 0 can only be the
|
|
// first. The search asks for one match more than the answer holds, to
|
|
// take its place.
|
|
results, total := asinfo.Search(query, maxASSearchResults+1)
|
|
if len(results) > 0 && results[0].ASN == ristypes.NoOrigin {
|
|
results = results[1:]
|
|
total--
|
|
}
|
|
|
|
results = results[:min(len(results), maxASSearchResults)]
|
|
|
|
// results is an empty list rather than nil when nothing matches, so
|
|
// that the answer holds [] and not null.
|
|
if results == nil {
|
|
results = []asinfo.Info{}
|
|
}
|
|
|
|
err := writeJSONSuccess(w, ASSearchResponse{
|
|
Query: query,
|
|
Total: total,
|
|
Results: results,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode AS search results", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleASDetail returns a handler that serves the AS detail HTML page
|
|
func (s *Server) handleASDetail() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
asnStr := chi.URLParam(r, "asn")
|
|
|
|
asn, err := strconv.Atoi(asnStr)
|
|
if err != nil {
|
|
http.Error(w, "Invalid ASN", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
asInfo, prefixes, err := s.asDetails(r.Context(), asn)
|
|
if deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
if errors.Is(err, ErrNoRoute) {
|
|
http.Error(w, "AS not found", http.StatusNotFound)
|
|
} else {
|
|
s.logger.Error("Failed to get AS details", "error", err)
|
|
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Get peers
|
|
peers, err := s.db.GetASPeersContext(r.Context(), asn)
|
|
if deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get AS peers", "error", err)
|
|
// Continue without peers rather than failing the whole request
|
|
peers = []database.ASPeer{}
|
|
}
|
|
|
|
// Group prefixes by IP version
|
|
ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes)
|
|
|
|
sortRoutesByMaskLength(ipv4Prefixes)
|
|
sortRoutesByMaskLength(ipv6Prefixes)
|
|
|
|
// Prepare template data
|
|
data := struct {
|
|
ASN *database.ASN
|
|
IPv4Prefixes []LiveRoute
|
|
IPv6Prefixes []LiveRoute
|
|
TotalCount int
|
|
IPv4Count int
|
|
IPv6Count int
|
|
Peers []database.ASPeer
|
|
PeerCount int
|
|
CSPNonce string
|
|
}{
|
|
ASN: asInfo,
|
|
IPv4Prefixes: ipv4Prefixes,
|
|
IPv6Prefixes: ipv6Prefixes,
|
|
TotalCount: len(prefixes),
|
|
IPv4Count: len(ipv4Prefixes),
|
|
IPv6Count: len(ipv6Prefixes),
|
|
Peers: peers,
|
|
PeerCount: len(peers),
|
|
CSPNonce: cspNonce(r.Context()),
|
|
}
|
|
|
|
// Check if context is still valid before writing response
|
|
if r.Context().Err() != nil {
|
|
// Request was cancelled, don't write response
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
err = templates.ASDetailTemplate().Execute(w, data)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render AS detail template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handlePrefixDetail returns a handler that serves the prefix detail HTML page
|
|
func (s *Server) handlePrefixDetail() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get prefix and length from URL params
|
|
prefixParam := chi.URLParam(r, "prefix")
|
|
lenParam := chi.URLParam(r, "len")
|
|
|
|
if prefixParam == "" || lenParam == "" {
|
|
http.Error(w, "Prefix and length parameters are required", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Masking the prefix lets any valid way of writing it find its routes.
|
|
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
|
if err != nil {
|
|
http.Error(w, invalidPrefixMessage, http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
parsed = parsed.Masked()
|
|
|
|
origins := s.table.Origins(parsed)
|
|
if len(origins) == 0 {
|
|
http.Error(w, "Prefix not found", http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
originInfos, err := s.originASNs(r.Context(), origins)
|
|
if deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get the origin ASes' names", "error", err)
|
|
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
routes := prefixRoutes(parsed, origins)
|
|
|
|
// Prepare template data
|
|
data := struct {
|
|
Prefix string
|
|
MaskLength int
|
|
IPVersion int
|
|
Routes []EnhancedRoute
|
|
Origins []*ASNInfo
|
|
PeerCount int
|
|
OriginCount int
|
|
CSPNonce string
|
|
}{
|
|
Prefix: parsed.String(),
|
|
MaskLength: parsed.Bits(),
|
|
IPVersion: ipVersionOf(parsed),
|
|
Routes: routesWithASPathHandles(routes),
|
|
Origins: originInfos,
|
|
PeerCount: len(routes),
|
|
OriginCount: len(originInfos),
|
|
CSPNonce: cspNonce(r.Context()),
|
|
}
|
|
|
|
// Check if context is still valid before writing response
|
|
select {
|
|
case <-r.Context().Done():
|
|
// Request was cancelled, don't write response
|
|
return
|
|
default:
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
err = templates.PrefixDetailTemplate().Execute(w, data)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render prefix detail template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ASNInfo is one origin AS of a prefix on the prefix detail page.
|
|
type ASNInfo struct {
|
|
ASN int
|
|
Handle string
|
|
Description string
|
|
PeerCount int
|
|
}
|
|
|
|
// originASNs returns each of origins, a prefix's origin ASes, with its handle
|
|
// and description from the database and the number of peers that carry the
|
|
// prefix with it.
|
|
func (s *Server) originASNs(
|
|
ctx context.Context, origins []routetable.Origin,
|
|
) ([]*ASNInfo, error) {
|
|
infos := make([]*ASNInfo, 0, len(origins))
|
|
|
|
for _, origin := range origins {
|
|
asInfo, _, err := s.db.GetAS(ctx, origin.ASN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
infos = append(infos, &ASNInfo{
|
|
ASN: origin.ASN,
|
|
Handle: asInfo.Handle,
|
|
Description: asInfo.Description,
|
|
PeerCount: len(origin.Peers),
|
|
})
|
|
}
|
|
|
|
return infos, nil
|
|
}
|
|
|
|
// prefixRoutes returns one route for each peer that carries prefix, by origin
|
|
// AS in the order of origins. Each origin's kept routes come first, newest
|
|
// first, with their AS path, next hop and time. Its other peers follow without
|
|
// them, since the routing table keeps only their addresses.
|
|
func prefixRoutes(
|
|
prefix netip.Prefix, origins []routetable.Origin,
|
|
) []LiveRoute {
|
|
var routes []LiveRoute
|
|
|
|
for _, origin := range origins {
|
|
base := LiveRoute{
|
|
Prefix: prefix.String(),
|
|
MaskLength: prefix.Bits(),
|
|
IPVersion: ipVersionOf(prefix),
|
|
OriginASN: origin.ASN,
|
|
}
|
|
|
|
kept := make(map[string]bool, len(origin.Routes))
|
|
|
|
for _, r := range origin.Routes {
|
|
kept[r.Peer] = true
|
|
|
|
route := base
|
|
route.PeerIP = r.Peer
|
|
route.ASPath = r.ASPath
|
|
// The next hop is the peer, as the prefix handler records it.
|
|
route.NextHop = r.Peer
|
|
route.LastUpdated = r.Time
|
|
routes = append(routes, route)
|
|
}
|
|
|
|
for _, peer := range origin.Peers {
|
|
if !kept[peer] {
|
|
route := base
|
|
route.PeerIP = peer
|
|
routes = append(routes, route)
|
|
}
|
|
}
|
|
}
|
|
|
|
return routes
|
|
}
|
|
|
|
// ASPathEntry is one AS of a route's AS path, with its handle.
|
|
type ASPathEntry struct {
|
|
ASN int
|
|
Handle string
|
|
}
|
|
|
|
// EnhancedRoute is a live route with the handle of each AS in its path.
|
|
type EnhancedRoute struct {
|
|
LiveRoute
|
|
|
|
ASPathWithHandle []ASPathEntry
|
|
}
|
|
|
|
// routesWithASPathHandles looks up the handle of each AS in the path of each
|
|
// route.
|
|
func routesWithASPathHandles(routes []LiveRoute) []EnhancedRoute {
|
|
enhancedRoutes := make([]EnhancedRoute, len(routes))
|
|
|
|
for i, route := range routes {
|
|
enhancedRoute := EnhancedRoute{
|
|
LiveRoute: route,
|
|
ASPathWithHandle: make([]ASPathEntry, len(route.ASPath)),
|
|
}
|
|
|
|
// Look up handle for each AS in the path
|
|
for j, asn := range route.ASPath {
|
|
handle := asinfo.GetHandle(asn)
|
|
enhancedRoute.ASPathWithHandle[j] = ASPathEntry{
|
|
ASN: asn,
|
|
Handle: handle,
|
|
}
|
|
}
|
|
|
|
enhancedRoutes[i] = enhancedRoute
|
|
}
|
|
|
|
return enhancedRoutes
|
|
}
|
|
|
|
// handlePrefixLength shows a random sample of IPv4 prefixes with the
|
|
// specified mask length
|
|
func (s *Server) handlePrefixLength() http.HandlerFunc {
|
|
const maxIPv4MaskLength = 32
|
|
|
|
return s.prefixLengthHandler(ipVersionV4, maxIPv4MaskLength)
|
|
}
|
|
|
|
// prefixLengthHandler returns a handler that shows a random sample of the
|
|
// prefixes of one IP version with the specified mask length, which may be at
|
|
// most maxMaskLength.
|
|
func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
lengthStr := chi.URLParam(r, "length")
|
|
if lengthStr == "" {
|
|
http.Error(w, "Length parameter is required", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
maskLength, err := strconv.Atoi(lengthStr)
|
|
if err != nil {
|
|
http.Error(w, "Invalid mask length", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Validate mask length
|
|
if maskLength < 0 || maskLength > maxMaskLength {
|
|
http.Error(w, "Invalid IPv"+strconv.Itoa(ipVersion)+" mask length",
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Get random sample of prefixes
|
|
const maxPrefixes = 500
|
|
|
|
prefixes := s.table.SamplePrefixes(ipVersion, maskLength, maxPrefixes)
|
|
|
|
// Sort prefixes for display
|
|
slices.SortFunc(prefixes, func(a, b routetable.PrefixSummary) int {
|
|
return a.Prefix.Compare(b.Prefix)
|
|
})
|
|
|
|
// Render template
|
|
data := map[string]any{
|
|
"MaskLength": maskLength,
|
|
"IPVersion": ipVersion,
|
|
"Prefixes": prefixesWithASDescriptions(prefixes),
|
|
"Count": len(prefixes),
|
|
"CSPNonce": cspNonce(r.Context()),
|
|
}
|
|
|
|
// Check if context is still valid before writing response
|
|
select {
|
|
case <-r.Context().Done():
|
|
// Request was cancelled, don't write response
|
|
return
|
|
default:
|
|
}
|
|
|
|
tmpl := templates.PrefixLengthTemplate()
|
|
|
|
err = tmpl.Execute(w, data)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render prefix length template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
LiveRoute
|
|
|
|
OriginASDescription string
|
|
Age string
|
|
}
|
|
|
|
// prefixesWithASDescriptions looks up the description of the origin AS of
|
|
// each prefix. It gives each prefix the age of its newest kept route, or no
|
|
// age when it keeps none.
|
|
func prefixesWithASDescriptions(
|
|
prefixes []routetable.PrefixSummary,
|
|
) []EnhancedPrefix {
|
|
enhancedPrefixes := make([]EnhancedPrefix, len(prefixes))
|
|
for i, prefix := range prefixes {
|
|
enhancedPrefixes[i] = EnhancedPrefix{
|
|
Prefix: prefix.Prefix.String(),
|
|
MaskLength: prefix.Prefix.Bits(),
|
|
IPVersion: ipVersionOf(prefix.Prefix),
|
|
OriginASN: prefix.OriginASN,
|
|
}
|
|
|
|
if prefix.Newest != nil {
|
|
enhancedPrefixes[i].LastUpdated = *prefix.Newest
|
|
enhancedPrefixes[i].Age = formatAge(*prefix.Newest)
|
|
}
|
|
|
|
// Get AS description
|
|
if asInfo, ok := asinfo.Get(prefix.OriginASN); ok {
|
|
enhancedPrefixes[i].OriginASDescription = asInfo.Description
|
|
}
|
|
}
|
|
|
|
return enhancedPrefixes
|
|
}
|
|
|
|
// handlePrefixLength6 shows a random sample of IPv6 prefixes with the
|
|
// specified mask length
|
|
func (s *Server) handlePrefixLength6() http.HandlerFunc {
|
|
const maxIPv6MaskLength = 128
|
|
|
|
return s.prefixLengthHandler(ipVersionV6, maxIPv6MaskLength)
|
|
}
|
|
|
|
// formatAge returns a human-readable age string
|
|
func formatAge(timestamp time.Time) string {
|
|
age := time.Since(timestamp)
|
|
|
|
const hoursPerDay = 24
|
|
|
|
switch {
|
|
case age < time.Minute:
|
|
return "< 1m"
|
|
case age < time.Hour:
|
|
minutes := int(age.Minutes())
|
|
|
|
return strconv.Itoa(minutes) + "m"
|
|
case age < hoursPerDay*time.Hour:
|
|
hours := int(age.Hours())
|
|
|
|
return strconv.Itoa(hours) + "h"
|
|
}
|
|
|
|
days := int(age.Hours() / hoursPerDay)
|
|
|
|
return strconv.Itoa(days) + "d"
|
|
}
|