diff --git a/internal/database/models.go b/internal/database/models.go index dca706d..fcfe8de 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -100,11 +100,12 @@ type ASInfo struct { // IPInfo represents comprehensive IP information for the /ip endpoint type IPInfo struct { - IP string `json:"ip"` - Netblock string `json:"netblock"` - MaskLength int `json:"mask_length"` - IPVersion int `json:"ip_version"` - NumPeers int `json:"num_peers"` + IP string `json:"ip"` + PTR []string `json:"ptr,omitempty"` + Netblock string `json:"netblock"` + MaskLength int `json:"mask_length"` + IPVersion int `json:"ip_version"` + NumPeers int `json:"num_peers"` // AS information ASN int `json:"asn"` ASName string `json:"as_name,omitempty"` diff --git a/internal/server/handlers.go b/internal/server/handlers.go index e26f955..292d9c1 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -11,6 +11,7 @@ import ( "runtime" "sort" "strconv" + "strings" "time" "git.eeqj.de/sneak/routewatch/internal/database" @@ -367,16 +368,17 @@ func (s *Server) handleIPLookup() http.HandlerFunc { return s.handleIPInfo() } -// HostLookupResponse is returned when looking up a hostname. -type HostLookupResponse struct { - Hostname string `json:"hostname,omitempty"` - Results []*database.IPInfo `json:"results"` - Errors []string `json:"errors,omitempty"` +// 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 both IP addresses and hostnames. For hostnames, resolves A and AAAA records. +// 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) { // Get IP/hostname from URL param, falling back to client IP @@ -395,102 +397,91 @@ func (s *Server) handleIPInfo() http.HandlerFunc { return } - // Check if target is already an IP address - if parsedIP := net.ParseIP(target); parsedIP != nil { - // Direct IP lookup - s.lookupSingleIP(w, r, target) + ctx := r.Context() + response := IPLookupResponse{ + Query: target, + Results: make([]*database.IPInfo, 0), + } + + // Collect all IPs to look up + var ipsToLookup []string + + // Check if target contains commas (multiple IPs) + targets := strings.Split(target, ",") + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + + // Check if this target is an IP address + if parsedIP := net.ParseIP(t); parsedIP != nil { + ipsToLookup = append(ipsToLookup, t) + } else { + // It's a hostname - resolve it + resolved, err := net.DefaultResolver.LookupHost(ctx, t) + if err != nil { + response.Errors = append(response.Errors, t+": "+err.Error()) + + continue + } + ipsToLookup = append(ipsToLookup, resolved...) + } + } + + if len(ipsToLookup) == 0 { + writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided") return } - // Target is a hostname - resolve and look up each IP - s.lookupHostname(w, r, target) - } -} + // Track ASNs that need WHOIS refresh + refreshASNs := make(map[int]bool) -// lookupSingleIP handles lookup for a single IP address. -func (s *Server) lookupSingleIP(w http.ResponseWriter, r *http.Request, ip string) { - ipInfo, err := s.db.GetIPInfoContext(r.Context(), ip) - if err != nil { - if errors.Is(err, database.ErrInvalidIP) { - writeJSONError(w, http.StatusBadRequest, err.Error()) - } else { - writeJSONError(w, http.StatusNotFound, err.Error()) + // Look up each IP + for _, ip := range ipsToLookup { + ipInfo, err := s.db.GetIPInfoContext(ctx, ip) + if err != nil { + response.Errors = append(response.Errors, ip+": "+err.Error()) + + continue + } + + // Do PTR lookup for this IP + ptrs, err := net.DefaultResolver.LookupAddr(ctx, ip) + if err == nil && len(ptrs) > 0 { + // Remove trailing dots from PTR records + for i, ptr := range ptrs { + ptrs[i] = strings.TrimSuffix(ptr, ".") + } + ipInfo.PTR = ptrs + } + + response.Results = append(response.Results, ipInfo) + + if ipInfo.NeedsWHOISRefresh { + refreshASNs[ipInfo.ASN] = true + } } - 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, ipInfo); err != nil { - s.logger.Error("Failed to encode IP info", "error", err) - } -} - -// lookupHostname resolves a hostname and looks up info for each IP. -func (s *Server) lookupHostname(w http.ResponseWriter, r *http.Request, hostname string) { - ctx := r.Context() - - // Resolve hostname to IP addresses - ips, err := net.DefaultResolver.LookupHost(ctx, hostname) - if err != nil { - writeJSONError(w, http.StatusBadRequest, "Failed to resolve hostname: "+err.Error()) - - return - } - - if len(ips) == 0 { - writeJSONError(w, http.StatusNotFound, "No IP addresses found for hostname") - - return - } - - response := HostLookupResponse{ - Hostname: hostname, - Results: make([]*database.IPInfo, 0, len(ips)), - } - - // Track ASNs that need WHOIS refresh - refreshASNs := make(map[int]bool) - - // Look up each resolved IP - for _, ip := range ips { - ipInfo, err := s.db.GetIPInfoContext(ctx, ip) - if err != nil { - response.Errors = append(response.Errors, ip+": "+err.Error()) - - continue + // Queue WHOIS refresh for stale ASNs (non-blocking) + if s.asnFetcher != nil { + for asn := range refreshASNs { + s.asnFetcher.QueueImmediate(asn) + } } - response.Results = append(response.Results, ipInfo) + // Return response (even if no results, include errors) + if len(response.Results) == 0 && len(response.Errors) > 0 { + writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0]) - if ipInfo.NeedsWHOISRefresh { - refreshASNs[ipInfo.ASN] = true + return } - } - // Queue WHOIS refresh for stale ASNs (non-blocking) - if s.asnFetcher != nil { - for asn := range refreshASNs { - s.asnFetcher.QueueImmediate(asn) + if err := writeJSONSuccess(w, response); err != nil { + s.logger.Error("Failed to encode IP lookup response", "error", err) } } - - // Return response - if len(response.Results) == 0 { - writeJSONError(w, http.StatusNotFound, "No routes found for any resolved IP") - - return - } - - if err := writeJSONSuccess(w, response); err != nil { - s.logger.Error("Failed to encode hostname lookup response", "error", err) - } } // extractClientIP extracts the client IP from the request.