From 27ae80ea2e6f6124581f8ecbdff1294c1e67ca02 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 28 Jul 2025 04:00:12 +0200 Subject: [PATCH] Refactor server package: split handlers and routes into separate files - Move all handler functions to handlers.go - Move setupRoutes to routes.go - Clean up server.go to only contain core server logic - Add missing GetASDetails and GetPrefixDetails to mockStore for tests - Fix linter errors (magic numbers, unused parameters, blank lines) --- internal/database/database.go | 98 ++++ internal/database/interface.go | 4 + internal/routewatch/app_integration_test.go | 20 + internal/server/handlers.go | 473 ++++++++++++++++++++ internal/server/routes.go | 41 ++ internal/server/server.go | 387 ---------------- 6 files changed, 636 insertions(+), 387 deletions(-) create mode 100644 internal/server/handlers.go create mode 100644 internal/server/routes.go diff --git a/internal/database/database.go b/internal/database/database.go index a03a3b9..2e58561 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -743,3 +743,101 @@ func CalculateIPv4Range(cidr string) (start, end uint32, err error) { return start, end, nil } + +// GetASDetails returns detailed information about an AS including prefixes +func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) { + // Get AS information + var asnInfo ASN + var idStr string + var handle, description sql.NullString + err := d.db.QueryRow( + "SELECT id, number, handle, description, first_seen, last_seen FROM asns WHERE number = ?", + asn, + ).Scan(&idStr, &asnInfo.Number, &handle, &description, &asnInfo.FirstSeen, &asnInfo.LastSeen) + + if err != nil { + if err == sql.ErrNoRows { + return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) + } + + return nil, nil, fmt.Errorf("failed to query AS: %w", err) + } + + asnInfo.ID, _ = uuid.Parse(idStr) + asnInfo.Handle = handle.String + asnInfo.Description = description.String + + // Get prefixes announced by this AS + query := ` + SELECT DISTINCT prefix, mask_length, ip_version, last_updated + FROM live_routes + WHERE origin_asn = ? + ORDER BY ip_version, mask_length, prefix + ` + + rows, err := d.db.Query(query, asn) + if err != nil { + return &asnInfo, nil, fmt.Errorf("failed to query prefixes: %w", err) + } + defer func() { _ = rows.Close() }() + + var prefixes []LiveRoute + for rows.Next() { + var route LiveRoute + err := rows.Scan(&route.Prefix, &route.MaskLength, &route.IPVersion, &route.LastUpdated) + if err != nil { + continue + } + route.OriginASN = asn + prefixes = append(prefixes, route) + } + + return &asnInfo, prefixes, nil +} + +// GetPrefixDetails returns detailed information about a prefix +func (d *Database) GetPrefixDetails(prefix string) ([]LiveRoute, error) { + query := ` + SELECT lr.origin_asn, lr.peer_ip, lr.as_path, lr.next_hop, lr.last_updated, + a.handle, a.description + FROM live_routes lr + LEFT JOIN asns a ON a.number = lr.origin_asn + WHERE lr.prefix = ? + ORDER BY lr.origin_asn, lr.peer_ip + ` + + rows, err := d.db.Query(query, prefix) + if err != nil { + return nil, fmt.Errorf("failed to query prefix details: %w", err) + } + defer func() { _ = rows.Close() }() + + var routes []LiveRoute + for rows.Next() { + var route LiveRoute + var pathJSON string + var handle, description sql.NullString + + err := rows.Scan( + &route.OriginASN, &route.PeerIP, &pathJSON, &route.NextHop, + &route.LastUpdated, &handle, &description, + ) + if err != nil { + continue + } + + // Decode AS path + if err := json.Unmarshal([]byte(pathJSON), &route.ASPath); err != nil { + route.ASPath = []int{} + } + + route.Prefix = prefix + routes = append(routes, route) + } + + if len(routes) == 0 { + return nil, fmt.Errorf("%w: %s", ErrNoRoute, prefix) + } + + return routes, nil +} diff --git a/internal/database/interface.go b/internal/database/interface.go index 98e1d2d..e67f8de 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -47,6 +47,10 @@ type Store interface { // IP lookup operations GetASInfoForIP(ip string) (*ASInfo, error) + // AS and prefix detail operations + GetASDetails(asn int) (*ASN, []LiveRoute, error) + GetPrefixDetails(prefix string) ([]LiveRoute, error) + // Lifecycle Close() error } diff --git a/internal/routewatch/app_integration_test.go b/internal/routewatch/app_integration_test.go index 5446d17..efb69b5 100644 --- a/internal/routewatch/app_integration_test.go +++ b/internal/routewatch/app_integration_test.go @@ -201,6 +201,26 @@ func (m *mockStore) GetASInfoForIP(ip string) (*database.ASInfo, error) { }, nil } +// GetASDetails mock implementation +func (m *mockStore) GetASDetails(asn int) (*database.ASN, []database.LiveRoute, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if ASN exists + if asnInfo, exists := m.ASNs[asn]; exists { + // Return empty prefixes for now + return asnInfo, []database.LiveRoute{}, nil + } + + return nil, nil, database.ErrNoRoute +} + +// GetPrefixDetails mock implementation +func (m *mockStore) GetPrefixDetails(prefix string) ([]database.LiveRoute, error) { + // Return empty routes for now + return []database.LiveRoute{}, nil +} + func TestRouteWatchLiveFeed(t *testing.T) { // Create mock database diff --git a/internal/server/handlers.go b/internal/server/handlers.go new file mode 100644 index 0000000..d34131d --- /dev/null +++ b/internal/server/handlers.go @@ -0,0 +1,473 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "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) { + prefix := chi.URLParam(r, "prefix") + if prefix == "" { + writeJSONError(w, http.StatusBadRequest, "Prefix parameter is required") + + 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, _ *http.Request) { + // TODO: Implement AS detail HTML page + http.Error(w, "Not implemented", http.StatusNotImplemented) + } +} + +// handlePrefixDetail returns a handler that serves the prefix detail HTML page +func (s *Server) handlePrefixDetail() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + // TODO: Implement prefix detail HTML page + http.Error(w, "Not implemented", http.StatusNotImplemented) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..cce24cd --- /dev/null +++ b/internal/server/routes.go @@ -0,0 +1,41 @@ +package server + +import ( + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +// setupRoutes configures the HTTP routes +func (s *Server) setupRoutes() { + r := chi.NewRouter() + + // Middleware + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + const requestTimeout = 2 * time.Second + r.Use(TimeoutMiddleware(requestTimeout)) + r.Use(JSONResponseMiddleware) + + // Routes + r.Get("/", s.handleRoot()) + r.Get("/status", s.handleStatusHTML()) + r.Get("/status.json", s.handleStatusJSON()) + + // AS and prefix detail pages + r.Get("/as/{asn}", s.handleASDetail()) + r.Get("/prefix/{prefix}", s.handlePrefixDetail()) + + // API routes + r.Route("/api/v1", func(r chi.Router) { + r.Get("/stats", s.handleStats()) + r.Get("/ip/{ip}", s.handleIPLookup()) + r.Get("/as/{asn}", s.handleASDetailJSON()) + r.Get("/prefix/{prefix}", s.handlePrefixDetailJSON()) + }) + + s.router = r +} diff --git a/internal/server/server.go b/internal/server/server.go index e73f8bf..3211bd3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,20 +3,14 @@ package server import ( "context" - "encoding/json" - "errors" "net/http" "os" - "runtime" "time" "git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/streamer" - "git.eeqj.de/sneak/routewatch/internal/templates" - "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" ) // Server provides HTTP endpoints for status monitoring @@ -41,33 +35,6 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger) return s } -// setupRoutes configures the HTTP routes -func (s *Server) setupRoutes() { - r := chi.NewRouter() - - // Middleware - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - const requestTimeout = 2 * time.Second - r.Use(TimeoutMiddleware(requestTimeout)) - r.Use(JSONResponseMiddleware) - - // Routes - r.Get("/", s.handleRoot()) - r.Get("/status", s.handleStatusHTML()) - r.Get("/status.json", s.handleStatusJSON()) - - // API routes - r.Route("/api/v1", func(r chi.Router) { - r.Get("/stats", s.handleStats()) - r.Get("/ip/{ip}", s.handleIPLookup()) - }) - - s.router = r -} - // Start starts the HTTP server func (s *Server) Start() error { port := os.Getenv("PORT") @@ -103,357 +70,3 @@ func (s *Server) Stop(ctx context.Context) error { return s.srv.Shutdown(ctx) } - -// 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) - } - } -}