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. 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" }