mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 07:37:15 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user