feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)

* feat(settings): add subscription format controls

* feat(sub): auto-detect subscription formats

* fix(xray): validate balancer regexes before save

* Revert "fix(xray): validate balancer regexes before save"

This reverts commit 8a208ce71b.

* doc(endpoints): align indent spaces

* doc(settings): improve error message formatting in validateSubUserAgentRegex

- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
  in the DB and inherited as the runtime default at read time

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Tomi lla
2026-07-14 19:01:40 +08:00
committed by GitHub
parent f2b17397f4
commit 129f50d92a
40 changed files with 1588 additions and 89 deletions
+271 -53
View File
@@ -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) {
}
}
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 {
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 = strings.TrimSpace(defaultPattern)
}
if pattern == "" {
return nil
}
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)
if strings.TrimSpace(defaultPattern) == "" {
return nil
}
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))
}
}
+329
View File
@@ -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,327 @@ 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: "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: "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 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 writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+19 -1
View File
@@ -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 {
+2 -2
View File
@@ -304,7 +304,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)
}
@@ -322,7 +322,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)
}
+2 -3
View File
@@ -58,7 +58,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)
@@ -125,9 +125,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, "", " ")
+54 -5
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}