From 2c9ad491fffc95839687e3adce0f1be026227128 Mon Sep 17 00:00:00 2001 From: skidoodle Date: Fri, 28 Feb 2025 18:29:27 +0000 Subject: [PATCH] Restructure and refactor codebase --- db.go | 73 -------- go.mod | 2 +- health.go | 11 -- internal/common/common.go | 181 +++++++++++++++++++ internal/db/db.go | 214 ++++++++++++++++++++++ internal/server/server.go | 236 +++++++++++++++++++++++++ main.go | 44 ++++- server.go | 160 ----------------- utils/health/health.go | 15 ++ iputils.go => utils/iputils/iputils.go | 132 +------------- 10 files changed, 690 insertions(+), 378 deletions(-) delete mode 100644 db.go delete mode 100644 health.go create mode 100644 internal/common/common.go create mode 100644 internal/db/db.go create mode 100644 internal/server/server.go delete mode 100644 server.go create mode 100644 utils/health/health.go rename iputils.go => utils/iputils/iputils.go (62%) diff --git a/db.go b/db.go deleted file mode 100644 index f176b48..0000000 --- a/db.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "os/exec" - "sync" - - "github.com/oschwald/maxminddb-golang" -) - -var ( - cityDB *maxminddb.Reader - asnDB *maxminddb.Reader - dbMtx = new(sync.RWMutex) -) - -const ( - cityDBPath = "./GeoLite2-City.mmdb" - asnDBPath = "./GeoLite2-ASN.mmdb" -) - -func downloadDB() error { - cmd := exec.Command("geoipupdate", "-d", "./") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to download database: %v. Ensure geoipupdate is installed and configured", err) - } - return nil -} - -func initDatabases() { - var err error - - dbMtx.Lock() - defer dbMtx.Unlock() - - cityDB, err = maxminddb.Open(cityDBPath) - if err != nil { - if os.IsNotExist(err) { - log.Println("City database not found, attempting to download...") - if errDownload := downloadDB(); errDownload != nil { - log.Fatalf("Error downloading city database: %v", errDownload) - } - cityDB, err = maxminddb.Open(cityDBPath) - if err != nil { - log.Fatalf("Error opening city database after download: %v", err) - } - } else { - log.Fatalf("Error opening city database: %v", err) - } - } - - asnDB, err = maxminddb.Open(asnDBPath) - if err != nil { - if os.IsNotExist(err) { - log.Println("ASN database not found, attempting to download...") - if errDownload := downloadDB(); errDownload != nil { - log.Fatalf("Error downloading ASN database: %v", errDownload) - } - asnDB, err = maxminddb.Open(asnDBPath) - if err != nil { - log.Fatalf("Error opening ASN database after download: %v", err) - } - } else { - log.Fatalf("Error opening ASN database: %v", err) - } - } -} - -func startUpdater() { - log.Println("MaxMind GeoIP databases will be updated by geoipupdate automatically.") -} diff --git a/go.mod b/go.mod index 90db679..1fccb66 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/skidoodle/ipinfo +module skidoodle/ipinfo go 1.22.4 diff --git a/health.go b/health.go deleted file mode 100644 index 6356226..0000000 --- a/health.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "net/http" -) - -func healthCheck() { - http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Healthy")) - }) -} diff --git a/internal/common/common.go b/internal/common/common.go new file mode 100644 index 0000000..d222146 --- /dev/null +++ b/internal/common/common.go @@ -0,0 +1,181 @@ +package internal + +import ( + "fmt" + "log" + "net" + "net/http" + "strings" + "sync" + "time" + + db "skidoodle/ipinfo/internal/db" + iputils "skidoodle/ipinfo/utils/iputils" +) + +type DataStruct struct { + IP *string `json:"ip"` + Hostname *string `json:"hostname"` + ASN *string `json:"asn"` + Org *string `json:"org"` + City *string `json:"city"` + Region *string `json:"region"` + Country *string `json:"country"` + Continent *string `json:"continent"` + Timezone *string `json:"timezone"` + Loc *string `json:"loc"` +} + +// Global IP cache with 10 minute TTL +var ipCache = NewIPCache(10 * time.Minute) + +type cachedIPData struct { + data *DataStruct + time time.Time +} + +// IPCache provides thread-safe caching of IP lookup results +type IPCache struct { + cache sync.Map + ttl time.Duration +} + +// NewIPCache creates a new IP cache with the specified TTL +func NewIPCache(ttl time.Duration) *IPCache { + return &IPCache{ + ttl: ttl, + } +} + +// Set stores an IP in the cache +func (c *IPCache) Set(ipStr string, data *DataStruct) { + c.cache.Store(ipStr, cachedIPData{ + data: data, + time: time.Now(), + }) +} + +// Get retrieves an IP from the cache if it exists and is not expired +func (c *IPCache) Get(ipStr string) (*DataStruct, bool) { + if cachedData, ok := c.cache.Load(ipStr); ok { + cached := cachedData.(cachedIPData) + if time.Since(cached.time) < c.ttl { + return cached.data, true + } + c.cache.Delete(ipStr) + } + return nil, false +} + +// LookupIPData looks up IP data in the databases with caching +func LookupIPData(geoIP *db.GeoIPManager, ip net.IP) *DataStruct { + // Check cache first + if data, found := ipCache.Get(ip.String()); found { + return data + } + + var cityRecord struct { + City struct { + Names map[string]string `maxminddb:"names"` + } `maxminddb:"city"` + Subdivisions []struct { + Names map[string]string `maxminddb:"names"` + } `maxminddb:"subdivisions"` + Country struct { + IsoCode string `maxminddb:"iso_code"` + Names map[string]string `maxminddb:"names"` + } `maxminddb:"country"` + Continent struct { + Code string `maxminddb:"code"` + Names map[string]string `maxminddb:"names"` + } `maxminddb:"continent"` + Location struct { + Latitude float64 `maxminddb:"latitude"` + Longitude float64 `maxminddb:"longitude"` + Timezone string `maxminddb:"time_zone"` + } `maxminddb:"location"` + } + + // Get database readers using thread-safe accessor methods + cityDB := geoIP.GetCityDB() + err := cityDB.Lookup(ip, &cityRecord) + if err != nil { + log.Printf("Error looking up city data: %v", err) + return nil + } + + var asnRecord struct { + AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"` + AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"` + } + asnDB := geoIP.GetASNDB() + err = asnDB.Lookup(ip, &asnRecord) + if err != nil { + log.Printf("Error looking up ASN data: %v", err) + return nil + } + + hostname, err := net.LookupAddr(ip.String()) + if err != nil || len(hostname) == 0 { + hostname = []string{""} + } + + var sd *string + if len(cityRecord.Subdivisions) > 0 { + name := cityRecord.Subdivisions[0].Names["en"] + sd = &name + } + + data := &DataStruct{ + IP: ToPtr(ip.String()), + Hostname: ToPtr(strings.TrimSuffix(hostname[0], ".")), + ASN: ToPtr(fmt.Sprintf("%d", asnRecord.AutonomousSystemNumber)), + Org: ToPtr(asnRecord.AutonomousSystemOrganization), + City: ToPtr(cityRecord.City.Names["en"]), + Region: sd, + Country: ToPtr(cityRecord.Country.Names["en"]), + Continent: ToPtr(cityRecord.Continent.Names["en"]), + Timezone: ToPtr(cityRecord.Location.Timezone), + Loc: ToPtr(fmt.Sprintf("%.4f,%.4f", cityRecord.Location.Latitude, cityRecord.Location.Longitude)), + } + + // Store in cache + ipCache.Set(ip.String(), data) + + return data +} + +// ToPtr converts string to pointer +func ToPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +// IsBogon checks if the IP is a bogon IP +func IsBogon(ip net.IP) bool { + for _, net := range iputils.BogonNets { + if net.Contains(ip) { + return true + } + } + return false +} + +// GetRealIP extracts the client's real IP address from request headers +func GetRealIP(r *http.Request) string { + // Try common proxy headers first + for _, header := range []string{"CF-Connecting-IP", "X-Real-IP", "X-Forwarded-For"} { + if ip := r.Header.Get(header); ip != "" { + return strings.TrimSpace(strings.Split(ip, ",")[0]) + } + } + + // Fall back to remote address + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..9165b48 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,214 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/exec" + "sync" + "time" + + "github.com/oschwald/maxminddb-golang" +) + +// Database paths +const ( + CityDBPath = "./GeoLite2-City.mmdb" + ASNDBPath = "./GeoLite2-ASN.mmdb" +) + +// Common errors +var ( + ErrDatabaseNotFound = errors.New("database file not found") + ErrDatabaseOpen = errors.New("failed to open database") + ErrDownloadFailed = errors.New("failed to download database") +) + +// Handles MaxMind GeoIP database operations +type GeoIPManager struct { + cityDB *maxminddb.Reader + asnDB *maxminddb.Reader + mu sync.RWMutex +} + +// Creates and initializes a new GeoIP database +func NewGeoIPManager() (*GeoIPManager, error) { + manager := &GeoIPManager{} + if err := manager.Initialize(); err != nil { + return nil, fmt.Errorf("initializing GeoIP manager: %w", err) + } + return manager, nil +} + +// Initialize opens the GeoIP databases, downloading them if necessary +func (g *GeoIPManager) Initialize() error { + g.mu.Lock() + defer g.mu.Unlock() + + if err := g.openCityDB(); err != nil { + return err + } + + if err := g.openASNDB(); err != nil { + return err + } + + return nil +} + +// Opens the city database, downloading it if necessary +func (g *GeoIPManager) openCityDB() error { + var err error + g.cityDB, err = maxminddb.Open(CityDBPath) + if err != nil { + if os.IsNotExist(err) { + log.Println("City database not found, attempting to download...") + if err := g.downloadDatabases(); err != nil { + return fmt.Errorf("%w: %v", ErrDownloadFailed, err) + } + g.cityDB, err = maxminddb.Open(CityDBPath) + if err != nil { + return fmt.Errorf("%w (city): %v", ErrDatabaseOpen, err) + } + return nil + } + return fmt.Errorf("%w (city): %v", ErrDatabaseOpen, err) + } + return nil +} + +// Opens the ASN database, downloading it if necessary +func (g *GeoIPManager) openASNDB() error { + var err error + g.asnDB, err = maxminddb.Open(ASNDBPath) + if err != nil { + if os.IsNotExist(err) { + log.Println("ASN database not found, attempting to download...") + if err := g.downloadDatabases(); err != nil { + return fmt.Errorf("%w: %v", ErrDownloadFailed, err) + } + g.asnDB, err = maxminddb.Open(ASNDBPath) + if err != nil { + return fmt.Errorf("%w (ASN): %v", ErrDatabaseOpen, err) + } + return nil + } + return fmt.Errorf("%w (ASN): %v", ErrDatabaseOpen, err) + } + return nil +} + +// Downloads both GeoIP databases using geoipupdate +func (g *GeoIPManager) downloadDatabases() error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "geoipupdate", "-d", "./") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to download databases: %v. Output: %s. Ensure geoipupdate is installed and configured", err, output) + } + return nil +} + +// Close properly closes the database readers +func (g *GeoIPManager) Close() error { + g.mu.Lock() + defer g.mu.Unlock() + + var errs []error + + if g.cityDB != nil { + if err := g.cityDB.Close(); err != nil { + errs = append(errs, fmt.Errorf("closing city database: %w", err)) + } + } + + if g.asnDB != nil { + if err := g.asnDB.Close(); err != nil { + errs = append(errs, fmt.Errorf("closing ASN database: %w", err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("errors closing databases: %v", errs) + } + return nil +} + +// Safely provides read access to the City database +func (g *GeoIPManager) GetCityDB() *maxminddb.Reader { + g.mu.RLock() + defer g.mu.RUnlock() + return g.cityDB +} + +// Safely provides read access to the ASN database +func (g *GeoIPManager) GetASNDB() *maxminddb.Reader { + g.mu.RLock() + defer g.mu.RUnlock() + return g.asnDB +} + +// Sets up automatic database updates +func (g *GeoIPManager) StartUpdater(ctx context.Context, updateInterval time.Duration) { + log.Printf("Starting MaxMind GeoIP database updater with interval: %s", updateInterval) + + ticker := time.NewTicker(updateInterval) + go func() { + for { + select { + case <-ticker.C: + log.Println("Performing scheduled GeoIP database update") + if err := g.UpdateDatabases(); err != nil { + log.Printf("Failed to update databases: %v", err) + } + case <-ctx.Done(): + ticker.Stop() + log.Println("GeoIP database updater stopped") + return + } + } + }() +} + +// Downloads fresh copies of the databases and reloads them +func (g *GeoIPManager) UpdateDatabases() error { + // Download new databases + if err := g.downloadDatabases(); err != nil { + return err + } + + g.mu.Lock() + defer g.mu.Unlock() + + // Close existing databases + if g.cityDB != nil { + if err := g.cityDB.Close(); err != nil { + log.Printf("Warning: error closing city database: %v", err) + } + } + + if g.asnDB != nil { + if err := g.asnDB.Close(); err != nil { + log.Printf("Warning: error closing ASN database: %v", err) + } + } + + // Reopen databases + var err error + g.cityDB, err = maxminddb.Open(CityDBPath) + if err != nil { + return fmt.Errorf("reopening city database: %w", err) + } + + g.asnDB, err = maxminddb.Open(ASNDBPath) + if err != nil { + return fmt.Errorf("reopening ASN database: %w", err) + } + + log.Println("Successfully updated and reloaded GeoIP databases") + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..9024141 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,236 @@ +package internal + +import ( + "compress/gzip" + "context" + "encoding/json" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + common "skidoodle/ipinfo/internal/common" + db "skidoodle/ipinfo/internal/db" + utils "skidoodle/ipinfo/utils/health" +) + +type bogonDataStruct struct { + IP string `json:"ip"` + Bogon bool `json:"bogon"` +} + +type gzipResponseWriter struct { + http.ResponseWriter + Writer *gzip.Writer +} + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +// HTTP server and its dependencies +type Server struct { + geoIP *db.GeoIPManager + server *http.Server + shutdownSignal chan struct{} +} + +// Creates a new server with the given GeoIPManager +func NewServer(geoIP *db.GeoIPManager) *Server { + s := &Server{ + geoIP: geoIP, + shutdownSignal: make(chan struct{}), + } + + mux := http.NewServeMux() + mux.Handle("/health", utils.HealthCheck()) // Register healthcheck + mux.HandleFunc("/", s.handler) // Main handler + + // Create HTTP server + s.server = &http.Server{ + Addr: ":3000", + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + return s +} + +// Starts the HTTP server +func (s *Server) Start(ctx context.Context) error { + // Start the server in a goroutine + go func() { + log.Println("Server listening on :3000") + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + // Wait for shutdown signal or context cancellation + select { + case <-s.shutdownSignal: + log.Println("Shutdown requested internally") + case <-ctx.Done(): + log.Println("Shutdown requested from context") + } + + // Create shutdown context with timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Shutdown the server + if err := s.server.Shutdown(shutdownCtx); err != nil { + return err + } + + log.Println("Server shutdown complete") + return nil +} + +// Graceful server shutdown +func (s *Server) Shutdown() { + close(s.shutdownSignal) +} + +// Field access functions map +var fieldMap = map[string]func(*common.DataStruct) *string{ + "ip": func(d *common.DataStruct) *string { return d.IP }, + "hostname": func(d *common.DataStruct) *string { return d.Hostname }, + "asn": func(d *common.DataStruct) *string { return d.ASN }, + "org": func(d *common.DataStruct) *string { return d.Org }, + "city": func(d *common.DataStruct) *string { return d.City }, + "region": func(d *common.DataStruct) *string { return d.Region }, + "country": func(d *common.DataStruct) *string { return d.Country }, + "continent": func(d *common.DataStruct) *string { return d.Continent }, + "timezone": func(d *common.DataStruct) *string { return d.Timezone }, + "loc": func(d *common.DataStruct) *string { return d.Loc }, +} + +// Retrieves a field from the dataStruct using the fieldMap +func getField(data *common.DataStruct, field string) *string { + if f, ok := fieldMap[field]; ok { + return f(data) + } + return nil +} + +// Processes HTTP requests +func (s *Server) handler(w http.ResponseWriter, r *http.Request) { + requestedThings := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + + // Enable gzip compression if requested + if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + defer gz.Close() + w = &gzipResponseWriter{Writer: gz, ResponseWriter: w} + } + + var IPAddress, field string + + // Parse the request URL + switch len(requestedThings) { + case 0: + IPAddress = common.GetRealIP(r) // Default to visitor's IP + case 1: + if requestedThings[0] == "" { + IPAddress = common.GetRealIP(r) // Handle root page case + } else if _, ok := fieldMap[requestedThings[0]]; ok { + IPAddress = common.GetRealIP(r) + field = requestedThings[0] + } else if net.ParseIP(requestedThings[0]) != nil { + IPAddress = requestedThings[0] // Valid IP provided + } else { + sendJSONError(w, "Please provide a valid IP address.", http.StatusBadRequest) + return + } + case 2: + IPAddress = requestedThings[0] + if _, ok := fieldMap[requestedThings[1]]; ok { + field = requestedThings[1] + } else { + sendJSONError(w, "Please provide a valid field.", http.StatusBadRequest) + return + } + default: + sendJSONError(w, "Please provide a valid IP address.", http.StatusBadRequest) + return + } + + // Validate the resolved IP + ip := net.ParseIP(IPAddress) + if ip == nil { + sendJSONError(w, "Please provide a valid IP address.", http.StatusBadRequest) + return + } + + // Check if the IP is bogon + if common.IsBogon(ip) { + sendJSONResponse(w, bogonDataStruct{IP: ip.String(), Bogon: true}, http.StatusOK) + return + } + + // Look up IP data + data := common.LookupIPData(s.geoIP, ip) + if data == nil { + sendJSONError(w, "Please provide a valid IP address.", http.StatusBadRequest) + return + } + + // Handle specific field requests + if field != "" { + value := getField(data, field) + sendJSONResponse(w, map[string]*string{field: value}, http.StatusOK) + return + } + + // Default case: return full IP data + sendJSONResponse(w, data, http.StatusOK) +} + +// Sends a JSON response with the given data and status code +func sendJSONResponse(w http.ResponseWriter, data any, statusCode int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statusCode) + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + if err := encoder.Encode(data); err != nil { + log.Printf("Error encoding JSON response: %v", err) + } +} + +// Sends a JSON error response +func sendJSONError(w http.ResponseWriter, errMsg string, statusCode int) { + sendJSONResponse(w, map[string]string{"error": errMsg}, statusCode) +} + +// Initializes and starts the server with the given GeoIPManager +func StartServer(ctx context.Context, geoIP *db.GeoIPManager) error { + // Create new server with the GeoIPManager + server := NewServer(geoIP) + + // Set up signal handling for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Handle shutdown signals + go func() { + select { + case <-sigCh: + log.Println("Received termination signal") + server.Shutdown() + case <-ctx.Done(): + log.Println("Context cancelled") + server.Shutdown() + } + }() + + // Start the server + return server.Start(ctx) +} diff --git a/main.go b/main.go index b2d0b18..a7e9696 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,44 @@ package main +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + db "skidoodle/ipinfo/internal/db" + server "skidoodle/ipinfo/internal/server" +) + func main() { - initDatabases() - go startUpdater() - healthCheck() - startServer() + // Create context with cancellation for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Set up signal handling for graceful shutdown + go handleSignals(cancel) + + // Initialize GeoIP manager from internal/db package + geoIP, err := db.NewGeoIPManager() + if err != nil { + log.Fatalf("Failed to initialize GeoIP databases: %v", err) + } + defer geoIP.Close() + + // Start database updater in background (update every 24 hours) + geoIP.StartUpdater(ctx, 24*time.Hour) + + // Start health check and server + server.StartServer(ctx, geoIP) +} + +// handleSignals gracefully handles termination signals +func handleSignals(cancel context.CancelFunc) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + log.Printf("Received signal %v, shutting down gracefully", sig) + cancel() } diff --git a/server.go b/server.go deleted file mode 100644 index 1582d5b..0000000 --- a/server.go +++ /dev/null @@ -1,160 +0,0 @@ -package main - -import ( - "compress/gzip" - "encoding/json" - "log" - "net" - "net/http" - "strings" -) - -var invalidIPBytes = []byte("Please provide a valid IP address.") -var invalidFieldBytes = []byte("Please provide a valid field.") - -type dataStruct struct { - IP *string `json:"ip"` - Hostname *string `json:"hostname"` - ASN *string `json:"asn"` - Org *string `json:"org"` - City *string `json:"city"` - Region *string `json:"region"` - Country *string `json:"country"` - Continent *string `json:"continent"` - Timezone *string `json:"timezone"` - Loc *string `json:"loc"` -} - -type bogonDataStruct struct { - IP string `json:"ip"` - Bogon bool `json:"bogon"` -} - -func startServer() { - log.Println("Server listening on :3000") - http.HandleFunc("/", handler) - log.Fatal(http.ListenAndServe(":3000", nil)) -} - -type gzipResponseWriter struct { - http.ResponseWriter - Writer *gzip.Writer -} - -func (w gzipResponseWriter) Write(b []byte) (int, error) { - return w.Writer.Write(b) -} - -func handler(w http.ResponseWriter, r *http.Request) { - requestedThings := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - - // Enable gzip compression if requested - if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { - w.Header().Set("Content-Encoding", "gzip") - gz := gzip.NewWriter(w) - defer gz.Close() - w = &gzipResponseWriter{Writer: gz, ResponseWriter: w} - } - - var IPAddress, field string - - // Parse the request URL - switch len(requestedThings) { - case 0: - IPAddress = getRealIP(r) // Default to visitor's IP - case 1: - if requestedThings[0] == "" { - IPAddress = getRealIP(r) // Handle root page case - } else if _, ok := fieldMap[requestedThings[0]]; ok { - IPAddress = getRealIP(r) - field = requestedThings[0] - } else if net.ParseIP(requestedThings[0]) != nil { - IPAddress = requestedThings[0] // Valid IP provided - } else { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusBadRequest) - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - encoder.Encode(map[string]string{"error": string(invalidIPBytes)}) - return - } - case 2: - IPAddress = requestedThings[0] - if _, ok := fieldMap[requestedThings[1]]; ok { - field = requestedThings[1] - } else { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusBadRequest) - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - encoder.Encode(map[string]string{"error": string(invalidFieldBytes)}) - return - } - default: - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusBadRequest) - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - encoder.Encode(map[string]string{"error": string(invalidIPBytes)}) - return - } - - // Validate the resolved IP - ip := net.ParseIP(IPAddress) - if ip == nil { - http.Error(w, string(invalidIPBytes), http.StatusBadRequest) - return - } - - // Check if the IP is a bogon IP - if isBogon(ip) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - encoder.Encode(bogonDataStruct{IP: ip.String(), Bogon: true}) - return - } - - // Look up IP data - data := lookupIPData(ip) - if data == nil { - http.Error(w, string(invalidIPBytes), http.StatusBadRequest) - return - } - - // Handle specific field requests - if field != "" { - value := getField(data, field) - w.Header().Set("Content-Type", "application/json; charset=utf-8") - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - encoder.Encode(map[string]*string{field: value}) - return - } - - // Default case: return full IP data - w.Header().Set("Content-Type", "application/json; charset=utf-8") - encoder := json.NewEncoder(w) - encoder.SetIndent("", " ") - encoder.Encode(data) -} - -var fieldMap = map[string]func(*dataStruct) *string{ - "ip": func(d *dataStruct) *string { return d.IP }, - "hostname": func(d *dataStruct) *string { return d.Hostname }, - "asn": func(d *dataStruct) *string { return d.ASN }, - "org": func(d *dataStruct) *string { return d.Org }, - "city": func(d *dataStruct) *string { return d.City }, - "region": func(d *dataStruct) *string { return d.Region }, - "country": func(d *dataStruct) *string { return d.Country }, - "continent": func(d *dataStruct) *string { return d.Continent }, - "timezone": func(d *dataStruct) *string { return d.Timezone }, - "loc": func(d *dataStruct) *string { return d.Loc }, -} - -func getField(data *dataStruct, field string) *string { - if f, ok := fieldMap[field]; ok { - return f(data) - } - return nil -} diff --git a/utils/health/health.go b/utils/health/health.go new file mode 100644 index 0000000..7ea7d48 --- /dev/null +++ b/utils/health/health.go @@ -0,0 +1,15 @@ +package utils + +import ( + "net/http" +) + +// Returns a simple health check handler +func HealthCheck() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Healthy")) + }) + return mux +} diff --git a/iputils.go b/utils/iputils/iputils.go similarity index 62% rename from iputils.go rename to utils/iputils/iputils.go index 068518c..b555d6d 100644 --- a/iputils.go +++ b/utils/iputils/iputils.go @@ -1,16 +1,11 @@ -package main +package utils import ( - "fmt" - "log" "net" - "net/http" - "strings" - "sync" - "time" ) -var bogonNets = []*net.IPNet{ +// Contains a list of known bogon IP ranges +var BogonNets = []*net.IPNet{ // IPv4 {IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // "This" network {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // Private-use networks @@ -70,124 +65,3 @@ var bogonNets = []*net.IPNet{ {IP: net.ParseIP("2001:0:f000::/36"), Mask: net.CIDRMask(36, 128)}, // Teredo bogon (240.0.0.0/4) {IP: net.ParseIP("2001:0:ffff:ffff::/64"), Mask: net.CIDRMask(64, 128)}, // Teredo bogon (255.255.255.255/32) } - -// Check if the IP is a bogon IP -func isBogon(ip net.IP) bool { - for _, net := range bogonNets { - if net.Contains(ip) { - return true - } - } - return false -} - -// Get the real IP address from the request headers -func getRealIP(r *http.Request) string { - for _, header := range []string{"CF-Connecting-IP", "X-Real-IP", "X-Forwarded-For"} { - if ip := r.Header.Get(header); ip != "" { - return strings.TrimSpace(strings.Split(ip, ",")[0]) - } - } - - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - -var ipCache = sync.Map{} - -type cachedIPData struct { - data *dataStruct - time time.Time -} - -const cacheTTL = time.Minute * 10 - -// Lookup IP data in the databases with caching -func lookupIPData(ip net.IP) *dataStruct { - if cachedData, ok := ipCache.Load(ip.String()); ok { - cached := cachedData.(cachedIPData) - if time.Since(cached.time) < cacheTTL { - return cached.data - } - ipCache.Delete(ip.String()) - } - - dbMtx.RLock() - defer dbMtx.RUnlock() - - var cityRecord struct { - City struct { - Names map[string]string `maxminddb:"names"` - } `maxminddb:"city"` - Subdivisions []struct { - Names map[string]string `maxminddb:"names"` - } `maxminddb:"subdivisions"` - Country struct { - IsoCode string `maxminddb:"iso_code"` - Names map[string]string `maxminddb:"names"` - } `maxminddb:"country"` - Continent struct { - Code string `maxminddb:"code"` - Names map[string]string `maxminddb:"names"` - } `maxminddb:"continent"` - Location struct { - Latitude float64 `maxminddb:"latitude"` - Longitude float64 `maxminddb:"longitude"` - Timezone string `maxminddb:"time_zone"` - } `maxminddb:"location"` - } - err := cityDB.Lookup(ip, &cityRecord) - if err != nil { - log.Printf("Error looking up city data: %v\n", err) - return nil - } - - var asnRecord struct { - AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"` - AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"` - } - err = asnDB.Lookup(ip, &asnRecord) - if err != nil { - log.Printf("Error looking up ASN data: %v\n", err) - return nil - } - - hostname, err := net.LookupAddr(ip.String()) - if err != nil || len(hostname) == 0 { - hostname = []string{""} - } - - var sd *string - if len(cityRecord.Subdivisions) > 0 { - name := cityRecord.Subdivisions[0].Names["en"] - sd = &name - } - - data := &dataStruct{ - IP: toPtr(ip.String()), - Hostname: toPtr(strings.TrimSuffix(hostname[0], ".")), - ASN: toPtr(fmt.Sprintf("%d", asnRecord.AutonomousSystemNumber)), - Org: toPtr(asnRecord.AutonomousSystemOrganization), - City: toPtr(cityRecord.City.Names["en"]), - Region: sd, - Country: toPtr(cityRecord.Country.Names["en"]), - Continent: toPtr(cityRecord.Continent.Names["en"]), - Timezone: toPtr(cityRecord.Location.Timezone), - Loc: toPtr(fmt.Sprintf("%.4f,%.4f", cityRecord.Location.Latitude, cityRecord.Location.Longitude)), - } - - ipCache.Store(ip.String(), cachedIPData{data: data, time: time.Now()}) - - return data -} - -// Convert string to pointer -func toPtr(s string) *string { - if s == "" { - return nil - } - return &s -}