Add IP information API with background WHOIS fetcher

- Add /ip and /ip/{addr} JSON endpoints returning comprehensive IP info
- Include ASN, netblock, country code, org name, abuse contact, RIR data
- Extend ASN schema with WHOIS fields (country, org, abuse contact, etc)
- Create background WHOIS fetcher for rate-limited ASN info updates
- Store raw WHOIS responses for debugging and data preservation
- Queue on-demand WHOIS lookups when stale data is requested
- Refactor handleIPInfo to serve all IP endpoints consistently
This commit is contained in:
2025-12-27 15:47:35 +07:00
parent 7e4dc528bd
commit 3b159454eb
12 changed files with 992 additions and 59 deletions

View File

@@ -364,35 +364,66 @@ func (s *Server) handleStatusHTML() http.HandlerFunc {
// handleIPLookup returns a handler that looks up AS information for an IP address
func (s *Server) handleIPLookup() http.HandlerFunc {
return s.handleIPInfo()
}
// handleIPInfo returns a handler that provides comprehensive IP information.
// Used for /ip, /ip/{addr}, and /api/v1/ip/{ip} endpoints.
func (s *Server) handleIPInfo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get IP from URL param, falling back to client IP
ip := chi.URLParam(r, "ip")
if ip == "" {
writeJSONError(w, http.StatusBadRequest, "IP parameter is required")
ip = chi.URLParam(r, "addr")
}
if ip == "" {
// Use client IP (RealIP middleware has already processed this)
ip = extractClientIP(r)
}
if ip == "" {
writeJSONError(w, http.StatusBadRequest, "Could not determine IP address")
return
}
// Look up AS information for the IP
asInfo, err := s.db.GetASInfoForIPContext(r.Context(), ip)
// Look up comprehensive IP information
ipInfo, err := s.db.GetIPInfoContext(r.Context(), ip)
if err != nil {
// Check if it's an invalid IP error
if errors.Is(err, database.ErrInvalidIP) {
writeJSONError(w, http.StatusBadRequest, err.Error())
} else {
// All other errors (including ErrNoRoute) are 404
writeJSONError(w, http.StatusNotFound, err.Error())
}
return
}
// Queue WHOIS refresh if data is stale (non-blocking)
if ipInfo.NeedsWHOISRefresh && s.asnFetcher != nil {
s.asnFetcher.QueueImmediate(ipInfo.ASN)
}
// Return successful response
if err := writeJSONSuccess(w, asInfo); err != nil {
s.logger.Error("Failed to encode AS info", "error", err)
if err := writeJSONSuccess(w, ipInfo); err != nil {
s.logger.Error("Failed to encode IP info", "error", err)
}
}
}
// extractClientIP extracts the client IP from the request.
// Works with chi's RealIP middleware which sets RemoteAddr.
func extractClientIP(r *http.Request) string {
// RemoteAddr is in the form "IP:port" or just "IP" for unix sockets
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// Might be just an IP without port
return r.RemoteAddr
}
return host
}
// handleASDetailJSON returns AS details as JSON
func (s *Server) handleASDetailJSON() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -750,36 +781,6 @@ func (s *Server) handlePrefixDetail() http.HandlerFunc {
}
}
// handleIPRedirect looks up the prefix containing the IP and redirects to its detail page
func (s *Server) handleIPRedirect() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := chi.URLParam(r, "ip")
if ip == "" {
http.Error(w, "IP parameter is required", http.StatusBadRequest)
return
}
// Look up AS information for the IP (which includes the prefix)
asInfo, err := s.db.GetASInfoForIP(ip)
if err != nil {
if errors.Is(err, database.ErrInvalidIP) {
http.Error(w, "Invalid IP address", http.StatusBadRequest)
} else if errors.Is(err, database.ErrNoRoute) {
http.Error(w, "No route found for this IP", http.StatusNotFound)
} else {
s.logger.Error("Failed to look up IP", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Redirect to the prefix detail page (URL encode the prefix)
http.Redirect(w, r, "/prefix/"+url.QueryEscape(asInfo.Prefix), http.StatusSeeOther)
}
}
// handlePrefixLength shows a random sample of IPv4 prefixes with the specified mask length
func (s *Server) handlePrefixLength() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View File

@@ -30,7 +30,13 @@ func (s *Server) setupRoutes() {
r.Get("/prefix/{prefix}", s.handlePrefixDetail())
r.Get("/prefixlength/{length}", s.handlePrefixLength())
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
r.Get("/ip/{ip}", s.handleIPRedirect())
// IP info JSON endpoints (replaces old /ip redirect)
r.Route("/ip", func(r chi.Router) {
r.Use(JSONValidationMiddleware)
r.Get("/", s.handleIPInfo()) // Client IP
r.Get("/{addr}", s.handleIPInfo()) // Specified IP
})
// API routes
r.Route("/api/v1", func(r chi.Router) {

View File

@@ -13,13 +13,19 @@ import (
"github.com/go-chi/chi/v5"
)
// ASNFetcher is an interface for queuing ASN WHOIS lookups.
type ASNFetcher interface {
QueueImmediate(asn int)
}
// Server provides HTTP endpoints for status monitoring
type Server struct {
router *chi.Mux
db database.Store
streamer *streamer.Streamer
logger *logger.Logger
srv *http.Server
router *chi.Mux
db database.Store
streamer *streamer.Streamer
logger *logger.Logger
srv *http.Server
asnFetcher ASNFetcher
}
// New creates a new HTTP server
@@ -70,3 +76,8 @@ func (s *Server) Stop(ctx context.Context) error {
return s.srv.Shutdown(ctx)
}
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups.
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
s.asnFetcher = fetcher
}