check / check (push) Canceled after 0s
The home page gets cards for the newest and oldest kept route, the newest and oldest updated AS, the AS with the most prefixes, the ASes with a single prefix and fifteen well-known networks. Two more cards count the bogon announcements and the prefixes with several origin ASes. They link to the new pages /bogons and /multiple-origins, which list up to 1,000 entries each. The new package internal/bogon tells reserved prefixes and AS numbers, from the IANA special-purpose and address space registries. Table.Overview walks the table and lets the read lock go every 10,000 prefixes. The server works the cards out once a minute and serves that copy. A new index on asns(last_seen) finds the newest and oldest updated AS. Model: opus-5-5
1809 lines
55 KiB
Go
1809 lines
55 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/dustin/go-humanize"
|
|
"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"`
|
|
CurrentInterval string `json:"current_interval"`
|
|
ConsecutiveFails int `json:"consecutive_fails"`
|
|
FreshPercent float64 `json:"fresh_percent"`
|
|
}
|
|
|
|
// Stats is the /status.json answer: uptime, message counts, database stats,
|
|
// and the routing table's figures. 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 {
|
|
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.
|
|
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{
|
|
Uptime: streamUptime(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(),
|
|
MemoryUsage: humanize.Bytes(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,
|
|
CurrentInterval: fetcherStats.CurrentInterval.String(),
|
|
ConsecutiveFails: fetcherStats.ConsecutiveFails,
|
|
FreshPercent: freshPercent,
|
|
}
|
|
}
|
|
|
|
// streamUptime is how long the stream has been connected, to the second, or
|
|
// "0s" if it has never connected.
|
|
func streamUptime(connectedSince time.Time) string {
|
|
if connectedSince.IsZero() {
|
|
return "0s"
|
|
}
|
|
|
|
return time.Since(connectedSince).Truncate(time.Second).String()
|
|
}
|
|
|
|
// connectionDuration is how long the stream has been connected, to the
|
|
// second, or "disconnected".
|
|
func connectionDuration(connected bool, connectedSince time.Time) string {
|
|
if !connected || connectedSince.IsZero() {
|
|
return "disconnected"
|
|
}
|
|
|
|
return time.Since(connectedSince).Truncate(time.Second).String()
|
|
}
|
|
|
|
// 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 {
|
|
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"`
|
|
}
|
|
|
|
// 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{
|
|
Uptime: streamUptime(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,
|
|
ConnectionDuration: connectionDuration(
|
|
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
|
ReconnectCount: streamMetrics.ReconnectCount,
|
|
GoVersion: runtime.Version(),
|
|
Goroutines: runtime.NumGoroutine(),
|
|
MemoryUsage: humanize.Bytes(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,
|
|
|
|
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. IPInfo is nil when
|
|
// the address has no live route, and the answer then has no route or AS
|
|
// fields. IP hides the IPInfo field of the same name, so that an address with
|
|
// no route has it too.
|
|
type IPLookupResult struct {
|
|
*IPInfo
|
|
|
|
IP string `json:"ip"`
|
|
PTR []string `json:"ptr,omitempty"`
|
|
|
|
// 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, without route fields.
|
|
ipInfo = nil
|
|
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 != nil && ipInfo.NeedsWHOISRefresh {
|
|
refreshASNs[ipInfo.ASN] = true
|
|
}
|
|
}
|
|
|
|
// Queue WHOIS refresh for stale ASNs (non-blocking)
|
|
s.queueWHOISRefresh(refreshASNs)
|
|
|
|
s.writeIPLookupAnswer(w, response, ownAddress)
|
|
}
|
|
}
|
|
|
|
// ipInfo returns the IP lookup's answer for ip. The routing table gives the
|
|
// most specific prefix that holds the address, the number of peers that carry
|
|
// it, the origin AS that most of them 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.
|
|
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", ErrInvalidIP, ip)
|
|
}
|
|
|
|
summary, found := s.table.Lookup(addr)
|
|
if !found {
|
|
return nil, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
|
|
}
|
|
|
|
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info := &IPInfo{
|
|
IP: ip,
|
|
Netblock: summary.Prefix.String(),
|
|
MaskLength: summary.Prefix.Bits(),
|
|
IPVersion: ipVersionOf(summary.Prefix),
|
|
NumPeers: summary.Peers,
|
|
ASN: summary.OriginASN,
|
|
ASName: asInfo.ASName,
|
|
Handle: asInfo.Handle,
|
|
Description: asInfo.Description,
|
|
OrgName: asInfo.OrgName,
|
|
OrgID: asInfo.OrgID,
|
|
Address: asInfo.Address,
|
|
CountryCode: asInfo.CountryCode,
|
|
AbuseEmail: asInfo.AbuseEmail,
|
|
RIR: asInfo.RIR,
|
|
FirstSeen: firstSeen,
|
|
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
|
|
}
|
|
|
|
if summary.Newest != nil {
|
|
info.LastSeen = *summary.Newest
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// 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, or
|
|
// nil when the lookup fails or runs past ptrLookupTimeout.
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
// 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"
|
|
}
|