Files
routewatch/internal/server/handlers.go
T
clawbot e8d04f1304
check / check (push) Canceled after 0s
Give every API value with a unit as a number (closes #199)
A value with a unit is now a number, and its field name ends in the
unit, as the vendored API_POLICIES.md requires. @meta.execution_time
becomes execution_time_ms. In the stats answers, uptime,
connection_duration, memory_usage and whois_stats.current_interval
become uptime_seconds, connection_duration_seconds, memory_usage_bytes
and whois_stats.current_interval_seconds. connection_duration_seconds
is null while the feed is not connected. The status page formats the
numbers itself. REPO_POLICIES.md is re-vendored from the same
sneak/prompts commit.

Model: opus-5-5
2026-10-27 20:19:01 +01:00

1830 lines
56 KiB
Go

package server
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"runtime"
"slices"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/routewatch/internal/blocklist"
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/routetable"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
"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 two 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
)
// The Cache-Control header of an IP lookup answer, such as
// "public, max-age=900".
const (
cacheControlHeader = "Cache-Control"
cacheControlPublic = "public"
cacheControlPrivate = "private"
cacheControlMaxAge = ", max-age="
)
// 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"
searchQueryMissingMessage = "Missing search query"
)
// The AS name search reads the text it looks for from the URL query parameter
// asSearchQueryParam, and answers with at most maxASSearchResults matches.
const (
asSearchQueryParam = "q"
maxASSearchResults = 50
)
// IP versions as 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, indexPageData{
CSPNonce: cspNonce(r.Context()),
ClientIP: extractClientIP(r),
Cards: s.cards.Load(),
})
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
}
// indexPageData is what the home page is rendered with.
type indexPageData struct {
// CSPNonce is the nonce the page's script and style blocks carry.
CSPNonce string
// ClientIP is the visitor's address, which the IP information API card
// looks up: the address /ip/ would look up for the same request.
ClientIP string
// Cards is the copy of the cards that show figures of the routing table,
// or nil before the server has first worked them out.
Cards *homeCards
}
// 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"`
ConsecutiveFails int `json:"consecutive_fails"`
FreshPercent float64 `json:"fresh_percent"`
// CurrentIntervalSeconds is the wait between two fetches. It can have a
// fractional part, such as 22.5.
CurrentIntervalSeconds float64 `json:"current_interval_seconds"`
}
// Stats is the /status.json answer: uptime, message counts, database stats,
// and the routing table's figures. UptimeSeconds is the time since the stream
// last connected, or 0 if it has never connected. MemoryUsageBytes is the Go
// heap in use. OldestRoute and NewestRoute are the times of the oldest and the
// newest kept routes.
//
//nolint:tagliatelle // field names are the existing JSON API
type Stats struct {
UptimeSeconds int64 `json:"uptime_seconds"`
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"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
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 []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
// OriginsWithoutKeptRoute counts the prefix and origin AS pairs that peers
// carry but whose kept routes were all withdrawn.
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
// 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
}
// 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.statusStats(metrics, dbStats, whoisStats))
if err != nil {
s.logger.Error("Failed to encode stats", "error", err)
}
}
}
// statusStats builds the /status.json answer from the stream metrics, the
// database stats, the routing table's figures and the WHOIS stats, with the
// route update rates and memory use as they are now.
func (s *Server) statusStats(
streamMetrics metrics.StreamMetrics,
dbStats database.Stats,
whoisStats *WHOISStatsInfo,
) Stats {
const bitsPerMegabit = 1000000.0
routeMetrics := s.streamer.GetMetricsTracker().GetRouteMetrics()
counts := s.table.Counts()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
return Stats{
UptimeSeconds: uptimeSeconds(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,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsageBytes: memStats.Alloc,
ASNs: dbStats.ASNs,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes,
IPv6Prefixes: counts.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: counts.IPv4Routes + counts.IPv6Routes,
IPv4Routes: counts.IPv4Routes,
IPv6Routes: counts.IPv6Routes,
OldestRoute: counts.OldestKeptRoute,
NewestRoute: counts.NewestKeptRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
IPv4PrefixDistribution: prefixDistribution(counts.IPv4PrefixesByLength),
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats,
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
}
}
// prefixDistribution lists the mask lengths that have at least one prefix, in
// ascending order, with their prefix counts. countsByLength[n] is the number
// of prefixes of mask length n.
func prefixDistribution(countsByLength []int) []PrefixDistribution {
var distribution []PrefixDistribution
for maskLength, count := range countsByLength {
if count > 0 {
distribution = append(distribution, PrefixDistribution{
MaskLength: maskLength, Count: count,
})
}
}
return distribution
}
// 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,
ConsecutiveFails: fetcherStats.ConsecutiveFails,
FreshPercent: freshPercent,
CurrentIntervalSeconds: fetcherStats.CurrentInterval.Seconds(),
}
}
// uptimeSeconds is the number of whole seconds since the stream last
// connected, or 0 if it has never connected.
func uptimeSeconds(connectedSince time.Time) int64 {
if connectedSince.IsZero() {
return 0
}
return int64(time.Since(connectedSince) / time.Second)
}
// connectionDurationSeconds is the number of whole seconds the stream has
// been connected, or nil while it is not connected.
func connectionDurationSeconds(connected bool, connectedSince time.Time) *int64 {
if !connected || connectedSince.IsZero() {
return nil
}
return new(uptimeSeconds(connectedSince))
}
// 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"`
RouteChurnPerMin int `json:"route_churn_per_min"`
BGPPeerCount int `json:"bgp_peer_count"`
}
// StatsResponse represents the API statistics response. The fields it shares
// with Stats mean what they mean there.
//
//nolint:tagliatelle // field names are the existing JSON API
type StatsResponse struct {
UptimeSeconds int64 `json:"uptime_seconds"`
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"`
ReconnectCount uint64 `json:"reconnect_count"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
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 []PrefixDistribution `json:"ipv4_prefix_distribution"`
IPv6PrefixDistribution []PrefixDistribution `json:"ipv6_prefix_distribution"`
WHOISStats *WHOISStatsInfo `json:"whois_stats,omitempty"`
// ConnectionDurationSeconds is null while the stream is not connected.
ConnectionDurationSeconds *int64 `json:"connection_duration_seconds"`
OriginsWithoutKeptRoute int `json:"origins_without_kept_route"`
}
// 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(),
RouteChurnPerMin: metricsTracker.GetChurnPerMinute(),
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, the routing table's figures 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()
counts := s.table.Counts()
// Get memory stats
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
return StatsResponse{
UptimeSeconds: uptimeSeconds(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,
ReconnectCount: streamMetrics.ReconnectCount,
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
MemoryUsageBytes: memStats.Alloc,
GC: gcStats(&memStats),
Stream: s.streamStats(),
ASNs: dbStats.ASNs,
Prefixes: counts.IPv4Prefixes + counts.IPv6Prefixes,
IPv4Prefixes: counts.IPv4Prefixes,
IPv6Prefixes: counts.IPv6Prefixes,
Peerings: dbStats.Peerings,
Peers: dbStats.Peers,
DatabaseSizeBytes: dbStats.FileSizeBytes,
LiveRoutes: counts.IPv4Routes + counts.IPv6Routes,
IPv4Routes: counts.IPv4Routes,
IPv6Routes: counts.IPv6Routes,
OldestRoute: counts.OldestKeptRoute,
NewestRoute: counts.NewestKeptRoute,
IPv4UpdatesPerSec: routeMetrics.IPv4UpdatesPerSec,
IPv6UpdatesPerSec: routeMetrics.IPv6UpdatesPerSec,
HandlerStats: handlerStatsInfo(s.streamer.GetHandlerStats()),
IPv4PrefixDistribution: prefixDistribution(counts.IPv4PrefixesByLength),
IPv6PrefixDistribution: prefixDistribution(counts.IPv6PrefixesByLength),
WHOISStats: whoisStats,
ConnectionDurationSeconds: connectionDurationSeconds(
streamMetrics.Connected, streamMetrics.ConnectedSince),
OriginsWithoutKeptRoute: counts.OriginsWithoutKeptRoute,
}
}
// 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 []IPLookupResult `json:"results"`
Errors []string `json:"errors,omitempty"`
}
// IPLookupResult is the answer for one address: its PTR names, its live route
// and that route's AS, and the blocklists that list it. Every result has every
// field. The fields of IPInfo are null when the address has no live route.
type IPLookupResult struct {
IPInfo
IP string `json:"ip"`
// PTR is empty, never nil, when the address has no PTR names.
PTR []string `json:"ptr"`
// Blocklists has one entry for each blocklist the address is on.
Blocklists []blocklist.Listing `json:"blocklists"`
}
// 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, ownAddress := lookupTarget(r)
if target == "" {
s.writeJSONError(w, http.StatusBadRequest, "Could not determine IP address")
return
}
ctx := r.Context()
response := IPLookupResponse{Query: target, Results: make([]IPLookupResult, 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.ipInfo(ctx, ip)
if deadlinePassed(ctx) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return
}
switch {
case errors.Is(err, ErrNoRoute):
// The address still gets a result, with every field of
// ipInfo null.
case errors.Is(err, 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
}
response.Results = append(response.Results, IPLookupResult{
IP: ip,
PTR: ptrs,
IPInfo: ipInfo,
Blocklists: s.blocklistListings(ip),
})
if ipInfo.NeedsWHOISRefresh {
refreshASNs[*ipInfo.ASN] = true
}
}
// Queue WHOIS refresh for stale ASNs (non-blocking)
s.queueWHOISRefresh(refreshASNs)
s.writeIPLookupAnswer(w, response, ownAddress)
}
}
// ipInfo returns what the IP lookup knows about the route of ip. The routing
// table gives the most specific prefix that holds the address, the number of
// peers that carry it, its origin ASes and the one that most peers carry, and
// the time of its newest kept route. The database gives the prefix's
// first-seen time and the AS's WHOIS details. It returns ErrNoRoute when no
// prefix holds the address, and ErrInvalidIP when ip does not parse. With an
// error, every field of the IPInfo it returns is nil.
func (s *Server) ipInfo(ctx context.Context, ip string) (IPInfo, error) {
addr, err := netip.ParseAddr(ip)
if err != nil {
return IPInfo{}, fmt.Errorf("%w: %s", ErrInvalidIP, ip)
}
summary, found := s.table.Lookup(addr)
if !found {
return IPInfo{}, fmt.Errorf("%w for IP %s", ErrNoRoute, ip)
}
firstSeen, err := s.db.GetPrefixFirstSeen(ctx, summary.Prefix)
if err != nil {
return IPInfo{}, err
}
asInfo, err := s.db.GetASWHOIS(ctx, summary.OriginASN)
if err != nil {
return IPInfo{}, err
}
info := IPInfo{
Netblock: new(summary.Prefix.String()),
MaskLength: new(summary.Prefix.Bits()),
IPVersion: new(ipVersionOf(summary.Prefix)),
NumPeers: new(summary.Peers),
ASN: new(summary.OriginASN),
NumOrigins: new(len(summary.OriginASNs)),
OriginASNs: summary.OriginASNs,
ASName: nilIfEmpty(asInfo.ASName),
Handle: nilIfEmpty(asInfo.Handle),
Description: nilIfEmpty(asInfo.Description),
OrgName: nilIfEmpty(asInfo.OrgName),
OrgID: nilIfEmpty(asInfo.OrgID),
Address: nilIfEmpty(asInfo.Address),
CountryCode: nilIfEmpty(asInfo.CountryCode),
AbuseEmail: nilIfEmpty(asInfo.AbuseEmail),
RIR: nilIfEmpty(asInfo.RIR),
LastSeen: summary.Newest,
NeedsWHOISRefresh: needsWHOISRefresh(asInfo),
}
if !firstSeen.IsZero() {
info.FirstSeen = &firstSeen
}
return info, nil
}
// nilIfEmpty returns a pointer to s, or nil when s is empty, so that JSON
// gives a detail the database does not hold as null.
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
// needsWHOISRefresh says whether the WHOIS details of asInfo were never
// fetched or are older than whoisStaleThreshold. AS 0, no known origin, has
// none to fetch.
func needsWHOISRefresh(asInfo *database.ASN) bool {
if asInfo.ASN == ristypes.NoOrigin {
return false
}
return asInfo.WHOISUpdatedAt == nil ||
time.Since(*asInfo.WHOISUpdatedAt) > whoisStaleThreshold
}
// ipVersionOf returns 4 or 6, the IP version of prefix.
func ipVersionOf(prefix netip.Prefix) int {
if prefix.Addr().Is4() {
return ipVersionV4
}
return ipVersionV6
}
// blocklistListings returns the blocklists that list ip. It returns an empty
// list, never nil, so that an answer's blocklists is [] rather than null.
func (s *Server) blocklistListings(ip string) []blocklist.Listing {
addr, err := netip.ParseAddr(ip)
if s.blocklists == nil || err != nil {
return []blocklist.Listing{}
}
return s.blocklists.Lookup(addr)
}
// setIPLookupCacheControl lets caches keep an IP lookup answer for the
// shortest blocklist interval, the soonest a blocklist copy can change. The
// answer about the caller's own address is private, so that a shared cache
// does not give it to another caller. Without blocklists it sets nothing.
func (s *Server) setIPLookupCacheControl(w http.ResponseWriter, ownAddress bool) {
if s.blocklists == nil {
return
}
visibility := cacheControlPublic
if ownAddress {
visibility = cacheControlPrivate
}
maxAgeSeconds := int(s.blocklists.ShortestInterval().Seconds())
w.Header().Set(cacheControlHeader,
visibility+cacheControlMaxAge+strconv.Itoa(maxAgeSeconds))
}
// 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 with its Cache-Control header.
// ownAddress says the request asked about the caller's own address.
func (s *Server) writeIPLookupAnswer(
w http.ResponseWriter, response IPLookupResponse, ownAddress bool,
) {
s.setIPLookupCacheControl(w, ownAddress)
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. The bool is true for the
// client's own address.
func lookupTarget(r *http.Request) (string, bool) {
target := chi.URLParam(r, "ip")
if target == "" {
target = chi.URLParam(r, "addr")
}
if target == "" {
// Use client IP (RealIP middleware has already processed this)
return extractClientIP(r), true
}
return target, false
}
// 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. It
// returns an empty list, never nil, when there are none, when the lookup fails
// and when it runs past ptrLookupTimeout, so that an answer's ptr is [] rather
// than null.
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 || ptrs == nil {
return []string{}
}
// 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.asDetails(r.Context(), asn)
if deadlinePassed(r.Context()) {
s.writeJSONError(w, http.StatusGatewayTimeout, requestTimeoutMessage)
return
}
if err != nil {
if errors.Is(err, 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)
}
}
}
// asDetails returns the AS's handle, description and seen times, from the
// database, and the prefixes it originates, from the routing table. Each
// prefix has the time of its newest kept route, if it keeps one. It returns
// ErrNoRoute for AS 0, which means no known origin. It also returns ErrNoRoute
// for an AS that the database, the AS list built into RouteWatch and the
// routing table all lack.
func (s *Server) asDetails(
ctx context.Context, asn int,
) (*database.ASN, []LiveRoute, error) {
if asn == ristypes.NoOrigin {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
asInfo, known, err := s.db.GetAS(ctx, asn)
if err != nil {
return nil, nil, err
}
originated := s.table.OriginatedPrefixes(asn)
if !known && len(originated) == 0 {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
prefixes := make([]LiveRoute, 0, len(originated))
for _, p := range originated {
route := LiveRoute{
Prefix: p.Prefix.String(),
MaskLength: p.Prefix.Bits(),
IPVersion: ipVersionOf(p.Prefix),
OriginASN: asn,
}
if p.Newest != nil {
route.LastUpdated = *p.Newest
}
prefixes = append(prefixes, route)
}
return asInfo, prefixes, nil
}
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither
// group is nil, so that a JSON answer holds [] for an empty one and not null.
func splitRoutesByIPVersion(
routes []LiveRoute,
) ([]LiveRoute, []LiveRoute) {
ipv4Routes, ipv6Routes := []LiveRoute{}, []LiveRoute{}
for _, route := range routes {
if route.IPVersion == ipVersionV4 {
ipv4Routes = append(ipv4Routes, route)
} else {
ipv6Routes = append(ipv6Routes, route)
}
}
return ipv4Routes, ipv6Routes
}
// sortRoutesByMaskLength sorts routes by mask length, shortest first, and
// routes with the same mask length by network address.
func sortRoutesByMaskLength(routes []LiveRoute) {
slices.SortFunc(routes, func(a, b LiveRoute) int {
prefixA, _ := netip.ParsePrefix(a.Prefix)
prefixB, _ := netip.ParsePrefix(b.Prefix)
return cmp.Or(cmp.Compare(prefixA.Bits(), prefixB.Bits()),
prefixA.Addr().Compare(prefixB.Addr()))
})
}
// 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
}
// Masking the prefix lets any valid way of writing it find its routes.
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
s.writeJSONError(w, http.StatusBadRequest, invalidPrefixMessage)
return
}
parsed = parsed.Masked()
prefix := parsed.String()
origins := s.table.Origins(parsed)
if len(origins) == 0 {
s.writeJSONError(w, http.StatusNotFound,
ErrNoRoute.Error()+": "+prefix)
return
}
routes := prefixRoutes(parsed, origins)
// Group by origin AS
originMap := make(map[int][]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)
}
}
}
// ASSearchResponse is the answer to an AS name search: the text searched for,
// the number of ASes that match it, and the first maxASSearchResults of them in
// AS number order.
type ASSearchResponse struct {
Query string `json:"query"`
Total int `json:"total"`
Results []asinfo.Info `json:"results"`
}
// handleASSearch returns a handler that searches the AS list built into
// asinfo for the text in the q parameter, without its leading and trailing
// spaces. It answers 400 when no text is left.
func (s *Server) handleASSearch() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get(asSearchQueryParam))
if query == "" {
s.writeJSONError(w, http.StatusBadRequest, searchQueryMissingMessage)
return
}
// AS 0 means no known origin and has no AS page, so the answer leaves
// it out. The matches come in AS number order, so AS 0 can only be the
// first. The search asks for one match more than the answer holds, to
// take its place.
results, total := asinfo.Search(query, maxASSearchResults+1)
if len(results) > 0 && results[0].ASN == ristypes.NoOrigin {
results = results[1:]
total--
}
results = results[:min(len(results), maxASSearchResults)]
// results is an empty list rather than nil when nothing matches, so
// that the answer holds [] and not null.
if results == nil {
results = []asinfo.Info{}
}
err := writeJSONSuccess(w, ASSearchResponse{
Query: query,
Total: total,
Results: results,
})
if err != nil {
s.logger.Error("Failed to encode AS search results", "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.asDetails(r.Context(), asn)
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
return
}
if err != nil {
if errors.Is(err, 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)
sortRoutesByMaskLength(ipv4Prefixes)
sortRoutesByMaskLength(ipv6Prefixes)
// Prepare template data
data := struct {
ASN *database.ASN
IPv4Prefixes []LiveRoute
IPv6Prefixes []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
}
// Masking the prefix lets any valid way of writing it find its routes.
parsed, err := netip.ParsePrefix(prefixParam + "/" + lenParam)
if err != nil {
http.Error(w, invalidPrefixMessage, http.StatusBadRequest)
return
}
parsed = parsed.Masked()
origins := s.table.Origins(parsed)
if len(origins) == 0 {
http.Error(w, "Prefix not found", http.StatusNotFound)
return
}
originInfos, err := s.originASNs(r.Context(), origins)
if deadlinePassed(r.Context()) {
http.Error(w, requestTimeoutMessage, http.StatusGatewayTimeout)
return
}
if err != nil {
s.logger.Error("Failed to get the origin ASes' names", "error", err)
http.Error(w, internalErrorMessage, http.StatusInternalServerError)
return
}
routes := prefixRoutes(parsed, origins)
// Prepare template data
data := struct {
Prefix string
MaskLength int
IPVersion int
Routes []EnhancedRoute
Origins []*ASNInfo
PeerCount int
OriginCount int
CSPNonce string
}{
Prefix: parsed.String(),
MaskLength: parsed.Bits(),
IPVersion: ipVersionOf(parsed),
Routes: routesWithASPathHandles(routes),
Origins: originInfos,
PeerCount: len(routes),
OriginCount: len(originInfos),
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 returns each of origins, a prefix's origin ASes, with its handle
// and description from the database and the number of peers that carry the
// prefix with it.
func (s *Server) originASNs(
ctx context.Context, origins []routetable.Origin,
) ([]*ASNInfo, error) {
infos := make([]*ASNInfo, 0, len(origins))
for _, origin := range origins {
asInfo, _, err := s.db.GetAS(ctx, origin.ASN)
if err != nil {
return nil, err
}
infos = append(infos, &ASNInfo{
ASN: origin.ASN,
Handle: asInfo.Handle,
Description: asInfo.Description,
PeerCount: len(origin.Peers),
})
}
return infos, nil
}
// prefixRoutes returns one route for each peer that carries prefix, by origin
// AS in the order of origins. Each origin's kept routes come first, newest
// first, with their AS path, next hop and time. Its other peers follow without
// them, since the routing table keeps only their addresses.
func prefixRoutes(
prefix netip.Prefix, origins []routetable.Origin,
) []LiveRoute {
var routes []LiveRoute
for _, origin := range origins {
base := LiveRoute{
Prefix: prefix.String(),
MaskLength: prefix.Bits(),
IPVersion: ipVersionOf(prefix),
OriginASN: origin.ASN,
}
kept := make(map[string]bool, len(origin.Routes))
for _, r := range origin.Routes {
kept[r.Peer] = true
route := base
route.PeerIP = r.Peer
route.ASPath = r.ASPath
// The next hop is the peer, as the prefix handler records it.
route.NextHop = r.Peer
route.LastUpdated = r.Time
routes = append(routes, route)
}
for _, peer := range origin.Peers {
if !kept[peer] {
route := base
route.PeerIP = peer
routes = append(routes, route)
}
}
}
return routes
}
// 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 {
LiveRoute
ASPathWithHandle []ASPathEntry
}
// routesWithASPathHandles looks up the handle of each AS in the path of each
// route.
func routesWithASPathHandles(routes []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
}
// 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 := s.table.SamplePrefixes(ipVersion, maskLength, maxPrefixes)
// Sort prefixes for display
slices.SortFunc(prefixes, func(a, b routetable.PrefixSummary) int {
return a.Prefix.Compare(b.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 prefix on the prefix length page, with the origin AS
// that most peers carry it with, that AS's description, and its age.
type EnhancedPrefix struct {
LiveRoute
OriginASDescription string
Age string
}
// prefixesWithASDescriptions looks up the description of the origin AS of
// each prefix. It gives each prefix the age of its newest kept route, or no
// age when it keeps none.
func prefixesWithASDescriptions(
prefixes []routetable.PrefixSummary,
) []EnhancedPrefix {
enhancedPrefixes := make([]EnhancedPrefix, len(prefixes))
for i, prefix := range prefixes {
enhancedPrefixes[i] = EnhancedPrefix{
Prefix: prefix.Prefix.String(),
MaskLength: prefix.Prefix.Bits(),
IPVersion: ipVersionOf(prefix.Prefix),
OriginASN: prefix.OriginASN,
}
if prefix.Newest != nil {
enhancedPrefixes[i].LastUpdated = *prefix.Newest
enhancedPrefixes[i].Age = formatAge(*prefix.Newest)
}
// 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"
}