check / check (push) Failing after 1s
The stats handlers ran the database query in a goroutine that sent on an unbuffered channel. When the 4s request timeout won, nothing received and the goroutine blocked forever; the status page polls every 2s, so once the query exceeds the timeout every poll leaked one goroutine. Give both channels capacity 1 so the send always completes. The streamer started two ticker goroutines per connection that exited only with the streamer's lifetime context, leaking two on every reconnect. Scope them to a per-connection context cancelled when the stream call returns. Tests force the stats timeout repeatedly and drive many reconnects, then assert the goroutine count settles back to its starting value. The streamer gains an internal endpoint field so a test can point it at a local server. Model: opus-4-8
1263 lines
39 KiB
Go
1263 lines
39 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
|
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
// statsContextTimeout is the timeout for stats API operations.
|
|
statsContextTimeout = 4 * time.Second
|
|
|
|
// healthCheckTimeout is the timeout for health check operations.
|
|
healthCheckTimeout = 2 * time.Second
|
|
)
|
|
|
|
// HealthCheckResponse represents the health check response.
|
|
type HealthCheckResponse struct {
|
|
Status string `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
Checks map[string]string `json:"checks"`
|
|
}
|
|
|
|
// handleHealthCheck returns a handler that performs health checks.
|
|
// Returns 200 if healthy, 503 if any check fails.
|
|
// Uses lightweight checks to avoid timeout issues under load.
|
|
func (s *Server) handleHealthCheck() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout)
|
|
defer cancel()
|
|
|
|
checks := make(map[string]string)
|
|
healthy := true
|
|
|
|
// Check database connectivity with lightweight ping
|
|
err := s.db.Ping(ctx)
|
|
if err != nil {
|
|
checks["database"] = "error: " + err.Error()
|
|
healthy = false
|
|
} else {
|
|
checks["database"] = "ok"
|
|
}
|
|
|
|
// Check streamer connection
|
|
metrics := s.streamer.GetMetrics()
|
|
if metrics.Connected {
|
|
checks["ris_live"] = "ok"
|
|
} else {
|
|
checks["ris_live"] = "disconnected"
|
|
healthy = false
|
|
}
|
|
|
|
// Build response
|
|
status := "ok"
|
|
if !healthy {
|
|
status = "error"
|
|
}
|
|
|
|
response := HealthCheckResponse{
|
|
Status: status,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Checks: checks,
|
|
}
|
|
|
|
if !healthy {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
|
|
if err := writeJSONSuccess(w, response); err != nil {
|
|
s.logger.Error("Failed to encode health check response", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleIndex returns a handler that serves the home page.
|
|
func (s *Server) handleIndex() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
tmpl := templates.IndexTemplate()
|
|
if err := tmpl.Execute(w, nil); err != nil {
|
|
s.logger.Error("Failed to render index template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeJSONError writes a standardized JSON error response with the given
|
|
// status code and error message.
|
|
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 containing
|
|
// the provided data wrapped in a status envelope.
|
|
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,
|
|
})
|
|
}
|
|
|
|
// WHOISStatsInfo contains WHOIS fetcher statistics for the status page.
|
|
type WHOISStatsInfo struct {
|
|
TotalASNs int `json:"total_asns"`
|
|
FreshASNs int `json:"fresh_asns"`
|
|
StaleASNs int `json:"stale_asns"`
|
|
NeverFetched int `json:"never_fetched"`
|
|
SuccessesLastHour int `json:"successes_last_hour"`
|
|
ErrorsLastHour int `json:"errors_last_hour"`
|
|
CurrentInterval string `json:"current_interval"`
|
|
ConsecutiveFails int `json:"consecutive_fails"`
|
|
FreshPercent float64 `json:"fresh_percent"`
|
|
}
|
|
|
|
// 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)
|
|
defer cancel()
|
|
|
|
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():
|
|
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.GetLiveRouteCountsContext(ctx)
|
|
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)
|
|
|
|
// Get WHOIS stats if fetcher is available
|
|
var whoisStats *WHOISStatsInfo
|
|
if s.asnFetcher != nil {
|
|
whoisStats = s.getWHOISStats(ctx)
|
|
}
|
|
|
|
stats := Stats{
|
|
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,
|
|
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,
|
|
OldestRoute: dbStats.OldestRoute,
|
|
NewestRoute: dbStats.NewestRoute,
|
|
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
|
|
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
|
|
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
|
|
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
|
|
WHOISStats: whoisStats,
|
|
}
|
|
|
|
if err := writeJSONSuccess(w, stats); err != nil {
|
|
s.logger.Error("Failed to encode stats", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getWHOISStats builds WHOIS statistics from database and fetcher.
|
|
func (s *Server) getWHOISStats(ctx context.Context) *WHOISStatsInfo {
|
|
// Get database WHOIS stats
|
|
dbStats, err := s.db.GetWHOISStats(ctx, whoisStaleThreshold)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to get WHOIS stats", "error", err)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get fetcher stats
|
|
fetcherStats := s.asnFetcher.GetStats()
|
|
|
|
// Calculate fresh percentage
|
|
var freshPercent float64
|
|
if dbStats.TotalASNs > 0 {
|
|
freshPercent = float64(dbStats.FreshASNs) / float64(dbStats.TotalASNs) * percentMultiplier
|
|
}
|
|
|
|
return &WHOISStatsInfo{
|
|
TotalASNs: dbStats.TotalASNs,
|
|
FreshASNs: dbStats.FreshASNs,
|
|
StaleASNs: dbStats.StaleASNs,
|
|
NeverFetched: dbStats.NeverFetched,
|
|
SuccessesLastHour: fetcherStats.SuccessesLastHour,
|
|
ErrorsLastHour: fetcherStats.ErrorsLastHour,
|
|
CurrentInterval: fetcherStats.CurrentInterval.String(),
|
|
ConsecutiveFails: fetcherStats.ConsecutiveFails,
|
|
FreshPercent: freshPercent,
|
|
}
|
|
}
|
|
|
|
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
|
const whoisStaleThreshold = 30 * 24 * time.Hour
|
|
|
|
// percentMultiplier converts a ratio to a percentage.
|
|
const percentMultiplier = 100
|
|
|
|
// 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)
|
|
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. 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():
|
|
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"
|
|
}
|
|
|
|
const bitsPerMegabit = 1000000.0
|
|
|
|
// Get route counts from database
|
|
ipv4Routes, ipv6Routes, err := s.db.GetLiveRouteCountsContext(ctx)
|
|
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,
|
|
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
|
|
}
|
|
|
|
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,
|
|
},
|
|
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,
|
|
OldestRoute: dbStats.OldestRoute,
|
|
NewestRoute: dbStats.NewestRoute,
|
|
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
|
|
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
|
|
HandlerStats: handlerStatsInfo,
|
|
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
|
|
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
|
|
WHOISStats: whoisStats,
|
|
}
|
|
|
|
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,
|
|
// which displays real-time statistics fetched via JavaScript.
|
|
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 s.handleIPInfo()
|
|
}
|
|
|
|
// IPLookupResponse is the standard response for IP/hostname lookups.
|
|
type IPLookupResponse struct {
|
|
Query string `json:"query"`
|
|
Results []*database.IPInfo `json:"results"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// handleIPInfo returns a handler that provides comprehensive IP information.
|
|
// Used for /ip, /ip/{addr}, and /api/v1/ip/{ip} endpoints.
|
|
// Accepts IP addresses (single or comma-separated) and hostnames.
|
|
// Always returns the same response structure with PTR records for each IP.
|
|
func (s *Server) handleIPInfo() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get IP/hostname from URL param, falling back to client IP
|
|
target := chi.URLParam(r, "ip")
|
|
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")
|
|
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
response := IPLookupResponse{
|
|
Query: target,
|
|
Results: make([]*database.IPInfo, 0),
|
|
}
|
|
|
|
// 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...)
|
|
}
|
|
}
|
|
|
|
if len(ipsToLookup) == 0 {
|
|
writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided")
|
|
|
|
return
|
|
}
|
|
|
|
// Track ASNs that need WHOIS refresh
|
|
refreshASNs := make(map[int]bool)
|
|
|
|
// Look up each IP
|
|
for _, ip := range ipsToLookup {
|
|
ipInfo, err := s.db.GetIPInfoContext(ctx, ip)
|
|
if err != nil {
|
|
response.Errors = append(response.Errors, ip+": "+err.Error())
|
|
|
|
continue
|
|
}
|
|
|
|
// 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, ".")
|
|
}
|
|
ipInfo.PTR = ptrs
|
|
}
|
|
|
|
response.Results = append(response.Results, ipInfo)
|
|
|
|
if ipInfo.NeedsWHOISRefresh {
|
|
refreshASNs[ipInfo.ASN] = true
|
|
}
|
|
}
|
|
|
|
// Queue WHOIS refresh for stale ASNs (non-blocking)
|
|
if s.asnFetcher != nil {
|
|
for asn := range refreshASNs {
|
|
s.asnFetcher.QueueImmediate(asn)
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
|
|
return
|
|
}
|
|
|
|
if err := writeJSONSuccess(w, response); err != nil {
|
|
s.logger.Error("Failed to encode IP lookup response", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractClientIP extracts the client IP from the request.
|
|
// Works with chi's RealIP middleware which sets RemoteAddr.
|
|
func extractClientIP(r *http.Request) string {
|
|
// RemoteAddr is in the form "IP:port" or just "IP" for unix sockets
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
// Might be just an IP without port
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
// handleASDetailJSON returns AS details as JSON
|
|
func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
asnStr := chi.URLParam(r, "asn")
|
|
asn, err := strconv.Atoi(asnStr)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Invalid ASN")
|
|
|
|
return
|
|
}
|
|
|
|
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), 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) {
|
|
// Get prefix and length from URL params
|
|
prefixParam := chi.URLParam(r, "prefix")
|
|
lenParam := chi.URLParam(r, "len")
|
|
|
|
if prefixParam == "" || lenParam == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Prefix and length parameters are required")
|
|
|
|
return
|
|
}
|
|
|
|
// Combine prefix and length into CIDR notation
|
|
prefix := prefixParam + "/" + lenParam
|
|
|
|
routes, err := s.db.GetPrefixDetailsContext(r.Context(), 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.GetASDetailsContext(r.Context(), 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
|
|
}
|
|
|
|
// Get peers
|
|
peers, err := s.db.GetASPeersContext(r.Context(), asn)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get AS peers", "error", err)
|
|
// Continue without peers rather than failing the whole request
|
|
peers = []database.ASPeer{}
|
|
}
|
|
|
|
// Group prefixes by IP version
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
})
|
|
|
|
// Prepare template data
|
|
data := struct {
|
|
ASN *database.ASN
|
|
IPv4Prefixes []database.LiveRoute
|
|
IPv6Prefixes []database.LiveRoute
|
|
TotalCount int
|
|
IPv4Count int
|
|
IPv6Count int
|
|
Peers []database.ASPeer
|
|
PeerCount int
|
|
}{
|
|
ASN: asInfo,
|
|
IPv4Prefixes: ipv4Prefixes,
|
|
IPv6Prefixes: ipv6Prefixes,
|
|
TotalCount: len(prefixes),
|
|
IPv4Count: len(ipv4Prefixes),
|
|
IPv6Count: len(ipv6Prefixes),
|
|
Peers: peers,
|
|
PeerCount: len(peers),
|
|
}
|
|
|
|
// Check if context is still valid before writing response
|
|
select {
|
|
case <-r.Context().Done():
|
|
// Request was cancelled, don't write response
|
|
return
|
|
default:
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
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) {
|
|
// Get prefix and length from URL params
|
|
prefixParam := chi.URLParam(r, "prefix")
|
|
lenParam := chi.URLParam(r, "len")
|
|
|
|
if prefixParam == "" || lenParam == "" {
|
|
http.Error(w, "Prefix and length parameters are required", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Combine prefix and length into CIDR notation
|
|
prefix := prefixParam + "/" + lenParam
|
|
|
|
routes, err := s.db.GetPrefixDetailsContext(r.Context(), 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 {
|
|
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++
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Prepare template data
|
|
data := struct {
|
|
Prefix string
|
|
MaskLength int
|
|
IPVersion int
|
|
Routes []EnhancedRoute
|
|
Origins []*ASNInfo
|
|
PeerCount int
|
|
OriginCount int
|
|
}{
|
|
Prefix: prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersion,
|
|
Routes: enhancedRoutes,
|
|
Origins: origins,
|
|
PeerCount: len(routes),
|
|
OriginCount: len(originMap),
|
|
}
|
|
|
|
// Check if context is still valid before writing response
|
|
select {
|
|
case <-r.Context().Done():
|
|
// Request was cancelled, don't write response
|
|
return
|
|
default:
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handlePrefixLength shows a random sample of IPv4 prefixes with the specified mask length
|
|
func (s *Server) handlePrefixLength() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
lengthStr := chi.URLParam(r, "length")
|
|
if lengthStr == "" {
|
|
http.Error(w, "Length parameter is required", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
maskLength, err := strconv.Atoi(lengthStr)
|
|
if err != nil {
|
|
http.Error(w, "Invalid mask length", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Validate IPv4 mask length
|
|
const maxIPv4MaskLength = 32
|
|
if maskLength < 0 || maskLength > maxIPv4MaskLength {
|
|
http.Error(w, "Invalid IPv4 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)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatAge returns a human-readable age string
|
|
func formatAge(timestamp time.Time) string {
|
|
age := time.Since(timestamp)
|
|
|
|
const hoursPerDay = 24
|
|
|
|
if age < time.Minute {
|
|
return "< 1m"
|
|
} else if age < time.Hour {
|
|
minutes := int(age.Minutes())
|
|
|
|
return strconv.Itoa(minutes) + "m"
|
|
} else if age < hoursPerDay*time.Hour {
|
|
hours := int(age.Hours())
|
|
|
|
return strconv.Itoa(hours) + "h"
|
|
}
|
|
|
|
days := int(age.Hours() / hoursPerDay)
|
|
|
|
return strconv.Itoa(days) + "d"
|
|
}
|