Files
3x-ui/internal/sub/controller_test.go
T
Matt Van Horn 61885c9126 fix(sub): isolate Incy subscription routing from Happ headers (#6810)
* sub: isolate Incy subscription routing from Happ headers

The resolved Incy line is written to the body only when IsIncyClient
matches the subscription User-Agent, Incy routing is enabled, and the
resolved text is non-blank. ApplyCommonHeaders computes that same Incy
check once and, for a matching client, skips the Happ routing-header
block, leaving Routing-Enable, the Happ Routing value, and the JSON
routing header unset. ApplyIncyHeaders is called with that incyClient
flag. The new test requests the subscription as INCY/1.0.0/ios and as
Happ/1.2.0 and expects the Incy response to end with the incy:// line
and to have empty Routing headers, and the Happ response to keep the
happ:// Routing header with Routing-Enable true and no incy:// line in
the body.

Fixes #6062

* fix(sub): keep the shared JSON routing profile for INCY clients

The INCY isolation wrapped the whole Routing block, JSON fallback
included, in !incyClient. INCY therefore lost the subJsonRoutingRules
profile that #6402 sends as the Routing header so Happ and INCY fetch
the geo files the baked rules reference, on the raw, JSON and Clash
endpoints alike, while every other client kept it, incy:// deeplinks
included.

INCY accepts a routing link of any scheme in that header
(docs.incy.cc/en/routing), so only the Happ tab is Happ-only: an INCY
request now reads it as blank and falls back to the shared profile,
as the setting text promises. Where the raw body carries INCY's own
profile the header is dropped, because INCY ranks a Routing header
above a base64 body line (docs.incy.cc/en/app-management) and the
shared profile would override it, which is #6062 again.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-13 16:01:32 +02:00

860 lines
32 KiB
Go

package sub
import (
"bytes"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
var testDistFS = fstest.MapFS{
"dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
}
// newTestSUBController builds a controller with just the bits loadSubTemplate
// needs, so the template tests don't require a database.
func newTestSUBController() *SUBController {
return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
}
type subscriptionTestRouterConfig struct {
clashAutoDetect bool
clashUserAgentRegex string
jsonAutoDetect bool
jsonUserAgentRegex string
jsonAlwaysArray bool
}
func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
router := gin.New()
options := []SUBControllerOption{
WithSUBJsonEnabled(true),
WithSUBClashEnabled(true),
}
if config.clashAutoDetect {
options = append(options, WithSUBClashAutoDetect(true))
}
if config.clashUserAgentRegex != "" {
options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
}
if config.jsonAutoDetect {
options = append(options, WithSUBJsonAutoDetect(true))
}
if config.jsonUserAgentRegex != "" {
options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
}
if config.jsonAlwaysArray {
options = append(options, WithSUBJsonAlwaysArray(true))
}
NewSUBController(router.Group("/"), options...)
return router
}
func TestNewSUBControllerOptions(t *testing.T) {
gin.SetMode(gin.TestMode)
defaults := NewSUBController(gin.New().Group("/"))
if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
}
if !defaults.subEncrypt || defaults.updateInterval != "12" {
t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
}
if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
}
if defaults.jsonEnabled || defaults.clashEnabled {
t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
}
configured := NewSUBController(
gin.New().Group("/"),
WithSUBPath("/custom/"),
WithSUBJsonEnabled(true),
WithSUBEncryption(false),
WithSUBUpdateInterval("24"),
)
if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
}
}
// 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
autoDetect bool
clashEnabled bool
wantsHTML bool
userAgent string
pattern string
want bool
}{
{name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
{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"},
{name: "empty raw fallback", autoDetect: true, clashEnabled: true},
{name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
{name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
{name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
if got != tt.want {
t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
}
})
}
}
func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
t.Fatal("configured User-Agent regex did not match")
}
if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
t.Fatal("built-in User-Agent matched after a custom regex replaced it")
}
}
func TestShouldAutoServeJson(t *testing.T) {
configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
if !shouldAutoServeJson(true, true, false, userAgent, configured) {
t.Errorf("configured Xray JSON regex did not match %q", userAgent)
}
}
for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
if shouldAutoServeJson(true, true, false, userAgent, configured) {
t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
}
}
if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
t.Fatal("disabled Xray JSON auto-detection matched")
}
if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
t.Fatal("disabled JSON endpoint matched")
}
if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
t.Fatal("browser HTML request matched Xray JSON")
}
empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
if empty != nil {
t.Fatal("empty Xray JSON default should not compile to a matcher")
}
if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
t.Fatal("empty Xray JSON default should not auto-serve")
}
}
func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
t.Fatal("configured Xray JSON User-Agent regex did not match")
}
if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
t.Fatal("unrelated User-Agent matched after a custom regex was configured")
}
}
func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
if !compiled.MatchString("Mihomo/1.19") {
t.Fatal("invalid regex did not fall back to the default pattern")
}
}
func TestSanitizeUserAgentForLog(t *testing.T) {
if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0 forged line" {
t.Fatalf("sanitizeUserAgentForLog() = %q", got)
}
long := strings.Repeat("界", 513)
if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
}
}
func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
t.Helper()
db := database.GetDB()
secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"
email := tag + "@e"
settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret)
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}",
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed mtproto inbound %s: %v", tag, err)
}
client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
}
func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
seedSubDB(t)
seedSubMtprotoInbound(t, "s1", "tg", 4490)
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("fallback response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "tg://proxy") {
t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
}
}
func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
t.Run("recognized client receives YAML", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, want YAML", got)
}
if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
}
})
t.Run("Clash wins when both format regexes match", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Hybrid/1.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
clashUserAgentRegex: `(?i)^hybrid/`,
jsonAutoDetect: true,
jsonUserAgentRegex: `(?i)^hybrid/`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, want Clash YAML", got)
}
})
t.Run("disabled setting preserves raw base64", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("raw response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
})
t.Run("configured regex controls detection", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Mihomo/1.19")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
clashUserAgentRegex: `(?i)^custom-client/`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
}
})
t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "GenericClient/1.10.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
jsonAutoDetect: true,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("raw response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
})
t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "JsonClient/1.6.32")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
jsonAutoDetect: true,
jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("Content-Type = %q, want JSON", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
}
})
t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want legacy text/plain", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
t.Fatalf("legacy explicit JSON body is not an object: %s", body)
}
})
t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want legacy text/plain", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
}
})
}
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"}`)
gin.SetMode(gin.TestMode)
oldDistFS := distFS
distFS = testDistFS
t.Cleanup(func() { distFS = oldDistFS })
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
tests := []struct {
name string
path string
contentType string
disposition string
bodyContains string
}{
{name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
{name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
req.Header.Set("Accept", "text/html")
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 got := resp.Header().Get("Content-Type"); got != tt.contentType {
t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
}
if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
}
if !strings.Contains(resp.Body.String(), tt.bodyContains) {
t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
}
})
}
for _, path := range []string{"/json/s1", "/clash/s1"} {
t.Run(path+" browser page", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
req.Header.Set("Accept", "text/html")
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 got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("Content-Type = %q, want HTML", got)
}
})
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
t.Helper()
tmpl, err := a.loadSubTemplate(dir)
if err != nil {
t.Fatalf("loadSubTemplate: unexpected error: %v", err)
}
if tmpl == nil {
t.Fatal("loadSubTemplate: expected a template, got nil")
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("execute: %v", err)
}
return buf.String()
}
func TestLoadSubTemplate_RendersIndex(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
if want := `<h1>abc-123</h1>`; got != want {
t.Fatalf("rendered = %q, want %q", got, want)
}
}
func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
got := renderTemplate(t, newTestSUBController(), dir, nil)
if got != "from-sub" {
t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
}
}
func TestLoadSubTemplate_FallbackCases(t *testing.T) {
a := newTestSUBController()
t.Run("missing dir", func(t *testing.T) {
tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
t.Run("path is a file not a dir", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "index.html")
writeFile(t, file, `whatever`)
tmpl, err := a.loadSubTemplate(file)
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
t.Run("dir without template file", func(t *testing.T) {
tmpl, err := a.loadSubTemplate(t.TempDir())
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
}
func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
dir := t.TempDir()
// Unterminated action — html/template fails to parse this.
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
tmpl, err := newTestSUBController().loadSubTemplate(dir)
if err == nil {
t.Fatal("expected a parse error for a malformed template, got nil")
}
if tmpl != nil {
t.Fatalf("expected nil template on parse error, got %v", tmpl)
}
}
func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
a := newTestSUBController()
dir := t.TempDir()
path := filepath.Join(dir, "index.html")
// v1 with a fixed mtime.
writeFile(t, path, `v1`)
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if err := os.Chtimes(path, t1, t1); err != nil {
t.Fatalf("chtimes: %v", err)
}
first, err := a.loadSubTemplate(dir)
if err != nil || first == nil {
t.Fatalf("first load: (%v, %v)", first, err)
}
// Same mtime → cache hit returns the identical parsed template.
second, err := a.loadSubTemplate(dir)
if err != nil {
t.Fatalf("second load: %v", err)
}
if second != first {
t.Fatal("expected cache hit to return the same *template.Template pointer")
}
// New content + newer mtime → cache invalidated, fresh content served.
writeFile(t, path, `v2`)
t2 := t1.Add(time.Hour)
if err := os.Chtimes(path, t2, t2); err != nil {
t.Fatalf("chtimes: %v", err)
}
third, err := a.loadSubTemplate(dir)
if err != nil || third == nil {
t.Fatalf("third load: (%v, %v)", third, err)
}
if third == first {
t.Fatal("expected cache invalidation to re-parse the template after mtime change")
}
var buf bytes.Buffer
if err := third.Execute(&buf, nil); err != nil {
t.Fatalf("execute: %v", err)
}
if buf.String() != "v2" {
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())
}
})
}
}
func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
const happRules = "happ://routing/onadd/existing-rules"
const incyRules = "incy://routing/onadd/AbC+/="
sharedRules := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"Name":"shared","DirectSites":["geosite:category-ru"]}`))
cases := []struct {
name string
path string
userAgent string
incyRouting bool
wantRouting string
wantRoutingEnable string
wantIncyLine bool
}{
{name: "happ keeps its own tab and never sees the incy line", path: "/sub/s1", userAgent: "Happ/1.2.0", incyRouting: true, wantRouting: happRules, wantRoutingEnable: "true"},
{name: "incy without its own tab gets the shared json profile", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", wantRouting: sharedRules},
{name: "incy body profile is not outranked by a routing header", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantIncyLine: true},
{name: "incy json documents keep the shared profile header", path: "/json/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantRouting: sharedRules},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
router := gin.New()
NewSUBController(router.Group("/"),
WithSUBEncryption(false),
WithSUBJsonEnabled(true),
WithSUBEnableRouting(true),
WithSUBRoutingRules(happRules),
WithSUBJsonRoutingRules(sharedRules),
WithSUBIncyEnableRouting(tc.incyRouting),
WithSUBIncyRoutingRules(incyRules),
)
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tc.path, nil)
req.Header.Set("User-Agent", tc.userAgent)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
body := resp.Body.String()
if resp.Code != http.StatusOK || !strings.Contains(body, "vless") {
t.Fatalf("status=%d, body=%s", resp.Code, body)
}
if got := resp.Header().Get("Routing"); got != tc.wantRouting {
t.Fatalf("Routing = %q, want %q", got, tc.wantRouting)
}
if got := resp.Header().Get("Routing-Enable"); got != tc.wantRoutingEnable {
t.Fatalf("Routing-Enable = %q, want %q", got, tc.wantRoutingEnable)
}
if got := strings.HasSuffix(body, incyRules+"\n"); got != tc.wantIncyLine || (!got && strings.Contains(body, incyRules)) {
t.Fatalf("incy line at body end = %v, want %v; body=%s", got, tc.wantIncyLine, body)
}
})
}
}