package server import ( "context" "encoding/json" "errors" "net" "net/http" "net/url" "runtime" "strconv" "time" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/templates" "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" ) // handleRoot returns a handler that redirects to /status func (s *Server) handleRoot() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/status", http.StatusSeeOther) } } // writeJSONError writes a standardized JSON error response func 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, }, }) } // writeJSONSuccess writes a standardized JSON success response func writeJSONSuccess(w http.ResponseWriter, data interface{}) error { w.Header().Set("Content-Type", "application/json") return json.NewEncoder(w).Encode(map[string]interface{}{ "status": "ok", "data": data, }) } // handleStatusJSON returns a handler that serves JSON statistics 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"` MessagesPerSec float64 `json:"messages_per_sec"` MbitsPerSec float64 `json:"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"` 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"` } return func(w http.ResponseWriter, r *http.Request) { // Create a 1 second timeout context for this request ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) defer cancel() metrics := s.streamer.GetMetrics() // Get database stats with timeout statsChan := make(chan database.Stats) errChan := make(chan error) go func() { dbStats, err := s.db.GetStats() 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(): s.logger.Error("Database stats timeout in status.json") 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" } const bitsPerMegabit = 1000000.0 // Get route counts from database ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts() if err != nil { s.logger.Warn("Failed to get live route counts", "error", err) // Continue with zero counts } // Get route update metrics routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics() // Get memory stats var memStats runtime.MemStats runtime.ReadMemStats(&memStats) stats := Stats{ Uptime: uptime, TotalMessages: metrics.TotalMessages, TotalBytes: metrics.TotalBytes, MessagesPerSec: metrics.MessagesPerSec, MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, Connected: metrics.Connected, GoVersion: runtime.Version(), Goroutines: runtime.NumGoroutine(), MemoryUsage: humanize.Bytes(memStats.Alloc), ASNs: dbStats.ASNs, Prefixes: dbStats.Prefixes, IPv4Prefixes: dbStats.IPv4Prefixes, IPv6Prefixes: dbStats.IPv6Prefixes, Peerings: dbStats.Peerings, Peers: dbStats.Peers, DatabaseSizeBytes: dbStats.FileSizeBytes, LiveRoutes: dbStats.LiveRoutes, IPv4Routes: ipv4Routes, IPv6Routes: ipv6Routes, IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec, IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec, IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution, IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution, } if err := writeJSONSuccess(w, stats); err != nil { s.logger.Error("Failed to encode stats", "error", err) } } } // handleStats returns a handler that serves API v1 statistics 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"` 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"` } // StatsResponse represents the API statistics response type StatsResponse struct { Uptime string `json:"uptime"` TotalMessages uint64 `json:"total_messages"` TotalBytes uint64 `json:"total_bytes"` MessagesPerSec float64 `json:"messages_per_sec"` MbitsPerSec float64 `json:"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"` 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"` } return func(w http.ResponseWriter, r *http.Request) { // Create a 1 second timeout context for this request ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) defer cancel() // Check if context is already cancelled select { case <-ctx.Done(): http.Error(w, "Request timeout", http.StatusRequestTimeout) return default: } metrics := s.streamer.GetMetrics() // Get database stats with timeout statsChan := make(chan database.Stats) errChan := make(chan error) go func() { dbStats, err := s.db.GetStats() 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(): s.logger.Error("Database stats timeout") http.Error(w, "Database timeout", http.StatusRequestTimeout) return case err := <-errChan: s.logger.Error("Failed to get database stats", "error", err) http.Error(w, err.Error(), http.StatusInternalServerError) return case dbStats = <-statsChan: // Success } uptime := time.Since(metrics.ConnectedSince).Truncate(time.Second).String() if metrics.ConnectedSince.IsZero() { uptime = "0s" } const bitsPerMegabit = 1000000.0 // Get route counts from database ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCounts() if err != nil { s.logger.Warn("Failed to get live route counts", "error", err) // Continue with zero counts } // 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, 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) stats := StatsResponse{ Uptime: uptime, TotalMessages: metrics.TotalMessages, TotalBytes: metrics.TotalBytes, MessagesPerSec: metrics.MessagesPerSec, MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit, Connected: metrics.Connected, GoVersion: runtime.Version(), Goroutines: runtime.NumGoroutine(), MemoryUsage: humanize.Bytes(memStats.Alloc), ASNs: dbStats.ASNs, Prefixes: dbStats.Prefixes, IPv4Prefixes: dbStats.IPv4Prefixes, IPv6Prefixes: dbStats.IPv6Prefixes, Peerings: dbStats.Peerings, Peers: dbStats.Peers, DatabaseSizeBytes: dbStats.FileSizeBytes, LiveRoutes: dbStats.LiveRoutes, IPv4Routes: ipv4Routes, IPv6Routes: ipv6Routes, IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec, IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec, HandlerStats: handlerStatsInfo, IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution, IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution, } if err := writeJSONSuccess(w, stats); err != nil { s.logger.Error("Failed to encode stats", "error", err) } } } // handleStatusHTML returns a handler that serves the HTML status page func (s *Server) handleStatusHTML() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl := templates.StatusTemplate() if err := tmpl.Execute(w, nil); 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 func(w http.ResponseWriter, r *http.Request) { ip := chi.URLParam(r, "ip") if ip == "" { writeJSONError(w, http.StatusBadRequest, "IP parameter is required") return } // Look up AS information for the IP asInfo, err := s.db.GetASInfoForIP(ip) if err != nil { // Check if it's an invalid IP error if errors.Is(err, database.ErrInvalidIP) { writeJSONError(w, http.StatusBadRequest, err.Error()) } else { // All other errors (including ErrNoRoute) are 404 writeJSONError(w, http.StatusNotFound, err.Error()) } return } // Return successful response if err := writeJSONSuccess(w, asInfo); err != nil { s.logger.Error("Failed to encode AS info", "error", err) } } } // 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 { writeJSONError(w, http.StatusBadRequest, "Invalid ASN") return } asInfo, prefixes, err := s.db.GetASDetails(asn) if err != nil { if errors.Is(err, database.ErrNoRoute) { writeJSONError(w, http.StatusNotFound, err.Error()) } else { 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) } } response := map[string]interface{}{ "asn": asInfo, "ipv4_prefixes": ipv4Prefixes, "ipv6_prefixes": ipv6Prefixes, "total_count": len(prefixes), } if err := writeJSONSuccess(w, response); err != nil { s.logger.Error("Failed to encode AS details", "error", err) } } } // handlePrefixDetailJSON returns prefix details as JSON func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { prefixParam := chi.URLParam(r, "prefix") if prefixParam == "" { writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required") return } // URL decode the prefix parameter prefix, err := url.QueryUnescape(prefixParam) if err != nil { writeJSONError(w, http.StatusBadRequest, "Invalid prefix parameter") return } routes, err := s.db.GetPrefixDetails(prefix) if err != nil { if errors.Is(err, database.ErrNoRoute) { writeJSONError(w, http.StatusNotFound, err.Error()) } else { writeJSONError(w, http.StatusInternalServerError, err.Error()) } return } // Group by origin AS originMap := make(map[int][]database.LiveRoute) for _, route := range routes { originMap[route.OriginASN] = append(originMap[route.OriginASN], route) } response := map[string]interface{}{ "prefix": prefix, "routes": routes, "origins": originMap, "peer_count": len(routes), "origin_count": len(originMap), } if err := writeJSONSuccess(w, response); err != nil { s.logger.Error("Failed to encode prefix details", "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.db.GetASDetails(asn) if err != nil { if errors.Is(err, database.ErrNoRoute) { http.Error(w, "AS not found", http.StatusNotFound) } else { s.logger.Error("Failed to get AS details", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } 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) } } // Prepare template data data := struct { ASN *database.ASN IPv4Prefixes []database.LiveRoute IPv6Prefixes []database.LiveRoute TotalCount int IPv4Count int IPv6Count int }{ ASN: asInfo, IPv4Prefixes: ipv4Prefixes, IPv6Prefixes: ipv6Prefixes, TotalCount: len(prefixes), IPv4Count: len(ipv4Prefixes), IPv6Count: len(ipv6Prefixes), } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl := templates.ASDetailTemplate() if err := tmpl.Execute(w, data); 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) { prefixParam := chi.URLParam(r, "prefix") if prefixParam == "" { http.Error(w, "Prefix parameter is required", http.StatusBadRequest) return } // URL decode the prefix parameter prefix, err := url.QueryUnescape(prefixParam) if err != nil { http.Error(w, "Invalid prefix parameter", http.StatusBadRequest) return } routes, err := s.db.GetPrefixDetails(prefix) if err != nil { if errors.Is(err, database.ErrNoRoute) { http.Error(w, "Prefix not found", http.StatusNotFound) } else { s.logger.Error("Failed to get prefix details", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } return } // Group by origin AS and collect unique AS info type ASNInfo struct { Number 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.GetASDetails(route.OriginASN) handle := "" description := "" if asInfo != nil { handle = asInfo.Handle description = asInfo.Description } originMap[route.OriginASN] = &ASNInfo{ Number: route.OriginASN, Handle: handle, Description: description, PeerCount: 0, } } originMap[route.OriginASN].PeerCount++ } // 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 } } } // Convert origin map to sorted slice var origins []*ASNInfo for _, origin := range originMap { origins = append(origins, origin) } // Prepare template data data := struct { Prefix string MaskLength int IPVersion int Routes []database.LiveRoute Origins []*ASNInfo PeerCount int OriginCount int }{ Prefix: prefix, MaskLength: maskLength, IPVersion: ipVersion, Routes: routes, Origins: origins, PeerCount: len(routes), OriginCount: len(originMap), } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl := templates.PrefixDetailTemplate() if err := tmpl.Execute(w, data); err != nil { s.logger.Error("Failed to render prefix detail template", "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } } // handleIPRedirect looks up the prefix containing the IP and redirects to its detail page func (s *Server) handleIPRedirect() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip := chi.URLParam(r, "ip") if ip == "" { http.Error(w, "IP parameter is required", http.StatusBadRequest) return } // Look up AS information for the IP (which includes the prefix) asInfo, err := s.db.GetASInfoForIP(ip) if err != nil { if errors.Is(err, database.ErrInvalidIP) { http.Error(w, "Invalid IP address", http.StatusBadRequest) } else if errors.Is(err, database.ErrNoRoute) { http.Error(w, "No route found for this IP", http.StatusNotFound) } else { s.logger.Error("Failed to look up IP", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } return } // Redirect to the prefix detail page (URL encode the prefix) http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther) } }