diff --git a/app/dns/dns.go b/app/dns/dns.go index ec350e84..8b52a6a5 100644 --- a/app/dns/dns.go +++ b/app/dns/dns.go @@ -158,9 +158,12 @@ func New(ctx context.Context, config *Config) (*DNS, error) { clients = append(clients, client) } - domainMatcher, err := geodata.DomainReg.BuildDomainMatcher(effectiveRules) - if err != nil { - return nil, err + var domainMatcher geodata.DomainMatcher + if len(effectiveRules) > 0 { + domainMatcher, err = geodata.DomainReg.BuildDomainMatcher(effectiveRules) + if err != nil { + return nil, err + } } // If there is no DNS client in config, add a `localhost` DNS client @@ -271,25 +274,27 @@ func (s *DNS) sortClients(domain string) []*Client { // Priority domain matching hasMatch := false - matchSlice := s.domainMatcher.Match(strings.ToLower(domain)) - sort.Slice(matchSlice, func(i, j int) bool { - return matchSlice[i] < matchSlice[j] - }) - for _, match := range matchSlice { - info := s.matcherInfos[match] - client := s.clients[info.clientIdx] - domainRule := info.domainRule - domainRules = append(domainRules, fmt.Sprintf("%s(DNS idx:%d)", domainRule, info.clientIdx)) - if clientUsed[info.clientIdx] { - continue - } - clientUsed[info.clientIdx] = true - clients = append(clients, client) - clientNames = append(clientNames, client.Name()) - hasMatch = true - if client.finalQuery { - logDecision(s.ctx, domain, domainRules, clientNames) - return clients + if s.domainMatcher != nil { + matchSlice := s.domainMatcher.Match(strings.ToLower(domain)) + sort.Slice(matchSlice, func(i, j int) bool { + return matchSlice[i] < matchSlice[j] + }) + for _, match := range matchSlice { + info := s.matcherInfos[match] + client := s.clients[info.clientIdx] + domainRule := info.domainRule + domainRules = append(domainRules, fmt.Sprintf("%s(DNS idx:%d)", domainRule, info.clientIdx)) + if clientUsed[info.clientIdx] { + continue + } + clientUsed[info.clientIdx] = true + clients = append(clients, client) + clientNames = append(clientNames, client.Name()) + hasMatch = true + if client.finalQuery { + logDecision(s.ctx, domain, domainRules, clientNames) + return clients + } } } diff --git a/app/dns/hosts.go b/app/dns/hosts.go index c3546967..a17d1215 100644 --- a/app/dns/hosts.go +++ b/app/dns/hosts.go @@ -13,8 +13,8 @@ import ( // StaticHosts represents static domain-ip mapping in DNS server. type StaticHosts struct { - reps [][]net.Address - matcher geodata.DomainMatcher + responses [][]net.Address + matcher geodata.DomainMatcher } // NewStaticHosts creates a new StaticHosts instance. @@ -45,21 +45,21 @@ func NewStaticHosts(hosts []*Config_HostMapping) (*StaticHosts, error) { rep = append(rep, addr) } } - // if len(rep) == 0 { - // errors.LogError(context.Background(), "empty value in static hosts, ignore this rule: ", mapping.Domain) - // continue - // } reps = append(reps, rep) rules = append(rules, mapping.Domain) } + if len(rules) == 0 { + return &StaticHosts{}, nil + } + matcher, err := geodata.DomainReg.BuildDomainMatcher(rules) if err != nil { return nil, err } return &StaticHosts{ - reps: reps, - matcher: matcher, + responses: reps, + matcher: matcher, }, nil } @@ -76,8 +76,8 @@ func filterIP(ips []net.Address, option dns.IPOption) []net.Address { func (h *StaticHosts) lookupInternal(domain string) ([]net.Address, error) { ips := make([]net.Address, 0) found := false - for _, ruleIdx := range h.matcher.Match(domain) { - for _, rep := range h.reps[ruleIdx] { + for _, idx := range h.matcher.Match(domain) { + for _, rep := range h.responses[idx] { if err, ok := rep.(dns.RCodeError); ok { if uint16(err) == 0 { return nil, dns.ErrEmptyResponse @@ -85,7 +85,7 @@ func (h *StaticHosts) lookupInternal(domain string) ([]net.Address, error) { return nil, err } } - ips = append(ips, h.reps[ruleIdx]...) + ips = append(ips, h.responses[idx]...) found = true } if !found { @@ -122,5 +122,8 @@ func (h *StaticHosts) lookup(domain string, option dns.IPOption, maxDepth int) ( // Lookup returns IP addresses or proxied domain for the given domain, if exists in this StaticHosts. func (h *StaticHosts) Lookup(domain string, option dns.IPOption) ([]net.Address, error) { + if h.matcher == nil { + return nil, nil + } return h.lookup(domain, option, 5) } diff --git a/common/geodata/domain_matcher.go b/common/geodata/domain_matcher.go index e5e854f9..be9e62bf 100644 --- a/common/geodata/domain_matcher.go +++ b/common/geodata/domain_matcher.go @@ -23,10 +23,54 @@ type DomainMatcherFactory interface { BuildMatcher(rules []*DomainRule) (DomainMatcher, error) } -type MphDomainMatcherFactory struct{} +type MphDomainMatcherFactory struct { + sync.Mutex + shared map[string]strmatcher.MatcherGroup // TODO: cleanup +} + +func buildDomainRulesKey(rules []*DomainRule) string { + var sb strings.Builder + cache := false + for _, r := range rules { + switch v := r.Value.(type) { + case *DomainRule_Custom: + sb.WriteString(v.Custom.Type.String()) + sb.WriteString(":") + sb.WriteString(v.Custom.Value) + sb.WriteString(",") + case *DomainRule_Geosite: + cache = true + sb.WriteString(v.Geosite.File) + sb.WriteString(":") + sb.WriteString(v.Geosite.Code) + sb.WriteString("@") + sb.WriteString(v.Geosite.Attrs) + sb.WriteString(",") + default: + panic("unknown domain rule type") + } + } + if !cache { + return "" + } + return sb.String() +} // BuildMatcher implements DomainMatcherFactory. func (f *MphDomainMatcherFactory) BuildMatcher(rules []*DomainRule) (DomainMatcher, error) { + if len(rules) == 0 { + return nil, errors.New("empty domain rule list") + } + key := buildDomainRulesKey(rules) + if key != "" { + f.Lock() + defer f.Unlock() + if g := f.shared[key]; g != nil { + errors.LogDebug(context.Background(), "geodata mph domain matcher cache HIT for ", len(rules), " rules") + return g, nil + } + errors.LogDebug(context.Background(), "geodata mph domain matcher cache MISS for ", len(rules), " rules") + } g := strmatcher.NewMphValueMatcher() for i, r := range rules { switch v := r.Value.(type) { @@ -57,25 +101,30 @@ func (f *MphDomainMatcherFactory) BuildMatcher(rules []*DomainRule) (DomainMatch if err := g.Build(); err != nil { return nil, err } + if key != "" { + f.shared[key] = g + } return g, nil } type CompactDomainMatcherFactory struct { sync.Mutex - shared map[string]strmatcher.MatcherGroup // TODO: cleanup + shared map[string]strmatcher.MatcherSet // TODO: cleanup } -func (f *CompactDomainMatcherFactory) getOrCreateFrom(rule *GeoSiteRule) (strmatcher.MatcherGroup, error) { +func (f *CompactDomainMatcherFactory) getOrCreateFrom(rule *GeoSiteRule) (strmatcher.MatcherSet, error) { key := rule.File + ":" + rule.Code + "@" + rule.Attrs f.Lock() defer f.Unlock() - if m := f.shared[key]; m != nil { - return m, nil + if s := f.shared[key]; s != nil { + errors.LogDebug(context.Background(), "geodata geosite matcher cache HIT ", key) + return s, nil } + errors.LogDebug(context.Background(), "geodata geosite matcher cache MISS ", key) - g := strmatcher.NewLinearValueMatcher() + s := strmatcher.NewLinearAnyMatcher() domains, err := loadSiteWithAttrs(rule.File, rule.Code, rule.Attrs) if err != nil { return nil, err @@ -87,16 +136,19 @@ func (f *CompactDomainMatcherFactory) getOrCreateFrom(rule *GeoSiteRule) (strmat errors.LogError(context.Background(), "ignore invalid geosite entry in ", rule.File, ":", rule.Code, " at index ", i, ", ", err) continue } - g.Add(m, 0) + s.Add(m) } - f.shared[key] = g - return g, err + f.shared[key] = s + return s, err } // BuildMatcher implements DomainMatcherFactory. func (f *CompactDomainMatcherFactory) BuildMatcher(rules []*DomainRule) (DomainMatcher, error) { + if len(rules) == 0 { + return nil, errors.New("empty domain rule list") + } compact := &CompactDomainMatcher{ - matchers: make([]strmatcher.MatcherGroup, 0, len(rules)), + matchers: make([]strmatcher.MatcherSet, 0, len(rules)), values: make([]uint32, 0, len(rules)), } for i, r := range rules { @@ -126,7 +178,7 @@ func (f *CompactDomainMatcherFactory) BuildMatcher(rules []*DomainRule) (DomainM type CompactDomainMatcher struct { custom strmatcher.ValueMatcher - matchers []strmatcher.MatcherGroup + matchers []strmatcher.MatcherSet values []uint32 } @@ -178,8 +230,8 @@ func parseDomain(d *Domain) (strmatcher.Matcher, error) { func newDomainMatcherFactory() DomainMatcherFactory { switch runtime.GOOS { case "ios", "android": - return &CompactDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherGroup)} + return &CompactDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherSet)} default: - return &MphDomainMatcherFactory{} + return &MphDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherGroup)} } } diff --git a/common/geodata/domain_matcher_test.go b/common/geodata/domain_matcher_test.go index 0506df09..83ae60ca 100644 --- a/common/geodata/domain_matcher_test.go +++ b/common/geodata/domain_matcher_test.go @@ -10,7 +10,7 @@ import ( ) func TestCompactDomainMatcher_PreservesCustomRuleIndices(t *testing.T) { - factory := &CompactDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherGroup)} + factory := &CompactDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherSet)} matcher, err := factory.BuildMatcher([]*DomainRule{ {Value: &DomainRule_Custom{Custom: &Domain{Type: Domain_Full, Value: "example.com"}}}, {Value: &DomainRule_Custom{Custom: &Domain{Type: Domain_Domain, Value: "example.com"}}}, @@ -31,7 +31,7 @@ func TestCompactDomainMatcher_PreservesCustomRuleIndices(t *testing.T) { func TestCompactDomainMatcher_PreservesMixedRuleIndices(t *testing.T) { t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources")) - factory := &CompactDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherGroup)} + factory := &CompactDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherSet)} matcher, err := factory.BuildMatcher([]*DomainRule{ {Value: &DomainRule_Geosite{Geosite: &GeoSiteRule{File: DefaultGeoSiteDat, Code: "CN"}}}, {Value: &DomainRule_Custom{Custom: &Domain{Type: Domain_Full, Value: "163.com"}}}, @@ -50,7 +50,7 @@ func TestCompactDomainMatcher_PreservesMixedRuleIndices(t *testing.T) { } func TestMphDomainMatcher_MatchReturnsDetachedSlice(t *testing.T) { - matcher, err := (&MphDomainMatcherFactory{}).BuildMatcher([]*DomainRule{ + matcher, err := (&MphDomainMatcherFactory{shared: make(map[string]strmatcher.MatcherGroup)}).BuildMatcher([]*DomainRule{ {Value: &DomainRule_Custom{Custom: &Domain{Type: Domain_Full, Value: "example.com"}}}, {Value: &DomainRule_Custom{Custom: &Domain{Type: Domain_Domain, Value: "example.com"}}}, }) diff --git a/common/geodata/ip_matcher.go b/common/geodata/ip_matcher.go index abacf109..165f904e 100644 --- a/common/geodata/ip_matcher.go +++ b/common/geodata/ip_matcher.go @@ -816,8 +816,10 @@ func (f *IPSetFactory) GetOrCreateFromGeoIPRules(rules []*GeoIPRule) (*IPSet, er defer f.Unlock() if ipset := f.shared[key]; ipset != nil { + errors.LogDebug(context.Background(), "geodata geoip matcher cache HIT ", key) return ipset, nil } + errors.LogDebug(context.Background(), "geodata geoip matcher cache MISS ", key) ipset, err := f.createFrom(func(add func(*CIDR)) error { for _, r := range rules { diff --git a/common/geodata/strmatcher/anymatcher_linear.go b/common/geodata/strmatcher/anymatcher_linear.go new file mode 100644 index 00000000..0f1e6bef --- /dev/null +++ b/common/geodata/strmatcher/anymatcher_linear.go @@ -0,0 +1,53 @@ +package strmatcher + +// LinearAnyMatcher is an implementation of AnyMatcher. +type LinearAnyMatcher struct { + full *FullMatcherSet + domain *DomainMatcherSet + substr *SubstrMatcherSet + regex *SimpleMatcherSet +} + +func NewLinearAnyMatcher() *LinearAnyMatcher { + return new(LinearAnyMatcher) +} + +// Add implements AnyMatcher.Add. +func (s *LinearAnyMatcher) Add(matcher Matcher) { + switch matcher := matcher.(type) { + case FullMatcher: + if s.full == nil { + s.full = NewFullMatcherSet() + } + s.full.AddFullMatcher(matcher) + case DomainMatcher: + if s.domain == nil { + s.domain = NewDomainMatcherSet() + } + s.domain.AddDomainMatcher(matcher) + case SubstrMatcher: + if s.substr == nil { + s.substr = new(SubstrMatcherSet) + } + s.substr.AddSubstrMatcher(matcher) + default: + if s.regex == nil { + s.regex = new(SimpleMatcherSet) + } + s.regex.AddMatcher(matcher) + } +} + +// MatchAny implements AnyMatcher.MatchAny. +func (s *LinearAnyMatcher) MatchAny(input string) bool { + if s.full != nil && s.full.MatchAny(input) { + return true + } + if s.domain != nil && s.domain.MatchAny(input) { + return true + } + if s.substr != nil && s.substr.MatchAny(input) { + return true + } + return s.regex != nil && s.regex.MatchAny(input) +} diff --git a/common/geodata/strmatcher/matchers.go b/common/geodata/strmatcher/matchers.go index fa288804..74360771 100644 --- a/common/geodata/strmatcher/matchers.go +++ b/common/geodata/strmatcher/matchers.go @@ -288,3 +288,65 @@ func CompositeMatchesReverse(matches [][]uint32) []uint32 { return result } } + +// MatcherSetForAll is an interface indicating a MatcherSet could accept all types of matchers. +type MatcherSetForAll interface { + AddMatcher(matcher Matcher) +} + +// MatcherSetForFull is an interface indicating a MatcherSet could accept FullMatchers. +type MatcherSetForFull interface { + AddFullMatcher(matcher FullMatcher) +} + +// MatcherSetForDomain is an interface indicating a MatcherSet could accept DomainMatchers. +type MatcherSetForDomain interface { + AddDomainMatcher(matcher DomainMatcher) +} + +// MatcherSetForSubstr is an interface indicating a MatcherSet could accept SubstrMatchers. +type MatcherSetForSubstr interface { + AddSubstrMatcher(matcher SubstrMatcher) +} + +// MatcherSetForRegex is an interface indicating a MatcherSet could accept RegexMatchers. +type MatcherSetForRegex interface { + AddRegexMatcher(matcher *RegexMatcher) +} + +// AddMatcherToSet is a helper function to try to add a Matcher to any kind of MatcherSet. +// It returns error if the MatcherSet does not accept the provided Matcher's type. +// This function is provided to help writing code to test a MatcherSet. +func AddMatcherToSet(s MatcherSet, matcher Matcher) error { + if s, ok := s.(IndexMatcher); ok { + s.Add(matcher) + return nil + } + if s, ok := s.(MatcherSetForAll); ok { + s.AddMatcher(matcher) + return nil + } + switch matcher := matcher.(type) { + case FullMatcher: + if s, ok := s.(MatcherSetForFull); ok { + s.AddFullMatcher(matcher) + return nil + } + case DomainMatcher: + if s, ok := s.(MatcherSetForDomain); ok { + s.AddDomainMatcher(matcher) + return nil + } + case SubstrMatcher: + if s, ok := s.(MatcherSetForSubstr); ok { + s.AddSubstrMatcher(matcher) + return nil + } + case *RegexMatcher: + if s, ok := s.(MatcherSetForRegex); ok { + s.AddRegexMatcher(matcher) + return nil + } + } + return errors.New("cannot add matcher to matcher set") +} diff --git a/common/geodata/strmatcher/matcherset_domain.go b/common/geodata/strmatcher/matcherset_domain.go new file mode 100644 index 00000000..1ad22a8e --- /dev/null +++ b/common/geodata/strmatcher/matcherset_domain.go @@ -0,0 +1,79 @@ +package strmatcher + +type trieNode2 struct { + matched bool + children map[string]*trieNode2 +} + +// DomainMatcherSet is an implementation of MatcherSet. +// It uses trie to optimize both memory consumption and lookup speed. Trie node is domain label based. +type DomainMatcherSet struct { + root *trieNode2 +} + +func NewDomainMatcherSet() *DomainMatcherSet { + return &DomainMatcherSet{ + root: new(trieNode2), + } +} + +// AddDomainMatcher implements MatcherSetForDomain.AddDomainMatcher. +func (s *DomainMatcherSet) AddDomainMatcher(matcher DomainMatcher) { + node := s.root + pattern := matcher.Pattern() + for i := len(pattern); i > 0; { + var part string + for j := i - 1; ; j-- { + if pattern[j] == '.' { + part = pattern[j+1 : i] + i = j + break + } + if j == 0 { + part = pattern[j:i] + i = j + break + } + } + if node.children == nil { + node.children = make(map[string]*trieNode2) + } + next := node.children[part] + if next == nil { + next = new(trieNode2) + node.children[part] = next + } + node = next + } + + node.matched = true +} + +// MatchAny implements MatcherSet.MatchAny. +func (s *DomainMatcherSet) MatchAny(input string) bool { + node := s.root + for i := len(input); i > 0; { + for j := i - 1; ; j-- { + if input[j] == '.' { + node = node.children[input[j+1:i]] + i = j + break + } + if j == 0 { + node = node.children[input[j:i]] + i = j + break + } + } + if node == nil { + return false + } + if node.matched { + return true + } + if node.children == nil { + return false + } + } + return false +} diff --git a/common/geodata/strmatcher/matcherset_domain_test.go b/common/geodata/strmatcher/matcherset_domain_test.go new file mode 100644 index 00000000..e0ae2486 --- /dev/null +++ b/common/geodata/strmatcher/matcherset_domain_test.go @@ -0,0 +1,95 @@ +package strmatcher_test + +import ( + "reflect" + "testing" + + . "github.com/xtls/xray-core/common/geodata/strmatcher" +) + +func TestDomainMatcherSet(t *testing.T) { + patterns := []struct { + Pattern string + }{ + { + Pattern: "example.com", + }, + { + Pattern: "google.com", + }, + { + Pattern: "x.a.com", + }, + { + Pattern: "a.b.com", + }, + { + Pattern: "c.a.b.com", + }, + { + Pattern: "x.y.com", + }, + { + Pattern: "x.y.com", + }, + } + testCases := []struct { + Domain string + Result bool + }{ + { + Domain: "x.example.com", + Result: true, + }, + { + Domain: "y.com", + Result: false, + }, + { + Domain: "a.b.com", + Result: true, + }, + { + Domain: "c.a.b.com", + Result: true, + }, + { + Domain: "c.a..b.com", + Result: false, + }, + { + Domain: ".com", + Result: false, + }, + { + Domain: "com", + Result: false, + }, + { + Domain: "", + Result: false, + }, + { + Domain: "x.y.com", + Result: true, + }, + } + s := NewDomainMatcherSet() + for _, pattern := range patterns { + AddMatcherToSet(s, DomainMatcher(pattern.Pattern)) + } + for _, testCase := range testCases { + r := s.MatchAny(testCase.Domain) + if !reflect.DeepEqual(r, testCase.Result) { + t.Error("Failed to match domain: ", testCase.Domain, ", expect ", testCase.Result, ", but got ", r) + } + } +} + +func TestEmptyDomainMatcherSet(t *testing.T) { + s := NewDomainMatcherSet() + r := s.MatchAny("example.com") + if r { + t.Error("Expect false, but ", r) + } +} diff --git a/common/geodata/strmatcher/matcherset_full.go b/common/geodata/strmatcher/matcherset_full.go new file mode 100644 index 00000000..357bdbe1 --- /dev/null +++ b/common/geodata/strmatcher/matcherset_full.go @@ -0,0 +1,24 @@ +package strmatcher + +// FullMatcherSet is an implementation of MatcherSet. +// It uses a hash table to facilitate exact match lookup. +type FullMatcherSet struct { + matchers map[string]struct{} +} + +func NewFullMatcherSet() *FullMatcherSet { + return &FullMatcherSet{ + matchers: make(map[string]struct{}), + } +} + +// AddFullMatcher implements MatcherSetForFull.AddFullMatcher. +func (s *FullMatcherSet) AddFullMatcher(matcher FullMatcher) { + s.matchers[matcher.Pattern()] = struct{}{} +} + +// MatchAny implements MatcherSet.Any. +func (s *FullMatcherSet) MatchAny(input string) bool { + _, found := s.matchers[input] + return found +} diff --git a/common/geodata/strmatcher/matcherset_full_test.go b/common/geodata/strmatcher/matcherset_full_test.go new file mode 100644 index 00000000..ea8d348c --- /dev/null +++ b/common/geodata/strmatcher/matcherset_full_test.go @@ -0,0 +1,65 @@ +package strmatcher_test + +import ( + "reflect" + "testing" + + . "github.com/xtls/xray-core/common/geodata/strmatcher" +) + +func TestFullMatcherSet(t *testing.T) { + patterns := []struct { + Pattern string + }{ + { + Pattern: "example.com", + }, + { + Pattern: "google.com", + }, + { + Pattern: "x.a.com", + }, + { + Pattern: "x.y.com", + }, + { + Pattern: "x.y.com", + }, + } + testCases := []struct { + Domain string + Result bool + }{ + { + Domain: "example.com", + Result: true, + }, + { + Domain: "y.com", + Result: false, + }, + { + Domain: "x.y.com", + Result: true, + }, + } + s := NewFullMatcherSet() + for _, pattern := range patterns { + AddMatcherToSet(s, FullMatcher(pattern.Pattern)) + } + for _, testCase := range testCases { + r := s.MatchAny(testCase.Domain) + if !reflect.DeepEqual(r, testCase.Result) { + t.Error("Failed to match domain: ", testCase.Domain, ", expect ", testCase.Result, ", but got ", r) + } + } +} + +func TestEmptyFullMatcherSet(t *testing.T) { + s := NewFullMatcherSet() + r := s.MatchAny("example.com") + if r { + t.Error("Expect false, but ", r) + } +} diff --git a/common/geodata/strmatcher/matcherset_simple.go b/common/geodata/strmatcher/matcherset_simple.go new file mode 100644 index 00000000..2f885a64 --- /dev/null +++ b/common/geodata/strmatcher/matcherset_simple.go @@ -0,0 +1,22 @@ +package strmatcher + +// SimpleMatcherSet is an implementation of MatcherSet. +// It simply stores all matchers in an array and sequentially matches them. +type SimpleMatcherSet struct { + matchers []Matcher +} + +// AddMatcher implements MatcherSetForAll.AddMatcher. +func (s *SimpleMatcherSet) AddMatcher(matcher Matcher) { + s.matchers = append(s.matchers, matcher) +} + +// MatchAny implements MatcherSet.MatchAny. +func (s *SimpleMatcherSet) MatchAny(input string) bool { + for _, m := range s.matchers { + if m.Match(input) { + return true + } + } + return false +} diff --git a/common/geodata/strmatcher/matcherset_simple_test.go b/common/geodata/strmatcher/matcherset_simple_test.go new file mode 100644 index 00000000..429bbe54 --- /dev/null +++ b/common/geodata/strmatcher/matcherset_simple_test.go @@ -0,0 +1,69 @@ +package strmatcher_test + +import ( + "reflect" + "testing" + + "github.com/xtls/xray-core/common" + . "github.com/xtls/xray-core/common/geodata/strmatcher" +) + +func TestSimpleMatcherSet(t *testing.T) { + patterns := []struct { + pattern string + mType Type + }{ + { + pattern: "example.com", + mType: Domain, + }, + { + pattern: "example.com", + mType: Full, + }, + { + pattern: "example.com", + mType: Regex, + }, + } + cases := []struct { + input string + output bool + }{ + { + input: "www.example.com", + output: true, + }, + { + input: "example.com", + output: true, + }, + { + input: "www.e3ample.com", + output: false, + }, + { + input: "xample.com", + output: false, + }, + { + input: "xexample.com", + output: true, + }, + { + input: "examplexcom", + output: true, + }, + } + matcherSet := &SimpleMatcherSet{} + for _, entry := range patterns { + matcher, err := entry.mType.New(entry.pattern) + common.Must(err) + common.Must(AddMatcherToSet(matcherSet, matcher)) + } + for _, test := range cases { + if r := matcherSet.MatchAny(test.input); !reflect.DeepEqual(r, test.output) { + t.Error("unexpected output: ", r, " for test case ", test) + } + } +} diff --git a/common/geodata/strmatcher/matcherset_substr.go b/common/geodata/strmatcher/matcherset_substr.go new file mode 100644 index 00000000..58bfd6e3 --- /dev/null +++ b/common/geodata/strmatcher/matcherset_substr.go @@ -0,0 +1,24 @@ +package strmatcher + +import "strings" + +// SubstrMatcherSet is implementation of MatcherSet, +// It is simply implmeneted to comply with the priority specification of Substr matchers. +type SubstrMatcherSet struct { + patterns []string +} + +// AddSubstrMatcher implements MatcherSetForSubstr.AddSubstrMatcher. +func (s *SubstrMatcherSet) AddSubstrMatcher(matcher SubstrMatcher) { + s.patterns = append(s.patterns, matcher.Pattern()) +} + +// MatchAny implements MatcherSet.MatchAny. +func (s *SubstrMatcherSet) MatchAny(input string) bool { + for _, pattern := range s.patterns { + if strings.Contains(input, pattern) { + return true + } + } + return false +} diff --git a/common/geodata/strmatcher/matcherset_substr_test.go b/common/geodata/strmatcher/matcherset_substr_test.go new file mode 100644 index 00000000..de73138e --- /dev/null +++ b/common/geodata/strmatcher/matcherset_substr_test.go @@ -0,0 +1,77 @@ +package strmatcher_test + +import ( + "reflect" + "testing" + + "github.com/xtls/xray-core/common" + . "github.com/xtls/xray-core/common/geodata/strmatcher" +) + +func TestSubstrMatcherSet(t *testing.T) { + patterns := []struct { + pattern string + mType Type + }{ + { + pattern: "apis", + mType: Substr, + }, + { + pattern: "google", + mType: Substr, + }, + { + pattern: "apis", + mType: Substr, + }, + } + cases := []struct { + input string + output bool + }{ + { + input: "google.com", + output: true, + }, + { + input: "apis.com", + output: true, + }, + { + input: "googleapis.com", + output: true, + }, + { + input: "fonts.googleapis.com", + output: true, + }, + { + input: "apis.googleapis.com", + output: true, + }, + { + input: "baidu.com", + output: false, + }, + { + input: "goog", + output: false, + }, + { + input: "api", + output: false, + }, + } + matcherSet := &SubstrMatcherSet{} + for _, entry := range patterns { + matcher, err := entry.mType.New(entry.pattern) + common.Must(err) + common.Must(AddMatcherToSet(matcherSet, matcher)) + } + for _, test := range cases { + if r := matcherSet.MatchAny(test.input); !reflect.DeepEqual(r, test.output) { + t.Error("unexpected output: ", r, " for test case ", test) + } + } +} diff --git a/common/geodata/strmatcher/strmatcher.go b/common/geodata/strmatcher/strmatcher.go index 2cc6f252..dbd10a8e 100644 --- a/common/geodata/strmatcher/strmatcher.go +++ b/common/geodata/strmatcher/strmatcher.go @@ -15,7 +15,7 @@ const ( ) // Matcher is the interface to determine a string matches a pattern. -// - This is a basic matcher to represent a certain kind of match semantic(full, substr, domain or regex). +// - This is a basic matcher to represent a certain kind of match semantic (full, substr, domain or regex). type Matcher interface { // Type returns the matcher's type. Type() Type @@ -101,3 +101,21 @@ type ValueMatcher interface { // MatchAny returns true as soon as one matching matcher is found. MatchAny(input string) bool } + +// MatcherSet is an advanced type of matcher to accept a bunch of basic Matchers (of certain type, not all matcher types). +// For example: +// - FullMatcherSet accepts FullMatcher and uses a hash table to facilitate lookup. +// - DomainMatcherSet accepts DomainMatcher and uses a trie to optimize both memory consumption and lookup speed. +type MatcherSet interface { + // MatchAny returns true as soon as one matching matcher is found. + MatchAny(input string) bool +} + +// AnyMatcher is a lightweight matcher for callers that only need existence checks. +type AnyMatcher interface { + // Add adds a new Matcher to AnyMatcher. + Add(matcher Matcher) + + // MatchAny returns true as soon as one matching matcher is found. + MatchAny(input string) bool +}