From 99072457d12e4f85b54853cbc8c83dbda25e696f Mon Sep 17 00:00:00 2001 From: Tomilla <5007859+Tomilla@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:02:18 +0800 Subject: [PATCH] feat(sub): auto-detect subscription formats --- internal/sub/controller.go | 324 +++++++++++++++++++++---- internal/sub/controller_test.go | 318 ++++++++++++++++++++++++ internal/sub/external_only_sub_test.go | 20 +- internal/sub/host_sub_test.go | 4 +- internal/sub/json_service.go | 5 +- internal/sub/sub.go | 59 ++++- internal/sub/sub_scale_test.go | 2 +- internal/sub/vless_route_sub_test.go | 2 +- 8 files changed, 668 insertions(+), 66 deletions(-) diff --git a/internal/sub/controller.go b/internal/sub/controller.go index d3befe06d..bccf694bd 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -10,10 +10,12 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "sync" "time" + "unicode" "github.com/gin-gonic/gin" @@ -54,13 +56,18 @@ type SUBController struct { subIncyEnableRouting bool subIncyRoutingRules string - subPath string - subJsonPath string - subClashPath string - jsonEnabled bool - clashEnabled bool - subEncrypt bool - updateInterval string + subPath string + subJsonPath string + subClashPath string + subClashAutoDetect bool + clashUserAgent *regexp.Regexp + jsonAutoDetect bool + jsonUserAgent *regexp.Regexp + jsonAlwaysArray bool + jsonEnabled bool + clashEnabled bool + subEncrypt bool + updateInterval string subService *SubService subJsonService *SubJsonService @@ -71,56 +78,198 @@ type SUBController struct { subTemplateCache map[string]*cachedSubTemplate } +type subControllerConfig struct { + subPath string + subJsonPath string + subClashPath string + + subClashAutoDetect bool + subClashUserAgentRegex string + subJsonAutoDetect bool + subJsonUserAgentRegex string + subJsonAlwaysArray bool + subJsonEnabled bool + subClashEnabled bool + + subEncrypt bool + remarkTemplate string + updateInterval string + + subJsonMux string + subJsonRules string + subJsonFinalMask string + subClashEnableRouting bool + subClashRules string + + subTitle string + subSupportURL string + subProfileURL string + subAnnounce string + subEnableRouting bool + subRoutingRules string + subHideSettings bool + + subIncyEnableRouting bool + subIncyRoutingRules string +} + +type SUBControllerOption func(*subControllerConfig) + +func WithSUBPath(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subPath = value } +} + +func WithSUBJsonPath(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonPath = value } +} + +func WithSUBClashPath(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashPath = value } +} + +func WithSUBClashAutoDetect(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashAutoDetect = value } +} + +func WithSUBClashUserAgentRegex(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashUserAgentRegex = value } +} + +func WithSUBJsonAutoDetect(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonAutoDetect = value } +} + +func WithSUBJsonUserAgentRegex(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value } +} + +func WithSUBJsonAlwaysArray(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonAlwaysArray = value } +} + +func WithSUBJsonEnabled(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonEnabled = value } +} + +func WithSUBClashEnabled(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashEnabled = value } +} + +func WithSUBEncryption(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subEncrypt = value } +} + +func WithSUBRemarkTemplate(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.remarkTemplate = value } +} + +func WithSUBUpdateInterval(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.updateInterval = value } +} + +func WithSUBJsonMux(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonMux = value } +} + +func WithSUBJsonRules(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonRules = value } +} + +func WithSUBJsonFinalMask(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subJsonFinalMask = value } +} + +func WithSUBClashEnableRouting(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashEnableRouting = value } +} + +func WithSUBClashRules(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashRules = value } +} + +func WithSUBTitle(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subTitle = value } +} + +func WithSUBSupportURL(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subSupportURL = value } +} + +func WithSUBProfileURL(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subProfileURL = value } +} + +func WithSUBAnnounce(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subAnnounce = value } +} + +func WithSUBEnableRouting(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subEnableRouting = value } +} + +func WithSUBRoutingRules(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subRoutingRules = value } +} + +func WithSUBHideSettings(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subHideSettings = value } +} + +func WithSUBIncyEnableRouting(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subIncyEnableRouting = value } +} + +func WithSUBIncyRoutingRules(value string) SUBControllerOption { + return func(config *subControllerConfig) { config.subIncyRoutingRules = value } +} + +func defaultSUBControllerConfig() subControllerConfig { + return subControllerConfig{ + subPath: "/sub/", + subJsonPath: "/json/", + subClashPath: "/clash/", + subEncrypt: true, + remarkTemplate: service.DefaultRemarkTemplate, + updateInterval: "12", + } +} + // NewSUBController creates a new subscription controller with the given configuration. -func NewSUBController( - g *gin.RouterGroup, - subPath string, - jsonPath string, - clashPath string, - jsonEnabled bool, - clashEnabled bool, - encrypt bool, - remarkTemplate string, - update string, - jsonMux string, - jsonRules string, - jsonFinalMask string, - clashEnableRouting bool, - clashRules string, - subTitle string, - subSupportUrl string, - subProfileUrl string, - subAnnounce string, - subEnableRouting bool, - subRoutingRules string, - subHideSettings bool, - subIncyEnableRouting bool, - subIncyRoutingRules string, -) *SUBController { - sub := NewSubService(remarkTemplate) +func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBController { + config := defaultSUBControllerConfig() + for _, option := range options { + option(&config) + } + + sub := NewSubService(config.remarkTemplate) a := &SUBController{ - subTitle: subTitle, - subSupportUrl: subSupportUrl, - subProfileUrl: subProfileUrl, - subAnnounce: subAnnounce, - subEnableRouting: subEnableRouting, - subRoutingRules: subRoutingRules, - subHideSettings: subHideSettings, + subTitle: config.subTitle, + subSupportUrl: config.subSupportURL, + subProfileUrl: config.subProfileURL, + subAnnounce: config.subAnnounce, + subEnableRouting: config.subEnableRouting, + subRoutingRules: config.subRoutingRules, + subHideSettings: config.subHideSettings, - subIncyEnableRouting: subIncyEnableRouting, - subIncyRoutingRules: subIncyRoutingRules, + subIncyEnableRouting: config.subIncyEnableRouting, + subIncyRoutingRules: config.subIncyRoutingRules, - subPath: subPath, - subJsonPath: jsonPath, - subClashPath: clashPath, - jsonEnabled: jsonEnabled, - clashEnabled: clashEnabled, - subEncrypt: encrypt, - updateInterval: update, + subPath: config.subPath, + subJsonPath: config.subJsonPath, + subClashPath: config.subClashPath, + subClashAutoDetect: config.subClashAutoDetect, + clashUserAgent: compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex), + jsonAutoDetect: config.subJsonAutoDetect, + jsonUserAgent: compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex), + jsonAlwaysArray: config.subJsonAlwaysArray, + jsonEnabled: config.subJsonEnabled, + clashEnabled: config.subClashEnabled, + subEncrypt: config.subEncrypt, + updateInterval: config.updateInterval, subService: sub, - subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub), - subClashService: NewSubClashService(clashEnableRouting, clashRules, sub), + subJsonService: NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub), + subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub), subTemplateCache: map[string]*cachedSubTemplate{}, } @@ -186,9 +335,22 @@ func (a *SUBController) maybeServeSubPage(c *gin.Context) bool { // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data. func (a *SUBController) subs(c *gin.Context) { + userAgent := c.GetHeader("User-Agent") if a.maybeServeSubPage(c) { + logSubscriptionRoute(userAgent, "html") return } + if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) { + logSubscriptionRoute(userAgent, "clash") + a.subClashs(c) + return + } + if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) { + logSubscriptionRoute(userAgent, "json") + a.serveJson(c, true, "application/json; charset=utf-8") + return + } + logSubscriptionRoute(userAgent, "raw") subId := c.Param("subid") scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) subReq := a.subService.ForRequest(host) @@ -224,6 +386,58 @@ func (a *SUBController) subs(c *gin.Context) { } } +// shouldAutoServeClash reports whether the standard subscription endpoint +// should return Clash/Mihomo YAML for this request. Browser HTML always wins, +// and disabling either auto-detection or Clash subscriptions preserves the +// existing raw/base64 behavior. +func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool { + return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex) +} + +func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool { + return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex) +} + +func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool { + // NewSUBController normally guarantees a compiled regex. Keep this guard so + // direct callers and partially initialized controllers fail closed. + if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil { + return false + } + return userAgentRegex.MatchString(userAgent) +} + +func logSubscriptionRoute(userAgent, branch string) { + logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent)) +} + +func sanitizeUserAgentForLog(userAgent string) string { + clean := strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, userAgent) + runes := []rune(clean) + if len(runes) > 512 { + return string(runes[:512]) + } + return clean +} + +func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + pattern = defaultPattern + } + compiled, err := regexp.Compile(pattern) + if err == nil { + return compiled + } + logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err) + return regexp.MustCompile(defaultPattern) +} + // serveSubPage renders internal/web/dist/subpage.html for the current subscription // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount — // we inject that here, along with window.X_UI_BASE_PATH so the @@ -387,9 +601,13 @@ func (a *SUBController) subJsons(c *gin.Context) { if a.maybeServeSubPage(c) { return } + a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8") +} + +func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) { subId := c.Param("subid") scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) - jsonSub, header, err := a.subJsonService.GetJson(subId, host) + jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray) if err != nil || len(jsonSub) == 0 { writeSubError(c, err) } else { @@ -399,7 +617,7 @@ func (a *SUBController) subJsons(c *gin.Context) { } a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) - c.String(200, jsonSub) + c.Data(200, contentType, []byte(jsonSub)) } } diff --git a/internal/sub/controller_test.go b/internal/sub/controller_test.go index 3e6b5ce94..9a0a07e14 100644 --- a/internal/sub/controller_test.go +++ b/internal/sub/controller_test.go @@ -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 { diff --git a/internal/sub/external_only_sub_test.go b/internal/sub/external_only_sub_test.go index 8b5b62a19..8d5941ef1 100644 --- a/internal/sub/external_only_sub_test.go +++ b/internal/sub/external_only_sub_test.go @@ -1,6 +1,7 @@ package sub import ( + "encoding/json" "strings" "testing" @@ -27,7 +28,8 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) { base := NewSubService("") - jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com") + jsonService := NewSubJsonService("", "", "", base) + jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false) if err != nil { t.Fatalf("GetJson err = %v", err) } @@ -37,6 +39,22 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) { if !strings.Contains(jsonOut, "DE-Provider") { t.Fatalf("GetJson missing external remark: %s", jsonOut) } + var config map[string]any + if err := json.Unmarshal([]byte(jsonOut), &config); err != nil { + t.Fatalf("legacy GetJson must return an object for a single profile: %v; body=%s", err, jsonOut) + } + + standardOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", true) + if err != nil { + t.Fatalf("standards-compliant GetJson err = %v", err) + } + var configs []map[string]any + if err := json.Unmarshal([]byte(standardOut), &configs); err != nil { + t.Fatalf("standards-compliant GetJson must return an array for a single profile: %v; body=%s", err, standardOut) + } + if len(configs) != 1 { + t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs)) + } clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com") if err != nil { diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 09213d1bb..465411ab9 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -281,7 +281,7 @@ func TestSub_HostSockoptJSON(t *testing.T) { SockoptParams: `{"tcpFastOpen":true}`, }) js := NewSubJsonService("", "", "", NewSubService("")) - out, _, err := js.GetJson("s1", "req.example.com") + out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) } @@ -299,7 +299,7 @@ func TestSub_HostMuxJSON(t *testing.T) { MuxParams: `{"enabled":true,"concurrency":8}`, }) js := NewSubJsonService("", "", "", NewSubService("")) - out, _, err := js.GetJson("s1", "req.example.com") + out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) } diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 7e77c38f6..37fcbe66f 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -57,7 +57,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S } // GetJson generates a JSON subscription configuration for the given subscription ID and host. -func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { +func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) { subReq := s.SubService.ForRequest(host) subReq.subscriptionBody = true inbounds, err := subReq.getInboundsBySubId(subId) @@ -124,9 +124,8 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err } traffic, _ := subReq.AggregateTrafficByEmails(emails) - // Combile outbounds var finalJson []byte - if len(configArray) == 1 { + if len(configArray) == 1 && !alwaysReturnArray { finalJson, _ = json.MarshalIndent(configArray[0], "", " ") } else { finalJson, _ = json.MarshalIndent(configArray, "", " ") diff --git a/internal/sub/sub.go b/internal/sub/sub.go index 858af331b..aef61777a 100644 --- a/internal/sub/sub.go +++ b/internal/sub/sub.go @@ -89,6 +89,31 @@ func (s *Server) initRouter() (*gin.Engine, error) { return nil, err } + subClashAutoDetect, err := s.settingService.GetSubClashAutoDetect() + if err != nil { + subClashAutoDetect = false + } + + subJsonAutoDetect, err := s.settingService.GetSubJsonAutoDetect() + if err != nil { + subJsonAutoDetect = false + } + + subJsonAlwaysArray, err := s.settingService.GetSubJsonAlwaysArray() + if err != nil { + subJsonAlwaysArray = false + } + + subJsonUserAgentRegex, err := s.settingService.GetSubJsonUserAgentRegex() + if err != nil { + subJsonUserAgentRegex = service.DefaultSubJsonUserAgentRegex + } + + subClashUserAgentRegex, err := s.settingService.GetSubClashUserAgentRegex() + if err != nil { + subClashUserAgentRegex = service.DefaultSubClashUserAgentRegex + } + // Set base_path based on LinksPath for template rendering // Ensure LinksPath ends with "/" for proper asset URL generation basePath := LinksPath @@ -239,11 +264,35 @@ func (s *Server) initRouter() (*gin.Engine, error) { g := engine.Group("/") - s.sub = NewSUBController( - g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates, - SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl, - SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings, - SubIncyEnableRouting, SubIncyRoutingRules) + s.sub = NewSUBController(g, + WithSUBPath(LinksPath), + WithSUBJsonPath(JsonPath), + WithSUBClashPath(ClashPath), + WithSUBClashAutoDetect(subClashAutoDetect), + WithSUBClashUserAgentRegex(subClashUserAgentRegex), + WithSUBJsonAutoDetect(subJsonAutoDetect), + WithSUBJsonUserAgentRegex(subJsonUserAgentRegex), + WithSUBJsonAlwaysArray(subJsonAlwaysArray), + WithSUBJsonEnabled(subJsonEnable), + WithSUBClashEnabled(subClashEnable), + WithSUBEncryption(Encrypt), + WithSUBRemarkTemplate(RemarkTemplate), + WithSUBUpdateInterval(SubUpdates), + WithSUBJsonMux(SubJsonMux), + WithSUBJsonRules(SubJsonRules), + WithSUBJsonFinalMask(SubJsonFinalMask), + WithSUBClashEnableRouting(SubClashEnableRouting), + WithSUBClashRules(SubClashRules), + WithSUBTitle(SubTitle), + WithSUBSupportURL(SubSupportUrl), + WithSUBProfileURL(SubProfileUrl), + WithSUBAnnounce(SubAnnounce), + WithSUBEnableRouting(SubEnableRouting), + WithSUBRoutingRules(SubRoutingRules), + WithSUBHideSettings(SubHideSettings), + WithSUBIncyEnableRouting(SubIncyEnableRouting), + WithSUBIncyRoutingRules(SubIncyRoutingRules), + ) return engine, nil } diff --git a/internal/sub/sub_scale_test.go b/internal/sub/sub_scale_test.go index c9858779e..11040cc16 100644 --- a/internal/sub/sub_scale_test.go +++ b/internal/sub/sub_scale_test.go @@ -212,7 +212,7 @@ func TestGetSubsScale(t *testing.T) { jsonSvc := NewSubJsonService("", "", "", &SubService{}) start = time.Now() for range reps { - body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com") + body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) } diff --git a/internal/sub/vless_route_sub_test.go b/internal/sub/vless_route_sub_test.go index c663272d4..35d146820 100644 --- a/internal/sub/vless_route_sub_test.go +++ b/internal/sub/vless_route_sub_test.go @@ -52,7 +52,7 @@ func TestSub_HostVlessRoute_JSON(t *testing.T) { seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"}) js := NewSubJsonService("", "", "", NewSubService("")) - out, _, err := js.GetJson("s1", "req.example.com") + out, _, err := js.GetJson("s1", "req.example.com", false) if err != nil { t.Fatalf("GetJson: %v", err) }