From 74647111b766db7108e72b2cd66c3586bde60ab9 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:27:54 +0500 Subject: [PATCH 1/8] Initialize Go module with GeoIP2 dependencies --- go.mod | 10 ++++++++++ go.sum | 14 ++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 go.mod create mode 100644 go.sum diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6fbab41 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.kor-elf.net/kor-elf-shield/geoip2 + +go 1.25 + +require github.com/oschwald/geoip2-golang/v2 v2.1.0 + +require ( + github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..011c4aa --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +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/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7LB23UBI2Amo= +github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc= +github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc= +github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -- 2.52.0 From 031981e6a2342f6e1df7f0d8126ebd3d852dc098 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:33:34 +0500 Subject: [PATCH 2/8] Add utility package for directory and file management --- internal/pkg/dir.go | 124 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 internal/pkg/dir.go diff --git a/internal/pkg/dir.go b/internal/pkg/dir.go new file mode 100644 index 0000000..a9a639d --- /dev/null +++ b/internal/pkg/dir.go @@ -0,0 +1,124 @@ +package pkg + +import ( + "crypto/rand" + "fmt" + "os" + "path/filepath" + "time" +) + +type Dir struct { + Path string + PathCurrentDir string + PathBackupDir string +} + +// NewDir creates a new Dir instance. +func NewDir(dir, nameCurrentDir string) *Dir { + return &Dir{ + Path: dir, + PathCurrentDir: filepath.Join(dir, nameCurrentDir), + PathBackupDir: filepath.Join(dir, nameCurrentDir+".bak"), + } +} + +// CreateRandomTmpDir creates a temporary directory in the specified directory with a random name. +// The directory is created with permissions 0750. +// If the directory already exists, an error is returned. +// @return string - path to the temporary directory +// @return error - an error if the directory could not be created +func (d *Dir) CreateRandomTmpDir() (string, error) { + tmpDir := filepath.Join(d.Path, time.Now().Format(time.RFC3339)+"_"+rand.Text()+".tmp") + if _, err := os.Stat(tmpDir); err == nil { + return "", fmt.Errorf("directory already exists: %s", tmpDir) + } + if err := os.MkdirAll(tmpDir, 0750); err != nil { + return "", err + } + return tmpDir, nil +} + +// ReplaceDirToCurrent replaces the current directory with a new directory. +// If the new directory already exists, it is renamed to a backup directory. +// If the backup directory already exists, it is removed. +// @return error - an error if the directory could not be replaced +func (d *Dir) ReplaceDirToCurrent(newDir string) error { + if err := os.RemoveAll(d.PathBackupDir); err != nil { + return err + } + + if _, err := os.Stat(d.PathCurrentDir); err == nil { + if err := os.Rename(d.PathCurrentDir, d.PathBackupDir); err != nil { + return err + } + } + + if err := os.Rename(newDir, d.PathCurrentDir); err != nil { + if _, statErr := os.Stat(d.PathBackupDir); statErr == nil { + _ = os.Rename(d.PathBackupDir, d.PathCurrentDir) + } + return err + } + + if err := os.RemoveAll(d.PathBackupDir); err != nil { + return err + } + + return nil +} + +// MovingFiles moves all files from dirFrom to dirTo. +// If dirTo does not exist, it is created. +// @param dirFrom - path to the directory containing the files to move +// @param dirTo - path to the directory where the files will be moved +// @return error - an error if the files could not be moved +func MovingFiles(dirFrom, dirTo string) error { + if _, err := os.Stat(dirTo); err != nil { + if err := os.MkdirAll(dirTo, 0750); err != nil { + return err + } + } + + entries, err := os.ReadDir(dirFrom) + if err != nil { + return err + } + + for _, entry := range entries { + if entry.IsDir() { + if err := MovingFiles(filepath.Join(dirFrom, entry.Name()), dirTo); err != nil { + return err + } + continue + } + src := filepath.Join(dirFrom, entry.Name()) + dst := filepath.Join(dirTo, entry.Name()) + if err := os.Rename(src, dst); err != nil { + return err + } + } + + return nil +} + +// FindMMDBFile finds the .mmdb file in the specified directory. +// @param dir - path to the directory to search +// @return string - path to the .mmdb file +// @return error - an error if the .mmdb file could not be found +func FindMMDBFile(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + if filepath.Ext(entry.Name()) == ".mmdb" { + return filepath.Join(dir, entry.Name()), nil + } + } + + return "", fmt.Errorf("no .mmdb file found in %s", dir) +} -- 2.52.0 From 2b277c8ee9988ec1a516adf54eb3a73e8e552b85 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:43:01 +0500 Subject: [PATCH 3/8] Add GeoIP2 package with interfaces and IP info structure --- geoip2.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 geoip2.go diff --git a/geoip2.go b/geoip2.go new file mode 100644 index 0000000..69a805c --- /dev/null +++ b/geoip2.go @@ -0,0 +1,78 @@ +package geoip2 + +import ( + "context" + "fmt" + "net/netip" + "strings" +) + +// ErrNotFound is returned when the database does not contain the IP address. +const ErrNotFound = "not found" + +// Info is a structure that contains information about the IP address. +type Info struct { + // IP is the IP address. + IP netip.Addr + // ISOCode is the ISO code of the country. + ISOCode string + // Continent is the continent. + Continent string + // Country is the country. + Country string + // City is the city. + City string + // CitySubdivisions is the list of subdivisions. + CitySubdivisions []string + // TimeZone is the time zone. + TimeZone string +} + +// ToString returns a string representation of the Info structure. +func (i Info) ToString() string { + var data []string + + if i.Continent != "" { + data = append(data, i.Continent) + } + + if i.ISOCode != "" { + data = append(data, i.ISOCode) + } + + if i.Country != "" { + data = append(data, i.Country) + } + + if len(i.CitySubdivisions) > 0 { + data = append(data, strings.Join(i.CitySubdivisions, ", ")) + } + + if i.City != "" { + data = append(data, i.City) + } + + return fmt.Sprintf("%s (%s) time zone: %s", + i.IP.String(), + strings.Join(data, "/"), + i.TimeZone, + ) +} + +// GeoIP2 is an interface for geoip2. +type GeoIP2 interface { + Info(ip netip.Addr) (Info, error) +} + +// RefreshableGeoIP2 is an interface for geoip2 with refresh and close functionality. +type RefreshableGeoIP2 interface { + GeoIP2 + Refresh(ctx context.Context) error + Close() error +} + +// Logger is an interface for logging. +type Logger interface { + // Error logs an error. + Error(err error) +} -- 2.52.0 From 64e4a138e3752d30081d664d98524ce05db0d836 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:49:46 +0500 Subject: [PATCH 4/8] Add MaxMind GeoIP2 database service with refreshable interface --- internal/service/maxmind/mmdb/mmdb.go | 187 ++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 internal/service/maxmind/mmdb/mmdb.go diff --git a/internal/service/maxmind/mmdb/mmdb.go b/internal/service/maxmind/mmdb/mmdb.go new file mode 100644 index 0000000..cdc1a07 --- /dev/null +++ b/internal/service/maxmind/mmdb/mmdb.go @@ -0,0 +1,187 @@ +package mmdb + +import ( + "context" + "errors" + "fmt" + "net/netip" + "os" + "sync" + + "git.kor-elf.net/kor-elf-shield/geoip2" + "git.kor-elf.net/kor-elf-shield/geoip2/internal/pkg" + + oschwaldGeoip2 "github.com/oschwald/geoip2-golang/v2" +) + +// download is an interface for downloading MaxMind GeoIP2 database. +type download interface { + // Download downloads MaxMind GeoIP2 database. + Download(dir string, ctx context.Context) error +} + +type service struct { + // download is an interface for downloading MaxMind GeoIP2 database. + download download + // info is a function that returns information about the IP address. + info info + logger geoip2.Logger + // dir is a directory for storing MaxMind GeoIP2 database. + dir *pkg.Dir + + // isRefreshing is a flag that indicates whether the database is being refreshed. + isRefreshing bool + // mu is a mutex for synchronizing access to the reader. + mu sync.RWMutex + // reader is a MaxMind GeoIP2 database reader. + reader *oschwaldGeoip2.Reader +} + +// info is a function that returns information about the IP address. +// @param ip - IP address +// @param reader - MaxMind GeoIP2 database reader +// @return geoip2.Info - information about the IP address +type info func(ip netip.Addr, reader *oschwaldGeoip2.Reader) (geoip2.Info, error) + +// NewMMDB creates a new MaxMind GeoIP2 database service. +// @param download - an interface for downloading MaxMind GeoIP2 database +// @param info - a function that returns information about the IP address +// @param logger - a logger +// @param dir - a directory for storing MaxMind GeoIP2 database +// @return geoip2.RefreshableGeoIP2 - a MaxMind GeoIP2 database service +func NewMMDB(download download, info info, logger geoip2.Logger, dir *pkg.Dir) geoip2.RefreshableGeoIP2 { + s := &service{ + download: download, + logger: logger, + dir: dir, + info: info, + + mu: sync.RWMutex{}, + } + s.init() + return s +} + +// Info returns information about the IP address. +// @param ip - IP address +// @return geoip2.Info - information about the IP address +// @return error - an error if the IP address could not be found +func (s *service) Info(ip netip.Addr) (geoip2.Info, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.reader == nil { + return geoip2.Info{}, errors.New("geoip reader is not ready") + } + + return s.info(ip, s.reader) +} + +// Refresh refreshes the MaxMind GeoIP2 database. +// @param ctx - a context +// @return error - an error if the database could not be refreshed +func (s *service) Refresh(ctx context.Context) error { + if s.isRefreshing { + return fmt.Errorf("process is refreshing") + } + s.isRefreshing = true + defer func() { + s.isRefreshing = false + }() + + if err := s.fetch(ctx); err != nil { + return err + } + + newReader, err := s.openReader() + if err != nil { + return err + } + + s.mu.Lock() + oldReader := s.reader + s.reader = newReader + s.mu.Unlock() + + if oldReader != nil { + _ = oldReader.Close() + } + + return nil +} + +// Close closes the MaxMind GeoIP2 database. +// @return error - an error if the database could not be closed +func (s *service) Close() error { + if s.reader != nil { + return s.reader.Close() + } + return nil +} + +// init initializes the MaxMind GeoIP2 database service. +func (s *service) init() { + reader, err := s.openReader() + if err != nil { + go func() { + // If there is no data yet, then we try to get the data. + if err := s.Refresh(context.Background()); err != nil { + s.logger.Error(err) + } + }() + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.reader = reader +} + +// openReader opens the MaxMind GeoIP2 database reader. +// @return (*oschwaldGeoip2.Reader, error) - a MaxMind GeoIP2 database reader and an error if the database could not be opened +// 1. If the database is already open, then it is returned. +// 2. If the database is not open, then it is opened and returned. +func (s *service) openReader() (*oschwaldGeoip2.Reader, error) { + path, err := pkg.FindMMDBFile(s.dir.PathCurrentDir) + if err != nil { + return nil, err + } + return oschwaldGeoip2.Open(path) +} + +// fetch downloads the MaxMind GeoIP2 database. +// @param ctx - a context +// @return error - an error if the database could not be downloaded +func (s *service) fetch(ctx context.Context) error { + tmpDir, err := s.dir.CreateRandomTmpDir() + if err != nil { + return err + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + s.logger.Error(err) + } + }() + + if err := s.download.Download(tmpDir, ctx); err != nil { + return err + } + + tmpDirForFiles, err := s.dir.CreateRandomTmpDir() + if err != nil { + return err + } + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + s.logger.Error(err) + } + }() + + if err := pkg.MovingFiles(tmpDir, tmpDirForFiles); err != nil { + return err + } + + if err := s.dir.ReplaceDirToCurrent(tmpDirForFiles); err != nil { + return err + } + return nil +} -- 2.52.0 From a2f0e96448e55758f13be2400468ff419c8644db Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:51:34 +0500 Subject: [PATCH 5/8] Add language constants to MaxMind service package --- service/maxmind/mmdb/mmdb.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 service/maxmind/mmdb/mmdb.go diff --git a/service/maxmind/mmdb/mmdb.go b/service/maxmind/mmdb/mmdb.go new file mode 100644 index 0000000..cbf65b6 --- /dev/null +++ b/service/maxmind/mmdb/mmdb.go @@ -0,0 +1,15 @@ +package mmdb + +type language string + +const ( + LanguageRussian = language("Russian") + LanguageEnglish = language("English") + LanguageSpanish = language("Spanish") + LanguageFrench = language("French") + LanguageGerman = language("German") + LanguageJapanese = language("Japanese") + + LanguageBrazilianPortuguese = language("BrazilianPortuguese") + LanguageSimplifiedChinese = language("SimplifiedChinese") +) -- 2.52.0 From 14f324bee7590bab41b8a1aedb0610584470701f Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:57:20 +0500 Subject: [PATCH 6/8] Add MaxMind GeoIP2 database download implementation --- service/maxmind/mmdb/download.go | 240 +++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 service/maxmind/mmdb/download.go diff --git a/service/maxmind/mmdb/download.go b/service/maxmind/mmdb/download.go new file mode 100644 index 0000000..37da82b --- /dev/null +++ b/service/maxmind/mmdb/download.go @@ -0,0 +1,240 @@ +package mmdb + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/rand" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "time" +) + +const ( + // requestTimeout defines the maximum duration for request operations before timing out. + requestTimeout = 60 * time.Second + + // maxDownloadSize defines the maximum allowed size of the downloaded file in bytes. + maxDownloadSize int64 = 200 << 20 // 200 MiB + + // maxArchiveFileSize defines the maximum allowed size of the extracted file from ZIP in bytes. + maxArchiveFileSize uint64 = 500 << 20 // 500 MiB +) + +// DownloadConfig defines the configuration for downloading MaxMind GeoIP2 database. +type DownloadConfig struct { + // RequestTimeout defines the maximum duration for request operations before timing out. + RequestTimeout time.Duration + // MaxDownloadSize defines the maximum allowed size of the downloaded file in bytes. + MaxDownloadSize int64 + // MaxArchiveFileSize defines the maximum allowed size of the extracted file from ZIP in bytes. + MaxArchiveFileSize uint64 +} + +// DefaultDownloadConfig returns the default configuration for downloading MaxMind GeoIP2 database. +func DefaultDownloadConfig() DownloadConfig { + return DownloadConfig{ + RequestTimeout: requestTimeout, + MaxDownloadSize: maxDownloadSize, + MaxArchiveFileSize: maxArchiveFileSize, + } +} + +// Download is a struct for downloading MaxMind GeoIP2 database. +type Download struct { + // url is a URL for downloading MaxMind GeoIP2 database. + url string + // username is a username for downloading MaxMind GeoIP2 database. + username string + // password is a password for downloading MaxMind GeoIP2 database. + password string + // Cfg is a configuration for downloading MaxMind GeoIP2 database. + Cfg DownloadConfig +} + +// NewDownload creates a new Download struct. +// @param downloadURL - a URL for downloading MaxMind GeoIP2 database +// @param username - a username for downloading MaxMind GeoIP2 database +// @param password - a password for downloading MaxMind GeoIP2 database +// @param cfg - a configuration for downloading MaxMind GeoIP2 database +// @return (*Download, error) - a Download struct and an error if the download URL is invalid +func NewDownload(downloadURL, username, password string, cfg DownloadConfig) (*Download, error) { + parsedURL, err := url.Parse(downloadURL) + if err != nil { + return nil, fmt.Errorf("invalid url: %w", err) + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, fmt.Errorf("invalid url scheme: %s", parsedURL.Scheme) + } + + return &Download{ + url: downloadURL, + username: username, + password: password, + Cfg: cfg, + }, nil +} + +// Download downloads MaxMind GeoIP2 database. +// @param dir - a directory to save the downloaded database +// @param ctx - a context for cancelling the download +// @return error - an error if the download failed +func (d *Download) Download(dir string, ctx context.Context) error { + if err := createDir(dir); err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.SetBasicAuth(d.username, d.password) + + client := &http.Client{ + Timeout: d.Cfg.RequestTimeout, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download archive: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download archive: unexpected status %s", resp.Status) + } + + filename := rand.Text() + ".tar.gz" + archivePath := filepath.Join(dir, filename) + file, err := os.Create(archivePath) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("archive file already exists: %w", err) + } + return fmt.Errorf("create archive file: %w", err) + } + + limitedReader := io.LimitReader(resp.Body, d.Cfg.MaxDownloadSize+1) + written, copyErr := io.Copy(file, limitedReader) + closeErr := file.Close() + if copyErr != nil { + if removeErr := os.Remove(archivePath); removeErr != nil { + return fmt.Errorf("remove archive: %w", removeErr) + } + return fmt.Errorf("save archive: %w", copyErr) + } + if closeErr != nil { + if removeErr := os.Remove(archivePath); removeErr != nil { + return fmt.Errorf("remove archive: %w", removeErr) + } + return fmt.Errorf("close archive file: %w", closeErr) + } + if written > d.Cfg.MaxDownloadSize { + if removeErr := os.Remove(archivePath); removeErr != nil { + return fmt.Errorf("remove archive: %w", removeErr) + } + return fmt.Errorf("download archive too large: %d bytes", written) + } + + if err := extractTarGz(archivePath, dir, d.Cfg.MaxArchiveFileSize); err != nil { + if removeErr := os.Remove(archivePath); removeErr != nil { + return fmt.Errorf("remove archive: %w", removeErr) + } + return err + } + + if removeErr := os.Remove(archivePath); removeErr != nil { + return fmt.Errorf("remove archive: %w", removeErr) + } + + return nil +} + +// createDir creates a directory if it does not exist. +// @param dir - a directory to create +// @return error - an error if the directory could not be created +func createDir(dir string) error { + if err := os.MkdirAll(dir, 0750); err != nil { + return err + } + return nil +} + +// extractTarGz extracts a tar.gz archive to a specified directory. +// @param archivePath - path to the archive to extract +// @param dir - path to the directory to extract the archive to +// @param maxExtractSize - maximum size of the extracted archive in bytes +// @return error - an error if the archive could not be extracted +func extractTarGz(archivePath string, dir string, maxExtractSize uint64) error { + file, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("open archive: %w", err) + } + defer func() { + _ = file.Close() + }() + + gzr, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("create gzip reader: %w", err) + } + defer func() { + _ = gzr.Close() + }() + + tr := tar.NewReader(gzr) + + var extractedSize uint64 + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read tar archive: %w", err) + } + + targetPath := filepath.Join(dir, header.Name) + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("create directory %q: %w", targetPath, err) + } + + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(targetPath), 0750); err != nil { + return fmt.Errorf("create parent directory %q: %w", targetPath, err) + } + + outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return fmt.Errorf("create file %q: %w", targetPath, err) + } + + n, err := io.Copy(outFile, tr) + closeErr := outFile.Close() + if err != nil { + return fmt.Errorf("write file %q: %w", targetPath, err) + } + if closeErr != nil { + return fmt.Errorf("close file %q: %w", targetPath, closeErr) + } + + extractedSize += uint64(n) + if extractedSize > maxExtractSize { + return fmt.Errorf("extracted archive too large: %d bytes", extractedSize) + } + } + } + + return nil +} -- 2.52.0 From aa2e6f9cdfc3c11760231ac6f2b1cdbc78eaedc1 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 21:59:36 +0500 Subject: [PATCH 7/8] Add City service with IP info extraction for MaxMind GeoIP2 database --- service/maxmind/mmdb/city.go | 189 +++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 service/maxmind/mmdb/city.go diff --git a/service/maxmind/mmdb/city.go b/service/maxmind/mmdb/city.go new file mode 100644 index 0000000..3cd5a62 --- /dev/null +++ b/service/maxmind/mmdb/city.go @@ -0,0 +1,189 @@ +package mmdb + +import ( + "errors" + "net/netip" + + "git.kor-elf.net/kor-elf-shield/geoip2" + "git.kor-elf.net/kor-elf-shield/geoip2/internal/pkg" + "git.kor-elf.net/kor-elf-shield/geoip2/internal/service/maxmind/mmdb" + + oschwaldGeoip2 "github.com/oschwald/geoip2-golang/v2" +) + +const ( + DownloadURLCityLite = "https://download.maxmind.com/geoip/databases/GeoLite2-City/download?suffix=tar.gz" + NameDirNameCityLite = "GeoLite2-City" +) + +// City is a structure that contains information about the IP address. +type City struct { + // lang is a language. + lang language +} + +// NewCity creates a new City instance. +// @param download - an interface for downloading MaxMind GeoIP2 database +// @param logger - a logger +// @param dir - a directory for storing MaxMind GeoIP2 database +// @return geoip2.RefreshableGeoIP2 - a MaxMind GeoIP2 database service +func NewCity(download *Download, logger geoip2.Logger, dir string, language language) geoip2.RefreshableGeoIP2 { + city := &City{ + lang: language, + } + + newDir := pkg.NewDir(dir, NameDirNameCityLite) + return mmdb.NewMMDB(download, city.infoCity, logger, newDir) +} + +// infoCity returns information about the IP address. +// @param ip - IP address +// @param reader - MaxMind GeoIP2 database reader +// @return geoip2.Info - information about the IP address +// @return error - an error if the IP address could not be found +func (c *City) infoCity(ip netip.Addr, reader *oschwaldGeoip2.Reader) (geoip2.Info, error) { + record, err := reader.City(ip) + if err != nil { + return geoip2.Info{}, err + } + if !record.HasData() { + return geoip2.Info{}, errors.New(geoip2.ErrNotFound) + } + + var timeZone string + if record.Location.HasData() { + timeZone = record.Location.TimeZone + } + + return geoip2.Info{ + IP: ip, + ISOCode: record.Country.ISOCode, + Country: c.country(record.Country), + City: c.city(record.City), + CitySubdivisions: c.subdivisions(record.Subdivisions), + TimeZone: timeZone, + Continent: c.continent(record.Continent), + }, nil +} + +func (c *City) continent(continent oschwaldGeoip2.Continent) string { + if !continent.HasData() { + return "" + } + + switch c.lang { + case LanguageRussian: + return continent.Names.Russian + case LanguageEnglish: + return continent.Names.English + case LanguageBrazilianPortuguese: + return continent.Names.BrazilianPortuguese + case LanguageSimplifiedChinese: + return continent.Names.SimplifiedChinese + case LanguageJapanese: + return continent.Names.Japanese + case LanguageGerman: + return continent.Names.German + case LanguageFrench: + return continent.Names.French + case LanguageSpanish: + return continent.Names.Spanish + default: + return continent.Names.English + } +} + +func (c *City) country(country oschwaldGeoip2.CountryRecord) string { + if !country.HasData() { + return "" + } + + switch c.lang { + case LanguageRussian: + return country.Names.Russian + case LanguageEnglish: + return country.Names.English + case LanguageBrazilianPortuguese: + return country.Names.BrazilianPortuguese + case LanguageSimplifiedChinese: + return country.Names.SimplifiedChinese + case LanguageJapanese: + return country.Names.Japanese + case LanguageGerman: + return country.Names.German + case LanguageFrench: + return country.Names.French + case LanguageSpanish: + return country.Names.Spanish + default: + return country.Names.English + } +} + +func (c *City) city(city oschwaldGeoip2.CityRecord) string { + if !city.HasData() { + return "" + } + + switch c.lang { + case LanguageRussian: + return city.Names.Russian + case LanguageEnglish: + return city.Names.English + case LanguageBrazilianPortuguese: + return city.Names.BrazilianPortuguese + case LanguageSimplifiedChinese: + return city.Names.SimplifiedChinese + case LanguageJapanese: + return city.Names.Japanese + case LanguageGerman: + return city.Names.German + case LanguageFrench: + return city.Names.French + case LanguageSpanish: + return city.Names.Spanish + default: + return city.Names.English + } +} + +func (c *City) subdivisions(subdivisions []oschwaldGeoip2.CitySubdivision) []string { + var citySubdivisions []string + + if len(subdivisions) == 0 { + return citySubdivisions + } + + for _, subdivision := range subdivisions { + citySubdivisions = append(citySubdivisions, c.subdivision(subdivision)) + } + + return citySubdivisions +} + +func (c *City) subdivision(subdivision oschwaldGeoip2.CitySubdivision) string { + if !subdivision.HasData() { + return "" + } + + switch c.lang { + case LanguageRussian: + return subdivision.Names.Russian + case LanguageEnglish: + return subdivision.Names.English + case LanguageBrazilianPortuguese: + return subdivision.Names.BrazilianPortuguese + case LanguageSimplifiedChinese: + return subdivision.Names.SimplifiedChinese + case LanguageJapanese: + return subdivision.Names.Japanese + case LanguageGerman: + return subdivision.Names.German + case LanguageFrench: + return subdivision.Names.French + case LanguageSpanish: + return subdivision.Names.Spanish + default: + return subdivision.Names.English + } +} -- 2.52.0 From 10e7f300721f9db205d687c75ea4d793cd757f78 Mon Sep 17 00:00:00 2001 From: Leonid Nikitin Date: Thu, 9 Apr 2026 22:06:42 +0500 Subject: [PATCH 8/8] Add README with installation and usage examples for GeoIP2 package --- README.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e78b06 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# geoip2 + +Получение данных с сервиса maxmind.com. Изначально пакет пишется для сервиса https://git.kor-elf.net/kor-elf-shield/kor-elf-shield. + +## Установка +```sh +go get git.kor-elf.net/kor-elf-shield/geoip2 +``` + +## Примеры использования +```go +package main + +import ( + "fmt" + "net/netip" + + "git.kor-elf.net/kor-elf-shield/geoip2/service/maxmind/mmdb" +) + +type Logger struct{} + +func (l *Logger) Error(err error) { + fmt.Println(err) +} + +func main() { + // Для получения username и password ознакомьтесь тут: https://support.maxmind.com/knowledge-base/articles/create-a-maxmind-account + username := "username" + password := "password" + download, err := mmdb.NewDownload(mmdb.DownloadURLCityLite, username, password, mmdb.DefaultDownloadConfig()) + if err != nil { + panic(err) + } + + city := mmdb.NewCity(download, &Logger{}, "tmp", mmdb.LanguageRussian) + defer func() { + _ = city.Close() + }() + + /* + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Если хотите обновить данные + if err := city.Refresh(ctx); err != nil { + panic(err) + } + */ + + // Вместо 127.0.0.1 укажите интересующиеся IP адрес + ip, err := netip.ParseAddr("127.0.0.1") + if err != nil { + panic(err) + } + info, err := city.Info(ip) + if err != nil { + panic(err) + } + fmt.Println(info.ToString()) +} +``` +## Лицензия + +[MIT](https://git.kor-elf.net/kor-elf-shield/geoip2/src/branch/main/LICENSE) \ No newline at end of file -- 2.52.0