From 06e55727a6bd3d265c0ca8888546fd323091d648 Mon Sep 17 00:00:00 2001 From: elliot Date: Fri, 5 Jun 2026 00:35:18 +0800 Subject: [PATCH] Apply direct rules to Clash subscriptions --- sub/subClashService.go | 138 +++++++++++++++++++++++++++++++++++- sub/subClashService_test.go | 44 ++++++++++++ sub/subController.go | 2 +- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/sub/subClashService.go b/sub/subClashService.go index 1dc61d67..357abdc3 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -16,6 +16,7 @@ import ( type SubClashService struct { inboundService service.InboundService SubService *SubService + directRules []string } type ClashConfig struct { @@ -24,8 +25,11 @@ type ClashConfig struct { Rules []string `yaml:"rules"` } -func NewSubClashService(subService *SubService) *SubClashService { - return &SubClashService{SubService: subService} +func NewSubClashService(subService *SubService, rules string) *SubClashService { + return &SubClashService{ + SubService: subService, + directRules: xrayDirectRulesToClash(rules), + } } func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { @@ -76,6 +80,10 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e } proxyNames = append(proxyNames, "DIRECT") + rules := make([]string, 0, len(s.directRules)+1) + rules = append(rules, s.directRules...) + rules = append(rules, "MATCH,PROXY") + config := ClashConfig{ Proxies: proxies, ProxyGroups: []map[string]any{{ @@ -83,7 +91,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e "type": "select", "proxies": proxyNames, }}, - Rules: []string{"MATCH,PROXY"}, + Rules: rules, } finalYAML, err := yaml.Marshal(config) @@ -127,6 +135,130 @@ func fallbackProxyName(proxy map[string]any, idx int) string { return fmt.Sprintf("proxy-%d", idx+1) } +type xrayDirectRule struct { + OutboundTag string `json:"outboundTag"` + Domain []string `json:"domain"` + IP []string `json:"ip"` +} + +func xrayDirectRulesToClash(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + + var xrayRules []xrayDirectRule + if err := json.Unmarshal([]byte(raw), &xrayRules); err != nil { + return nil + } + + var rules []string + for _, rule := range xrayRules { + if rule.OutboundTag != "direct" { + continue + } + for _, domain := range rule.Domain { + if clashRule := xrayDomainRuleToClash(domain); clashRule != "" { + rules = append(rules, clashRule) + } + } + for _, ip := range rule.IP { + rules = append(rules, xrayIPRulesToClash(ip)...) + } + } + return dedupeClashRules(rules) +} + +func xrayDomainRuleToClash(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + switch { + case strings.HasPrefix(value, "geosite:"): + tag := strings.TrimSpace(strings.TrimPrefix(value, "geosite:")) + if tag == "" { + return "" + } + return fmt.Sprintf("GEOSITE,%s,DIRECT", tag) + case strings.HasPrefix(value, "domain:"): + domain := strings.TrimSpace(strings.TrimPrefix(value, "domain:")) + if domain == "" { + return "" + } + return fmt.Sprintf("DOMAIN-SUFFIX,%s,DIRECT", domain) + case strings.HasPrefix(value, "full:"): + domain := strings.TrimSpace(strings.TrimPrefix(value, "full:")) + if domain == "" { + return "" + } + return fmt.Sprintf("DOMAIN,%s,DIRECT", domain) + case strings.HasPrefix(value, "keyword:"): + keyword := strings.TrimSpace(strings.TrimPrefix(value, "keyword:")) + if keyword == "" { + return "" + } + return fmt.Sprintf("DOMAIN-KEYWORD,%s,DIRECT", keyword) + case strings.HasPrefix(value, "regexp:"): + return "" + default: + return fmt.Sprintf("DOMAIN-SUFFIX,%s,DIRECT", value) + } +} + +func xrayIPRulesToClash(value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + + if strings.HasPrefix(value, "geoip:") { + tag := strings.TrimSpace(strings.TrimPrefix(value, "geoip:")) + if tag == "" { + return nil + } + if strings.EqualFold(tag, "private") { + return []string{ + "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", + "IP-CIDR,172.16.0.0/12,DIRECT,no-resolve", + "IP-CIDR,192.168.0.0/16,DIRECT,no-resolve", + "IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", + "IP-CIDR,169.254.0.0/16,DIRECT,no-resolve", + "IP-CIDR6,fc00::/7,DIRECT,no-resolve", + "IP-CIDR6,fe80::/10,DIRECT,no-resolve", + "IP-CIDR6,::1/128,DIRECT,no-resolve", + } + } + return []string{fmt.Sprintf("GEOIP,%s,DIRECT", strings.ToUpper(tag))} + } + + if strings.HasPrefix(value, "ext:") { + return nil + } + + ruleType := "IP-CIDR" + if strings.Contains(value, ":") { + ruleType = "IP-CIDR6" + } + return []string{fmt.Sprintf("%s,%s,DIRECT,no-resolve", ruleType, value)} +} + +func dedupeClashRules(rules []string) []string { + if len(rules) == 0 { + return nil + } + seen := make(map[string]struct{}, len(rules)) + deduped := make([]string, 0, len(rules)) + for _, rule := range rules { + if _, ok := seen[rule]; ok { + continue + } + seen[rule] = struct{}{} + deduped = append(deduped, rule) + } + return deduped +} + func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any { stream := s.streamData(inbound.StreamSettings) // For node-managed inbounds the Clash proxy "server" must be the diff --git a/sub/subClashService_test.go b/sub/subClashService_test.go index c8a1195d..73e86ff9 100644 --- a/sub/subClashService_test.go +++ b/sub/subClashService_test.go @@ -39,6 +39,50 @@ func TestEnsureUniqueProxyNames(t *testing.T) { } } +func TestXrayDirectRulesToClash(t *testing.T) { + raw := `[ + {"type":"field","outboundTag":"direct","domain":["geosite:cn","domain:example.com","full:exact.example","keyword:bank"],"ip":["geoip:cn","geoip:private","1.2.3.0/24","2001:db8::/32"]}, + {"type":"field","outboundTag":"proxy","domain":["geosite:google"],"ip":["geoip:us"]}, + {"type":"field","outboundTag":"direct","domain":["geosite:cn"],"ip":["geoip:cn"]} + ]` + + got := xrayDirectRulesToClash(raw) + want := []string{ + "GEOSITE,cn,DIRECT", + "DOMAIN-SUFFIX,example.com,DIRECT", + "DOMAIN,exact.example,DIRECT", + "DOMAIN-KEYWORD,bank,DIRECT", + "GEOIP,CN,DIRECT", + "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve", + "IP-CIDR,172.16.0.0/12,DIRECT,no-resolve", + "IP-CIDR,192.168.0.0/16,DIRECT,no-resolve", + "IP-CIDR,127.0.0.0/8,DIRECT,no-resolve", + "IP-CIDR,169.254.0.0/16,DIRECT,no-resolve", + "IP-CIDR6,fc00::/7,DIRECT,no-resolve", + "IP-CIDR6,fe80::/10,DIRECT,no-resolve", + "IP-CIDR6,::1/128,DIRECT,no-resolve", + "IP-CIDR,1.2.3.0/24,DIRECT,no-resolve", + "IP-CIDR6,2001:db8::/32,DIRECT,no-resolve", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("xrayDirectRulesToClash() = %#v, want %#v", got, want) + } +} + +func TestXrayDirectRulesToClashIgnoresInvalidRules(t *testing.T) { + cases := []string{ + "", + "not-json", + `[{"outboundTag":"direct","domain":["regexp:.*"]},{"outboundTag":"blocked","ip":["geoip:cn"]}]`, + } + + for _, raw := range cases { + if got := xrayDirectRulesToClash(raw); len(got) != 0 { + t.Fatalf("xrayDirectRulesToClash(%q) = %#v, want empty", raw, got) + } + } +} + func TestApplyTransport_XHTTP(t *testing.T) { svc := &SubClashService{} proxy := map[string]any{} diff --git a/sub/subController.go b/sub/subController.go index 05569a54..cada94d7 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -91,7 +91,7 @@ func NewSUBController( subService: sub, subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), - subClashService: NewSubClashService(sub), + subClashService: NewSubClashService(sub, jsonRules), } a.initRouter(g) return a