From 2f3bb0dce38bc939f5978df354a12d11d5e93e33 Mon Sep 17 00:00:00 2001 From: ayushi-work Date: Mon, 6 Jul 2026 21:00:44 +0530 Subject: [PATCH] feat(util): support IP range/CIDR filtering in event logs Signed-off-by: ayushi-work --- pkg/util/ip.go | 236 +++++++++++++++++++++++ pkg/util/ip_test.go | 432 ++++++++++++++++++++++++++++++++++++++++++ service/admin/list.go | 25 +++ 3 files changed, 693 insertions(+) create mode 100644 pkg/util/ip.go create mode 100644 pkg/util/ip_test.go diff --git a/pkg/util/ip.go b/pkg/util/ip.go new file mode 100644 index 00000000..cebcc06d --- /dev/null +++ b/pkg/util/ip.go @@ -0,0 +1,236 @@ +package util + +import ( + "fmt" + "net" + "strings" +) + +// IPMatcher checks whether an IP address matches a given filter. +// The filter can be: +// - A single IP address (e.g., "192.168.1.1" or "::1") +// - A CIDR notation (e.g., "192.168.1.0/24" or "2001:db8::/32") +// - An IP range (e.g., "192.168.1.1-192.168.1.255") +// - A wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*") +type IPMatcher struct { + cidr *net.IPNet + ipStart net.IP + ipEnd net.IP + exact net.IP +} + +// NewIPMatcher creates an IPMatcher from a filter string. +// Supported formats: +// - CIDR: "192.168.1.0/24", "2001:db8::/32" +// - Range: "192.168.1.1-192.168.1.255", "::1-::10" +// - Wildcard: "192.168.1.*", "192.168.*.*", "10.*.*.*" +// - Single IP: "192.168.1.1", "::1" +func NewIPMatcher(filter string) (*IPMatcher, error) { + filter = strings.TrimSpace(filter) + if filter == "" { + return nil, fmt.Errorf("empty IP filter") + } + + // Try CIDR notation first + if strings.Contains(filter, "/") { + _, cidrNet, err := net.ParseCIDR(filter) + if err == nil { + return &IPMatcher{cidr: cidrNet}, nil + } + // If it has "/" but isn't valid CIDR, continue to try other formats + } + + // Try IP range (e.g., "192.168.1.1-192.168.1.255") + if strings.Count(filter, "-") == 1 { + parts := strings.SplitN(filter, "-", 2) + start := net.ParseIP(strings.TrimSpace(parts[0])) + end := net.ParseIP(strings.TrimSpace(parts[1])) + if start != nil && end != nil { + // Ensure same IP version + if (start.To4() != nil) == (end.To4() != nil) { + return &IPMatcher{ipStart: start, ipEnd: end}, nil + } + return nil, fmt.Errorf("IP range must contain same IP version: %s", filter) + } + } + + // Try wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*") + if strings.Contains(filter, "*") { + cidr, err := wildcardToCIDR(filter) + if err == nil { + return &IPMatcher{cidr: cidr}, nil + } + return nil, err + } + + // Try single exact IP + ip := net.ParseIP(filter) + if ip != nil { + return &IPMatcher{exact: ip}, nil + } + + return nil, fmt.Errorf("invalid IP filter format: %s", filter) +} + +// Match checks if the given IP address matches the filter. +// The ip parameter should be a string representation of an IP address. +// Returns an error if the IP string is invalid. +func (m *IPMatcher) Match(ip string) (bool, error) { + parsedIP := net.ParseIP(strings.TrimSpace(ip)) + if parsedIP == nil { + return false, fmt.Errorf("invalid IP address: %s", ip) + } + + return m.MatchIP(parsedIP), nil +} + +// MatchIP checks if the given parsed IP address matches the filter. +func (m *IPMatcher) MatchIP(ip net.IP) bool { + if ip == nil { + return false + } + + switch { + case m.cidr != nil: + return m.cidr.Contains(ip) + case m.ipStart != nil && m.ipEnd != nil: + return ipInRange(ip, m.ipStart, m.ipEnd) + case m.exact != nil: + return m.exact.Equal(ip) + } + + return false +} + +// ipInRange checks if ip is within the inclusive range [start, end]. +func ipInRange(ip, start, end net.IP) bool { + // Normalize to 16-byte representation for comparison + ip16 := ip.To16() + start16 := start.To16() + end16 := end.To16() + + return bytesCompare(start16, ip16) <= 0 && bytesCompare(ip16, end16) <= 0 +} + +// bytesCompare compares two byte slices lexicographically. +// Returns -1 if a < b, 0 if a == b, 1 if a > b. +func bytesCompare(a, b []byte) int { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + return 0 +} + +// wildcardToCIDR converts a wildcard IP pattern to a CIDR net. +// Only supports IPv4 wildcards (e.g., "192.168.1.*" -> "192.168.1.0/24"). +func wildcardToCIDR(pattern string) (*net.IPNet, error) { + pattern = strings.TrimSpace(pattern) + parts := strings.Split(pattern, ".") + + if len(parts) != 4 { + return nil, fmt.Errorf("wildcard pattern currently only supports IPv4: %s", pattern) + } + + // Count how many fixed octets + fixedOctets := 0 + for i, part := range parts { + part = strings.TrimSpace(part) + if part == "*" { + // Fill remaining octets with 0 for the network address + for j := i; j < 4; j++ { + parts[j] = "0" + } + break + } + fixedOctets++ + } + + // All wildcards would be 0.0.0.0/0 + if fixedOctets == 0 { + return &net.IPNet{ + IP: net.IPv4(0, 0, 0, 0), + Mask: net.CIDRMask(0, 32), + }, nil + } + + // Construct CIDR network + cidrStr := fmt.Sprintf("%s/%d", strings.Join(parts, "."), fixedOctets*8) + _, cidrNet, err := net.ParseCIDR(cidrStr) + if err != nil { + return nil, fmt.Errorf("invalid wildcard pattern: %s (%w)", pattern, err) + } + + return cidrNet, nil +} + +// IsCIDRNotation checks if a filter string is a valid CIDR notation. +func IsCIDRNotation(filter string) bool { + _, _, err := net.ParseCIDR(strings.TrimSpace(filter)) + return err == nil +} + +// IPFilterToCIDRs converts an IP filter string to a list of CIDR networks. +// This is useful when you need to convert user-friendly IP filters +// to CIDR notation for display or storage. +// Supports CIDR, wildcard, single IP, and IP range (expanded). +func IPFilterToCIDRs(filter string) ([]*net.IPNet, error) { + matcher, err := NewIPMatcher(filter) + if err != nil { + return nil, err + } + + switch { + case matcher.cidr != nil: + return []*net.IPNet{matcher.cidr}, nil + case matcher.exact != nil: + // Single IP -> /32 or /128 + if matcher.exact.To4() != nil { + return []*net.IPNet{{ + IP: matcher.exact, + Mask: net.CIDRMask(32, 32), + }}, nil + } + return []*net.IPNet{{ + IP: matcher.exact, + Mask: net.CIDRMask(128, 128), + }}, nil + case matcher.ipStart != nil && matcher.ipEnd != nil: + // IP range - return as two /32 or /128 CIDRs + // Note: This is a simplified representation; the caller should + // use Match/MatchIP for exact range matching + cidrs := make([]*net.IPNet, 0) + if matcher.ipStart.To4() != nil { + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipStart, + Mask: net.CIDRMask(32, 32), + }) + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipEnd, + Mask: net.CIDRMask(32, 32), + }) + } else { + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipStart, + Mask: net.CIDRMask(128, 128), + }) + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipEnd, + Mask: net.CIDRMask(128, 128), + }) + } + return cidrs, nil + } + + return nil, fmt.Errorf("cannot convert filter to CIDR: %s", filter) +} diff --git a/pkg/util/ip_test.go b/pkg/util/ip_test.go new file mode 100644 index 00000000..df794d25 --- /dev/null +++ b/pkg/util/ip_test.go @@ -0,0 +1,432 @@ +package util + +import ( + "net" + "testing" +) + +func TestNewIPMatcher_CIDR(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 CIDR /24", "192.168.1.0/24", false}, + {"IPv4 CIDR /16", "10.0.0.0/16", false}, + {"IPv4 CIDR /32", "192.168.1.1/32", false}, + {"IPv4 CIDR /0", "0.0.0.0/0", false}, + {"IPv6 CIDR /64", "2001:db8::/64", false}, + {"IPv6 CIDR /128", "::1/128", false}, + {"Invalid CIDR - bad mask", "192.168.1.0/33", true}, + {"Invalid CIDR - bad IP", "999.999.999.999/24", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.cidr == nil { + t.Errorf("NewIPMatcher(%q) expected CIDR matcher, got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_Range(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 range", "192.168.1.1-192.168.1.255", false}, + {"IPv4 range with spaces", "192.168.1.1 - 192.168.1.255", false}, + {"IPv6 range", "::1-::10", false}, + {"Cross IP version", "192.168.1.1-::1", true}, + {"Invalid start", "abc-192.168.1.255", true}, + {"Invalid end", "192.168.1.1-abc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.ipStart == nil || matcher.ipEnd == nil { + t.Errorf("NewIPMatcher(%q) expected range matcher, got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_Wildcard(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"One wildcard", "192.168.1.*", false}, + {"Two wildcards", "192.168.*.*", false}, + {"Three wildcards", "192.*.*.*", false}, + {"All wildcards", "*.*.*.*", false}, + {"IPv6 not supported", "2001:db8::*", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.cidr == nil { + t.Errorf("NewIPMatcher(%q) expected CIDR matcher (from wildcard), got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_SingleIP(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 single", "192.168.1.1", false}, + {"IPv6 single", "::1", false}, + {"IPv6 full", "2001:db8::1", false}, + {"Invalid", "not-an-ip", true}, + {"Empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.exact == nil { + t.Errorf("NewIPMatcher(%q) expected exact IP matcher, got nil", tt.filter) + } + }) + } +} + +func TestIPMatcher_Match_CIDR(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.0/24") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.1.255", true}, + {"192.168.1.0", true}, + {"192.168.2.1", false}, + {"10.0.0.1", false}, + {"invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if tt.ip == "invalid" { + if err == nil { + t.Errorf("Match(%q) expected error", tt.ip) + } + return + } + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_IPv6CIDR(t *testing.T) { + matcher, err := NewIPMatcher("2001:db8::/32") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"2001:db8::1", true}, + {"2001:db8:1234::1", true}, + {"2001:db9::1", false}, + {"::1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_Range(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.10-192.168.1.20") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.10", true}, + {"192.168.1.15", true}, + {"192.168.1.20", true}, + {"192.168.1.9", false}, + {"192.168.1.21", false}, + {"192.168.2.1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_Wildcard(t *testing.T) { + matcher, err := NewIPMatcher("192.168.*.*") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.255.255", true}, + {"192.168.0.0", true}, + {"192.169.1.1", false}, + {"10.0.0.1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_SingleIP(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.1") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.1.2", false}, + {"192.168.1.0", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, _ := matcher.Match(tt.ip) + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_MatchIP_WithNetIP(t *testing.T) { + matcher, err := NewIPMatcher("10.0.0.0/8") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + // Test MatchIP with pre-parsed net.IP + ip := net.ParseIP("10.255.255.255") + if !matcher.MatchIP(ip) { + t.Errorf("MatchIP(%v) = false, want true", ip) + } + + ip = net.ParseIP("11.0.0.1") + if matcher.MatchIP(ip) { + t.Errorf("MatchIP(%v) = true, want false", ip) + } + + // Test nil IP + if matcher.MatchIP(nil) { + t.Errorf("MatchIP(nil) = true, want false") + } +} + +func TestWildcardToCIDR(t *testing.T) { + tests := []struct { + pattern string + cidr string + wantErr bool + }{ + {"192.168.1.*", "192.168.1.0/24", false}, + {"192.168.*.*", "192.168.0.0/16", false}, + {"192.*.*.*", "192.0.0.0/8", false}, + {"*.*.*.*", "0.0.0.0/0", false}, + {"10.0.*.*", "10.0.0.0/16", false}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + cidrNet, err := wildcardToCIDR(tt.pattern) + if tt.wantErr { + if err == nil { + t.Errorf("wildcardToCIDR(%q) expected error, got nil", tt.pattern) + } + return + } + if err != nil { + t.Errorf("wildcardToCIDR(%q) unexpected error: %v", tt.pattern, err) + return + } + if cidrNet.String() != tt.cidr { + t.Errorf("wildcardToCIDR(%q) = %q, want %q", tt.pattern, cidrNet.String(), tt.cidr) + } + }) + } +} + +func TestIsCIDRNotation(t *testing.T) { + tests := []struct { + filter string + isCIDR bool + }{ + {"192.168.1.0/24", true}, + {"2001:db8::/32", true}, + {"0.0.0.0/0", true}, + {"192.168.1.1", false}, + {"not-a-cidr", false}, + {"192.168.1.*", false}, + {"192.168.1.1-192.168.1.255", false}, + } + + for _, tt := range tests { + t.Run(tt.filter, func(t *testing.T) { + if got := IsCIDRNotation(tt.filter); got != tt.isCIDR { + t.Errorf("IsCIDRNotation(%q) = %v, want %v", tt.filter, got, tt.isCIDR) + } + }) + } +} + +func TestIPMatcher_BackwardCompatible(t *testing.T) { + // Ensures existing exact IP filtering still works + matcher, err := NewIPMatcher("123.45.67.89") + if err != nil { + t.Fatalf("Failed to create matcher for exact IP: %v", err) + } + + matched, err := matcher.Match("123.45.67.89") + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if !matched { + t.Error("Exact IP should match") + } + + matched, err = matcher.Match("123.45.67.90") + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched { + t.Error("Different exact IP should not match") + } +} + +func TestIPMatcher_IPv6Range(t *testing.T) { + matcher, err := NewIPMatcher("::1-::5") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"::1", true}, + {"::3", true}, + {"::5", true}, + {"::6", false}, + {"::0", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} diff --git a/service/admin/list.go b/service/admin/list.go index b9aa85c2..131e24fb 100644 --- a/service/admin/list.go +++ b/service/admin/list.go @@ -2,6 +2,7 @@ package admin import ( "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/cloudreve/Cloudreve/v4/pkg/util" ) // AdminListService 仪表盘列条目服务 @@ -17,6 +18,30 @@ type ( AdminListServiceParamsCtx struct{} ) +// IPMatcherFromCondition returns an IPMatcher for the given condition key. +// If the condition value is empty, returns nil (no filter). +// The condition value can be a single IP, CIDR notation, IP range, or wildcard pattern. +// Returns an error only if the condition value is non-empty but invalid. +func (s *AdminListService) IPMatcherFromCondition(key string) (*util.IPMatcher, error) { + value, ok := s.Conditions[key] + if !ok || value == "" { + return nil, nil + } + return util.NewIPMatcher(value) +} + +// IPMatcherFromSearch returns an IPMatcher for the given search key. +// If the search value is empty, returns nil (no filter). +// The search value can be a single IP, CIDR notation, IP range, or wildcard pattern. +// Returns an error only if the search value is non-empty but invalid. +func (s *AdminListService) IPMatcherFromSearch(key string) (*util.IPMatcher, error) { + value, ok := s.Searches[key] + if !ok || value == "" { + return nil, nil + } + return util.NewIPMatcher(value) +} + // GroupList 获取用户组列表 func (service *NoParamService) GroupList() serializer.Response { //var res []model.Group