mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 09:06:07 +00:00
feat(sub): auto-detect subscription formats
This commit is contained in:
+271
-53
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user