feat: geoip
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
+1
Submodule pkg/geoip/test-data added at 0be8559aca
Reference in New Issue
Block a user