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

* feat(sub): add legacy Clash subscription endpoint

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

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

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

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

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

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

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

---------

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