From d5485238b8fd4cc566af00eae2b17d69a119f991 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Thu, 19 Jun 2025 12:56:27 -0500 Subject: [PATCH] feat: configurable local ipv6 ranges for audit log (#657) --- backend/internal/common/env_config.go | 2 + backend/internal/service/geolite_service.go | 71 +++++- .../internal/service/geolite_service_test.go | 231 ++++++++++++++++++ 3 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 backend/internal/service/geolite_service_test.go diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index fe74910f..5e43c65a 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -37,6 +37,7 @@ type EnvConfigSchema struct { MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` + LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` MetricsEnabled bool `env:"METRICS_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"` @@ -58,6 +59,7 @@ var EnvConfig = &EnvConfigSchema{ MaxMindLicenseKey: "", GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBUrl: MaxMindGeoLiteCityUrl, + LocalIPv6Ranges: "", UiConfigDisabled: false, MetricsEnabled: false, TracingEnabled: false, diff --git a/backend/internal/service/geolite_service.go b/backend/internal/service/geolite_service.go index b8eeaafe..97e08a26 100644 --- a/backend/internal/service/geolite_service.go +++ b/backend/internal/service/geolite_service.go @@ -13,6 +13,7 @@ import ( "net/netip" "os" "path/filepath" + "strings" "sync" "time" @@ -22,9 +23,10 @@ import ( ) type GeoLiteService struct { - httpClient *http.Client - disableUpdater bool - mutex sync.RWMutex + httpClient *http.Client + disableUpdater bool + mutex sync.RWMutex + localIPv6Ranges []*net.IPNet } var localhostIPNets = []*net.IPNet{ @@ -54,9 +56,66 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService { service.disableUpdater = true } + // Initialize IPv6 local ranges + if err := service.initializeIPv6LocalRanges(); err != nil { + log.Printf("Warning: Failed to initialize IPv6 local ranges: %v", err) + } + return service } +// initializeIPv6LocalRanges parses the LOCAL_IPV6_RANGES environment variable +func (s *GeoLiteService) initializeIPv6LocalRanges() error { + rangesEnv := common.EnvConfig.LocalIPv6Ranges + if rangesEnv == "" { + return nil // No local IPv6 ranges configured + } + + ranges := strings.Split(rangesEnv, ",") + localRanges := make([]*net.IPNet, 0, len(ranges)) + + for _, rangeStr := range ranges { + rangeStr = strings.TrimSpace(rangeStr) + if rangeStr == "" { + continue + } + + _, ipNet, err := net.ParseCIDR(rangeStr) + if err != nil { + return fmt.Errorf("invalid IPv6 range '%s': %w", rangeStr, err) + } + + // Ensure it's an IPv6 range + if ipNet.IP.To4() != nil { + return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr) + } + + localRanges = append(localRanges, ipNet) + } + + s.localIPv6Ranges = localRanges + + if len(localRanges) > 0 { + log.Printf("Initialized %d IPv6 local ranges", len(localRanges)) + } + return nil +} + +// isLocalIPv6 checks if the given IPv6 address is within any of the configured local ranges +func (s *GeoLiteService) isLocalIPv6(ip net.IP) bool { + if ip.To4() != nil { + return false // Not an IPv6 address + } + + for _, localRange := range s.localIPv6Ranges { + if localRange.Contains(ip) { + return true + } + } + + return false +} + func (s *GeoLiteService) DisableUpdater() bool { return s.disableUpdater } @@ -65,6 +124,12 @@ func (s *GeoLiteService) DisableUpdater() bool { func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) { // Check the IP address against known private IP ranges if ip := net.ParseIP(ipAddress); ip != nil { + // Check IPv6 local ranges first + if s.isLocalIPv6(ip) { + return "Internal Network", "LAN", nil + } + + // Check existing IPv4 ranges for _, ipNet := range tailscaleIPNets { if ipNet.Contains(ip) { return "Internal Network", "Tailscale", nil diff --git a/backend/internal/service/geolite_service_test.go b/backend/internal/service/geolite_service_test.go new file mode 100644 index 00000000..fad5b00b --- /dev/null +++ b/backend/internal/service/geolite_service_test.go @@ -0,0 +1,231 @@ +package service + +import ( + "net" + "net/http" + "testing" + + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +func TestGeoLiteService_IPv6LocalRanges(t *testing.T) { + tests := []struct { + name string + localRanges string + testIP string + expectedCountry string + expectedCity string + expectError bool + }{ + { + name: "IPv6 in local range", + localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56", + testIP: "2001:0db8:abcd:000::1", + expectedCountry: "Internal Network", + expectedCity: "LAN", + expectError: false, + }, + { + name: "IPv6 not in local range", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "2001:0db8:ffff:000::1", + expectError: true, + }, + { + name: "Multiple ranges - second range match", + localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56", + testIP: "2001:0db8:abcd:001::1", + expectedCountry: "Internal Network", + expectedCity: "LAN", + expectError: false, + }, + { + name: "Empty local ranges", + localRanges: "", + testIP: "2001:0db8:abcd:000::1", + expectError: true, + }, + { + name: "IPv4 private address still works", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "192.168.1.1", + expectedCountry: "Internal Network", + expectedCity: "LAN", + expectError: false, + }, + { + name: "IPv6 loopback", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "::1", + expectedCountry: "Internal Network", + expectedCity: "localhost", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalConfig := common.EnvConfig.LocalIPv6Ranges + common.EnvConfig.LocalIPv6Ranges = tt.localRanges + defer func() { + common.EnvConfig.LocalIPv6Ranges = originalConfig + }() + + service := NewGeoLiteService(&http.Client{}) + + country, city, err := service.GetLocationByIP(tt.testIP) + + if tt.expectError { + if err == nil && country != "Internal Network" { + t.Errorf("Expected error or internal network classification for external IP") + } + } else { + if err != nil { + t.Errorf("Expected no error for local IP, got: %v", err) + } + if country != tt.expectedCountry { + t.Errorf("Expected country %s, got %s", tt.expectedCountry, country) + } + if city != tt.expectedCity { + t.Errorf("Expected city %s, got %s", tt.expectedCity, city) + } + } + }) + } +} + +func TestGeoLiteService_isLocalIPv6(t *testing.T) { + tests := []struct { + name string + localRanges string + testIP string + expected bool + }{ + { + name: "Valid IPv6 in range", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "2001:0db8:abcd:000::1", + expected: true, + }, + { + name: "Valid IPv6 not in range", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "2001:0db8:ffff:000::1", + expected: false, + }, + { + name: "IPv4 address should return false", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "192.168.1.1", + expected: false, + }, + { + name: "No ranges configured", + localRanges: "", + testIP: "2001:0db8:abcd:000::1", + expected: false, + }, + { + name: "Edge of range", + localRanges: "2001:0db8:abcd:000::/56", + testIP: "2001:0db8:abcd:00ff:ffff:ffff:ffff:ffff", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalConfig := common.EnvConfig.LocalIPv6Ranges + common.EnvConfig.LocalIPv6Ranges = tt.localRanges + defer func() { + common.EnvConfig.LocalIPv6Ranges = originalConfig + }() + + service := NewGeoLiteService(&http.Client{}) + ip := net.ParseIP(tt.testIP) + if ip == nil { + t.Fatalf("Invalid test IP: %s", tt.testIP) + } + + result := service.isLocalIPv6(ip) + if result != tt.expected { + t.Errorf("Expected %v, got %v for IP %s", tt.expected, result, tt.testIP) + } + }) + } +} + +func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) { + tests := []struct { + name string + envValue string + expectError bool + expectCount int + }{ + { + name: "Valid IPv6 ranges", + envValue: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56", + expectError: false, + expectCount: 2, + }, + { + name: "Empty environment variable", + envValue: "", + expectError: false, + expectCount: 0, + }, + { + name: "Invalid CIDR notation", + envValue: "2001:0db8:abcd:000::/999", + expectError: true, + expectCount: 0, + }, + { + name: "IPv4 range in IPv6 env var", + envValue: "192.168.1.0/24", + expectError: true, + expectCount: 0, + }, + { + name: "Mixed valid and invalid ranges", + envValue: "2001:0db8:abcd:000::/56,invalid-range", + expectError: true, + expectCount: 0, + }, + { + name: "Whitespace handling", + envValue: " 2001:0db8:abcd:000::/56 , 2001:0db8:abcd:001::/56 ", + expectError: false, + expectCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalConfig := common.EnvConfig.LocalIPv6Ranges + common.EnvConfig.LocalIPv6Ranges = tt.envValue + defer func() { + common.EnvConfig.LocalIPv6Ranges = originalConfig + }() + + service := &GeoLiteService{ + httpClient: &http.Client{}, + } + + err := service.initializeIPv6LocalRanges() + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + + rangeCount := len(service.localIPv6Ranges) + + if rangeCount != tt.expectCount { + t.Errorf("Expected %d ranges, got %d", tt.expectCount, rangeCount) + } + }) + } +}