From d29a17d33360bed44188d14e1e1de42b36775c6b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 1 Jun 2026 18:07:01 +0200 Subject: [PATCH] fix(sub): ensure unique Clash proxy names (#4641) genRemark can return an empty string (remark-less inbound, or a remark model that depends on the email the Clash path drops), which was set verbatim as the proxy name. mihomo rejects the whole config on a duplicate name, so two such proxies made the Clash Verge profile vanish on refresh; a single one was dropped from the PROXY group, collapsing it to DIRECT so Rule mode stopped proxying while Global still worked. Guarantee every proxy carries a non-empty, unique name before assembling the group. --- sub/subClashService.go | 34 ++++++++++++++++++++++++++++++++++ sub/subClashService_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/sub/subClashService.go b/sub/subClashService.go index 93682f5a..bf06bbaf 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -60,6 +60,8 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e return "", "", nil } + ensureUniqueProxyNames(proxies) + emails := make([]string, 0, len(seenEmails)) for e := range seenEmails { emails = append(emails, e) @@ -93,6 +95,38 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e return string(finalYAML), header, nil } +// ensureUniqueProxyNames keeps every proxy "name" non-empty and unique: +// mihomo rejects the whole config on a duplicate name (the empty string +// genRemark returns for a remark-less inbound counts), vanishing the Clash +// profile on refresh. See issue #4641. +func ensureUniqueProxyNames(proxies []map[string]any) { + seen := make(map[string]struct{}, len(proxies)) + for i, proxy := range proxies { + base, _ := proxy["name"].(string) + if base == "" { + base = fallbackProxyName(proxy, i) + } + name := base + for n := 2; ; n++ { + if _, dup := seen[name]; !dup { + break + } + name = fmt.Sprintf("%s-%d", base, n) + } + seen[name] = struct{}{} + proxy["name"] = name + } +} + +func fallbackProxyName(proxy map[string]any, idx int) string { + typ, _ := proxy["type"].(string) + server, _ := proxy["server"].(string) + if typ != "" && server != "" { + return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"]) + } + return fmt.Sprintf("proxy-%d", idx+1) +} + 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 49e7788a..c8a1195d 100644 --- a/sub/subClashService_test.go +++ b/sub/subClashService_test.go @@ -5,6 +5,40 @@ import ( "testing" ) +func TestEnsureUniqueProxyNames(t *testing.T) { + proxies := []map[string]any{ + {"name": "", "type": "vless", "server": "a.com", "port": 443}, + {"name": "", "type": "vmess", "server": "b.com", "port": 8443}, + {"name": "node"}, + {"name": "node"}, + {"name": ""}, + } + + ensureUniqueProxyNames(proxies) + + seen := map[string]bool{} + for i, p := range proxies { + name, _ := p["name"].(string) + if name == "" { + t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i) + } + if seen[name] { + t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name) + } + seen[name] = true + } + + if got := proxies[0]["name"]; got != "vless-a.com-443" { + t.Errorf("empty name fallback = %q, want vless-a.com-443", got) + } + if proxies[2]["name"] == proxies[3]["name"] { + t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"]) + } + if got := proxies[4]["name"]; got != "proxy-5" { + t.Errorf("typeless empty name fallback = %q, want proxy-5", got) + } +} + func TestApplyTransport_XHTTP(t *testing.T) { svc := &SubClashService{} proxy := map[string]any{}