- Create modular architecture with separate packages for config, database, HTTP, logging, and state management - Implement Cobra CLI with daemon command - Set up Uber FX dependency injection - Add Chi router with health check and IP lookup endpoints - Implement GeoIP database downloader with automatic updates - Add state persistence for tracking database download times - Include comprehensive test coverage for all components - Configure structured logging with slog - Add Makefile with test, lint, and build targets - Support both IPv4 and IPv6 lookups - Return country, city, ASN, and location data in JSON format
158 lines
4.1 KiB
Go
158 lines
4.1 KiB
Go
// Package http provides the HTTP server and routing functionality.
|
|
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/ipapi/internal/database"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// IPInfo represents the API response for IP lookups.
|
|
type IPInfo struct {
|
|
IP string `json:"ip"`
|
|
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"`
|
|
}
|
|
|
|
// NewRouter creates a new HTTP router with all endpoints configured.
|
|
func NewRouter(logger *slog.Logger, db *database.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
|
|
r.Get("/api/{ip}", handleIPLookup(logger, db))
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func handleIPLookup(logger *slog.Logger, db *database.Manager) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ipStr := chi.URLParam(r, "ip")
|
|
|
|
// Validate IP address
|
|
ip := net.ParseIP(ipStr)
|
|
if ip == nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid IP address")
|
|
|
|
return
|
|
}
|
|
|
|
info := &IPInfo{
|
|
IP: ipStr,
|
|
}
|
|
|
|
// Look up in Country database
|
|
if countryDB := db.GetCountryDB(); 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 := db.GetCityDB(); 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 := db.GetASNDB(); asnDB != nil {
|
|
asn, err := asnDB.ASN(ip)
|
|
if err == nil {
|
|
info.ASN = asn.AutonomousSystemNumber
|
|
info.ASNOrg = asn.AutonomousSystemOrganization
|
|
}
|
|
}
|
|
|
|
// Set content type and encode response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(info); err != nil {
|
|
logger.Error("Failed to encode response", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "Internal server error")
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
if err := json.NewEncoder(w).Encode(map[string]string{
|
|
"error": message,
|
|
}); 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
|
|
} |