From 241ae92bce511be80c3aac4576abf657bac27fd3 Mon Sep 17 00:00:00 2001 From: ayanamist Date: Fri, 27 Jun 2025 16:35:55 +0800 Subject: [PATCH] feat: support `DOMAIN-WILDCARD` rule (#2124) only support asterisk(*) and question mark(?) --- component/wildcard/wildcard.go | 101 ++++++++++++++++++++++++++ component/wildcard/wildcard_test.go | 105 ++++++++++++++++++++++++++++ constant/rule.go | 3 + docs/config.yaml | 1 + rules/common/domain_wildcard.go | 41 +++++++++++ rules/parser.go | 2 + 6 files changed, 253 insertions(+) create mode 100644 component/wildcard/wildcard.go create mode 100644 component/wildcard/wildcard_test.go create mode 100644 rules/common/domain_wildcard.go diff --git a/component/wildcard/wildcard.go b/component/wildcard/wildcard.go new file mode 100644 index 00000000..7fcf15ba --- /dev/null +++ b/component/wildcard/wildcard.go @@ -0,0 +1,101 @@ +package wildcard + +// copy and modified from https://github.com/IGLOU-EU/go-wildcard/tree/ce22b7af48e487517a492d3727d9386492043e21 +// which is licensed under OpenBSD's ISC-style license. +// Copyright (c) 2023 Iglou.eu contact@iglou.eu Copyright (c) 2023 Adrien Kara adrien@iglou.eu + +func Match(pattern, s string) bool { + if pattern == "" { + return s == pattern + } + if pattern == "*" || s == pattern { + return true + } + + return matchByString(pattern, s) +} + +func matchByString(pattern, s string) bool { + var lastErotemeCluster byte + var patternIndex, sIndex, lastStar, lastEroteme int + patternLen := len(pattern) + sLen := len(s) + star := -1 + eroteme := -1 + +Loop: + if sIndex >= sLen { + goto checkPattern + } + + if patternIndex >= patternLen { + if star != -1 { + patternIndex = star + 1 + lastStar++ + sIndex = lastStar + goto Loop + } + return false + } + switch pattern[patternIndex] { + // Removed dot matching as it conflicts with dot in domains. + // case '.': + // It matches any single character. So, we don't need to check anything. + case '?': + // '?' matches one character. Store its position and match exactly one character in the string. + eroteme = patternIndex + lastEroteme = sIndex + lastErotemeCluster = byte(s[sIndex]) + case '*': + // '*' matches zero or more characters. Store its position and increment the pattern index. + star = patternIndex + lastStar = sIndex + patternIndex++ + goto Loop + default: + // If the characters don't match, check if there was a previous '?' or '*' to backtrack. + if pattern[patternIndex] != s[sIndex] { + if eroteme != -1 { + patternIndex = eroteme + 1 + sIndex = lastEroteme + eroteme = -1 + goto Loop + } + + if star != -1 { + patternIndex = star + 1 + lastStar++ + sIndex = lastStar + goto Loop + } + + return false + } + + // If the characters match, check if it was not the same to validate the eroteme. + if eroteme != -1 && lastErotemeCluster != byte(s[sIndex]) { + eroteme = -1 + } + } + + patternIndex++ + sIndex++ + goto Loop + + // Check if the remaining pattern characters are '*' or '?', which can match the end of the string. +checkPattern: + if patternIndex < patternLen { + if pattern[patternIndex] == '*' { + patternIndex++ + goto checkPattern + } else if pattern[patternIndex] == '?' { + if sIndex >= sLen { + sIndex-- + } + patternIndex++ + goto checkPattern + } + } + + return patternIndex == patternLen +} diff --git a/component/wildcard/wildcard_test.go b/component/wildcard/wildcard_test.go new file mode 100644 index 00000000..719a4979 --- /dev/null +++ b/component/wildcard/wildcard_test.go @@ -0,0 +1,105 @@ +package wildcard + +/* + * copy and modified from https://github.com/IGLOU-EU/go-wildcard/tree/ce22b7af48e487517a492d3727d9386492043e21 + * + * Copyright (c) 2023 Iglou.eu + * Copyright (c) 2023 Adrien Kara + * + * Licensed under the BSD 3-Clause License, + */ + +import ( + "testing" +) + +// TestMatch validates the logic of wild card matching, +// it need to support '*', '?' and only validate for byte comparison +// over string, not rune or grapheme cluster +func TestMatch(t *testing.T) { + cases := []struct { + s string + pattern string + result bool + }{ + {"", "", true}, + {"", "*", true}, + {"", "**", true}, + {"", "?", true}, + {"", "??", true}, + {"", "?*", true}, + {"", "*?", true}, + {"", ".", false}, + {"", ".?", false}, + {"", "?.", false}, + {"", ".*", false}, + {"", "*.", false}, + {"", "*.?", false}, + {"", "?.*", false}, + + {"a", "", false}, + {"a", "a", true}, + {"a", "*", true}, + {"a", "**", true}, + {"a", "?", true}, + {"a", "??", true}, + {"a", ".", false}, + {"a", ".?", false}, + {"a", "?.", false}, + {"a", ".*", false}, + {"a", "*.", false}, + {"a", "*.?", false}, + {"a", "?.*", false}, + + {"match the exact string", "match the exact string", true}, + {"do not match a different string", "this is a different string", false}, + {"Match The Exact String WITH DIFFERENT CASE", "Match The Exact String WITH DIFFERENT CASE", true}, + {"do not match a different string WITH DIFFERENT CASE", "this is a different string WITH DIFFERENT CASE", false}, + {"Do Not Match The Exact String With Different Case", "do not match the exact string with different case", false}, + {"match an emoji πŸ˜ƒ", "match an emoji πŸ˜ƒ", true}, + {"do not match because of different emoji πŸ˜ƒ", "do not match because of different emoji πŸ˜„", false}, + {"πŸŒ…β˜•οΈπŸ“°πŸ‘¨β€πŸ’ΌπŸ‘©β€πŸ’ΌπŸ’πŸ–₯οΈπŸ’ΌπŸ’»πŸ“ŠπŸ“ˆπŸ“‰πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸπŸ•°οΈπŸ’ͺπŸ‹οΈβ€β™‚οΈπŸ‹οΈβ€β™€οΈπŸ‹οΈβ€β™‚οΈπŸ’ΌπŸš΄β€β™‚οΈπŸš΄β€β™€οΈπŸš΄β€β™‚οΈπŸ›€πŸ’€πŸŒƒ", "πŸŒ…β˜•οΈπŸ“°πŸ‘¨β€πŸ’ΌπŸ‘©β€πŸ’ΌπŸ’πŸ–₯οΈπŸ’ΌπŸ’»πŸ“ŠπŸ“ˆπŸ“‰πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸπŸ•°οΈπŸ’ͺπŸ‹οΈβ€β™‚οΈπŸ‹οΈβ€β™€οΈπŸ‹οΈβ€β™‚οΈπŸ’ΌπŸš΄β€β™‚οΈπŸš΄β€β™€οΈπŸš΄β€β™‚οΈπŸ›€πŸ’€πŸŒƒ", true}, + {"πŸŒ…β˜•οΈπŸ“°πŸ‘¨β€πŸ’ΌπŸ‘©β€πŸ’ΌπŸ’πŸ–₯οΈπŸ’ΌπŸ’»πŸ“ŠπŸ“ˆπŸ“‰πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸπŸ•°οΈπŸ’ͺπŸ‹οΈβ€β™‚οΈπŸ‹οΈβ€β™€οΈπŸ‹οΈβ€β™‚οΈπŸ’ΌπŸš΄β€β™‚οΈπŸš΄β€β™€οΈπŸš΄β€β™‚οΈπŸ›€πŸ’€πŸŒƒ", "πŸ¦ŒπŸ‡πŸ¦‘πŸΏοΈπŸŒ²πŸŒ³πŸ°πŸŒ³πŸŒ²πŸŒžπŸŒ§οΈβ„οΈπŸŒ¬οΈβ›ˆοΈπŸ”₯πŸŽ„πŸŽ…πŸŽπŸŽ‰πŸŽŠπŸ₯³πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸ’πŸ‘ͺπŸ’–πŸ‘©β€πŸ’ΌπŸ›€", false}, + + {"match a string with a *", "match a string *", true}, + {"match a string with a * at the beginning", "* at the beginning", true}, + {"match a string with two *", "match * with *", true}, + {"do not match a string with extra and a *", "do not match a string * with more", false}, + + {"match a string with a ?", "match ? string with a ?", true}, + {"match a string with a ? at the beginning", "?atch a string with a ? at the beginning", true}, + {"match a string with two ?", "match a string with two ??", true}, + {"match a optional char with a ?", "match a optional? char with a ?", true}, + {"match a optional char with a ?", "match a optional? char with a ?", true}, + {"do not match a string with extra and a ?", "do not match ? string with extra and a ? like this", false}, + + {"do not match a string with a .", "do not match . string with a .", false}, + {"do not match a string with a . at the beginning", "do not .atch a string with a . at the beginning", false}, + {"do not match a string with two .", "do not match a ..ring with two .", false}, + {"do not match a string with extra .", "do not match a string with extra ..", false}, + + {"A big brown fox jumps over the lazy dog, with all there wildcards friends", ". big?brown fox jumps over * wildcard. friend??", false}, + {"A big brown fox fails to jump over the lazy dog, with all there wildcards friends", ". big?brown fox jumps over * wildcard. friend??", false}, + + {"domain a.b.c", "domain a.b.c", true}, + {"domain adb.c", "domain a.b.c", false}, + {"aaaa", "a*a", true}, + } + + for i, c := range cases { + t.Run(c.s, func(t *testing.T) { + result := Match(c.pattern, c.s) + if c.result != result { + t.Errorf("Test %d: Expected `%v`, found `%v`; With Pattern: `%s` and String: `%s`", i+1, c.result, result, c.pattern, c.s) + } + }) + } +} + +func FuzzMatch(f *testing.F) { + f.Fuzz(func(t *testing.T, s string) { + if !Match(string(s), string(s)) { + t.Fatalf("%s does not match %s", s, s) + } + }) +} diff --git a/constant/rule.go b/constant/rule.go index 5bdb5a5e..a5941e6b 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -6,6 +6,7 @@ const ( DomainSuffix DomainKeyword DomainRegex + DomainWildcard GEOSITE GEOIP SrcGEOIP @@ -48,6 +49,8 @@ func (rt RuleType) String() string { return "DomainKeyword" case DomainRegex: return "DomainRegex" + case DomainWildcard: + return "DomainWildcard" case GEOSITE: return "GeoSite" case GEOIP: diff --git a/docs/config.yaml b/docs/config.yaml index 96c6a61b..d458d60d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1119,6 +1119,7 @@ rules: - DOMAIN-REGEX,^abc,DIRECT - DOMAIN-SUFFIX,baidu.com,DIRECT - DOMAIN-KEYWORD,google,ss1 + - DOMAIN-WILDCARD,test.*.mihomo.com,ss1 - IP-CIDR,1.1.1.1/32,ss1 - IP-CIDR6,2409::/64,DIRECT # ε½“ζ»‘θΆ³ζ‘δ»Άζ˜― TCP ζˆ– UDP ζ΅ι‡ζ—ΆοΌŒδ½Ώη”¨εδΈΊ sub-rule-name1 ηš„θ§„εˆ™ι›† diff --git a/rules/common/domain_wildcard.go b/rules/common/domain_wildcard.go new file mode 100644 index 00000000..3dc38932 --- /dev/null +++ b/rules/common/domain_wildcard.go @@ -0,0 +1,41 @@ +package common + +import ( + "strings" + + "github.com/metacubex/mihomo/component/wildcard" + C "github.com/metacubex/mihomo/constant" +) + +type DomainWildcard struct { + *Base + pattern string + adapter string +} + +func (dw *DomainWildcard) RuleType() C.RuleType { + return C.DomainWildcard +} + +func (dw *DomainWildcard) Match(metadata *C.Metadata, _ C.RuleMatchHelper) (bool, string) { + return wildcard.Match(dw.pattern, metadata.Host), dw.adapter +} + +func (dw *DomainWildcard) Adapter() string { + return dw.adapter +} + +func (dw *DomainWildcard) Payload() string { + return dw.pattern +} + +var _ C.Rule = (*DomainWildcard)(nil) + +func NewDomainWildcard(pattern string, adapter string) (*DomainWildcard, error) { + pattern = strings.ToLower(pattern) + return &DomainWildcard{ + Base: &Base{}, + pattern: pattern, + adapter: adapter, + }, nil +} diff --git a/rules/parser.go b/rules/parser.go index f4e945c9..675c52ec 100644 --- a/rules/parser.go +++ b/rules/parser.go @@ -19,6 +19,8 @@ func ParseRule(tp, payload, target string, params []string, subRules map[string] parsed = RC.NewDomainKeyword(payload, target) case "DOMAIN-REGEX": parsed, parseErr = RC.NewDomainRegex(payload, target) + case "DOMAIN-WILDCARD": + parsed, parseErr = RC.NewDomainWildcard(payload, target) case "GEOSITE": parsed, parseErr = RC.NewGEOSITE(payload, target) case "GEOIP":