feat: geoip

This commit is contained in:
Ilia Denisov
2026-04-09 14:16:36 +02:00
committed by GitHub
parent 94b7b6ce06
commit 84eeaf5184
11 changed files with 596 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "pkg/geoip/test-data"]
path = pkg/geoip/test-data
url = https://github.com/maxmind/MaxMind-DB.git
+7
View File
@@ -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.
+1
View File
@@ -597,6 +597,7 @@ Role in the system:
## Geo-IP Source ## Geo-IP Source
The service uses a locally stored free Geo-IP country database. The service uses a locally stored free Geo-IP country database.
The Geo-IP acessible via [geoip](../pkg/geoip/) package.
Requirements: Requirements:
+3 -1
View File
@@ -1,4 +1,4 @@
go 1.26.0 go 1.26.1
use ( use (
./authsession ./authsession
@@ -8,6 +8,7 @@ use (
./pkg/calc ./pkg/calc
./pkg/connector ./pkg/connector
./pkg/error ./pkg/error
./pkg/geoip
./pkg/model ./pkg/model
./pkg/schema ./pkg/schema
./pkg/storage ./pkg/storage
@@ -19,6 +20,7 @@ replace (
galaxy/calc v0.0.0 => ./pkg/calc galaxy/calc v0.0.0 => ./pkg/calc
galaxy/connector v0.0.0 => ./pkg/connector galaxy/connector v0.0.0 => ./pkg/connector
galaxy/error v0.0.0 => ./pkg/error galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip
galaxy/model v0.0.0 => ./pkg/model galaxy/model v0.0.0 => ./pkg/model
galaxy/schema v0.0.0 => ./pkg/schema galaxy/schema v0.0.0 => ./pkg/schema
galaxy/storage v0.0.0 => ./pkg/storage galaxy/storage v0.0.0 => ./pkg/storage
+40
View File
@@ -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
```
+39
View File
@@ -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)
}
}
+164
View File
@@ -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
}
+300
View File
@@ -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,
)
}
+19
View File
@@ -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
)
+19
View File
@@ -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=
Submodule pkg/geoip/test-data added at 0be8559aca