From ac334b14f92ca50f8694a36b19b26356fbd25d7d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 05:23:53 -0400 Subject: [PATCH] feat(canary): geoip wrapper package + config wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../canary-token-generator/backend/go.mod | 8 +- .../canary-token-generator/backend/go.sum | 4 + .../backend/internal/config/config.go | 8 + .../backend/internal/geoip/service.go | 99 ++++++++ .../backend/internal/geoip/service_test.go | 216 ++++++++++++++++++ 5 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.mod b/PROJECTS/beginner/canary-token-generator/backend/go.mod index e8b0f2d5..f15a419e 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/go.mod +++ b/PROJECTS/beginner/canary-token-generator/backend/go.mod @@ -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 ) diff --git a/PROJECTS/beginner/canary-token-generator/backend/go.sum b/PROJECTS/beginner/canary-token-generator/backend/go.sum index 23a8c405..4610b1b9 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/go.sum +++ b/PROJECTS/beginner/canary-token-generator/backend/go.sum @@ -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= diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go index 068deaa1..0df84e63 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -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 { diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go new file mode 100644 index 00000000..37e43ca0 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service.go @@ -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 "" +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go new file mode 100644 index 00000000..bbf252d8 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/geoip/service_test.go @@ -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) +}