feat(sub): add legacy Clash subscription endpoint (#6338)

* feat(sub): add legacy Clash subscription endpoint

* fix(deps): update js-yaml to patched release

Raise the Swagger UI js-yaml override to 4.3.2 and refresh the lockfile to resolve GHSA-2883-xcg3-v3hh without changing Swagger UI.

* fix(sub): preserve client detection and normalize legacy cipher

Keep the original Clash/Mihomo auto-detection default so existing subscription URLs continue returning YAML. Normalize the panel-supported chacha20-poly1305 alias when generating legacy Clash profiles, and cover both regressions through HTTP endpoint tests.

* refactor(sub): drop an unreachable guard and make the alias test assert

Review of the legacy Clash subscription endpoint left three LOW findings, all
introduced by the change:

- The comment above the routing merge ran to three lines, over CLAUDE.md's
  two-line cap.
- validateClashRouteGraph on the legacy path could never fail: the legacy
  branch skips the routing merge, so it validated the literal config built a
  few lines above against itself. Dead code that reads as a guard.
- TestClashAliasesSkipConfiguredPathConflicts asserted nothing — it could only
  fail on an escaping gin panic, so a regression that registered the alias
  handler on the configured path went unnoticed. It now drives each collision
  through the router and asserts which format answers each path.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
duqigit
2026-09-11 03:58:55 +08:00
committed by GitHub
parent 3f1e52f09e
commit 64b6e43e2b
6 changed files with 520 additions and 16 deletions
+115 -1
View File
@@ -22,11 +22,21 @@ type SubClashService struct {
SubService *SubService
}
var errNoLegacyClashProxies = errors.New("no Clash for Windows-compatible proxies found; use the Mihomo subscription for modern proxy types")
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
return s.getClash(subId, host, false)
}
func (s *SubClashService) GetClashLegacy(subId string, host string) (string, string, error) {
return s.getClash(subId, host, true)
}
func (s *SubClashService) getClash(subId string, host string, legacy bool) (string, string, error) {
subReq := s.SubService.ForRequest(host)
subReq.subscriptionBody = true
inbounds, err := subReq.getInboundsBySubId(subId)
@@ -87,6 +97,12 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
if len(proxies) == 0 && !hasInactiveExternal {
return "", "", nil
}
if legacy {
proxies = legacyClashProxies(proxies)
if len(proxies) == 0 {
return "", "", errNoLegacyClashProxies
}
}
emails := make([]string, 0, len(seenEmails))
for e := range seenEmails {
@@ -138,7 +154,9 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
"rules": []string{"MATCH,PROXY"},
}
if s.enableRouting {
// Custom Clash routing can inject Mihomo-only groups, rules, providers or a
// top-level proxies key — exactly what the legacy filter just removed.
if s.enableRouting && !legacy {
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
if remote {
@@ -159,6 +177,102 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
return string(finalYAML), header, nil
}
func legacyClashProxies(proxies []map[string]any) []map[string]any {
compatible := make([]map[string]any, 0, len(proxies))
for _, proxy := range proxies {
if filtered := legacyClashProxy(proxy); filtered != nil {
compatible = append(compatible, filtered)
}
}
return compatible
}
func legacyClashProxy(proxy map[string]any) map[string]any {
proxyType, _ := proxy["type"].(string)
network, _ := proxy["network"].(string)
if _, reality := proxy["reality-opts"]; reality {
return nil
}
var fields []string
var cipher string
switch proxyType {
case "vmess":
if !legacyClashNetwork(network) || !legacyVmessCipher(proxy["cipher"]) {
return nil
}
fields = []string{
"name", "type", "server", "port", "uuid", "alterId", "cipher", "udp",
"network", "tls", "skip-cert-verify", "servername", "grpc-opts", "ws-opts",
}
case "trojan":
tls, _ := proxy["tls"].(bool)
if !tls || !legacyClashNetwork(network) {
return nil
}
fields = []string{
"name", "type", "server", "port", "password", "alpn", "sni", "skip-cert-verify",
"udp", "network", "grpc-opts", "ws-opts",
}
case "ss":
tls, _ := proxy["tls"].(bool)
cipher = legacyShadowsocksCipher(proxy["cipher"])
if (network != "" && network != "tcp") || tls || cipher == "" {
return nil
}
fields = []string{"name", "type", "server", "port", "password", "cipher", "udp", "plugin", "plugin-opts"}
default:
return nil
}
filtered := make(map[string]any, len(fields))
for _, field := range fields {
if value, exists := proxy[field]; exists {
filtered[field] = value
}
}
if proxyType == "ss" {
filtered["cipher"] = cipher
}
return filtered
}
func legacyClashNetwork(network string) bool {
switch network {
case "", "tcp", "ws", "grpc":
return true
default:
return false
}
}
func legacyVmessCipher(value any) bool {
cipher, _ := value.(string)
switch strings.ToLower(strings.TrimSpace(cipher)) {
case "auto", "aes-128-gcm", "chacha20-poly1305", "none":
return true
default:
return false
}
}
func legacyShadowsocksCipher(value any) string {
cipher, _ := value.(string)
cipher = strings.ToLower(strings.TrimSpace(cipher))
switch cipher {
case "chacha20-poly1305":
return "chacha20-ietf-poly1305"
case "aes-128-gcm", "aes-192-gcm", "aes-256-gcm",
"aes-128-cfb", "aes-192-cfb", "aes-256-cfb",
"aes-128-ctr", "aes-192-ctr", "aes-256-ctr",
"rc4-md5", "chacha20-ietf", "xchacha20",
"chacha20-ietf-poly1305", "xchacha20-ietf-poly1305":
return cipher
default:
return ""
}
}
// 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