From 5c038b7e828afdec56d241afe64b3fd76c4c5b98 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 13:04:30 +0000 Subject: [PATCH] WIP server lint fixes Model: opus-5-5 --- internal/server/handlers.go | 1091 +++++++++-------- ...lers_test.go => handlers_internal_test.go} | 63 +- internal/server/middleware.go | 57 +- internal/server/routes.go | 7 +- internal/server/server.go | 12 +- 5 files changed, 666 insertions(+), 564 deletions(-) rename internal/server/{handlers_test.go => handlers_internal_test.go} (75%) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 195cb33..29fcaf1 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -14,6 +14,7 @@ import ( "time" "git.eeqj.de/sneak/routewatch/internal/database" + "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" @@ -28,6 +29,22 @@ const ( healthCheckTimeout = 2 * time.Second ) +// 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" +) + +// IP versions as database.LiveRoute.IPVersion records them. +const ( + ipVersionV4 = 4 + ipVersionV6 = 6 +) + // HealthCheckResponse represents the health check response. type HealthCheckResponse struct { Status string `json:"status"` @@ -67,7 +84,7 @@ func (s *Server) handleHealthCheck() http.HandlerFunc { // Build response status := "ok" if !healthy { - status = "error" + status = jsonStatusError } response := HealthCheckResponse{ @@ -80,7 +97,8 @@ func (s *Server) handleHealthCheck() http.HandlerFunc { w.WriteHeader(http.StatusServiceUnavailable) } - if err := writeJSONSuccess(w, response); err != nil { + err = writeJSONSuccess(w, response) + if err != nil { s.logger.Error("Failed to encode health check response", "error", err) } } @@ -92,7 +110,9 @@ func (s *Server) handleIndex() http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl := templates.IndexTemplate() - if err := tmpl.Execute(w, nil); err != nil { + + err := tmpl.Execute(w, nil) + if err != nil { s.logger.Error("Failed to render index template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } @@ -101,30 +121,36 @@ func (s *Server) handleIndex() http.HandlerFunc { // writeJSONError writes a standardized JSON error response with the given // status code and error message. -func writeJSONError(w http.ResponseWriter, statusCode int, message string) { +func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "error", - "error": map[string]interface{}{ - "msg": message, - "code": 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 interface{}) error { +func writeJSONSuccess(w http.ResponseWriter, data any) error { w.Header().Set("Content-Type", "application/json") - return json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "ok", - "data": data, + 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"` @@ -137,41 +163,75 @@ type WHOISStatsInfo struct { FreshPercent float64 `json:"fresh_percent"` } +// Stats is the /status.json answer: uptime, message counts, database stats, +// and route information. +// +//nolint:tagliatelle // field names are the existing JSON API +type Stats struct { + Uptime string `json:"uptime"` + TotalMessages uint64 `json:"total_messages"` + TotalBytes uint64 `json:"total_bytes"` + TotalWireBytes uint64 `json:"total_wire_bytes"` + MessagesPerSec float64 `json:"messages_per_sec"` + MbitsPerSec float64 `json:"mbits_per_sec"` + WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` + Connected bool `json:"connected"` + GoVersion string `json:"go_version"` + Goroutines int `json:"goroutines"` + MemoryUsage string `json:"memory_usage"` + ASNs int `json:"asns"` + Prefixes int `json:"prefixes"` + IPv4Prefixes int `json:"ipv4_prefixes"` + IPv6Prefixes int `json:"ipv6_prefixes"` + Peerings int `json:"peerings"` + Peers int `json:"peers"` + DatabaseSizeBytes int64 `json:"database_size_bytes"` + LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + OldestRoute *time.Time `json:"oldest_route,omitempty"` + NewestRoute *time.Time `json:"newest_route,omitempty"` + IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` + IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` + WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` +} + +// 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 { - // Stats represents the statistics response - type Stats struct { - Uptime string `json:"uptime"` - TotalMessages uint64 `json:"total_messages"` - TotalBytes uint64 `json:"total_bytes"` - TotalWireBytes uint64 `json:"total_wire_bytes"` - MessagesPerSec float64 `json:"messages_per_sec"` - MbitsPerSec float64 `json:"mbits_per_sec"` - WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` - Connected bool `json:"connected"` - GoVersion string `json:"go_version"` - Goroutines int `json:"goroutines"` - MemoryUsage string `json:"memory_usage"` - ASNs int `json:"asns"` - Prefixes int `json:"prefixes"` - IPv4Prefixes int `json:"ipv4_prefixes"` - IPv6Prefixes int `json:"ipv6_prefixes"` - Peerings int `json:"peerings"` - Peers int `json:"peers"` - DatabaseSizeBytes int64 `json:"database_size_bytes"` - LiveRoutes int `json:"live_routes"` - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - OldestRoute *time.Time `json:"oldest_route,omitempty"` - NewestRoute *time.Time `json:"newest_route,omitempty"` - IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` - IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` - IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` - IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` - WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` - } - return func(w http.ResponseWriter, r *http.Request) { // Create a 4 second timeout context for this request ctx, cancel := context.WithTimeout(r.Context(), statsContextTimeout) @@ -179,42 +239,19 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { metrics := s.streamer.GetMetrics() - // Get database stats with timeout. 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. - 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 - }() - - var dbStats database.Stats - select { - case <-ctx.Done(): + dbStats, err := s.databaseStats(ctx) + if err != nil && ctx.Err() != nil { s.logger.Error("Database stats timeout in status.json") - writeJSONError(w, http.StatusRequestTimeout, "Database timeout") + s.writeJSONError(w, http.StatusRequestTimeout, "Database timeout") return - case err := <-errChan: - s.logger.Error("Failed to get database stats", "error", err) - writeJSONError(w, http.StatusInternalServerError, err.Error()) - - return - case dbStats = <-statsChan: - // Success } - uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String() - if metrics.ConnectedSince.IsZero() { - uptime = "0s" + if err != nil { + s.logger.Error("Failed to get database stats", "error", err) + s.writeJSONError(w, http.StatusInternalServerError, err.Error()) + + return } const bitsPerMegabit = 1000000.0 @@ -227,13 +264,10 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { runtime.ReadMemStats(&memStats) // Get WHOIS stats if fetcher is available - var whoisStats *WHOISStatsInfo - if s.asnFetcher != nil { - whoisStats = s.getWHOISStats(ctx) - } + whoisStats := s.getWHOISStats(ctx) stats := Stats{ - Uptime: uptime, + Uptime: streamUptime(metrics.ConnectedSince), TotalMessages: metrics.TotalMessages, TotalBytes: metrics.TotalBytes, TotalWireBytes: metrics.TotalWireBytes, @@ -263,14 +297,20 @@ func (s *Server) handleStatusJSON() http.HandlerFunc { WHOISStats: whoisStats, } - if err := writeJSONSuccess(w, stats); err != nil { + err = writeJSONSuccess(w, stats) + if err != nil { s.logger.Error("Failed to encode stats", "error", err) } } } -// getWHOISStats builds WHOIS statistics from database and fetcher. +// getWHOISStats builds WHOIS statistics from database and fetcher, or returns +// nil when there is no fetcher. func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo { + if s.asnFetcher == nil { + return nil + } + // Get database WHOIS stats dbStats, err := s.db.GetWHOISStats(ctx, whoisStaleThreshold) if err != nil { @@ -285,7 +325,8 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo { // Calculate fresh percentage var freshPercent float64 if dbStats.TotalASNs > 0 { - freshPercent = float64(dbStats.FreshASNs) / float64(dbStats.TotalASNs) * percentMultiplier + freshPercent = float64(dbStats.FreshASNs) / float64(dbStats.TotalASNs) * + percentMultiplier } return &WHOISStatsInfo{ @@ -301,82 +342,174 @@ func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo { } } +// 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"` + RouteChurnPerSec float64 `json:"route_churn_per_sec"` + BGPPeerCount int `json:"bgp_peer_count"` +} + +// StatsResponse represents the API statistics response +// +//nolint:tagliatelle // field names are the existing JSON API +type StatsResponse struct { + Uptime string `json:"uptime"` + TotalMessages uint64 `json:"total_messages"` + TotalBytes uint64 `json:"total_bytes"` + TotalWireBytes uint64 `json:"total_wire_bytes"` + MessagesPerSec float64 `json:"messages_per_sec"` + MbitsPerSec float64 `json:"mbits_per_sec"` + WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` + Connected bool `json:"connected"` + ConnectionDuration string `json:"connection_duration"` + ReconnectCount uint64 `json:"reconnect_count"` + GoVersion string `json:"go_version"` + Goroutines int `json:"goroutines"` + MemoryUsage string `json:"memory_usage"` + GC GCStats `json:"gc"` + Stream StreamStats `json:"stream"` + ASNs int `json:"asns"` + Prefixes int `json:"prefixes"` + IPv4Prefixes int `json:"ipv4_prefixes"` + IPv6Prefixes int `json:"ipv6_prefixes"` + Peerings int `json:"peerings"` + Peers int `json:"peers"` + DatabaseSizeBytes int64 `json:"database_size_bytes"` + LiveRoutes int `json:"live_routes"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + OldestRoute *time.Time `json:"oldest_route,omitempty"` + NewestRoute *time.Time `json:"newest_route,omitempty"` + IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` + IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` + HandlerStats []HandlerStatsInfo `json:"handler_stats"` + IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` + WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` +} + +// 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(), + RouteChurnPerSec: metricsTracker.GetChurnRate(), + 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 { - // HandlerStatsInfo represents handler statistics in the API response - 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 - 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 - type StreamStats struct { - Announcements uint64 `json:"announcements"` - Withdrawals uint64 `json:"withdrawals"` - RouteChurnPerSec float64 `json:"route_churn_per_sec"` - BGPPeerCount int `json:"bgp_peer_count"` - } - - // StatsResponse represents the API statistics response - type StatsResponse struct { - Uptime string `json:"uptime"` - TotalMessages uint64 `json:"total_messages"` - TotalBytes uint64 `json:"total_bytes"` - TotalWireBytes uint64 `json:"total_wire_bytes"` - MessagesPerSec float64 `json:"messages_per_sec"` - MbitsPerSec float64 `json:"mbits_per_sec"` - WireMbitsPerSec float64 `json:"wire_mbits_per_sec"` - Connected bool `json:"connected"` - ConnectionDuration string `json:"connection_duration"` - ReconnectCount uint64 `json:"reconnect_count"` - GoVersion string `json:"go_version"` - Goroutines int `json:"goroutines"` - MemoryUsage string `json:"memory_usage"` - GC GCStats `json:"gc"` - Stream StreamStats `json:"stream"` - ASNs int `json:"asns"` - Prefixes int `json:"prefixes"` - IPv4Prefixes int `json:"ipv4_prefixes"` - IPv6Prefixes int `json:"ipv6_prefixes"` - Peerings int `json:"peerings"` - Peers int `json:"peers"` - DatabaseSizeBytes int64 `json:"database_size_bytes"` - LiveRoutes int `json:"live_routes"` - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - OldestRoute *time.Time `json:"oldest_route,omitempty"` - NewestRoute *time.Time `json:"newest_route,omitempty"` - IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"` - IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"` - HandlerStats []HandlerStatsInfo `json:"handler_stats"` - IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` - IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` - WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"` - } - return func(w http.ResponseWriter, r *http.Request) { // Create a 4 second timeout context for this request ctx, cancel := context.WithTimeout(r.Context(), statsContextTimeout) @@ -393,41 +526,19 @@ func (s *Server) handleStats() http.HandlerFunc { metrics := s.streamer.GetMetrics() - // Get database stats with timeout. 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. - 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 - }() - - var dbStats database.Stats - select { - case <-ctx.Done(): + dbStats, err := s.databaseStats(ctx) + if err != nil && ctx.Err() != nil { s.logger.Error("Database stats timeout") + // Don't write response here - timeout middleware already handles it return - case err := <-errChan: - s.logger.Error("Failed to get database stats", "error", err) - writeJSONError(w, http.StatusInternalServerError, err.Error()) - - return - case dbStats = <-statsChan: - // Success } - uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String() - if metrics.ConnectedSince.IsZero() { - uptime = "0s" + if err != nil { + s.logger.Error("Failed to get database stats", "error", err) + s.writeJSONError(w, http.StatusInternalServerError, err.Error()) + + return } const bitsPerMegabit = 1000000.0 @@ -435,86 +546,30 @@ func (s *Server) handleStats() http.HandlerFunc { // Get route update metrics routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics() - // Get handler stats - handlerStats := s.streamer.GetHandlerStats() - handlerStatsInfo := make([]HandlerStatsInfo, 0, len(handlerStats)) - const microsecondsPerMillisecond = 1000.0 - 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, - }) - } - // Get memory stats var memStats runtime.MemStats runtime.ReadMemStats(&memStats) // Get WHOIS stats if fetcher is available - var whoisStats *WHOISStatsInfo - if s.asnFetcher != nil { - whoisStats = s.getWHOISStats(ctx) - } - - // Calculate connection duration - connectionDuration := "disconnected" - if metrics.Connected && !metrics.ConnectedSince.IsZero() { - connectionDuration = time.Since(metrics.ConnectedSince).Truncate(time.Second).String() - } - - // Get announcement/withdrawal stats from metrics tracker - metricsTracker := s.streamer.GetMetricsTracker() - announcements := metricsTracker.GetAnnouncementCount() - withdrawals := metricsTracker.GetWithdrawalCount() - churnRate := metricsTracker.GetChurnRate() - bgpPeerCount := metricsTracker.GetBGPPeerCount() - - // Calculate last GC pause - const ( - nanosecondsPerMillisecond = 1e6 - gcPauseHistorySize = 256 // Size of runtime.MemStats.PauseNs circular buffer - ) - 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 - } + whoisStats := s.getWHOISStats(ctx) stats := StatsResponse{ - Uptime: uptime, - TotalMessages: metrics.TotalMessages, - TotalBytes: metrics.TotalBytes, - TotalWireBytes: metrics.TotalWireBytes, - MessagesPerSec: metrics.MessagesPerSec, - MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, - WireMbitsPerSec: metrics.WireBitsPerSec / bitsPerMegabit, - Connected: metrics.Connected, - ConnectionDuration: connectionDuration, - ReconnectCount: metrics.ReconnectCount, - GoVersion: runtime.Version(), - Goroutines: runtime.NumGoroutine(), - MemoryUsage: humanize.Bytes(memStats.Alloc), - GC: GCStats{ - NumGC: memStats.NumGC, - TotalPauseMs: memStats.PauseTotalNs / uint64(nanosecondsPerMillisecond), - LastPauseMs: lastPauseMs, - HeapAllocBytes: memStats.HeapAlloc, - HeapSysBytes: memStats.HeapSys, - }, - Stream: StreamStats{ - Announcements: announcements, - Withdrawals: withdrawals, - RouteChurnPerSec: churnRate, - BGPPeerCount: bgpPeerCount, - }, + Uptime: streamUptime(metrics.ConnectedSince), + TotalMessages: metrics.TotalMessages, + TotalBytes: metrics.TotalBytes, + TotalWireBytes: metrics.TotalWireBytes, + MessagesPerSec: metrics.MessagesPerSec, + MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, + WireMbitsPerSec: metrics.WireBitsPerSec / bitsPerMegabit, + Connected: metrics.Connected, + ConnectionDuration: connectionDuration( + metrics.Connected, metrics.ConnectedSince), + ReconnectCount: metrics.ReconnectCount, + GoVersion: runtime.Version(), + Goroutines: runtime.NumGoroutine(), + MemoryUsage: humanize.Bytes(memStats.Alloc), + GC: gcStats(&memStats), + Stream: s.streamStats(), ASNs: dbStats.ASNs, Prefixes: dbStats.Prefixes, IPv4Prefixes: dbStats.IPv4Prefixes, @@ -529,13 +584,14 @@ func (s *Server) handleStats() http.HandlerFunc { NewestRoute: dbStats.NewestRoute, IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec, IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec, - HandlerStats: handlerStatsInfo, + HandlerStats: handlerStatsInfo(s.streamer.GetHandlerStats()), IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution, IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution, WHOISStats: whoisStats, } - if err := writeJSONSuccess(w, stats); err != nil { + err = writeJSONSuccess(w, stats) + if err != nil { s.logger.Error("Failed to encode stats", "error", err) } } @@ -548,7 +604,9 @@ func (s *Server) handleStatusHTML() http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl := templates.StatusTemplate() - if err := tmpl.Execute(w, nil); err != nil { + + err := tmpl.Execute(w, nil) + if err != nil { s.logger.Error("Failed to render template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } @@ -578,13 +636,14 @@ func (s *Server) handleIPInfo() http.HandlerFunc { if target == "" { target = chi.URLParam(r, "addr") } + if target == "" { // Use client IP (RealIP middleware has already processed this) target = extractClientIP(r) } if target == "" { - writeJSONError(w, http.StatusBadRequest, "Could not determine IP address") + s.writeJSONError(w, http.StatusBadRequest, "Could not determine IP address") return } @@ -596,33 +655,11 @@ func (s *Server) handleIPInfo() http.HandlerFunc { } // Collect all IPs to look up - var ipsToLookup []string - - // Check if target contains commas (multiple IPs) - targets := strings.Split(target, ",") - for _, t := range targets { - t = strings.TrimSpace(t) - if t == "" { - continue - } - - // Check if this target is an IP address - if parsedIP := net.ParseIP(t); parsedIP != nil { - ipsToLookup = append(ipsToLookup, t) - } else { - // It's a hostname - resolve it - resolved, err := net.DefaultResolver.LookupHost(ctx, t) - if err != nil { - response.Errors = append(response.Errors, t+": "+err.Error()) - - continue - } - ipsToLookup = append(ipsToLookup, resolved...) - } - } + ipsToLookup, resolveErrors := resolveLookupTargets(ctx, target) + response.Errors = append(response.Errors, resolveErrors...) if len(ipsToLookup) == 0 { - writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided") + s.writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided") return } @@ -640,12 +677,8 @@ func (s *Server) handleIPInfo() http.HandlerFunc { } // Do PTR lookup for this IP - ptrs, err := net.DefaultResolver.LookupAddr(ctx, ip) - if err == nil && len(ptrs) > 0 { - // Remove trailing dots from PTR records - for i, ptr := range ptrs { - ptrs[i] = strings.TrimSuffix(ptr, ".") - } + ptrs := lookupPTRNames(ctx, ip) + if len(ptrs) > 0 { ipInfo.PTR = ptrs } @@ -665,17 +698,67 @@ func (s *Server) handleIPInfo() http.HandlerFunc { // Return response (even if no results, include errors) if len(response.Results) == 0 && len(response.Errors) > 0 { - writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0]) + s.writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0]) return } - if err := writeJSONSuccess(w, response); err != nil { + err := writeJSONSuccess(w, response) + if err != nil { s.logger.Error("Failed to encode IP lookup response", "error", err) } } } +// 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. +func resolveLookupTargets(ctx context.Context, target string) ([]string, []string) { + var ipsToLookup, resolveErrors []string + + for t := range strings.SplitSeq(target, ",") { + t = strings.TrimSpace(t) + if t == "" { + continue + } + + // 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 := net.DefaultResolver.LookupHost(ctx, t) + if err != nil { + resolveErrors = append(resolveErrors, t+": "+err.Error()) + + continue + } + + ipsToLookup = append(ipsToLookup, resolved...) + } + + return ipsToLookup, resolveErrors +} + +// lookupPTRNames returns the PTR records of ip without their trailing dots, or +// nil when the lookup fails. +func lookupPTRNames(ctx context.Context, ip string) []string { + ptrs, err := net.DefaultResolver.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 chi's RealIP middleware which sets RemoteAddr. func extractClientIP(r *http.Request) string { @@ -693,9 +776,10 @@ func extractClientIP(r *http.Request) string { 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 { - writeJSONError(w, http.StatusBadRequest, "Invalid ASN") + s.writeJSONError(w, http.StatusBadRequest, "Invalid ASN") return } @@ -703,38 +787,69 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc { asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn) if err != nil { if errors.Is(err, database.ErrNoRoute) { - writeJSONError(w, http.StatusNotFound, err.Error()) + s.writeJSONError(w, http.StatusNotFound, err.Error()) } else { - writeJSONError(w, http.StatusInternalServerError, err.Error()) + s.writeJSONError(w, http.StatusInternalServerError, err.Error()) } return } // Group prefixes by IP version - const ipVersionV4 = 4 - var ipv4Prefixes, ipv6Prefixes []database.LiveRoute - for _, p := range prefixes { - if p.IPVersion == ipVersionV4 { - ipv4Prefixes = append(ipv4Prefixes, p) - } else { - ipv6Prefixes = append(ipv6Prefixes, p) - } - } + ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes) - response := map[string]interface{}{ + response := map[string]any{ "asn": asInfo, "ipv4_prefixes": ipv4Prefixes, "ipv6_prefixes": ipv6Prefixes, "total_count": len(prefixes), } - if err := writeJSONSuccess(w, response); err != nil { + err = writeJSONSuccess(w, response) + if err != nil { s.logger.Error("Failed to encode AS details", "error", err) } } } +// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. +func splitRoutesByIPVersion( + routes []database.LiveRoute, +) ([]database.LiveRoute, []database.LiveRoute) { + var ipv4Routes, ipv6Routes []database.LiveRoute + + for _, route := range routes { + if route.IPVersion == ipVersionV4 { + ipv4Routes = append(ipv4Routes, route) + } else { + ipv6Routes = append(ipv6Routes, route) + } + } + + return ipv4Routes, ipv6Routes +} + +// sortRoutesByNetwork sorts routes by network address, then by mask length. +func sortRoutesByNetwork(routes []database.LiveRoute) { + sort.Slice(routes, func(i, j int) bool { + // Parse the prefixes to compare network addresses + ipI, netI, _ := net.ParseCIDR(routes[i].Prefix) + ipJ, netJ, _ := net.ParseCIDR(routes[j].Prefix) + + // Compare by network address first + cmp := bytes.Compare(ipI.To16(), ipJ.To16()) + if cmp != 0 { + return cmp < 0 + } + + // If network addresses are equal, compare by mask length + onesI, _ := netI.Mask.Size() + onesJ, _ := netJ.Mask.Size() + + return onesI < onesJ + }) +} + // handlePrefixDetailJSON returns prefix details as JSON func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -743,7 +858,8 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { lenParam := chi.URLParam(r, "len") if prefixParam == "" || lenParam == "" { - writeJSONError(w, http.StatusBadRequest, "Prefix and length parameters are required") + s.writeJSONError(w, http.StatusBadRequest, + "Prefix and length parameters are required") return } @@ -754,9 +870,9 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { routes, err := s.db.GetPrefixDetailsContext(r.Context(), prefix) if err != nil { if errors.Is(err, database.ErrNoRoute) { - writeJSONError(w, http.StatusNotFound, err.Error()) + s.writeJSONError(w, http.StatusNotFound, err.Error()) } else { - writeJSONError(w, http.StatusInternalServerError, err.Error()) + s.writeJSONError(w, http.StatusInternalServerError, err.Error()) } return @@ -768,7 +884,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { originMap[route.OriginASN] = append(originMap[route.OriginASN], route) } - response := map[string]interface{}{ + response := map[string]any{ "prefix": prefix, "routes": routes, "origins": originMap, @@ -776,7 +892,8 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { "origin_count": len(originMap), } - if err := writeJSONSuccess(w, response); err != nil { + err = writeJSONSuccess(w, response) + if err != nil { s.logger.Error("Failed to encode prefix details", "error", err) } } @@ -786,6 +903,7 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { 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) @@ -814,52 +932,11 @@ func (s *Server) handleASDetail() http.HandlerFunc { } // Group prefixes by IP version - const ipVersionV4 = 4 - var ipv4Prefixes, ipv6Prefixes []database.LiveRoute - for _, p := range prefixes { - if p.IPVersion == ipVersionV4 { - ipv4Prefixes = append(ipv4Prefixes, p) - } else { - ipv6Prefixes = append(ipv6Prefixes, p) - } - } + ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes) // Sort prefixes by network address - sort.Slice(ipv4Prefixes, func(i, j int) bool { - // Parse the prefixes to compare network addresses - ipI, netI, _ := net.ParseCIDR(ipv4Prefixes[i].Prefix) - ipJ, netJ, _ := net.ParseCIDR(ipv4Prefixes[j].Prefix) - - // Compare by network address first - cmp := bytes.Compare(ipI.To4(), ipJ.To4()) - if cmp != 0 { - return cmp < 0 - } - - // If network addresses are equal, compare by mask length - onesI, _ := netI.Mask.Size() - onesJ, _ := netJ.Mask.Size() - - return onesI < onesJ - }) - - sort.Slice(ipv6Prefixes, func(i, j int) bool { - // Parse the prefixes to compare network addresses - ipI, netI, _ := net.ParseCIDR(ipv6Prefixes[i].Prefix) - ipJ, netJ, _ := net.ParseCIDR(ipv6Prefixes[j].Prefix) - - // Compare by network address first - cmp := bytes.Compare(ipI.To16(), ipJ.To16()) - if cmp != 0 { - return cmp < 0 - } - - // If network addresses are equal, compare by mask length - onesI, _ := netI.Mask.Size() - onesJ, _ := netJ.Mask.Size() - - return onesI < onesJ - }) + sortRoutesByNetwork(ipv4Prefixes) + sortRoutesByNetwork(ipv6Prefixes) // Prepare template data data := struct { @@ -891,8 +968,11 @@ func (s *Server) handleASDetail() http.HandlerFunc { } w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := templates.ASDetailTemplate() - if err := tmpl.Execute(w, data); err != nil { + + err = tmpl.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) } @@ -928,47 +1008,12 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { } // Group by origin AS and collect unique AS info - type ASNInfo struct { - ASN int - Handle string - Description string - PeerCount int - } - originMap := make(map[int]*ASNInfo) - for _, route := range routes { - if _, exists := originMap[route.OriginASN]; !exists { - // Get AS info from database - asInfo, _, _ := s.db.GetASDetailsContext(r.Context(), route.OriginASN) - handle := "" - description := "" - if asInfo != nil { - handle = asInfo.Handle - description = asInfo.Description - } - originMap[route.OriginASN] = &ASNInfo{ - ASN: route.OriginASN, - Handle: handle, - Description: description, - PeerCount: 0, - } - } - originMap[route.OriginASN].PeerCount++ - } + originMap := s.originASNs(r.Context(), routes) // Get the first route to extract some common info var maskLength, ipVersion int if len(routes) > 0 { - // Parse CIDR to get mask length and IP version - _, ipNet, err := net.ParseCIDR(prefix) - if err == nil { - ones, _ := ipNet.Mask.Size() - maskLength = ones - if ipNet.IP.To4() != nil { - ipVersion = 4 - } else { - ipVersion = 6 - } - } + maskLength, ipVersion = prefixMaskLengthAndIPVersion(prefix) } // Convert origin map to sorted slice @@ -978,33 +1023,7 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { } // Create enhanced routes with AS path handles - type ASPathEntry struct { - ASN int - Handle string - } - type EnhancedRoute struct { - database.LiveRoute - ASPathWithHandle []ASPathEntry - } - - 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 - } + enhancedRoutes := routesWithASPathHandles(routes) // Prepare template data data := struct { @@ -1034,16 +1053,127 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc { } w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := templates.PrefixDetailTemplate() - if err := tmpl.Execute(w, data); err != nil { + + err = tmpl.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) } } } -// handlePrefixLength shows a random sample of IPv4 prefixes with the specified mask length +// 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 groups routes by origin AS, with each AS's handle and description +// from the database and the number of routes it originates. +func (s *Server) originASNs( + ctx context.Context, + routes []database.LiveRoute, +) map[int]*ASNInfo { + originMap := make(map[int]*ASNInfo) + + for _, route := range routes { + if _, exists := originMap[route.OriginASN]; !exists { + // Get AS info from database + asInfo, _, _ := s.db.GetASDetailsContext(ctx, route.OriginASN) + handle := "" + description := "" + + if asInfo != nil { + handle = asInfo.Handle + description = asInfo.Description + } + + originMap[route.OriginASN] = &ASNInfo{ + ASN: route.OriginASN, + Handle: handle, + Description: description, + PeerCount: 0, + } + } + + originMap[route.OriginASN].PeerCount++ + } + + return originMap +} + +// 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 { + database.LiveRoute + + ASPathWithHandle []ASPathEntry +} + +// routesWithASPathHandles looks up the handle of each AS in the path of each +// route. +func routesWithASPathHandles(routes []database.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 +} + +// prefixMaskLengthAndIPVersion returns the mask length and IP version of a +// prefix in CIDR notation, or zeros if it does not parse. +func prefixMaskLengthAndIPVersion(prefix string) (int, int) { + _, ipNet, err := net.ParseCIDR(prefix) + if err != nil { + return 0, 0 + } + + maskLength, _ := ipNet.Mask.Size() + + if ipNet.IP.To4() != nil { + return maskLength, ipVersionV4 + } + + return maskLength, ipVersionV6 +} + +// 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 == "" { @@ -1059,19 +1189,19 @@ func (s *Server) handlePrefixLength() http.HandlerFunc { return } - // Validate IPv4 mask length - const maxIPv4MaskLength = 32 - if maskLength < 0 || maskLength > maxIPv4MaskLength { - http.Error(w, "Invalid IPv4 mask length", http.StatusBadRequest) + // Validate mask length + if maskLength < 0 || maskLength > maxMaskLength { + http.Error(w, "Invalid IPv"+strconv.Itoa(ipVersion)+" mask length", + http.StatusBadRequest) return } - const ipVersion = 4 - // Get random sample of prefixes const maxPrefixes = 500 - prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes) + + prefixes, err := s.db.GetRandomPrefixesByLengthContext( + r.Context(), maskLength, ipVersion, maxPrefixes) if err != nil { s.logger.Error("Failed to get prefixes by length", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1092,6 +1222,7 @@ func (s *Server) handlePrefixLength() http.HandlerFunc { // Create enhanced prefixes with AS descriptions type EnhancedPrefix struct { database.LiveRoute + OriginASDescription string Age string } @@ -1110,7 +1241,7 @@ func (s *Server) handlePrefixLength() http.HandlerFunc { } // Render template - data := map[string]interface{}{ + data := map[string]any{ "MaskLength": maskLength, "IPVersion": ipVersion, "Prefixes": enhancedPrefixes, @@ -1126,102 +1257,21 @@ func (s *Server) handlePrefixLength() http.HandlerFunc { } tmpl := templates.PrefixLengthTemplate() - if err := tmpl.Execute(w, data); err != nil { + + 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) } } } -// handlePrefixLength6 shows a random sample of IPv6 prefixes with the specified mask length +// handlePrefixLength6 shows a random sample of IPv6 prefixes with the +// specified mask length func (s *Server) handlePrefixLength6() 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) + const maxIPv6MaskLength = 128 - return - } - - maskLength, err := strconv.Atoi(lengthStr) - if err != nil { - http.Error(w, "Invalid mask length", http.StatusBadRequest) - - return - } - - // Validate IPv6 mask length - const maxIPv6MaskLength = 128 - if maskLength < 0 || maskLength > maxIPv6MaskLength { - http.Error(w, "Invalid IPv6 mask length", http.StatusBadRequest) - - return - } - - const ipVersion = 6 - - // Get random sample of prefixes - const maxPrefixes = 500 - prefixes, err := s.db.GetRandomPrefixesByLengthContext(r.Context(), maskLength, ipVersion, maxPrefixes) - if err != nil { - s.logger.Error("Failed to get prefixes by length", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - - return - } - - // Sort prefixes for display - sort.Slice(prefixes, func(i, j int) bool { - // First compare by IP version - if prefixes[i].IPVersion != prefixes[j].IPVersion { - return prefixes[i].IPVersion < prefixes[j].IPVersion - } - // Then by prefix - return prefixes[i].Prefix < prefixes[j].Prefix - }) - - // Create enhanced prefixes with AS descriptions - type EnhancedPrefix struct { - database.LiveRoute - OriginASDescription string - Age string - } - - enhancedPrefixes := make([]EnhancedPrefix, len(prefixes)) - for i, prefix := range prefixes { - enhancedPrefixes[i] = EnhancedPrefix{ - LiveRoute: prefix, - Age: formatAge(prefix.LastUpdated), - } - - // Get AS description - if asInfo, ok := asinfo.Get(prefix.OriginASN); ok { - enhancedPrefixes[i].OriginASDescription = asInfo.Description - } - } - - // Render template - data := map[string]interface{}{ - "MaskLength": maskLength, - "IPVersion": ipVersion, - "Prefixes": enhancedPrefixes, - "Count": len(prefixes), - } - - // 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() - if err := tmpl.Execute(w, data); err != nil { - s.logger.Error("Failed to render prefix length template", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - } + return s.prefixLengthHandler(ipVersionV6, maxIPv6MaskLength) } // formatAge returns a human-readable age string @@ -1230,13 +1280,14 @@ func formatAge(timestamp time.Time) string { const hoursPerDay = 24 - if age < time.Minute { + switch { + case age < time.Minute: return "< 1m" - } else if age < time.Hour { + case age < time.Hour: minutes := int(age.Minutes()) return strconv.Itoa(minutes) + "m" - } else if age < hoursPerDay*time.Hour { + case age < hoursPerDay*time.Hour: hours := int(age.Hours()) return strconv.Itoa(hours) + "h" diff --git a/internal/server/handlers_test.go b/internal/server/handlers_internal_test.go similarity index 75% rename from internal/server/handlers_test.go rename to internal/server/handlers_internal_test.go index 5bff6d4..99de4f8 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_internal_test.go @@ -24,6 +24,7 @@ import ( // path and would panic if called. type blockingStatsDB struct { database.Store + release chan struct{} } @@ -39,6 +40,8 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err // value. Before the fix the per-request goroutine sent on an unbuffered channel // that nothing received once the timeout won, so it blocked forever and every // poll leaked one goroutine. +// +//nolint:paralleltest // counts goroutines, so no other test may run alongside func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) { release := make(chan struct{}) db := blockingStatsDB{release: release} @@ -58,7 +61,7 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) { for _, handler := range handlers { for range iterations { ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) - req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil) handler(httptest.NewRecorder(), req) cancel() } @@ -83,27 +86,33 @@ func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) { // The oldest and newest route times still come from one-row lookups at the ends // of an index; with the database closed they are left out of the answer. func TestStatsHandlersAnswerFromMemory(t *testing.T) { + t.Parallel() + db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) if err != nil { t.Fatalf("database.New: %v", err) } ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - if err := db.UpsertLiveRouteBatch([]*database.LiveRoute{ + + err = db.UpsertLiveRouteBatch([]*database.LiveRoute{ { ID: uuid.New(), Prefix: "198.51.100.0/24", MaskLength: 24, IPVersion: 4, - OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500}, NextHop: "192.0.2.1", - LastUpdated: ts, + OriginASN: 64500, PeerIP: "192.0.2.1", ASPath: []int{64500}, + NextHop: "192.0.2.1", LastUpdated: ts, }, { ID: uuid.New(), Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6, - OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, NextHop: "2001:db8::1", - LastUpdated: ts, + OriginASN: 64501, PeerIP: "2001:db8::1", ASPath: []int{64501}, + NextHop: "2001:db8::1", LastUpdated: ts, }, - }); err != nil { + }) + if err != nil { t.Fatalf("UpsertLiveRouteBatch: %v", err) } - if err := db.Close(); err != nil { + + err = db.Close() + if err != nil { t.Fatalf("Close: %v", err) } @@ -115,35 +124,45 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) { for name, handler := range handlers { rec := httptest.NewRecorder() - handler(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + handler(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { - t.Errorf("%s: status %d, want %d; body %s", name, rec.Code, http.StatusOK, rec.Body) + t.Errorf("%s: status %d, want %d; body %s", + name, rec.Code, http.StatusOK, rec.Body) continue } + //nolint:tagliatelle // field names are the existing JSON API var body struct { Data struct { - IPv4Routes int `json:"ipv4_routes"` - IPv6Routes int `json:"ipv6_routes"` - IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` - IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` + IPv4Routes int `json:"ipv4_routes"` + IPv6Routes int `json:"ipv6_routes"` + IPv4Distribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"` + IPv6Distribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"` } `json:"data"` } - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + + err := json.Unmarshal(rec.Body.Bytes(), &body) + if err != nil { t.Fatalf("%s: decoding the answer: %v", name, err) } if body.Data.IPv4Routes != 1 || body.Data.IPv6Routes != 1 { - t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)", name, body.Data.IPv4Routes, body.Data.IPv6Routes) + t.Errorf("%s: routes = (v4 %d, v6 %d), want (1, 1)", + name, body.Data.IPv4Routes, body.Data.IPv6Routes) } + wantV4 := []database.PrefixDistribution{{MaskLength: 24, Count: 1}} - if !slices.Equal(body.Data.IPv4PrefixDistribution, wantV4) { - t.Errorf("%s: IPv4 distribution = %v, want %v", name, body.Data.IPv4PrefixDistribution, wantV4) + if !slices.Equal(body.Data.IPv4Distribution, wantV4) { + t.Errorf("%s: IPv4 distribution = %v, want %v", + name, body.Data.IPv4Distribution, wantV4) } + wantV6 := []database.PrefixDistribution{{MaskLength: 32, Count: 1}} - if !slices.Equal(body.Data.IPv6PrefixDistribution, wantV6) { - t.Errorf("%s: IPv6 distribution = %v, want %v", name, body.Data.IPv6PrefixDistribution, wantV6) + if !slices.Equal(body.Data.IPv6Distribution, wantV6) { + t.Errorf("%s: IPv6 distribution = %v, want %v", + name, body.Data.IPv6Distribution, wantV6) } } } @@ -152,12 +171,15 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) { // current count. func settledGoroutineCount() int { prev := runtime.NumGoroutine() + for range 20 { time.Sleep(10 * time.Millisecond) + cur := runtime.NumGoroutine() if cur == prev { return cur } + prev = cur } @@ -170,6 +192,7 @@ func waitForGoroutines(target int) bool { if runtime.NumGoroutine() <= target { return true } + time.Sleep(10 * time.Millisecond) } diff --git a/internal/server/middleware.go b/internal/server/middleware.go index dd61ee8..43e5ee9 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -14,6 +14,7 @@ import ( // responseWriter wraps http.ResponseWriter to capture the response type responseWriter struct { http.ResponseWriter + body *bytes.Buffer statusCode int written bool @@ -86,8 +87,10 @@ func JSONResponseMiddleware(next http.Handler) http.Handler { } // Parse the original response - var originalResponse map[string]interface{} - if err := json.Unmarshal(rw.body.Bytes(), &originalResponse); err != nil { + var originalResponse map[string]any + + err := json.Unmarshal(rw.body.Bytes(), &originalResponse) + if err != nil { // If we can't parse it, just write original w.WriteHeader(rw.statusCode) _, _ = w.Write(rw.body.Bytes()) @@ -96,7 +99,7 @@ func JSONResponseMiddleware(next http.Handler) http.Handler { } // Add @meta field - originalResponse["@meta"] = map[string]interface{}{ + originalResponse["@meta"] = map[string]any{ "time_zone": "UTC", "api_version": 1, "execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()), @@ -112,6 +115,7 @@ func JSONResponseMiddleware(next http.Handler) http.Handler { // timeoutWriter wraps ResponseWriter to prevent concurrent writes after timeout type timeoutWriter struct { http.ResponseWriter + mu sync.Mutex written bool header http.Header // cached header to prevent concurrent access @@ -158,6 +162,7 @@ func (tw *timeoutWriter) Header() http.Header { func (tw *timeoutWriter) markWritten() { tw.mu.Lock() defer tw.mu.Unlock() + tw.written = true } @@ -168,7 +173,10 @@ func (tw *timeoutWriter) markWritten() { // The timeout parameter specifies the maximum duration allowed for request processing. // The returned middleware handles panics from the wrapped handler by re-panicking // after cleanup, and prevents concurrent writes to the response after timeout occurs. -func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Handler) http.Handler { +func TimeoutMiddleware( + timeout time.Duration, + logger *slog.Logger, +) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { startTime := time.Now() @@ -182,7 +190,7 @@ func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Han } done := make(chan struct{}) - panicChan := make(chan interface{}, 1) + panicChan := make(chan any, 1) go func() { defer func() { @@ -202,6 +210,7 @@ func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Han return case <-ctx.Done(): tw.markWritten() // Prevent the handler from writing after timeout + execTime := time.Since(startTime) // Log the timeout as a warning @@ -219,13 +228,13 @@ func TimeoutMiddleware(timeout time.Duration, logger *slog.Logger) func(http.Han w.WriteHeader(http.StatusRequestTimeout) tw.mu.Unlock() - response := map[string]interface{}{ - "status": "error", - "error": map[string]interface{}{ - "msg": "Request timeout", - "code": http.StatusRequestTimeout, + response := map[string]any{ + jsonKeyStatus: jsonStatusError, + jsonKeyError: map[string]any{ + jsonKeyMsg: "Request timeout", + jsonKeyCode: http.StatusRequestTimeout, }, - "@meta": map[string]interface{}{ + "@meta": map[string]any{ "time_zone": "UTC", "api_version": 1, "execution_time": fmt.Sprintf("%d ms", execTime.Milliseconds()), @@ -273,8 +282,10 @@ func JSONValidationMiddleware(next http.Handler) http.Handler { // For JSON responses, validate the JSON if rw.body.Len() > 0 { - var testParse interface{} - if err := json.Unmarshal(rw.body.Bytes(), &testParse); err == nil { + var testParse any + + err := json.Unmarshal(rw.body.Bytes(), &testParse) + if err == nil { // Valid JSON, write it out w.Header().Set("Content-Type", "application/json") w.WriteHeader(rw.statusCode) @@ -293,6 +304,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler { if statusCode == http.StatusOK { statusCode = http.StatusInternalServerError } + w.WriteHeader(statusCode) errorMsg := "Internal server error" @@ -300,11 +312,11 @@ func JSONValidationMiddleware(next http.Handler) http.Handler { errorMsg = "Request timeout" } - response := map[string]interface{}{ - "status": "error", - "error": map[string]interface{}{ - "msg": errorMsg, - "code": statusCode, + response := map[string]any{ + jsonKeyStatus: jsonStatusError, + jsonKeyError: map[string]any{ + jsonKeyMsg: errorMsg, + jsonKeyCode: statusCode, }, } _ = json.NewEncoder(w).Encode(response) @@ -314,6 +326,7 @@ func JSONValidationMiddleware(next http.Handler) http.Handler { // statusWriter wraps http.ResponseWriter to capture the status code type statusWriter struct { http.ResponseWriter + statusCode int written bool } @@ -323,6 +336,7 @@ func (sw *statusWriter) WriteHeader(statusCode int) { sw.statusCode = statusCode sw.written = true } + sw.ResponseWriter.WriteHeader(statusCode) } @@ -362,11 +376,12 @@ func RequestLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handle // Slow query threshold (1 second) const slowQueryThreshold = 1 * time.Second - if sw.statusCode >= http.StatusInternalServerError { + switch { + case sw.statusCode >= http.StatusInternalServerError: logLevel = slog.LevelError - } else if sw.statusCode >= http.StatusBadRequest { + case sw.statusCode >= http.StatusBadRequest: logLevel = slog.LevelWarn - } else if duration >= slowQueryThreshold { + case duration >= slowQueryThreshold: logLevel = slog.LevelWarn } diff --git a/internal/server/routes.go b/internal/server/routes.go index cde3293..82ce950 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -16,7 +16,9 @@ func (s *Server) setupRoutes() { r.Use(middleware.RealIP) r.Use(RequestLoggerMiddleware(s.logger.Logger)) // Structured request logging r.Use(middleware.Recoverer) + const requestTimeout = 30 * time.Second // Increased from 8s for slow queries + r.Use(TimeoutMiddleware(requestTimeout, s.logger.Logger)) r.Use(JSONResponseMiddleware) @@ -24,7 +26,10 @@ func (s *Server) setupRoutes() { r.Get("/", s.handleIndex()) r.Get("/status", s.handleStatusHTML()) r.Get("/status.json", JSONValidationMiddleware(s.handleStatusJSON()).ServeHTTP) - r.Get("/.well-known/healthcheck.json", JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP) + r.Get( + "/.well-known/healthcheck.json", + JSONValidationMiddleware(s.handleHealthCheck()).ServeHTTP, + ) // AS and prefix detail pages r.Get("/as/{asn}", s.handleASDetail()) diff --git a/internal/server/server.go b/internal/server/server.go index 10f8649..7e77776 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "context" + "errors" "net/http" "strconv" "time" @@ -40,7 +41,12 @@ type Server struct { } // New creates a new HTTP server -func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger, cfg *config.Config) *Server { +func New( + db database.Store, + streamer *streamer.Streamer, + logger *logger.Logger, + cfg *config.Config, +) *Server { s := &Server{ db: db, streamer: streamer, @@ -76,7 +82,9 @@ func (s *Server) Start() error { // Start in goroutine but log when actually listening go func() { s.logger.Info("HTTP server listening", "addr", s.srv.Addr) - if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + + err := s.srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { s.logger.Error("HTTP server error", "error", err) } }()