- HTTP request timeout: 2s -> 8s - Stats collection context timeout: 1s -> 4s - HTTP read header timeout: 10s -> 40s This should prevent timeout errors when the database is under load or when complex queries take longer than expected (slow query threshold is 50ms).
884 lines
27 KiB
Go
884 lines
27 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"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"
|
|
)
|
|
|
|
// 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 4 second timeout context for this request
|
|
ctx, cancel := context.WithTimeout(r.Context(), 4*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.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)
|
|
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
// 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 4 second timeout context for this request
|
|
ctx, cancel := context.WithTimeout(r.Context(), 4*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.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)
|
|
|
|
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.GetASInfoForIPContext(r.Context(), 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.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) {
|
|
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.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) {
|
|
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.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// handlePrefixLength shows a random sample of 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
|
|
}
|
|
|
|
// Determine IP version based on mask length
|
|
const (
|
|
maxIPv4MaskLength = 32
|
|
maxIPv6MaskLength = 128
|
|
)
|
|
var ipVersion int
|
|
if maskLength <= maxIPv4MaskLength {
|
|
ipVersion = 4
|
|
} else if maskLength <= maxIPv6MaskLength {
|
|
ipVersion = 6
|
|
} else {
|
|
http.Error(w, "Invalid mask length", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// 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"
|
|
}
|