// Package http provides the HTTP server and routing functionality. package http import ( "encoding/json" "log/slog" "net" "net/http" "strings" "time" "git.eeqj.de/sneak/ipapi/internal/database" "git.eeqj.de/sneak/ipapi/internal/state" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) var ( // RFC1918 private address ranges //nolint:gochecknoglobals // Pre-parsed networks for performance rfc1918Networks = []*net.IPNet{} // Documentation address ranges //nolint:gochecknoglobals // Pre-parsed networks for performance documentationIPv4Networks = []*net.IPNet{} //nolint:gochecknoglobals // Pre-parsed networks for performance documentationIPv6Networks = []*net.IPNet{} ) func init() { // Initialize RFC1918 networks for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} { _, network, _ := net.ParseCIDR(cidr) rfc1918Networks = append(rfc1918Networks, network) } // Initialize documentation IPv4 networks for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"} { _, network, _ := net.ParseCIDR(cidr) documentationIPv4Networks = append(documentationIPv4Networks, network) } // Initialize documentation IPv6 networks _, network, _ := net.ParseCIDR("2001:db8::/32") documentationIPv6Networks = append(documentationIPv6Networks, network) } // IPInfo represents the data for a single IP address. type IPInfo struct { IP string `json:"ip"` IPVersion int `json:"ipVersion"` IsPrivate bool `json:"isPrivate"` IsRFC1918 bool `json:"isRfc1918"` IsLoopback bool `json:"isLoopback"` IsMulticast bool `json:"isMulticast"` IsLinkLocal bool `json:"isLinkLocal"` IsGlobalUnicast bool `json:"isGlobalUnicast"` IsUnspecified bool `json:"isUnspecified"` IsBroadcast bool `json:"isBroadcast"` IsDocumentation bool `json:"isDocumentation"` Country string `json:"country,omitempty"` CountryCode string `json:"countryCode,omitempty"` City string `json:"city,omitempty"` Region string `json:"region,omitempty"` PostalCode string `json:"postalCode,omitempty"` Latitude float64 `json:"latitude,omitempty"` Longitude float64 `json:"longitude,omitempty"` Timezone string `json:"timezone,omitempty"` ASN uint `json:"asn,omitempty"` ASNOrg string `json:"asnOrg,omitempty"` DatabaseTimestamp time.Time `json:"databaseTimestamp"` } // SuccessResponse represents a successful API response. type SuccessResponse struct { Status string `json:"status"` Date time.Time `json:"date"` Result map[string]*IPInfo `json:"result"` Error bool `json:"error"` } // ErrorResponse represents an error API response. type ErrorResponse struct { Status string `json:"status"` Date time.Time `json:"date"` Error map[string]interface{} `json:"error"` } // NewRouter creates a new HTTP router with all endpoints configured. func NewRouter(logger *slog.Logger, db *database.Manager, state *state.Manager) (chi.Router, error) { r := chi.NewRouter() // Middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) const requestTimeout = 60 r.Use(middleware.Timeout(requestTimeout)) // Logging middleware r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := r.Context().Value(middleware.RequestIDKey).(string) logger.Debug("HTTP request", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr, "request_id", start, ) next.ServeHTTP(w, r) }) }) // Health check r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte("OK")); err != nil { logger.Error("Failed to write health response", "error", err) } }) // IP lookup endpoint - supports comma-delimited IPs r.Get("/api/v1/ipinfo/local/{ips}", handleIPLookup(logger, db, state)) return r, nil } func handleIPLookup(logger *slog.Logger, db *database.Manager, stateManager *state.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ipsParam := chi.URLParam(r, "ips") // Split comma-delimited IPs ipList := strings.Split(ipsParam, ",") // Check for IP limit const maxIPs = 50 const errorCodeTooManyIPs = 1005 if len(ipList) > maxIPs { writeErrorResponse(w, http.StatusBadRequest, errorCodeTooManyIPs, "Too many IPs requested. Maximum is 50") return } // Get database references countryDB := db.GetCountryDB() cityDB := db.GetCityDB() asnDB := db.GetASNDB() // Check if databases are loaded const errorCodeDatabasesNotLoaded = 1002 if countryDB == nil && cityDB == nil && asnDB == nil { writeErrorResponse(w, http.StatusServiceUnavailable, errorCodeDatabasesNotLoaded, "GeoIP databases are not yet loaded") return } // Get database timestamp stateData, err := stateManager.Load() var dbTimestamp time.Time if err == nil && stateData != nil { // Use the most recent database download time timestamps := []time.Time{ stateData.LastASNDownload, stateData.LastCityDownload, stateData.LastCountryDownload, } dbTimestamp = timestamps[0] for _, ts := range timestamps[1:] { if ts.After(dbTimestamp) { dbTimestamp = ts } } } result := make(map[string]*IPInfo) for _, ipStr := range ipList { ipStr = strings.TrimSpace(ipStr) if ipStr == "" { continue } // Validate IP address ip := net.ParseIP(ipStr) if ip == nil { result[ipStr] = &IPInfo{ IP: ipStr, DatabaseTimestamp: dbTimestamp, } continue } info := &IPInfo{ IP: ipStr, DatabaseTimestamp: dbTimestamp, } // Populate IP properties info.IsPrivate = ip.IsPrivate() info.IsLoopback = ip.IsLoopback() info.IsMulticast = ip.IsMulticast() info.IsLinkLocal = ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() info.IsGlobalUnicast = ip.IsGlobalUnicast() info.IsUnspecified = ip.IsUnspecified() // Determine IP version and additional properties if ipv4 := ip.To4(); ipv4 != nil { info.IPVersion = 4 // Check for RFC1918 private addresses info.IsRFC1918 = isRFC1918(ipv4) // Check for broadcast (255.255.255.255) const maxByte = 255 info.IsBroadcast = ipv4[0] == maxByte && ipv4[1] == maxByte && ipv4[2] == maxByte && ipv4[3] == maxByte // Check for documentation addresses (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) info.IsDocumentation = isDocumentationIPv4(ipv4) } else if ip.To16() != nil { info.IPVersion = 6 info.IsRFC1918 = false info.IsBroadcast = false // Check for documentation addresses (2001:db8::/32) info.IsDocumentation = isDocumentationIPv6(ip) } // Look up in Country database if countryDB != nil { country, err := countryDB.Country(ip) if err == nil { info.Country = country.Country.Names["en"] info.CountryCode = country.Country.IsoCode } } // Look up in City database if cityDB != nil { city, err := cityDB.City(ip) if err == nil { info.City = city.City.Names["en"] if len(city.Subdivisions) > 0 { info.Region = city.Subdivisions[0].Names["en"] } info.PostalCode = city.Postal.Code info.Latitude = city.Location.Latitude info.Longitude = city.Location.Longitude info.Timezone = city.Location.TimeZone } } // Look up in ASN database if asnDB != nil { asn, err := asnDB.ASN(ip) if err == nil { info.ASN = asn.AutonomousSystemNumber info.ASNOrg = asn.AutonomousSystemOrganization } } result[ipStr] = info } // Return success response response := SuccessResponse{ Status: "success", Date: time.Now().UTC(), Result: result, Error: false, } w.Header().Set("Content-Type", "application/json") const errorCodeEncodeFailure = 1004 if err := json.NewEncoder(w).Encode(response); err != nil { logger.Error("Failed to encode response", "error", err) writeErrorResponse(w, http.StatusInternalServerError, errorCodeEncodeFailure, "Failed to encode response") } } } func isRFC1918(ip net.IP) bool { for _, network := range rfc1918Networks { if network.Contains(ip) { return true } } return false } func isDocumentationIPv4(ip net.IP) bool { for _, network := range documentationIPv4Networks { if network.Contains(ip) { return true } } return false } func isDocumentationIPv6(ip net.IP) bool { for _, network := range documentationIPv6Networks { if network.Contains(ip) { return true } } return false } func writeErrorResponse(w http.ResponseWriter, httpCode int, errorCode int, message string) { response := ErrorResponse{ Status: "error", Date: time.Now().UTC(), Error: map[string]interface{}{ "code": errorCode, "msg": message, }, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(httpCode) if err := json.NewEncoder(w).Encode(response); err != nil { // Log error but don't try to write again _ = err } } //nolint:unused // will be used in future for rate limiting func getClientIP(r *http.Request) string { // Check X-Forwarded-For header if xff := r.Header.Get("X-Forwarded-For"); xff != "" { ips := strings.Split(xff, ",") if len(ips) > 0 { return strings.TrimSpace(ips[0]) } } // Check X-Real-IP header if xri := r.Header.Get("X-Real-IP"); xri != "" { return xri } // Fall back to RemoteAddr host, _, _ := net.SplitHostPort(r.RemoteAddr) return host }