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:
CarterPerez-dev 2026-05-17 05:23:53 -04:00
parent d870e85f23
commit ac334b14f9
5 changed files with 332 additions and 3 deletions

View File

@ -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
)

View File

@ -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=

View File

@ -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 {

View File

@ -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 ""
}

View File

@ -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)
}