From 64b6e43e2b5d504a8034d33e9de224f3fbd1751e Mon Sep 17 00:00:00 2001 From: duqigit <916475145@qq.com> Date: Fri, 11 Sep 2026 03:58:55 +0800 Subject: [PATCH] feat(sub): add legacy Clash subscription endpoint (#6338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/content/docs/en/config/subscription.mdx | 31 ++- docs/content/docs/zh/config/subscription.mdx | 27 ++- internal/sub/clash_service.go | 116 +++++++++- internal/sub/clash_service_test.go | 79 +++++++ internal/sub/controller.go | 67 +++++- internal/sub/controller_test.go | 216 +++++++++++++++++++ 6 files changed, 520 insertions(+), 16 deletions(-) diff --git a/docs/content/docs/en/config/subscription.mdx b/docs/content/docs/en/config/subscription.mdx index 2be1c3f03..453418975 100644 --- a/docs/content/docs/en/config/subscription.mdx +++ b/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/` for Clash Verge Rev, Mihomo, and other maintained +Mihomo-based clients. Use `/clash-legacy/` 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/` for a compatible profile. + ### Base64 vs JSON The **Base64** body is just the newline-joined share links, standard-base64 diff --git a/docs/content/docs/zh/config/subscription.mdx b/docs/content/docs/zh/config/subscription.mdx index b5bbf811d..173d5fe9e 100644 --- a/docs/content/docs/zh/config/subscription.mdx +++ b/docs/content/docs/zh/config/subscription.mdx @@ -36,14 +36,31 @@ https://:// **格式由路径决定**,每种格式都有各自的启用开关: -| 格式 | 路径 | 启用方式 | 输出 | -| --------------------- | --------- | ---------------- | --------------------------------------------------- | -| **原始链接** | `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/`。已经停止维护的 Clash for Windows 应使用 +`/clash-legacy/`;旧版端点只保留兼容的 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/` 获取兼容配置。 + ### 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`。 diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index dc6529d1a..7bcd0d7ca 100644 --- a/internal/sub/clash_service.go +++ b/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 diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 4dbf9a431..f87851cf5 100644 --- a/internal/sub/clash_service_test.go +++ b/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 diff --git a/internal/sub/controller.go b/internal/sub/controller.go index e60aaf1e5..6565d07cb 100644 --- a/internal/sub/controller.go +++ b/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,9 +320,42 @@ 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 // browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html). // It reports whether the request was handled. The remark template's per-client @@ -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 } diff --git a/internal/sub/controller_test.go b/internal/sub/controller_test.go index 2c3f43e38..7af2aa6ac 100644 --- a/internal/sub/controller_test.go +++ b/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()) + } + }) + } +}