Files
routewatch/internal/server/handlers.go
T
sneak 10464cfe8b
check / check (push) Waiting to run
Bound the IP lookup's address list and name lookups (closes #110)
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
2026-10-20 00:16:05 +00:00

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"
}