feat(canary): geoip wrapper package + config wiring
- internal/geoip: Service over oschwald/geoip2-golang v2 with City db
enrichment (Country/Region/Subdivision/City) and a NopService
factory for environments without the mmdb file. Nil-safe Lookup
returns a zero geoip.Lookup on any failure path (nil receiver,
nil reader, empty/malformed IP, reader error, !rec.HasData).
- internal/geoip: unexported cityReader interface allows whitebox
fake-based unit testing without bundling a real .mmdb fixture;
extractLookup is a pure function for synthetic-record coverage.
- config: GeoIPConfig{Path} koanf section; GEOLITE_PATH env binding
matching compose.yml; default /data/GeoLite2-City.mmdb.
- deps: github.com/oschwald/geoip2-golang/v2 v2.1.0.
ASN/ASNOrg fields are reserved on geoip.Lookup but stay zero with a
City-only mmdb; populating them is a future-extension concern (ASN
db is not fetched by scripts/init.sh per design §12.5).
This commit is contained in:
parent
d870e85f23
commit
ac334b14f9
|
|
@ -3,6 +3,8 @@ module github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/
|
|||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1
|
||||
|
|
@ -13,6 +15,7 @@ require (
|
|||
github.com/knadh/koanf/providers/env v1.1.0
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
github.com/knadh/koanf/v2 v2.1.2
|
||||
github.com/oschwald/geoip2-golang/v2 v2.1.0
|
||||
github.com/pdfcpu/pdfcpu v0.12.1
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
|
|
@ -25,15 +28,14 @@ require (
|
|||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
|
|
@ -83,6 +85,7 @@ require (
|
|||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
|
|
@ -110,5 +113,4 @@ require (
|
|||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
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/pdfcpu/pdfcpu v0.12.1 h1:HwoN72zJCj+pPbfMDChYBTZrT7SY0VwgUzqeaId3I20=
|
||||
github.com/pdfcpu/pdfcpu v0.12.1/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
|
|
|
|||
|
|
@ -28,12 +28,17 @@ type Config struct {
|
|||
MySQL MySQLConfig `koanf:"mysql"`
|
||||
Notify NotifyConfig `koanf:"notify"`
|
||||
Operator OperatorConfig `koanf:"operator"`
|
||||
GeoIP GeoIPConfig `koanf:"geoip"`
|
||||
}
|
||||
|
||||
type OperatorConfig struct {
|
||||
Token string `koanf:"token"`
|
||||
}
|
||||
|
||||
type GeoIPConfig struct {
|
||||
Path string `koanf:"path"`
|
||||
}
|
||||
|
||||
type NotifyConfig struct {
|
||||
DedupTTL time.Duration `koanf:"dedup_ttl"`
|
||||
SendTimeout time.Duration `koanf:"send_timeout"`
|
||||
|
|
@ -262,6 +267,8 @@ func loadDefaults(k *koanf.Koanf) error {
|
|||
"notify.fingerprint_window": "5m",
|
||||
|
||||
"operator.token": "",
|
||||
|
||||
"geoip.path": "/data/GeoLite2-City.mmdb",
|
||||
}
|
||||
|
||||
for key, value := range defaults {
|
||||
|
|
@ -313,6 +320,7 @@ var envKeyMap = map[string]string{
|
|||
"NOTIFY_TELEGRAM_API_BASE": "notify.telegram_api_base",
|
||||
"NOTIFY_FINGERPRINT_WINDOW": "notify.fingerprint_window",
|
||||
"OPERATOR_TOKEN": "operator.token",
|
||||
"GEOLITE_PATH": "geoip.path",
|
||||
}
|
||||
|
||||
func envKeyReplacer(s string) string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// ©AngelaMos | 2026
|
||||
// service.go
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"github.com/oschwald/geoip2-golang/v2"
|
||||
)
|
||||
|
||||
type Lookup struct {
|
||||
Country string
|
||||
Region string
|
||||
City string
|
||||
ASNOrg string
|
||||
ASN int
|
||||
}
|
||||
|
||||
type Lookuper interface {
|
||||
Lookup(ip string) Lookup
|
||||
}
|
||||
|
||||
type cityReader interface {
|
||||
City(netip.Addr) (*geoip2.City, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
reader cityReader
|
||||
}
|
||||
|
||||
func Open(path string) (*Service, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("geoip: path is empty")
|
||||
}
|
||||
r, err := geoip2.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geoip open %q: %w", path, err)
|
||||
}
|
||||
return &Service{reader: r}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Lookup(ip string) Lookup {
|
||||
if s == nil || s.reader == nil || ip == "" {
|
||||
return Lookup{}
|
||||
}
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return Lookup{}
|
||||
}
|
||||
rec, err := s.reader.City(addr)
|
||||
if err != nil || rec == nil || !rec.HasData() {
|
||||
return Lookup{}
|
||||
}
|
||||
return extractLookup(rec)
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s == nil || s.reader == nil {
|
||||
return nil
|
||||
}
|
||||
return s.reader.Close()
|
||||
}
|
||||
|
||||
type nopService struct{}
|
||||
|
||||
func NopService() Lookuper {
|
||||
return nopService{}
|
||||
}
|
||||
|
||||
func (nopService) Lookup(string) Lookup {
|
||||
return Lookup{}
|
||||
}
|
||||
|
||||
func extractLookup(rec *geoip2.City) Lookup {
|
||||
if rec == nil {
|
||||
return Lookup{}
|
||||
}
|
||||
return Lookup{
|
||||
Country: rec.Country.ISOCode,
|
||||
Region: firstSubdivisionName(rec.Subdivisions),
|
||||
City: rec.City.Names.English,
|
||||
}
|
||||
}
|
||||
|
||||
func firstSubdivisionName(subs []geoip2.CitySubdivision) string {
|
||||
for _, s := range subs {
|
||||
if s.Names.English != "" {
|
||||
return s.Names.English
|
||||
}
|
||||
if s.ISOCode != "" {
|
||||
return s.ISOCode
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
// ©AngelaMos | 2026
|
||||
// service_test.go
|
||||
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/oschwald/geoip2-golang/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeCityReader struct {
|
||||
rec *geoip2.City
|
||||
err error
|
||||
calls int
|
||||
closeCalls int
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (f *fakeCityReader) City(netip.Addr) (*geoip2.City, error) {
|
||||
f.calls++
|
||||
return f.rec, f.err
|
||||
}
|
||||
|
||||
func (f *fakeCityReader) Close() error {
|
||||
f.closeCalls++
|
||||
return f.closeErr
|
||||
}
|
||||
|
||||
func newServiceWithFake(r *fakeCityReader) *Service {
|
||||
return &Service{reader: r}
|
||||
}
|
||||
|
||||
func TestNopService_AlwaysReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
n := NopService()
|
||||
require.Equal(t, Lookup{}, n.Lookup("203.0.113.1"))
|
||||
require.Equal(t, Lookup{}, n.Lookup(""))
|
||||
require.Equal(t, Lookup{}, n.Lookup("not-an-ip"))
|
||||
}
|
||||
|
||||
func TestNopService_SatisfiesLookuper(t *testing.T) {
|
||||
t.Parallel()
|
||||
var _ Lookuper = NopService()
|
||||
}
|
||||
|
||||
func TestServiceImplementsLookuper(t *testing.T) {
|
||||
t.Parallel()
|
||||
var _ Lookuper = (*Service)(nil)
|
||||
}
|
||||
|
||||
func TestOpen_EmptyPathReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, err := Open("")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, s)
|
||||
}
|
||||
|
||||
func TestOpen_NonexistentPathReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
missing := filepath.Join(t.TempDir(), "nonexistent.mmdb")
|
||||
s, err := Open(missing)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, s)
|
||||
}
|
||||
|
||||
func TestService_Lookup_NilReceiverReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *Service
|
||||
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
|
||||
}
|
||||
|
||||
func TestService_Lookup_NilReaderReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Service{}
|
||||
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
|
||||
}
|
||||
|
||||
func TestService_Lookup_EmptyIPReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeCityReader{}
|
||||
s := newServiceWithFake(fake)
|
||||
require.Equal(t, Lookup{}, s.Lookup(""))
|
||||
require.Equal(t, 0, fake.calls,
|
||||
"empty IP must short-circuit before reader call")
|
||||
}
|
||||
|
||||
func TestService_Lookup_MalformedIPReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeCityReader{}
|
||||
s := newServiceWithFake(fake)
|
||||
for _, bad := range []string{"not-an-ip", "999.999.999.999", "::xyz", " "} {
|
||||
require.Equal(t, Lookup{}, s.Lookup(bad), "input=%q", bad)
|
||||
}
|
||||
require.Equal(t, 0, fake.calls,
|
||||
"malformed IP must short-circuit before reader call")
|
||||
}
|
||||
|
||||
func TestService_Lookup_ReaderErrorReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeCityReader{err: errors.New("boom")}
|
||||
s := newServiceWithFake(fake)
|
||||
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
|
||||
require.Equal(t, 1, fake.calls)
|
||||
}
|
||||
|
||||
func TestService_Lookup_NoDataReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeCityReader{rec: &geoip2.City{}}
|
||||
s := newServiceWithFake(fake)
|
||||
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
|
||||
}
|
||||
|
||||
func TestService_Lookup_NilRecordReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeCityReader{rec: nil}
|
||||
s := newServiceWithFake(fake)
|
||||
require.Equal(t, Lookup{}, s.Lookup("203.0.113.1"))
|
||||
}
|
||||
|
||||
func TestService_Lookup_PopulatesAllAvailableFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &geoip2.City{}
|
||||
rec.Country.ISOCode = "US"
|
||||
rec.Country.Names.English = "United States"
|
||||
rec.City.Names.English = "Mountain View"
|
||||
rec.Subdivisions = []geoip2.CitySubdivision{
|
||||
{Names: geoip2.Names{English: "California"}, ISOCode: "CA"},
|
||||
}
|
||||
fake := &fakeCityReader{rec: rec}
|
||||
s := newServiceWithFake(fake)
|
||||
|
||||
got := s.Lookup("203.0.113.1")
|
||||
require.Equal(t, "US", got.Country)
|
||||
require.Equal(t, "California", got.Region)
|
||||
require.Equal(t, "Mountain View", got.City)
|
||||
require.Equal(t, 0, got.ASN,
|
||||
"City db carries no ASN; field is reserved for ASN-db extension")
|
||||
require.Empty(t, got.ASNOrg,
|
||||
"City db carries no ASNOrg; field is reserved for ASN-db extension")
|
||||
}
|
||||
|
||||
func TestService_Lookup_AcceptsIPv6(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &geoip2.City{}
|
||||
rec.Country.ISOCode = "DE"
|
||||
rec.City.Names.English = "Berlin"
|
||||
fake := &fakeCityReader{rec: rec}
|
||||
s := newServiceWithFake(fake)
|
||||
|
||||
got := s.Lookup("2001:db8::1")
|
||||
require.Equal(t, "DE", got.Country)
|
||||
require.Equal(t, "Berlin", got.City)
|
||||
}
|
||||
|
||||
func TestExtractLookup_NilReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, Lookup{}, extractLookup(nil))
|
||||
}
|
||||
|
||||
func TestExtractLookup_PicksFirstSubdivisionEnglishName(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &geoip2.City{}
|
||||
rec.Subdivisions = []geoip2.CitySubdivision{
|
||||
{Names: geoip2.Names{English: "England"}, ISOCode: "ENG"},
|
||||
{Names: geoip2.Names{English: "Oxfordshire"}, ISOCode: "OXF"},
|
||||
}
|
||||
require.Equal(t, "England", extractLookup(rec).Region)
|
||||
}
|
||||
|
||||
func TestExtractLookup_FallsBackToISOCodeWhenEnglishMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &geoip2.City{}
|
||||
rec.Subdivisions = []geoip2.CitySubdivision{
|
||||
{ISOCode: "CA"},
|
||||
}
|
||||
require.Equal(t, "CA", extractLookup(rec).Region)
|
||||
}
|
||||
|
||||
func TestExtractLookup_NoSubdivisionsLeavesRegionEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &geoip2.City{}
|
||||
rec.Country.ISOCode = "JP"
|
||||
got := extractLookup(rec)
|
||||
require.Empty(t, got.Region)
|
||||
require.Equal(t, "JP", got.Country)
|
||||
}
|
||||
|
||||
func TestService_Close_NilReceiverIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *Service
|
||||
require.NoError(t, s.Close())
|
||||
}
|
||||
|
||||
func TestService_Close_NilReaderIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.NoError(t, (&Service{}).Close())
|
||||
}
|
||||
|
||||
func TestService_Close_DelegatesToReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeCityReader{}
|
||||
require.NoError(t, newServiceWithFake(fake).Close())
|
||||
require.Equal(t, 1, fake.closeCalls)
|
||||
}
|
||||
|
||||
func TestService_Close_PropagatesReaderError(t *testing.T) {
|
||||
t.Parallel()
|
||||
wantErr := errors.New("close failed")
|
||||
fake := &fakeCityReader{closeErr: wantErr}
|
||||
require.ErrorIs(t, newServiceWithFake(fake).Close(), wantErr)
|
||||
}
|
||||
Loading…
Reference in New Issue