mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 00:56:08 +00:00
feat(sub): auto-detect subscription formats
This commit is contained in:
@@ -2,10 +2,18 @@ package sub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// newTestSUBController builds a controller with just the bits loadSubTemplate
|
||||
@@ -14,6 +22,316 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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: "stash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "STASH/2.6.0", want: true},
|
||||
{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
|
||||
{name: "xray raw fallback", autoDetect: true, clashEnabled: true, userAgent: "v2rayNG/1.10.0"},
|
||||
{name: "v2raya raw fallback", autoDetect: true, clashEnabled: true, userAgent: "v2rayA/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", "", service.DefaultSubJsonUserAgentRegex)
|
||||
for _, userAgent := range []string{"Streisand/1.6.32", "streisand 1.6.32"} {
|
||||
if !shouldAutoServeJson(true, true, false, userAgent, configured) {
|
||||
t.Errorf("default Xray JSON regex did not match %q", userAgent)
|
||||
}
|
||||
}
|
||||
for _, userAgent := range []string{"v2rayNG/1.10.0", "v2rayA/2.2", "v2rayN/7.0", "Happ/2.0", "ktor-client/3.0", "CustomClient/1.0"} {
|
||||
if shouldAutoServeJson(true, true, false, userAgent, configured) {
|
||||
t.Errorf("default Xray JSON regex unexpectedly matched %q", userAgent)
|
||||
}
|
||||
}
|
||||
if shouldAutoServeJson(false, true, false, "Streisand/1.6.32", configured) {
|
||||
t.Fatal("disabled Xray JSON auto-detection matched")
|
||||
}
|
||||
if shouldAutoServeJson(true, false, false, "Streisand/1.6.32", configured) {
|
||||
t.Fatal("disabled JSON endpoint matched")
|
||||
}
|
||||
if shouldAutoServeJson(true, true, true, "Streisand/1.6.32", configured) {
|
||||
t.Fatal("browser HTML request matched Xray JSON")
|
||||
}
|
||||
}
|
||||
|
||||
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, "v2rayNG/1.10.0", configured) {
|
||||
t.Fatal("default Xray JSON User-Agent matched after a custom regex replaced it")
|
||||
}
|
||||
}
|
||||
|
||||
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 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("v2rayNG preserves raw base64", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
|
||||
req.Header.Set("User-Agent", "v2rayNG/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", "Streisand/1.6.32")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAutoDetect: 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/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 writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user