1
0
mirror of https://github.com/MetaCubeX/mihomo.git synced 2025-07-18 17:08:06 +08:00

feat: support DOMAIN-WILDCARD rule (#2124)

only support asterisk(*) and question mark(?)
This commit is contained in:
ayanamist
2025-06-27 16:35:55 +08:00
committed by GitHub
parent 91985c1ef8
commit 241ae92bce
6 changed files with 253 additions and 0 deletions

View File

@ -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
}

View File

@ -0,0 +1,105 @@
package wildcard
/*
* copy and modified from https://github.com/IGLOU-EU/go-wildcard/tree/ce22b7af48e487517a492d3727d9386492043e21
*
* Copyright (c) 2023 Iglou.eu <contact@iglou.eu>
* Copyright (c) 2023 Adrien Kara <adrien@iglou.eu>
*
* 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)
}
})
}

View File

@ -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:

View File

@ -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 的规则集

View File

@ -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
}

View File

@ -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":