From c341aea5d0eacc3ca7766fbeab62effc5c299ec1 Mon Sep 17 00:00:00 2001 From: Toby Date: Wed, 22 Nov 2023 20:21:08 -0800 Subject: [PATCH] feat: domain suffix match --- extras/outbounds/acl/compile.go | 19 ++++- extras/outbounds/acl/compile_test.go | 36 +++++++- extras/outbounds/acl/matchers.go | 21 ++++- extras/outbounds/acl/matchers_test.go | 116 ++++++++++++++++++++------ 4 files changed, 157 insertions(+), 35 deletions(-) diff --git a/extras/outbounds/acl/compile.go b/extras/outbounds/acl/compile.go index 3fe02e6..40d0069 100644 --- a/extras/outbounds/acl/compile.go +++ b/extras/outbounds/acl/compile.go @@ -236,6 +236,17 @@ func compileHostMatcher(addr string, geoLoader GeoLoader) (hostMatcher, string) } return m, "" } + if strings.HasPrefix(addr, "suffix:") { + // Domain suffix matcher + suffix := addr[7:] + if len(suffix) == 0 { + return nil, "empty domain suffix" + } + return &domainMatcher{ + Pattern: suffix, + Mode: domainMatchSuffix, + }, "" + } if strings.Contains(addr, "/") { // CIDR matcher _, ipnet, err := net.ParseCIDR(addr) @@ -251,14 +262,14 @@ func compileHostMatcher(addr string, geoLoader GeoLoader) (hostMatcher, string) if strings.Contains(addr, "*") { // Wildcard domain matcher return &domainMatcher{ - Pattern: addr, - Wildcard: true, + Pattern: addr, + Mode: domainMatchWildcard, }, "" } // Nothing else matched, treat it as a non-wildcard domain return &domainMatcher{ - Pattern: addr, - Wildcard: false, + Pattern: addr, + Mode: domainMatchExact, }, "" } diff --git a/extras/outbounds/acl/compile_test.go b/extras/outbounds/acl/compile_test.go index 58c10a9..772f8b6 100644 --- a/extras/outbounds/acl/compile_test.go +++ b/extras/outbounds/acl/compile_test.go @@ -22,7 +22,7 @@ func (l *testGeoLoader) LoadGeoSite() (map[string]*v2geo.GeoSite, error) { } func TestCompile(t *testing.T) { - ob1, ob2, ob3, ob4 := 1, 2, 3, 4 + ob1, ob2, ob3, ob4, ob5 := 1, 2, 3, 4, 5 rules := []TextRule{ { Outbound: "ob1", @@ -84,12 +84,19 @@ func TestCompile(t *testing.T) { ProtoPort: "*/*", HijackAddress: "", }, + { + Outbound: "ob5", + Address: "suffix:microsoft.com", + ProtoPort: "*/*", + HijackAddress: "", + }, } comp, err := Compile[int](rules, map[string]int{ "ob1": ob1, "ob2": ob2, "ob3": ob3, "ob4": ob4, + "ob5": ob5, }, 100, &testGeoLoader{}) assert.NoError(t, err) @@ -208,6 +215,33 @@ func TestCompile(t *testing.T) { wantOutbound: 0, // no match default wantIP: nil, }, + { + host: HostInfo{ + Name: "microsoft.com", + }, + proto: ProtocolTCP, + port: 6000, + wantOutbound: ob5, + wantIP: nil, + }, + { + host: HostInfo{ + Name: "real.microsoft.com", + }, + proto: ProtocolUDP, + port: 5353, + wantOutbound: ob5, + wantIP: nil, + }, + { + host: HostInfo{ + Name: "fakemicrosoft.com", + }, + proto: ProtocolTCP, + port: 5000, + wantOutbound: 0, // no match default + wantIP: nil, + }, } for _, test := range tests { diff --git a/extras/outbounds/acl/matchers.go b/extras/outbounds/acl/matchers.go index fe40297..a24a56b 100644 --- a/extras/outbounds/acl/matchers.go +++ b/extras/outbounds/acl/matchers.go @@ -2,10 +2,17 @@ package acl import ( "net" + "strings" "golang.org/x/net/idna" ) +const ( + domainMatchExact = uint8(iota) + domainMatchWildcard + domainMatchSuffix +) + type hostMatcher interface { Match(HostInfo) bool } @@ -27,8 +34,8 @@ func (m *cidrMatcher) Match(host HostInfo) bool { } type domainMatcher struct { - Pattern string - Wildcard bool + Pattern string + Mode uint8 } func (m *domainMatcher) Match(host HostInfo) bool { @@ -36,10 +43,16 @@ func (m *domainMatcher) Match(host HostInfo) bool { if err != nil { name = host.Name } - if m.Wildcard { + switch m.Mode { + case domainMatchExact: + return name == m.Pattern + case domainMatchWildcard: return deepMatchRune([]rune(name), []rune(m.Pattern)) + case domainMatchSuffix: + return name == m.Pattern || strings.HasSuffix(name, "."+m.Pattern) + default: + return false // Invalid mode } - return name == m.Pattern } func deepMatchRune(str, pattern []rune) bool { diff --git a/extras/outbounds/acl/matchers_test.go b/extras/outbounds/acl/matchers_test.go index 0e7f39c..4da4249 100644 --- a/extras/outbounds/acl/matchers_test.go +++ b/extras/outbounds/acl/matchers_test.go @@ -142,8 +142,8 @@ func Test_cidrMatcher_Match(t *testing.T) { func Test_domainMatcher_Match(t *testing.T) { type fields struct { - Pattern string - Wildcard bool + Pattern string + Mode uint8 } tests := []struct { name string @@ -154,8 +154,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "non-wildcard match", fields: fields{ - Pattern: "example.com", - Wildcard: false, + Pattern: "example.com", + Mode: domainMatchExact, }, host: HostInfo{ Name: "example.com", @@ -165,8 +165,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "non-wildcard IDN match", fields: fields{ - Pattern: "政府.中国", - Wildcard: false, + Pattern: "政府.中国", + Mode: domainMatchExact, }, host: HostInfo{ Name: "xn--mxtq1m.xn--fiqs8s", @@ -176,8 +176,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "non-wildcard no match", fields: fields{ - Pattern: "example.com", - Wildcard: false, + Pattern: "example.com", + Mode: domainMatchExact, }, host: HostInfo{ Name: "example.org", @@ -187,8 +187,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "non-wildcard IDN no match", fields: fields{ - Pattern: "政府.中国", - Wildcard: false, + Pattern: "政府.中国", + Mode: domainMatchExact, }, host: HostInfo{ Name: "xn--mxtq1m.xn--yfro4i67o", @@ -198,8 +198,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "wildcard match 1", fields: fields{ - Pattern: "*.example.com", - Wildcard: true, + Pattern: "*.example.com", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "www.example.com", @@ -209,8 +209,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "wildcard match 2", fields: fields{ - Pattern: "example*.com", - Wildcard: true, + Pattern: "example*.com", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "example2.com", @@ -220,8 +220,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "wildcard IDN match 1", fields: fields{ - Pattern: "战狼*.com", - Wildcard: true, + Pattern: "战狼*.com", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "xn--2-x14by21c.com", @@ -231,8 +231,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "wildcard IDN match 2", fields: fields{ - Pattern: "*大学*", - Wildcard: true, + Pattern: "*大学*", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "xn--xkry9kk1bz66a.xn--ses554g", @@ -242,8 +242,8 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "wildcard no match", fields: fields{ - Pattern: "*.example.com", - Wildcard: true, + Pattern: "*.example.com", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "example.com", @@ -253,19 +253,83 @@ func Test_domainMatcher_Match(t *testing.T) { { name: "wildcard IDN no match", fields: fields{ - Pattern: "*呵呵*", - Wildcard: true, + Pattern: "*呵呵*", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "xn--6qqt7juua.cn", }, want: false, }, + { + name: "suffix match 1", + fields: fields{ + Pattern: "apple.com", + Mode: domainMatchSuffix, + }, + host: HostInfo{ + Name: "apple.com", + }, + want: true, + }, + { + name: "suffix match 2", + fields: fields{ + Pattern: "apple.com", + Mode: domainMatchSuffix, + }, + host: HostInfo{ + Name: "store.apple.com", + }, + want: true, + }, + { + name: "suffix IDN match 1", + fields: fields{ + Pattern: "中国", + Mode: domainMatchSuffix, + }, + host: HostInfo{ + Name: "中国", + }, + want: true, + }, + { + name: "suffix IDN match 2", + fields: fields{ + Pattern: "中国", + Mode: domainMatchSuffix, + }, + host: HostInfo{ + Name: "天安门.中国", + }, + want: true, + }, + { + name: "suffix no match", + fields: fields{ + Pattern: "news.com", + }, + host: HostInfo{ + Name: "fakenews.com", + }, + want: false, + }, + { + name: "suffix IDN no match", + fields: fields{ + Pattern: "冲浪", + }, + host: HostInfo{ + Name: "666.网上冲浪", + }, + want: false, + }, { name: "empty", fields: fields{ - Pattern: "*.example.com", - Wildcard: true, + Pattern: "*.example.com", + Mode: domainMatchWildcard, }, host: HostInfo{ Name: "", @@ -276,8 +340,8 @@ func Test_domainMatcher_Match(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := &domainMatcher{ - Pattern: tt.fields.Pattern, - Wildcard: tt.fields.Wildcard, + Pattern: tt.fields.Pattern, + Mode: tt.fields.Mode, } if got := m.Match(tt.host); got != tt.want { t.Errorf("Match() = %v, want %v", got, tt.want)