diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..00ed739 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pkg/geoip/test-data"] + path = pkg/geoip/test-data + url = https://github.com/maxmind/MaxMind-DB.git diff --git a/gateway/TODO.md b/gateway/TODO.md new file mode 100644 index 0000000..93a9e31 --- /dev/null +++ b/gateway/TODO.md @@ -0,0 +1,7 @@ +# TODOs + +## 1. Suggest User's Preferred Language when registering a new User + +Upon user's device/session registration flow, `preferred_language` value +must be obtained via existing [geoip](../pkg/geoip) package by returned Country. +When geoip feils to return country by ip, fallback is `en` language. diff --git a/geoprofile/README.md b/geoprofile/README.md index a591ac4..18ef10c 100644 --- a/geoprofile/README.md +++ b/geoprofile/README.md @@ -597,6 +597,7 @@ Role in the system: ## Geo-IP Source The service uses a locally stored free Geo-IP country database. +The Geo-IP acessible via [geoip](../pkg/geoip/) package. Requirements: diff --git a/go.work b/go.work index 781e8b8..708a98b 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.26.1 use ( ./authsession @@ -8,6 +8,7 @@ use ( ./pkg/calc ./pkg/connector ./pkg/error + ./pkg/geoip ./pkg/model ./pkg/schema ./pkg/storage @@ -19,6 +20,7 @@ replace ( galaxy/calc v0.0.0 => ./pkg/calc galaxy/connector v0.0.0 => ./pkg/connector galaxy/error v0.0.0 => ./pkg/error + galaxy/geoip v0.0.0 => ./pkg/geoip galaxy/model v0.0.0 => ./pkg/model galaxy/schema v0.0.0 => ./pkg/schema galaxy/storage v0.0.0 => ./pkg/storage diff --git a/pkg/geoip/README.md b/pkg/geoip/README.md new file mode 100644 index 0000000..cf77f02 --- /dev/null +++ b/pkg/geoip/README.md @@ -0,0 +1,40 @@ +# `galaxy/geoip` + +`galaxy/geoip` is a small in-process library that resolves a country from an IP +address by reading a local MaxMind `.mmdb` database. + +The package is intended for trusted internal services such as `Edge Gateway` +and `Geo Profile Service`. It is not a standalone network service. + +## Requirements + +- A local country-capable MaxMind database in `.mmdb` format. +- `GeoLite2 Country` is the expected default database for product deployments. +- One long-lived `Resolver` instance should be reused across goroutines and + closed during process shutdown. + +## Usage + +See [example](example/) directory. + +## Error Semantics + +- `ErrInvalidAddress`: the caller supplied an invalid IP string or `netip.Addr`. +- `ErrCountryNotFound`: the IP is valid, but the database has no country record + for it. This is the expected fail-open branch for private, local, or + otherwise unmapped addresses. +- `ErrClosed`: the resolver has already been closed. + +Returned country codes are normalized to uppercase `ISO 3166-1 alpha-2` +because that is the country format exposed by MaxMind Country databases. + +## Test Data + +Tests use the MaxMind reference fixtures via a git submodule under +`pkg/geoip/test-data`. + +Initialize submodules before running package tests: + +```bash +git submodule update --init --recursive +``` diff --git a/pkg/geoip/example/main.go b/pkg/geoip/example/main.go new file mode 100644 index 0000000..8d662ab --- /dev/null +++ b/pkg/geoip/example/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "errors" + "fmt" + "log" + "net/netip" + + "galaxy/geoip" +) + +func main() { + resolver, err := geoip.Open("/srv/geoip/GeoLite2-Country.mmdb") + if err != nil { + log.Fatal(err) + } + defer func() { + if err := resolver.Close(); err != nil { + log.Fatal(err) + } + }() + + country, err := resolver.Country(netip.MustParseAddr("81.2.69.160")) + if err != nil { + log.Fatal(err) + } + + fmt.Println(country) + + fromString, err := resolver.CountryString("203.0.113.10") + switch { + case err == nil: + fmt.Println(fromString) + case errors.Is(err, geoip.ErrCountryNotFound): + // Fail open: keep request processing and treat the country as unknown. + default: + log.Fatal(err) + } +} diff --git a/pkg/geoip/geoip.go b/pkg/geoip/geoip.go new file mode 100644 index 0000000..e1cf235 --- /dev/null +++ b/pkg/geoip/geoip.go @@ -0,0 +1,164 @@ +package geoip + +import ( + "errors" + "fmt" + "net/netip" + "strings" + "sync" + + "github.com/oschwald/geoip2-golang/v2" +) + +var ( + // ErrInvalidAddress reports that a caller supplied an invalid IP address. + ErrInvalidAddress = errors.New("invalid IP address") + + // ErrCountryNotFound reports that the GeoIP database contains no country + // record for a valid IP address. + ErrCountryNotFound = errors.New("country not found") + + // ErrClosed reports that the resolver has already released its database + // resources and can no longer serve lookups. + ErrClosed = errors.New("geoip resolver is closed") +) + +type countryReader interface { + Country(netip.Addr) (*geoip2.Country, error) + Close() error +} + +// Resolver resolves ISO 3166-1 alpha-2 country codes from a local MaxMind +// country-capable database. +// +// Resolver is safe for concurrent lookups. Call Close when the process no +// longer needs the memory-mapped database file. +type Resolver struct { + mu sync.RWMutex + reader countryReader +} + +// Open constructs a Resolver backed by the MaxMind database file at +// databasePath. +// +// databasePath must point to a local country-capable .mmdb file such as +// GeoLite2 Country. Open trims surrounding whitespace from databasePath before +// opening the file. +func Open(databasePath string) (*Resolver, error) { + path := strings.TrimSpace(databasePath) + if path == "" { + return nil, errors.New("open geoip database: database path must not be empty") + } + + reader, err := geoip2.Open(path) + if err != nil { + return nil, fmt.Errorf("open geoip database %q: %w", path, err) + } + + return newResolver(reader), nil +} + +// NewGeoIP is a legacy alias for Open. +// +// Deprecated: use Open for new code. +func NewGeoIP(databasePath string) (*Resolver, error) { + return Open(databasePath) +} + +func newResolver(reader countryReader) *Resolver { + return &Resolver{reader: reader} +} + +// Country resolves addr to an uppercase ISO 3166-1 alpha-2 country code. +// +// Country returns ErrInvalidAddress when addr is not valid, ErrCountryNotFound +// when the database contains no country for addr, and ErrClosed after Close +// has been called successfully. +func (r *Resolver) Country(addr netip.Addr) (string, error) { + if !addr.IsValid() { + return "", fmt.Errorf("lookup country: %w", ErrInvalidAddress) + } + if r == nil { + return "", fmt.Errorf("lookup country for %s: %w", addr, ErrClosed) + } + + r.mu.RLock() + defer r.mu.RUnlock() + + if r.reader == nil { + return "", fmt.Errorf("lookup country for %s: %w", addr, ErrClosed) + } + + record, err := r.reader.Country(addr) + if err != nil { + return "", fmt.Errorf("lookup country for %s: %w", addr, err) + } + if record == nil { + return "", fmt.Errorf("lookup country for %s: nil country record", addr) + } + if !record.HasData() || strings.TrimSpace(record.Country.ISOCode) == "" { + return "", fmt.Errorf("lookup country for %s: %w", addr, ErrCountryNotFound) + } + + code, err := normalizeCountryCode(record.Country.ISOCode) + if err != nil { + return "", fmt.Errorf("lookup country for %s: %w", addr, err) + } + + return code, nil +} + +// CountryString resolves raw to an uppercase ISO 3166-1 alpha-2 country code. +// +// CountryString trims surrounding whitespace from raw before parsing it as an +// IP address and then delegates to Country. +func (r *Resolver) CountryString(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + + addr, err := netip.ParseAddr(trimmed) + if err != nil { + return "", fmt.Errorf("parse IP address %q: %w", raw, errors.Join(ErrInvalidAddress, err)) + } + + return r.Country(addr) +} + +// Close releases the underlying database resources. +// +// Close is idempotent and nil-safe. +func (r *Resolver) Close() error { + if r == nil { + return nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + if r.reader == nil { + return nil + } + + reader := r.reader + r.reader = nil + + if err := reader.Close(); err != nil { + return fmt.Errorf("close geoip resolver: %w", err) + } + + return nil +} + +func normalizeCountryCode(raw string) (string, error) { + code := strings.ToUpper(strings.TrimSpace(raw)) + if len(code) != 2 { + return "", fmt.Errorf("invalid ISO 3166-1 alpha-2 code %q", raw) + } + + for idx := 0; idx < len(code); idx++ { + if code[idx] < 'A' || code[idx] > 'Z' { + return "", fmt.Errorf("invalid ISO 3166-1 alpha-2 code %q", raw) + } + } + + return code, nil +} diff --git a/pkg/geoip/geoip_test.go b/pkg/geoip/geoip_test.go new file mode 100644 index 0000000..90ad67d --- /dev/null +++ b/pkg/geoip/geoip_test.go @@ -0,0 +1,300 @@ +package geoip + +import ( + "errors" + "net/netip" + "os" + "testing" + + "github.com/oschwald/geoip2-golang/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const countryFixturePath = "test-data/test-data/GeoIP2-Country-Test.mmdb" + +type fakeReader struct { + countryFunc func(netip.Addr) (*geoip2.Country, error) + closeFunc func() error +} + +func (f fakeReader) Country(addr netip.Addr) (*geoip2.Country, error) { + if f.countryFunc == nil { + return nil, nil + } + + return f.countryFunc(addr) +} + +func (f fakeReader) Close() error { + if f.closeFunc == nil { + return nil + } + + return f.closeFunc() +} + +func TestOpenRejectsEmptyPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + }{ + {name: "empty", path: ""}, + {name: "whitespace", path: " \t\n"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resolver, err := Open(tt.path) + require.Error(t, err) + assert.Nil(t, resolver) + assert.Contains(t, err.Error(), "database path must not be empty") + }) + } +} + +func TestOpenMissingFile(t *testing.T) { + t.Parallel() + + resolver, err := Open("test-data/test-data/does-not-exist.mmdb") + require.Error(t, err) + assert.Nil(t, resolver) + assert.Contains(t, err.Error(), `open geoip database "test-data/test-data/does-not-exist.mmdb"`) +} + +func TestResolverFixtureLookups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lookup func(*Resolver) (string, error) + want string + }{ + { + name: "addr lookup", + lookup: func(resolver *Resolver) (string, error) { + return resolver.Country(netip.MustParseAddr("81.2.69.160")) + }, + want: "GB", + }, + { + name: "string lookup", + lookup: func(resolver *Resolver) (string, error) { + return resolver.CountryString(" 81.2.69.160 ") + }, + want: "GB", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resolver := openFixtureResolver(t) + + got, err := tt.lookup(resolver) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewGeoIPLegacyAlias(t *testing.T) { + t.Parallel() + + requireFixtureDatabase(t) + + resolver, err := NewGeoIP(countryFixturePath) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resolver.Close()) + }) + + got, err := resolver.CountryString("81.2.69.160") + require.NoError(t, err) + assert.Equal(t, "GB", got) +} + +func TestResolverFixtureReturnsNotFoundForPrivateAddress(t *testing.T) { + t.Parallel() + + resolver := openFixtureResolver(t) + + _, err := resolver.Country(netip.MustParseAddr("192.168.1.1")) + require.Error(t, err) + assert.ErrorIs(t, err, ErrCountryNotFound) +} + +func TestResolverCloseIsIdempotent(t *testing.T) { + t.Parallel() + + resolver := openFixtureResolver(t) + + require.NoError(t, resolver.Close()) + require.NoError(t, resolver.Close()) +} + +func TestResolverCloseIsNilSafe(t *testing.T) { + t.Parallel() + + var resolver *Resolver + require.NoError(t, resolver.Close()) +} + +func TestResolverCountryAfterClose(t *testing.T) { + t.Parallel() + + resolver := openFixtureResolver(t) + require.NoError(t, resolver.Close()) + + _, err := resolver.Country(netip.MustParseAddr("81.2.69.160")) + require.Error(t, err) + assert.ErrorIs(t, err, ErrClosed) +} + +func TestResolverCountryRejectsZeroAddress(t *testing.T) { + t.Parallel() + + resolver := newResolver(fakeReader{}) + + _, err := resolver.Country(netip.Addr{}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidAddress) +} + +func TestResolverCountryStringRejectsInvalidAddress(t *testing.T) { + t.Parallel() + + tests := []string{ + "", + "not-an-ip", + "999.0.0.1", + } + + for _, raw := range tests { + raw := raw + t.Run(raw, func(t *testing.T) { + t.Parallel() + + resolver := newResolver(fakeReader{}) + + _, err := resolver.CountryString(raw) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidAddress) + }) + } +} + +func TestResolverCountryWrapsReaderError(t *testing.T) { + t.Parallel() + + lookupErr := errors.New("lookup failed") + resolver := newResolver(fakeReader{ + countryFunc: func(netip.Addr) (*geoip2.Country, error) { + return nil, lookupErr + }, + }) + + _, err := resolver.Country(netip.MustParseAddr("203.0.113.10")) + require.Error(t, err) + assert.ErrorIs(t, err, lookupErr) + assert.Contains(t, err.Error(), "lookup country for 203.0.113.10") +} + +func TestResolverCountryReturnsNotFoundWhenISOCodeIsEmpty(t *testing.T) { + t.Parallel() + + resolver := newResolver(fakeReader{ + countryFunc: func(netip.Addr) (*geoip2.Country, error) { + return &geoip2.Country{ + Continent: geoip2.Continent{Code: "EU"}, + }, nil + }, + }) + + _, err := resolver.Country(netip.MustParseAddr("203.0.113.10")) + require.Error(t, err) + assert.ErrorIs(t, err, ErrCountryNotFound) +} + +func TestResolverCountryNormalizesCountryCode(t *testing.T) { + t.Parallel() + + resolver := newResolver(fakeReader{ + countryFunc: func(netip.Addr) (*geoip2.Country, error) { + return &geoip2.Country{ + Country: geoip2.CountryRecord{ISOCode: "gb"}, + }, nil + }, + }) + + got, err := resolver.Country(netip.MustParseAddr("203.0.113.10")) + require.NoError(t, err) + assert.Equal(t, "GB", got) +} + +func TestResolverCountryRejectsInvalidISOCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code string + }{ + {name: "digit", code: "G1"}, + {name: "too long", code: "USA"}, + {name: "non ascii", code: "éé"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resolver := newResolver(fakeReader{ + countryFunc: func(netip.Addr) (*geoip2.Country, error) { + return &geoip2.Country{ + Country: geoip2.CountryRecord{ISOCode: tt.code}, + }, nil + }, + }) + + _, err := resolver.Country(netip.MustParseAddr("203.0.113.10")) + require.Error(t, err) + assert.NotErrorIs(t, err, ErrCountryNotFound) + assert.Contains(t, err.Error(), "invalid ISO 3166-1 alpha-2 code") + }) + } +} + +func openFixtureResolver(t *testing.T) *Resolver { + t.Helper() + + requireFixtureDatabase(t) + + resolver, err := Open(countryFixturePath) + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, resolver.Close()) + }) + + return resolver +} + +func requireFixtureDatabase(t *testing.T) { + t.Helper() + + _, err := os.Stat(countryFixturePath) + require.NoErrorf( + t, + err, + "fixture database %q is unavailable; run `git submodule update --init --recursive` before running tests", + countryFixturePath, + ) +} diff --git a/pkg/geoip/go.mod b/pkg/geoip/go.mod new file mode 100644 index 0000000..5bc36ce --- /dev/null +++ b/pkg/geoip/go.mod @@ -0,0 +1,19 @@ +module galaxy/geoip + +go 1.26.1 + +require ( + github.com/oschwald/geoip2-golang/v2 v2.1.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/geoip/go.sum b/pkg/geoip/go.sum new file mode 100644 index 0000000..1a20929 --- /dev/null +++ b/pkg/geoip/go.sum @@ -0,0 +1,19 @@ +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +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/pkg/geoip/test-data b/pkg/geoip/test-data new file mode 160000 index 0000000..0be8559 --- /dev/null +++ b/pkg/geoip/test-data @@ -0,0 +1 @@ +Subproject commit 0be8559aca0f19741c7a80ff0cba56c4c4d52e53