check / check (push) Waiting to run
An IP lookup took any number of addresses and host names, gave each PTR lookup until the request's 30-second limit, and looked up and named every address a host name resolved to. It now answers 400 for more than 10 entries, or more than 10 addresses once the names are resolved, before reading the database. Each PTR lookup gets 2 seconds; one that runs out leaves the address's result without PTR names. Of a host name's addresses only public ones are kept: global unicast and not private. A name with none is reported as having no public address. The tests give the server a fake resolver. Model: opus-5-5
1534 lines
46 KiB
Go
1534 lines
46 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
|
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
|
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
|
"github.com/dustin/go-humanize"
|
|
"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
|
|
)
|
|
|
|
// Limits of one IP lookup.
|
|
const (
|
|
// maxLookupTargets is the most addresses and host names an IP lookup may
|
|
// list, and the most addresses it may have once the names are resolved:
|
|
// each address costs up to 113 database reads and a PTR lookup.
|
|
maxLookupTargets = 10
|
|
|
|
// ptrLookupTimeout is each PTR lookup's own time limit, so that ten
|
|
// addresses whose PTR lookups never answer fit in the 30-second limit.
|
|
ptrLookupTimeout = 2 * time.Second
|
|
)
|
|
|
|
// Keys of the JSON envelope the handlers and middleware write, and the status
|
|
// value of an error answer.
|
|
const (
|
|
jsonKeyStatus = "status"
|
|
jsonKeyError = "error"
|
|
jsonKeyMsg = "msg"
|
|
jsonKeyCode = "code"
|
|
jsonStatusError = "error"
|
|
)
|
|
|
|
// Error messages an answer sends, in place of the error behind it where there
|
|
// is one.
|
|
const (
|
|
invalidPrefixMessage = "Invalid prefix"
|
|
hostNotResolvedMessage = "could not resolve host name"
|
|
hostNotPublicMessage = "host name has no public address"
|
|
tooManyTargetsMessage = "Too many addresses or host names"
|
|
)
|
|
|
|
// IP versions as database.LiveRoute.IPVersion records them.
|
|
const (
|
|
ipVersionV4 = 4
|
|
ipVersionV6 = 6
|
|
)
|
|
|
|
// HealthCheckResponse represents the health check response.
|
|
type HealthCheckResponse struct {
|
|
Status string `json:"status"`
|
|
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 {
|
|
s.logger.Error("Health check database ping failed", "error", err)
|
|
|
|
checks["database"] = jsonStatusError
|
|
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 = jsonStatusError
|
|
}
|
|
|
|
response := HealthCheckResponse{
|
|
Status: status,
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Checks: checks,
|
|
}
|
|
|
|
if !healthy {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}
|
|
|
|
err = writeJSONSuccess(w, response)
|
|
if 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, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
tmpl := templates.IndexTemplate()
|
|
|
|
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
|
|
if err != nil {
|
|
s.logger.Error("Failed to render index template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// pageData is what the pages that show no data are rendered with: the nonce
|
|
// their script and style blocks carry.
|
|
type pageData struct {
|
|
CSPNonce string
|
|
}
|
|
|
|
// writeJSONError writes a standardized JSON error response with the given
|
|
// status code and error message.
|
|
func (s *Server) writeJSONError(w http.ResponseWriter, statusCode int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
|
|
err := json.NewEncoder(w).Encode(map[string]any{
|
|
jsonKeyStatus: jsonStatusError,
|
|
jsonKeyError: map[string]any{
|
|
jsonKeyMsg: message,
|
|
jsonKeyCode: statusCode,
|
|
},
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode error response", "error", err)
|
|
}
|
|
}
|
|
|
|
// writeJSONSuccess writes a standardized JSON success response containing
|
|
// the provided data wrapped in a status envelope.
|
|
func writeJSONSuccess(w http.ResponseWriter, data any) error {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
return json.NewEncoder(w).Encode(map[string]any{
|
|
jsonKeyStatus: "ok",
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
// WHOISStatsInfo contains WHOIS fetcher statistics for the status page.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type WHOISStatsInfo struct {
|
|
TotalASNs int `json:"total_asns"`
|
|
FreshASNs int `json:"fresh_asns"`
|
|
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"`
|
|
}
|
|
|
|
// Stats is the /status.json answer: uptime, message counts, database stats,
|
|
// and route information.
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type Stats struct {
|
|
Uptime string `json:"uptime"`
|
|
TotalMessages uint64 `json:"total_messages"`
|
|
TotalBytes uint64 `json:"total_bytes"`
|
|
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
|
MessagesPerSec float64 `json:"messages_per_sec"`
|
|
MbitsPerSec float64 `json:"mbits_per_sec"`
|
|
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
|
|
Connected bool `json:"connected"`
|
|
GoVersion string `json:"go_version"`
|
|
Goroutines int `json:"goroutines"`
|
|
MemoryUsage string `json:"memory_usage"`
|
|
ASNs int `json:"asns"`
|
|
Prefixes int `json:"prefixes"`
|
|
IPv4Prefixes int `json:"ipv4_prefixes"`
|
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
|
Peerings int `json:"peerings"`
|
|
Peers int `json:"peers"`
|
|
DatabaseSizeBytes int64 `json:"database_size_bytes"`
|
|
LiveRoutes int `json:"live_routes"`
|
|
IPv4Routes int `json:"ipv4_routes"`
|
|
IPv6Routes int `json:"ipv6_routes"`
|
|
OldestRoute *time.Time `json:"oldest_route,omitempty"`
|
|
NewestRoute *time.Time `json:"newest_route,omitempty"`
|
|
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
|
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
|
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
|
|
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
|
|
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
|
}
|
|
|
|
// databaseStats gets the database stats, giving up when ctx is done; it then
|
|
// returns ctx's error. The channels are buffered so the goroutine's send never
|
|
// blocks if the timeout wins and nothing here receives; otherwise it would
|
|
// block forever and leak.
|
|
func (s *Server) databaseStats(ctx context.Context) (database.Stats, error) {
|
|
statsChan := make(chan database.Stats, 1)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
dbStats, err := s.db.GetStatsContext(ctx)
|
|
if err != nil {
|
|
s.logger.Debug("Database stats query failed", "error", err)
|
|
|
|
errChan <- err
|
|
|
|
return
|
|
}
|
|
|
|
statsChan <- dbStats
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return database.Stats{}, ctx.Err()
|
|
case err := <-errChan:
|
|
return database.Stats{}, err
|
|
case dbStats := <-statsChan:
|
|
return dbStats, nil
|
|
}
|
|
}
|
|
|
|
// handleStatusJSON returns a handler that serves JSON statistics including
|
|
// uptime, message counts, database stats, and route information.
|
|
func (s *Server) handleStatusJSON() http.HandlerFunc {
|
|
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()
|
|
|
|
dbStats, err := s.databaseStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.logger.Error("Database stats timeout in status.json")
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get database stats", "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
const bitsPerMegabit = 1000000.0
|
|
|
|
// 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)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
stats := Stats{
|
|
Uptime: streamUptime(metrics.ConnectedSince),
|
|
TotalMessages: metrics.TotalMessages,
|
|
TotalBytes: metrics.TotalBytes,
|
|
TotalWireBytes: metrics.TotalWireBytes,
|
|
MessagesPerSec: metrics.MessagesPerSec,
|
|
MbitsPerSec: metrics.BitsPerSec / bitsPerMegabit,
|
|
WireMbitsPerSec: metrics.WireBitsPerSec / bitsPerMegabit,
|
|
Connected: metrics.Connected,
|
|
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: dbStats.IPv4Routes,
|
|
IPv6Routes: dbStats.IPv6Routes,
|
|
OldestRoute: dbStats.OldestRoute,
|
|
NewestRoute: dbStats.NewestRoute,
|
|
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
|
|
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
|
|
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
|
|
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
|
|
WHOISStats: whoisStats,
|
|
}
|
|
|
|
err = writeJSONSuccess(w, stats)
|
|
if 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,
|
|
}
|
|
}
|
|
|
|
// streamUptime is how long the stream has been connected, to the second, or
|
|
// "0s" if it has never connected.
|
|
func streamUptime(connectedSince time.Time) string {
|
|
if connectedSince.IsZero() {
|
|
return "0s"
|
|
}
|
|
|
|
return time.Since(connectedSince).Truncate(time.Second).String()
|
|
}
|
|
|
|
// connectionDuration is how long the stream has been connected, to the
|
|
// second, or "disconnected".
|
|
func connectionDuration(connected bool, connectedSince time.Time) string {
|
|
if !connected || connectedSince.IsZero() {
|
|
return "disconnected"
|
|
}
|
|
|
|
return time.Since(connectedSince).Truncate(time.Second).String()
|
|
}
|
|
|
|
// whoisStaleThreshold matches the fetcher's threshold for consistency.
|
|
const whoisStaleThreshold = 30 * 24 * time.Hour
|
|
|
|
// percentMultiplier converts a ratio to a percentage.
|
|
const percentMultiplier = 100
|
|
|
|
// HandlerStatsInfo represents handler statistics in the API response
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type HandlerStatsInfo struct {
|
|
Name string `json:"name"`
|
|
QueueLength int `json:"queue_length"`
|
|
QueueCapacity int `json:"queue_capacity"`
|
|
QueueHighWaterMark int `json:"queue_high_water_mark"`
|
|
ProcessedCount uint64 `json:"processed_count"`
|
|
DroppedCount uint64 `json:"dropped_count"`
|
|
AvgProcessTimeMs float64 `json:"avg_process_time_ms"`
|
|
MinProcessTimeMs float64 `json:"min_process_time_ms"`
|
|
MaxProcessTimeMs float64 `json:"max_process_time_ms"`
|
|
}
|
|
|
|
// GCStats represents garbage collection statistics
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type GCStats struct {
|
|
NumGC uint32 `json:"num_gc"`
|
|
TotalPauseMs uint64 `json:"total_pause_ms"`
|
|
LastPauseMs float64 `json:"last_pause_ms"`
|
|
HeapAllocBytes uint64 `json:"heap_alloc_bytes"`
|
|
HeapSysBytes uint64 `json:"heap_sys_bytes"`
|
|
}
|
|
|
|
// StreamStats represents stream statistics including announcements/withdrawals
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type StreamStats struct {
|
|
Announcements uint64 `json:"announcements"`
|
|
Withdrawals uint64 `json:"withdrawals"`
|
|
RouteChurnPerSec float64 `json:"route_churn_per_sec"`
|
|
BGPPeerCount int `json:"bgp_peer_count"`
|
|
}
|
|
|
|
// StatsResponse represents the API statistics response
|
|
//
|
|
//nolint:tagliatelle // field names are the existing JSON API
|
|
type StatsResponse struct {
|
|
Uptime string `json:"uptime"`
|
|
TotalMessages uint64 `json:"total_messages"`
|
|
TotalBytes uint64 `json:"total_bytes"`
|
|
TotalWireBytes uint64 `json:"total_wire_bytes"`
|
|
MessagesPerSec float64 `json:"messages_per_sec"`
|
|
MbitsPerSec float64 `json:"mbits_per_sec"`
|
|
WireMbitsPerSec float64 `json:"wire_mbits_per_sec"`
|
|
Connected bool `json:"connected"`
|
|
ConnectionDuration string `json:"connection_duration"`
|
|
ReconnectCount uint64 `json:"reconnect_count"`
|
|
GoVersion string `json:"go_version"`
|
|
Goroutines int `json:"goroutines"`
|
|
MemoryUsage string `json:"memory_usage"`
|
|
GC GCStats `json:"gc"`
|
|
Stream StreamStats `json:"stream"`
|
|
ASNs int `json:"asns"`
|
|
Prefixes int `json:"prefixes"`
|
|
IPv4Prefixes int `json:"ipv4_prefixes"`
|
|
IPv6Prefixes int `json:"ipv6_prefixes"`
|
|
Peerings int `json:"peerings"`
|
|
Peers int `json:"peers"`
|
|
DatabaseSizeBytes int64 `json:"database_size_bytes"`
|
|
LiveRoutes int `json:"live_routes"`
|
|
IPv4Routes int `json:"ipv4_routes"`
|
|
IPv6Routes int `json:"ipv6_routes"`
|
|
OldestRoute *time.Time `json:"oldest_route,omitempty"`
|
|
NewestRoute *time.Time `json:"newest_route,omitempty"`
|
|
IPv4UpdatesPerSec float64 `json:"ipv4_updates_per_sec"`
|
|
IPv6UpdatesPerSec float64 `json:"ipv6_updates_per_sec"`
|
|
HandlerStats []HandlerStatsInfo `json:"handler_stats"`
|
|
IPv4PrefixDistribution []database.PrefixDistribution `json:"ipv4_prefix_distribution"`
|
|
IPv6PrefixDistribution []database.PrefixDistribution `json:"ipv6_prefix_distribution"`
|
|
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
|
|
}
|
|
|
|
// handlerStatsInfo converts the streamer's handler statistics for the API
|
|
// response.
|
|
func handlerStatsInfo(handlerStats []streamer.HandlerStats) []HandlerStatsInfo {
|
|
const microsecondsPerMillisecond = 1000.0
|
|
|
|
handlerStatsInfo := make([]HandlerStatsInfo, 0, len(handlerStats))
|
|
for _, hs := range handlerStats {
|
|
handlerStatsInfo = append(handlerStatsInfo, HandlerStatsInfo{
|
|
Name: hs.Name,
|
|
QueueLength: hs.QueueLength,
|
|
QueueCapacity: hs.QueueCapacity,
|
|
QueueHighWaterMark: hs.QueueHighWaterMark,
|
|
ProcessedCount: hs.ProcessedCount,
|
|
DroppedCount: hs.DroppedCount,
|
|
AvgProcessTimeMs: float64(hs.AvgProcessTime.Microseconds()) /
|
|
microsecondsPerMillisecond,
|
|
MinProcessTimeMs: float64(hs.MinProcessTime.Microseconds()) /
|
|
microsecondsPerMillisecond,
|
|
MaxProcessTimeMs: float64(hs.MaxProcessTime.Microseconds()) /
|
|
microsecondsPerMillisecond,
|
|
})
|
|
}
|
|
|
|
return handlerStatsInfo
|
|
}
|
|
|
|
// gcStats reports the garbage collector's figures from memStats.
|
|
func gcStats(memStats *runtime.MemStats) GCStats {
|
|
const (
|
|
nanosecondsPerMillisecond = 1e6
|
|
gcPauseHistorySize = 256 // Size of runtime.MemStats.PauseNs circular buffer
|
|
)
|
|
|
|
// Calculate last GC pause
|
|
var lastPauseMs float64
|
|
|
|
if memStats.NumGC > 0 {
|
|
// PauseNs is a circular buffer, get the most recent pause
|
|
lastPauseIdx := (memStats.NumGC + gcPauseHistorySize - 1) % gcPauseHistorySize
|
|
lastPauseMs = float64(memStats.PauseNs[lastPauseIdx]) / nanosecondsPerMillisecond
|
|
}
|
|
|
|
return GCStats{
|
|
NumGC: memStats.NumGC,
|
|
TotalPauseMs: memStats.PauseTotalNs / uint64(nanosecondsPerMillisecond),
|
|
LastPauseMs: lastPauseMs,
|
|
HeapAllocBytes: memStats.HeapAlloc,
|
|
HeapSysBytes: memStats.HeapSys,
|
|
}
|
|
}
|
|
|
|
// streamStats reports the announcement, withdrawal and BGP peer figures of the
|
|
// streamer's metrics tracker.
|
|
func (s *Server) streamStats() StreamStats {
|
|
metricsTracker := s.streamer.GetMetricsTracker()
|
|
|
|
return StreamStats{
|
|
Announcements: metricsTracker.GetAnnouncementCount(),
|
|
Withdrawals: metricsTracker.GetWithdrawalCount(),
|
|
RouteChurnPerSec: metricsTracker.GetChurnRate(),
|
|
BGPPeerCount: metricsTracker.GetBGPPeerCount(),
|
|
}
|
|
}
|
|
|
|
// handleStats returns a handler that serves API v1 statistics including
|
|
// detailed handler queue statistics and performance metrics.
|
|
func (s *Server) handleStats() http.HandlerFunc {
|
|
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():
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
default:
|
|
}
|
|
|
|
metrics := s.streamer.GetMetrics()
|
|
|
|
dbStats, err := s.databaseStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.logger.Error("Database stats timeout")
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get database stats", "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
// Get WHOIS stats if fetcher is available
|
|
var whoisStats *WHOISStatsInfo
|
|
if s.asnFetcher != nil {
|
|
whoisStats = s.getWHOISStats(ctx)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
err = writeJSONSuccess(w, s.statsResponse(metrics, dbStats, whoisStats))
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode stats", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// statsResponse builds the /api/v1/stats answer from the stream metrics, the
|
|
// database stats and the WHOIS stats, with the route update rates, memory use
|
|
// and handler queues as they are now.
|
|
func (s *Server) statsResponse(
|
|
streamMetrics metrics.StreamMetrics,
|
|
dbStats database.Stats,
|
|
whoisStats *WHOISStatsInfo,
|
|
) StatsResponse {
|
|
const bitsPerMegabit = 1000000.0
|
|
|
|
// Get route update metrics
|
|
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
|
|
|
|
// Get memory stats
|
|
var memStats runtime.MemStats
|
|
runtime.ReadMemStats(&memStats)
|
|
|
|
return StatsResponse{
|
|
Uptime: streamUptime(streamMetrics.ConnectedSince),
|
|
TotalMessages: streamMetrics.TotalMessages,
|
|
TotalBytes: streamMetrics.TotalBytes,
|
|
TotalWireBytes: streamMetrics.TotalWireBytes,
|
|
MessagesPerSec: streamMetrics.MessagesPerSec,
|
|
MbitsPerSec: streamMetrics.BitsPerSec / bitsPerMegabit,
|
|
WireMbitsPerSec: streamMetrics.WireBitsPerSec / bitsPerMegabit,
|
|
Connected: streamMetrics.Connected,
|
|
ConnectionDuration: connectionDuration(
|
|
streamMetrics.Connected, streamMetrics.ConnectedSince),
|
|
ReconnectCount: streamMetrics.ReconnectCount,
|
|
GoVersion: runtime.Version(),
|
|
Goroutines: runtime.NumGoroutine(),
|
|
MemoryUsage: humanize.Bytes(memStats.Alloc),
|
|
GC: gcStats(&memStats),
|
|
Stream: s.streamStats(),
|
|
ASNs: dbStats.ASNs,
|
|
Prefixes: dbStats.Prefixes,
|
|
IPv4Prefixes: dbStats.IPv4Prefixes,
|
|
IPv6Prefixes: dbStats.IPv6Prefixes,
|
|
Peerings: dbStats.Peerings,
|
|
Peers: dbStats.Peers,
|
|
DatabaseSizeBytes: dbStats.FileSizeBytes,
|
|
LiveRoutes: dbStats.LiveRoutes,
|
|
IPv4Routes: dbStats.IPv4Routes,
|
|
IPv6Routes: dbStats.IPv6Routes,
|
|
OldestRoute: dbStats.OldestRoute,
|
|
NewestRoute: dbStats.NewestRoute,
|
|
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
|
|
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
|
|
HandlerStats: handlerStatsInfo(s.streamer.GetHandlerStats()),
|
|
IPv4PrefixDistribution: dbStats.IPv4PrefixDistribution,
|
|
IPv6PrefixDistribution: dbStats.IPv6PrefixDistribution,
|
|
WHOISStats: whoisStats,
|
|
}
|
|
}
|
|
|
|
// 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, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
tmpl := templates.StatusTemplate()
|
|
|
|
err := tmpl.Execute(w, pageData{CSPNonce: cspNonce(r.Context())})
|
|
if 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) {
|
|
target := lookupTarget(r)
|
|
if target == "" {
|
|
s.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
|
|
ipsToLookup, resolveErrors, err := s.resolveLookupTargets(ctx, target)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.writeJSONError(w, http.StatusBadRequest, tooManyTargetsMessage)
|
|
|
|
return
|
|
}
|
|
|
|
response.Errors = append(response.Errors, resolveErrors...)
|
|
|
|
if len(ipsToLookup) == 0 {
|
|
s.writeJSONError(w, http.StatusBadRequest, noTargetsMessage(resolveErrors))
|
|
|
|
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 deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
switch {
|
|
case errors.Is(err, database.ErrNoRoute), errors.Is(err, database.ErrInvalidIP):
|
|
response.Errors = append(response.Errors, ip+": "+err.Error())
|
|
|
|
continue
|
|
case err != nil:
|
|
s.logger.Error("Failed to look up IP", "ip", ip, "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError, internalErrorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
// Do PTR lookup for this IP
|
|
ptrs := s.lookupPTRNames(ctx, ip)
|
|
if deadlinePassed(ctx) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if len(ptrs) > 0 {
|
|
ipInfo.PTR = ptrs
|
|
}
|
|
|
|
response.Results = append(response.Results, ipInfo)
|
|
|
|
if ipInfo.NeedsWHOISRefresh {
|
|
refreshASNs[ipInfo.ASN] = true
|
|
}
|
|
}
|
|
|
|
// Queue WHOIS refresh for stale ASNs (non-blocking)
|
|
s.queueWHOISRefresh(refreshASNs)
|
|
|
|
s.writeIPLookupAnswer(w, response)
|
|
}
|
|
}
|
|
|
|
// noTargetsMessage returns the message of the 400 an IP lookup answers when
|
|
// no address is left to look up, followed by the first of resolveErrors, which
|
|
// says why, when there is one.
|
|
func noTargetsMessage(resolveErrors []string) string {
|
|
const message = "No valid IPs or hostnames provided"
|
|
|
|
if len(resolveErrors) == 0 {
|
|
return message
|
|
}
|
|
|
|
return message + ": " + resolveErrors[0]
|
|
}
|
|
|
|
// writeIPLookupAnswer sends response, or 404 with its first error when no
|
|
// address had a route.
|
|
func (s *Server) writeIPLookupAnswer(
|
|
w http.ResponseWriter, response IPLookupResponse,
|
|
) {
|
|
if len(response.Results) == 0 && len(response.Errors) > 0 {
|
|
s.writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
|
|
|
|
return
|
|
}
|
|
|
|
err := writeJSONSuccess(w, response)
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode IP lookup response", "error", err)
|
|
}
|
|
}
|
|
|
|
// lookupTarget returns what an IP lookup request asks for: the ip or addr URL
|
|
// parameter, or else the client's own address.
|
|
func lookupTarget(r *http.Request) string {
|
|
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)
|
|
}
|
|
|
|
return target
|
|
}
|
|
|
|
// queueWHOISRefresh asks the WHOIS fetcher, when there is one, to refresh
|
|
// each of asns now.
|
|
func (s *Server) queueWHOISRefresh(asns map[int]bool) {
|
|
if s.asnFetcher == nil {
|
|
return
|
|
}
|
|
|
|
for asn := range asns {
|
|
s.asnFetcher.QueueImmediate(asn)
|
|
}
|
|
}
|
|
|
|
// errTooManyTargets is what resolveLookupTargets returns for a list of more
|
|
// than maxLookupTargets addresses and host names, or of names that resolve to
|
|
// more than maxLookupTargets addresses.
|
|
var errTooManyTargets = errors.New("too many addresses or host names")
|
|
|
|
// splitLookupTargets returns the addresses and host names in a comma-separated
|
|
// list, leaving out blank entries, or errTooManyTargets when there are more
|
|
// than maxLookupTargets.
|
|
func splitLookupTargets(target string) ([]string, error) {
|
|
var targets []string
|
|
|
|
for t := range strings.SplitSeq(target, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
|
|
if len(targets) == maxLookupTargets {
|
|
return nil, errTooManyTargets
|
|
}
|
|
|
|
targets = append(targets, t)
|
|
}
|
|
|
|
return targets, nil
|
|
}
|
|
|
|
// resolveLookupTargets splits a comma-separated list of IP addresses and
|
|
// hostnames and returns the IP addresses to look up, resolving each hostname.
|
|
// A hostname that does not resolve is reported in the returned errors instead,
|
|
// with the resolver's own error only in the log, since it names the DNS server.
|
|
// Only the public addresses a hostname resolves to are kept, so the answer
|
|
// does not show the deployment's private addresses; a hostname with none is
|
|
// reported in the errors. More than maxLookupTargets entries, or addresses once
|
|
// resolved, return errTooManyTargets. It stops once ctx's deadline has passed;
|
|
// the caller then answers 504.
|
|
func (s *Server) resolveLookupTargets(
|
|
ctx context.Context, target string,
|
|
) ([]string, []string, error) {
|
|
targets, err := splitLookupTargets(target)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var ipsToLookup, resolveErrors []string
|
|
|
|
for _, t := range targets {
|
|
// Check if this target is an IP address
|
|
if net.ParseIP(t) != nil {
|
|
ipsToLookup = append(ipsToLookup, t)
|
|
|
|
continue
|
|
}
|
|
|
|
// It's a hostname - resolve it
|
|
resolved, err := s.resolver.LookupHost(ctx, t)
|
|
if deadlinePassed(ctx) {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Warn("Host name lookup failed", "host", t, "error", err)
|
|
resolveErrors = append(resolveErrors, t+": "+hostNotResolvedMessage)
|
|
|
|
continue
|
|
}
|
|
|
|
public := publicAddresses(resolved)
|
|
if len(public) == 0 {
|
|
resolveErrors = append(resolveErrors, t+": "+hostNotPublicMessage)
|
|
|
|
continue
|
|
}
|
|
|
|
ipsToLookup = append(ipsToLookup, public...)
|
|
}
|
|
|
|
if len(ipsToLookup) > maxLookupTargets {
|
|
return nil, nil, errTooManyTargets
|
|
}
|
|
|
|
return ipsToLookup, resolveErrors, nil
|
|
}
|
|
|
|
// publicAddresses returns the addresses in addrs that are global unicast and
|
|
// not private; Go counts the private ranges as global unicast.
|
|
func publicAddresses(addrs []string) []string {
|
|
var public []string
|
|
|
|
for _, a := range addrs {
|
|
ip, err := netip.ParseAddr(a)
|
|
if err == nil && ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
|
public = append(public, a)
|
|
}
|
|
}
|
|
|
|
return public
|
|
}
|
|
|
|
// lookupPTRNames returns the PTR records of ip without their trailing dots, or
|
|
// nil when the lookup fails or runs past ptrLookupTimeout.
|
|
func (s *Server) lookupPTRNames(ctx context.Context, ip string) []string {
|
|
ctx, cancel := context.WithTimeout(ctx, ptrLookupTimeout)
|
|
defer cancel()
|
|
|
|
ptrs, err := s.resolver.LookupAddr(ctx, ip)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Remove trailing dots from PTR records
|
|
for i, ptr := range ptrs {
|
|
ptrs[i] = strings.TrimSuffix(ptr, ".")
|
|
}
|
|
|
|
return ptrs
|
|
}
|
|
|
|
// extractClientIP extracts the client IP from the request.
|
|
// Works with RealIPMiddleware, 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 {
|
|
s.writeJSONError(w, http.StatusBadRequest, "Invalid ASN")
|
|
|
|
return
|
|
}
|
|
|
|
asInfo, prefixes, err := s.db.GetASDetailsContext(r.Context(), asn)
|
|
if deadlinePassed(r.Context()) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
if errors.Is(err, database.ErrNoRoute) {
|
|
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
|
} else {
|
|
s.logger.Error("Failed to get AS details", "asn", asn, "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError,
|
|
internalErrorMessage)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Group prefixes by IP version
|
|
ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes)
|
|
|
|
response := map[string]any{
|
|
"asn": asInfo,
|
|
"ipv4_prefixes": ipv4Prefixes,
|
|
"ipv6_prefixes": ipv6Prefixes,
|
|
"total_count": len(prefixes),
|
|
}
|
|
|
|
err = writeJSONSuccess(w, response)
|
|
if err != nil {
|
|
s.logger.Error("Failed to encode AS details", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones.
|
|
func splitRoutesByIPVersion(
|
|
routes []database.LiveRoute,
|
|
) ([]database.LiveRoute, []database.LiveRoute) {
|
|
var ipv4Routes, ipv6Routes []database.LiveRoute
|
|
|
|
for _, route := range routes {
|
|
if route.IPVersion == ipVersionV4 {
|
|
ipv4Routes = append(ipv4Routes, route)
|
|
} else {
|
|
ipv6Routes = append(ipv6Routes, route)
|
|
}
|
|
}
|
|
|
|
return ipv4Routes, ipv6Routes
|
|
}
|
|
|
|
// sortRoutesByNetwork sorts routes by network address, then by mask length.
|
|
func sortRoutesByNetwork(routes []database.LiveRoute) {
|
|
sort.Slice(routes, func(i, j int) bool {
|
|
// Parse the prefixes to compare network addresses
|
|
ipI, netI, _ := net.ParseCIDR(routes[i].Prefix)
|
|
ipJ, netJ, _ := net.ParseCIDR(routes[j].Prefix)
|
|
|
|
// Compare by network address first
|
|
cmp := bytes.Compare(ipI.To16(), ipJ.To16())
|
|
if cmp != 0 {
|
|
return cmp < 0
|
|
}
|
|
|
|
// If network addresses are equal, compare by mask length
|
|
onesI, _ := netI.Mask.Size()
|
|
onesJ, _ := netJ.Mask.Size()
|
|
|
|
return onesI < onesJ
|
|
})
|
|
}
|
|
|
|
// handlePrefixDetailJSON returns prefix details as JSON
|
|
func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Get prefix and length from URL params
|
|
prefixParam := chi.URLParam(r, "prefix")
|
|
lenParam := chi.URLParam(r, "len")
|
|
|
|
if prefixParam == "" || lenParam == "" {
|
|
s.writeJSONError(w, http.StatusBadRequest,
|
|
"Prefix and length parameters are required")
|
|
|
|
return
|
|
}
|
|
|
|
// Look the prefix up in the text form net/netip prints, the form the
|
|
// tables store, so that any valid way of writing it finds the route.
|
|
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
|
if err != nil {
|
|
s.writeJSONError(w, http.StatusBadRequest, invalidPrefixMessage)
|
|
|
|
return
|
|
}
|
|
|
|
prefix := parsed.Masked().String()
|
|
|
|
routes, _, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
|
|
if deadlinePassed(r.Context()) {
|
|
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
if errors.Is(err, database.ErrNoRoute) {
|
|
s.writeJSONError(w, http.StatusNotFound, err.Error())
|
|
} else {
|
|
s.logger.Error("Failed to get prefix details",
|
|
"prefix", prefix, "error", err)
|
|
s.writeJSONError(w, http.StatusInternalServerError,
|
|
internalErrorMessage)
|
|
}
|
|
|
|
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]any{
|
|
"prefix": prefix,
|
|
"routes": routes,
|
|
"origins": originMap,
|
|
"peer_count": len(routes),
|
|
"origin_count": len(originMap),
|
|
}
|
|
|
|
err = writeJSONSuccess(w, response)
|
|
if 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 deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
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, internalErrorMessage, http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Get peers
|
|
peers, err := s.db.GetASPeersContext(r.Context(), asn)
|
|
if deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
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
|
|
ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes)
|
|
|
|
// Sort prefixes by network address
|
|
sortRoutesByNetwork(ipv4Prefixes)
|
|
sortRoutesByNetwork(ipv6Prefixes)
|
|
|
|
// 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
|
|
CSPNonce string
|
|
}{
|
|
ASN: asInfo,
|
|
IPv4Prefixes: ipv4Prefixes,
|
|
IPv6Prefixes: ipv6Prefixes,
|
|
TotalCount: len(prefixes),
|
|
IPv4Count: len(ipv4Prefixes),
|
|
IPv6Count: len(ipv6Prefixes),
|
|
Peers: peers,
|
|
PeerCount: len(peers),
|
|
CSPNonce: cspNonce(r.Context()),
|
|
}
|
|
|
|
// Check if context is still valid before writing response
|
|
if r.Context().Err() != nil {
|
|
// Request was cancelled, don't write response
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
err = templates.ASDetailTemplate().Execute(w, data)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render AS detail template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Look the prefix up in the text form net/netip prints, the form the
|
|
// tables store, so that any valid way of writing it finds the route.
|
|
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
|
|
if err != nil {
|
|
http.Error(w, invalidPrefixMessage, http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
prefix := parsed.Masked().String()
|
|
|
|
routes, originNames, err := s.db.GetPrefixDetailsContext(r.Context(), prefix)
|
|
if deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
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, internalErrorMessage, http.StatusInternalServerError)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Group by origin AS and collect unique AS info
|
|
origins := originASNs(routes, originNames)
|
|
|
|
// Get the first route to extract some common info
|
|
var maskLength, ipVersion int
|
|
if len(routes) > 0 {
|
|
maskLength, ipVersion = prefixMaskLengthAndIPVersion(prefix)
|
|
}
|
|
|
|
// Create enhanced routes with AS path handles
|
|
enhancedRoutes := routesWithASPathHandles(routes)
|
|
|
|
// Prepare template data
|
|
data := struct {
|
|
Prefix string
|
|
MaskLength int
|
|
IPVersion int
|
|
Routes []EnhancedRoute
|
|
Origins []*ASNInfo
|
|
PeerCount int
|
|
OriginCount int
|
|
CSPNonce string
|
|
}{
|
|
Prefix: prefix,
|
|
MaskLength: maskLength,
|
|
IPVersion: ipVersion,
|
|
Routes: enhancedRoutes,
|
|
Origins: origins,
|
|
PeerCount: len(routes),
|
|
OriginCount: len(origins),
|
|
CSPNonce: cspNonce(r.Context()),
|
|
}
|
|
|
|
// 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")
|
|
|
|
err = templates.PrefixDetailTemplate().Execute(w, data)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render prefix detail template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ASNInfo is one origin AS of a prefix on the prefix detail page.
|
|
type ASNInfo struct {
|
|
ASN int
|
|
Handle string
|
|
Description string
|
|
PeerCount int
|
|
}
|
|
|
|
// originASNs groups routes by origin AS, with each AS's handle and description
|
|
// from names, as the prefix query returned them, and the number of routes it
|
|
// originates. The origin ASes come in no particular order.
|
|
func originASNs(
|
|
routes []database.LiveRoute,
|
|
names map[int]database.ASN,
|
|
) []*ASNInfo {
|
|
originMap := make(map[int]*ASNInfo)
|
|
|
|
for _, route := range routes {
|
|
if _, exists := originMap[route.OriginASN]; !exists {
|
|
originMap[route.OriginASN] = &ASNInfo{
|
|
ASN: route.OriginASN,
|
|
Handle: names[route.OriginASN].Handle,
|
|
Description: names[route.OriginASN].Description,
|
|
PeerCount: 0,
|
|
}
|
|
}
|
|
|
|
originMap[route.OriginASN].PeerCount++
|
|
}
|
|
|
|
origins := make([]*ASNInfo, 0, len(originMap))
|
|
for _, origin := range originMap {
|
|
origins = append(origins, origin)
|
|
}
|
|
|
|
return origins
|
|
}
|
|
|
|
// ASPathEntry is one AS of a route's AS path, with its handle.
|
|
type ASPathEntry struct {
|
|
ASN int
|
|
Handle string
|
|
}
|
|
|
|
// EnhancedRoute is a live route with the handle of each AS in its path.
|
|
type EnhancedRoute struct {
|
|
database.LiveRoute
|
|
|
|
ASPathWithHandle []ASPathEntry
|
|
}
|
|
|
|
// routesWithASPathHandles looks up the handle of each AS in the path of each
|
|
// route.
|
|
func routesWithASPathHandles(routes []database.LiveRoute) []EnhancedRoute {
|
|
enhancedRoutes := make([]EnhancedRoute, len(routes))
|
|
|
|
for i, route := range routes {
|
|
enhancedRoute := EnhancedRoute{
|
|
LiveRoute: route,
|
|
ASPathWithHandle: make([]ASPathEntry, len(route.ASPath)),
|
|
}
|
|
|
|
// Look up handle for each AS in the path
|
|
for j, asn := range route.ASPath {
|
|
handle := asinfo.GetHandle(asn)
|
|
enhancedRoute.ASPathWithHandle[j] = ASPathEntry{
|
|
ASN: asn,
|
|
Handle: handle,
|
|
}
|
|
}
|
|
|
|
enhancedRoutes[i] = enhancedRoute
|
|
}
|
|
|
|
return enhancedRoutes
|
|
}
|
|
|
|
// prefixMaskLengthAndIPVersion returns the mask length and IP version of a
|
|
// prefix in CIDR notation, or zeros if it does not parse.
|
|
func prefixMaskLengthAndIPVersion(prefix string) (int, int) {
|
|
_, ipNet, err := net.ParseCIDR(prefix)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
|
|
maskLength, _ := ipNet.Mask.Size()
|
|
|
|
if ipNet.IP.To4() != nil {
|
|
return maskLength, ipVersionV4
|
|
}
|
|
|
|
return maskLength, ipVersionV6
|
|
}
|
|
|
|
// handlePrefixLength shows a random sample of IPv4 prefixes with the
|
|
// specified mask length
|
|
func (s *Server) handlePrefixLength() http.HandlerFunc {
|
|
const maxIPv4MaskLength = 32
|
|
|
|
return s.prefixLengthHandler(ipVersionV4, maxIPv4MaskLength)
|
|
}
|
|
|
|
// prefixLengthHandler returns a handler that shows a random sample of the
|
|
// prefixes of one IP version with the specified mask length, which may be at
|
|
// most maxMaskLength.
|
|
func (s *Server) prefixLengthHandler(ipVersion, maxMaskLength int) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
lengthStr := chi.URLParam(r, "length")
|
|
if lengthStr == "" {
|
|
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 mask length
|
|
if maskLength < 0 || maskLength > maxMaskLength {
|
|
http.Error(w, "Invalid IPv"+strconv.Itoa(ipVersion)+" mask length",
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
// Get random sample of prefixes
|
|
const maxPrefixes = 500
|
|
|
|
prefixes, err := s.db.GetRandomPrefixesByLengthContext(
|
|
r.Context(), maskLength, ipVersion, maxPrefixes)
|
|
if deadlinePassed(r.Context()) {
|
|
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to get prefixes by length", "error", err)
|
|
http.Error(w, internalErrorMessage, 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
|
|
})
|
|
|
|
// Render template
|
|
data := map[string]any{
|
|
"MaskLength": maskLength,
|
|
"IPVersion": ipVersion,
|
|
"Prefixes": prefixesWithASDescriptions(prefixes),
|
|
"Count": len(prefixes),
|
|
"CSPNonce": cspNonce(r.Context()),
|
|
}
|
|
|
|
// 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()
|
|
|
|
err = tmpl.Execute(w, data)
|
|
if err != nil {
|
|
s.logger.Error("Failed to render prefix length template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// EnhancedPrefix is a live route on the prefix length page, with the
|
|
// description of its origin AS and its age.
|
|
type EnhancedPrefix struct {
|
|
database.LiveRoute
|
|
|
|
OriginASDescription string
|
|
Age string
|
|
}
|
|
|
|
// prefixesWithASDescriptions looks up the description of the origin AS of
|
|
// each prefix and gives each one's age.
|
|
func prefixesWithASDescriptions(prefixes []database.LiveRoute) []EnhancedPrefix {
|
|
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
|
|
}
|
|
}
|
|
|
|
return enhancedPrefixes
|
|
}
|
|
|
|
// handlePrefixLength6 shows a random sample of IPv6 prefixes with the
|
|
// specified mask length
|
|
func (s *Server) handlePrefixLength6() http.HandlerFunc {
|
|
const maxIPv6MaskLength = 128
|
|
|
|
return s.prefixLengthHandler(ipVersionV6, maxIPv6MaskLength)
|
|
}
|
|
|
|
// formatAge returns a human-readable age string
|
|
func formatAge(timestamp time.Time) string {
|
|
age := time.Since(timestamp)
|
|
|
|
const hoursPerDay = 24
|
|
|
|
switch {
|
|
case age < time.Minute:
|
|
return "< 1m"
|
|
case age < time.Hour:
|
|
minutes := int(age.Minutes())
|
|
|
|
return strconv.Itoa(minutes) + "m"
|
|
case age < hoursPerDay*time.Hour:
|
|
hours := int(age.Hours())
|
|
|
|
return strconv.Itoa(hours) + "h"
|
|
}
|
|
|
|
days := int(age.Hours() / hoursPerDay)
|
|
|
|
return strconv.Itoa(days) + "d"
|
|
}
|