mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-14 01:45:00 +03:00
61885c9126
* sub: isolate Incy subscription routing from Happ headers The resolved Incy line is written to the body only when IsIncyClient matches the subscription User-Agent, Incy routing is enabled, and the resolved text is non-blank. ApplyCommonHeaders computes that same Incy check once and, for a matching client, skips the Happ routing-header block, leaving Routing-Enable, the Happ Routing value, and the JSON routing header unset. ApplyIncyHeaders is called with that incyClient flag. The new test requests the subscription as INCY/1.0.0/ios and as Happ/1.2.0 and expects the Incy response to end with the incy:// line and to have empty Routing headers, and the Happ response to keep the happ:// Routing header with Routing-Enable true and no incy:// line in the body. Fixes #6062 * fix(sub): keep the shared JSON routing profile for INCY clients The INCY isolation wrapped the whole Routing block, JSON fallback included, in !incyClient. INCY therefore lost the subJsonRoutingRules profile that #6402 sends as the Routing header so Happ and INCY fetch the geo files the baked rules reference, on the raw, JSON and Clash endpoints alike, while every other client kept it, incy:// deeplinks included. INCY accepts a routing link of any scheme in that header (docs.incy.cc/en/routing), so only the Happ tab is Happ-only: an INCY request now reads it as blank and falls back to the shared profile, as the setting text promises. Where the raw body carries INCY's own profile the header is dropped, because INCY ranks a Routing header above a base64 body line (docs.incy.cc/en/app-management) and the shared profile would override it, which is #6062 again. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
860 lines
32 KiB
Go
860 lines
32 KiB
Go
package sub
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
var testDistFS = fstest.MapFS{
|
|
"dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
|
|
}
|
|
|
|
// newTestSUBController builds a controller with just the bits loadSubTemplate
|
|
// needs, so the template tests don't require a database.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A configured subscription path keeps its own format when it collides with a
|
|
// hard-coded Clash alias, and the alias that does not collide still serves.
|
|
func TestClashAliasesSkipConfiguredPathConflicts(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubProtocolInbound(t, "s1", "vm", 4487, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
|
|
seedSubInbound(t, "s1", "vl", 4488, 2, `{"network":"tcp","security":"none"}`)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
type check struct {
|
|
path string
|
|
want []string
|
|
notWant []string
|
|
}
|
|
// The full Mihomo profile is the only body carrying "type: vless"; the
|
|
// legacy one keeps VMess and drops it.
|
|
fullProfile := []string{"type: vmess", "type: vless"}
|
|
legacyProfile := []string{"type: vmess"}
|
|
|
|
tests := []struct {
|
|
name string
|
|
options []SUBControllerOption
|
|
checks []check
|
|
}{
|
|
{
|
|
name: "raw path uses Mihomo alias",
|
|
options: []SUBControllerOption{WithSUBPath(subMihomoPath), WithSUBEncryption(false)},
|
|
checks: []check{
|
|
{path: "/mihomo/s1", want: []string{"vmess://"}, notWant: []string{"type: vmess"}},
|
|
{path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
|
|
},
|
|
},
|
|
{
|
|
name: "JSON path uses legacy alias",
|
|
options: []SUBControllerOption{WithSUBJsonEnabled(true), WithSUBJsonPath(subClashLegacyPath)},
|
|
checks: []check{
|
|
{path: "/clash-legacy/s1", want: []string{`"outbounds"`}, notWant: []string{"type: vmess"}},
|
|
{path: "/mihomo/s1", want: fullProfile},
|
|
},
|
|
},
|
|
{
|
|
name: "configured Clash path is already Mihomo alias",
|
|
options: []SUBControllerOption{WithSUBClashPath(subMihomoPath)},
|
|
checks: []check{
|
|
{path: "/mihomo/s1", want: fullProfile},
|
|
{path: "/clash-legacy/s1", want: legacyProfile, notWant: []string{"type: vless"}},
|
|
},
|
|
},
|
|
{
|
|
name: "configured Clash path uses legacy alias",
|
|
options: []SUBControllerOption{WithSUBClashPath(subClashLegacyPath)},
|
|
checks: []check{
|
|
{path: "/clash-legacy/s1", want: fullProfile},
|
|
{path: "/mihomo/s1", want: fullProfile},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
router := gin.New()
|
|
NewSUBController(router.Group("/"), append([]SUBControllerOption{WithSUBClashEnabled(true)}, tt.options...)...)
|
|
for _, c := range tt.checks {
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com"+c.path, nil))
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("GET %s: status = %d, want 200; body=%s", c.path, resp.Code, resp.Body.String())
|
|
}
|
|
body := resp.Body.String()
|
|
for _, want := range c.want {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("GET %s: body is missing %q:\n%s", c.path, want, body)
|
|
}
|
|
}
|
|
for _, notWant := range c.notWant {
|
|
if strings.Contains(body, notWant) {
|
|
t.Fatalf("GET %s: body must not contain %q:\n%s", c.path, notWant, body)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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: "clash for windows preserves existing detection", autoDetect: true, clashEnabled: true, userAgent: "ClashforWindows/0.20.39", 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 seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"
|
|
email := tag + "@e"
|
|
settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret)
|
|
ib := &model.Inbound{
|
|
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
|
Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}",
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("seed mtproto inbound %s: %v", tag, err)
|
|
}
|
|
client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true}
|
|
if err := db.Create(client).Error; err != nil {
|
|
t.Fatalf("seed client %s: %v", email, err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
|
t.Fatalf("seed client_inbound %s: %v", email, err)
|
|
}
|
|
}
|
|
|
|
func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubMtprotoInbound(t, "s1", "tg", 4490)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
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, 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("fallback response is not base64: %v", err)
|
|
}
|
|
if !strings.Contains(string(decoded), "tg://proxy") {
|
|
t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
|
|
}
|
|
}
|
|
|
|
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 TestExplicitMihomoAndLegacyClashEndpoints(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "vless", 4482, 1, `{"network":"tcp","security":"none"}`)
|
|
seedSubProtocolInbound(t, "s1", "vmess", 4483, 2, `{"network":"ws","security":"tls","wsSettings":{"path":"/ws"},"tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
|
|
gin.SetMode(gin.TestMode)
|
|
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
|
|
|
|
for _, path := range []string{"/clash/s1", "/mihomo/s1"} {
|
|
t.Run(path+" keeps the full Mihomo profile", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
if body := resp.Body.String(); !strings.Contains(body, "type: vless") || !strings.Contains(body, "type: vmess") {
|
|
t.Fatalf("full profile must keep VLESS and VMess:\n%s", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("legacy status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
if body := resp.Body.String(); !strings.Contains(body, "type: vmess") || strings.Contains(body, "type: vless") {
|
|
t.Fatalf("legacy profile must keep VMess and remove VLESS:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestLegacyClashEndpointExplainsWhenNoCompatibleProxyExists(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "vless", 4484, 1, `{"network":"tcp","security":"none"}`)
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
|
|
resp := httptest.NewRecorder()
|
|
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("status = %d, want 422; body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
if !strings.Contains(resp.Body.String(), "no Clash for Windows-compatible proxies") {
|
|
t.Fatalf("legacy endpoint did not explain the incompatibility: %s", resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLegacyClashEndpointIgnoresCustomMihomoRouting(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubProtocolInbound(t, "s1", "vmess", 4485, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"vm.example.com"}}`, model.VMESS)
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
NewSUBController(
|
|
router.Group("/"),
|
|
WithSUBClashEnabled(true),
|
|
WithSUBClashEnableRouting(true),
|
|
WithSUBClashRules(`
|
|
proxies:
|
|
- name: injected-modern-node
|
|
type: vless
|
|
server: modern.example.com
|
|
port: 443
|
|
uuid: 11111111-2222-4333-8444-555555555555
|
|
proxy-groups:
|
|
- name: MIHOMO-ONLY
|
|
type: select
|
|
include-all: true
|
|
rules:
|
|
- MATCH,MIHOMO-ONLY
|
|
`),
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
|
}
|
|
body := resp.Body.String()
|
|
if strings.Contains(body, "injected-modern-node") || strings.Contains(body, "type: vless") || strings.Contains(body, "include-all") {
|
|
t.Fatalf("custom Mihomo routing leaked into legacy profile:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "type: vmess") || !strings.Contains(body, "MATCH,PROXY") {
|
|
t.Fatalf("legacy profile did not retain its compatible proxy and simple route:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
|
|
gin.SetMode(gin.TestMode)
|
|
oldDistFS := distFS
|
|
distFS = testDistFS
|
|
t.Cleanup(func() { distFS = oldDistFS })
|
|
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
contentType string
|
|
disposition string
|
|
bodyContains string
|
|
}{
|
|
{name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
|
|
{name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
resp := httptest.NewRecorder()
|
|
router.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 != tt.contentType {
|
|
t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
|
|
}
|
|
if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
|
|
t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
|
|
}
|
|
if !strings.Contains(resp.Body.String(), tt.bodyContains) {
|
|
t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, path := range []string{"/json/s1", "/clash/s1"} {
|
|
t.Run(path+" browser page", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
resp := httptest.NewRecorder()
|
|
router.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/html; charset=utf-8" {
|
|
t.Fatalf("Content-Type = %q, want HTML", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
|
|
t.Helper()
|
|
tmpl, err := a.loadSubTemplate(dir)
|
|
if err != nil {
|
|
t.Fatalf("loadSubTemplate: unexpected error: %v", err)
|
|
}
|
|
if tmpl == nil {
|
|
t.Fatal("loadSubTemplate: expected a template, got nil")
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func TestLoadSubTemplate_RendersIndex(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
|
|
|
|
got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
|
|
if want := `<h1>abc-123</h1>`; got != want {
|
|
t.Fatalf("rendered = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
|
|
writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
|
|
|
|
got := renderTemplate(t, newTestSUBController(), dir, nil)
|
|
if got != "from-sub" {
|
|
t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
|
|
}
|
|
}
|
|
|
|
func TestLoadSubTemplate_FallbackCases(t *testing.T) {
|
|
a := newTestSUBController()
|
|
|
|
t.Run("missing dir", func(t *testing.T) {
|
|
tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
|
|
if tmpl != nil || err != nil {
|
|
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
|
}
|
|
})
|
|
|
|
t.Run("path is a file not a dir", func(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "index.html")
|
|
writeFile(t, file, `whatever`)
|
|
tmpl, err := a.loadSubTemplate(file)
|
|
if tmpl != nil || err != nil {
|
|
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
|
}
|
|
})
|
|
|
|
t.Run("dir without template file", func(t *testing.T) {
|
|
tmpl, err := a.loadSubTemplate(t.TempDir())
|
|
if tmpl != nil || err != nil {
|
|
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Unterminated action — html/template fails to parse this.
|
|
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
|
|
|
|
tmpl, err := newTestSUBController().loadSubTemplate(dir)
|
|
if err == nil {
|
|
t.Fatal("expected a parse error for a malformed template, got nil")
|
|
}
|
|
if tmpl != nil {
|
|
t.Fatalf("expected nil template on parse error, got %v", tmpl)
|
|
}
|
|
}
|
|
|
|
func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
|
|
a := newTestSUBController()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "index.html")
|
|
|
|
// v1 with a fixed mtime.
|
|
writeFile(t, path, `v1`)
|
|
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
if err := os.Chtimes(path, t1, t1); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
|
|
first, err := a.loadSubTemplate(dir)
|
|
if err != nil || first == nil {
|
|
t.Fatalf("first load: (%v, %v)", first, err)
|
|
}
|
|
|
|
// Same mtime → cache hit returns the identical parsed template.
|
|
second, err := a.loadSubTemplate(dir)
|
|
if err != nil {
|
|
t.Fatalf("second load: %v", err)
|
|
}
|
|
if second != first {
|
|
t.Fatal("expected cache hit to return the same *template.Template pointer")
|
|
}
|
|
|
|
// New content + newer mtime → cache invalidated, fresh content served.
|
|
writeFile(t, path, `v2`)
|
|
t2 := t1.Add(time.Hour)
|
|
if err := os.Chtimes(path, t2, t2); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
|
|
third, err := a.loadSubTemplate(dir)
|
|
if err != nil || third == nil {
|
|
t.Fatalf("third load: (%v, %v)", third, err)
|
|
}
|
|
if third == first {
|
|
t.Fatal("expected cache invalidation to re-parse the template after mtime change")
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := third.Execute(&buf, nil); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if buf.String() != "v2" {
|
|
t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
|
|
}
|
|
}
|
|
|
|
func TestStandardSubscriptionPreservesClashUserAgents(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubProtocolInbound(t, "s1", "vm", 4905, 1, `{"network":"tcp","security":"none"}`, model.VMESS)
|
|
gin.SetMode(gin.TestMode)
|
|
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true})
|
|
for _, ua := range []string{"mihomo/1.19.12", "clash.meta", "Clash.Meta/1.19.12", "ClashX Meta/1.0", "ClashforWindows/0.20.39"} {
|
|
t.Run(ua, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
|
|
req.Header.Set("User-Agent", ua)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK || resp.Header().Get("Content-Type") != "application/yaml; charset=utf-8" || !strings.Contains(resp.Body.String(), "type: vmess") {
|
|
t.Fatalf("UA=%q: status=%d, content-type=%q; expected VMess YAML, body=%s", ua, resp.Code, resp.Header().Get("Content-Type"), resp.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLegacyClashEndpointNormalizesShadowsocksCipher(t *testing.T) {
|
|
for _, method := range []string{"chacha20-ietf-poly1305", "chacha20-poly1305"} {
|
|
t.Run(method, func(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubProtocolInbound(t, "s1", "ss", 4906, 1, `{"network":"tcp","security":"none"}`, model.Shadowsocks)
|
|
db := database.GetDB()
|
|
if err := db.Model(ib).Update("settings", fmt.Sprintf(`{"method":%q,"network":"tcp,udp"}`, method)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "ss@e").Update("password", "test-password").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gin.SetMode(gin.TestMode)
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash-legacy/s1", nil)
|
|
resp := httptest.NewRecorder()
|
|
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
|
|
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), "type: ss") || !strings.Contains(resp.Body.String(), "cipher: chacha20-ietf-poly1305") {
|
|
t.Fatalf("method=%s: status=%d, body=%s", method, resp.Code, resp.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIncyRoutingIsolatedFromHappHeaders(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "vl", 4492, 1, `{"network":"tcp","security":"none"}`)
|
|
gin.SetMode(gin.TestMode)
|
|
const happRules = "happ://routing/onadd/existing-rules"
|
|
const incyRules = "incy://routing/onadd/AbC+/="
|
|
sharedRules := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"Name":"shared","DirectSites":["geosite:category-ru"]}`))
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
userAgent string
|
|
incyRouting bool
|
|
wantRouting string
|
|
wantRoutingEnable string
|
|
wantIncyLine bool
|
|
}{
|
|
{name: "happ keeps its own tab and never sees the incy line", path: "/sub/s1", userAgent: "Happ/1.2.0", incyRouting: true, wantRouting: happRules, wantRoutingEnable: "true"},
|
|
{name: "incy without its own tab gets the shared json profile", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", wantRouting: sharedRules},
|
|
{name: "incy body profile is not outranked by a routing header", path: "/sub/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantIncyLine: true},
|
|
{name: "incy json documents keep the shared profile header", path: "/json/s1", userAgent: "INCY/1.0.0/ios", incyRouting: true, wantRouting: sharedRules},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
router := gin.New()
|
|
NewSUBController(router.Group("/"),
|
|
WithSUBEncryption(false),
|
|
WithSUBJsonEnabled(true),
|
|
WithSUBEnableRouting(true),
|
|
WithSUBRoutingRules(happRules),
|
|
WithSUBJsonRoutingRules(sharedRules),
|
|
WithSUBIncyEnableRouting(tc.incyRouting),
|
|
WithSUBIncyRoutingRules(incyRules),
|
|
)
|
|
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tc.path, nil)
|
|
req.Header.Set("User-Agent", tc.userAgent)
|
|
resp := httptest.NewRecorder()
|
|
router.ServeHTTP(resp, req)
|
|
body := resp.Body.String()
|
|
if resp.Code != http.StatusOK || !strings.Contains(body, "vless") {
|
|
t.Fatalf("status=%d, body=%s", resp.Code, body)
|
|
}
|
|
if got := resp.Header().Get("Routing"); got != tc.wantRouting {
|
|
t.Fatalf("Routing = %q, want %q", got, tc.wantRouting)
|
|
}
|
|
if got := resp.Header().Get("Routing-Enable"); got != tc.wantRoutingEnable {
|
|
t.Fatalf("Routing-Enable = %q, want %q", got, tc.wantRoutingEnable)
|
|
}
|
|
if got := strings.HasSuffix(body, incyRules+"\n"); got != tc.wantIncyLine || (!got && strings.Contains(body, incyRules)) {
|
|
t.Fatalf("incy line at body end = %v, want %v; body=%s", got, tc.wantIncyLine, body)
|
|
}
|
|
})
|
|
}
|
|
}
|