1
0

controller_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. package sub
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "net/http"
  6. "net/http/httptest"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/gin-gonic/gin"
  13. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  14. )
  15. // newTestSUBController builds a controller with just the bits loadSubTemplate
  16. // needs, so the template tests don't require a database.
  17. func newTestSUBController() *SUBController {
  18. return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
  19. }
  20. type subscriptionTestRouterConfig struct {
  21. clashAutoDetect bool
  22. clashUserAgentRegex string
  23. jsonAutoDetect bool
  24. jsonUserAgentRegex string
  25. jsonAlwaysArray bool
  26. }
  27. func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
  28. router := gin.New()
  29. options := []SUBControllerOption{
  30. WithSUBJsonEnabled(true),
  31. WithSUBClashEnabled(true),
  32. }
  33. if config.clashAutoDetect {
  34. options = append(options, WithSUBClashAutoDetect(true))
  35. }
  36. if config.clashUserAgentRegex != "" {
  37. options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
  38. }
  39. if config.jsonAutoDetect {
  40. options = append(options, WithSUBJsonAutoDetect(true))
  41. }
  42. if config.jsonUserAgentRegex != "" {
  43. options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
  44. }
  45. if config.jsonAlwaysArray {
  46. options = append(options, WithSUBJsonAlwaysArray(true))
  47. }
  48. NewSUBController(router.Group("/"), options...)
  49. return router
  50. }
  51. func TestNewSUBControllerOptions(t *testing.T) {
  52. gin.SetMode(gin.TestMode)
  53. defaults := NewSUBController(gin.New().Group("/"))
  54. if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
  55. t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
  56. }
  57. if !defaults.subEncrypt || defaults.updateInterval != "12" {
  58. t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
  59. }
  60. if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
  61. t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
  62. }
  63. if defaults.jsonEnabled || defaults.clashEnabled {
  64. t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
  65. }
  66. configured := NewSUBController(
  67. gin.New().Group("/"),
  68. WithSUBPath("/custom/"),
  69. WithSUBJsonEnabled(true),
  70. WithSUBEncryption(false),
  71. WithSUBUpdateInterval("24"),
  72. )
  73. if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
  74. t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
  75. configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
  76. }
  77. }
  78. func TestShouldAutoServeClash(t *testing.T) {
  79. tests := []struct {
  80. name string
  81. autoDetect bool
  82. clashEnabled bool
  83. wantsHTML bool
  84. userAgent string
  85. pattern string
  86. want bool
  87. }{
  88. {name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
  89. {name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
  90. {name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
  91. {name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
  92. {name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
  93. {name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
  94. {name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
  95. {name: "empty raw fallback", autoDetect: true, clashEnabled: true},
  96. {name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
  97. {name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
  98. {name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
  99. }
  100. for _, tt := range tests {
  101. t.Run(tt.name, func(t *testing.T) {
  102. got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
  103. if got != tt.want {
  104. t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
  105. }
  106. })
  107. }
  108. }
  109. func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
  110. configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
  111. if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
  112. t.Fatal("configured User-Agent regex did not match")
  113. }
  114. if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
  115. t.Fatal("built-in User-Agent matched after a custom regex replaced it")
  116. }
  117. }
  118. func TestShouldAutoServeJson(t *testing.T) {
  119. configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
  120. for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
  121. if !shouldAutoServeJson(true, true, false, userAgent, configured) {
  122. t.Errorf("configured Xray JSON regex did not match %q", userAgent)
  123. }
  124. }
  125. for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
  126. if shouldAutoServeJson(true, true, false, userAgent, configured) {
  127. t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
  128. }
  129. }
  130. if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
  131. t.Fatal("disabled Xray JSON auto-detection matched")
  132. }
  133. if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
  134. t.Fatal("disabled JSON endpoint matched")
  135. }
  136. if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
  137. t.Fatal("browser HTML request matched Xray JSON")
  138. }
  139. empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
  140. if empty != nil {
  141. t.Fatal("empty Xray JSON default should not compile to a matcher")
  142. }
  143. if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
  144. t.Fatal("empty Xray JSON default should not auto-serve")
  145. }
  146. }
  147. func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
  148. configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
  149. if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
  150. t.Fatal("configured Xray JSON User-Agent regex did not match")
  151. }
  152. if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
  153. t.Fatal("unrelated User-Agent matched after a custom regex was configured")
  154. }
  155. }
  156. func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
  157. compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
  158. if !compiled.MatchString("Mihomo/1.19") {
  159. t.Fatal("invalid regex did not fall back to the default pattern")
  160. }
  161. }
  162. func TestSanitizeUserAgentForLog(t *testing.T) {
  163. if got := sanitizeUserAgentForLog("client/1.0\r\nforged\tline"); got != "client/1.0 forged line" {
  164. t.Fatalf("sanitizeUserAgentForLog() = %q", got)
  165. }
  166. long := strings.Repeat("界", 513)
  167. if got := sanitizeUserAgentForLog(long); len([]rune(got)) != 512 {
  168. t.Fatalf("sanitized User-Agent length = %d runes, want 512", len([]rune(got)))
  169. }
  170. }
  171. func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
  172. seedSubDB(t)
  173. seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
  174. gin.SetMode(gin.TestMode)
  175. t.Run("recognized client receives YAML", func(t *testing.T) {
  176. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  177. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  178. resp := httptest.NewRecorder()
  179. newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
  180. if resp.Code != http.StatusOK {
  181. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  182. }
  183. if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
  184. t.Fatalf("Content-Type = %q, want YAML", got)
  185. }
  186. if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
  187. t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
  188. }
  189. })
  190. t.Run("Clash wins when both format regexes match", func(t *testing.T) {
  191. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  192. req.Header.Set("User-Agent", "Hybrid/1.0")
  193. resp := httptest.NewRecorder()
  194. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  195. clashAutoDetect: true,
  196. clashUserAgentRegex: `(?i)^hybrid/`,
  197. jsonAutoDetect: true,
  198. jsonUserAgentRegex: `(?i)^hybrid/`,
  199. }).ServeHTTP(resp, req)
  200. if resp.Code != http.StatusOK {
  201. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  202. }
  203. if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
  204. t.Fatalf("Content-Type = %q, want Clash YAML", got)
  205. }
  206. })
  207. t.Run("disabled setting preserves raw base64", func(t *testing.T) {
  208. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  209. req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
  210. resp := httptest.NewRecorder()
  211. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  212. if resp.Code != http.StatusOK {
  213. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  214. }
  215. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  216. if err != nil {
  217. t.Fatalf("raw response is not base64: %v", err)
  218. }
  219. if !strings.Contains(string(decoded), "vless://") {
  220. t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
  221. }
  222. })
  223. t.Run("configured regex controls detection", func(t *testing.T) {
  224. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  225. req.Header.Set("User-Agent", "Mihomo/1.19")
  226. resp := httptest.NewRecorder()
  227. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  228. clashAutoDetect: true,
  229. clashUserAgentRegex: `(?i)^custom-client/`,
  230. }).ServeHTTP(resp, req)
  231. if resp.Code != http.StatusOK {
  232. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  233. }
  234. if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
  235. t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
  236. }
  237. })
  238. t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
  239. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  240. req.Header.Set("User-Agent", "GenericClient/1.10.0")
  241. resp := httptest.NewRecorder()
  242. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  243. clashAutoDetect: true,
  244. jsonAutoDetect: true,
  245. }).ServeHTTP(resp, req)
  246. if resp.Code != http.StatusOK {
  247. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  248. }
  249. decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
  250. if err != nil {
  251. t.Fatalf("raw response is not base64: %v", err)
  252. }
  253. if !strings.Contains(string(decoded), "vless://") {
  254. t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
  255. }
  256. })
  257. t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
  258. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
  259. req.Header.Set("User-Agent", "JsonClient/1.6.32")
  260. resp := httptest.NewRecorder()
  261. newSubscriptionTestRouter(subscriptionTestRouterConfig{
  262. jsonAutoDetect: true,
  263. jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
  264. }).ServeHTTP(resp, req)
  265. if resp.Code != http.StatusOK {
  266. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  267. }
  268. if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
  269. t.Fatalf("Content-Type = %q, want JSON", got)
  270. }
  271. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
  272. t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
  273. }
  274. })
  275. t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
  276. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
  277. resp := httptest.NewRecorder()
  278. newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
  279. if resp.Code != http.StatusOK {
  280. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  281. }
  282. if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
  283. t.Fatalf("Content-Type = %q, want legacy text/plain", got)
  284. }
  285. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
  286. t.Fatalf("legacy explicit JSON body is not an object: %s", body)
  287. }
  288. })
  289. t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
  290. req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
  291. resp := httptest.NewRecorder()
  292. newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
  293. if resp.Code != http.StatusOK {
  294. t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
  295. }
  296. if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
  297. t.Fatalf("Content-Type = %q, want legacy text/plain", got)
  298. }
  299. if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
  300. t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
  301. }
  302. })
  303. }
  304. func writeFile(t *testing.T, path, content string) {
  305. t.Helper()
  306. if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
  307. t.Fatalf("write %s: %v", path, err)
  308. }
  309. }
  310. func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
  311. t.Helper()
  312. tmpl, err := a.loadSubTemplate(dir)
  313. if err != nil {
  314. t.Fatalf("loadSubTemplate: unexpected error: %v", err)
  315. }
  316. if tmpl == nil {
  317. t.Fatal("loadSubTemplate: expected a template, got nil")
  318. }
  319. var buf bytes.Buffer
  320. if err := tmpl.Execute(&buf, data); err != nil {
  321. t.Fatalf("execute: %v", err)
  322. }
  323. return buf.String()
  324. }
  325. func TestLoadSubTemplate_RendersIndex(t *testing.T) {
  326. dir := t.TempDir()
  327. writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
  328. got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
  329. if want := `<h1>abc-123</h1>`; got != want {
  330. t.Fatalf("rendered = %q, want %q", got, want)
  331. }
  332. }
  333. func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
  334. dir := t.TempDir()
  335. writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
  336. writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
  337. got := renderTemplate(t, newTestSUBController(), dir, nil)
  338. if got != "from-sub" {
  339. t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
  340. }
  341. }
  342. func TestLoadSubTemplate_FallbackCases(t *testing.T) {
  343. a := newTestSUBController()
  344. t.Run("missing dir", func(t *testing.T) {
  345. tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
  346. if tmpl != nil || err != nil {
  347. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  348. }
  349. })
  350. t.Run("path is a file not a dir", func(t *testing.T) {
  351. file := filepath.Join(t.TempDir(), "index.html")
  352. writeFile(t, file, `whatever`)
  353. tmpl, err := a.loadSubTemplate(file)
  354. if tmpl != nil || err != nil {
  355. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  356. }
  357. })
  358. t.Run("dir without template file", func(t *testing.T) {
  359. tmpl, err := a.loadSubTemplate(t.TempDir())
  360. if tmpl != nil || err != nil {
  361. t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
  362. }
  363. })
  364. }
  365. func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
  366. dir := t.TempDir()
  367. // Unterminated action — html/template fails to parse this.
  368. writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
  369. tmpl, err := newTestSUBController().loadSubTemplate(dir)
  370. if err == nil {
  371. t.Fatal("expected a parse error for a malformed template, got nil")
  372. }
  373. if tmpl != nil {
  374. t.Fatalf("expected nil template on parse error, got %v", tmpl)
  375. }
  376. }
  377. func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
  378. a := newTestSUBController()
  379. dir := t.TempDir()
  380. path := filepath.Join(dir, "index.html")
  381. // v1 with a fixed mtime.
  382. writeFile(t, path, `v1`)
  383. t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  384. if err := os.Chtimes(path, t1, t1); err != nil {
  385. t.Fatalf("chtimes: %v", err)
  386. }
  387. first, err := a.loadSubTemplate(dir)
  388. if err != nil || first == nil {
  389. t.Fatalf("first load: (%v, %v)", first, err)
  390. }
  391. // Same mtime → cache hit returns the identical parsed template.
  392. second, err := a.loadSubTemplate(dir)
  393. if err != nil {
  394. t.Fatalf("second load: %v", err)
  395. }
  396. if second != first {
  397. t.Fatal("expected cache hit to return the same *template.Template pointer")
  398. }
  399. // New content + newer mtime → cache invalidated, fresh content served.
  400. writeFile(t, path, `v2`)
  401. t2 := t1.Add(time.Hour)
  402. if err := os.Chtimes(path, t2, t2); err != nil {
  403. t.Fatalf("chtimes: %v", err)
  404. }
  405. third, err := a.loadSubTemplate(dir)
  406. if err != nil || third == nil {
  407. t.Fatalf("third load: (%v, %v)", third, err)
  408. }
  409. if third == first {
  410. t.Fatal("expected cache invalidation to re-parse the template after mtime change")
  411. }
  412. var buf bytes.Buffer
  413. if err := third.Execute(&buf, nil); err != nil {
  414. t.Fatalf("execute: %v", err)
  415. }
  416. if buf.String() != "v2" {
  417. t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
  418. }
  419. }