diff --git a/internal/matcher/cidr_trie.go b/internal/matcher/cidr_trie.go new file mode 100644 index 00000000..9abdcd62 --- /dev/null +++ b/internal/matcher/cidr_trie.go @@ -0,0 +1,147 @@ +package matcher + +import ( + "net" + "net/netip" +) + +// cidrTrie is a binary trie for zero-allocation netip.Prefix containment lookups. +// IPv4 and IPv6 prefixes are stored in separate sub-tries. +type cidrTrie struct { + root4 *cidrNode + root6 *cidrNode +} + +type cidrNode struct { + hasEntry bool + children [2]*cidrNode +} + +func newCIDRTrie() *cidrTrie { + return &cidrTrie{} +} + +// insert adds prefix to the trie. +func (t *cidrTrie) insert(prefix netip.Prefix) { + addr := prefix.Addr() + bits := prefix.Bits() + if bits == 0 { + // Match everything for this address family. + root := t.root(addr) + if root == nil { + root = &cidrNode{hasEntry: true} + t.setRoot(addr, root) + } else { + root.hasEntry = true + } + return + } + + root := t.rootOrCreate(addr) + b := addr.As16() + maxBits := 128 + if addr.Is4() || addr.Is4In6() { + maxBits = 32 + } + if bits > maxBits { + bits = maxBits + } + + node := root + for i := 0; i < bits; i++ { + bit := bitAt(b, i, addr.Is4() || addr.Is4In6()) + if node.children[bit] == nil { + node.children[bit] = &cidrNode{} + } + node = node.children[bit] + } + node.hasEntry = true +} + +// contains reports whether any inserted prefix covers addr. +func (t *cidrTrie) contains(addr netip.Addr) bool { + root := t.root(addr) + if root == nil { + return false + } + if root.hasEntry { + return true + } + + b := addr.As16() + is4 := addr.Is4() || addr.Is4In6() + maxBits := 128 + if is4 { + maxBits = 32 + } + + node := root + for i := 0; i < maxBits; i++ { + bit := bitAt(b, i, is4) + child := node.children[bit] + if child == nil { + return false + } + if child.hasEntry { + return true + } + node = child + } + return false +} + +func (t *cidrTrie) root(addr netip.Addr) *cidrNode { + if addr.Is4() || addr.Is4In6() { + return t.root4 + } + return t.root6 +} + +func (t *cidrTrie) setRoot(addr netip.Addr, n *cidrNode) { + if addr.Is4() || addr.Is4In6() { + t.root4 = n + } else { + t.root6 = n + } +} + +func (t *cidrTrie) rootOrCreate(addr netip.Addr) *cidrNode { + if addr.Is4() || addr.Is4In6() { + if t.root4 == nil { + t.root4 = &cidrNode{} + } + return t.root4 + } + if t.root6 == nil { + t.root6 = &cidrNode{} + } + return t.root6 +} + +// bitAt returns the i-th bit (MSB-first) from a 16-byte address representation. +// For IPv4, bits start at byte index 12 (the last 4 bytes of the 16-byte form). +func bitAt(b [16]byte, i int, is4 bool) uint8 { + offset := 0 + if is4 { + offset = 12 + } + byteIdx := offset + i/8 + bitIdx := 7 - i%8 + return (b[byteIdx] >> bitIdx) & 1 +} + +// ipNetToPrefix converts a net.IPNet to a netip.Prefix. +// Returns ok=false if conversion fails. +func ipNetToPrefix(inet *net.IPNet) (netip.Prefix, bool) { + addr, ok := netip.AddrFromSlice(inet.IP) + if !ok { + return netip.Prefix{}, false + } + addr = addr.Unmap() + ones, _ := inet.Mask.Size() + bits := addr.BitLen() + if ones > bits { + ones = bits + } + return netip.PrefixFrom(addr, ones), true +} diff --git a/internal/matcher/matcher.go b/internal/matcher/matcher.go index 32ac409c..bbebfb59 100644 --- a/internal/matcher/matcher.go +++ b/internal/matcher/matcher.go @@ -10,7 +10,6 @@ import ( xnet "github.com/go-gost/x/internal/net" "github.com/gobwas/glob" - "github.com/yl2chen/cidranger" ) // Matcher is a generic pattern matcher that tests whether a given value @@ -19,17 +18,35 @@ type Matcher interface { Match(v string) bool } +// splitHostPort splits addr into host and port without allocating when +// addr contains no colon (i.e. no port). For inputs with a colon, +// net.SplitHostPort is used as normal. +func splitHostPort(addr string) (host, port string) { + if !strings.Contains(addr, ":") { + return addr, "" + } + host, port, _ = net.SplitHostPort(addr) + if host == "" { + host = addr + } + return +} + type ipMatcher struct { - ips map[string]struct{} + ips map[netip.Addr]struct{} } // IPMatcher creates a Matcher for a list of IP addresses. func IPMatcher(ips []net.IP) Matcher { matcher := &ipMatcher{ - ips: make(map[string]struct{}), + ips: make(map[netip.Addr]struct{}), } for _, ip := range ips { - matcher.ips[ip.String()] = struct{}{} + addr, ok := netip.AddrFromSlice(ip) + if !ok { + continue + } + matcher.ips[addr.Unmap()] = struct{}{} } return matcher } @@ -42,7 +59,7 @@ func (m *ipMatcher) Match(ip string) bool { if err != nil { return false } - _, ok := m.ips[addr.String()] + _, ok := m.ips[addr.Unmap()] return ok } @@ -61,7 +78,7 @@ func AddrMatcher(addrs []string) Matcher { addrs: make(map[string][]*xnet.PortRange), } for _, addr := range addrs { - host, port, _ := net.SplitHostPort(addr) + host, port := splitHostPort(addr) if host == "" { host = strings.ToLower(addr) matcher.addrs[host] = append(matcher.addrs[host], nil) @@ -81,10 +98,7 @@ func (m *addrMatcher) Match(addr string) bool { if m == nil || len(m.addrs) == 0 { return false } - host, sp, _ := net.SplitHostPort(addr) - if host == "" { - host = addr - } + host, sp := splitHostPort(addr) host = strings.ToLower(host) port, _ := strconv.Atoi(sp) @@ -128,27 +142,29 @@ func portRangeMatch(prs []*xnet.PortRange, port int) bool { } type cidrMatcher struct { - ranger cidranger.Ranger + trie *cidrTrie } // CIDRMatcher creates a Matcher for a list of CIDR notation IP addresses. func CIDRMatcher(inets []*net.IPNet) Matcher { - ranger := cidranger.NewPCTrieRanger() + trie := newCIDRTrie() for _, inet := range inets { - ranger.Insert(cidranger.NewBasicRangerEntry(*inet)) + if prefix, ok := ipNetToPrefix(inet); ok { + trie.insert(prefix) + } } - return &cidrMatcher{ranger: ranger} + return &cidrMatcher{trie: trie} } func (m *cidrMatcher) Match(ip string) bool { - if m == nil || m.ranger == nil { + if m == nil || m.trie == nil { return false } - if netIP := net.ParseIP(ip); netIP != nil { - b, _ := m.ranger.Contains(netIP) - return b + addr, err := netip.ParseAddr(ip) + if err != nil { + return false } - return false + return m.trie.contains(addr.Unmap()) } type domainMatcher struct { @@ -212,7 +228,7 @@ type wildcardMatcher struct { func WildcardMatcher(patterns []string) Matcher { matcher := &wildcardMatcher{} for _, pattern := range patterns { - host, port, _ := net.SplitHostPort(pattern) + host, port := splitHostPort(pattern) if host == "" { host = pattern } @@ -239,10 +255,7 @@ func (m *wildcardMatcher) Match(addr string) bool { return false } - host, sp, _ := net.SplitHostPort(addr) - if host == "" { - host = addr - } + host, sp := splitHostPort(addr) host = strings.ToLower(host) port, _ := strconv.Atoi(sp) for _, pattern := range m.patterns { @@ -273,10 +286,7 @@ func (m *ipRangeMatcher) Match(addr string) bool { return false } - host, _, _ := net.SplitHostPort(addr) - if host == "" { - host = addr - } + host, _ := splitHostPort(addr) adr, err := netip.ParseAddr(host) if err != nil { return false diff --git a/internal/matcher/matcher_bench_test.go b/internal/matcher/matcher_bench_test.go new file mode 100644 index 00000000..5239bef1 --- /dev/null +++ b/internal/matcher/matcher_bench_test.go @@ -0,0 +1,329 @@ +package matcher + +import ( + "net" + "strconv" + "testing" + + xnet "github.com/go-gost/x/internal/net" +) + +// --------------------------------------------------------------------------- +// IPMatcher +// --------------------------------------------------------------------------- + +func BenchmarkIPMatcher(b *testing.B) { + ips := []net.IP{ + net.ParseIP("192.168.1.1"), + net.ParseIP("10.0.0.1"), + net.ParseIP("::1"), + net.ParseIP("2001:db8::1"), + } + m := IPMatcher(ips) + + b.Run("match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("192.168.1.1") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("172.16.0.1") + } + }) + b.Run("invalid", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("not-an-ip") + } + }) +} + +func BenchmarkIPMatcher_Nil(b *testing.B) { + var m *ipMatcher + for i := 0; i < b.N; i++ { + m.Match("192.168.1.1") + } +} + +// --------------------------------------------------------------------------- +// AddrMatcher +// --------------------------------------------------------------------------- + +var benchAddrMatcher = AddrMatcher([]string{ + "example.com:80", + "example.com:443", + ".example.com", + "192.168.1.1:8080", + "10.0.0.1", + ".sub.test.com:0-65535", +}) + +func BenchmarkAddrMatcher(b *testing.B) { + b.Run("exact_host_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("example.com:80") + } + }) + b.Run("dot_prefix_subdomain", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("api.example.com:443") + } + }) + b.Run("dot_prefix_exact", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("example.com:443") + } + }) + b.Run("ip_with_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("192.168.1.1:8080") + } + }) + b.Run("host_only", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("10.0.0.1") + } + }) + b.Run("deep_subdomain", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("a.b.c.sub.test.com:1234") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchAddrMatcher.Match("other.com:80") + } + }) +} + +// --------------------------------------------------------------------------- +// CIDRMatcher +// --------------------------------------------------------------------------- + +var benchCIDRMatcher = mustCIDRMatcher([]*net.IPNet{ + mustParseCIDR("192.168.0.0/16"), + mustParseCIDR("10.0.0.0/8"), + mustParseCIDR("2001:db8::/32"), +}) + +func BenchmarkCIDRMatcher(b *testing.B) { + b.Run("match_v4", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchCIDRMatcher.Match("192.168.100.50") + } + }) + b.Run("match_v6", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchCIDRMatcher.Match("2001:db8::42") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchCIDRMatcher.Match("8.8.8.8") + } + }) + b.Run("invalid", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchCIDRMatcher.Match("not-an-ip") + } + }) +} + +// --------------------------------------------------------------------------- +// DomainMatcher +// --------------------------------------------------------------------------- + +var benchDomainMatcher = DomainMatcher([]string{ + "example.com", + ".test.org", + "sub.domain.io", + ".deep.sub.example.net", +}) + +func BenchmarkDomainMatcher(b *testing.B) { + b.Run("exact", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchDomainMatcher.Match("example.com") + } + }) + b.Run("dot_prefix_subdomain", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchDomainMatcher.Match("api.test.org") + } + }) + b.Run("dot_prefix_exact", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchDomainMatcher.Match("test.org") + } + }) + b.Run("deep_walk", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchDomainMatcher.Match("a.b.c.deep.sub.example.net") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchDomainMatcher.Match("other.com") + } + }) +} + +// --------------------------------------------------------------------------- +// WildcardMatcher +// --------------------------------------------------------------------------- + +var benchWildcardMatcher = WildcardMatcher([]string{ + "*.example.com", + "*.example.com:80", + "*.test.org:0-65535", + "mail.*.company.net", +}) + +func BenchmarkWildcardMatcher(b *testing.B) { + b.Run("match_any_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchWildcardMatcher.Match("api.example.com:443") + } + }) + b.Run("match_specific_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchWildcardMatcher.Match("api.example.com:80") + } + }) + b.Run("match_range", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchWildcardMatcher.Match("sub.test.org:8080") + } + }) + b.Run("mid_wildcard", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchWildcardMatcher.Match("mail.eu.company.net:25") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchWildcardMatcher.Match("other.com:80") + } + }) + b.Run("no_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchWildcardMatcher.Match("api.example.com") + } + }) +} + +// --------------------------------------------------------------------------- +// IPRangeMatcher +// --------------------------------------------------------------------------- + +var benchIPRangeMatcher = IPRangeMatcher([]xnet.IPRange{ + mustIPRange("192.168.1.0", "192.168.1.255"), + mustIPRange("10.0.0.0", "10.255.255.255"), +}) + +func BenchmarkIPRangeMatcher(b *testing.B) { + b.Run("match_with_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchIPRangeMatcher.Match("192.168.1.100:8080") + } + }) + b.Run("match_no_port", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchIPRangeMatcher.Match("10.50.0.1") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchIPRangeMatcher.Match("172.16.0.1:80") + } + }) + b.Run("invalid", func(b *testing.B) { + for i := 0; i < b.N; i++ { + benchIPRangeMatcher.Match("not-an-ip:80") + } + }) +} + +// --------------------------------------------------------------------------- +// NopMatcher +// --------------------------------------------------------------------------- + +func BenchmarkNopMatcher(b *testing.B) { + m := NopMatcher() + for i := 0; i < b.N; i++ { + m.Match("anything") + } +} + +// --------------------------------------------------------------------------- +// Large-scale AddrMatcher (many patterns) +// --------------------------------------------------------------------------- + +func BenchmarkAddrMatcher_Large(b *testing.B) { + addrs := make([]string, 500) + for i := range addrs { + addrs[i] = net.JoinHostPort( + "host"+string(rune('a'+i%26))+".example.com", + strconv.Itoa(i%65536), + ) + } + m := AddrMatcher(addrs) + + b.Run("first_match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("host0.example.com:0") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("unknown.host:80") + } + }) +} + +// --------------------------------------------------------------------------- +// Large-scale DomainMatcher (many patterns) +// --------------------------------------------------------------------------- + +func BenchmarkDomainMatcher_Large(b *testing.B) { + domains := make([]string, 1000) + for i := range domains { + domains[i] = "domain" + string(rune('a'+i%26)) + ".example.com" + } + m := DomainMatcher(domains) + + b.Run("match", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("domaina.example.com") + } + }) + b.Run("miss", func(b *testing.B) { + for i := 0; i < b.N; i++ { + m.Match("missing.example.com") + } + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func mustParseCIDR(s string) *net.IPNet { + _, ipNet, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return ipNet +} + +func mustCIDRMatcher(inets []*net.IPNet) Matcher { + return CIDRMatcher(inets) +} + +func mustIPRange(minStr, maxStr string) xnet.IPRange { + r := xnet.IPRange{} + if err := r.Parse(minStr + "-" + maxStr); err != nil { + panic(err) + } + return r +}