diff --git a/docs/config.yaml b/docs/config.yaml index 44741d353e..c5af73b1de 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -771,7 +771,7 @@ proxies: # socks5 # max-connections: 1 # Maximum connections. Conflict with max-streams. # min-streams: 0 # Minimum multiplexed streams in a connection before opening a new connection. Conflict with max-streams. # max-streams: 0 # Maximum multiplexed streams in a connection before opening a new connection. Conflict with max-connections and min-streams. - + reality-opts: public-key: CrrQSjAG_YkHLwvM2M-7XkKJilgL5upBKCp0od0tLhE short-id: 10f897e26c4b9478 @@ -1195,7 +1195,7 @@ proxies: # socks5 - name: sudoku type: sudoku server: server_ip/domain # 1.2.3.4 or domain - port: 443 + port: 443 key: "" # 如果你使用sudoku生成的ED25519密钥对,请填写密钥对中的私钥,否则填入和服务端相同的uuid aead-method: chacha20-poly1305 # 可选:chacha20-poly1305、aes-128-gcm、none(不建议;none 不提供 AEAD 保护) padding-min: 2 # 最小填充率(0-100) @@ -1428,11 +1428,16 @@ rule-providers: rules: - RULE-SET,rule1,REJECT - - IP-ASN,1,PROXY + - IP-ASN,13335,PROXY # 匹配 cloudflare ASN13335 IP 段 + - IP-ASN,13335/396982,PROXY # 支持正斜杠(/)分割, 效果等同于 OR - DOMAIN-REGEX,^abc,DIRECT - DOMAIN-SUFFIX,baidu.com,DIRECT - DOMAIN-KEYWORD,google,ss1 - DOMAIN-WILDCARD,test.*.mihomo.com,ss1 + - GEOSITE,baidu,DIRECT # 匹配所有百度网站域名 + - GEOSITE,google/youtube,PROXY # 支持正斜杠(/)分割, 效果等同于 OR + - GEOIP,cn,DIRECT # 匹配所有 CN IP 段 + - GEOIP,gfw/cloudflare,PROXY # 支持正斜杠(/)分割, 效果等同于 OR - IP-CIDR,1.1.1.1/32,ss1 - IP-CIDR6,2409::/64,DIRECT # 当满足条件是 TCP 或 UDP 流量时,使用名为 sub-rule-name1 的规则集 diff --git a/rules/common/geoip.go b/rules/common/geoip.go index 26a2c42b59..73cc47427e 100644 --- a/rules/common/geoip.go +++ b/rules/common/geoip.go @@ -5,6 +5,7 @@ import ( "fmt" "net/netip" "strings" + "sync" "github.com/metacubex/mihomo/component/geodata" "github.com/metacubex/mihomo/component/geodata/router" @@ -18,10 +19,39 @@ import ( type GEOIP struct { Base - country string + countries []string + payload string adapter string noResolveIP bool isSourceIP bool + matchers []namedGeoIPMatcher + matcher router.IPMatcher + matcherErr error + matcherOnce sync.Once +} + +type namedGeoIPMatcher struct { + country string + matcher router.IPMatcher +} + +type multiGeoIPMatcher []router.IPMatcher + +func (m multiGeoIPMatcher) Match(ip netip.Addr) bool { + for _, matcher := range m { + if matcher.Match(ip) { + return true + } + } + return false +} + +func (m multiGeoIPMatcher) Count() int { + count := 0 + for _, matcher := range m { + count += matcher.Count() + } + return count } var _ C.Rule = (*GEOIP)(nil) @@ -46,42 +76,50 @@ func (g *GEOIP) Match(metadata *C.Metadata, helper C.RuleMatchHelper) (bool, str return false, "" } - if g.country == "lan" { - return g.isLan(ip), g.adapter + if g.matchLan(ip) { + return true, g.adapter } if geodata.GeodataMode() { if g.isSourceIP { - if slices.Contains(metadata.SrcGeoIP, g.country) { + if g.matchCountries(metadata.SrcGeoIP) { return true, g.adapter } } else { - if slices.Contains(metadata.DstGeoIP, g.country) { + if g.matchCountries(metadata.DstGeoIP) { return true, g.adapter } } - matcher, err := g.getIPMatcher() + + matchers, err := g.getNamedIPMatchers() if err != nil { return false, "" } - match := matcher.Match(ip) - if match { - if g.isSourceIP { - metadata.SrcGeoIP = append(metadata.SrcGeoIP, g.country) - } else { - metadata.DstGeoIP = append(metadata.DstGeoIP, g.country) + for _, matcher := range matchers { + if matcher.matcher.Match(ip) { + if g.isSourceIP { + metadata.SrcGeoIP = append(metadata.SrcGeoIP, matcher.country) + } else { + metadata.DstGeoIP = append(metadata.DstGeoIP, matcher.country) + } + return true, g.adapter } } - return match, g.adapter + + return false, "" } if g.isSourceIP { if metadata.SrcGeoIP != nil { - return slices.Contains(metadata.SrcGeoIP, g.country), g.adapter + if g.matchCountries(metadata.SrcGeoIP) { + return true, g.adapter + } } } else { if metadata.DstGeoIP != nil { - return slices.Contains(metadata.DstGeoIP, g.country), g.adapter + if g.matchCountries(metadata.DstGeoIP) { + return true, g.adapter + } } } codes := mmdb.IPInstance().LookupCode(ip.AsSlice()) @@ -90,7 +128,7 @@ func (g *GEOIP) Match(metadata *C.Metadata, helper C.RuleMatchHelper) (bool, str } else { metadata.DstGeoIP = codes } - if slices.Contains(codes, g.country) { + if g.matchCountries(codes) { return true, g.adapter } return false, "" @@ -102,20 +140,25 @@ func (g *GEOIP) MatchIp(ip netip.Addr) bool { return false } - if g.country == "lan" { - return g.isLan(ip) + if g.matchLan(ip) { + return true } if geodata.GeodataMode() { - matcher, err := g.getIPMatcher() + matchers, err := g.getNamedIPMatchers() if err != nil { return false } - return matcher.Match(ip) + for _, matcher := range matchers { + if matcher.matcher.Match(ip) { + return true + } + } + return false } codes := mmdb.IPInstance().LookupCode(ip.AsSlice()) - return slices.Contains(codes, g.country) + return g.matchCountries(codes) } // MatchIp implements C.IpMatcher @@ -128,20 +171,7 @@ func (g dnsFallbackFilter) MatchIp(ip netip.Addr) bool { return false } - if g.country == "lan" { - return !g.isLan(ip) - } - - if geodata.GeodataMode() { - matcher, err := g.getIPMatcher() - if err != nil { - return false - } - return !matcher.Match(ip) - } - - codes := mmdb.IPInstance().LookupCode(ip.AsSlice()) - return !slices.Contains(codes, g.country) + return !g.GEOIP.MatchIp(ip) } type dnsFallbackFilter struct { @@ -166,22 +196,69 @@ func (g *GEOIP) Adapter() string { } func (g *GEOIP) Payload() string { - return g.country + return g.payload } func (g *GEOIP) GetCountry() string { - return g.country + if len(g.countries) == 0 { + return "" + } + return g.countries[0] } func (g *GEOIP) GetIPMatcher() (router.IPMatcher, error) { + if !g.hasNonLanCountry() { + return nil, errors.New("geoip matcher has no data") + } + if geodata.GeodataMode() { - return g.getIPMatcher() + matchers, err := g.getNamedIPMatchers() + if err != nil { + return nil, err + } + if len(matchers) == 1 { + return matchers[0].matcher, nil + } + return g.matcher, nil } return nil, errors.New("not geodata mode") } -func (g *GEOIP) getIPMatcher() (router.IPMatcher, error) { - geoIPMatcher, err := geodata.LoadGeoIPMatcher(g.country) +func (g *GEOIP) getNamedIPMatchers() ([]namedGeoIPMatcher, error) { + g.matcherOnce.Do(func() { + matchers := make([]namedGeoIPMatcher, 0, len(g.countries)) + combined := make(multiGeoIPMatcher, 0, len(g.countries)) + for _, country := range g.countries { + if country == "lan" { + continue + } + matcher, err := g.getIPMatcher(country) + if err != nil { + g.matcherErr = err + return + } + matchers = append(matchers, namedGeoIPMatcher{country: country, matcher: matcher}) + combined = append(combined, matcher) + } + g.matchers = matchers + switch len(combined) { + case 0: + g.matcherErr = errors.New("geoip matcher has no data") + case 1: + g.matcher = combined[0] + default: + g.matcher = combined + } + }) + + if g.matcherErr != nil { + return nil, g.matcherErr + } + return g.matchers, nil +} + +func (g *GEOIP) getIPMatcher(country string) (router.IPMatcher, error) { + geoIPMatcher, err := geodata.LoadGeoIPMatcher(country) if err != nil { return nil, fmt.Errorf("[GeoIP] %w", err) } @@ -190,8 +267,7 @@ func (g *GEOIP) getIPMatcher() (router.IPMatcher, error) { } func (g *GEOIP) GetRecodeSize() int { - // skip pseudorule lan - if g.country == "lan" { + if !g.hasNonLanCountry() { return 0 } @@ -202,17 +278,29 @@ func (g *GEOIP) GetRecodeSize() int { } func NewGEOIP(country string, adapter string, isSrc, noResolveIP bool) (*GEOIP, error) { - country = strings.ToLower(country) + countries, err := parseSlashSeparatedPayload(country, "geoip country", strings.ToLower) + if err != nil { + return nil, err + } geoip := &GEOIP{ Base: Base{}, - country: country, + countries: countries, + payload: strings.Join(countries, "/"), adapter: adapter, noResolveIP: noResolveIP, isSourceIP: isSrc, } - if country == "lan" { + allLan := true + for _, country := range countries { + if country != "lan" { + allLan = false + break + } + } + + if allLan { return geoip, nil } @@ -222,14 +310,48 @@ func NewGEOIP(country string, adapter string, isSrc, noResolveIP bool) (*GEOIP, } if geodata.GeodataMode() { - geoIPMatcher, err := geoip.getIPMatcher() // test load + records := 0 + matchers, err := geoip.getNamedIPMatchers() // test load if err != nil { return nil, err } - log.Infoln("Finished initial GeoIP rule %s => %s, records: %d", country, adapter, geoIPMatcher.Count()) + for _, matcher := range matchers { + records += matcher.matcher.Count() + } + log.Infoln("Finished initial GeoIP rule %s => %s, records: %d", geoip.payload, adapter, records) } return geoip, nil } +func (g *GEOIP) matchLan(ip netip.Addr) bool { + for _, country := range g.countries { + if country == "lan" { + return g.isLan(ip) + } + } + return false +} + +func (g *GEOIP) matchCountries(codes []string) bool { + for _, country := range g.countries { + if country == "lan" { + continue + } + if slices.Contains(codes, country) { + return true + } + } + return false +} + +func (g *GEOIP) hasNonLanCountry() bool { + for _, country := range g.countries { + if country != "lan" { + return true + } + } + return false +} + var _ C.Rule = (*GEOIP)(nil) diff --git a/rules/common/geosite.go b/rules/common/geosite.go index 58e0217ee0..d4ec7fde5c 100644 --- a/rules/common/geosite.go +++ b/rules/common/geosite.go @@ -2,6 +2,8 @@ package common import ( "fmt" + "strings" + "sync" "github.com/metacubex/mihomo/component/geodata" _ "github.com/metacubex/mihomo/component/geodata/memconservative" @@ -13,9 +15,32 @@ import ( type GEOSITE struct { Base - country string + countries []string + payload string adapter string recodeSize int + matcher router.DomainMatcher + matcherErr error + matcherOnce sync.Once +} + +type multiGeoSiteMatcher []router.DomainMatcher + +func (m multiGeoSiteMatcher) ApplyDomain(domain string) bool { + for _, matcher := range m { + if matcher.ApplyDomain(domain) { + return true + } + } + return false +} + +func (m multiGeoSiteMatcher) Count() int { + count := 0 + for _, matcher := range m { + count += matcher.Count() + } + return count } func (gs *GEOSITE) RuleType() C.RuleType { @@ -43,15 +68,34 @@ func (gs *GEOSITE) Adapter() string { } func (gs *GEOSITE) Payload() string { - return gs.country + return gs.payload } func (gs *GEOSITE) GetDomainMatcher() (router.DomainMatcher, error) { - matcher, err := geodata.LoadGeoSiteMatcher(gs.country) - if err != nil { - return nil, fmt.Errorf("load GeoSite data error, %w", err) + gs.matcherOnce.Do(func() { + matchers := make(multiGeoSiteMatcher, 0, len(gs.countries)) + for _, country := range gs.countries { + matcher, err := geodata.LoadGeoSiteMatcher(country) + if err != nil { + gs.matcherErr = fmt.Errorf("load GeoSite data error, %w", err) + return + } + matchers = append(matchers, matcher) + } + switch len(matchers) { + case 0: + gs.matcherErr = fmt.Errorf("load GeoSite data error, empty matcher list") + case 1: + gs.matcher = matchers[0] + default: + gs.matcher = matchers + } + }) + + if gs.matcherErr != nil { + return nil, gs.matcherErr } - return matcher, nil + return gs.matcher, nil } func (gs *GEOSITE) GetRecodeSize() int { @@ -62,15 +106,21 @@ func (gs *GEOSITE) GetRecodeSize() int { } func NewGEOSITE(country string, adapter string) (*GEOSITE, error) { + countries, err := parseSlashSeparatedPayload(country, "geosite country", nil) + if err != nil { + return nil, err + } + if err := geodata.InitGeoSite(); err != nil { log.Errorln("can't initial GeoSite: %s", err) return nil, err } geoSite := &GEOSITE{ - Base: Base{}, - country: country, - adapter: adapter, + Base: Base{}, + countries: countries, + payload: strings.Join(countries, "/"), + adapter: adapter, } matcher, err := geoSite.GetDomainMatcher() // test load @@ -78,7 +128,7 @@ func NewGEOSITE(country string, adapter string) (*GEOSITE, error) { return nil, err } - log.Infoln("Finished initial GeoSite rule %s => %s, records: %d", country, adapter, matcher.Count()) + log.Infoln("Finished initial GeoSite rule %s => %s, records: %d", geoSite.payload, adapter, matcher.Count()) return geoSite, nil } diff --git a/rules/common/ipasn.go b/rules/common/ipasn.go index 651cb7c919..d001e103d3 100644 --- a/rules/common/ipasn.go +++ b/rules/common/ipasn.go @@ -1,6 +1,8 @@ package common import ( + "strings" + "github.com/metacubex/mihomo/component/geodata" "github.com/metacubex/mihomo/component/mmdb" C "github.com/metacubex/mihomo/constant" @@ -9,7 +11,8 @@ import ( type ASN struct { Base - asn string + asns []string + payload string adapter string noResolveIP bool isSourceIP bool @@ -35,7 +38,13 @@ func (a *ASN) Match(metadata *C.Metadata, helper C.RuleMatchHelper) (bool, strin metadata.DstIPASN = asn + " " + aso } - return a.asn == asn, a.adapter + for _, ruleASN := range a.asns { + if ruleASN == asn { + return true, a.adapter + } + } + + return false, "" } func (a *ASN) RuleType() C.RuleType { @@ -50,14 +59,22 @@ func (a *ASN) Adapter() string { } func (a *ASN) Payload() string { - return a.asn + return a.payload } func (a *ASN) GetASN() string { - return a.asn + if len(a.asns) == 0 { + return "" + } + return a.asns[0] } func NewIPASN(asn string, adapter string, isSrc, noResolveIP bool) (*ASN, error) { + asns, err := parseSlashSeparatedPayload(asn, "asn", nil) + if err != nil { + return nil, err + } + if err := geodata.InitASN(); err != nil { log.Errorln("can't initial ASN: %s", err) return nil, err @@ -65,7 +82,8 @@ func NewIPASN(asn string, adapter string, isSrc, noResolveIP bool) (*ASN, error) return &ASN{ Base: Base{}, - asn: asn, + asns: asns, + payload: strings.Join(asns, "/"), adapter: adapter, noResolveIP: noResolveIP, isSourceIP: isSrc, diff --git a/rules/common/payload.go b/rules/common/payload.go new file mode 100644 index 0000000000..be7232ab5a --- /dev/null +++ b/rules/common/payload.go @@ -0,0 +1,28 @@ +package common + +import ( + "fmt" + "slices" + "strings" +) + +func parseSlashSeparatedPayload(payload, valueName string, normalize func(string) string) ([]string, error) { + parts := strings.Split(payload, "/") + parsed := make([]string, 0, len(parts)) + for i, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("%s couldn't be empty", valueName) + } + if normalize != nil { + part = normalize(part) + } + if slices.Contains(parsed, part) { + continue + } + parsed = append(parsed, part) + parts[i] = part + } + + return parsed, nil +} diff --git a/rules/common/payload_test.go b/rules/common/payload_test.go new file mode 100644 index 0000000000..d6a1c4d210 --- /dev/null +++ b/rules/common/payload_test.go @@ -0,0 +1,147 @@ +package common + +import ( + "net/netip" + "reflect" + "strings" + "testing" + + "github.com/metacubex/mihomo/component/geodata" +) + +func TestParseSlashSeparatedPayload(t *testing.T) { + t.Parallel() + + values, err := parseSlashSeparatedPayload(" CN / us ", "country", strings.ToLower) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []string{"cn", "us"} + if !reflect.DeepEqual(values, expected) { + t.Fatalf("unexpected parsed values: got %v, want %v", values, expected) + } +} + +func TestParseSlashSeparatedPayloadDeduplicatesPreservingOrder(t *testing.T) { + t.Parallel() + + values, err := parseSlashSeparatedPayload(" CN / us / cn / jp / us ", "country", strings.ToLower) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []string{"cn", "us", "jp"} + if !reflect.DeepEqual(values, expected) { + t.Fatalf("unexpected parsed values: got %v, want %v", values, expected) + } +} + +func TestParseSlashSeparatedPayloadRejectsEmptyPart(t *testing.T) { + t.Parallel() + + testCases := []string{"cn//us", " / cn", "cn / "} + for _, input := range testCases { + if _, err := parseSlashSeparatedPayload(input, "country", nil); err == nil { + t.Fatalf("expected error for empty slash-separated part in %q", input) + } + } +} + +func TestNewGEOIPCanonicalizesPayload(t *testing.T) { + t.Parallel() + + rule, err := NewGEOIP(" LAN / lan ", "DIRECT", false, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := rule.Payload(), "lan"; got != want { + t.Fatalf("unexpected payload: got %q, want %q", got, want) + } +} + +func TestNewGEOSITECanonicalizesPayload(t *testing.T) { + t.Parallel() + + rule, err := NewGEOSITE("cn / gfw / cn", "DIRECT") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := rule.Payload(), "cn/gfw"; got != want { + t.Fatalf("unexpected payload: got %q, want %q", got, want) + } +} + +func TestNewIPASNCanonicalizesPayload(t *testing.T) { + t.Parallel() + + if err := geodata.InitASN(); err != nil { + t.Skipf("ASN data unavailable: %v", err) + } + + rule, err := NewIPASN("123 / 456 / 123", "DIRECT", false, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := rule.Payload(), "123/456"; got != want { + t.Fatalf("unexpected payload: got %q, want %q", got, want) + } +} + +func TestNewGEOIPMixedPayloadCanonicalizesAndReportsRecordSize(t *testing.T) { + t.Parallel() + + rule, err := NewGEOIP(" LAN / cn / lan ", "DIRECT", false, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got, want := rule.Payload(), "lan/cn"; got != want { + t.Fatalf("unexpected payload: got %q, want %q", got, want) + } + got := rule.GetRecodeSize() + if geodata.GeodataMode() { + if got <= 0 { + t.Fatalf("expected positive record size for mixed geoip payload in geodata mode, got %d", got) + } + } else if got != 0 { + t.Fatalf("expected zero record size outside geodata mode, got %d", got) + } +} + +func TestNewGEOIPPureLanHasZeroRecordSize(t *testing.T) { + t.Parallel() + + rule, err := NewGEOIP("lan", "DIRECT", false, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := rule.GetRecodeSize(); got != 0 { + t.Fatalf("unexpected record size: got %d, want 0", got) + } +} + +func TestGeoIPMatchLanMixedPayload(t *testing.T) { + t.Parallel() + + rule := &GEOIP{countries: []string{"lan", "cn"}} + if !rule.MatchIp(netip.MustParseAddr("192.168.1.1")) { + t.Fatalf("expected lan address to match mixed geoip rule") + } + if rule.MatchIp(netip.MustParseAddr("8.8.8.8")) { + t.Fatalf("unexpected match for non-lan address without geodata/mmdb support") + } +} + +func TestGeoIPDnsFallbackFilterPreservesLanBypass(t *testing.T) { + t.Parallel() + + filter := dnsFallbackFilter{GEOIP: &GEOIP{countries: []string{"lan", "cn"}}} + if filter.MatchIp(netip.MustParseAddr("10.0.0.1")) { + t.Fatalf("expected lan address to bypass fallback filter") + } +}