Unify IP lookup response structure and add PTR lookups

- Always return consistent JSON structure with query and results array
- Add PTR field to IPInfo for reverse DNS records
- Support comma-separated IPs and hostnames in single query
- Do PTR lookup for all IPs (direct, resolved from hostname, or listed)
- Remove trailing dots from PTR records
This commit is contained in:
Jeffrey Paul 2025-12-27 15:56:10 +07:00
parent cb75409647
commit f8b7d3b773
2 changed files with 85 additions and 93 deletions

View File

@ -101,6 +101,7 @@ type ASInfo struct {
// IPInfo represents comprehensive IP information for the /ip endpoint // IPInfo represents comprehensive IP information for the /ip endpoint
type IPInfo struct { type IPInfo struct {
IP string `json:"ip"` IP string `json:"ip"`
PTR []string `json:"ptr,omitempty"`
Netblock string `json:"netblock"` Netblock string `json:"netblock"`
MaskLength int `json:"mask_length"` MaskLength int `json:"mask_length"`
IPVersion int `json:"ip_version"` IPVersion int `json:"ip_version"`

View File

@ -11,6 +11,7 @@ import (
"runtime" "runtime"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"git.eeqj.de/sneak/routewatch/internal/database" "git.eeqj.de/sneak/routewatch/internal/database"
@ -367,16 +368,17 @@ func (s *Server) handleIPLookup() http.HandlerFunc {
return s.handleIPInfo() return s.handleIPInfo()
} }
// HostLookupResponse is returned when looking up a hostname. // IPLookupResponse is the standard response for IP/hostname lookups.
type HostLookupResponse struct { type IPLookupResponse struct {
Hostname string `json:"hostname,omitempty"` Query string `json:"query"`
Results []*database.IPInfo `json:"results"` Results []*database.IPInfo `json:"results"`
Errors []string `json:"errors,omitempty"` Errors []string `json:"errors,omitempty"`
} }
// handleIPInfo returns a handler that provides comprehensive IP information. // handleIPInfo returns a handler that provides comprehensive IP information.
// Used for /ip, /ip/{addr}, and /api/v1/ip/{ip} endpoints. // 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 { func (s *Server) handleIPInfo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Get IP/hostname from URL param, falling back to client IP // Get IP/hostname from URL param, falling back to client IP
@ -395,71 +397,49 @@ func (s *Server) handleIPInfo() http.HandlerFunc {
return return
} }
// Check if target is already an IP address
if parsedIP := net.ParseIP(target); parsedIP != nil {
// Direct IP lookup
s.lookupSingleIP(w, r, target)
return
}
// Target is a hostname - resolve and look up each IP
s.lookupHostname(w, r, target)
}
}
// 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())
}
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() ctx := r.Context()
response := IPLookupResponse{
Query: target,
Results: make([]*database.IPInfo, 0),
}
// Resolve hostname to IP addresses // Collect all IPs to look up
ips, err := net.DefaultResolver.LookupHost(ctx, hostname) 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 { if err != nil {
writeJSONError(w, http.StatusBadRequest, "Failed to resolve hostname: "+err.Error()) response.Errors = append(response.Errors, t+": "+err.Error())
return continue
}
ipsToLookup = append(ipsToLookup, resolved...)
}
} }
if len(ips) == 0 { if len(ipsToLookup) == 0 {
writeJSONError(w, http.StatusNotFound, "No IP addresses found for hostname") writeJSONError(w, http.StatusBadRequest, "No valid IPs or hostnames provided")
return return
} }
response := HostLookupResponse{
Hostname: hostname,
Results: make([]*database.IPInfo, 0, len(ips)),
}
// Track ASNs that need WHOIS refresh // Track ASNs that need WHOIS refresh
refreshASNs := make(map[int]bool) refreshASNs := make(map[int]bool)
// Look up each resolved IP // Look up each IP
for _, ip := range ips { for _, ip := range ipsToLookup {
ipInfo, err := s.db.GetIPInfoContext(ctx, ip) ipInfo, err := s.db.GetIPInfoContext(ctx, ip)
if err != nil { if err != nil {
response.Errors = append(response.Errors, ip+": "+err.Error()) response.Errors = append(response.Errors, ip+": "+err.Error())
@ -467,6 +447,16 @@ func (s *Server) lookupHostname(w http.ResponseWriter, r *http.Request, hostname
continue 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) response.Results = append(response.Results, ipInfo)
if ipInfo.NeedsWHOISRefresh { if ipInfo.NeedsWHOISRefresh {
@ -481,15 +471,16 @@ func (s *Server) lookupHostname(w http.ResponseWriter, r *http.Request, hostname
} }
} }
// Return response // Return response (even if no results, include errors)
if len(response.Results) == 0 { if len(response.Results) == 0 && len(response.Errors) > 0 {
writeJSONError(w, http.StatusNotFound, "No routes found for any resolved IP") writeJSONError(w, http.StatusNotFound, "No routes found: "+response.Errors[0])
return return
} }
if err := writeJSONSuccess(w, response); err != nil { if err := writeJSONSuccess(w, response); err != nil {
s.logger.Error("Failed to encode hostname lookup response", "error", err) s.logger.Error("Failed to encode IP lookup response", "error", err)
}
} }
} }