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
+26 -5
View File
@@ -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
View File
@@ -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
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
+79
View File
@@ -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
View File
@@ -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
}
+216
View File
@@ -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())
}
})
}
}