feat: add GeoIP country lookup with embedded DB-IP Lite database (PLAN.md 4.3)
Embeds a DB-IP Lite country MMDB (~5MB) in the binary via go:embed, keeping the single-binary deployment story clean. Country codes are stored alongside login attempts and sessions, shown in the dashboard (Top IPs, Top Countries card, Recent/Active Sessions, session detail). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
internal/geoip/geoip.go
Normal file
51
internal/geoip/geoip.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package geoip
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
//go:embed dbip-country-lite.mmdb
|
||||
var mmdbData []byte
|
||||
|
||||
// Reader provides country-level GeoIP lookups using an embedded DB-IP Lite database.
|
||||
type Reader struct {
|
||||
db *maxminddb.Reader
|
||||
}
|
||||
|
||||
// New opens the embedded MMDB and returns a ready-to-use Reader.
|
||||
func New() (*Reader, error) {
|
||||
db, err := maxminddb.FromBytes(mmdbData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Reader{db: db}, nil
|
||||
}
|
||||
|
||||
type countryRecord struct {
|
||||
Country struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
|
||||
// Lookup returns the ISO 3166-1 alpha-2 country code for the given IP address,
|
||||
// or an empty string if the lookup fails or no result is found.
|
||||
func (r *Reader) Lookup(ipStr string) string {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var record countryRecord
|
||||
if err := r.db.Lookup(ip, &record); err != nil {
|
||||
return ""
|
||||
}
|
||||
return record.Country.ISOCode
|
||||
}
|
||||
|
||||
// Close releases resources held by the reader.
|
||||
func (r *Reader) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
44
internal/geoip/geoip_test.go
Normal file
44
internal/geoip/geoip_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package geoip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
reader, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
want string
|
||||
}{
|
||||
{"8.8.8.8", "US"},
|
||||
{"1.1.1.1", "AU"},
|
||||
{"invalid", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
got := reader.Lookup(tt.ip)
|
||||
if got != tt.want {
|
||||
t.Errorf("Lookup(%q) = %q, want %q", tt.ip, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupPrivateIP(t *testing.T) {
|
||||
reader, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Private IPs should return empty string (no country).
|
||||
got := reader.Lookup("10.0.0.1")
|
||||
if got != "" {
|
||||
t.Errorf("Lookup(10.0.0.1) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user