From 477bc242aaf406ebf8a558f6f4c881336d7841c4 Mon Sep 17 00:00:00 2001 From: skidoodle Date: Sat, 9 Aug 2025 14:52:29 +0200 Subject: [PATCH] Refactor project structure and enhance logging --- .gitignore | 9 +- Dockerfile | 23 +- docker-compose.dev.yml => compose.dev.yaml | 1 - docker-compose.yml => compose.yaml | 0 go.mod | 13 +- go.sum | 22 +- internal/common/common.go | 13 + internal/db/db.go | 340 ++++++++++++--------- internal/logger/logger.go | 13 + internal/server/server.go | 173 ++++++----- main.go | 37 ++- 11 files changed, 380 insertions(+), 264 deletions(-) rename docker-compose.dev.yml => compose.dev.yaml (90%) rename docker-compose.yml => compose.yaml (100%) create mode 100644 internal/logger/logger.go diff --git a/.gitignore b/.gitignore index fff681c..065f490 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +.env *.mmdb -.env** .geoipupdate.lock diff --git a/Dockerfile b/Dockerfile index 3576334..3541d52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,25 @@ -FROM golang:alpine AS builder +FROM golang:1.24-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -ldflags="-s -w" -o ipinfo . RUN go build -ldflags="-s -w" -o healthcheck ./healthcheck/healthcheck.go -RUN go install github.com/maxmind/geoipupdate/v7/cmd/geoipupdate@latest FROM alpine:latest -RUN apk add --no-cache curl tzdata busybox-suid +RUN apk add --no-cache tzdata + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY --from=builder /build/ipinfo . COPY --from=builder /build/healthcheck . -COPY --from=builder /go/bin/geoipupdate /usr/local/bin/geoipupdate -ENV GEOIPUPDATE_ACCOUNT_ID=${GEOIPUPDATE_ACCOUNT_ID} -ENV GEOIPUPDATE_LICENSE_KEY=${GEOIPUPDATE_LICENSE_KEY} +RUN chown -R appuser:appgroup /app + +USER appuser + ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" -ENV GEOIPUPDATE_DB_DIR=/app - -RUN echo "AccountID ${GEOIPUPDATE_ACCOUNT_ID}" > /etc/GeoIP.conf && \ - echo "LicenseKey ${GEOIPUPDATE_LICENSE_KEY}" >> /etc/GeoIP.conf && \ - echo "EditionIDs ${GEOIPUPDATE_EDITION_IDS}" >> /etc/GeoIP.conf && \ - echo "DatabaseDirectory ${GEOIPUPDATE_DB_DIR}" >> /etc/GeoIP.conf - -RUN echo "0 0 * * * geoipupdate >> /var/log/geoipupdate.log 2>&1" > /etc/crontabs/root -RUN cat /etc/crontabs/root HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD ["./healthcheck"] diff --git a/docker-compose.dev.yml b/compose.dev.yaml similarity index 90% rename from docker-compose.dev.yml rename to compose.dev.yaml index 92033b9..34a4fc4 100644 --- a/docker-compose.dev.yml +++ b/compose.dev.yaml @@ -4,7 +4,6 @@ services: context: . dockerfile: Dockerfile container_name: ipinfo - restart: unless-stopped ports: - "3000:3000" environment: diff --git a/docker-compose.yml b/compose.yaml similarity index 100% rename from docker-compose.yml rename to compose.yaml diff --git a/go.mod b/go.mod index a728ea9..b108a71 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,15 @@ module skidoodle/ipinfo go 1.24.0 -require github.com/oschwald/maxminddb-golang v1.13.1 +require ( + github.com/joho/godotenv v1.5.1 + github.com/oschwald/maxminddb-golang v1.13.1 + github.com/pkg/errors v0.9.1 +) -require golang.org/x/sys v0.30.0 // indirect +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum index c565698..314a387 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,18 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/common/common.go b/internal/common/common.go index 4dc83e1..6401a3b 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -14,6 +14,7 @@ import ( iputils "skidoodle/ipinfo/utils/iputils" ) +// DataStruct represents the structure of the IP data returned by the API. type DataStruct struct { IP *string `json:"ip"` Hostname *string `json:"hostname"` @@ -25,16 +26,19 @@ type DataStruct struct { Loc *string `json:"loc"` } +// ASNDataResponse represents the structure of the ASN data returned by the API. type ASNDataResponse struct { Details Details `json:"details"` Prefixes PrefixInfo `json:"prefixes"` } +// Details represents the structure of the ASN details returned by the API. type Details struct { ASN uint `json:"asn"` Name string `json:"name"` } +// PrefixInfo represents the structure of the ASN prefix information returned by the API. type PrefixInfo struct { IPv4 []string `json:"ipv4"` IPv6 []string `json:"ipv6"` @@ -44,11 +48,13 @@ type PrefixInfo struct { var ipCache = NewIPCache(10 * time.Minute) var asnCache = NewASNCache(10 * time.Minute) +// cachedIPData represents a cached IP lookup result. type cachedIPData struct { data *DataStruct time time.Time } +// cachedASNData represents a cached ASN lookup result. type cachedASNData struct { data *ASNDataResponse time time.Time @@ -67,6 +73,7 @@ func NewIPCache(ttl time.Duration) *IPCache { } } +// Set adds a new entry to the IP cache func (c *IPCache) Set(ipStr string, data *DataStruct) { c.cache.Store(ipStr, cachedIPData{ data: data, @@ -74,6 +81,7 @@ func (c *IPCache) Set(ipStr string, data *DataStruct) { }) } +// Get retrieves an entry from the IP cache func (c *IPCache) Get(ipStr string) (*DataStruct, bool) { if cachedData, ok := c.cache.Load(ipStr); ok { cached := cachedData.(cachedIPData) @@ -85,17 +93,20 @@ func (c *IPCache) Get(ipStr string) (*DataStruct, bool) { return nil, false } +// ASNCache provides thread-safe caching of ASN lookup results type ASNCache struct { cache sync.Map ttl time.Duration } +// NewASNCache creates a new ASN cache with the specified TTL func NewASNCache(ttl time.Duration) *ASNCache { return &ASNCache{ ttl: ttl, } } +// Set adds a new entry to the ASN cache func (c *ASNCache) Set(asn uint, data *ASNDataResponse) { c.cache.Store(asn, cachedASNData{ data: data, @@ -103,6 +114,7 @@ func (c *ASNCache) Set(asn uint, data *ASNDataResponse) { }) } +// Get retrieves an entry from the ASN cache func (c *ASNCache) Get(asn uint) (*ASNDataResponse, bool) { if cachedData, ok := c.cache.Load(asn); ok { cached := cachedData.(cachedASNData) @@ -178,6 +190,7 @@ func LookupIPData(geoIP *db.GeoIPManager, ip net.IP) *DataStruct { return data } +// LookupASNData looks up ASN data in the databases with caching func LookupASNData(geoIP *db.GeoIPManager, targetASN uint) (*ASNDataResponse, error) { if data, found := asnCache.Get(targetASN); found { return data, nil diff --git a/internal/db/db.go b/internal/db/db.go index a40995c..fcd0ce3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,254 +1,320 @@ package internal import ( + "compress/gzip" "context" - "errors" + "crypto/md5" "fmt" - "log" + "io" "net" + "net/http" "os" - "os/exec" + "strings" "sync" "time" + "skidoodle/ipinfo/internal/logger" + "github.com/oschwald/maxminddb-golang" + "github.com/pkg/errors" ) -// Database paths +// Constants for database names and paths const ( - CityDBPath = "./GeoLite2-City.mmdb" - ASNDBPath = "./GeoLite2-ASN.mmdb" + CityDBName = "GeoLite2-City" + ASNDBName = "GeoLite2-ASN" + DBExtension = ".mmdb" + CityDBPath = CityDBName + DBExtension + ASNDBPath = ASNDBName + DBExtension ) -// Common errors +// Error messages var ( ErrDatabaseNotFound = errors.New("database file not found") ErrDatabaseOpen = errors.New("failed to open database") ErrDownloadFailed = errors.New("failed to download database") ) +// ASNRecord represents a record in the ASN database type ASNRecord struct { AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"` AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"` } -// Handles MaxMind GeoIP database operations +// GeoIPManager manages the GeoIP databases type GeoIPManager struct { cityDB *maxminddb.Reader asnDB *maxminddb.Reader asnPrefixMap map[uint][]*net.IPNet + httpClient *http.Client mu sync.RWMutex } -// Creates and initializes a new GeoIP database +// NewGeoIPManager creates a new GeoIPManager func NewGeoIPManager() (*GeoIPManager, error) { - manager := &GeoIPManager{} + manager := &GeoIPManager{ + httpClient: &http.Client{Timeout: 2 * time.Minute}, + } 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 +// Initialize initializes the GeoIPManager by opening the database files func (g *GeoIPManager) Initialize() error { g.mu.Lock() defer g.mu.Unlock() - if err := g.openCityDB(); err != nil { + if err := g.openDB(CityDBPath); err != nil { return err } - - if err := g.openASNDB(); err != nil { + if err := g.openDB(ASNDBPath); err != nil { return err } g.buildASNPrefixMap() - return nil } -// buildASNPrefixMap iterates the ASN database once and builds the lookup map. +// openDB opens a MaxMind DB file +func (g *GeoIPManager) openDB(path string) error { + db, err := maxminddb.Open(path) + if err == nil { + if path == CityDBPath { + g.cityDB = db + } else { + g.asnDB = db + } + return nil + } + + if !os.IsNotExist(err) { + return errors.Wrapf(ErrDatabaseOpen, "failed to open %s: %v", path, err) + } + + logger.Log.Info("Database not found, attempting initial download", "path", path) + if err := g.DownloadDatabases(context.Background()); err != nil { + return errors.Wrap(ErrDownloadFailed, err.Error()) + } + + db, err = maxminddb.Open(path) + if err != nil { + return errors.Wrapf(ErrDatabaseOpen, "failed to open %s after download: %v", path, err) + } + + if path == CityDBPath { + g.cityDB = db + } else { + g.asnDB = db + } + return nil +} + +// buildASNPrefixMap builds a map of ASN prefixes for fast lookups func (g *GeoIPManager) buildASNPrefixMap() { - log.Println("Building ASN prefix map for fast lookups...") + logger.Log.Info("Building ASN prefix map for fast lookups...") startTime := time.Now() - g.asnPrefixMap = make(map[uint][]*net.IPNet) + if g.asnDB == nil { + logger.Log.Warn("ASN database is not available, skipping prefix map build") + return + } networks := g.asnDB.Networks() - for networks.Next() { var record ASNRecord subnet, err := networks.Network(&record) if err != nil { - continue // Skip records that fail to decode + continue } if record.AutonomousSystemNumber > 0 { g.asnPrefixMap[record.AutonomousSystemNumber] = append(g.asnPrefixMap[record.AutonomousSystemNumber], subnet) } } - log.Printf("Finished building ASN prefix map in %v", time.Since(startTime)) + logger.Log.Info("Finished building ASN prefix map", "duration", time.Since(startTime)) } -// 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) +// DownloadDatabases downloads the GeoIP databases +func (g *GeoIPManager) DownloadDatabases(ctx context.Context) error { + accountID := os.Getenv("GEOIPUPDATE_ACCOUNT_ID") + licenseKey := os.Getenv("GEOIPUPDATE_LICENSE_KEY") + if accountID == "" || licenseKey == "" { + return errors.New("GEOIPUPDATE_ACCOUNT_ID and GEOIPUPDATE_LICENSE_KEY must be set") + } + + editionIDs := os.Getenv("GEOIPUPDATE_EDITION_IDS") + if editionIDs == "" { + editionIDs = "GeoLite2-City GeoLite2-ASN" + } + + var firstError error + for _, editionID := range strings.Fields(editionIDs) { + if err := g.downloadEdition(ctx, accountID, licenseKey, editionID); err != nil { + logger.Log.Error("Failed to download edition", "edition", editionID, "error", err) + if firstError == nil { + firstError = 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 + return firstError } -// Opens the ASN database, downloading it if necessary -func (g *GeoIPManager) openASNDB() error { - var err error - g.asnDB, err = maxminddb.Open(ASNDBPath) +// downloadEdition downloads a specific GeoIP database edition +func (g *GeoIPManager) downloadEdition(ctx context.Context, accountID, licenseKey, editionID string) error { + dbPath := editionID + DBExtension + logger.Log.Info("Checking for updates", "database", dbPath) + + hash, err := fileMD5(dbPath) + if err != nil && !os.IsNotExist(err) { + return errors.Wrapf(err, "could not calculate MD5 for %s", dbPath) + } + + downloadURL := fmt.Sprintf("https://updates.maxmind.com/geoip/databases/%s/update?db_md5=%s", editionID, hash) + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) 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 errors.Wrap(err, "could not create request") } - return nil -} + req.SetBasicAuth(accountID, licenseKey) -// 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() + resp, err := g.httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to download databases: %v. Output: %s. Ensure geoipupdate is installed and configured", err, output) + return errors.Wrap(err, "http request failed") } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotModified { + logger.Log.Info("Database is already up to date", "database", dbPath) + return nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("received non-200 status code: %d - %s", resp.StatusCode, string(body)) + } + + logger.Log.Info("Downloading and decompressing new version", "database", dbPath) + + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return errors.Wrap(err, "could not create gzip reader") + } + defer gzr.Close() + + tmpPath := dbPath + ".tmp" + outFile, err := os.Create(tmpPath) + if err != nil { + return errors.Wrap(err, "could not create temporary file") + } + defer outFile.Close() + + if _, err := io.Copy(outFile, gzr); err != nil { + os.Remove(tmpPath) + return errors.Wrap(err, "could not decompress and write db file") + } + + if err := os.Rename(tmpPath, dbPath); err != nil { + return errors.Wrap(err, "could not replace database file") + } + + logger.Log.Info("Successfully downloaded and updated", "database", dbPath) return nil } -// Close properly closes the database readers -func (g *GeoIPManager) Close() error { +// UpdateDatabases updates the GeoIP databases +func (g *GeoIPManager) UpdateDatabases() error { + if err := g.DownloadDatabases(context.Background()); err != nil { + return err + } + 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)) - } + g.cityDB.Close() } - if g.asnDB != nil { - if err := g.asnDB.Close(); err != nil { - errs = append(errs, fmt.Errorf("closing ASN database: %w", err)) - } + g.asnDB.Close() } - if len(errs) > 0 { - return fmt.Errorf("errors closing databases: %v", errs) + var openErr error + g.cityDB, openErr = maxminddb.Open(CityDBPath) + if openErr != nil { + return errors.Wrap(openErr, "reopening city database") } + + g.asnDB, openErr = maxminddb.Open(ASNDBPath) + if openErr != nil { + return errors.Wrap(openErr, "reopening ASN database") + } + + g.buildASNPrefixMap() + logger.Log.Info("Successfully reloaded GeoIP databases") 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 -} - -// GetASNPrefixes returns the pre-computed list of prefixes for an ASN. -func (g *GeoIPManager) GetASNPrefixes(asn uint) []*net.IPNet { - g.mu.RLock() - defer g.mu.RUnlock() - return g.asnPrefixMap[asn] -} - -// Sets up automatic database updates +// StartUpdater starts a background updater for the GeoIP databases func (g *GeoIPManager) StartUpdater(ctx context.Context, updateInterval time.Duration) { - log.Printf("Starting MaxMind GeoIP database updater with interval: %s", updateInterval) - + logger.Log.Info("Starting MaxMind GeoIP database updater", "interval", updateInterval.String()) ticker := time.NewTicker(updateInterval) go func() { for { select { case <-ticker.C: - log.Println("Performing scheduled GeoIP database update") + logger.Log.Info("Performing scheduled GeoIP database update") if err := g.UpdateDatabases(); err != nil { - log.Printf("Failed to update databases: %v", err) + logger.Log.Error("Failed to update databases", "error", err) } case <-ctx.Done(): ticker.Stop() - log.Println("GeoIP database updater stopped") + logger.Log.Info("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 - } - +// Close closes the GeoIP database readers +func (g *GeoIPManager) Close() { 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) - } + g.cityDB.Close() } - if g.asnDB != nil { - if err := g.asnDB.Close(); err != nil { - log.Printf("Warning: error closing ASN database: %v", err) - } + g.asnDB.Close() } - - // 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) - } - - // Rebuild the prefix map with the new data. - g.buildASNPrefixMap() - - log.Println("Successfully updated and reloaded GeoIP databases") - return nil +} + +// GetCityDB retrieves the city database reader +func (g *GeoIPManager) GetCityDB() *maxminddb.Reader { + g.mu.RLock() + defer g.mu.RUnlock() + return g.cityDB +} + +// GetASNDB retrieves the ASN database reader +func (g *GeoIPManager) GetASNDB() *maxminddb.Reader { + g.mu.RLock() + defer g.mu.RUnlock() + return g.asnDB +} + +// GetASNPrefixes retrieves the list of IP prefixes for a given ASN +func (g *GeoIPManager) GetASNPrefixes(asn uint) []*net.IPNet { + g.mu.RLock() + defer g.mu.RUnlock() + return g.asnPrefixMap[asn] +} + +// fileMD5 calculates the MD5 hash of a file +func fileMD5(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := md5.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return fmt.Sprintf("%x", hash.Sum(nil)), nil } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..a0889d8 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,13 @@ +package logger + +import ( + "log/slog" + "os" +) + +var Log *slog.Logger + +// init initializes the logger +func init() { + Log = slog.New(slog.NewJSONHandler(os.Stdout, nil)) +} diff --git a/internal/server/server.go b/internal/server/server.go index 5924607..776a8d6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,92 +4,125 @@ import ( "compress/gzip" "context" "encoding/json" - "log" + "log/slog" "net" "net/http" "os" - "os/signal" "strconv" "strings" - "syscall" "time" common "skidoodle/ipinfo/internal/common" db "skidoodle/ipinfo/internal/db" + "skidoodle/ipinfo/internal/logger" utils "skidoodle/ipinfo/utils/health" ) +// favicon is the SVG data for the favicon +const favicon = `` + +// bogonDataStruct represents the response structure for bogon IP queries type bogonDataStruct struct { IP string `json:"ip"` Bogon bool `json:"bogon"` } +// gzipResponseWriter is a wrapper for gzip compression type gzipResponseWriter struct { http.ResponseWriter Writer *gzip.Writer } +// Write writes the compressed data to the response func (w gzipResponseWriter) Write(b []byte) (int, error) { return w.Writer.Write(b) } +// Server represents the HTTP server type Server struct { - geoIP *db.GeoIPManager - server *http.Server - shutdownSignal chan struct{} + server *http.Server } +// NewServer creates a new HTTP server func NewServer(geoIP *db.GeoIPManager) *Server { - s := &Server{ - geoIP: geoIP, - shutdownSignal: make(chan struct{}), - } - mux := http.NewServeMux() mux.Handle("/health", utils.HealthCheck()) - mux.HandleFunc("/", s.router) + mux.HandleFunc("/favicon.ico", faviconHandler) + mux.HandleFunc("/", router(geoIP)) - s.server = &http.Server{ - Addr: ":3000", - Handler: mux, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 60 * time.Second, + // Chain the logging middleware + var handler http.Handler = mux + handler = loggingMiddleware(handler) + + return &Server{ + server: &http.Server{ + Addr: ":3000", + Handler: handler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + }, } - - return s } -func (s *Server) Start(ctx context.Context) error { +// StartServer starts the HTTP server +func StartServer(ctx context.Context, geoIP *db.GeoIPManager) error { + server := NewServer(geoIP) + go func() { - log.Println("Server listening on :3000") - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server error: %v", err) + logger.Log.Info("Server listening", "address", server.server.Addr) + if err := server.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Log.Error("Server error", "error", err) + os.Exit(1) } }() - select { - case <-s.shutdownSignal: - log.Println("Shutdown requested internally") - case <-ctx.Done(): - log.Println("Shutdown requested from context") - } + <-ctx.Done() + + logger.Log.Info("Shutdown signal received, shutting down server gracefully...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := s.server.Shutdown(shutdownCtx); err != nil { + if err := server.server.Shutdown(shutdownCtx); err != nil { + logger.Log.Error("Server shutdown failed", "error", err) return err } - log.Println("Server shutdown complete") + logger.Log.Info("Server shutdown complete") return nil } -func (s *Server) Shutdown() { - close(s.shutdownSignal) +// router returns the HTTP request router for the GeoIP service +func router(geoIP *db.GeoIPManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + 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} + } + + path := strings.Trim(r.URL.Path, "/") + lowerPath := strings.ToLower(path) + + if strings.HasPrefix(lowerPath, "as") { + handleASNLookup(w, r, path, geoIP) + return + } + + handleIPLookup(w, r, path, geoIP) + } } +// faviconHandler handles requests for the favicon +func faviconHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(favicon)) +} + +// fieldMap maps request fields to their corresponding data struct fields 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 }, @@ -101,6 +134,7 @@ var fieldMap = map[string]func(*common.DataStruct) *string{ "loc": func(d *common.DataStruct) *string { return d.Loc }, } +// getField retrieves the value of a specific field from the data struct func getField(data *common.DataStruct, field string) *string { if f, ok := fieldMap[field]; ok { return f(data) @@ -108,26 +142,8 @@ func getField(data *common.DataStruct, field string) *string { return nil } -func (s *Server) router(w http.ResponseWriter, r *http.Request) { - 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} - } - - path := strings.Trim(r.URL.Path, "/") - lowerPath := strings.ToLower(path) - - if strings.HasPrefix(lowerPath, "as") { - s.handleASNLookup(w, r, path) - return - } - - s.handleIPLookup(w, r, path) -} - -func (s *Server) handleASNLookup(w http.ResponseWriter, _ *http.Request, path string) { +// handleASNLookup handles ASN lookup requests +func handleASNLookup(w http.ResponseWriter, _ *http.Request, path string, geoIP *db.GeoIPManager) { var asnStr string lowerPath := strings.ToLower(path) @@ -146,12 +162,12 @@ func (s *Server) handleASNLookup(w http.ResponseWriter, _ *http.Request, path st return } - data, err := common.LookupASNData(s.geoIP, uint(asn)) + data, err := common.LookupASNData(geoIP, uint(asn)) if err != nil { if strings.Contains(err.Error(), "no prefixes found") { sendJSONError(w, err.Error(), http.StatusNotFound) } else { - log.Printf("Error looking up ASN data for %d: %v", asn, err) + logger.Log.Error("Error looking up ASN data", "asn", asn, "error", err) sendJSONError(w, "Error retrieving data for ASN.", http.StatusInternalServerError) } return @@ -160,21 +176,22 @@ func (s *Server) handleASNLookup(w http.ResponseWriter, _ *http.Request, path st sendJSONResponse(w, data, http.StatusOK) } -func (s *Server) handleIPLookup(w http.ResponseWriter, r *http.Request, path string) { +// handleIPLookup handles IP lookup requests +func handleIPLookup(w http.ResponseWriter, r *http.Request, path string, geoIP *db.GeoIPManager) { requestedThings := strings.Split(path, "/") var IPAddress, field string switch len(requestedThings) { case 0: - IPAddress = common.GetRealIP(r) // Default to visitor's IP + IPAddress = common.GetRealIP(r) case 1: if requestedThings[0] == "" { - IPAddress = common.GetRealIP(r) // Handle root page case + IPAddress = common.GetRealIP(r) } 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 + IPAddress = requestedThings[0] } else { sendJSONError(w, "Please provide a valid IP address.", http.StatusBadRequest) return @@ -203,7 +220,7 @@ func (s *Server) handleIPLookup(w http.ResponseWriter, r *http.Request, path str return } - data := common.LookupIPData(s.geoIP, ip) + data := common.LookupIPData(geoIP, ip) if data == nil { sendJSONError(w, "Please provide a valid IP address.", http.StatusBadRequest) return @@ -218,36 +235,32 @@ func (s *Server) handleIPLookup(w http.ResponseWriter, r *http.Request, path str sendJSONResponse(w, data, http.StatusOK) } +// sendJSONResponse 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) + logger.Log.Error("Error encoding JSON response", "error", err) } } +// sendJSONError sends a JSON error response with the given message and status code. func sendJSONError(w http.ResponseWriter, errMsg string, statusCode int) { sendJSONResponse(w, map[string]string{"error": errMsg}, statusCode) } -func StartServer(ctx context.Context, geoIP *db.GeoIPManager) error { - server := NewServer(geoIP) - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - select { - case <-sigCh: - log.Println("Received termination signal") - server.Shutdown() - case <-ctx.Done(): - log.Println("Context cancelled") - server.Shutdown() - } - }() - - return server.Start(ctx) +// loggingMiddleware logs the incoming HTTP request and its duration. +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + logger.Log.Info("HTTP request", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("remote_addr", r.RemoteAddr), + slog.Duration("duration", time.Since(start)), + ) + }) } diff --git a/main.go b/main.go index a7e9696..d9bb6b4 100644 --- a/main.go +++ b/main.go @@ -2,43 +2,40 @@ package main import ( "context" - "log" "os" "os/signal" "syscall" "time" db "skidoodle/ipinfo/internal/db" + logger "skidoodle/ipinfo/internal/logger" server "skidoodle/ipinfo/internal/server" + + "github.com/joho/godotenv" ) +// main is the entry point of the application func main() { - // Create context with cancellation for graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) + if err := godotenv.Load(); err != nil { + logger.Log.Info("No .env file found, using system environment variables") + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 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) + logger.Log.Error("Failed to initialize GeoIP databases", "error", err) + os.Exit(1) } 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() + logger.Log.Info("Starting server...") + if err := server.StartServer(ctx, geoIP); err != nil { + logger.Log.Error("Server failed", "error", err) + os.Exit(1) + } + logger.Log.Info("Application shut down gracefully") }