Переглянути джерело

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 <[email protected]>
duqigit 12 годин тому
батько
коміт
64b6e43e2b

+ 26 - 5
docs/content/docs/en/config/subscription.mdx

@@ -42,17 +42,38 @@ URLs and preview both bodies here:
 
 The **format is chosen by path**, each with its own enable toggle:
 
-| Format                | Path      | Enabled by       | Output                                              |
-| --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **Raw links**         | `subPath`      | always (if on)   | A list of `vless://`, `vmess://`, … links (base64-encoded when `subEncrypt` is on). |
-| **JSON**              | `subJsonPath`  | `subJsonEnable`  | Full Xray client config(s).                         |
-| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML profile.                                       |
+| Format                         | Path             | Enabled by       | Output                                              |
+| ------------------------------ | ---------------- | ---------------- | --------------------------------------------------- |
+| **Raw links**                  | `subPath`        | always (if on)   | A list of `vless://`, `vmess://`, … links (base64-encoded when `subEncrypt` is on). |
+| **JSON**                       | `subJsonPath`    | `subJsonEnable`  | Full Xray client config(s).                         |
+| **Clash / Mihomo**             | `subClashPath`   | `subClashEnable` | Full Mihomo-compatible YAML profile.                |
+| **Mihomo (explicit)**          | `/mihomo/`       | `subClashEnable` | Alias for the full `subClashPath` profile.          |
+| **Clash for Windows (legacy)** | `/clash-legacy/` | `subClashEnable` | YAML limited to proxy types, transports, and ciphers supported by the legacy Clash core. |
 
 Only enabled inbounds using **VLESS, VMess, Trojan, Shadowsocks, or Hysteria2**
 appear in a subscription, ordered by their sub-sort index. Requesting `subPath`
 with an `Accept: text/html` header (or `?html=1`) returns a human-readable info
 page instead of the raw body.
 
+Use `/mihomo/<sub-id>` for Clash Verge Rev, Mihomo, and other maintained
+Mihomo-based clients. Use `/clash-legacy/<sub-id>` only for the discontinued
+Clash for Windows client. The legacy endpoint keeps compatible VMess, Trojan,
+and Shadowsocks nodes and excludes VLESS, Hysteria2, Reality, XHTTP,
+HTTPUpgrade, and Shadowsocks 2022. If no compatible node exists, it returns an
+explicit `422` response instead of a YAML profile the client cannot import.
+To avoid Mihomo-only syntax entering the legacy profile, this endpoint always
+uses its minimal `PROXY` group and `MATCH,PROXY` rule and ignores custom Clash
+routing settings.
+
+If an administrator has already assigned `/mihomo/` or `/clash-legacy/` to a
+different configurable subscription path, that existing path is preserved and
+the conflicting alias is skipped with a warning at startup.
+
+Automatic Clash format detection keeps the existing `(?i)(clash|mihomo)`
+default matcher so existing subscription URLs continue returning YAML.
+It does not distinguish legacy clients from Mihomo-based clients; Clash for
+Windows users must use `/clash-legacy/<sub-id>` for a compatible profile.
+
 ### Base64 vs JSON
 
 The **Base64** body is just the newline-joined share links, standard-base64

+ 22 - 5
docs/content/docs/zh/config/subscription.mdx

@@ -36,14 +36,31 @@ https://<sub-host>:<sub-port>/<sub-path>/<sub-id>
 
 **格式由路径决定**,每种格式都有各自的启用开关:
 
-| 格式                  | 路径      | 启用方式         | 输出                                                |
-| --------------------- | --------- | ---------------- | --------------------------------------------------- |
-| **原始链接**          | `subPath`      | 始终(若已开启) | 一组 `vless://`、`vmess://` 等链接的列表(当 `subEncrypt` 开启时进行 base64 编码)。 |
-| **JSON**              | `subJsonPath`  | `subJsonEnable`  | 完整的 Xray 客户端配置。                            |
-| **Clash / Mihomo**    | `subClashPath` | `subClashEnable` | YAML 配置文件。                                     |
+| 格式                          | 路径             | 启用方式         | 输出                                                |
+| ----------------------------- | ---------------- | ---------------- | --------------------------------------------------- |
+| **原始链接**                  | `subPath`        | 始终(若已开启) | 一组 `vless://`、`vmess://` 等链接的列表(当 `subEncrypt` 开启时进行 base64 编码)。 |
+| **JSON**                      | `subJsonPath`    | `subJsonEnable`  | 完整的 Xray 客户端配置。                            |
+| **Clash / Mihomo**            | `subClashPath`   | `subClashEnable` | 完整的 Mihomo 兼容 YAML 配置。                      |
+| **Mihomo(明确端点)**        | `/mihomo/`       | `subClashEnable` | 完整 `subClashPath` 配置的别名。                    |
+| **Clash for Windows(旧版)** | `/clash-legacy/` | `subClashEnable` | 仅包含旧 Clash 内核支持的代理类型、传输方式和加密算法。 |
 
 只有使用 **VLESS、VMess、Trojan、Shadowsocks 或 Hysteria2** 的已启用入站才会出现在订阅中,并按其订阅排序索引排列。使用 `Accept: text/html` 头(或 `?html=1`)请求 `subPath` 会返回一个人类可读的信息页面,而非原始内容。
 
+Clash Verge Rev、Mihomo 及其他仍在维护的 Mihomo 客户端应使用
+`/mihomo/<sub-id>`。已经停止维护的 Clash for Windows 应使用
+`/clash-legacy/<sub-id>`;旧版端点只保留兼容的 VMess、Trojan 和
+Shadowsocks 节点,并排除 VLESS、Hysteria2、Reality、XHTTP、HTTPUpgrade
+和 Shadowsocks 2022。如果没有任何兼容节点,端点会明确返回 `422`,而不是返回一份无法导入的 YAML。
+为避免 Mihomo 专用语法进入旧版配置,此端点始终使用最小的 `PROXY` 策略组与
+`MATCH,PROXY` 规则,并忽略自定义 Clash 路由设置。
+
+如果管理员已经把 `/mihomo/` 或 `/clash-legacy/` 分配给其他可配置订阅路径,
+系统会保留原有路径,并在启动时记录警告、跳过发生冲突的别名。
+
+Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,确保已有订阅 URL
+继续返回 YAML。它不区分旧版客户端与 Mihomo 系客户端;Clash for Windows 用户
+必须使用 `/clash-legacy/<sub-id>` 获取兼容配置。
+
 ### Base64 与 JSON
 
 **Base64** 内容只是用换行符连接的分享链接,经标准 base64 编码(通过 `subEncrypt` 开关控制)。**JSON** 内容则将每个客户端包装为一份完整的 Xray 客户端配置 —— 一套固定的骨架(绑定到 127.0.0.1 的本地 SOCKS/HTTP 入站、DNS、路由、策略)加上一个指向该入站的 `proxy` 出站。3x-ui **对单个客户端输出单个配置对象,对多个客户端输出数组**,使用扁平的出站 `settings` 形式(`address`/`port`/`id`,`level: 8`),并从 `streamSettings` 中剥离 `sockopt`。

+ 115 - 1
internal/sub/clash_service.go

@@ -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

+ 79 - 0
internal/sub/clash_service_test.go

@@ -44,6 +44,85 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
 	}
 }
 
+func TestLegacyClashProxyCompatibility(t *testing.T) {
+	t.Run("keeps legacy vmess fields", func(t *testing.T) {
+		proxy := map[string]any{
+			"name": "vm", "type": "vmess", "server": "vm.example.com", "port": 443,
+			"uuid": "11111111-2222-4333-8444-555555555555", "alterId": 0, "cipher": "auto",
+			"udp": true, "network": "ws", "tls": true, "servername": "sni.example.com",
+			"ws-opts": map[string]any{"path": "/ws"}, "client-fingerprint": "chrome", "alpn": []string{"h2"},
+		}
+
+		got := legacyClashProxy(proxy)
+		if got == nil || got["type"] != "vmess" || got["network"] != "ws" {
+			t.Fatalf("legacy vmess was filtered or changed: %#v", got)
+		}
+		for _, field := range []string{"client-fingerprint", "alpn"} {
+			if _, exists := got[field]; exists {
+				t.Fatalf("Mihomo-only field %q leaked into legacy vmess: %#v", field, got)
+			}
+		}
+	})
+
+	t.Run("keeps legacy trojan fields", func(t *testing.T) {
+		proxy := map[string]any{
+			"name": "tr", "type": "trojan", "server": "tr.example.com", "port": 443,
+			"password": "secret", "udp": true, "network": "grpc", "tls": true,
+			"sni": "sni.example.com", "servername": "sni.example.com", "alpn": []string{"h2"},
+			"grpc-opts": map[string]any{"grpc-service-name": "svc"},
+		}
+
+		got := legacyClashProxy(proxy)
+		if got == nil || got["type"] != "trojan" || got["sni"] != "sni.example.com" {
+			t.Fatalf("legacy trojan was filtered or changed: %#v", got)
+		}
+		for _, field := range []string{"tls", "servername"} {
+			if _, exists := got[field]; exists {
+				t.Fatalf("field %q is not part of the legacy Trojan schema: %#v", field, got)
+			}
+		}
+
+		withoutTLS := cloneMap(proxy)
+		withoutTLS["tls"] = false
+		if got := legacyClashProxy(withoutTLS); got != nil {
+			t.Fatalf("Trojan without TLS must not reach Clash for Windows: %#v", got)
+		}
+	})
+
+	t.Run("keeps only legacy shadowsocks ciphers", func(t *testing.T) {
+		legacy := map[string]any{
+			"name": "ss", "type": "ss", "server": "ss.example.com", "port": 443,
+			"password": "secret", "cipher": "aes-256-gcm", "udp": true, "network": "tcp", "tls": false,
+		}
+		got := legacyClashProxy(legacy)
+		if got == nil || got["type"] != "ss" {
+			t.Fatalf("legacy Shadowsocks proxy was filtered: %#v", got)
+		}
+		for _, field := range []string{"network", "tls"} {
+			if _, exists := got[field]; exists {
+				t.Fatalf("field %q is not part of the legacy Shadowsocks schema: %#v", field, got)
+			}
+		}
+
+		ss2022 := cloneMap(legacy)
+		ss2022["cipher"] = "2022-blake3-aes-256-gcm"
+		if got := legacyClashProxy(ss2022); got != nil {
+			t.Fatalf("SS-2022 must not reach Clash for Windows: %#v", got)
+		}
+	})
+
+	for _, proxy := range []map[string]any{
+		{"name": "vl", "type": "vless"},
+		{"name": "hy", "type": "hysteria2"},
+		{"name": "xh", "type": "vmess", "cipher": "auto", "network": "xhttp"},
+		{"name": "reality", "type": "vmess", "cipher": "auto", "network": "tcp", "reality-opts": map[string]any{}},
+	} {
+		if got := legacyClashProxy(proxy); got != nil {
+			t.Fatalf("modern proxy reached Clash for Windows: %#v", got)
+		}
+	}
+}
+
 // TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
 // applySecurity (clash_service.go ~488): a regression that drops servername,
 // public-key, short-id, or client-fingerprint would hand mihomo a broken reality

+ 62 - 5
internal/sub/controller.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"html/template"
 	"io/fs"
@@ -23,6 +24,11 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 )
 
+const (
+	subMihomoPath      = "/mihomo/"
+	subClashLegacyPath = "/clash-legacy/"
+)
+
 // writeSubError translates a service-layer result into an HTTP response.
 // A nil error with no rows means the subId doesn't match anything (deleted
 // client, never-existed id) and becomes 404. A real error becomes 500. No
@@ -314,7 +320,40 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 		gClash := g.Group(a.subClashPath)
 		gClash.GET(":subid", a.subClashs)
 		gClash.HEAD(":subid", a.subClashs)
+		if sameSubscriptionPath(a.subClashPath, subMihomoPath) {
+			// The configured Clash path already provides the full Mihomo profile.
+		} else if owner := a.configuredSubscriptionPathOwner(subMihomoPath); owner != "" {
+			logger.Warningf("Mihomo subscription alias %q is unavailable because it conflicts with the configured %s path", subMihomoPath, owner)
+		} else {
+			gMihomo := g.Group(subMihomoPath)
+			gMihomo.GET(":subid", a.subClashs)
+			gMihomo.HEAD(":subid", a.subClashs)
+		}
+		if owner := a.configuredSubscriptionPathOwner(subClashLegacyPath); owner != "" {
+			logger.Warningf("Legacy Clash subscription alias %q is unavailable because it conflicts with the configured %s path", subClashLegacyPath, owner)
+		} else {
+			gLegacy := g.Group(subClashLegacyPath)
+			gLegacy.GET(":subid", a.subClashLegacy)
+			gLegacy.HEAD(":subid", a.subClashLegacy)
+		}
+	}
+}
+
+func sameSubscriptionPath(left, right string) bool {
+	return strings.Trim(left, "/") == strings.Trim(right, "/")
+}
+
+func (a *SUBController) configuredSubscriptionPathOwner(candidate string) string {
+	if sameSubscriptionPath(candidate, a.subPath) {
+		return "raw subscription"
+	}
+	if a.jsonEnabled && sameSubscriptionPath(candidate, a.subJsonPath) {
+		return "JSON subscription"
+	}
+	if a.clashEnabled && sameSubscriptionPath(candidate, a.subClashPath) {
+		return "Clash subscription"
 	}
+	return ""
 }
 
 // maybeServeSubPage renders the HTML info page when the request comes from a
@@ -411,7 +450,7 @@ func (a *SUBController) subs(c *gin.Context) {
 	if !a.enforceHwid(c) {
 		return
 	}
-	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
+	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false, false) {
 		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "clash")
 		return
@@ -777,11 +816,19 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
 }
 
 func (a *SUBController) subClashs(c *gin.Context) {
+	a.subClash(c, false)
+}
+
+func (a *SUBController) subClashLegacy(c *gin.Context) {
+	a.subClash(c, true)
+}
+
+func (a *SUBController) subClash(c *gin.Context, legacy bool) {
 	if strings.EqualFold(c.Query("view"), "raw") {
 		if !a.enforceHwid(c) {
 			return
 		}
-		if !a.serveClashBody(c, true) {
+		if !a.serveClashBody(c, true, legacy) {
 			writeSubError(c, nil)
 		}
 		a.recordSubscriptionFetch(c)
@@ -793,17 +840,27 @@ func (a *SUBController) subClashs(c *gin.Context) {
 	if !a.enforceHwid(c) {
 		return
 	}
-	if !a.serveClashBody(c, false) {
+	if !a.serveClashBody(c, false, legacy) {
 		writeSubError(c, nil)
 	}
 	a.recordSubscriptionFetch(c)
 }
 
-func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
+func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy bool) bool {
 	subId := c.Param("subid")
 	scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
-	clashSub, header, err := a.subClashService.GetClash(subId, host)
+	var clashSub, header string
+	var err error
+	if legacy {
+		clashSub, header, err = a.subClashService.GetClashLegacy(subId, host)
+	} else {
+		clashSub, header, err = a.subClashService.GetClash(subId, host)
+	}
 	if err != nil {
+		if errors.Is(err, errNoLegacyClashProxies) {
+			c.String(http.StatusUnprocessableEntity, err.Error())
+			return true
+		}
 		writeSubError(c, err)
 		return true
 	}

+ 216 - 0
internal/sub/controller_test.go

@@ -92,6 +92,89 @@ func TestNewSUBControllerOptions(t *testing.T) {
 	}
 }
 
+// A configured subscription path keeps its own format when it collides with a
+// hard-coded Clash alias, and the alias that does not collide still serves.
+func TestClashAliasesSkipConfiguredPathConflicts(t *testing.T) {
+	seedSubDB(t)
+	seedSubProtocolInbound(t, "s1", "vm", 4487, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
+	seedSubInbound(t, "s1", "vl", 4488, 2, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+
+	type check struct {
+		path    string
+		want    []string
+		notWant []string
+	}
+	// The full Mihomo profile is the only body carrying "type: vless"; the
+	// legacy one keeps VMess and drops it.
+	fullProfile := []string{"type: vmess", "type: vless"}
+	legacyProfile := []string{"type: vmess"}
+
+	tests := []struct {
+		name    string
+		options []SUBControllerOption
+		checks  []check
+	}{
+		{
+			name:    "raw path uses Mihomo alias",
+			options: []SUBControllerOption{WithSUBPath(subMihomoPath), WithSUBEncryption(false)},
+			checks: []check{
+				{path: "/mihomo/s1", want: []string{"vmess://"}, notWant: []string{"type: vmess"}},
+				{path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
+			},
+		},
+		{
+			name:    "JSON path uses legacy alias",
+			options: []SUBControllerOption{WithSUBJsonEnabled(true), WithSUBJsonPath(subClashLegacyPath)},
+			checks: []check{
+				{path: "/clash-legacy/s1", want: []string{`"outbounds"`}, notWant: []string{"type: vmess"}},
+				{path: "/mihomo/s1", want: fullProfile},
+			},
+		},
+		{
+			name:    "configured Clash path is already Mihomo alias",
+			options: []SUBControllerOption{WithSUBClashPath(subMihomoPath)},
+			checks: []check{
+				{path: "/mihomo/s1", want: fullProfile},
+				{path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
+			},
+		},
+		{
+			name:    "configured Clash path uses legacy alias",
+			options: []SUBControllerOption{WithSUBClashPath(subClashLegacyPath)},
+			checks: []check{
+				{path: "/clash-legacy/s1", want: fullProfile},
+				{path: "/mihomo/s1", want: fullProfile},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			router := gin.New()
+			NewSUBController(router.Group("/"), append([]SUBControllerOption{WithSUBClashEnabled(true)}, tt.options...)...)
+			for _, c := range tt.checks {
+				resp := httptest.NewRecorder()
+				router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+c.path, nil))
+				if resp.Code != http.StatusOK {
+					t.Fatalf("GET %s: status = %d, want 200; body=%s", c.path, resp.Code, resp.Body.String())
+				}
+				body := resp.Body.String()
+				for _, want := range c.want {
+					if !strings.Contains(body, want) {
+						t.Fatalf("GET %s: body is missing %q:\n%s", c.path, want, body)
+					}
+				}
+				for _, notWant := range c.notWant {
+					if strings.Contains(body, notWant) {
+						t.Fatalf("GET %s: body must not contain %q:\n%s", c.path, notWant, body)
+					}
+				}
+			}
+		})
+	}
+}
+
 func TestShouldAutoServeClash(t *testing.T) {
 	tests := []struct {
 		name         string
@@ -106,6 +189,7 @@ func TestShouldAutoServeClash(t *testing.T) {
 		{name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
 		{name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
 		{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
+		{name: "clash for windows preserves existing detection", autoDetect: true, clashEnabled: true, userAgent: "ClashforWindows/0.20.39", want: true},
 		{name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
 		{name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
 		{name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
@@ -396,6 +480,97 @@ func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
 	})
 }
 
+func TestExplicitMihomoAndLegacyClashEndpoints(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "vless", 4482, 1, `{"network":"tcp","security":"none"}`)
+	seedSubProtocolInbound(t, "s1", "vmess", 4483, 2, `{"network":"ws","security":"tls","wsSettings":{"path":"/ws"},"tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
+	gin.SetMode(gin.TestMode)
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
+
+	for _, path := range []string{"/clash/s1", "/mihomo/s1"} {
+		t.Run(path+" keeps the full Mihomo profile", func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, req)
+
+			if resp.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+			}
+			if body := resp.Body.String(); !strings.Contains(body, "type: vless") || !strings.Contains(body, "type: vmess") {
+				t.Fatalf("full profile must keep VLESS and VMess:\n%s", body)
+			}
+		})
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+	resp := httptest.NewRecorder()
+	router.ServeHTTP(resp, req)
+	if resp.Code != http.StatusOK {
+		t.Fatalf("legacy status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+	}
+	if body := resp.Body.String(); !strings.Contains(body, "type: vmess") || strings.Contains(body, "type: vless") {
+		t.Fatalf("legacy profile must keep VMess and remove VLESS:\n%s", body)
+	}
+}
+
+func TestLegacyClashEndpointExplainsWhenNoCompatibleProxyExists(t *testing.T) {
+	seedSubDB(t)
+	seedSubInbound(t, "s1", "vless", 4484, 1, `{"network":"tcp","security":"none"}`)
+	gin.SetMode(gin.TestMode)
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+	resp := httptest.NewRecorder()
+	newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+
+	if resp.Code != http.StatusUnprocessableEntity {
+		t.Fatalf("status = %d, want 422; body=%s", resp.Code, resp.Body.String())
+	}
+	if !strings.Contains(resp.Body.String(), "no Clash for Windows-compatible proxies") {
+		t.Fatalf("legacy endpoint did not explain the incompatibility: %s", resp.Body.String())
+	}
+}
+
+func TestLegacyClashEndpointIgnoresCustomMihomoRouting(t *testing.T) {
+	seedSubDB(t)
+	seedSubProtocolInbound(t, "s1", "vmess", 4485, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSUBController(
+		router.Group("/"),
+		WithSUBClashEnabled(true),
+		WithSUBClashEnableRouting(true),
+		WithSUBClashRules(`
+proxies:
+  - name: injected-modern-node
+    type: vless
+    server: modern.example.com
+    port: 443
+    uuid: 11111111-2222-4333-8444-555555555555
+proxy-groups:
+  - name: MIHOMO-ONLY
+    type: select
+    include-all: true
+rules:
+  - MATCH,MIHOMO-ONLY
+`),
+	)
+
+	req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+	resp := httptest.NewRecorder()
+	router.ServeHTTP(resp, req)
+
+	if resp.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+	}
+	body := resp.Body.String()
+	if strings.Contains(body, "injected-modern-node") || strings.Contains(body, "type: vless") || strings.Contains(body, "include-all") {
+		t.Fatalf("custom Mihomo routing leaked into legacy profile:\n%s", body)
+	}
+	if !strings.Contains(body, "type: vmess") || !strings.Contains(body, "MATCH,PROXY") {
+		t.Fatalf("legacy profile did not retain its compatible proxy and simple route:\n%s", body)
+	}
+}
+
 func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
 	seedSubDB(t)
 	seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
@@ -586,3 +761,44 @@ func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
 		t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
 	}
 }
+
+func TestStandardSubscriptionPreservesClashUserAgents(t *testing.T) {
+	seedSubDB(t)
+	seedSubProtocolInbound(t, "s1", "vm", 4905, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
+	gin.SetMode(gin.TestMode)
+	router := newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true})
+	for _, ua := range []string{"mihomo/1.19.12", "clash.meta", "Clash.Meta/1.19.12", "ClashX Meta/1.0", "ClashforWindows/0.20.39"} {
+		t.Run(ua, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
+			req.Header.Set("User-Agent", ua)
+			resp := httptest.NewRecorder()
+			router.ServeHTTP(resp, req)
+			if resp.Code != http.StatusOK || resp.Header().Get("Content-Type") != "application/yaml; charset=utf-8" || !strings.Contains(resp.Body.String(), "type: vmess") {
+				t.Fatalf("UA=%q: status=%d, content-type=%q; expected VMess YAML, body=%s", ua, resp.Code, resp.Header().Get("Content-Type"), resp.Body.String())
+			}
+		})
+	}
+}
+
+func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
+	for _, method := range []string{"chacha20-ietf-poly1305", "chacha20-poly1305"} {
+		t.Run(method, func(t *testing.T) {
+			seedSubDB(t)
+			ib := seedSubProtocolInbound(t, "s1", "ss", 4906, 1, `{"network":"tcp","security":"none"}`, model.Shadowsocks)
+			db := database.GetDB()
+			if err := db.Model(ib).Update("settings", fmt.Sprintf(`{"method":%q,"network":"tcp,udp"}`, method)).Error; err != nil {
+				t.Fatal(err)
+			}
+			if err := db.Model(&model.ClientRecord{}).Where("email = ?", "ss@e").Update("password", "test-password").Error; err != nil {
+				t.Fatal(err)
+			}
+			gin.SetMode(gin.TestMode)
+			req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
+			resp := httptest.NewRecorder()
+			newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
+			if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "type: ss") || !strings.Contains(resp.Body.String(), "cipher: chacha20-ietf-poly1305") {
+				t.Fatalf("method=%s: status=%d, body=%s", method, resp.Code, resp.Body.String())
+			}
+		})
+	}
+}