feat(sub): auto-detect subscription formats

This commit is contained in:
Tomilla
2026-07-06 10:02:18 +08:00
parent 8bb9a8c6d5
commit 99072457d1
8 changed files with 668 additions and 66 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) {
}
}
// 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))
}
}
+318
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,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 {
+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
@@ -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)
}
+2 -3
View File
@@ -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, "", " ")
+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)
}